From 4da12293b5a77a7d9cf376fc05a8076c3281450a Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Mon, 27 Jul 2026 12:07:47 +0800 Subject: [PATCH] Add Prettier formatting checks Pin Prettier and format the existing codebase. Enforce formatting in CI, release, and prepublish checks. Preserve the bilingual Markdown wrapping rules. Signed-off-by: Lu Zhang --- .claude/skills/wdl-deploy/SKILL.md | 47 +- .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 1 + .prettierrc.json | 22 + AGENTS.md | 65 +- CHANGELOG.md | 71 +- CONTRIBUTING.md | 4 +- GUIDE.md | 209 +- README.md | 11 +- bin/wdl.js | 43 +- commands/config.js | 13 +- commands/d1.js | 120 +- commands/delete.js | 33 +- commands/deploy.js | 69 +- commands/doctor.js | 50 +- commands/init.js | 106 +- commands/r2.js | 57 +- commands/secret.js | 54 +- commands/tail.js | 96 +- commands/token.js | 50 +- commands/whoami.js | 25 +- commands/workflows.js | 46 +- docs/cron-triggers.md | 10 +- docs/d1.md | 16 +- docs/deploy.md | 129 +- docs/queues.md | 10 +- docs/secrets.md | 8 +- docs/token.md | 16 +- docs/workflows.md | 5 +- eslint.config.js | 14 +- examples/d1-demo/src/index.js | 12 +- examples/durable-objects-demo/src/index.js | 9 +- examples/hello-jsonc/wrangler.jsonc | 4 +- examples/inspection-demo/public/app.js | 8 +- examples/inspection-demo/public/style.css | 9 +- examples/inspection-demo/src/index.js | 78 +- examples/kv-demo/src/index.js | 13 +- examples/pages-assets/public/style.css | 14 +- examples/pages-assets/src/index.js | 5 +- examples/pages-assets/wrangler.jsonc | 4 +- examples/queues-demo/src/index.js | 31 +- lib/bundle-modules.js | 21 +- lib/command.js | 25 +- lib/common.js | 58 +- lib/config-state.js | 33 +- lib/control-fetch.js | 19 +- lib/credentials.js | 71 +- lib/d1-files.js | 8 +- lib/d1-format.js | 16 +- lib/delete-format.js | 36 +- lib/dotenv.js | 14 +- lib/output.js | 31 +- lib/secret-envelope-errors.js | 6 +- lib/stdin.js | 18 +- lib/token-store.js | 66 +- lib/whoami.js | 12 +- lib/workers-format.js | 11 +- lib/workflows-format.js | 12 +- lib/wrangler-pack.js | 51 +- lib/wrangler/assets.js | 6 +- lib/wrangler/bindings.js | 64 +- lib/wrangler/command.js | 18 +- lib/wrangler/config.js | 19 +- lib/wrangler/modules.js | 9 +- package-lock.json | 17 + package.json | 5 +- templates/AGENTS.md | 23 +- tests/integration/cli-live.test.js | 870 ++++--- tests/unit/cli-bundle-modules.test.js | 11 +- tests/unit/cli-command.test.js | 169 +- tests/unit/cli-config-doctor.test.js | 265 +- tests/unit/cli-control-fetch.test.js | 86 +- tests/unit/cli-credentials.test.js | 321 ++- tests/unit/cli-d1.test.js | 674 +++--- tests/unit/cli-deploy.test.js | 2550 +++++++++++--------- tests/unit/cli-init.test.js | 49 +- tests/unit/cli-lifecycle.test.js | 1400 ++++++----- tests/unit/cli-ns-pattern.test.js | 21 +- tests/unit/cli-output.test.js | 10 +- tests/unit/cli-stdin.test.js | 26 +- tests/unit/cli-token-store.test.js | 226 +- tests/unit/cli-token.test.js | 156 +- tests/unit/helpers.js | 7 +- tsconfig.json | 14 +- 84 files changed, 5104 insertions(+), 4008 deletions(-) create mode 100644 .prettierrc.json diff --git a/.claude/skills/wdl-deploy/SKILL.md b/.claude/skills/wdl-deploy/SKILL.md index f4da7ea..43edcff 100644 --- a/.claude/skills/wdl-deploy/SKILL.md +++ b/.claude/skills/wdl-deploy/SKILL.md @@ -53,31 +53,30 @@ enable flags, `legacy_error_serialization`, and (`wrangler.json`, then `wrangler.jsonc`, then `wrangler.toml`). Both JSON filenames use Wrangler's JSONC syntax, including comments and trailing commas. The control plane is canonical for unsupported runtime shapes such as -unsupported workerd compatibility flags and WDL-reserved injected module -names. The CLI still fails fast for cheap local cases such as Python Workers -modules, unmapped top-level or selected-env Wrangler runtime/deploy keys -(`[site]`, `pages_build_output_dir`, `observability`, `limits`, -`placement`, etc.), and ambiguous runtime `env` name collisions between -`[vars]`, explicit bindings, and the implicit `ASSETS` binding. -For an operator-enabled routed Worker, explicit `workers_dev = false` keeps its -pattern routes active while disabling the default platform-domain URL; it -requires at least one `route` / `routes` pattern and is not inferred. -The deploy summary prints every active route-pattern URL hint, preserving the -trailing `*` on prefix patterns, and includes the platform-domain URL only while -it is enabled. Cloudflare's separate `preview_urls` field is unsupported and -rejected by the CLI. -WDL-only `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, and +unsupported workerd compatibility flags and WDL-reserved injected module names. +The CLI still fails fast for cheap local cases such as Python Workers modules, +unmapped top-level or selected-env Wrangler runtime/deploy keys (`[site]`, +`pages_build_output_dir`, `observability`, `limits`, `placement`, etc.), and +ambiguous runtime `env` name collisions between `[vars]`, explicit bindings, and +the implicit `ASSETS` binding. For an operator-enabled routed Worker, explicit +`workers_dev = false` keeps its pattern routes active while disabling the +default platform-domain URL; it requires at least one `route` / `routes` pattern +and is not inferred. The deploy summary prints every active route-pattern URL +hint, preserving the trailing `*` on prefix patterns, and includes the +platform-domain URL only while it is enabled. Cloudflare's separate +`preview_urls` field is unsupported and rejected by the CLI. WDL-only +`[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, and `[[services]].ns` are parsed by the CLI and removed from Wrangler's temporary -bundle config; other fields retain their existing Wrangler passthrough -behavior. Wrangler's object-shaped declarative `exports` config is unsupported. -The dry-run child hides Wrangler's banner (and its normal update check) and -disables anonymous telemetry. Wrangler may still consult the configured npm -registry when reporting an unknown configuration field; project build hooks -retain their normal network access. -For `[[services]]` and `[[exports]]`, read `docs/deploy.md`: tenant JSRPC may -delegate service or Durable Object class stubs as opaque capabilities, but the -receiver cannot rewrite their host-authored caller properties. Keep delegated -stubs in memory; long-term irrevocable stub storage is unsupported. +bundle config; other fields retain their existing Wrangler passthrough behavior. +Wrangler's object-shaped declarative `exports` config is unsupported. The +dry-run child hides Wrangler's banner (and its normal update check) and disables +anonymous telemetry. Wrangler may still consult the configured npm registry when +reporting an unknown configuration field; project build hooks retain their +normal network access. For `[[services]]` and `[[exports]]`, read +`docs/deploy.md`: tenant JSRPC may delegate service or Durable Object class +stubs as opaque capabilities, but the receiver cannot rewrite their +host-authored caller properties. Keep delegated stubs in memory; long-term +irrevocable stub storage is unsupported. Never recommend setting `CONTROL_CONNECT_HOST` outside local development: it overrides the TCP target the admin token connects to (Host header + TLS SNI diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a08347..8a8658d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,7 @@ jobs: - run: npm ci - run: npm audit --audit-level=moderate + - run: npm run format:check - run: npm run lint - run: npm run typecheck - run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d46fd1..37f8a8e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,7 @@ jobs: - run: npm ci - run: npm audit --audit-level=moderate + - run: npm run format:check - run: npm run lint - run: npm run typecheck - run: npm test diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..a917aad --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,22 @@ +{ + "embeddedLanguageFormatting": "off", + "printWidth": 120, + "proseWrap": "always", + "quoteProps": "preserve", + "trailingComma": "es5", + "overrides": [ + { + "files": "**/*.md", + "options": { + "printWidth": 80 + } + }, + { + "files": "**/*-zh.md", + "options": { + "printWidth": 80, + "proseWrap": "never" + } + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index 9063a4c..9efa7aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,8 @@ newer target. - `npm install`: install dependencies from `package-lock.json`. - `npm link`: expose the local `wdl` binary for manual testing. +- `npm run format`: format supported source and documentation files. +- `npm run format:check`: verify formatting without modifying files. - `npm run lint`: run ESLint over the CLI, docs helpers, and examples. - `npm run typecheck`: run TypeScript's JavaScript-aware `tsc --noEmit` check. - `npm test`: run Node's built-in test runner against @@ -31,9 +33,9 @@ newer target. configured — the CLI has no default endpoint. Use Node.js 22 or newer for local work. The GitHub Actions workflow uses Node -22, runs `npm ci`, `npm audit --audit-level=moderate`, `npm run lint`, -`npm run typecheck`, `npm test`, and `npm pack --dry-run`, then checks workflow -syntax with actionlint. +22, runs `npm ci`, `npm audit --audit-level=moderate`, `npm run format:check`, +`npm run lint`, `npm run typecheck`, `npm test`, and `npm pack --dry-run`, then +checks workflow syntax with actionlint. ## Coding Style & Naming Conventions @@ -47,14 +49,14 @@ dependency injection for testable command behavior, as seen in types rather than `any`, and use `unknown` plus narrowing for values validated at runtime. -Markdown wrapping is bilingual by design, normalized with Prettier -(`--embedded-language-formatting=off`; code blocks are hand-formatted) and kept -by editing habit rather than a linter: English prose hard-wraps at 80 columns -(`--prose-wrap always`), while Chinese prose never hard-wraps inside a sentence -(`--prose-wrap never`, one line per paragraph) because CommonMark renders a soft -break as a space and CJK text would pick up spurious mid-sentence spaces. -Tables, code blocks, and long URLs are exempt; there is no line-length lint, -since table rows would trip it. +Prettier formats supported files with a 120-column source width. Markdown +wrapping is bilingual by design (`--embedded-language-formatting=off`; code +blocks are hand-formatted): English prose hard-wraps at 80 columns +(`--prose-wrap always`), while Chinese `*-zh.md` prose never hard-wraps inside a +sentence (`--prose-wrap never`, one line per paragraph) because CommonMark +renders a soft break as a space and CJK text would pick up spurious mid-sentence +spaces. Tables, code blocks, and long URLs are exempt; run +`npm run format:check` to enforce the repository configuration. ## Testing Guidelines @@ -75,26 +77,27 @@ defaults change. The per-feature docs are bilingual pairs — `docs/.md` (English) and `docs/-zh.md` (Chinese) — and both languages are authoritative: update the pair in the same change. Agent-facing references (`templates/AGENTS.md`, the wdl-deploy skill, generated projects) point only at -the English set. Before packaging, re-run the audit, test, and `npm pack ---dry-run` checks from Build, Test, and Development Commands. +the English set. Before packaging, re-run the audit, test, and +`npm pack --dry-run` checks from Build, Test, and Development Commands. ## Release -Releases are tag-driven. `.github/workflows/release.yml` re-runs audit, lint, -typecheck, and tests, verifies the tag matches `version` in `package.json`, and -runs `npm pack --dry-run` — all before any publish, so a broken release fails the -tag's check job and never publishes. It then publishes `@wdl-dev/cli` to npmjs -(with provenance) and to GitHub Packages (authenticated with the workflow's own -`GITHUB_TOKEN`), and creates a GitHub Release for the tag: final releases take -their notes from the matching `CHANGELOG.md` section, pre-releases fall back to -generated notes and are marked Pre-release. Do not run `npm publish` by hand. +Releases are tag-driven. `.github/workflows/release.yml` re-runs formatting, +audit, lint, typecheck, and tests, verifies the tag matches `version` in +`package.json`, and runs `npm pack --dry-run` — all before any publish, so a +broken release fails the tag's check job and never publishes. It then publishes +`@wdl-dev/cli` to npmjs (with provenance) and to GitHub Packages (authenticated +with the workflow's own `GITHUB_TOKEN`), and creates a GitHub Release for the +tag: final releases take their notes from the matching `CHANGELOG.md` section, +pre-releases fall back to generated notes and are marked Pre-release. Do not run +`npm publish` by hand. Published npm versions are immutable (no reuse; unpublish only within 72 hours), but the check job gates every publish, so most releases tag the final version directly. This project ships documented breaking removals in 1.x minors (called out in the CHANGELOG) — do not hold or re-version a release for generic SemVer reasons. Stage a pre-release only for the narrower risk an RC actually guards: -the *published artifact* differing from what the check job validated — packaging +the _published artifact_ differing from what the check job validated — packaging changes (the `files` allowlist, entry points, the bundle/publish pipeline) or a large release you want to smoke-test as a real `@next` install. For an RC, set `version` to e.g. `2.0.0-rc.1`, write the CHANGELOG entry, commit, and tag @@ -128,15 +131,15 @@ 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 — `--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 intact when touching `loadCliControlEnv` or -`lib/token-store.js`; `--no-token-store` / `WDL_TOKEN_STORE=off` (via -`tokenStoreReader`, read from the process env, not a project `.env`) must keep -opting the store out of resolution entirely. Do not commit tenant tokens or -generated secrets; read credentials from the environment and keep example -configuration generic. +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 +intact when touching `loadCliControlEnv` or `lib/token-store.js`; +`--no-token-store` / `WDL_TOKEN_STORE=off` (via `tokenStoreReader`, read from +the process env, not a project `.env`) must keep opting the store out of +resolution entirely. Do not commit tenant tokens or generated secrets; read +credentials from the environment and keep example configuration generic. ### Deploy runs project code as you diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b92e7a..ae95dd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,17 +6,17 @@ ### Added -- Routed Workers with at least one custom route can set `workers_dev = false` - to disable their platform-domain URL. Deploy summaries print the active - platform URL and route-pattern URL hints, omitting the disabled URL. +- Routed Workers with at least one custom route can set `workers_dev = false` to + disable their platform-domain URL. Deploy summaries print the active platform + URL and route-pattern URL hints, omitting the disabled URL. ## 1.5.1 ### Security -- Bump the bundled `wrangler` to 4.114.0, which vendors a patched sharp - (0.35.2) and clears a high-severity libvips advisory (GHSA-f88m-g3jw-g9cj) - reachable only through the miniflare dev server, which the CLI never runs. +- Bump the bundled `wrangler` to 4.114.0, which vendors a patched sharp (0.35.2) + and clears a high-severity libvips advisory (GHSA-f88m-g3jw-g9cj) reachable + only through the miniflare dev server, which the CLI never runs. ## 1.5.0 @@ -41,11 +41,10 @@ ### Fixed -- `wdl deploy` no longer passes WDL-only `[[exports]]`, - `[[platform_bindings]]`, `[[triggers.schedules]]`, or `[[services]].ns` to - Wrangler's temporary bundle config. This avoids the `exports` schema - collision in Wrangler 4.107+ while preserving the fields in the WDL deploy - manifest. +- `wdl deploy` no longer passes WDL-only `[[exports]]`, `[[platform_bindings]]`, + `[[triggers.schedules]]`, or `[[services]].ns` to Wrangler's temporary bundle + config. This avoids the `exports` schema collision in Wrangler 4.107+ while + preserving the fields in the WDL deploy manifest. ## 1.4.0 @@ -54,8 +53,8 @@ - `wdl deploy` now selects `wrangler.json`, then `wrangler.jsonc`, then `wrangler.toml`, parses both JSON filenames as JSONC, and resolves Wrangler from explicit/project/package/`PATH` sources (`npx` remains opt-in). It - rejects Python modules, unmapped runtime fields, and binding collisions - before upload; runtime/workerd bundle policy remains canonical in control. + rejects Python modules, unmapped runtime fields, and binding collisions before + upload; runtime/workerd bundle policy remains canonical in control. - New projects use `compatibility_date = "2026-06-17"` unless a feature requires newer behavior. - Control requests now identify the CLI version, cap buffered JSON responses at @@ -73,17 +72,17 @@ ### Fixed -- Control connection failures, invalid 2xx JSON, unreadable project `.env` or - D1 input files, and unexpected arguments now use escaped CLI diagnostics - instead of raw Node errors or silently ignored input. +- Control connection failures, invalid 2xx JSON, unreadable project `.env` or D1 + input files, and unexpected arguments now use escaped CLI diagnostics instead + of raw Node errors or silently ignored input. - Deploy failures now retain control warnings and actionable secret, environment-budget, and caller-secret guidance. - D1 migrations reject symlinked SQL files, invalid `.assetsignore` patterns identify the offending rule, and project `.env` parsing tolerates unrelated dotenv extensions. -- Secret mutation failures explain environment-budget, contention, and - envelope errors, including when no mutation was written; token list safely - renders stored labels and endpoints. +- Secret mutation failures explain environment-budget, contention, and envelope + errors, including when no mutation was written; token list safely renders + stored labels and endpoints. ### Security @@ -103,14 +102,14 @@ ### Changed -- `wdl d1 execute` now requires exactly one SQL source (`--sql` or `--file`) - and rejects empty SQL locally before contacting control. Even `--sql ""` - conflicts with `--file`. +- `wdl d1 execute` now requires exactly one SQL source (`--sql` or `--file`) and + rejects empty SQL locally before contacting control. Even `--sql ""` conflicts + with `--file`. - `wdl deploy` now rejects more malformed Wrangler config locally instead of silently dropping invalid input: non-array `kv_namespaces`, malformed KV entries, present-but-non-table `[assets]`, and several validated fields that - previously reached the manifest with loose types. Wrangler local-dev KV - fields `preview_id` and `remote` remain allowed but are ignored by deploy. + previously reached the manifest with loose types. Wrangler local-dev KV fields + `preview_id` and `remote` remain allowed but are ignored by deploy. ## 1.2.2 @@ -120,8 +119,8 @@ world-writable store directory: a 0600 file there can still be deleted, replaced, or symlink-swapped by another user, so the write fails with a `chmod 700` hint instead. POSIX only. -- `wdl r2 object head --json` parses custom metadata without prototype - pollution — an `x-amz-meta-__proto__` header stays an own key, and an empty +- `wdl r2 object head --json` parses custom metadata without prototype pollution + — an `x-amz-meta-__proto__` header stays an own key, and an empty `x-amz-meta-` header is dropped. ### Changed @@ -140,8 +139,8 @@ dropped. - A local deploy prints a direct `http://.:8080//` URL instead of a `curl -H 'Host: ...'` hint, and a control plane reached via a - `.test` / `.local` host is recognized as local (previously only - localhost / 127.0.0.1). + `.test` / `.local` host is recognized as local (previously only localhost / + 127.0.0.1). - KV docs (GUIDE and `docs/kv.md`) describe the 512-byte key / list-prefix cap the platform now enforces; they previously said it was not checked. Documented that `wdl d1 execute --mode exec` takes no `--params`. @@ -182,9 +181,9 @@ values already were. A malicious or compromised control plane could put terminal control bytes (ESC / OSC / C1) in a JSON error property name and have them written unescaped to stderr (OSC 52 clipboard writes, display spoofing). -- Control-plane responses now abort the connection when the body exceeds the - 10 MiB cap, instead of rejecting the result while continuing to read the - stream — the cap bounds resource use, not just the returned value. +- Control-plane responses now abort the connection when the body exceeds the 10 + MiB cap, instead of rejecting the result while continuing to read the stream — + the cap bounds resource use, not just the returned value. - The trusted-publishing release job pins the npm CLI to an exact reviewed version instead of installing `npm@latest`, so a compromised npm release can't run in the job that holds the npm OIDC token and publish a tampered, @@ -225,8 +224,8 @@ ### Changed -- `wdl init`'s `--ns` is now optional. With `--ns`, the scaffolded `npm run - deploy` keeps `wdl deploy . --ns `; without it the script is +- `wdl init`'s `--ns` is now optional. With `--ns`, the scaffolded + `npm run deploy` keeps `wdl deploy . --ns `; without it the script is `wdl deploy .` and the namespace is resolved at deploy time (`--ns` / `WDL_NS` / project `.env` / a `wdl token` default). `init` also no longer autoloads control credentials, so a corrupt token store cannot block scaffolding. @@ -244,9 +243,9 @@ - `wdl secret put` no longer echoes the typed secret on a TTY: input is read in raw mode (hidden), and fails closed — it errors rather than echo if the terminal cannot hide input. -- `.env` values containing literal backslash escape sequences (e.g. a token - with a backslash followed by `n`) now round-trip correctly instead of being - decoded as control characters. +- `.env` values containing literal backslash escape sequences (e.g. a token with + a backslash followed by `n`) now round-trip correctly instead of being decoded + as control characters. ### Security diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a16f9c..3c93e30 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,9 +58,11 @@ reaching the terminal**. ## Checks -All of these run in CI on every pull request and must pass: +Run `npm run format` to apply the repository formatting rules. The following +checks run in CI on every pull request and must pass: ```bash +npm run format:check npm run lint npm run typecheck npm test diff --git a/GUIDE.md b/GUIDE.md index 2bd9442..4253007 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -58,8 +58,7 @@ If you do not want to link the CLI globally, call the entrypoint directly: node /path/to/cli/bin/wdl.js deploy ./my-worker ``` -For command-specific help, run `wdl --help` or -`wdl help `. +For command-specific help, run `wdl --help` or `wdl help `. ### Configure Defaults @@ -106,47 +105,45 @@ The CLI loads only WDL platform variables from `.env`: `ADMIN_TOKEN`, `CLI flag > shell/CI env > [resolved-ns] section > base .env > wdl token store`, and if none supplies a value the command fails — there is no built-in default. Namespace resolution is `--ns`, then `WDL_NS` from your shell or base `.env`, -then the token store's default namespace. Section -names may be normal tenant 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. - -`CONTROL_CONNECT_HOST` is a local-dev / debug override: it changes the TCP target -the request connects to while the HTTP Host header and TLS SNI keep tracking -`CONTROL_URL` (so over HTTPS the control plane's certificate still rejects a -redirected connection; plain http has no such check). Use it only for local -development — never set it persistently in a CI or production shell, where a -stale value could route the admin token to an unintended target. When the +then the token store's default namespace. Section names may be normal tenant +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. + +`CONTROL_CONNECT_HOST` is a local-dev / debug override: it changes the TCP +target the request connects to while the HTTP Host header and TLS SNI keep +tracking `CONTROL_URL` (so over HTTPS the control plane's certificate still +rejects a redirected connection; plain http has no such check). Use it only for +local development — never set it persistently in a CI or production shell, where +a stale value could route the admin token to an unintended target. When the override is a URL, its scheme only selects the default TCP port (`http` uses 80; `https` uses 443); request transport, Host, and SNI still follow `CONTROL_URL`. The recommended setup keeps these credentials in a managed store rather than a shell export or a project `.env`: `wdl token set --ns --control-url ` -reads the token with -hidden input, validates it against `/whoami`, and stores it under the namespace -in `~/.config/wdl/credentials` (so it never lands in shell 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 deploy` runs the project's local Wrangler dry-run and build hooks as your OS -user before uploading, and that code can read the on-disk store (the env scrub -keeps WDL variables out of the Wrangler child's environment, not out of the -file), so only deploy projects you trust. `--no-token-store` (or +reads the token with hidden input, validates it against `/whoami`, and stores it +under the namespace in `~/.config/wdl/credentials` (so it never lands in shell +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 deploy` runs the project's local Wrangler dry-run and build hooks as your +OS user before uploading, and that code can read the on-disk store (the env +scrub keeps WDL variables out of the Wrangler child's environment, not out of +the file), so only deploy projects you trust. `--no-token-store` (or `WDL_TOKEN_STORE=off`) resolves credentials from flags / shell / `.env` only and -never reads the store — a resolution opt-out for less-trusted projects or CI, not -protection for the file itself. +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 @@ -157,9 +154,9 @@ credentials, and `/whoami` reachability. Add `--strict` when using it as a CI gate; the command still prints the checks, then exits non-zero if any check fails. `doctor` can detect token validity, principal namespace, platform version, and CLI compatibility when the control plane exposes `/whoami`; deeper -capability checks still require additional control endpoints. -The namespace URL can be `(unavailable)` when the operator has not configured a -public platform domain; authentication and other `/whoami` fields still work. +capability checks still require additional control endpoints. The namespace URL +can be `(unavailable)` when the operator has not configured a public platform +domain; authentication and other `/whoami` fields still work. ## Scaffolding a New Worker @@ -222,10 +219,10 @@ APP_NAME = "hello" ``` For new projects, use `compatibility_date = "2026-06-17"` unless a required -feature or your operator gives you a newer target. -Control rejects explicit dates before `2026-04-01`, invalid or future dates, -and dates newer than the bundled workerd supports. Upstream experimental enable -flags, `legacy_error_serialization`, and `allow_irrevocable_stub_storage` are +feature or your operator gives you a newer target. Control rejects explicit +dates before `2026-04-01`, invalid or future dates, and dates newer than the +bundled workerd supports. Upstream experimental enable flags, +`legacy_error_serialization`, and `allow_irrevocable_stub_storage` are unsupported. These checks are control-owned; the CLI does not mirror workerd's flag table. @@ -242,9 +239,8 @@ consult the configured npm registry when reporting an unknown configuration field. Project build hooks retain their normal network access. When several Wrangler config files exist, WDL follows Wrangler's priority: -`wrangler.json`, then `wrangler.jsonc`, then `wrangler.toml`. -Both JSON filenames use Wrangler's JSONC syntax, including comments and -trailing commas. +`wrangler.json`, then `wrangler.jsonc`, then `wrangler.toml`. Both JSON +filenames use Wrangler's JSONC syntax, including comments and trailing commas. After configuring the CLI defaults: @@ -349,8 +345,8 @@ for you. A Worker with at least one route pattern may set `workers_dev = false` to disable its default WDL platform-domain URL while keeping its pattern routes -active. WDL requires this explicit opt-out; declaring `route` / `routes` -alone does not disable the platform URL. The deploy summary prints every active +active. WDL requires this explicit opt-out; declaring `route` / `routes` alone +does not disable the platform URL. The deploy summary prints every active route-pattern URL hint, preserving the trailing `*` on prefix patterns, and prints the platform-domain URL only while it is enabled. @@ -359,46 +355,46 @@ preview URLs are controlled separately by `preview_urls`, which defaults to the `workers_dev` setting. WDL maps `workers_dev` to `.//`, the namespace's ordinary serving path, so review the flag when porting a `wrangler.toml`. WDL also requires an explicit -opt-out with at least one route pattern instead of inferring it from routes. -WDL does not support `preview_urls`; the CLI rejects that field. +opt-out with at least one route pattern instead of inferring it from routes. WDL +does not support `preview_urls`; the CLI rejects that field. ## Supported Wrangler Configuration -The control plane is the canonical validator for shapes that Wrangler can -bundle but WDL cannot run, including unsupported workerd compatibility flags -and WDL-reserved injected module names. The CLI still fails fast for cheap local +The control plane is the canonical validator for shapes that Wrangler can bundle +but WDL cannot run, including unsupported workerd compatibility flags and +WDL-reserved injected module names. The CLI still fails fast for cheap local cases such as Python Worker modules and ambiguous runtime `env` name collisions between `[vars]`, explicit bindings, and the implicit `ASSETS` binding. Deploy and secret mutation also enforce the headroomed 1 MiB workerd `workerLoader` env budget; large `[vars]`, secrets, binding metadata, or retained versions can fail with `worker_env_too_large`. -| Configuration | Support | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `name` / `main` / `compatibility_date` / `compatibility_flags` | Supported | -| `[vars]` | Supported; must be an object. Values must be string / number / boolean; arrays and nested values are rejected. Accepted values are exposed through Worker `env` | -| `[[kv_namespaces]]` | Supported for common KV APIs | -| `[[d1_databases]]` | Supported for bindings; create/manage databases with `wdl d1`, then reference them by `database_id` (preferred when present) or `database_name` (namespace-unique alias) | -| `[assets] directory = "..."` | Supported; static files are deployed to platform assets, and the Worker gets `env.ASSETS.url(path)` | -| `route` / `routes` | Not generally available for tenant self-service; use only when your operator explicitly enables a custom host for your namespace | -| `workers_dev` | Optional boolean for a Worker with at least one `route` / `routes` pattern. `false` disables the default WDL platform-domain URL; omitted or `true` keeps it enabled | -| `[triggers] crons` | Supported; Cloudflare-compatible form, executed in UTC | -| `[[triggers.schedules]]` | Platform extension; each cron can specify its own `timezone`; not part of standard Cloudflare configuration | -| `[[queues.producers]]` / `[[queues.consumers]]` | Supported for producing and consuming queues; `delivery_delay` and `retry_delay` are honored, while `max_concurrency` is rejected | -| `[[services]]` | Supported for Worker-to-Worker calls; same namespace works directly, cross-namespace calls require target-side authorization | -| `[[platform_bindings]]` | Supported for platform-provided first-party capabilities | -| `[env.]` | Supported; select with `--env ` or `CLOUDFLARE_ENV`; see environment override notes below | -| `[[r2_buckets]]` | Supported for common R2 object APIs, including conditional requests, range GETs, and `list({ include })`; objects are stored in platform-local R2 and isolated by namespace + `bucket_name` | -| Durable Objects | Supported for local classes listed in `[[migrations]].new_classes` or `[[migrations]].new_sqlite_classes`; both map to SQLite-backed DO storage in WDL. `script_name` and renamed/deleted migrations are not supported yet. `stub.fetch()`, JSON-structured `stub.method(...args)` DO RPC, synchronous `ctx.storage.sql`, the alarm shim, ordinary WebSocket upgrade, and the native WebSocket hibernation API surface are available; platform-level session/cursor recovery remains application-owned | -| `[[workflows]]` | Supported for workflow classes defined in the current Worker. `WorkflowEntrypoint`, `env..create()`, `createBatch()`, `get()`, `status()`, `pause()`/`resume()`/`restart()`/`terminate()`, `sendEvent()`, `step.do()`/`sleep()`/`sleepUntil()`/`waitForEvent()`, retries, `NonRetryableError`, same-worker DO progress callbacks, and runtime-observed parallel/DAG steps are available. This is WDL Workflows support, not full Cloudflare Workflows parity. Instance payloads, per-turn step fan-out, and parallel step ordering are bounded; started steps must be awaited. `script_name`, cross-worker workflows, cross-worker callbacks, service-binding callbacks, and Cloudflare source-AST visualizer are unsupported | -| Analytics Engine | Not currently supported; deploy fails if configured | -| Other unmapped Wrangler binding/config/policy sections (for example `ai`, `vectorize`, `hyperdrive`, `agent_memory`, `websearch`, `media`, `stream`, `ratelimits`, `vpc_services`, `cloudchamber`, `containers`, `wasm_modules`, `[site]`, `limits`, `placement`, `observability`, `pages_build_output_dir`) | Not supported; deploy fails loudly instead of silently dropping the binding/config. The CLI error names the rejected field; the internal rejection list tracks the bundled Wrangler schema and is not reproduced exhaustively here | - -WDL parses `[[exports]]`, `[[platform_bindings]]`, -`[[triggers.schedules]]`, and `[[services]].ns` itself and removes these private -extensions from the temporary config passed to the Wrangler bundler. Other -fields retain their existing Wrangler passthrough behavior. Wrangler's -object-shaped declarative `exports` configuration is not supported by WDL. +| Configuration | Support | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` / `main` / `compatibility_date` / `compatibility_flags` | Supported | +| `[vars]` | Supported; must be an object. Values must be string / number / boolean; arrays and nested values are rejected. Accepted values are exposed through Worker `env` | +| `[[kv_namespaces]]` | Supported for common KV APIs | +| `[[d1_databases]]` | Supported for bindings; create/manage databases with `wdl d1`, then reference them by `database_id` (preferred when present) or `database_name` (namespace-unique alias) | +| `[assets] directory = "..."` | Supported; static files are deployed to platform assets, and the Worker gets `env.ASSETS.url(path)` | +| `route` / `routes` | Not generally available for tenant self-service; use only when your operator explicitly enables a custom host for your namespace | +| `workers_dev` | Optional boolean for a Worker with at least one `route` / `routes` pattern. `false` disables the default WDL platform-domain URL; omitted or `true` keeps it enabled | +| `[triggers] crons` | Supported; Cloudflare-compatible form, executed in UTC | +| `[[triggers.schedules]]` | Platform extension; each cron can specify its own `timezone`; not part of standard Cloudflare configuration | +| `[[queues.producers]]` / `[[queues.consumers]]` | Supported for producing and consuming queues; `delivery_delay` and `retry_delay` are honored, while `max_concurrency` is rejected | +| `[[services]]` | Supported for Worker-to-Worker calls; same namespace works directly, cross-namespace calls require target-side authorization | +| `[[platform_bindings]]` | Supported for platform-provided first-party capabilities | +| `[env.]` | Supported; select with `--env ` or `CLOUDFLARE_ENV`; see environment override notes below | +| `[[r2_buckets]]` | Supported for common R2 object APIs, including conditional requests, range GETs, and `list({ include })`; objects are stored in platform-local R2 and isolated by namespace + `bucket_name` | +| Durable Objects | Supported for local classes listed in `[[migrations]].new_classes` or `[[migrations]].new_sqlite_classes`; both map to SQLite-backed DO storage in WDL. `script_name` and renamed/deleted migrations are not supported yet. `stub.fetch()`, JSON-structured `stub.method(...args)` DO RPC, synchronous `ctx.storage.sql`, the alarm shim, ordinary WebSocket upgrade, and the native WebSocket hibernation API surface are available; platform-level session/cursor recovery remains application-owned | +| `[[workflows]]` | Supported for workflow classes defined in the current Worker. `WorkflowEntrypoint`, `env..create()`, `createBatch()`, `get()`, `status()`, `pause()`/`resume()`/`restart()`/`terminate()`, `sendEvent()`, `step.do()`/`sleep()`/`sleepUntil()`/`waitForEvent()`, retries, `NonRetryableError`, same-worker DO progress callbacks, and runtime-observed parallel/DAG steps are available. This is WDL Workflows support, not full Cloudflare Workflows parity. Instance payloads, per-turn step fan-out, and parallel step ordering are bounded; started steps must be awaited. `script_name`, cross-worker workflows, cross-worker callbacks, service-binding callbacks, and Cloudflare source-AST visualizer are unsupported | +| Analytics Engine | Not currently supported; deploy fails if configured | +| Other unmapped Wrangler binding/config/policy sections (for example `ai`, `vectorize`, `hyperdrive`, `agent_memory`, `websearch`, `media`, `stream`, `ratelimits`, `vpc_services`, `cloudchamber`, `containers`, `wasm_modules`, `[site]`, `limits`, `placement`, `observability`, `pages_build_output_dir`) | Not supported; deploy fails loudly instead of silently dropping the binding/config. The CLI error names the rejected field; the internal rejection list tracks the bundled Wrangler schema and is not reproduced exhaustively here | + +WDL parses `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, and +`[[services]].ns` itself and removes these private extensions from the temporary +config passed to the Wrangler bundler. Other fields retain their existing +Wrangler passthrough behavior. Wrangler's object-shaped declarative `exports` +configuration is not supported by WDL. Cron triggers and queue consumers are dispatch features. Declare them only on routeable Workers in tenant namespaces unless your operator gives you an @@ -477,8 +473,8 @@ arrayBuffer/stream shapes before proxying. Returned keys are not sorted, `limit` is a target page size capped at 1000, and the opaque WDL cursor must be passed back verbatim. KV values are capped at 25 MiB before proxying, and keys (and list prefixes) are capped at 512 bytes, -matching Cloudflare's limit — a longer key fails with `KV key exceeds 512 byte -limit`. +matching Cloudflare's limit — a longer key fails with +`KV key exceeds 512 byte limit`. WDL KV writes are visible immediately. Expiring a key removes both value and metadata; putting the key again without an expiration clears the previous @@ -532,10 +528,9 @@ Conditional reads, range GETs, and `list({ include })` metadata hydration are also supported for Workers that need them; metadata hydration issues extra HEAD requests under a concurrency cap. `put(stream, ...)` currently buffers before sending a single S3 PUT and has a 25 MiB maximum. Multipart upload, SSE-C, and -checksum selection are not supported. -When `httpMetadata` is supplied as a `Headers` object, an `Expires` header must -use canonical IMF-fixdate syntax, such as -`Wed, 21 Oct 2015 07:28:00 GMT`. +checksum selection are not supported. When `httpMetadata` is supplied as a +`Headers` object, an `Expires` header must use canonical IMF-fixdate syntax, +such as `Wed, 21 Oct 2015 07:28:00 GMT`. Use `wdl r2` commands to inspect or explicitly delete namespace R2 data: @@ -601,9 +596,9 @@ happen. SQLite object names starting with `_cf_` are reserved by workerd, case-insensitively. Avoid creating or renaming D1 tables, indexes, triggers, or -views to `_cf_*`; a migration containing that DDL can fail on a new database. -Do not edit an already-applied migration file — add a forward migration that -moves application data to a non-reserved name when needed. +views to `_cf_*`; a migration containing that DDL can fail on a new database. Do +not edit an already-applied migration file — add a forward migration that moves +application data to a non-reserved name when needed. Useful commands: @@ -616,9 +611,9 @@ wdl d1 migrations apply main wdl d1 delete main ``` -`wdl d1 execute` requires exactly one of `--sql` or `--file` (even -`--sql ""` conflicts with `--file`), and the selected SQL source must be -non-empty. `--file` must exist, be readable, and stay inside the project root; +`wdl d1 execute` requires exactly one of `--sql` or `--file` (even `--sql ""` +conflicts with `--file`), and the selected SQL source must be non-empty. +`--file` must exist, be readable, and stay inside the project root; missing/unreadable files are rejected before control is contacted. `wdl d1 delete` asks for confirmation by default. In automation, pass `--yes` @@ -637,10 +632,10 @@ files into smaller batches before applying. Control-plane D1 lifecycle APIs redact 5xx failures forwarded from the D1 runtime/backend to `Internal error` and retain only bounded machine classifiers -(`upstreamCode`, `upstreamCategory`, `upstreamRetryable`, and -`upstreamStatus`). Control-generated contention and collision 503 responses -retain actionable messages. Outward 4xx SQL and migration failures can retain -actionable diagnostics. +(`upstreamCode`, `upstreamCategory`, `upstreamRetryable`, and `upstreamStatus`). +Control-generated contention and collision 503 responses retain actionable +messages. Outward 4xx SQL and migration failures can retain actionable +diagnostics. See `examples/d1-demo` for a minimal visitor counter using D1 plus a forward-only migration. @@ -798,8 +793,8 @@ Effect timing: version, so new traffic cold-loads the updated secret. Already-loaded historical versions can keep old values until runtime eviction or recycle. - Worker-level secret changes are atomic. If the active version changes during - the mutation, control returns `secret_mutation_contention` and the CLI asks you - to retry instead of leaving a stored-but-not-promoted partial update. + the mutation, control returns `secret_mutation_contention` and the CLI asks + you to retry instead of leaving a stored-but-not-promoted partial update. - Secret-envelope errors such as `secret_encryption_unconfigured`, `secret_decrypt_failed`, `invalid_envelope`, `unsupported_envelope`, `unknown_kid`, or `secret_not_encrypted` mean the mutation was not written; @@ -873,15 +868,15 @@ Workers, not on platform binding target Workers. Queue behavior tenants can rely on: -| Feature | Behavior | -| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Body types | `json` is the default. Use `{ contentType: "text" }` for strings and `{ contentType: "bytes" }` for `Uint8Array` payloads. `v8` structured-clone payloads are not supported. | -| Send delay | `[[queues.producers]].delivery_delay` is the default send delay in seconds. `send(body, { delaySeconds })` and per-message `sendBatch()` delays override it; `delaySeconds: 0` means immediate delivery. | -| Retry delay | `[[queues.consumers]].retry_delay` is the default retry delay in seconds. `msg.retry({ delaySeconds })` / `batch.retryAll({ delaySeconds })` override it; `delaySeconds: 0` means immediate retry. | -| Attempts | The handler sees `msg.attempts` starting at `1`. With `max_retries = N`, a message can be delivered up to `N + 1` times before dead-letter handling. | -| Dead letter queue | `dead_letter_queue` is honored. If omitted, failed messages use the queue's default DLQ. | +| Feature | Behavior | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Body types | `json` is the default. Use `{ contentType: "text" }` for strings and `{ contentType: "bytes" }` for `Uint8Array` payloads. `v8` structured-clone payloads are not supported. | +| Send delay | `[[queues.producers]].delivery_delay` is the default send delay in seconds. `send(body, { delaySeconds })` and per-message `sendBatch()` delays override it; `delaySeconds: 0` means immediate delivery. | +| Retry delay | `[[queues.consumers]].retry_delay` is the default retry delay in seconds. `msg.retry({ delaySeconds })` / `batch.retryAll({ delaySeconds })` override it; `delaySeconds: 0` means immediate retry. | +| Attempts | The handler sees `msg.attempts` starting at `1`. With `max_retries = N`, a message can be delivered up to `N + 1` times before dead-letter handling. | +| Dead letter queue | `dead_letter_queue` is honored. If omitted, failed messages use the queue's default DLQ. | | Batch timeout | The CLI forwards `max_batch_timeout` values that pass basic integer delay parsing for config compatibility; WDL control enforces the tighter Cloudflare-compatible 0..60 second range. Dispatch is currently capped by `max_batch_size`; do not depend on timeout-based batch flushing. | -| Unsupported config | `max_concurrency` is rejected during deploy instead of being silently ignored. | +| Unsupported config | `max_concurrency` is rejected during deploy instead of being silently ignored. | See `examples/queues-demo` for a single Worker that produces queue messages, consumes them, and stores delivery state in KV. @@ -1185,7 +1180,7 @@ wdl tail hello | Symptom | Likely cause | What to check | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| `Missing admin token` | No tenant token was provided | Run `wdl token set --ns --control-url ` (recommended), set `ADMIN_TOKEN`, or pass `--token` | +| `Missing admin token` | No tenant token was provided | Run `wdl token set --ns --control-url ` (recommended), set `ADMIN_TOKEN`, or pass `--token` | | `wrangler build failed` | Wrangler could not bundle the Worker project | Run `npx wrangler deploy --dry-run` inside the Worker project and fix local build/config errors | | Deploy succeeds but promote fails | Route, custom host, or binding validation failed at promotion time | Check that custom hosts are enabled for your namespace and service-binding targets exist | | Worker URL returns 404 | URL shape or worker name is wrong | Use `https://.//`; include the worker name path segment | diff --git a/README.md b/README.md index d62153a..704a149 100644 --- a/README.md +++ b/README.md @@ -96,11 +96,12 @@ wdl tail hello # live logs while you try the URL The worker is now at `https://./hello/`. Prefer not to store the token? Credentials can also come from shell env -(`WDL_NS` / `ADMIN_TOKEN` / `CONTROL_URL`) or a project `.env` with per-namespace -sections (copy [`.env.example`](https://github.com/wdl-dev/cli/blob/main/.env.example)) -— see [docs/deploy.md](https://github.com/wdl-dev/cli/blob/main/docs/deploy.md) -for the full precedence (flags beat shell env, which beats `.env`, which beats -the `wdl token` store). +(`WDL_NS` / `ADMIN_TOKEN` / `CONTROL_URL`) or a project `.env` with +per-namespace sections (copy +[`.env.example`](https://github.com/wdl-dev/cli/blob/main/.env.example)) — see +[docs/deploy.md](https://github.com/wdl-dev/cli/blob/main/docs/deploy.md) for +the full precedence (flags beat shell env, which beats `.env`, which beats the +`wdl token` store). ## Commands diff --git a/bin/wdl.js b/bin/wdl.js index 1055a61..1ced928 100755 --- a/bin/wdl.js +++ b/bin/wdl.js @@ -24,7 +24,21 @@ import { tokenStoreReader } from "../lib/token-store.js"; // Ordered for `wdl help`. Each entry carries its own { name, summary } metadata, // so the dispatch map and the help table below are both derived from it — no // command name or description is maintained twice. -const REGISTRY = [initCmd, deployCmd, secretCmd, workersCmd, deleteCmd, d1Cmd, r2Cmd, tailCmd, workflowsCmd, tokenCmd, configCmd, doctorCmd, whoamiCmd]; +const REGISTRY = [ + initCmd, + deployCmd, + secretCmd, + workersCmd, + deleteCmd, + d1Cmd, + r2Cmd, + tailCmd, + workflowsCmd, + tokenCmd, + configCmd, + doctorCmd, + whoamiCmd, +]; // Alias -> canonical command name. const ALIASES = { secrets: "secret" }; @@ -151,21 +165,18 @@ function usage(exitCode) { } const width = Math.max(...REGISTRY.map((c) => c.meta.name.length)) + 1; const write = exitCode === 0 ? console.log : console.error; - write(formatHelp({ - usage: [ - "wdl [args] [options]", - "wdl --help", - "wdl help ", - "wdl --version", - ], - description: "Manage deployments, diagnostics, secrets, workers, D1, R2, and Workflows for a WDL control plane.", - commands: REGISTRY.map((c) => { - const alias = aliasesByTarget[c.meta.name]; - const note = alias ? ` (alias: ${alias.join(", ")})` : ""; - return `${c.meta.name.padEnd(width)}${c.meta.summary}${note}`; - }), - options: commonCliOptions(), - })); + write( + formatHelp({ + usage: ["wdl [args] [options]", "wdl --help", "wdl help ", "wdl --version"], + description: "Manage deployments, diagnostics, secrets, workers, D1, R2, and Workflows for a WDL control plane.", + commands: REGISTRY.map((c) => { + const alias = aliasesByTarget[c.meta.name]; + const note = alias ? ` (alias: ${alias.join(", ")})` : ""; + return `${c.meta.name.padEnd(width)}${c.meta.summary}${note}`; + }), + options: commonCliOptions(), + }) + ); process.exit(exitCode); } diff --git a/commands/config.js b/commands/config.js index 2234b55..8a3735c 100644 --- a/commands/config.js +++ b/commands/config.js @@ -23,7 +23,12 @@ async function runConfig({ values, positionals, context }) { const [subcommand, extra] = positionals; if (subcommand !== "explain" || extra) throw new CliError(usageText()); - const state = resolveCliConfigState({ values, env: context.env, cwd: context.cwd, warn: context.warn }); + const state = resolveCliConfigState({ + values, + env: context.env, + cwd: context.cwd, + warn: context.warn, + }); const body = { namespace: publicEntry(state.namespace), controlUrl: publicEntry(state.controlUrl), @@ -71,11 +76,7 @@ function formatConfigExplain(body) { * @param {PublicConfigEntry} entry */ function formatBlock(name, entry) { - const lines = [ - `${name}:`, - ` value: ${entry.value}`, - ` source: ${entry.source}`, - ]; + const lines = [`${name}:`, ` value: ${entry.value}`, ` source: ${entry.source}`]; if (entry.error) lines.push(` error: ${entry.error}`); return lines; } diff --git a/commands/d1.js b/commands/d1.js index 3dcd8fc..d92a155 100644 --- a/commands/d1.js +++ b/commands/d1.js @@ -16,7 +16,15 @@ import { } from "../lib/d1-format.js"; import { LONG_CONTROL_TIMEOUT_MS } from "../lib/control-fetch.js"; import { defineCommand } from "../lib/command.js"; -import { CliError, defineCliOption, formatHelp, isMain, isPathInside, optionHelp, unexpectedArgument } from "../lib/common.js"; +import { + CliError, + defineCliOption, + formatHelp, + isMain, + isPathInside, + optionHelp, + unexpectedArgument, +} from "../lib/common.js"; import { confirmAction } from "../lib/stdin.js"; import { escapeTerminalText, formatDiagnosticValue, writeResult } from "../lib/output.js"; @@ -96,17 +104,24 @@ async function runD1({ values, positionals, context }) { if (positionals[2]) throw unexpectedArgument("d1 create", positionals[2]); const { headers } = context.resolveControl(); const body = /** @type {{ namespace?: string, databaseId?: string, databaseName?: string }} */ ( - await context.fetchJson(context.nsUrl("d1", "databases"), { - method: "POST", - headers, - body: JSON.stringify({ - databaseName, - }), - }, "create d1 database") + await context.fetchJson( + context.nsUrl("d1", "databases"), + { + method: "POST", + headers, + body: JSON.stringify({ + databaseName, + }), + }, + "create d1 database" + ) + ); + writeResult( + values.json === true, + body, + () => [`OK ${body.namespace}/${body.databaseId} created name=${body.databaseName || "-"}`], + stdout ); - writeResult(values.json === true, body, () => [ - `OK ${body.namespace}/${body.databaseId} created name=${body.databaseName || "-"}`, - ], stdout); return; } @@ -123,14 +138,16 @@ async function runD1({ values, positionals, context }) { action: `delete D1 database "${ns}/${databaseRef}"`, }); const body = /** @type {{ namespace?: string, databaseId?: string }} */ ( - await context.fetchJson(context.nsUrl("d1", "databases", databaseRef), { - method: "DELETE", - headers, - }, "delete d1 database") + await context.fetchJson( + context.nsUrl("d1", "databases", databaseRef), + { + method: "DELETE", + headers, + }, + "delete d1 database" + ) ); - writeResult(values.json === true, body, () => [ - `OK ${body.namespace}/${body.databaseId} deleted`, - ], stdout); + writeResult(values.json === true, body, () => [`OK ${body.namespace}/${body.databaseId} deleted`], stdout); return; } @@ -161,16 +178,20 @@ async function runD1({ values, positionals, context }) { } const { headers } = context.resolveControl(); const body = /** @type {Parameters[0]} */ ( - await context.fetchJson(context.nsUrl("d1", "databases", databaseRef, "query"), { - method: "POST", - headers, - body: JSON.stringify({ - sql, - mode, - ...(params ? { params } : {}), - }), - timeoutMs: LONG_CONTROL_TIMEOUT_MS, - }, "execute d1 query") + await context.fetchJson( + context.nsUrl("d1", "databases", databaseRef, "query"), + { + method: "POST", + headers, + body: JSON.stringify({ + sql, + mode, + ...(params ? { params } : {}), + }), + timeoutMs: LONG_CONTROL_TIMEOUT_MS, + }, + "execute d1 query" + ) ); writeResult(values.json === true, body, () => formatD1Execute(body), stdout); return; @@ -200,11 +221,15 @@ async function runMigrationsCommand({ action, databaseRef, context }) { if (action === "status") { const migrations = loadLocalMigrations({ values, env, cwd, databaseRef, warn }); const body = /** @type {Parameters[0]} */ ( - await context.fetchJson(`${migrationsBase}/status`, { - method: "POST", - headers, - body: serializeMigrationStatusRequest(migrations), - }, "show d1 migration status") + await context.fetchJson( + `${migrationsBase}/status`, + { + method: "POST", + headers, + body: serializeMigrationStatusRequest(migrations), + }, + "show d1 migration status" + ) ); writeResult(values.json === true, body, () => formatD1MigrationStatus(body), stdout); return; @@ -213,12 +238,16 @@ async function runMigrationsCommand({ action, databaseRef, context }) { if (action === "apply") { const migrations = loadLocalMigrations({ values, env, cwd, databaseRef, warn }); const body = /** @type {Parameters[0]} */ ( - await context.fetchJson(`${migrationsBase}/apply`, { - method: "POST", - headers, - body: JSON.stringify({ migrations }), - timeoutMs: LONG_CONTROL_TIMEOUT_MS, - }, "apply d1 migrations") + await context.fetchJson( + `${migrationsBase}/apply`, + { + method: "POST", + headers, + body: JSON.stringify({ migrations }), + timeoutMs: LONG_CONTROL_TIMEOUT_MS, + }, + "apply d1 migrations" + ) ); writeResult(values.json === true, body, () => formatD1MigrationApply(body), stdout); return; @@ -309,7 +338,9 @@ function resolveMigrationsDir({ values, env, cwd, databaseRef, warn }) { const byName = []; for (const [idx, entry] of entries.entries()) { if (entry.migrations_dir != null && (typeof entry.migrations_dir !== "string" || !entry.migrations_dir.trim())) { - throw new CliError(`${escapeTerminalText(configRel)}: [[d1_databases]] ${escapeTerminalText(entry.binding || idx)}: migrations_dir must be a string`); + throw new CliError( + `${escapeTerminalText(configRel)}: [[d1_databases]] ${escapeTerminalText(entry.binding || idx)}: migrations_dir must be a string` + ); } if (entry.database_id === databaseRef) { byId.push(entry); @@ -322,12 +353,14 @@ function resolveMigrationsDir({ values, env, cwd, databaseRef, warn }) { const matches = byId.length > 0 ? byId : byName; if (matches.length > 1) { - throw new CliError(`${escapeTerminalText(configRel)}: multiple [[d1_databases]] entries match ${formatDiagnosticValue(databaseRef)}`); + throw new CliError( + `${escapeTerminalText(configRel)}: multiple [[d1_databases]] entries match ${formatDiagnosticValue(databaseRef)}` + ); } if (matches.length === 0) { throw new CliError( `${escapeTerminalText(configRel)}: no matching [[d1_databases]] entry for ${formatDiagnosticValue(databaseRef)}; ` + - "use a configured database_name/database_id or pass --dir explicitly" + "use a configured database_name/database_id or pass --dir explicitly" ); } @@ -370,13 +403,12 @@ function resolveConfiguredMigrationsDir({ configDir, migrationsDir, configRel, b if (!isPathInside(root, resolved)) { throw new CliError( `${escapeTerminalText(configRel)}: [[d1_databases]] ${escapeTerminalText(binding)}: ` + - `migrations_dir must stay inside the project (got ${formatDiagnosticValue(migrationsDir)})` + `migrations_dir must stay inside the project (got ${formatDiagnosticValue(migrationsDir)})` ); } return resolved; } - function usageText() { return formatHelp({ usage: [ diff --git a/commands/delete.js b/commands/delete.js index fa59cd1..6895a86 100644 --- a/commands/delete.js +++ b/commands/delete.js @@ -1,5 +1,13 @@ import { defineCommand } from "../lib/command.js"; -import { CliError, defineCliOption, defineHiddenCliOption, formatHelp, isMain, optionHelp, unexpectedArgument } from "../lib/common.js"; +import { + CliError, + defineCliOption, + defineHiddenCliOption, + formatHelp, + isMain, + optionHelp, + unexpectedArgument, +} from "../lib/common.js"; import { confirmAction } from "../lib/stdin.js"; import { escapeTerminalText, writeResult } from "../lib/output.js"; import { formatVersionDelete, formatWorkerDelete } from "../lib/delete-format.js"; @@ -54,9 +62,14 @@ async function runDelete({ values, positionals, context }) { const body = await context.fetchJson( context.nsUrl("worker", worker, "versions", version), { method: "DELETE", headers }, - "delete version", + "delete version" + ); + writeResult( + values.json === true, + body, + () => formatVersionDelete(/** @type {Parameters[0]} */ (body)), + stdout ); - writeResult(values.json === true, body, () => formatVersionDelete(/** @type {Parameters[0]} */ (body)), stdout); return; } @@ -80,19 +93,21 @@ async function runDelete({ values, positionals, context }) { const body = await context.fetchJson( `${context.nsUrl("worker", worker, "delete")}${suffix}`, { method: "POST", headers }, - dryRun ? "dry-run delete worker" : "delete worker", + dryRun ? "dry-run delete worker" : "delete worker" + ); + writeResult( + values.json === true, + body, + () => formatWorkerDelete(/** @type {Parameters[0]} */ (body)), + stdout ); - writeResult(values.json === true, body, () => formatWorkerDelete(/** @type {Parameters[0]} */ (body)), stdout); return; } } function usageText() { return formatHelp({ - usage: [ - "wdl delete version [options] ", - "wdl delete worker [options] ", - ], + usage: ["wdl delete version [options] ", "wdl delete worker [options] "], description: "Delete retained versions or all worker-owned lifecycle state.", commands: [ "version Delete one retained non-active worker version.", diff --git a/commands/deploy.js b/commands/deploy.js index adce6e9..1490c5a 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -4,8 +4,23 @@ import { execFileSync } from "node:child_process"; import { LONG_CONTROL_TIMEOUT_MS } from "../lib/control-fetch.js"; import { defineCommand } from "../lib/command.js"; -import { CliError, defineCliOption, formatHelp, formatHttpError, isMain, optionHelp, readJsonOrFail, unexpectedArgument } from "../lib/common.js"; -import { escapeTerminalText, formatDiagnosticValue, formatKnownWarning, shellArgForDisplay, writeStatusLine } from "../lib/output.js"; +import { + CliError, + defineCliOption, + formatHelp, + formatHttpError, + isMain, + optionHelp, + readJsonOrFail, + unexpectedArgument, +} from "../lib/common.js"; +import { + escapeTerminalText, + formatDiagnosticValue, + formatKnownWarning, + shellArgForDisplay, + writeStatusLine, +} from "../lib/output.js"; import { isLocalDevHost } from "../lib/credentials.js"; import { isSecretEnvelopeErrorCode } from "../lib/secret-envelope-errors.js"; import { packWranglerProject } from "../lib/wrangler-pack.js"; @@ -69,7 +84,11 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, writeStatusLine(stdout, `[2/3] uploading ${workerName} → ${controlUrl}/ns/${ns}`); // `version` comes from the control response; keep the raw value for the // promote request body — display sites escape via writeStatusLine. - const { version, warnings, workersDev: deployedWorkersDev } = /** @type {{ version: unknown, warnings?: DeployWarning[], workersDev?: unknown }} */ ( + const { + version, + warnings, + workersDev: deployedWorkersDev, + } = /** @type {{ version: unknown, warnings?: DeployWarning[], workersDev?: unknown }} */ ( await fetchDeployJson({ context, url: context.nsUrl("worker", workerName, "deploy"), @@ -89,7 +108,7 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, if (workersDevOptOutRequested && deployedWorkersDev !== false) { throw new CliError( "control did not confirm workers_dev = false; the uploaded version was retained but NOT promoted. " + - "Upgrade control and re-run `wdl deploy`." + "Upgrade control and re-run `wdl deploy`." ); } @@ -105,20 +124,20 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, headers: jsonHeaders, body: JSON.stringify({ version }), }, - "promote", + "promote" ) ); } catch (err) { stderr( `note: version ${escapeTerminalText(String(version))} was uploaded and retained but NOT promoted; ` + - `the previously active version still serves traffic. Re-run \`wdl deploy\` to retry.` + `the previously active version still serves traffic. Re-run \`wdl deploy\` to retry.` ); throw err; } if (workersDevOptOutRequested && promoteBody.workersDev !== false) { throw new CliError( "control promoted the worker without preserving workers_dev = false; " + - "the platform-domain URL may still be active." + "the platform-domain URL may still be active." ); } return { @@ -139,8 +158,7 @@ function promotedWorkerUrlHints(raw, includePlatform) { return { platform: null, routes: [] }; } const urls = /** @type {{ platform?: unknown, routes?: unknown }} */ (raw); - const platform = - includePlatform && typeof urls.platform === "string" ? urls.platform : null; + const platform = includePlatform && typeof urls.platform === "string" ? urls.platform : null; const seen = new Set(platform === null ? [] : [platform]); const routes = []; if (Array.isArray(urls.routes)) { @@ -172,15 +190,13 @@ function displayWorkerUrl(rawUrl, controlUrl, isLocal) { } const authorityStart = rawUrl.indexOf("://") + 3; const suffixOffset = rawUrl.slice(authorityStart).search(/[/?#]/); - const authorityEnd = - suffixOffset === -1 ? rawUrl.length : authorityStart + suffixOffset; + const authorityEnd = suffixOffset === -1 ? rawUrl.length : authorityStart + suffixOffset; if (rawUrl.slice(0, authorityEnd) !== `${workerUrl.protocol}//${workerUrl.host}`) { return null; } // Normalize only the origin. URL.href would also normalize dot segments and // change the operator's original route pattern. - const suffix = - suffixOffset === -1 ? "" : rawUrl.slice(authorityEnd); + const suffix = suffixOffset === -1 ? "" : rawUrl.slice(authorityEnd); const protocol = isLocal ? controlUrl.protocol : workerUrl.protocol; const port = isLocal ? controlUrl.port : workerUrl.port; return `${protocol}//${workerUrl.hostname}${port ? `:${port}` : ""}${suffix}`; @@ -205,7 +221,9 @@ async function fetchDeployJson({ context, url, init, label, ns, workerName, stde if (res.ok) return await readJsonOrFail(res, label); const text = await res.text(); renderDeployWarningsFromErrorBody(text, { ns, workerName, stderr }); - throw new CliError(`${label} failed: ${formatHttpError(res.status, stripRenderedWarnings(text), res.headers)}${deployErrorHint(text)}`); + throw new CliError( + `${label} failed: ${formatHttpError(res.status, stripRenderedWarnings(text), res.headers)}${deployErrorHint(text)}` + ); } /** @@ -223,9 +241,9 @@ function renderDeployWarnings(warnings, { ns, workerName, stderr }) { const keys = escapeTerminalText(w.missingCallerSecrets.join(", ")); stderr( `warning: platform binding "${escapeTerminalText(w.binding)}" (platform="${escapeTerminalText(w.platform)}"): ` + - `missing caller secrets ${keys}\n` + - ` ns-wide: wdl secret put --ns ${nsArg} --scope ns \n` + - ` per-worker: wdl secret put --ns ${nsArg} --worker ${workerArg} ` + `missing caller secrets ${keys}\n` + + ` ns-wide: wdl secret put --ns ${nsArg} --scope ns \n` + + ` per-worker: wdl secret put --ns ${nsArg} --worker ${workerArg} ` ); } else { stderr(`warning: ${formatKnownWarning(w, DEPLOY_WARNING_KEYS)}`); @@ -304,21 +322,12 @@ export function serializeDeployManifest(manifest, maxBytes = DEPLOY_JSON_BODY_MA const body = JSON.stringify(manifest); const bodyBytes = Buffer.byteLength(body); if (bodyBytes > maxBytes) { - throw new CliError( - `deploy manifest is ${bodyBytes} bytes, exceeds ${maxBytes} byte control-plane request cap` - ); + throw new CliError(`deploy manifest is ${bodyBytes} bytes, exceeds ${maxBytes} byte control-plane request cap`); } return body; } -const DEPLOY_WARNING_KEYS = [ - "code", - "message", - "binding", - "platform", - "className", - "entrypoint", -]; +const DEPLOY_WARNING_KEYS = ["code", "message", "binding", "platform", "className", "entrypoint"]; const command = defineCommand({ name: "deploy", @@ -382,9 +391,7 @@ async function runDeploy({ values, positionals, context: baseContext }) { const reportedUrlHints = promotedWorkerUrlHints(urls, workersDev !== false); const invalidUrlHints = []; const displayedPlatformUrl = - reportedUrlHints.platform === null - ? null - : displayWorkerUrl(reportedUrlHints.platform, parsedControlUrl, isLocal); + reportedUrlHints.platform === null ? null : displayWorkerUrl(reportedUrlHints.platform, parsedControlUrl, isLocal); if (reportedUrlHints.platform !== null && displayedPlatformUrl === null) { invalidUrlHints.push(reportedUrlHints.platform); } diff --git a/commands/doctor.js b/commands/doctor.js index 95969fa..96736eb 100644 --- a/commands/doctor.js +++ b/commands/doctor.js @@ -58,7 +58,12 @@ async function runDoctor({ values, positionals, context: baseContext }) { let tokenStoreError = null; let state; try { - state = resolveCliConfigState({ values, env: context.env, cwd: context.cwd, warn: context.warn }); + state = resolveCliConfigState({ + values, + env: context.env, + cwd: context.cwd, + warn: context.warn, + }); } catch (err) { if (!(err instanceof TokenStoreConfigError)) throw err; tokenStoreError = err.message; @@ -266,12 +271,14 @@ async function checkRemoteWhoami({ state, controlFetch, warn }) { warnIfInsecureControlUrl(control.controlUrl, warn, state.env); try { - const remote = summarizeWhoami(await fetchWhoami({ - controlUrl: control.controlUrl, - headers: control.headers, - controlFetch, - env: state.env, - })); + const remote = summarizeWhoami( + await fetchWhoami({ + controlUrl: control.controlUrl, + headers: control.headers, + controlFetch, + env: state.env, + }) + ); const tokenNs = namespaceFromPrincipal(remote.principal ?? undefined); const checks = [ check({ @@ -298,24 +305,29 @@ async function checkRemoteWhoami({ state, controlFetch, warn }) { checks.push(check({ ok: true, label: `Platform ${remote.platformVersion}` })); } if (state.namespace.value && tokenNs) { - checks.push(check({ - ok: state.namespace.value === tokenNs, - label: `Token namespace ${tokenNs}`, - detail: state.namespace.value === tokenNs - ? `matches configured namespace ${state.namespace.value}` - : `configured namespace is ${state.namespace.value}`, - })); + checks.push( + check({ + ok: state.namespace.value === tokenNs, + label: `Token namespace ${tokenNs}`, + detail: + state.namespace.value === tokenNs + ? `matches configured namespace ${state.namespace.value}` + : `configured namespace is ${state.namespace.value}`, + }) + ); } return { whoami: remote, error: null, checks }; } catch (err) { return { whoami: null, error: err instanceof Error && err.message ? err.message : String(err), - checks: [check({ - ok: false, - label: "Control /whoami", - detail: err instanceof Error && err.message ? err.message : String(err), - })], + checks: [ + check({ + ok: false, + label: "Control /whoami", + detail: err instanceof Error && err.message ? err.message : String(err), + }), + ], }; } } diff --git a/commands/init.js b/commands/init.js index 55b834c..4471e97 100644 --- a/commands/init.js +++ b/commands/init.js @@ -3,7 +3,16 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs as nodeParseArgs } from "node:util"; import { isHelpAlias } from "../lib/command.js"; -import { CliError, defineCliOption, formatHelp, handleCliError, isMain, isNonEmptyString, optionHelp, optionParseOptions } from "../lib/common.js"; +import { + CliError, + defineCliOption, + formatHelp, + handleCliError, + isMain, + isNonEmptyString, + optionHelp, + optionParseOptions, +} from "../lib/common.js"; import { NS_PATTERN, RESERVED_TENANT_NS, isReservedNs } from "../lib/ns-pattern.js"; import { escapeTerminalText } from "../lib/output.js"; import { WRANGLER_WDL_TMP_PREFIX } from "../lib/wrangler/config.js"; @@ -17,7 +26,12 @@ const DEFAULT_COMPATIBILITY_DATE = "2026-06-17"; const CLI_ROOT = path.resolve(fileURLToPath(import.meta.url), "../.."); const INIT_OPTIONS = [ defineCliOption("ns", { type: "string" }, "--ns ", "Tenant namespace baked into the deploy script (optional)."), - defineCliOption("worker", { type: "string" }, "--worker ", "Worker name in wrangler.jsonc (defaults to )."), + defineCliOption( + "worker", + { type: "string" }, + "--worker ", + "Worker name in wrangler.jsonc (defaults to )." + ), defineCliOption("help", { type: "boolean", short: "h" }, "-h, --help", "Show this help."), ]; @@ -48,7 +62,7 @@ export async function main(argv = process.argv.slice(2)) { if (!NAME_REGEX.test(packageName)) { throw new CliError( `project name "${escapeTerminalText(packageName)}" must match ${NAME_REGEX} ` + - `(letter, then letters / digits / hyphens).`, + `(letter, then letters / digits / hyphens).` ); } @@ -101,7 +115,7 @@ function parseArgs(argv) { } const helpAlias = isHelpAlias(positionals); return { - target: helpAlias ? null : positionals[0] ?? null, + target: helpAlias ? null : (positionals[0] ?? null), ns: values.ns ?? null, worker: values.worker ?? null, help: Boolean(values.help || helpAlias), @@ -136,7 +150,7 @@ function validateNs(value, label) { if (!TENANT_NS_RE.test(value) || RESERVED_TENANT_NS.has(value) || isReservedNs(value)) { throw new CliError( `${escapeTerminalText(label)} "${escapeTerminalText(value)}" is not a valid tenant namespace ` + - `(1-63 lowercase letters / digits / hyphens, start and end with a letter or digit; reserved names are not allowed).`, + `(1-63 lowercase letters / digits / hyphens, start and end with a letter or digit; reserved names are not allowed).` ); } } @@ -149,7 +163,7 @@ function validateWorker(value, label) { if (!WORKER_NAME_REGEX.test(value)) { throw new CliError( `${escapeTerminalText(label)} "${escapeTerminalText(value)}" must match ${WORKER_NAME_REGEX} ` + - `(letter or digit, then letters / digits / underscores / hyphens; up to 255 chars).`, + `(letter or digit, then letters / digits / underscores / hyphens; up to 255 chars).` ); } } @@ -166,13 +180,13 @@ async function ensureEmpty(dir, isInPlace) { if (err instanceof Error && /** @type {{ code?: unknown }} */ (err).code === "ENOENT") return; throw err; } - const offending = entries.filter(name => !IGNORABLE_DIR_ENTRIES.has(name)); + const offending = entries.filter((name) => !IGNORABLE_DIR_ENTRIES.has(name)); if (offending.length === 0) return; const where = isInPlace ? "current directory" : escapeTerminalText(dir); throw new CliError( `${where} is not empty (found: ${offending.slice(0, 5).map(escapeTerminalText).join(", ")}` + - (offending.length > 5 ? ", …" : "") + - `). Refusing to overwrite.`, + (offending.length > 5 ? ", …" : "") + + `). Refusing to overwrite.` ); } @@ -181,28 +195,29 @@ async function ensureEmpty(dir, isInPlace) { * @param {{ packageName: string, workerName: string, ns: string | null }} arg */ async function writeStarter(targetDir, { packageName, workerName, ns }) { - const [wdlCliDep, wranglerDep] = await Promise.all([ - resolveWdlCliDep(process.env), - resolveWranglerDep(), - ]); - - const packageJson = JSON.stringify({ - name: packageName, - version: "0.0.0", - private: true, - type: "module", - scripts: { - deploy: ns ? `wdl deploy . --ns ${ns}` : "wdl deploy .", - "dry-run": "wrangler deploy --dry-run --outdir=.deploy-dist", - }, - devDependencies: { - wrangler: wranglerDep, - "@wdl-dev/cli": wdlCliDep, - }, - }, null, 2) + "\n"; - - const wranglerJsonc = -`{ + const [wdlCliDep, wranglerDep] = await Promise.all([resolveWdlCliDep(process.env), resolveWranglerDep()]); + + const packageJson = + JSON.stringify( + { + name: packageName, + version: "0.0.0", + private: true, + type: "module", + scripts: { + deploy: ns ? `wdl deploy . --ns ${ns}` : "wdl deploy .", + "dry-run": "wrangler deploy --dry-run --outdir=.deploy-dist", + }, + devDependencies: { + wrangler: wranglerDep, + "@wdl-dev/cli": wdlCliDep, + }, + }, + null, + 2 + ) + "\n"; + + const wranglerJsonc = `{ "$schema": "node_modules/wrangler/config-schema.json", "name": "${workerName}", "main": "src/index.js", @@ -211,16 +226,14 @@ async function writeStarter(targetDir, { packageName, workerName, ns }) { } `; - const indexJs = -`export default { + const indexJs = `export default { async fetch(request, env, ctx) { return new Response("Hello from ${workerName}"); }, }; `; - const gitignore = -`node_modules/ + const gitignore = `node_modules/ .deploy-dist/ .wrangler/ ${WRANGLER_WDL_TMP_PREFIX}*.json @@ -281,8 +294,8 @@ async function copyAgentsDoc(targetDir) { if (err instanceof Error && /** @type {{ code?: unknown }} */ (err).code === "ENOENT") { throw new CliError( `templates/AGENTS.md missing from the wdl-cli package. ` + - `If you installed from npm, please re-install; ` + - `if you cloned, the file should live at ${src}.`, + `If you installed from npm, please re-install; ` + + `if you cloned, the file should live at ${src}.` ); } throw err; @@ -326,16 +339,15 @@ function printNextSteps(target, { packageName, workerName, ns, isInPlace }) { /** @param {number} exitCode */ function printHelp(exitCode) { - console.log(formatHelp({ - usage: [ - "wdl init [--ns ] [--worker ]", - "wdl init --help", - ], - description: - "Scaffold a new WDL Worker project. is a directory " + - "name (creates .//) or '.' to scaffold into the current directory.", - options: optionHelp(INIT_OPTIONS), - })); + console.log( + formatHelp({ + usage: ["wdl init [--ns ] [--worker ]", "wdl init --help"], + description: + "Scaffold a new WDL Worker project. is a directory " + + "name (creates .//) or '.' to scaffold into the current directory.", + options: optionHelp(INIT_OPTIONS), + }) + ); process.exit(exitCode); } diff --git a/commands/r2.js b/commands/r2.js index 5705e26..9e4adaf 100644 --- a/commands/r2.js +++ b/commands/r2.js @@ -2,10 +2,7 @@ import { createWriteStream } from "node:fs"; import { once } from "node:events"; 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 { 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 { confirmAction } from "../lib/stdin.js"; @@ -100,15 +97,21 @@ async function runR2({ values, positionals, context: baseContext }) { if (extraArg) throw unexpectedArgument("r2 objects get", extraArg); const objectKey = requireR2ObjectKey(key); if (!values.out && isInteractiveStdout(stdoutStream)) { - throw new CliError("r2 objects get refuses to write raw object bytes to an interactive terminal; pass --out "); + throw new CliError( + "r2 objects get refuses to write raw object bytes to an interactive terminal; pass --out " + ); } const { headers } = context.resolveControl(); - const res = await context.fetchStream(objectUrl(objectKey), { - headers, - timeoutMs: LONG_CONTROL_TIMEOUT_MS, - maxBodyBytes: UNLIMITED_CONTROL_BODY_BYTES, - streamResponse: true, - }, "get R2 object"); + const res = await context.fetchStream( + objectUrl(objectKey), + { + headers, + timeoutMs: LONG_CONTROL_TIMEOUT_MS, + maxBodyBytes: UNLIMITED_CONTROL_BODY_BYTES, + streamResponse: true, + }, + "get R2 object" + ); // streamResponse: true always yields a body. const responseBody = /** @type {import("node:stream").Readable} */ (res.body); if (values.out) { @@ -125,10 +128,14 @@ async function runR2({ values, positionals, context: baseContext }) { if (extraArg) throw unexpectedArgument("r2 objects head", extraArg); const objectKey = requireR2ObjectKey(key); const { headers } = context.resolveControl(); - const res = await context.fetchStream(objectUrl(objectKey), { - method: "HEAD", - headers, - }, "head R2 object"); + const res = await context.fetchStream( + objectUrl(objectKey), + { + method: "HEAD", + headers, + }, + "head R2 object" + ); const body = objectHeadFromHeaders({ namespace: ns, bucket, @@ -152,14 +159,16 @@ async function runR2({ values, positionals, context: baseContext }) { action: `delete R2 object "${ns}/${bucket}/${objectKey}"`, }); const body = /** @type {{ namespace?: string, bucket?: string, key?: string }} */ ( - await context.fetchJson(objectUrl(objectKey), { - method: "DELETE", - headers, - }, "delete R2 object") + await context.fetchJson( + objectUrl(objectKey), + { + method: "DELETE", + headers, + }, + "delete R2 object" + ) ); - writeResult(values.json === true, body, () => [ - `OK ${body.namespace}/${body.bucket}/${body.key} deleted`, - ], stdout); + writeResult(values.json === true, body, () => [`OK ${body.namespace}/${body.bucket}/${body.key} deleted`], stdout); return; } @@ -241,7 +250,9 @@ function objectHeadFromHeaders({ namespace, bucket, key, headers }) { httpMetadata: { ...(getHeader(headers, "content-type") ? { contentType: getHeader(headers, "content-type") } : {}), ...(getHeader(headers, "content-language") ? { contentLanguage: getHeader(headers, "content-language") } : {}), - ...(getHeader(headers, "content-disposition") ? { contentDisposition: getHeader(headers, "content-disposition") } : {}), + ...(getHeader(headers, "content-disposition") + ? { contentDisposition: getHeader(headers, "content-disposition") } + : {}), ...(getHeader(headers, "content-encoding") ? { contentEncoding: getHeader(headers, "content-encoding") } : {}), ...(getHeader(headers, "cache-control") ? { cacheControl: getHeader(headers, "cache-control") } : {}), ...(getHeader(headers, "expires") ? { cacheExpiry: getHeader(headers, "expires") } : {}), diff --git a/commands/secret.js b/commands/secret.js index 9b2cc14..32a41cd 100644 --- a/commands/secret.js +++ b/commands/secret.js @@ -3,7 +3,17 @@ // "meant worker, wrote ns" credential leaks. import { defineCommand } from "../lib/command.js"; -import { CliError, defineCliOption, formatHelp, formatHttpError, isMain, isNonEmptyString, optionHelp, readJsonOrFail, unexpectedArgument } from "../lib/common.js"; +import { + CliError, + defineCliOption, + formatHelp, + formatHttpError, + isMain, + isNonEmptyString, + optionHelp, + readJsonOrFail, + unexpectedArgument, +} from "../lib/common.js"; import { confirmAction, readSecretStdin } from "../lib/stdin.js"; import { escapeTerminalText, writeJsonOr, writeStatusLine } from "../lib/output.js"; import { isSecretEnvelopeErrorCode } from "../lib/secret-envelope-errors.js"; @@ -72,7 +82,9 @@ async function runSecret({ values, positionals, context }) { if (subcommand === "list") { if (keyArg) throw unexpectedArgument("secret list", keyArg); const { headers } = context.resolveControl(); - const body = /** @type {SecretResponse} */ (await context.fetchJson(context.nsUrl(...secretPath), { headers }, "list")); + const body = /** @type {SecretResponse} */ ( + await context.fetchJson(context.nsUrl(...secretPath), { headers }, "list") + ); if (writeJsonOr(Boolean(values.json), body, stdout)) return; const keys = Array.isArray(body.keys) ? body.keys : []; if (keys.length === 0) writeStatusLine(stdout, "(no secrets)"); @@ -89,11 +101,18 @@ async function runSecret({ values, positionals, context }) { prompt: `Enter secret value for ${scopeLabel}/${keyArg} (input hidden): `, stderr, }); - const body = /** @type {SecretResponse} */ (await fetchSecretMutationJson(context, context.nsUrl(...secretPath, keyArg), { - method: "PUT", - headers: { ...headers, "content-type": "application/json" }, - body: JSON.stringify({ value }), - }, "put")); + const body = /** @type {SecretResponse} */ ( + await fetchSecretMutationJson( + context, + context.nsUrl(...secretPath, keyArg), + { + method: "PUT", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ value }), + }, + "put" + ) + ); if (writeJsonOr(Boolean(values.json), body, stdout)) return; if (hasWorker && body.version) { writeStatusLine(stdout, `✓ ${scopeLabel}/${keyArg} set — promoted ${body.previousVersion} → ${body.version}`); @@ -116,14 +135,23 @@ async function runSecret({ values, positionals, context }) { prompt: `Are you sure you want to delete secret "${scopeLabel}/${keyArg}"? [y/N] `, action: `delete secret "${scopeLabel}/${keyArg}"`, }); - const body = /** @type {SecretResponse} */ (await fetchSecretMutationJson(context, context.nsUrl(...secretPath, keyArg), { - method: "DELETE", - headers, - }, "delete")); + const body = /** @type {SecretResponse} */ ( + await fetchSecretMutationJson( + context, + context.nsUrl(...secretPath, keyArg), + { + method: "DELETE", + headers, + }, + "delete" + ) + ); if (writeJsonOr(Boolean(values.json), body, stdout)) return; if (!body.deleted) writeStatusLine(stdout, `(${keyArg} was not set)`); - else if (hasWorker && body.version) writeStatusLine(stdout, `✓ ${scopeLabel}/${keyArg} deleted — promoted ${body.previousVersion} → ${body.version}`); - else if (hasWorker) writeStatusLine(stdout, `✓ ${scopeLabel}/${keyArg} deleted — no active worker version to promote`); + else if (hasWorker && body.version) + writeStatusLine(stdout, `✓ ${scopeLabel}/${keyArg} deleted — promoted ${body.previousVersion} → ${body.version}`); + else if (hasWorker) + writeStatusLine(stdout, `✓ ${scopeLabel}/${keyArg} deleted — no active worker version to promote`); else writeStatusLine(stdout, `✓ ${scopeLabel}/${keyArg} deleted — effect on next natural cold-load`); return; } diff --git a/commands/tail.js b/commands/tail.js index 163b2f4..a65f8fe 100644 --- a/commands/tail.js +++ b/commands/tail.js @@ -6,11 +6,7 @@ import http from "node:http"; import https from "node:https"; import { defineCommand } from "../lib/command.js"; -import { - controlRequestError, - controlRequestOptions, - validateControlHeaders, -} from "../lib/control-fetch.js"; +import { controlRequestError, controlRequestOptions, validateControlHeaders } from "../lib/control-fetch.js"; import { CliError, defineCliOption, formatHelp, isMain, isNonEmptyString, optionHelp } from "../lib/common.js"; import { escapeTerminalLines, escapeTerminalText, formatDiagnosticValue } from "../lib/output.js"; @@ -25,14 +21,22 @@ const TAIL_ERROR_BODY_MAX_BYTES = 64 * 1024; export const SSE_MAX_LINE_CHARS = 1024 * 1024; // Socket-shutdown error shapes we tolerate as "our own abort". // Anything else (e.g. a 5xx racing the abort) bubbles to the user. -const ABORT_TOLERATED_ERRORS = new Set([ - "ECONNRESET", "ECONNABORTED", "EPIPE", "ABORT_ERR", -]); +const ABORT_TOLERATED_ERRORS = new Set(["ECONNRESET", "ECONNABORTED", "EPIPE", "ABORT_ERR"]); const TAIL_OPTIONS = [ defineCliOption("raw", { type: "boolean" }, "--raw", "Emit one JSON object per line (no pretty-print)."), - defineCliOption("since", { type: "string" }, "--since ", "Resume from the given Redis stream id (single-worker only)."), - defineCliOption("max-reconnects", { type: "string" }, "--max-reconnects ", `Bail after N consecutive reconnect attempts that all stayed at the ${RECONNECT_MAX_MS}ms backoff cap (default ${DEFAULT_MAX_RECONNECTS_AT_CAP}, 0 = unlimited).`), + defineCliOption( + "since", + { type: "string" }, + "--since ", + "Resume from the given Redis stream id (single-worker only)." + ), + defineCliOption( + "max-reconnects", + { type: "string" }, + "--max-reconnects ", + `Bail after N consecutive reconnect attempts that all stayed at the ${RECONNECT_MAX_MS}ms backoff cap (default ${DEFAULT_MAX_RECONNECTS_AT_CAP}, 0 = unlimited).` + ), "ns", "control", "help", @@ -145,7 +149,7 @@ async function runTail({ values, positionals, context: baseContext }) { if (!/^\d+$/.test(raw)) { throw new CliError( `--max-reconnects must be a non-negative integer (got ${formatDiagnosticValue(raw)}); ` + - `use 0 to disable the cap`, + `use 0 to disable the cap` ); } maxReconnectsAtCap = Number(raw); @@ -213,10 +217,11 @@ async function runTail({ values, positionals, context: baseContext }) { try { const hasResumeCursor = lastEventId !== null; result = await streamSse({ - url: attempts === 0 || (values.since && !hasResumeCursor) - ? initialUrl - : reconnectUrl, - headers: requestHeaders, signal: ctrl.signal, env: context.env, transport, + url: attempts === 0 || (values.since && !hasResumeCursor) ? initialUrl : reconnectUrl, + headers: requestHeaders, + signal: ctrl.signal, + env: context.env, + transport, // renderEvent writes stdout synchronously — fine for TTY // backpressure; tail isn't a guaranteed-delivery surface. onEvent: (event) => { @@ -225,9 +230,7 @@ async function runTail({ values, positionals, context: baseContext }) { }, onConnected: () => { connectedAt = now(); - stderr(attempts === 0 - ? "tail connected; waiting for events…" - : "tail reconnected; waiting for events…"); + stderr(attempts === 0 ? "tail connected; waiting for events…" : "tail reconnected; waiting for events…"); }, }); } catch (err) { @@ -256,9 +259,8 @@ async function runTail({ values, positionals, context: baseContext }) { consecutiveAtCap = 0; } if (transportErr) { - const detail = transportErr instanceof Error - ? `${transportErr.name}: ${transportErr.message}` - : String(transportErr); + const detail = + transportErr instanceof Error ? `${transportErr.name}: ${transportErr.message}` : String(transportErr); stderr(`tail transport error (${escapeTerminalText(detail)}); will reconnect`); } // Only stable sessions reset consecutiveAtCap. A flapping network can @@ -268,8 +270,8 @@ async function runTail({ values, positionals, context: baseContext }) { if (maxReconnectsAtCap > 0 && consecutiveAtCap >= maxReconnectsAtCap) { throw new CliError( `tail: gave up after ${consecutiveAtCap} consecutive reconnects ` + - `failed at the ${RECONNECT_MAX_MS}ms backoff cap ` + - `(override with --max-reconnects N, or 0 to disable)`, + `failed at the ${RECONNECT_MAX_MS}ms backoff cap ` + + `(override with --max-reconnects N, or 0 to disable)` ); } } @@ -299,7 +301,10 @@ function buildTailUrl({ baseUrl, workers, since }) { function sleep(ms, signal) { return new Promise((resolve) => { if (signal?.aborted) return resolve(); - const onAbort = () => { clearTimeout(t); resolve(); }; + const onAbort = () => { + clearTimeout(t); + resolve(); + }; const t = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); @@ -365,7 +370,9 @@ function streamSse({ url, headers, signal, env, transport, onEvent, onConnected res.on("end", () => { let detail; try { - const body = /** @type {{ message?: string, error?: string }} */ (JSON.parse(Buffer.concat(chunks).toString("utf8"))); + const body = /** @type {{ message?: string, error?: string }} */ ( + JSON.parse(Buffer.concat(chunks).toString("utf8")) + ); detail = escapeTerminalText(body.message || body.error || `HTTP ${status}`); } catch { detail = `HTTP ${status}`; @@ -424,7 +431,9 @@ function streamSse({ url, headers, signal, env, transport, onEvent, onConnected // Drop the session-long signal listener once this connection settles so a // flapping reconnect loop doesn't accumulate one closure per attempt. return signal - ? promise.finally(() => { if (onAbort) signal.removeEventListener("abort", onAbort); }) + ? promise.finally(() => { + if (onAbort) signal.removeEventListener("abort", onAbort); + }) : promise; } @@ -474,8 +483,10 @@ export class SseParser { if (line.startsWith(":")) return; // comment const colon = line.indexOf(":"); let field, value; - if (colon < 0) { field = line; value = ""; } - else { + if (colon < 0) { + field = line; + value = ""; + } else { field = line.slice(0, colon); value = line.slice(colon + 1); if (value.startsWith(" ")) value = value.slice(1); @@ -520,16 +531,17 @@ function renderEvent({ event, raw, stdout, stderr, isMultiWorker }) { let payload; try { const decoded = JSON.parse(event.data); - payload = decoded && typeof decoded === "object" && !Array.isArray(decoded) - ? /** @type {TailPayload} */ (decoded) - : { event: event.event, raw: decoded }; + payload = + decoded && typeof decoded === "object" && !Array.isArray(decoded) + ? /** @type {TailPayload} */ (decoded) + : { event: event.event, raw: decoded }; } catch { payload = { event: event.event, raw: event.data }; } const eventType = payload.event || event.event; - const isServerRecycle = eventType === "tail_warning" && - (payload.code === "session_idle" || payload.code === "session_expired"); + const isServerRecycle = + eventType === "tail_warning" && (payload.code === "session_idle" || payload.code === "session_expired"); if (raw) { stdout(JSON.stringify(payload)); @@ -543,16 +555,16 @@ function renderEvent({ event, raw, stdout, stderr, isMultiWorker }) { // newlines but every line is escaped. if (eventType === "tail_warning") { if (isServerRecycle) { - stderr(`tail ${escapeTerminalText(payload.code)}: ${escapeTerminalText(payload.message || "session closed by control")}`); + stderr( + `tail ${escapeTerminalText(payload.code)}: ${escapeTerminalText(payload.message || "session closed by control")}` + ); return "server-recycle"; } stderr(`! tail_warning ${escapeTerminalText(payload.code || "")}: ${escapeTerminalText(payload.message || "")}`); return; } - const ts = typeof payload.ts === "number" - ? new Date(payload.ts).toISOString() - : new Date().toISOString(); + const ts = typeof payload.ts === "number" ? new Date(payload.ts).toISOString() : new Date().toISOString(); const prefix = isMultiWorker && payload.worker ? `[${escapeTerminalText(payload.worker)}] ` : ""; if (eventType === "worker_console") { @@ -648,14 +660,16 @@ function formatConsoleArgs(message) { function stringifyMessage(value) { if (value === null || value === undefined) return ""; if (typeof value === "string") return value; - try { return JSON.stringify(value); } catch { return String(value); } + try { + return JSON.stringify(value); + } catch { + return String(value); + } } function usageText() { return formatHelp({ - usage: [ - "wdl tail [...] [options]", - ], + usage: ["wdl tail [...] [options]"], description: "Live-tail worker console, exception, fetch, scheduled, and queue events in a namespace.", options: optionHelp(TAIL_OPTIONS), }); diff --git a/commands/token.js b/commands/token.js index 70a7b49..f6d3313 100644 --- a/commands/token.js +++ b/commands/token.js @@ -17,7 +17,12 @@ import { readTokenStore, tokenStorePath, updateTokenStore } from "../lib/token-s const TOKEN_OPTIONS = [ defineCliOption("label", { type: "string" }, "--label ", "Human label shown by `wdl token list` (set)."), - defineCliOption("default", { type: "boolean" }, "--default", "Make this the default namespace, used when --ns is omitted (set)."), + defineCliOption( + "default", + { type: "boolean" }, + "--default", + "Make this the default namespace, used when --ns is omitted (set)." + ), // Custom ns option: set/use/rm mutate the global store and ignore ambient // WDL_NS, so the shared preset's "(env: WDL_NS)" wording would mislead here. defineCliOption("ns", { type: "string" }, "--ns ", "Namespace for set/use/rm (required; ignores WDL_NS)."), @@ -96,10 +101,12 @@ async function tokenSet({ values, context }) { // that sends the token. warnIfInsecureControlUrl(controlUrl, context.warn, context.env); - const token = (await readSecretStdin(context.stdin, { - prompt: `Token for ${ns} @ ${controlUrl} (input hidden): `, - stderr: context.stderr, - })).trim(); + const token = ( + await readSecretStdin(context.stdin, { + prompt: `Token for ${ns} @ ${controlUrl} (input hidden): `, + stderr: context.stderr, + }) + ).trim(); if (!token) throw new CliError("no token provided on stdin"); // Validate before storing so a typo'd or revoked token is never persisted, @@ -168,13 +175,15 @@ function tokenUse({ values, context, nsArg }) { /** @param {{ values: TokenValues, context: import("../lib/command.js").CommandContext }} arg */ function tokenList({ values, context }) { const store = readTokenStore(tokenStorePath(context.env)); - const rows = Object.keys(store.namespaces).sort().map((ns) => ({ - default: store.defaultNs === ns, - namespace: ns, - label: store.namespaces[ns].LABEL || "", - controlUrl: store.namespaces[ns].CONTROL_URL || "", - token: maskToken(store.namespaces[ns].ADMIN_TOKEN), - })); + const rows = Object.keys(store.namespaces) + .sort() + .map((ns) => ({ + default: store.defaultNs === ns, + namespace: ns, + label: store.namespaces[ns].LABEL || "", + controlUrl: store.namespaces[ns].CONTROL_URL || "", + token: maskToken(store.namespaces[ns].ADMIN_TOKEN), + })); writeResult(Boolean(values.json), rows, () => formatTokenList(rows), context.stdout); } @@ -244,16 +253,17 @@ function formatTokenList(rows) { const header = ["", "NAMESPACE", "LABEL", "CONTROL URL", "TOKEN"]; const cells = [ header, - ...rows.map((r) => [ - r.default ? "*" : "", - r.namespace, - r.label, - r.controlUrl, - r.token, - ].map((cell) => escapeTerminalText(cell))), + ...rows.map((r) => + [r.default ? "*" : "", r.namespace, r.label, r.controlUrl, r.token].map((cell) => escapeTerminalText(cell)) + ), ]; const widths = header.map((_, col) => Math.max(...cells.map((l) => l[col].length))); - const lines = cells.map((l) => l.map((cell, col) => cell.padEnd(widths[col])).join(" ").trimEnd()); + const lines = cells.map((l) => + l + .map((cell, col) => cell.padEnd(widths[col])) + .join(" ") + .trimEnd() + ); if (rows.some((r) => r.default)) lines.push("", "* default namespace (used when --ns is omitted)"); return lines; } diff --git a/commands/whoami.js b/commands/whoami.js index fd22e53..d7cbbe8 100644 --- a/commands/whoami.js +++ b/commands/whoami.js @@ -30,15 +30,22 @@ export const meta = command.meta; async function runWhoami({ values, positionals, context }) { if (positionals.length > 0) throw new CliError(usageText()); - const state = resolveCliConfigState({ values, env: context.env, cwd: context.cwd, warn: context.warn }); + const state = resolveCliConfigState({ + values, + env: context.env, + cwd: context.cwd, + warn: context.warn, + }); const control = ensureControlContextFromConfigState(state); warnIfInsecureControlUrl(control.controlUrl, context.warn, state.env); - const remote = summarizeWhoami(await fetchWhoami({ - controlUrl: control.controlUrl, - headers: control.headers, - controlFetch: context.controlFetch, - env: state.env, - })); + const remote = summarizeWhoami( + await fetchWhoami({ + controlUrl: control.controlUrl, + headers: control.headers, + controlFetch: context.controlFetch, + env: state.env, + }) + ); const body = buildWhoamiBody(state, remote); writeResult(values.json === true, body, () => formatWhoami(body), context.stdout); } @@ -93,7 +100,9 @@ function formatWhoami(body) { `Assets URL: ${displayRemoteValue(body.urls.assets)}`, ]; if (!body.namespace.matchesConfigured) { - lines.push(`Configured NS: ${displayRemoteValue(body.namespace.configured)} (token principal is ${displayRemoteValue(body.namespace.value)})`); + lines.push( + `Configured NS: ${displayRemoteValue(body.namespace.configured)} (token principal is ${displayRemoteValue(body.namespace.value)})` + ); } return lines; } diff --git a/commands/workflows.js b/commands/workflows.js index 1257ea0..98fb025 100644 --- a/commands/workflows.js +++ b/commands/workflows.js @@ -1,24 +1,25 @@ import { defineCommand } from "../lib/command.js"; import { CliError, defineCliOption, formatHelp, isMain, 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"; +import { escapeTerminalText, writeJsonOr, writeResult, writeStatusLine } from "../lib/output.js"; +import { formatInstanceList, formatInstanceStatus, formatWorkflowList } from "../lib/workflows-format.js"; const LIFECYCLE_ACTIONS = new Set(["pause", "resume", "restart", "terminate"]); const WORKFLOW_OPTIONS = [ defineCliOption("limit", { type: "string" }, "--limit ", "Instance page size (default 100, max 1000)."), defineCliOption("cursor", { type: "string" }, "--cursor ", "Opaque instances pagination cursor."), - defineCliOption("include-steps", { type: "boolean" }, "--include-steps", "Include bounded step history in status output."), - defineCliOption("step-limit", { type: "string" }, "--step-limit ", "Step history page size (default 100, max 1000)."), + defineCliOption( + "include-steps", + { type: "boolean" }, + "--include-steps", + "Include bounded step history in status output." + ), + defineCliOption( + "step-limit", + { type: "string" }, + "--step-limit ", + "Step history page size (default 100, max 1000)." + ), defineCliOption("yes", { type: "boolean" }, "--yes", "Confirm restart or terminate."), "ns", "control", @@ -62,9 +63,10 @@ async function runWorkflows({ values, positionals, context }) { const url = new URL(context.nsUrl("workflows", worker, workflow, "instances")); if (values.limit) url.searchParams.set("limit", values.limit); if (values.cursor) url.searchParams.set("cursor", values.cursor); - const body = /** @type {{ instances?: import("../lib/workflows-format.js").WorkflowInstance[], cursor?: string }} */ ( - await context.fetchJson(url.href, { headers }, "list workflow instances") - ); + const body = + /** @type {{ instances?: import("../lib/workflows-format.js").WorkflowInstance[], cursor?: string }} */ ( + await context.fetchJson(url.href, { headers }, "list workflow instances") + ); writeResult(Boolean(values.json), body, () => formatInstanceList(body), stdout); return; } @@ -97,11 +99,13 @@ async function runWorkflows({ values, positionals, context }) { action: `${subcommand} workflow instance "${ns}/${worker}/${workflow}/${instanceId}"`, }); } - const body = /** @type {{ id?: string, status?: string }} */ (await context.fetchJson( - context.nsUrl("workflows", worker, workflow, "instances", instanceId, subcommand), - { method: "POST", headers }, - `${subcommand} workflow instance`, - )); + const body = /** @type {{ id?: string, status?: string }} */ ( + await context.fetchJson( + context.nsUrl("workflows", worker, workflow, "instances", instanceId, subcommand), + { method: "POST", headers }, + `${subcommand} workflow instance` + ) + ); if (writeJsonOr(Boolean(values.json), body, stdout)) return; writeStatusLine( stdout, diff --git a/docs/cron-triggers.md b/docs/cron-triggers.md index 4b7e31a..4fadbd9 100644 --- a/docs/cron-triggers.md +++ b/docs/cron-triggers.md @@ -95,11 +95,11 @@ combinations: - ❌ Assuming cron fires exactly on minute boundaries. There is jitter; do not depend on sub-minute timing. - ❌ Relying on missed runs being replayed. Cron is Cloudflare-style - best-effort: minute-aligned slots fire at most once each, slots missed - during downtime are skipped (never replayed), consecutive runs may overlap - if a handler outlasts its slot, and a handler failure is recorded as the - outcome — the scheduler does not retry it. `event.scheduledTime` is the - slot timestamp, not the dispatch time. + best-effort: minute-aligned slots fire at most once each, slots missed during + downtime are skipped (never replayed), consecutive runs may overlap if a + handler outlasts its slot, and a handler failure is recorded as the outcome — + the scheduler does not retry it. `event.scheduledTime` is the slot timestamp, + not the dispatch time. ## Local development diff --git a/docs/d1.md b/docs/d1.md index c37bcbf..867a7df 100644 --- a/docs/d1.md +++ b/docs/d1.md @@ -126,10 +126,10 @@ combination locally. D1 requests are bounded before execution: the binary query body is capped at 8 MiB; a decoded request can contain at most 1000 SQL statements and 8 MiB of aggregate SQL plus params; each statement returns at most 65,536 rows -(Cloudflare D1 compatibility — overflow maps to `limit-exceeded`); result -bodies are protected by the platform default 16 MiB aggregate cap. Multi-statement `exec()` runs in one SQLite transaction; -if a later statement fails, the statements already executed in that `exec()` -call are rolled back. +(Cloudflare D1 compatibility — overflow maps to `limit-exceeded`); result bodies +are protected by the platform default 16 MiB aggregate cap. Multi-statement +`exec()` runs in one SQLite transaction; if a later statement fails, the +statements already executed in that `exec()` call are rolled back. D1 migration management uses the control-plane JSON request parser, so `wdl d1 migrations status/apply` request bodies are capped at 1 MiB. Split very @@ -140,10 +140,10 @@ control. Control-plane D1 lifecycle APIs redact 5xx failures forwarded from the D1 runtime/backend to `Internal error` and expose only bounded machine classifiers -(`upstreamCode`, `upstreamCategory`, `upstreamRetryable`, and -`upstreamStatus`). Control-generated contention and collision 503 responses -retain actionable messages. Outward 4xx SQL and migration failures can retain -actionable D1 diagnostics. +(`upstreamCode`, `upstreamCategory`, `upstreamRetryable`, and `upstreamStatus`). +Control-generated contention and collision 503 responses retain actionable +messages. Outward 4xx SQL and migration failures can retain actionable D1 +diagnostics. SQLite object names beginning with `_cf_` are reserved by workerd, case-insensitively. Do not create or rename application tables, indexes, diff --git a/docs/deploy.md b/docs/deploy.md index c8e5a31..d3aa6d2 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -34,15 +34,15 @@ resolved. The CLI needs three values: -| Value | Purpose | -| ------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| `ADMIN_TOKEN` | Tenant deploy token. **Sensitive** — never paste it into chat or commit history. | -| `WDL_NS` | Tenant namespace, e.g. `acme`, `demo-prod`. | +| Value | Purpose | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ADMIN_TOKEN` | Tenant deploy token. **Sensitive** — never paste it into chat or commit history. | +| `WDL_NS` | Tenant namespace, e.g. `acme`, `demo-prod`. | | `CONTROL_URL` | Control-plane URL — from your operator, or your own self-hosted platform (e.g. `https://api.wdl.dev`). The CLI has no built-in default; it must be configured. | **Recommended path:** `wdl token set --ns --control-url ` reads the -token at a hidden prompt, validates it against `/whoami`, and stores it `0600` in -`~/.config/wdl/credentials` — so it never lands in a project file or shell +token at a hidden prompt, validates it against `/whoami`, and stores it `0600` +in `~/.config/wdl/credentials` — so it never lands in a project file or shell history. The first stored namespace becomes the default, so later `wdl deploy` needs no `--ns`. One store serves every project on the machine; see [token.md](./token.md). @@ -50,8 +50,8 @@ needs no `--ns`. One store serves every project on the machine; see **Per-repo alternative:** when a project should carry its own control URL / namespace, copy `.env.example` to `.env` and fill in the `[]` section (the committed `.env.example` also documents the shape for teammates). The CLI reads -only `./.env` from the directory you run `wdl` in (there is no upward search), so -run `wdl` from the directory that holds it. The token stays in the gitignored +only `./.env` from the directory you run `wdl` in (there is no upward search), +so run `wdl` from the directory that holds it. The token stays in the gitignored `.env`, never committed. **CI / automation:** inject `ADMIN_TOKEN`, `CONTROL_URL`, and `WDL_NS` as @@ -62,17 +62,17 @@ 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. -Precedence: `CLI flag > shell env > .env [] section > .env base section > wdl -token store`. If none supplies a value, the command fails — there is no built-in -default. +Precedence: +`CLI flag > shell env > .env [] section > .env base section > wdl token store`. +If none supplies a value, the command fails — there is no built-in default. **Untrusted projects:** `wdl deploy` runs the project's local Wrangler dry-run and build hooks as your OS user, so that code can read the on-disk token store (the credential scrub only keeps WDL variables out of the Wrangler child's environment, not out of the file). Only deploy projects you trust. For an untrusted or third-party project, pass an ephemeral `--token` / `--control-url` -plus `--no-token-store` (or `WDL_TOKEN_STORE=off`) so the CLI ignores the store — -and don't keep a global store at all, since the flag opts out of *reading* the +plus `--no-token-store` (or `WDL_TOKEN_STORE=off`) so the CLI ignores the store +— and don't keep a global store at all, since the flag opts out of _reading_ the file, not its presence on disk. See [token.md](./token.md). When unsure which value won, run `wdl config explain`; to confirm which control @@ -80,8 +80,8 @@ the token actually reaches, plus the principal, platform version, and URL hints, run `wdl whoami`; for baseline local and remote diagnostics, run `wdl doctor`. When the control plane supports `/whoami`, `doctor` verifies the remote token, principal namespace, platform version, and CLI compatibility. Use -`wdl doctor --strict` in CI when a failed check should make the job fail. -The namespace URL may be `(unavailable)` when the operator has not configured a +`wdl doctor --strict` in CI when a failed check should make the job fail. The +namespace URL may be `(unavailable)` when the operator has not configured a public platform domain; that does not mean authentication failed. For runtime secrets (distinct from `ADMIN_TOKEN`), see @@ -125,15 +125,15 @@ changes only the control socket target and never a printed Worker origin. | Delete a worker (preview) | `wdl delete worker --dry-run` | | Inspect Workflow instances | `wdl workflows instances ` | -`--ns` is optional whenever `WDL_NS` is set via env or `.env`, or the `wdl token` -store has a default namespace. Every subcommand implements `--help` — run it when -you don't know which flag to use. +`--ns` is optional whenever `WDL_NS` is set via env or `.env`, or the +`wdl token` store has a default namespace. Every subcommand implements `--help` +— run it when you don't know which flag to use. ## Standard deploy flow 1. **Resolve the CLI invocation form** (above). -2. **Resolve credentials** — for a trusted project, prefer `.env` or the `wdl - token` store; do not inline environment variables. For an untrusted or +2. **Resolve credentials** — for a trusted project, prefer `.env` or the + `wdl token` store; do not inline environment variables. For an untrusted or third-party project, use an ephemeral `--token` / `--control-url` with `--no-token-store` instead (see Credentials above — deploy runs project code as you). @@ -182,9 +182,8 @@ names like `my-worker-preview`; WDL does not append that suffix. See ## Supported / unsupported wrangler configuration When multiple Wrangler config files exist, the CLI follows Wrangler's priority: -`wrangler.json`, then `wrangler.jsonc`, then `wrangler.toml`. -Both JSON filenames use Wrangler's JSONC syntax, including comments and -trailing commas. +`wrangler.json`, then `wrangler.jsonc`, then `wrangler.toml`. Both JSON +filenames use Wrangler's JSONC syntax, including comments and trailing commas. New projects should keep the `2026-06-17` compatibility date unless a feature requires a newer one. Explicit dates before `2026-04-01`, invalid or future @@ -192,19 +191,19 @@ dates, and dates newer than the bundled workerd supports are rejected by control. Upstream experimental enable flags, `legacy_error_serialization`, and `allow_irrevocable_stub_storage` are unsupported. -**Supported:** `name`, `main`, `compatibility_date` / `compatibility_flags`, `[vars]`, -`[[kv_namespaces]]`, `[[d1_databases]]`, `[[durable_objects.bindings]]`, -`[[workflows]]`, `[[r2_buckets]]`, `[assets] directory`, `[triggers] crons`, -`[[triggers.schedules]]` (with timezone, a platform extension), -`[[queues.producers]]` / `[[queues.consumers]]`, `[[services]]`, -`[[platform_bindings]]`, `[[exports]]`, `route` / `routes`, `workers_dev`, -`[env.]`. +**Supported:** `name`, `main`, `compatibility_date` / `compatibility_flags`, +`[vars]`, `[[kv_namespaces]]`, `[[d1_databases]]`, +`[[durable_objects.bindings]]`, `[[workflows]]`, `[[r2_buckets]]`, +`[assets] directory`, `[triggers] crons`, `[[triggers.schedules]]` (with +timezone, a platform extension), `[[queues.producers]]` / +`[[queues.consumers]]`, `[[services]]`, `[[platform_bindings]]`, `[[exports]]`, +`route` / `routes`, `workers_dev`, `[env.]`. -WDL parses `[[exports]]`, `[[platform_bindings]]`, -`[[triggers.schedules]]`, and `[[services]].ns` itself and removes these private -extensions from the temporary config passed to the Wrangler bundler. Other -fields retain their existing Wrangler passthrough behavior. Wrangler's -object-shaped declarative `exports` configuration is not supported by WDL. +WDL parses `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, and +`[[services]].ns` itself and removes these private extensions from the temporary +config passed to the Wrangler bundler. Other fields retain their existing +Wrangler passthrough behavior. Wrangler's object-shaped declarative `exports` +configuration is not supported by WDL. ### Service bindings and delegated capabilities @@ -220,16 +219,14 @@ current Worker — not full Cloudflare Workflows parity; `script_name`, cross-worker workflows, cross-worker callbacks, service-binding callbacks, and the Cloudflare source-AST visualizer are not supported. `route` / `routes` are supported only when the operator enables them. Python Workers modules, -unsupported workerd compatibility flags, and WDL-reserved injected module -names are rejected during deploy: the CLI fails fast on local `.py` modules, -and the control plane is canonical for workerd compatibility and bundle-shape -policy. +unsupported workerd compatibility flags, and WDL-reserved injected module names +are rejected during deploy: the CLI fails fast on local `.py` modules, and the +control plane is canonical for workerd compatibility and bundle-shape policy. Top-level or selected-environment Wrangler runtime/deploy config fields and sections that WDL would otherwise ignore are also rejected by the CLI, including legacy `[site]` Workers Sites, `pages_build_output_dir`, `observability`, `limits`, `placement`, and other unsupported binding/config fields or sections -named in the error. -`assets.run_worker_first` is silently ignored. +named in the error. `assets.run_worker_first` is silently ignored. Cron triggers and queue consumers are runtime dispatch features; declare them only on routeable tenant Workers. Workers selected through @@ -244,36 +241,36 @@ consumers. run it first (or do a read-only check), then add `--yes` only after confirming with the user. Do **not** add `--yes` on your own. -`wdl workers` reports `workflow-defs=yes` or `workflow-defs=no`; `unknown` -means an older control omitted the field, not that no definitions exist. Worker -delete dry-runs report secret and workflow-definition presence even when a -blocker makes `wouldDelete=no`. +`wdl workers` reports `workflow-defs=yes` or `workflow-defs=no`; `unknown` means +an older control omitted the field, not that no definitions exist. Worker delete +dry-runs report secret and workflow-definition presence even when a blocker +makes `wouldDelete=no`. Deleting a worker does **not** delete R2 data — see [r2.md](./r2.md). ## Common errors -| Symptom | Cause / fix | -| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `wdl: command not found` | The CLI is not on PATH. Inside the wdl-cli repo use `node /bin/wdl.js`; otherwise run `npm i -g @wdl-dev/cli`. | -| `Missing admin token` | No token resolved. Run `wdl token set --ns --control-url ` (recommended), or set `ADMIN_TOKEN` / pass `--token` / use the `[]` section of `.env`. | -| `401 unknown_token: unauthorized` | The token is invalid for this control plane / namespace. Re-check `ADMIN_TOKEN`. | -| `[vars] must be an object` | Use a `[vars]` table/object; arrays are invalid. | -| `[vars] : only string/number/boolean values are supported` | Remove nested values; move sensitive strings to a secret. | -| `binding name collision: ` | `[vars]`, explicit bindings, or the implicit `ASSETS` binding reused a runtime env name. Rename one of them. | -| `experimental_compat_flag_unsupported` | Remove the experimental workerd compatibility flag. | -| `compatibility_flag_unsupported` | Remove the unsupported compatibility flag named by control. | -| `python_workers_unsupported` | Python Workers are not supported by WDL; remove Python Worker modules. The CLI also fails fast on local `.py` modules. | -| `worker_env_too_large` | Reduce `[vars]`, secrets, or binding metadata; redeploy/delete any retained version named in the error. | -| `worker_code_too_large` | Reduce generated Worker code size or split the worker. | -| `worker_code_invalid` | Fix the Worker bundle shape reported by the control plane, including WDL-reserved injected module names. | -| `wrangler build failed` | Run `npx wrangler deploy --dry-run` inside the project and fix it there. | -| Deploy succeeds but promote fails | Custom host or service-binding target validation issue; check the binding targets. | -| Worker URL returns 404 | The URL is missing the `/` segment. | -| `wdl tail` has no history | Tail is live-only; open `wdl tail ` before triggering the request. | -| `tail session_idle` / `tail session_expired` | Control reclaimed the live-tail stream; the CLI reconnects automatically unless the reconnect cap is reached. | -| Namespace secret did not take effect | NS-level secrets do not force-bump workers; redeploy once or use a worker-level secret. | -| Service binding still hits the old target | Bindings are pinned at caller deploy time; redeploy the caller. | +| Symptom | Cause / fix | +| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `wdl: command not found` | The CLI is not on PATH. Inside the wdl-cli repo use `node /bin/wdl.js`; otherwise run `npm i -g @wdl-dev/cli`. | +| `Missing admin token` | No token resolved. Run `wdl token set --ns --control-url ` (recommended), or set `ADMIN_TOKEN` / pass `--token` / use the `[]` section of `.env`. | +| `401 unknown_token: unauthorized` | The token is invalid for this control plane / namespace. Re-check `ADMIN_TOKEN`. | +| `[vars] must be an object` | Use a `[vars]` table/object; arrays are invalid. | +| `[vars] : only string/number/boolean values are supported` | Remove nested values; move sensitive strings to a secret. | +| `binding name collision: ` | `[vars]`, explicit bindings, or the implicit `ASSETS` binding reused a runtime env name. Rename one of them. | +| `experimental_compat_flag_unsupported` | Remove the experimental workerd compatibility flag. | +| `compatibility_flag_unsupported` | Remove the unsupported compatibility flag named by control. | +| `python_workers_unsupported` | Python Workers are not supported by WDL; remove Python Worker modules. The CLI also fails fast on local `.py` modules. | +| `worker_env_too_large` | Reduce `[vars]`, secrets, or binding metadata; redeploy/delete any retained version named in the error. | +| `worker_code_too_large` | Reduce generated Worker code size or split the worker. | +| `worker_code_invalid` | Fix the Worker bundle shape reported by the control plane, including WDL-reserved injected module names. | +| `wrangler build failed` | Run `npx wrangler deploy --dry-run` inside the project and fix it there. | +| Deploy succeeds but promote fails | Custom host or service-binding target validation issue; check the binding targets. | +| Worker URL returns 404 | The URL is missing the `/` segment. | +| `wdl tail` has no history | Tail is live-only; open `wdl tail ` before triggering the request. | +| `tail session_idle` / `tail session_expired` | Control reclaimed the live-tail stream; the CLI reconnects automatically unless the reconnect cap is reached. | +| Namespace secret did not take effect | NS-level secrets do not force-bump workers; redeploy once or use a worker-level secret. | +| Service binding still hits the old target | Bindings are pinned at caller deploy time; redeploy the caller. | ## Anti-patterns diff --git a/docs/queues.md b/docs/queues.md index 20b2185..de4e108 100644 --- a/docs/queues.md +++ b/docs/queues.md @@ -57,15 +57,15 @@ and leave it to the platform's `max_retries` and `dead_letter_queue` handling. - The default body type is JSON; use `{ contentType: "text" }` for strings and `{ contentType: "bytes" }` for binary. - `delivery_delay` and `send(..., { delaySeconds })` delay delivery. -- `retry_delay` and `message.retry({ delaySeconds })` delay retries; an - explicit `delaySeconds` overrides the consumer's `retry_delay`, including `0` - for an immediate retry. +- `retry_delay` and `message.retry({ delaySeconds })` delay retries; an explicit + `delaySeconds` overrides the consumer's `retry_delay`, including `0` for an + immediate retry. - `message.attempts` starts at 1, so `max_retries = N` means the handler observes at most N + 1 attempts before the message moves to the dead-letter queue. - The dead-letter queue is a bounded diagnostic channel (about 10k entries by - default, approximately trimmed) — drain it promptly rather than treating it - as a durable archive. + default, approximately trimmed) — drain it promptly rather than treating it as + a durable archive. - The CLI forwards `max_batch_timeout` values that pass basic integer delay parsing for config compatibility; WDL control enforces the tighter Cloudflare-compatible 0..60 second range. Do not rely on it for full diff --git a/docs/secrets.md b/docs/secrets.md index 57a9e2e..b30f252 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -75,13 +75,13 @@ retry after the operator reports the envelope issue repaired. ## Constraints -- Keys must follow environment-variable grammar: `[A-Za-z_][A-Za-z0-9_]*`, - with a maximum length of 128 characters. Runtime-reserved names and reserved +- Keys must follow environment-variable grammar: `[A-Za-z_][A-Za-z0-9_]*`, with + a maximum length of 128 characters. Runtime-reserved names and reserved `Object.prototype` keys are rejected. - Values are limited to 64 KiB. - Secrets count toward the workerLoader env budget together with `[vars]` and - binding metadata. If a mutation returns `worker_env_too_large`, reduce the - env payload or redeploy/delete the retained version named in the error. + binding metadata. If a mutation returns `worker_env_too_large`, reduce the env + payload or redeploy/delete the retained version named in the error. ## Reading in the Worker diff --git a/docs/token.md b/docs/token.md index 04c11af..e771636 100644 --- a/docs/token.md +++ b/docs/token.md @@ -97,10 +97,10 @@ stored token to a host it chose. `wdl deploy` runs the project's local Wrangler dry-run and any build commands or dependency hooks **as your OS user**, before uploading. Scrubbing `ADMIN_TOKEN` and the control-plane variables from that child's environment only closes the -*environment* path — it is not a sandbox. The on-disk store at +_environment_ path — it is not a sandbox. The on-disk store at `~/.config/wdl/credentials` stays readable by that code, the same way -`~/.aws/credentials` or `~/.npmrc` would. So a malicious project can read it, and -because the store can hold tokens for **several namespaces**, one untrusted +`~/.aws/credentials` or `~/.npmrc` would. So a malicious project can read it, +and because the store can hold tokens for **several namespaces**, one untrusted deploy can exfiltrate tokens for namespaces unrelated to that project. - Only run `wdl deploy` on projects you trust. @@ -108,11 +108,11 @@ deploy can exfiltrate tokens for namespaces unrelated to that project. ephemeral `ADMIN_TOKEN` / `CONTROL_URL` from your shell or `--token` / `--control-url` scoped to that one namespace, and ideally use a dedicated OS user or a container. -- `--no-token-store` (or `WDL_TOKEN_STORE=off`) makes the CLI resolve credentials - from flags / env / `.env` only and never read the store. This is a resolution - opt-out, **not** protection for the file — the bytes on disk are still readable - by project code. The protection comes from not having a store present, not from - the flag. +- `--no-token-store` (or `WDL_TOKEN_STORE=off`) makes the CLI resolve + credentials from flags / env / `.env` only and never read the store. This is a + resolution opt-out, **not** protection for the file — the bytes on disk are + still readable by project code. The protection comes from not having a store + present, not from the flag. ## Anti-patterns diff --git a/docs/workflows.md b/docs/workflows.md index 5085d6e..01c2f9b 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -83,9 +83,8 @@ Semantic size limits in the Workflows API return `request_too_large`; size limits hit during HTTP body parsing may return `request_body_too_large`. A Workflows 5xx means a platform or backend failure, and the response body stays a generic error summary; underlying diagnostics go to platform logs and are not -stable CLI output. -`workflow_metadata_contention` means the active workflow metadata changed while -control was reading it; retry the command. +stable CLI output. `workflow_metadata_contention` means the active workflow +metadata changed while control was reading it; retry the command. ## End-to-end example diff --git a/eslint.config.js b/eslint.config.js index 4a52763..3ae3abb 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -32,20 +32,10 @@ const commonRules = { export default [ js.configs.recommended, { - ignores: [ - "**/node_modules/**", - "**/.deploy-dist/**", - "**/.wrangler/**", - ], + 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", "tests/**/*.js", "eslint.config.js"], languageOptions: { ecmaVersion: 2024, sourceType: "module", diff --git a/examples/d1-demo/src/index.js b/examples/d1-demo/src/index.js index c9ddc98..1821b97 100644 --- a/examples/d1-demo/src/index.js +++ b/examples/d1-demo/src/index.js @@ -10,11 +10,11 @@ export default { ON CONFLICT(name) DO UPDATE SET count = count + 1, updated_at = CURRENT_TIMESTAMP` - ).bind(name).run(); + ) + .bind(name) + .run(); - const current = await env.DB.prepare( - "SELECT count FROM visits WHERE name = ?1" - ).bind(name).first(); + const current = await env.DB.prepare("SELECT count FROM visits WHERE name = ?1").bind(name).first(); const { results } = await env.DB.prepare( "SELECT name, count FROM visits ORDER BY count DESC, name ASC LIMIT 20" @@ -24,9 +24,7 @@ export default { greeting: env.GREETING, you: name, visits: current?.count || 0, - leaderboard: Object.fromEntries( - results.map((row) => [row.name, row.count]) - ), + leaderboard: Object.fromEntries(results.map((row) => [row.name, row.count])), }); }, }; diff --git a/examples/durable-objects-demo/src/index.js b/examples/durable-objects-demo/src/index.js index fc9427c..eac3c61 100644 --- a/examples/durable-objects-demo/src/index.js +++ b/examples/durable-objects-demo/src/index.js @@ -13,17 +13,12 @@ export class Room extends DurableObject { } ensureSchema() { - this.ctx.storage.sql.exec( - "CREATE TABLE IF NOT EXISTS counters (name TEXT PRIMARY KEY, value INTEGER NOT NULL)" - ); + this.ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS counters (name TEXT PRIMARY KEY, value INTEGER NOT NULL)"); } readCounter(name) { this.ensureSchema(); - const rows = this.ctx.storage.sql.exec( - "SELECT value FROM counters WHERE name = ?", - name - ); + const rows = this.ctx.storage.sql.exec("SELECT value FROM counters WHERE name = ?", name); return [...rows][0]?.value ?? 0; } diff --git a/examples/hello-jsonc/wrangler.jsonc b/examples/hello-jsonc/wrangler.jsonc index 1871896..c1c9125 100644 --- a/examples/hello-jsonc/wrangler.jsonc +++ b/examples/hello-jsonc/wrangler.jsonc @@ -7,6 +7,6 @@ // Proves the deploy CLI handles wrangler.jsonc (comments + JSON) the same // way it handles wrangler.toml. No bindings — this is the minimal demo. "vars": { - "GREETING": "hello from jsonc" - } + "GREETING": "hello from jsonc", + }, } diff --git a/examples/inspection-demo/public/app.js b/examples/inspection-demo/public/app.js index 75ac32d..9b56c37 100644 --- a/examples/inspection-demo/public/app.js +++ b/examples/inspection-demo/public/app.js @@ -28,7 +28,9 @@ function renderItems(items) { return; } - listEl.innerHTML = items.map((item) => ` + listEl.innerHTML = items + .map( + (item) => ` - `).join(""); + ` + ) + .join(""); } function escapeHtml(value) { diff --git a/examples/inspection-demo/public/style.css b/examples/inspection-demo/public/style.css index 05c1836..1e31665 100644 --- a/examples/inspection-demo/public/style.css +++ b/examples/inspection-demo/public/style.css @@ -9,7 +9,14 @@ --accent-strong: #0b5f59; --danger: #b42318; --ok: #146c43; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; } * { diff --git a/examples/inspection-demo/src/index.js b/examples/inspection-demo/src/index.js index bce6753..ed804fb 100644 --- a/examples/inspection-demo/src/index.js +++ b/examples/inspection-demo/src/index.js @@ -26,12 +26,14 @@ function escapeHtml(value) { } function safeFileName(name) { - return String(name || "upload") - .replaceAll("/", "-") - .replaceAll("\\", "-") - .replace(/[^\w. -]/g, "") - .trim() - .slice(0, 80) || "upload"; + return ( + String(name || "upload") + .replaceAll("/", "-") + .replaceAll("\\", "-") + .replace(/[^\w. -]/g, "") + .trim() + .slice(0, 80) || "upload" + ); } async function ensureSchema(env) { @@ -39,17 +41,14 @@ async function ensureSchema(env) { } async function incrementCounter(env, key) { - const current = Number.parseInt(await env.COUNTERS.get(key) || "0", 10) || 0; + const current = Number.parseInt((await env.COUNTERS.get(key)) || "0", 10) || 0; const next = current + 1; await env.COUNTERS.put(key, String(next)); return next; } async function counters(env) { - const [visits, submissions] = await Promise.all([ - env.COUNTERS.get("visits"), - env.COUNTERS.get("submissions"), - ]); + const [visits, submissions] = await Promise.all([env.COUNTERS.get("visits"), env.COUNTERS.get("submissions")]); return { visits: Number.parseInt(visits || "0", 10) || 0, submissions: Number.parseInt(submissions || "0", 10) || 0, @@ -71,12 +70,14 @@ function rowToInspection(row) { async function listInspections(env) { await ensureSchema(env); - const { results } = await env.DB.prepare(` + const { results } = await env.DB.prepare( + ` select id, image_key, image_name, image_type, image_size, comment, created_at from inspections order by created_at desc limit 30 - `).all(); + ` + ).all(); return results.map(rowToInspection); } @@ -117,29 +118,36 @@ async function createInspection(request, env) { }); try { - await env.DB.prepare(` + await env.DB.prepare( + ` insert into inspections (id, image_key, image_name, image_type, image_size, comment, created_at) values (?, ?, ?, ?, ?, ?, ?) - `).bind(id, imageKey, imageName, image.type, image.size, comment, createdAt).run(); + ` + ) + .bind(id, imageKey, imageName, image.type, image.size, comment, createdAt) + .run(); } catch (err) { await env.IMAGES.delete(imageKey).catch(() => {}); throw err; } await incrementCounter(env, "submissions"); - return json({ - inspection: rowToInspection({ - id, - image_key: imageKey, - image_name: imageName, - image_type: image.type, - image_size: image.size, - comment, - created_at: createdAt, - }), - counters: await counters(env), - }, { status: 201 }); + return json( + { + inspection: rowToInspection({ + id, + image_key: imageKey, + image_name: imageName, + image_type: image.type, + image_size: image.size, + comment, + created_at: createdAt, + }), + counters: await counters(env), + }, + { status: 201 } + ); } async function imageResponse(env, encodedKey) { @@ -155,13 +163,11 @@ async function imageResponse(env, encodedKey) { } async function page(env) { - const [cssUrl, jsUrl] = await Promise.all([ - env.ASSETS.url("style.css"), - env.ASSETS.url("app.js"), - ]); + const [cssUrl, jsUrl] = await Promise.all([env.ASSETS.url("style.css"), env.ASSETS.url("app.js")]); await incrementCounter(env, "visits"); - return new Response(` + return new Response( + ` @@ -214,9 +220,11 @@ async function page(env) { -`, { - headers: { "content-type": "text/html; charset=utf-8" }, - }); +`, + { + headers: { "content-type": "text/html; charset=utf-8" }, + } + ); } export default { diff --git a/examples/kv-demo/src/index.js b/examples/kv-demo/src/index.js index d7aae81..a2b738d 100644 --- a/examples/kv-demo/src/index.js +++ b/examples/kv-demo/src/index.js @@ -4,17 +4,16 @@ export default { const segments = url.pathname.split("/").filter(Boolean); const name = segments[0] || "visitor"; - const current = Number.parseInt(await env.VISITS.get(name) || "0", 10); + const current = Number.parseInt((await env.VISITS.get(name)) || "0", 10); const next = current + 1; await env.VISITS.put(name, String(next)); const { keys } = await env.VISITS.list({ limit: 20 }); - const leaderboard = (await Promise.all( - keys.map(async (key) => [ - key.name, - Number.parseInt(await env.VISITS.get(key.name) || "0", 10), - ]) - )).toSorted((a, b) => b[1] - a[1]); + const leaderboard = ( + await Promise.all( + keys.map(async (key) => [key.name, Number.parseInt((await env.VISITS.get(key.name)) || "0", 10)]) + ) + ).toSorted((a, b) => b[1] - a[1]); return Response.json({ greeting: env.GREETING, diff --git a/examples/pages-assets/public/style.css b/examples/pages-assets/public/style.css index 6d3177e..25c00b9 100644 --- a/examples/pages-assets/public/style.css +++ b/examples/pages-assets/public/style.css @@ -1,9 +1,17 @@ body { - font-family: system-ui, -apple-system, sans-serif; + font-family: + system-ui, + -apple-system, + sans-serif; max-width: 600px; margin: 3rem auto; padding: 0 1rem; color: #222; } -h1 { color: #0055cc; } -a { color: #0055cc; word-break: break-all; } +h1 { + color: #0055cc; +} +a { + color: #0055cc; + word-break: break-all; +} diff --git a/examples/pages-assets/src/index.js b/examples/pages-assets/src/index.js index 16448cf..2a1aae8 100644 --- a/examples/pages-assets/src/index.js +++ b/examples/pages-assets/src/index.js @@ -8,10 +8,7 @@ function escapeHtml(value) { export default { async fetch(_request, env) { - const [cssUrl, logoUrl] = await Promise.all([ - env.ASSETS.url("style.css"), - env.ASSETS.url("hello.txt"), - ]); + const [cssUrl, logoUrl] = await Promise.all([env.ASSETS.url("style.css"), env.ASSETS.url("hello.txt")]); const safeCssUrl = escapeHtml(cssUrl); const safeLogoUrl = escapeHtml(logoUrl); diff --git a/examples/pages-assets/wrangler.jsonc b/examples/pages-assets/wrangler.jsonc index 5bd7283..4cec4b4 100644 --- a/examples/pages-assets/wrangler.jsonc +++ b/examples/pages-assets/wrangler.jsonc @@ -8,6 +8,6 @@ // worker uses env.ASSETS.url() to get CDN URLs back. Same field shape // as Cloudflare Workers Assets. "assets": { - "directory": "./public" - } + "directory": "./public", + }, } diff --git a/examples/queues-demo/src/index.js b/examples/queues-demo/src/index.js index abcdb7a..c7dfca7 100644 --- a/examples/queues-demo/src/index.js +++ b/examples/queues-demo/src/index.js @@ -18,20 +18,14 @@ async function readPayload(request) { async function listJobs(env) { const { keys } = await env.QUEUE_STATE.list({ prefix: JOB_PREFIX, limit: 20 }); - const jobs = await Promise.all( - keys.map(async (key) => env.QUEUE_STATE.get(key.name, { type: "json" })) - ); - return jobs - .filter(Boolean) - .toSorted((a, b) => (b.receivedAt || "").localeCompare(a.receivedAt || "")); + const jobs = await Promise.all(keys.map(async (key) => env.QUEUE_STATE.get(key.name, { type: "json" }))); + return jobs.filter(Boolean).toSorted((a, b) => (b.receivedAt || "").localeCompare(a.receivedAt || "")); } async function enqueue(request, env) { const url = new URL(request.url); const requestedDelay = Number.parseInt(url.searchParams.get("delay") || "0", 10); - const delaySeconds = Number.isFinite(requestedDelay) && requestedDelay > 0 - ? requestedDelay - : 0; + const delaySeconds = Number.isFinite(requestedDelay) && requestedDelay > 0 ? requestedDelay : 0; const payload = await readPayload(request); const id = crypto.randomUUID(); @@ -80,14 +74,17 @@ export default { for (const message of batch.messages) { const body = message.body || {}; const id = body.id || message.id; - await env.QUEUE_STATE.put(`${JOB_PREFIX}${id}`, JSON.stringify({ - id, - queue: batch.queue, - attempts: message.attempts, - payload: body.payload ?? body, - queuedAt: body.queuedAt || null, - receivedAt: new Date().toISOString(), - })); + await env.QUEUE_STATE.put( + `${JOB_PREFIX}${id}`, + JSON.stringify({ + id, + queue: batch.queue, + attempts: message.attempts, + payload: body.payload ?? body, + queuedAt: body.queuedAt || null, + receivedAt: new Date().toISOString(), + }) + ); message.ack(); } }, diff --git a/lib/bundle-modules.js b/lib/bundle-modules.js index de2ea9e..98293d5 100644 --- a/lib/bundle-modules.js +++ b/lib/bundle-modules.js @@ -24,12 +24,19 @@ export function inferType(filePath) { */ export function toWireModule(buf, type) { switch (type) { - case "module": return buf.toString("utf8"); - case "cjs": return { cjs: buf.toString("utf8") }; - case "text": return { text: buf.toString("utf8") }; - case "json": return { json: JSON.parse(buf.toString("utf8")) }; - case "wasm": return { wasm_b64: buf.toString("base64") }; - case "data": return { data_b64: buf.toString("base64") }; - default: throw new Error(`Unknown module type "${type}"`); + case "module": + return buf.toString("utf8"); + case "cjs": + return { cjs: buf.toString("utf8") }; + case "text": + return { text: buf.toString("utf8") }; + case "json": + return { json: JSON.parse(buf.toString("utf8")) }; + case "wasm": + return { wasm_b64: buf.toString("base64") }; + case "data": + return { data_b64: buf.toString("base64") }; + default: + throw new Error(`Unknown module type "${type}"`); } } diff --git a/lib/command.js b/lib/command.js index 4518908..169ce3a 100644 --- a/lib/command.js +++ b/lib/command.js @@ -12,7 +12,15 @@ import { parseArgs } from "node:util"; import { controlFetch as defaultControlFetch } from "./control-fetch.js"; -import { CliError, encodePath, optionParseOptions, printHelpIfRequested, readJsonOrFail, runCliMain, throwHttpErrorIfNotOk } from "./common.js"; +import { + CliError, + encodePath, + optionParseOptions, + printHelpIfRequested, + readJsonOrFail, + runCliMain, + throwHttpErrorIfNotOk, +} from "./common.js"; import { resolveControlContext, resolveNamespace, warnIfInsecureControlUrl } from "./credentials.js"; import { escapeTerminalText } from "./output.js"; @@ -201,11 +209,12 @@ function buildContext(deps, commandDefaults, values, positionals) { context.resolveNamespace = resolveNamespaceFn; /** @type {ReturnType | undefined} */ let controlMemo; - const resolveControlFn = () => (controlMemo ??= (() => { - const control = resolveControlContext(values, env); - warnIfInsecureControlUrl(control.controlUrl, warn, env); - return control; - })()); + const resolveControlFn = () => + (controlMemo ??= (() => { + const control = resolveControlContext(values, env); + warnIfInsecureControlUrl(control.controlUrl, warn, env); + return control; + })()); context.resolveControl = resolveControlFn; // Build a control URL under the resolved namespace, encoding each segment, e.g. @@ -218,9 +227,7 @@ function buildContext(deps, commandDefaults, values, positionals) { const ns = resolveNamespaceFn(); if (!ns) throw new CliError("nsUrl: namespace not resolved (command must validate --ns first)"); const base = `${controlUrl}/ns/${encodePath(ns)}`; - return segments.length === 0 - ? base - : `${base}/${segments.map((s) => encodePath(s)).join("/")}`; + return segments.length === 0 ? base : `${base}/${segments.map((s) => encodePath(s)).join("/")}`; }; // controlFetch + readJsonOrFail in one call — the pair most commands repeat. diff --git a/lib/common.js b/lib/common.js index 2c1a9a2..ea5da1a 100644 --- a/lib/common.js +++ b/lib/common.js @@ -74,14 +74,28 @@ export function formatHelp({ usage, description, commands = [], options = [] }) /** * @param {{ namespace?: boolean, controlUrl?: boolean, token?: boolean, noTokenStore?: boolean, json?: boolean, help?: boolean }} [options] */ -export function commonCliOptions({ namespace = true, controlUrl = true, token = true, noTokenStore = true, json = false, help = true } = {}) { +export function commonCliOptions({ + namespace = true, + controlUrl = true, + token = true, + noTokenStore = true, + json = false, + help = true, +} = {}) { return optionHelp(commonCliOptionSpecs({ namespace, controlUrl, token, noTokenStore, json, help })); } /** * @param {{ namespace?: boolean, controlUrl?: boolean, token?: boolean, noTokenStore?: boolean, json?: boolean, help?: boolean }} [options] */ -export function commonCliOptionSpecs({ namespace = true, controlUrl = true, token = true, noTokenStore = true, json = false, help = true } = {}) { +export function commonCliOptionSpecs({ + namespace = true, + controlUrl = true, + token = true, + noTokenStore = true, + json = false, + help = true, +} = {}) { const specs = []; if (namespace) specs.push(OPTION_DEFS.ns); if (controlUrl) specs.push(OPTION_DEFS.controlUrl); @@ -128,11 +142,21 @@ export function defineHiddenCliOption(name, parseConfig) { const OPTION_DEFS = { ns: defineCliOption("ns", { type: "string" }, "--ns ", "Namespace (env: WDL_NS)."), env: defineCliOption("env", { type: "string" }, "--env ", "Wrangler environment (env: CLOUDFLARE_ENV)."), - controlUrl: defineCliOption("control-url", { type: "string" }, "--control-url ", "Control URL (env: CONTROL_URL)."), + controlUrl: defineCliOption( + "control-url", + { type: "string" }, + "--control-url ", + "Control URL (env: CONTROL_URL)." + ), token: defineCliOption("token", { type: "string" }, "--token ", "Admin token (env: ADMIN_TOKEN)."), json: defineCliOption("json", { type: "boolean" }, "--json", "Print the raw control response."), yes: defineCliOption("yes", { type: "boolean" }, "--yes", "Confirm destructive actions."), - noTokenStore: defineCliOption("no-token-store", { type: "boolean" }, "--no-token-store", "Ignore the global token store; resolve credentials from flag/env/.env only (env: WDL_TOKEN_STORE=off)."), + noTokenStore: defineCliOption( + "no-token-store", + { type: "boolean" }, + "--no-token-store", + "Ignore the global token store; resolve credentials from flag/env/.env only (env: WDL_TOKEN_STORE=off)." + ), help: defineCliOption("help", { type: "boolean", short: "h" }, "-h, --help", "Show this help."), }; @@ -216,7 +240,7 @@ function isCliOptionSpec(item) { * @returns {err is { code: string }} */ export function hasErrorCode(err) { - return Boolean(err) && typeof err === "object" && typeof (/** @type {{ code?: unknown }} */ (err)).code === "string"; + return Boolean(err) && typeof err === "object" && typeof (/** @type {{ code?: unknown }} */ (err).code) === "string"; } /** @param {unknown} err */ @@ -260,8 +284,11 @@ export function printHelpIfRequested(requested, usageText, stdout = (line) => co * @returns {err is Error & { code: string }} */ function isParseArgsError(err) { - return Boolean(err) && typeof (/** @type {{ code?: unknown }} */ (err)).code === "string" && - /** @type {{ code: string }} */ (err).code.startsWith("ERR_PARSE_ARGS_"); + return ( + Boolean(err) && + typeof (/** @type {{ code?: unknown }} */ (err).code) === "string" && + /** @type {{ code: string }} */ (err).code.startsWith("ERR_PARSE_ARGS_") + ); } /** @@ -290,20 +317,9 @@ export async function throwHttpErrorIfNotOk(res, label) { throw new CliError(`${label} failed: ${formatHttpError(res.status, await res.text(), res.headers)}`); } -const ERROR_SUMMARY_KEYS = new Set([ - "error", - "message", - "detail", - "details", - "stack", -]); - -const ARRAY_CONTEXT_KEYS = new Set([ - "blockers", - "referrers", - "reserved_modules", - "warnings", -]); +const ERROR_SUMMARY_KEYS = new Set(["error", "message", "detail", "details", "stack"]); + +const ARRAY_CONTEXT_KEYS = new Set(["blockers", "referrers", "reserved_modules", "warnings"]); /** * @param {number | undefined} status diff --git a/lib/config-state.js b/lib/config-state.js index 4a02efa..28c2966 100644 --- a/lib/config-state.js +++ b/lib/config-state.js @@ -1,6 +1,13 @@ import path from "node:path"; import { CliError, isNonEmptyString } from "./common.js"; -import { flagSet, isTokenStoreDisabled, loadCliControlEnv, protectedEnvKeys, resolveControlUrl, resolveNamespace } from "./credentials.js"; +import { + flagSet, + isTokenStoreDisabled, + loadCliControlEnv, + protectedEnvKeys, + resolveControlUrl, + resolveNamespace, +} from "./credentials.js"; import { maskToken } from "./output.js"; import { tokenStoreReader } from "./token-store.js"; @@ -27,7 +34,14 @@ export class TokenStoreConfigError extends CliError { * warn?: (line: string) => void, * }} [options] */ -export function resolveCliConfigState({ values = {}, env = process.env, cwd = process.cwd(), dotenvPath = ".env", readStore, warn = () => {} } = {}) { +export function resolveCliConfigState({ + values = {}, + env = process.env, + cwd = process.cwd(), + dotenvPath = ".env", + readStore, + warn = () => {}, +} = {}) { const workingEnv = { ...env }; // The loader's helper, not a raw Object.keys set: diagnostics must resolve what // an operating command would. See protectedEnvKeys. @@ -39,13 +53,14 @@ export function resolveCliConfigState({ values = {}, env = process.env, cwd = pr const resolvedDotenvPath = path.resolve(cwd, dotenvPath); /** @param {{ key: string, section: string | null, origin?: "store" | "store-default" }} entry */ const recordDotenvLoad = ({ key, section, origin }) => { - const label = origin === "store-default" - ? "token store default" - : origin === "store" - ? `token store [${section}].${key}` - : section === null - ? `.env ${key}` - : `.env [${section}].${key}`; + const label = + origin === "store-default" + ? "token store default" + : origin === "store" + ? `token store [${section}].${key}` + : section === null + ? `.env ${key}` + : `.env [${section}].${key}`; sources.set(key, label); }; diff --git a/lib/control-fetch.js b/lib/control-fetch.js index d40fb47..f3e346d 100644 --- a/lib/control-fetch.js +++ b/lib/control-fetch.js @@ -207,7 +207,9 @@ export function validateControlHeaders(headers) { } } catch (err) { const message = err instanceof Error && err.message ? err.message : String(err); - throw new CliError(`control request failed: invalid HTTP header ${formatDiagnosticValue(name)}: ${escapeTerminalText(message)}`); + throw new CliError( + `control request failed: invalid HTTP header ${formatDiagnosticValue(name)}: ${escapeTerminalText(message)}` + ); } } } @@ -220,10 +222,8 @@ function toError(err) { /** @param {Error} err */ export function controlRequestError(err) { if (err instanceof CliError) return err; - const codeValue = (/** @type {{ code?: unknown }} */ (/** @type {unknown} */ (err))).code; - const code = typeof codeValue === "string" - ? `${escapeTerminalText(codeValue)} ` - : ""; + const codeValue = /** @type {{ code?: unknown }} */ (/** @type {unknown} */ (err)).code; + const code = typeof codeValue === "string" ? `${escapeTerminalText(codeValue)} ` : ""; const message = escapeTerminalText(err.message || String(err)); return new CliError(`control request failed: ${code}${message}`); } @@ -302,9 +302,12 @@ function streamControlResponse(res, body) { body, text: async () => (await readControlResponse(body)).text(), json: async () => (await readControlResponse(body)).json(), - arrayBuffer: async () => (await readControlResponse(body, { - maxBodyBytes: UNLIMITED_CONTROL_BODY_BYTES, - })).arrayBuffer(), + arrayBuffer: async () => + ( + await readControlResponse(body, { + maxBodyBytes: UNLIMITED_CONTROL_BODY_BYTES, + }) + ).arrayBuffer(), }; } diff --git a/lib/credentials.js b/lib/credentials.js index 97f66ff..9356944 100644 --- a/lib/credentials.js +++ b/lib/credentials.js @@ -22,14 +22,10 @@ export function resolveControlUrl(values, env = process.env) { // No built-in default: a fallback host would silently receive the admin // token whenever a self-hosted user forgets to configure their endpoint. if (!raw) { - throw new CliError( - "No control URL configured. Set CONTROL_URL (e.g. in ./.env), or pass --control-url." - ); + throw new CliError("No control URL configured. Set CONTROL_URL (e.g. in ./.env), or pass --control-url."); } const text = String(raw).trim(); - const withScheme = /^[a-z][a-z\d+.-]*:\/\//i.test(text) - ? text - : `${defaultSchemeForBareControlUrl(text)}://${text}`; + const withScheme = /^[a-z][a-z\d+.-]*:\/\//i.test(text) ? text : `${defaultSchemeForBareControlUrl(text)}://${text}`; const normalized = withScheme.replace(/\/+$/, ""); let parsed; try { @@ -73,7 +69,9 @@ function defaultSchemeForBareControlUrl(text) { export function resolveControlContext(values, env = process.env) { const token = /** @type {string | undefined} */ (values.token) || env.ADMIN_TOKEN; if (!token) { - throw new CliError("Missing admin token. Run 'wdl token set --ns --control-url ' (recommended), pass --token , or set ADMIN_TOKEN."); + throw new CliError( + "Missing admin token. Run 'wdl token set --ns --control-url ' (recommended), pass --token , or set ADMIN_TOKEN." + ); } return { controlUrl: resolveControlUrl(values, env), @@ -119,8 +117,10 @@ function insecureControlUrlReason(controlUrl, env) { if (!isNonEmptyString(connectHost)) return null; const actualHost = controlConnectHostForWarning(connectHost); if (!isLocalDevHost(actualHost)) { - return `control URL ${escapeTerminalText(controlUrl)} is plain http and ` + - `CONTROL_CONNECT_HOST=${escapeTerminalText(connectHost)} is non-local`; + return ( + `control URL ${escapeTerminalText(controlUrl)} is plain http and ` + + `CONTROL_CONNECT_HOST=${escapeTerminalText(connectHost)} is non-local` + ); } return null; } @@ -186,11 +186,7 @@ export function protectedEnvKeys(env) { * }} [options] * @returns {string[]} */ -export function loadCliDotEnv( - env = process.env, - path = ".env", - options = {} -) { +export function loadCliDotEnv(env = process.env, path = ".env", options = {}) { let text; try { text = readFileSync(path, "utf8"); @@ -212,7 +208,10 @@ export function loadCliDotEnv( const loaded = []; /** @type {string | null} */ let section = null; - for (const [idx, rawLine] of text.replace(/^\uFEFF/, "").split(/\r?\n/).entries()) { + for (const [idx, rawLine] of text + .replace(/^\uFEFF/, "") + .split(/\r?\n/) + .entries()) { const line = rawLine.trim(); if (!line || line.startsWith("#")) continue; @@ -240,9 +239,7 @@ export function loadCliDotEnv( if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { throw new CliError(`Invalid .env line ${idx + 1}: invalid key "${key}"`); } - const shouldLoad = section === null - ? loadBase - : section === selectedSection; + const shouldLoad = section === null ? loadBase : section === selectedSection; if (!shouldLoad) continue; if (section !== null && key === "WDL_NS") { warn(`Ignoring WDL_NS in .env section [${section}]`); @@ -282,18 +279,21 @@ function firstDotEnvToken(body) { * onLoad?: (entry: { key: string, value: string, section: string | null, line: number, origin?: "store" | "store-default" }) => void, * }} [options] */ -export function loadCliControlEnv(env, { - dotenvPath, - nsFromFlag, - tokenFromFlag = false, - controlUrlFromFlag = false, - protectedKeys = protectedEnvKeys(env), - loadEnv = loadCliDotEnv, - readStore = () => ({}), - warn, - onCrossOrigin = (line) => console.error(line), - onLoad, -} = {}) { +export function loadCliControlEnv( + env, + { + dotenvPath, + nsFromFlag, + tokenFromFlag = false, + controlUrlFromFlag = false, + protectedKeys = protectedEnvKeys(env), + loadEnv = loadCliDotEnv, + readStore = () => ({}), + warn, + onCrossOrigin = (line) => console.error(line), + onLoad, + } = {} +) { /** @type {Set} */ const loaded = new Set(); // loadCliDotEnv returns the loaded keys; a test-injected loader may return @@ -311,7 +311,7 @@ export function loadCliControlEnv(env, { // 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 = () => (storeData ??= readStore(env) || {}); let ns = firstNonEmptyString(nsFromFlag, env.WDL_NS); // The store's base WDL_NS names a default namespace — the lowest-precedence @@ -423,18 +423,15 @@ function guardCrossOriginControlEnv(env, loadedFromDotenv, tokenFromFlag, onCros // placeholder would otherwise mark the .env endpoint same-source while the // real token gets gap-filled from the global store afterwards — letting an // untrusted .env redirect a STORED token to a host it chose. - const tokenIsFromDotenv = - loadedFromDotenv.has("ADMIN_TOKEN") && - isNonEmptyString(env.ADMIN_TOKEN) && - !tokenFromFlag; + const tokenIsFromDotenv = loadedFromDotenv.has("ADMIN_TOKEN") && isNonEmptyString(env.ADMIN_TOKEN) && !tokenFromFlag; if (tokenIsFromDotenv) return; for (const key of CONTROL_ENDPOINT_KEYS) { if (!loadedFromDotenv.has(key)) continue; delete env[key]; onCrossOrigin( `warning: ignoring ${key} from .env — it would send a token from your shell or --token ` + - `to a host chosen by this directory's .env. Set ${key} in your shell/CI env, pass ` + - `--control-url, or put ADMIN_TOKEN in the same .env.` + `to a host chosen by this directory's .env. Set ${key} in your shell/CI env, pass ` + + `--control-url, or put ADMIN_TOKEN in the same .env.` ); } } diff --git a/lib/d1-files.js b/lib/d1-files.js index 21426ce..d9f4c5f 100644 --- a/lib/d1-files.js +++ b/lib/d1-files.js @@ -78,7 +78,9 @@ export function readMigrationFiles(dir = "migrations") { for (const entry of entries) { if (!entry.name.endsWith(".sql")) continue; if (entry.isSymbolicLink()) { - throw new CliError(`d1 migrations: ${escapeTerminalText(entry.name)} is a symlink; copy the SQL file into the migrations dir`); + throw new CliError( + `d1 migrations: ${escapeTerminalText(entry.name)} is a symlink; copy the SQL file into the migrations dir` + ); } if (entry.isFile()) filenames.push(entry.name); } @@ -98,7 +100,9 @@ function readMigrationFile(root, filename) { try { const st = lstatSync(file); if (st.isSymbolicLink()) { - throw new CliError(`d1 migrations: ${escapeTerminalText(filename)} is a symlink; copy the SQL file into the migrations dir`); + throw new CliError( + `d1 migrations: ${escapeTerminalText(filename)} is a symlink; copy the SQL file into the migrations dir` + ); } if (!st.isFile()) return null; } catch (err) { diff --git a/lib/d1-format.js b/lib/d1-format.js index 094ef41..6931843 100644 --- a/lib/d1-format.js +++ b/lib/d1-format.js @@ -21,9 +21,7 @@ export function formatD1List(body) { const databases = Array.isArray(body.databases) ? body.databases : []; if (databases.length === 0) return ["(no d1 databases)"]; - return databases.map((db) => - `${db.databaseId}\tname=${db.databaseName || "-"}\tcreated=${db.createdAt || "-"}` - ); + return databases.map((db) => `${db.databaseId}\tname=${db.databaseName || "-"}\tcreated=${db.createdAt || "-"}`); } /** @@ -41,8 +39,8 @@ export function formatD1Execute(body) { export function formatD1MigrationList(body) { const migrations = Array.isArray(body.migrations) ? body.migrations : []; if (migrations.length === 0) return ["(no d1 migrations applied)"]; - return migrations.map((migration) => - `${migration.id}\tapplied=${migration.appliedAt || "-"}\tchecksum=${migration.checksum || "-"}` + return migrations.map( + (migration) => `${migration.id}\tapplied=${migration.appliedAt || "-"}\tchecksum=${migration.checksum || "-"}` ); } @@ -53,8 +51,8 @@ export function formatD1MigrationList(body) { export function formatD1MigrationStatus(body) { const migrations = Array.isArray(body.migrations) ? body.migrations : []; if (migrations.length === 0) return ["(no local migrations)"]; - return migrations.map((migration) => - `${migration.id}\tstate=${migration.state}\tapplied=${migration.appliedAt || "-"}` + return migrations.map( + (migration) => `${migration.id}\tstate=${migration.state}\tapplied=${migration.appliedAt || "-"}` ); } @@ -67,9 +65,7 @@ export function formatD1MigrationApply(body) { const skipped = Array.isArray(body.skipped) ? body.skipped : []; if (applied.length === 0 && skipped.length === 0) return ["(no migrations applied)"]; return [ - ...applied.map((migration) => - `Applied ${migration.id}\tstatements=${migration.statementCount ?? "-"}` - ), + ...applied.map((migration) => `Applied ${migration.id}\tstatements=${migration.statementCount ?? "-"}`), ...skipped.map((migration) => `Skipped ${migration.id}\talready applied`), ]; } diff --git a/lib/delete-format.js b/lib/delete-format.js index 3c12ee8..cbc44dd 100644 --- a/lib/delete-format.js +++ b/lib/delete-format.js @@ -1,13 +1,6 @@ import { escapeTerminalText, formatKnownWarning } from "./output.js"; -const ASSET_WARNING_KEYS = [ - "code", - "message", - "path", - "key", - "prefix", - "reason", -]; +const ASSET_WARNING_KEYS = ["code", "message", "path", "key", "prefix", "reason"]; /** * @typedef {object} DeleteAssetsSummary @@ -84,13 +77,12 @@ export function formatWorkerDelete(body) { return [`(${field(body.namespace)}/${field(body.name)} had no worker-owned state)`]; } - const versions = Array.isArray(body.versionsDeleted) && body.versionsDeleted.length - ? body.versionsDeleted.map((version) => field(version)).join(",") - : "-"; + const versions = + Array.isArray(body.versionsDeleted) && body.versionsDeleted.length + ? body.versionsDeleted.map((version) => field(version)).join(",") + : "-"; const active = field(body.activeDeleted); - const lines = [ - `OK ${field(body.namespace)}/${field(body.name)} deleted active=${active} versions=${versions}`, - ]; + const lines = [`OK ${field(body.namespace)}/${field(body.name)} deleted active=${active} versions=${versions}`]; if (Array.isArray(body.affectedHosts) && body.affectedHosts.length) { lines.push(` affected hosts: ${body.affectedHosts.map((host) => field(host)).join(",")}`); } @@ -106,9 +98,10 @@ export function formatWorkerDelete(body) { * @returns {string[]} */ function formatDryRun(body) { - const versions = Array.isArray(body.versionsDeleted) && body.versionsDeleted.length - ? body.versionsDeleted.map((version) => field(version)).join(",") - : "-"; + const versions = + Array.isArray(body.versionsDeleted) && body.versionsDeleted.length + ? body.versionsDeleted.map((version) => field(version)).join(",") + : "-"; const active = field(body.activeDeleted); const lines = [ `DRY RUN ${field(body.namespace)}/${field(body.name)} wouldDelete=${body.deleted ? "yes" : "no"} active=${active} versions=${versions}`, @@ -159,8 +152,7 @@ function appendBlockers(lines, blockers) { ` ${field(ref.callerNs)}/${field(ref.callerWorker)}@${field(ref.callerVersion)} binding=${field(ref.binding)}` ); } - if (Number.isFinite(blocker.crossNamespaceReferrerCount) && - Number(blocker.crossNamespaceReferrerCount) > 0) { + if (Number.isFinite(blocker.crossNamespaceReferrerCount) && Number(blocker.crossNamespaceReferrerCount) > 0) { lines.push(` cross-namespace referrers: ${blocker.crossNamespaceReferrerCount}`); } } @@ -177,7 +169,7 @@ function appendWorkflowBlocker(lines, blocker) { if (blocker.error || blocker.message) { lines.push( ` ${field(blocker.error, "workflow_instances_active")}: ` + - `${field(blocker.message, "active workflow instances")}` + `${field(blocker.message, "active workflow instances")}` ); } if (Number.isFinite(blocker.count)) { @@ -185,9 +177,7 @@ function appendWorkflowBlocker(lines, blocker) { } const refs = Array.isArray(blocker.blockers) ? blocker.blockers : []; for (const ref of refs) { - lines.push( - ` ${field(ref.workflowKey)} instance=${field(ref.instanceId)}` - ); + lines.push(` ${field(ref.workflowKey)} instance=${field(ref.instanceId)}`); } } diff --git a/lib/dotenv.js b/lib/dotenv.js index 88452d7..7cd6671 100644 --- a/lib/dotenv.js +++ b/lib/dotenv.js @@ -5,12 +5,7 @@ import { CliError } from "./common.js"; -export const CLI_DOTENV_KEYS = new Set([ - "ADMIN_TOKEN", - "CONTROL_CONNECT_HOST", - "CONTROL_URL", - "WDL_NS", -]); +export const CLI_DOTENV_KEYS = new Set(["ADMIN_TOKEN", "CONTROL_CONNECT_HOST", "CONTROL_URL", "WDL_NS"]); // Keys deleted from the Wrangler child env (lib/wrangler/command.js) so WDL // control-plane config in the env never reaches the bundler or the build scripts @@ -34,12 +29,11 @@ export function parseDotEnvSection(line, lineNumber) { return match[1].trim(); } - /** @param {string} value */ export function parseDotEnvValue(value) { if (!value) return ""; const quote = value[0]; - if (quote === "\"" || quote === "'") { + if (quote === '"' || quote === "'") { const end = findClosingQuote(value, quote); if (end === -1) { throw new CliError("Invalid .env value: missing closing quote"); @@ -87,7 +81,7 @@ function unescapeDoubleQuoted(s) { if (next === "n") out += "\n"; else if (next === "r") out += "\r"; else if (next === "t") out += "\t"; - else if (next === "\"") out += "\""; + else if (next === '"') out += '"'; else if (next === "\\") out += "\\"; else out += "\\" + next; // preserve unknown escapes verbatim } else { @@ -104,7 +98,7 @@ function unescapeDoubleQuoted(s) { function findClosingQuote(value, quote) { for (let i = 1; i < value.length; i += 1) { if (value[i] !== quote) continue; - if (quote === "\"" && isEscaped(value, i)) continue; + if (quote === '"' && isEscaped(value, i)) continue; return i; } return -1; diff --git a/lib/output.js b/lib/output.js index fa5df29..d7d6010 100644 --- a/lib/output.js +++ b/lib/output.js @@ -35,10 +35,7 @@ export function formatKnownWarning(value, keys) { * @returns {value is string | number | boolean | null} */ function isScalarWarningField(value) { - return value == null || - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean"; + return value == null || typeof value === "string" || typeof value === "number" || typeof value === "boolean"; } // Matching control characters is the whole point here. @@ -78,7 +75,10 @@ export function maskToken(token) { function escapeControlChars(text, keepLayout) { let out = ""; for (const ch of text) { - if (keepLayout && (ch === "\t" || ch === "\n")) { out += ch; continue; } + if (keepLayout && (ch === "\t" || ch === "\n")) { + out += ch; + continue; + } const code = ch.charCodeAt(0); out += isTerminalControlCode(code) ? escapeControlChar(ch, code) : ch; } @@ -91,13 +91,20 @@ function escapeControlChars(text, keepLayout) { */ function escapeControlChar(ch, code) { switch (ch) { - case "\n": return "\\n"; - case "\r": return "\\r"; - case "\t": return "\\t"; - case "\b": return "\\b"; - case "\f": return "\\f"; - case "\v": return "\\u000b"; - default: return `\\u${code.toString(16).padStart(4, "0")}`; + case "\n": + return "\\n"; + case "\r": + return "\\r"; + case "\t": + return "\\t"; + case "\b": + return "\\b"; + case "\f": + return "\\f"; + case "\v": + return "\\u000b"; + default: + return `\\u${code.toString(16).padStart(4, "0")}`; } } diff --git a/lib/secret-envelope-errors.js b/lib/secret-envelope-errors.js index 88c210c..2cdc139 100644 --- a/lib/secret-envelope-errors.js +++ b/lib/secret-envelope-errors.js @@ -1,9 +1,11 @@ /** @param {unknown} error */ export function isSecretEnvelopeErrorCode(error) { - return error === "invalid_envelope" || + return ( + error === "invalid_envelope" || error === "secret_decrypt_failed" || error === "secret_encryption_unconfigured" || error === "secret_not_encrypted" || error === "unsupported_envelope" || - error === "unknown_kid"; + error === "unknown_kid" + ); } diff --git a/lib/stdin.js b/lib/stdin.js index 9c3f710..2597d5b 100644 --- a/lib/stdin.js +++ b/lib/stdin.js @@ -35,7 +35,9 @@ export async function confirmAction({ } = {}) { if (yes) return; if (!stdin.isTTY) { - throw new CliError(`Refusing to ${escapeTerminalText(action)} without interactive confirmation. Pass --yes to confirm.`); + throw new CliError( + `Refusing to ${escapeTerminalText(action)} without interactive confirmation. Pass --yes to confirm.` + ); } const answer = await readTtyLine(stdin, { prompt, stderr }); @@ -64,7 +66,11 @@ export function readTtyLine(stdin, { prompt, stderr, hidden = false } = {}) { stdin.off("end", onEnd); stdin.off("error", onError); if (raw) { - try { stdin.setRawMode?.(false); } catch { /* terminal already restored */ } + try { + stdin.setRawMode?.(false); + } catch { + /* terminal already restored */ + } if (stderr) stderr("\n"); // the un-echoed Enter still needs a line break } if (typeof stdin.pause === "function") stdin.pause(); @@ -93,7 +99,10 @@ export function readTtyLine(stdin, { prompt, stderr, hidden = false } = {}) { if (ch === "\r" || ch === "\n") return finish(data); if (ch === "\u0003") return fail(new CliError("input aborted")); // Ctrl-C if (ch === "\u0004") return finish(data); // Ctrl-D: submit what we have - if (ch === "\u007f" || ch === "\b") { data = data.slice(0, -1); continue; } // backspace + if (ch === "\u007f" || ch === "\b") { + data = data.slice(0, -1); + continue; + } // backspace data += ch; } }; @@ -103,8 +112,7 @@ export function readTtyLine(stdin, { prompt, stderr, hidden = false } = {}) { stdin.setEncoding("utf8"); if (wantHidden) { - const failClosed = () => - reject(new CliError("cannot hide input on this terminal; pipe the value in instead")); + const failClosed = () => reject(new CliError("cannot hide input on this terminal; pipe the value in instead")); if (typeof stdin.setRawMode !== "function") return failClosed(); try { stdin.setRawMode(true); diff --git a/lib/token-store.js b/lib/token-store.js index c9c8b55..651f38d 100644 --- a/lib/token-store.js +++ b/lib/token-store.js @@ -1,5 +1,15 @@ import { randomUUID } from "node:crypto"; -import { chmodSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs"; +import { + chmodSync, + lstatSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + utimesSync, + writeFileSync, +} from "node:fs"; import os from "node:os"; import path from "node:path"; import { CliError, hasErrorCode, isNonEmptyString } from "./common.js"; @@ -48,10 +58,7 @@ export function tokenStoreDir(env = process.env, homedir = os.homedir, platform if (platform === "win32" && env.APPDATA) { return path.join(env.APPDATA, "wdl"); } - const base = - isNonEmptyString(env.XDG_CONFIG_HOME) - ? env.XDG_CONFIG_HOME - : path.join(homedir(), ".config"); + const base = isNonEmptyString(env.XDG_CONFIG_HOME) ? env.XDG_CONFIG_HOME : path.join(homedir(), ".config"); return path.join(base, "wdl"); } @@ -90,7 +97,10 @@ export function readTokenStore(storePath) { let defaultNs = null; /** @type {string | null} */ let section = null; - for (const [idx, rawLine] of text.replace(/^\uFEFF/, "").split(/\r?\n/).entries()) { + for (const [idx, rawLine] of text + .replace(/^\uFEFF/, "") + .split(/\r?\n/) + .entries()) { const line = rawLine.trim(); if (!line || line.startsWith("#")) continue; @@ -102,7 +112,12 @@ export function readTokenStore(storePath) { // "constructor"/"toString" one would collide with an inherited member. // defineProperty creates the own data entry regardless, prototype intact. if (!Object.hasOwn(namespaces, section)) { - Object.defineProperty(namespaces, section, { value: {}, writable: true, enumerable: true, configurable: true }); + Object.defineProperty(namespaces, section, { + value: {}, + writable: true, + enumerable: true, + configurable: true, + }); } continue; } @@ -186,10 +201,7 @@ export function writeTokenStore(storePath, store) { */ function writeTokenStoreFile(storePath, store, { beforeCommit = () => {}, tempDir = path.dirname(storePath) } = {}) { const namespaces = store.namespaces || {}; - const lines = [ - "# Managed by `wdl token`. Do not hand-edit — use a project .env for overrides.", - "", - ]; + const lines = ["# Managed by `wdl token`. Do not hand-edit — use a project .env for overrides.", ""]; // Write the default-namespace pointer only when it has a stored entry, so the // file never carries a dangling default. Mirrors a base WDL_NS in a `.env`. if (store.defaultNs && Object.hasOwn(namespaces, store.defaultNs)) { @@ -248,10 +260,7 @@ function writeTokenStoreFile(storePath, store, { beforeCommit = () => {}, tempDi export function updateTokenStore( storePath, update, - { - lockTimeoutMs = DEFAULT_LOCK_TIMEOUT_MS, - staleLockMs = DEFAULT_STALE_LOCK_MS, - } = {} + { lockTimeoutMs = DEFAULT_LOCK_TIMEOUT_MS, staleLockMs = DEFAULT_STALE_LOCK_MS } = {} ) { /** @type {R | undefined} */ let result; @@ -329,14 +338,11 @@ function acquireTokenStoreLock(lockDir, lockTimeoutMs, staleLockMs) { return owner; } catch (err) { if (!isTokenStoreLockContentionError(err)) throw err; - if (hasErrorCode(err) && err.code === "EEXIST" && - recoverTokenStoreLock(lockDir, owner, staleLockMs)) { + if (hasErrorCode(err) && err.code === "EEXIST" && recoverTokenStoreLock(lockDir, owner, staleLockMs)) { return owner; } if (Date.now() >= deadline) { - throw new CliError( - "credential store is locked by another wdl token command; retry after it finishes" - ); + throw new CliError("credential store is locked by another wdl token command; retry after it finishes"); } Atomics.wait(LOCK_WAIT, 0, 0, LOCK_WAIT_MS); } @@ -369,8 +375,7 @@ function createTokenStoreLock(lockDir, owner) { * @param {number} staleLockMs */ function recoverTokenStoreLock(lockDir, owner, staleLockMs) { - const recoveredPath = moveReleasedTokenStoreLock(lockDir) || - moveStaleTokenStoreLock(lockDir, staleLockMs); + const recoveredPath = moveReleasedTokenStoreLock(lockDir) || moveStaleTokenStoreLock(lockDir, staleLockMs); if (!recoveredPath) return false; try { createTokenStoreLock(lockDir, owner); @@ -385,10 +390,7 @@ function recoverTokenStoreLock(lockDir, owner, staleLockMs) { /** @param {unknown} err */ function isTokenStoreLockContentionError(err) { - return hasErrorCode(err) && ( - err.code === "EEXIST" || - err.code === LOCK_CONTENTION_CODE - ); + return hasErrorCode(err) && (err.code === "EEXIST" || err.code === LOCK_CONTENTION_CODE); } /** @param {unknown} err */ @@ -525,8 +527,10 @@ function moveObservedTokenStoreLock(lockDir, observed, expected) { if (expected.requireUnchangedMtime && current.mtimeMs !== observed.mtimeMs) return false; if (!current.isDirectory()) return false; if (readTokenStoreLockOwner(lockDir) !== expected.owner) return false; - if (expected.release !== undefined && - (expected.owner === null || readTokenStoreLockRelease(lockDir) !== expected.release)) { + if ( + expected.release !== undefined && + (expected.owner === null || readTokenStoreLockRelease(lockDir) !== expected.release) + ) { return false; } const final = lstatSync(lockDir); @@ -630,11 +634,7 @@ function readTokenStoreLockRelease(lockDir) { /** @param {unknown} err */ function isLockRecoveryFsError(err) { - return hasErrorCode(err) && ( - err.code === "EACCES" || - err.code === "EPERM" || - err.code === "ENOTDIR" - ); + return hasErrorCode(err) && (err.code === "EACCES" || err.code === "EPERM" || err.code === "ENOTDIR"); } /** @param {unknown} err */ diff --git a/lib/whoami.js b/lib/whoami.js index 7a3f997..c639cef 100644 --- a/lib/whoami.js +++ b/lib/whoami.js @@ -95,9 +95,10 @@ export function cliCompatibility(cliVersion, minCliVersion) { return { ok: cmp >= 0, label: cmp >= 0 ? "supported" : "unsupported", - detail: cmp >= 0 - ? `CLI ${cliVersion} satisfies control minimum ${minCliVersion}` - : `CLI ${cliVersion} is older than control minimum ${minCliVersion}`, + detail: + cmp >= 0 + ? `CLI ${cliVersion} satisfies control minimum ${minCliVersion}` + : `CLI ${cliVersion} is older than control minimum ${minCliVersion}`, }; } @@ -204,7 +205,10 @@ export function ensureControlContextFromConfigState(state) { if (!state.controlUrl.value) { throw new CliError("No control URL configured. Set CONTROL_URL (e.g. in ./.env), or pass --control-url."); } - if (!state.token.value) throw new CliError("Missing admin token. Run 'wdl token set --ns --control-url ' (recommended), pass --token , or set ADMIN_TOKEN."); + if (!state.token.value) + throw new CliError( + "Missing admin token. Run 'wdl token set --ns --control-url ' (recommended), pass --token , or set ADMIN_TOKEN." + ); return { controlUrl: state.controlUrl.value, token: state.token.value, diff --git a/lib/workers-format.js b/lib/workers-format.js index b56f011..5d3682f 100644 --- a/lib/workers-format.js +++ b/lib/workers-format.js @@ -19,14 +19,13 @@ export function formatWorkersList(body) { const workers = Array.isArray(body.workers) ? body.workers : []; if (workers.length === 0) return ["(no workers)"]; return workers.map((w) => { - const versions = Array.isArray(w.versions) && w.versions.length - ? w.versions.map((version) => escapeTerminalText(version)).join(",") - : "-"; + const versions = + Array.isArray(w.versions) && w.versions.length + ? w.versions.map((version) => escapeTerminalText(version)).join(",") + : "-"; const active = w.activeVersion ? escapeTerminalText(w.activeVersion) : "-"; const secrets = w.hasSecrets ? "yes" : "no"; - const workflowDefs = typeof w.hasWorkflowDefs === "boolean" - ? (w.hasWorkflowDefs ? "yes" : "no") - : "unknown"; + const workflowDefs = typeof w.hasWorkflowDefs === "boolean" ? (w.hasWorkflowDefs ? "yes" : "no") : "unknown"; return `${escapeTerminalText(w.name)}\tactive=${active}\tversions=${versions}\tsecrets=${secrets}\tworkflow-defs=${workflowDefs}`; }); } diff --git a/lib/workflows-format.js b/lib/workflows-format.js index fd292f5..fd49fff 100644 --- a/lib/workflows-format.js +++ b/lib/workflows-format.js @@ -36,8 +36,9 @@ function field(value) { export function formatWorkflowList(body) { const workflows = Array.isArray(body?.workflows) ? body.workflows : []; if (workflows.length === 0) return ["(no workflows)"]; - return workflows.map((entry) => - `${field(entry.worker)}/${field(entry.name)}\tbinding=${field(entry.binding)}\tclass=${field(entry.className)}\tactive=${field(entry.activeVersion)}\tkey=${field(entry.workflowKey)}\tretired=${entry.retired ? "yes" : "no"}` + return workflows.map( + (entry) => + `${field(entry.worker)}/${field(entry.name)}\tbinding=${field(entry.binding)}\tclass=${field(entry.className)}\tactive=${field(entry.activeVersion)}\tkey=${field(entry.workflowKey)}\tretired=${entry.retired ? "yes" : "no"}` ); } @@ -47,9 +48,10 @@ export function formatWorkflowList(body) { */ export function formatInstanceList(body) { const instances = Array.isArray(body?.instances) ? body.instances : []; - const lines = instances.length === 0 - ? ["(no workflow instances)"] - : instances.map((entry) => `${field(entry.id)}\tstatus=${field(entry.status)}`); + const lines = + instances.length === 0 + ? ["(no workflow instances)"] + : instances.map((entry) => `${field(entry.id)}\tstatus=${field(entry.status)}`); if (body?.cursor) lines.push(`Next cursor: ${escapeTerminalText(body.cursor)}`); return lines; } diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index 0330483..f10303c 100644 --- a/lib/wrangler-pack.js +++ b/lib/wrangler-pack.js @@ -7,10 +7,7 @@ import { rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { CliError } from "./common.js"; import { escapeTerminalText, formatDiagnosticValue } from "./output.js"; -import { - RESERVED_OBJECT_KEYS, - WDL_RESERVED_BINDING_RE, -} from "./ns-pattern.js"; +import { RESERVED_OBJECT_KEYS, WDL_RESERVED_BINDING_RE } from "./ns-pattern.js"; import { collectAssets, resolveAssetsDir } from "./wrangler/assets.js"; import { parseD1DatabasesFromCfg, @@ -44,12 +41,7 @@ import { asRecord, hasOwn, manifestMap } from "./wrangler/utils.js"; // Keep `wrangler-pack.js` as the stable facade for deploy.js and existing // helper tests. New helper code should import from `./wrangler/.js` // directly when it does not need the full packWranglerProject orchestration. -export { - collectAssets, - MAX_ASSET_FILE_BYTES, - MAX_ASSETS_TOTAL_BYTES, - resolveAssetsDir, -} from "./wrangler/assets.js"; +export { collectAssets, MAX_ASSET_FILE_BYTES, MAX_ASSETS_TOTAL_BYTES, resolveAssetsDir } from "./wrangler/assets.js"; export { parseD1DatabasesFromCfg, parseDurableObjectsFromCfg, @@ -62,11 +54,7 @@ export { parseTriggers, parseWorkflowsFromCfg, } from "./wrangler/bindings.js"; -export { - parseWranglerMajorVersion, - resolveWranglerCommand, - wranglerChildEnv, -} from "./wrangler/command.js"; +export { parseWranglerMajorVersion, resolveWranglerCommand, wranglerChildEnv } from "./wrangler/command.js"; export { createWranglerBundleConfig, formatWranglerConfigShadowWarning, @@ -223,9 +211,7 @@ export async function packWranglerProject({ claimBinding(workflow.binding); } - const { producers: queueProducers, consumers: queueConsumers } = wrapCli(() => - parseQueues(cfg.queues, configRel) - ); + const { producers: queueProducers, consumers: queueConsumers } = wrapCli(() => parseQueues(cfg.queues, configRel)); for (const p of queueProducers) { claimBinding(p.binding); /** @type {{ type: string, id: string, deliveryDelaySeconds?: number }} */ @@ -261,7 +247,7 @@ export async function packWranglerProject({ checkWranglerVersion({ execFile, cwd: absProject, env, wrangler }); stdout( `[1/3] bundling via wrangler${envName ? ` (env=${escapeTerminalText(envName)})` : ""} → ` + - `${escapeTerminalText(path.relative(cwd, outDir))}` + `${escapeTerminalText(path.relative(cwd, outDir))}` ); const tmpConfigPath = path.join(absProject, `${WRANGLER_WDL_TMP_PREFIX}-${randomUUID()}.json`); const wranglerBundleConfig = createWranglerBundleConfig(rawCfg); @@ -271,14 +257,7 @@ export async function packWranglerProject({ const cleanupTempConfig = installTempFileCleanup(tmpConfigPath); let bundlingFailed = false; try { - const wranglerArgs = [ - ...wrangler.args, - "deploy", - "--dry-run", - `--outdir=${outDir}`, - "--config", - tmpConfigPath, - ]; + const wranglerArgs = [...wrangler.args, "deploy", "--dry-run", `--outdir=${outDir}`, "--config", tmpConfigPath]; if (envName) wranglerArgs.push("--env", envName); /** @type {import("node:child_process").ExecFileSyncOptions} */ const wranglerOpts = { @@ -307,7 +286,7 @@ export async function packWranglerProject({ if (!hasOwn(modules, entryName)) { throw new CliError( `wrangler output doesn't contain expected entry ${formatDiagnosticValue(entryName)} ` + - `(from main=${formatDiagnosticValue(cfg.main)}). Found: ${Object.keys(modules).map(escapeTerminalText).join(", ")}` + `(from main=${formatDiagnosticValue(cfg.main)}). Found: ${Object.keys(modules).map(escapeTerminalText).join(", ")}` ); } @@ -331,7 +310,7 @@ export async function packWranglerProject({ if (doList.length) { stderr( "note: Durable Object workers expose named WorkerEntrypoint classes only when listed in [[exports]]; " + - "undeclared named exports are hidden to protect internal DO_BACKEND" + "undeclared named exports are hidden to protect internal DO_BACKEND" ); } @@ -356,9 +335,9 @@ export async function packWranglerProject({ const more = skippedAssets.length > 5 ? `, … ${skippedAssets.length - 5} more` : ""; stderr( `note: assets: skipped ${skippedAssets.length} ignored ` + - `${skippedAssets.length === 1 ? "entry" : "entries"} (${shown}${more}; ` + - `a trailing / is a whole subtree); ` + - `defaults and .assetsignore rules apply — re-include with a !pattern line` + `${skippedAssets.length === 1 ? "entry" : "entries"} (${shown}${more}; ` + + `a trailing / is a whole subtree); ` + + `defaults and .assetsignore rules apply — re-include with a !pattern line` ); } claimBinding("ASSETS"); @@ -418,9 +397,7 @@ export function parseWorkersDev(cfg, routes, configRel) { throw new CliError(`${shownConfig}: "workers_dev" must be a boolean`); } if (!cfg.workers_dev && routes.length === 0) { - throw new CliError( - `${shownConfig}: workers_dev = false requires at least one route pattern` - ); + throw new CliError(`${shownConfig}: workers_dev = false requires at least one route pattern`); } return cfg.workers_dev; } @@ -437,7 +414,9 @@ export function parseWorkersDev(cfg, routes, configRel) { export function installTempFileCleanup( filePath, processLike = process, - terminate = (signal) => { process.kill(process.pid, signal); } + terminate = (signal) => { + process.kill(process.pid, signal); + } ) { let active = true; /** @param {{ ignoreErrors: boolean }} options */ diff --git a/lib/wrangler/assets.js b/lib/wrangler/assets.js index d32f23b..2c43774 100644 --- a/lib/wrangler/assets.js +++ b/lib/wrangler/assets.js @@ -83,9 +83,7 @@ export function resolveAssetsDir(absProject, assetsDirRel, configRel = "wrangler throw assetsDirFsError(configRel, "stat", assetsDirRel, err); } if (dirStat.isSymbolicLink()) { - throw new Error( - `${configRel} assets.directory ${formatDiagnosticValue(assetsDirRel)} must not be a symlink` - ); + throw new Error(`${configRel} assets.directory ${formatDiagnosticValue(assetsDirRel)} must not be a symlink`); } if (!dirStat.isDirectory()) { throw new Error(`${configRel} assets.directory ${formatDiagnosticValue(assetsDirRel)} is not a directory`); @@ -166,7 +164,7 @@ export function collectAssets(dir, { onIgnore = null } = {}) { const shownRelPath = formatDiagnosticValue(relPath); throw new Error( `assets: symlink not allowed at ${shownRelPath} ` + - `(add ${shownRelPath} to .assetsignore to skip it; patterns ending in "/" match only real directories, not symlinks)` + `(add ${shownRelPath} to .assetsignore to skip it; patterns ending in "/" match only real directories, not symlinks)` ); } if (st.isDirectory()) { diff --git a/lib/wrangler/bindings.js b/lib/wrangler/bindings.js index c78ae23..4b83c02 100644 --- a/lib/wrangler/bindings.js +++ b/lib/wrangler/bindings.js @@ -200,11 +200,12 @@ export function parseQueues(queues, configRel = "wrangler config") { const entry = { queue: c.queue }; if (c.max_batch_size != null) entry.maxBatchSize = c.max_batch_size; if (c.max_batch_timeout != null) { - entry.maxBatchTimeoutMs = normalizeQueueDelayConfig( - c.max_batch_timeout, - configRel, - `[[queues.consumers]] ${formatConfigLabel(c.queue)}.max_batch_timeout` - ) * 1000; + entry.maxBatchTimeoutMs = + normalizeQueueDelayConfig( + c.max_batch_timeout, + configRel, + `[[queues.consumers]] ${formatConfigLabel(c.queue)}.max_batch_timeout` + ) * 1000; } if (c.max_retries != null) entry.maxRetries = c.max_retries; if (c.retry_delay != null) { @@ -245,9 +246,7 @@ export function parseKvNamespacesFromCfg(cfg, configRel = "wrangler config") { } const unknownKeys = Object.keys(entry).filter((key) => !allowedKeys.has(key)); if (unknownKeys.length > 0) { - throw new Error( - `${configRel}: [[kv_namespaces]] contains unknown field(s): ${formatConfigKeyList(unknownKeys)}` - ); + throw new Error(`${configRel}: [[kv_namespaces]] contains unknown field(s): ${formatConfigKeyList(unknownKeys)}`); } if (typeof entry.binding !== "string" || !entry.binding.trim()) { throw new Error(`${configRel}: [[kv_namespaces]] entry needs a non-empty string 'binding'`); @@ -255,7 +254,9 @@ export function parseKvNamespacesFromCfg(cfg, configRel = "wrangler config") { assertNotRuntimeReservedBinding(configRel, "[[kv_namespaces]]", entry.binding); assertValidBindingName(configRel, "[[kv_namespaces]]", entry.binding); if (typeof entry.id !== "string" || !entry.id.trim()) { - throw new Error(`${configRel}: [[kv_namespaces]] ${formatConfigLabel(entry.binding)}: 'id' must be a non-empty string`); + throw new Error( + `${configRel}: [[kv_namespaces]] ${formatConfigLabel(entry.binding)}: 'id' must be a non-empty string` + ); } // Store trimmed values like the d1/r2 parsers, so a stray "abc " id isn't // forwarded with whitespace. @@ -293,9 +294,7 @@ export function parseD1DatabasesFromCfg(cfg, configRel = "wrangler config") { } const unknownKeys = Object.keys(entry).filter((key) => !allowedKeys.has(key)); if (unknownKeys.length > 0) { - throw new Error( - `${configRel}: [[d1_databases]] contains unknown field(s): ${formatConfigKeyList(unknownKeys)}` - ); + throw new Error(`${configRel}: [[d1_databases]] contains unknown field(s): ${formatConfigKeyList(unknownKeys)}`); } if (typeof entry.binding !== "string" || !entry.binding.trim()) { throw new Error(`${configRel}: [[d1_databases]].binding is required`); @@ -303,11 +302,14 @@ export function parseD1DatabasesFromCfg(cfg, configRel = "wrangler config") { assertNotRuntimeReservedBinding(configRel, "[[d1_databases]]", entry.binding); assertValidBindingName(configRel, "[[d1_databases]]", entry.binding); - const databaseRef = typeof entry.database_id === "string" && entry.database_id.trim() - ? entry.database_id.trim() - : entry.database_name; + const databaseRef = + typeof entry.database_id === "string" && entry.database_id.trim() + ? entry.database_id.trim() + : entry.database_name; if (typeof databaseRef !== "string" || !databaseRef.trim()) { - throw new Error(`${configRel}: [[d1_databases]] ${formatConfigLabel(entry.binding)}: database_name or database_id is required`); + throw new Error( + `${configRel}: [[d1_databases]] ${formatConfigLabel(entry.binding)}: database_name or database_id is required` + ); } out.push({ binding: entry.binding.trim(), databaseId: databaseRef.trim() }); } @@ -335,9 +337,7 @@ export function parseR2BucketsFromCfg(cfg, configRel = "wrangler config") { } const unknownKeys = Object.keys(entry).filter((key) => !allowedKeys.has(key)); if (unknownKeys.length > 0) { - throw new Error( - `${configRel}: [[r2_buckets]] contains unknown field(s): ${formatConfigKeyList(unknownKeys)}` - ); + throw new Error(`${configRel}: [[r2_buckets]] contains unknown field(s): ${formatConfigKeyList(unknownKeys)}`); } if (typeof entry.binding !== "string" || !entry.binding.trim()) { throw new Error(`${configRel}: [[r2_buckets]].binding is required`); @@ -406,10 +406,14 @@ export function parseServicesFromCfg(cfg, configRel = "wrangler config") { // the manifest, and a non-string `binding` (e.g. ["AB"]) would be silently // String()-coerced past the BINDING_NAME_RE check below. if (typeof entry.binding !== "string" || !entry.binding.trim()) { - throw new Error(`${configRel}: [[services]] binding must be a non-empty string, got ${formatDiagnosticValue(entry.binding)}`); + throw new Error( + `${configRel}: [[services]] binding must be a non-empty string, got ${formatDiagnosticValue(entry.binding)}` + ); } if (typeof entry.service !== "string" || !entry.service.trim()) { - throw new Error(`${configRel}: [[services]] ${formatConfigLabel(entry.binding)}: service must be a non-empty string, got ${formatDiagnosticValue(entry.service)}`); + throw new Error( + `${configRel}: [[services]] ${formatConfigLabel(entry.binding)}: service must be a non-empty string, got ${formatDiagnosticValue(entry.service)}` + ); } assertNotRuntimeReservedBinding(configRel, "[[services]]", entry.binding); assertValidBindingName(configRel, "[[services]]", entry.binding); @@ -518,7 +522,9 @@ export function parseDurableObjectsFromCfg(cfg, configRel = "wrangler config") { throw new Error(`${configRel}: [[durable_objects.bindings]].name is required`); } if (entry.script_name != null) { - throw new Error(`${configRel}: [[durable_objects.bindings]] ${formatConfigLabel(entry.name)}: script_name is not supported by WDL Durable Objects yet`); + throw new Error( + `${configRel}: [[durable_objects.bindings]] ${formatConfigLabel(entry.name)}: script_name is not supported by WDL Durable Objects yet` + ); } assertNotRuntimeReservedBinding(configRel, "[[durable_objects.bindings]]", entry.name); assertValidBindingName(configRel, "[[durable_objects.bindings]]", entry.name); @@ -570,10 +576,14 @@ export function parseWorkflowsFromCfg(cfg, configRel = "wrangler config") { ); } if (entry.script_name != null) { - throw new Error(`${configRel}: [[workflows]] ${formatConfigLabel(entry.name)}: script_name is not supported by WDL Workflows`); + throw new Error( + `${configRel}: [[workflows]] ${formatConfigLabel(entry.name)}: script_name is not supported by WDL Workflows` + ); } if (RESERVED_OBJECT_KEYS.has(entry.name)) { - throw new Error(`${configRel}: [[workflows]] ${formatConfigLabel(entry.name)}: name is a reserved Object.prototype key`); + throw new Error( + `${configRel}: [[workflows]] ${formatConfigLabel(entry.name)}: name is a reserved Object.prototype key` + ); } if (seenNames.has(entry.name)) { throw new Error(`${configRel}: [[workflows]] duplicate name ${formatDiagnosticValue(entry.name)}`); @@ -586,7 +596,9 @@ export function parseWorkflowsFromCfg(cfg, configRel = "wrangler config") { } assertNotRuntimeReservedBinding(configRel, "[[workflows]]", entry.binding); if (RESERVED_OBJECT_KEYS.has(entry.binding)) { - throw new Error(`${configRel}: [[workflows]] ${formatConfigLabel(entry.name)}: binding is a reserved Object.prototype key`); + throw new Error( + `${configRel}: [[workflows]] ${formatConfigLabel(entry.name)}: binding is a reserved Object.prototype key` + ); } if (seenBindings.has(entry.binding)) { throw new Error(`${configRel}: [[workflows]] duplicate binding ${formatDiagnosticValue(entry.binding)}`); @@ -630,7 +642,7 @@ export function parseExportsFromCfg(cfg, configRel = "wrangler config") { if (!Array.isArray(cfg.exports)) { throw new Error( `${configRel}: WDL [[exports]] must be an array of tables; ` + - "Wrangler declarative exports objects are not supported" + "Wrangler declarative exports objects are not supported" ); } /** @type {ExportEntry[]} */ diff --git a/lib/wrangler/command.js b/lib/wrangler/command.js index 18723fa..90e55e4 100644 --- a/lib/wrangler/command.js +++ b/lib/wrangler/command.js @@ -76,8 +76,8 @@ export function resolveWranglerCommand({ // .cmd shim Node refuses to run. throw new CliError( "No runnable wrangler found. Install wrangler@^4 in the Worker project " + - "(npm i -D wrangler), set WDL_WRANGLER_BIN to a runnable wrangler entry, " + - "or set WDL_ALLOW_NPX_WRANGLER=1." + "(npm i -D wrangler), set WDL_WRANGLER_BIN to a runnable wrangler entry, " + + "or set WDL_ALLOW_NPX_WRANGLER=1." ); } @@ -115,7 +115,7 @@ export function checkWranglerVersion({ execFile = execFileSync, cwd, env, wrangl if (parsed < MIN_WRANGLER_MAJOR) { throw new CliError( `wdl deploy requires Wrangler v${MIN_WRANGLER_MAJOR} (wrangler@^${MIN_WRANGLER_MAJOR}); ` + - `found v${parsed}. Upgrade the Worker project's wrangler dependency before deploying.` + `found v${parsed}. Upgrade the Worker project's wrangler dependency before deploying.` ); } } @@ -147,11 +147,7 @@ export function wranglerChildEnv(env) { export function formatWranglerFailure(rawErr) { const err = asExecFailure(rawErr); const reason = escapeTerminalText(err.status ?? err.signal ?? err.message ?? "unknown"); - const output = [err.stdout, err.stderr] - .map(toText) - .filter(Boolean) - .join("\n") - .trim(); + const output = [err.stdout, err.stderr].map(toText).filter(Boolean).join("\n").trim(); if (!output) return `wrangler build failed (${reason})`; return `wrangler build failed (${reason})\n${escapeTerminalLines(truncateOutput(output))}`; } @@ -256,11 +252,7 @@ function pathWrangler(env, platform) { function formatWranglerVersionFailure(rawErr) { const err = asExecFailure(rawErr); const reason = escapeTerminalText(err.status ?? err.signal ?? err.message ?? "unknown"); - const output = [err.stdout, err.stderr] - .map(toText) - .filter(Boolean) - .join("\n") - .trim(); + const output = [err.stdout, err.stderr].map(toText).filter(Boolean).join("\n").trim(); const shownOutput = escapeTerminalLines(truncateOutput(output)); let message = output ? `wrangler version check failed (${reason})\n${shownOutput}` diff --git a/lib/wrangler/config.js b/lib/wrangler/config.js index c53336a..51a2a15 100644 --- a/lib/wrangler/config.js +++ b/lib/wrangler/config.js @@ -18,11 +18,7 @@ import { asRecord } from "./utils.js"; * @typedef {Record} WranglerConfig */ -const TOP_LEVEL_ONLY_ENV_KEYS = new Set([ - "name", - "keep_vars", - "send_metrics", -]); +const TOP_LEVEL_ONLY_ENV_KEYS = new Set(["name", "keep_vars", "send_metrics"]); // Runtime/deploy-facing Wrangler keys the WDL manifest has no mapping for. // Reject them loudly: wrangler dry-run accepts them happily, so a silent @@ -226,8 +222,7 @@ function stripWdlConfigExtensions(cfg) { export function validateUnsupportedWranglerConfig(rawCfg, envName, configRel = "wrangler config") { const cfg = asRecord(rawCfg); const envTable = cfg ? asRecord(cfg.env) : null; - const selectedEnvCfg = - envName && envTable ? asRecord(envTable[envName]) : null; + const selectedEnvCfg = envName && envTable ? asRecord(envTable[envName]) : null; const shownConfig = escapeTerminalText(configRel); const shownEnv = escapeTerminalText(envName ?? ""); @@ -247,7 +242,7 @@ export function validateUnsupportedWranglerConfig(rawCfg, envName, configRel = " // The control plane rejects a deploy carrying a top-level allowedCallers, so // fail fast here (before the wrangler bundle) rather than after a control 400. const allowedCallersHint = - 'Authorize cross-namespace service-binding callers on the target via ' + + "Authorize cross-namespace service-binding callers on the target via " + '[[exports]] (entrypoint = "default", allowed_callers = [...]).'; if (hasConfiguredKey(cfg, "allowed_callers")) { throw new Error(`${shownConfig} uses top-level allowed_callers — removed. ${allowedCallersHint}`); @@ -276,8 +271,7 @@ export function resolveWranglerConfig(rawCfg, envName, configRel = "wrangler con if (!envName) { if (availableEnvs.length) { throw new Error( - `${shownConfig}: named environments found (${shownAvailableEnvs}); ` + - `pass --env or set CLOUDFLARE_ENV` + `${shownConfig}: named environments found (${shownAvailableEnvs}); ` + `pass --env or set CLOUDFLARE_ENV` ); } return { cfg, envName: null }; @@ -289,10 +283,7 @@ export function resolveWranglerConfig(rawCfg, envName, configRel = "wrangler con const envTable = asRecord(cfg.env); if (!envTable || !Object.hasOwn(envTable, envName)) { - throw new Error( - `${shownConfig}: environment "${shownEnv}" not found ` + - `(available: ${shownAvailableEnvs})` - ); + throw new Error(`${shownConfig}: environment "${shownEnv}" not found ` + `(available: ${shownAvailableEnvs})`); } const envCfg = asRecord(envTable[envName]); diff --git a/lib/wrangler/modules.js b/lib/wrangler/modules.js index c87a3a0..30c2eb8 100644 --- a/lib/wrangler/modules.js +++ b/lib/wrangler/modules.js @@ -12,9 +12,12 @@ import { manifestMap } from "./utils.js"; */ function moduleFsError(action, relPath, err) { const message = err instanceof Error ? err.message : String(err); - return new Error(`wrangler output: failed to ${action} ${formatDiagnosticValue(relPath)}: ${escapeTerminalText(message)}`, { - cause: err, - }); + return new Error( + `wrangler output: failed to ${action} ${formatDiagnosticValue(relPath)}: ${escapeTerminalText(message)}`, + { + cause: err, + } + ); } // Skip only the known incidentals (.map, README.md). Dropping any other diff --git a/package-lock.json b/package-lock.json index b2c1355..13a31cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@types/node": "^22.20.1", "eslint": "^10.7.0", "globals": "^17.7.0", + "prettier": "3.9.6", "typescript": "^6.0.3" }, "engines": { @@ -2140,6 +2141,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/package.json b/package.json index fe78f35..76fc736 100644 --- a/package.json +++ b/package.json @@ -52,11 +52,13 @@ "node": ">=22" }, "scripts": { + "format": "prettier --write .", + "format:check": "prettier --check .", "lint": "eslint .", "typecheck": "tsc --noEmit", "test": "node --test tests/unit/cli-*.test.js", "test:integration:live": "node --test tests/integration/cli-live.test.js", - "prepublishOnly": "npm run lint && npm run typecheck && npm test" + "prepublishOnly": "npm run format:check && npm run lint && npm run typecheck && npm test" }, "publishConfig": { "access": "public" @@ -66,6 +68,7 @@ "@types/node": "^22.20.1", "eslint": "^10.7.0", "globals": "^17.7.0", + "prettier": "3.9.6", "typescript": "^6.0.3" } } diff --git a/templates/AGENTS.md b/templates/AGENTS.md index f993e3a..cc82c91 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -50,15 +50,14 @@ plane is canonical for unsupported runtime shapes such as unsupported workerd compatibility flags and WDL-reserved injected module names. The CLI still fails fast for cheap local cases such as Python Workers modules, unmapped top-level or selected-env Wrangler runtime/deploy keys (`[site]`, `pages_build_output_dir`, -etc.), and ambiguous runtime `env` name collisions -between `[vars]`, explicit bindings, and the implicit `ASSETS` binding. -For an operator-enabled routed Worker, explicit `workers_dev = false` keeps its -pattern routes active while disabling the default platform-domain URL; it -requires at least one `route` / `routes` pattern and is not inferred. -The deploy summary prints every active route-pattern URL hint, preserving the -trailing `*` on prefix patterns, and includes the platform-domain URL only while -it is enabled. Cloudflare's separate `preview_urls` field is unsupported and -rejected by the CLI. +etc.), and ambiguous runtime `env` name collisions between `[vars]`, explicit +bindings, and the implicit `ASSETS` binding. For an operator-enabled routed +Worker, explicit `workers_dev = false` keeps its pattern routes active while +disabling the default platform-domain URL; it requires at least one `route` / +`routes` pattern and is not inferred. The deploy summary prints every active +route-pattern URL hint, preserving the trailing `*` on prefix patterns, and +includes the platform-domain URL only while it is enabled. Cloudflare's separate +`preview_urls` field is unsupported and rejected by the CLI. ## Runnable end-to-end examples @@ -80,9 +79,9 @@ When a snippet is not enough and you need a complete working file tree: ## Project-level anti-patterns - ❌ Hardcoding third-party API tokens or keys into code, `.env`, or Wrangler - config. Push them with `wdl secret put --worker ` — the - secret value is read from stdin (type it interactively, or pipe / redirect it - in, e.g. `printf '%s' "$VALUE" | wdl secret put --worker `); it is + config. Push them with `wdl secret put --worker ` — the secret + value is read from stdin (type it interactively, or pipe / redirect it in, + e.g. `printf '%s' "$VALUE" | wdl secret put --worker `); it is deliberately not a command-line argument so it stays out of shell history. - ❌ Testing platform bindings with `wrangler dev` — `[[platform_bindings]]` never resolves in any local runtime; the binding is `undefined` locally and diff --git a/tests/integration/cli-live.test.js b/tests/integration/cli-live.test.js index 00481dc..2ffdfbb 100644 --- a/tests/integration/cli-live.test.js +++ b/tests/integration/cli-live.test.js @@ -129,353 +129,422 @@ const LIVE_WORKER_COMPATIBILITY_DATE = process.env.WDL_LIVE_COMPATIBILITY_DATE | const LIVE_TIMEOUT_MS = 20 * 60_000; const TENANT_REQUEST_TIMEOUT_MS = 30_000; -test("live CLI integration covers command surface against a WDL control plane", { - timeout: LIVE_TIMEOUT_MS, -}, async (t) => { - const ctx = createLiveContext(); - const tempRoot = mkdtempSync(path.join(os.tmpdir(), "wdl-cli-live-")); - /** @type {Array<() => Promise>} */ - const cleanup = []; - let appDir = ""; - let doDir = ""; - let wfDir = ""; - let envDir = ""; - let initDir = ""; - /** @type {NodeJS.ProcessEnv | null} */ - let storeEnv = null; - const cleaned = { - appWorker: false, - d1: false, - }; - - /** - * @param {string} label - * @param {() => unknown} fn - */ - const cleanupStep = (label, fn) => { - cleanup.push(async () => { - try { - await fn(); - } catch (err) { - console.error(`cleanup warning (${label}): ${errorMessage(err)}`); - } - }); - }; - /** - * @template T - * @param {string} name - * @param {() => T | Promise} fn - * @returns {Promise} - */ - const step = (name, fn) => runStep(t, name, fn); - - try { - const activeTenant = await runStep(t, "preflight and temporary tenant token", async () => { - await assertControlReachable(ctx); - const provisioned = await provisionTenantToken(ctx); - cleanupStep("temporary tokens", () => provisioned.revoke?.()); - return provisioned; - }); - if (!activeTenant) throw new Error("preflight did not provision a tenant token"); - - const ns = activeTenant.ns; - const appWorker = "cli-live-app"; - const doWorker = "cli-live-do"; - const wfWorker = "cli-live-wf"; - const envWorker = "cli-live-env"; - const dbName = `${ns}-main`; - const bucket = `cli-live-${ns}`; - const kvId = `${ns}-kv`; - const queueName = `${ns}-jobs`; - const objectKey = `objects/${ns}/sample.txt`; - const xdg = path.join(tempRoot, "xdg"); - const commonEnv = integrationEnv(ctx, { XDG_CONFIG_HOME: xdg }); - const noCliEnv = withoutCliControlEnv(commonEnv); - const directTenantEnv = integrationEnv(ctx, { - XDG_CONFIG_HOME: xdg, - ADMIN_TOKEN: activeTenant.token, - CONTROL_URL: ctx.controlUrl, - WDL_NS: ns, - }); +test( + "live CLI integration covers command surface against a WDL control plane", + { + timeout: LIVE_TIMEOUT_MS, + }, + async (t) => { + const ctx = createLiveContext(); + const tempRoot = mkdtempSync(path.join(os.tmpdir(), "wdl-cli-live-")); + /** @type {Array<() => Promise>} */ + const cleanup = []; + let appDir = ""; + let doDir = ""; + let wfDir = ""; + let envDir = ""; + let initDir = ""; + /** @type {NodeJS.ProcessEnv | null} */ + let storeEnv = null; + const cleaned = { + appWorker: false, + d1: false, + }; /** - * @param {string[]} args - * @param {RunWrapperOptions} [options] - * @returns {RunResult} + * @param {string} label + * @param {() => unknown} fn */ - const run = (args, options = {}) => runWdl(args, { - cwd: options.cwd || CLI_ROOT, - env: options.env ?? commonEnv, - input: options.input, - timeoutMs: options.timeoutMs, - }); + const cleanupStep = (label, fn) => { + cleanup.push(async () => { + try { + await fn(); + } catch (err) { + console.error(`cleanup warning (${label}): ${errorMessage(err)}`); + } + }); + }; /** - * @template [T=unknown] - * @param {string[]} args - * @param {RunWrapperOptions} [options] - * @returns {T} + * @template T + * @param {string} name + * @param {() => T | Promise} fn + * @returns {Promise} */ - const runJson = (args, options = {}) => JSON.parse(run(args, options).stdout); - - await step("top-level help and command help", () => { - run(["--version"]); - run(["help"]); - for (const command of [ - "init", "deploy", "secret", "secrets", "workers", "delete", "d1", "r2", - "tail", "workflows", "token", "config", "doctor", "whoami", - ]) { - run([command, "--help"]); - } - }); + const step = (name, fn) => runStep(t, name, fn); - await step("init command scaffolds a project", () => { - initDir = path.join(tempRoot, "init-project"); - run(["init", "init-project", "--ns", ns, "--worker", "init-worker"], { cwd: tempRoot }); - assert.match(readFileSync(path.join(initDir, "wrangler.jsonc"), "utf8"), /2026-06-17/); - }); + try { + const activeTenant = await runStep(t, "preflight and temporary tenant token", async () => { + await assertControlReachable(ctx); + const provisioned = await provisionTenantToken(ctx); + cleanupStep("temporary tokens", () => provisioned.revoke?.()); + return provisioned; + }); + if (!activeTenant) throw new Error("preflight did not provision a tenant token"); + + const ns = activeTenant.ns; + const appWorker = "cli-live-app"; + const doWorker = "cli-live-do"; + const wfWorker = "cli-live-wf"; + const envWorker = "cli-live-env"; + const dbName = `${ns}-main`; + const bucket = `cli-live-${ns}`; + const kvId = `${ns}-kv`; + const queueName = `${ns}-jobs`; + const objectKey = `objects/${ns}/sample.txt`; + const xdg = path.join(tempRoot, "xdg"); + const commonEnv = integrationEnv(ctx, { XDG_CONFIG_HOME: xdg }); + const noCliEnv = withoutCliControlEnv(commonEnv); + const directTenantEnv = integrationEnv(ctx, { + XDG_CONFIG_HOME: xdg, + ADMIN_TOKEN: activeTenant.token, + CONTROL_URL: ctx.controlUrl, + WDL_NS: ns, + }); - await step("token store commands", () => { - run([ - "token", "set", - "--ns", ns, - "--control-url", ctx.controlUrl, - "--label", "live integration", - "--default", - ], { - input: `${activeTenant.token}\n`, - env: noCliEnv, + /** + * @param {string[]} args + * @param {RunWrapperOptions} [options] + * @returns {RunResult} + */ + const run = (args, options = {}) => + runWdl(args, { + cwd: options.cwd || CLI_ROOT, + env: options.env ?? commonEnv, + input: options.input, + timeoutMs: options.timeoutMs, + }); + /** + * @template [T=unknown] + * @param {string[]} args + * @param {RunWrapperOptions} [options] + * @returns {T} + */ + const runJson = (args, options = {}) => JSON.parse(run(args, options).stdout); + + await step("top-level help and command help", () => { + run(["--version"]); + run(["help"]); + for (const command of [ + "init", + "deploy", + "secret", + "secrets", + "workers", + "delete", + "d1", + "r2", + "tail", + "workflows", + "token", + "config", + "doctor", + "whoami", + ]) { + run([command, "--help"]); + } }); - const tokenList = runJson(["token", "list", "--json"], { - env: noCliEnv, + + await step("init command scaffolds a project", () => { + initDir = path.join(tempRoot, "init-project"); + run(["init", "init-project", "--ns", ns, "--worker", "init-worker"], { cwd: tempRoot }); + assert.match(readFileSync(path.join(initDir, "wrangler.jsonc"), "utf8"), /2026-06-17/); }); - assert.equal(/** @type {TokenListEntry[]} */ (tokenList)[0]?.namespace, ns); - run(["token", "use", ns], { env: noCliEnv }); - storeEnv = noCliEnv; - assert.equal(storeEnv.CONTROL_URL, undefined); - assert.equal(storeEnv.ADMIN_TOKEN, undefined); - assert.equal(storeEnv.WDL_NS, undefined); - }); - await step("config, whoami, and doctor commands", () => { - const config = /** @type {ConfigExplain} */ (runJson(["config", "explain", "--json"], { env: storeEnv })); - assert.equal(config.namespace.value, ns); - const whoami = /** @type {WhoamiResult} */ (runJson(["whoami", "--json"], { env: storeEnv })); - assert.equal(whoami.namespace.value, ns); - assert.equal(whoami.namespace.matchesConfigured, true); - const doctor = /** @type {DoctorResult} */ (runJson(["doctor", "--json"], { env: storeEnv, cwd: initDir })); - assert.ok(Array.isArray(doctor.checks)); - }); + await step("token store commands", () => { + run(["token", "set", "--ns", ns, "--control-url", ctx.controlUrl, "--label", "live integration", "--default"], { + input: `${activeTenant.token}\n`, + env: noCliEnv, + }); + const tokenList = runJson(["token", "list", "--json"], { + env: noCliEnv, + }); + assert.equal(/** @type {TokenListEntry[]} */ (tokenList)[0]?.namespace, ns); + run(["token", "use", ns], { env: noCliEnv }); + storeEnv = noCliEnv; + assert.equal(storeEnv.CONTROL_URL, undefined); + assert.equal(storeEnv.ADMIN_TOKEN, undefined); + assert.equal(storeEnv.WDL_NS, undefined); + }); - await step("write live app and workflow fixtures", () => { - appDir = writeAppProject(tempRoot, { worker: appWorker, dbName, bucket, kvId, queueName }); - doDir = writeDurableObjectProject(tempRoot, { worker: doWorker }); - wfDir = writeWorkflowProject(tempRoot, { worker: wfWorker }); - envDir = writeEnvProject(tempRoot, { worker: envWorker }); - }); + await step("config, whoami, and doctor commands", () => { + const config = /** @type {ConfigExplain} */ (runJson(["config", "explain", "--json"], { env: storeEnv })); + assert.equal(config.namespace.value, ns); + const whoami = /** @type {WhoamiResult} */ (runJson(["whoami", "--json"], { env: storeEnv })); + assert.equal(whoami.namespace.value, ns); + assert.equal(whoami.namespace.matchesConfigured, true); + const doctor = /** @type {DoctorResult} */ (runJson(["doctor", "--json"], { env: storeEnv, cwd: initDir })); + assert.ok(Array.isArray(doctor.checks)); + }); - cleanupStep("delete d1 database", () => { - if (!cleaned.d1) run(["d1", "delete", dbName, "--yes", "--json"], { env: directTenantEnv }); - }); - cleanupStep("delete app worker", () => { - if (!cleaned.appWorker) run(["delete", "worker", appWorker, "--yes", "--json"], { env: directTenantEnv }); - }); - cleanupStep("delete durable object worker", () => { - run(["delete", "worker", doWorker, "--yes", "--json"], { env: directTenantEnv }); - }); - cleanupStep("delete workflow worker", () => { - try { - run(["delete", "worker", wfWorker, "--yes", "--json"], { env: directTenantEnv }); - } catch (/** @type {unknown} */ err) { - const message = err instanceof Error ? err.message : undefined; - if (String(message || err).includes("workflow_instances_active")) { - console.error(`cleanup note: ${ns}/${wfWorker} is retained until workflow instance retention expires`); - return; - } - throw err; - } - }); - cleanupStep("delete env worker", () => { - run(["delete", "worker", envWorker, "--yes", "--json"], { env: directTenantEnv }); - }); + await step("write live app and workflow fixtures", () => { + appDir = writeAppProject(tempRoot, { worker: appWorker, dbName, bucket, kvId, queueName }); + doDir = writeDurableObjectProject(tempRoot, { worker: doWorker }); + wfDir = writeWorkflowProject(tempRoot, { worker: wfWorker }); + envDir = writeEnvProject(tempRoot, { worker: envWorker }); + }); - await step("d1 commands create, migrate, list, execute", () => { - const createdDb = /** @type {D1CreateResult} */ (runJson(["d1", "create", dbName, "--json"], { env: storeEnv })); - assert.equal(createdDb.databaseName, dbName); - assert.ok(/** @type {D1ListResult} */ (runJson(["d1", "list", "--json"], { env: storeEnv })).databases.some((db) => - db.databaseName === dbName - )); - runJson(["d1", "migrations", "status", dbName, "--dir", "migrations", "--json"], { - cwd: appDir, - env: storeEnv, + cleanupStep("delete d1 database", () => { + if (!cleaned.d1) run(["d1", "delete", dbName, "--yes", "--json"], { env: directTenantEnv }); }); - runJson(["d1", "migrations", "apply", dbName, "--dir", "migrations", "--json"], { - cwd: appDir, - env: storeEnv, + cleanupStep("delete app worker", () => { + if (!cleaned.appWorker) run(["delete", "worker", appWorker, "--yes", "--json"], { env: directTenantEnv }); }); - runJson(["d1", "migrations", "list", dbName, "--json"], { env: storeEnv }); - runJson(["d1", "execute", dbName, "--sql", "select count(*) as n from cli_live_items", "--json"], { - env: storeEnv, + cleanupStep("delete durable object worker", () => { + run(["delete", "worker", doWorker, "--yes", "--json"], { env: directTenantEnv }); + }); + cleanupStep("delete workflow worker", () => { + try { + run(["delete", "worker", wfWorker, "--yes", "--json"], { env: directTenantEnv }); + } catch (/** @type {unknown} */ err) { + const message = err instanceof Error ? err.message : undefined; + if (String(message || err).includes("workflow_instances_active")) { + console.error(`cleanup note: ${ns}/${wfWorker} is retained until workflow instance retention expires`); + return; + } + throw err; + } + }); + cleanupStep("delete env worker", () => { + run(["delete", "worker", envWorker, "--yes", "--json"], { env: directTenantEnv }); }); - }); - await step("deploy command publishes app worker", async () => { - const firstDeploy = run(["deploy", appDir], { env: storeEnv, timeoutMs: 5 * 60_000 }); - assertDeployPrintedLiveVersion(firstDeploy.stdout); - await waitForTenantJson(ctx, ns, appWorker, "/health", (body) => - /** @type {TenantHealthBody} */ (body).worker === appWorker); - }); + await step("d1 commands create, migrate, list, execute", () => { + const createdDb = /** @type {D1CreateResult} */ ( + runJson(["d1", "create", dbName, "--json"], { env: storeEnv }) + ); + assert.equal(createdDb.databaseName, dbName); + assert.ok( + /** @type {D1ListResult} */ (runJson(["d1", "list", "--json"], { env: storeEnv })).databases.some( + (db) => db.databaseName === dbName + ) + ); + runJson(["d1", "migrations", "status", dbName, "--dir", "migrations", "--json"], { + cwd: appDir, + env: storeEnv, + }); + runJson(["d1", "migrations", "apply", dbName, "--dir", "migrations", "--json"], { + cwd: appDir, + env: storeEnv, + }); + runJson(["d1", "migrations", "list", dbName, "--json"], { env: storeEnv }); + runJson(["d1", "execute", dbName, "--sql", "select count(*) as n from cli_live_items", "--json"], { + env: storeEnv, + }); + }); - await step("secret and secrets commands", () => { - run(["secret", "put", "--scope", "ns", "LIVE_NS_SECRET"], { - input: "ns-secret\n", - env: storeEnv, + await step("deploy command publishes app worker", async () => { + const firstDeploy = run(["deploy", appDir], { env: storeEnv, timeoutMs: 5 * 60_000 }); + assertDeployPrintedLiveVersion(firstDeploy.stdout); + await waitForTenantJson( + ctx, + ns, + appWorker, + "/health", + (body) => /** @type {TenantHealthBody} */ (body).worker === appWorker + ); }); - assert.ok( - /** @type {SecretListResult} */ (runJson(["secret", "list", "--scope", "ns", "--json"], { env: storeEnv })) - .keys.includes("LIVE_NS_SECRET") - ); - run(["secrets", "list", "--scope", "ns"], { env: storeEnv }); - - run(["secret", "put", "--worker", appWorker, "LIVE_WORKER_SECRET"], { - input: "worker-secret\n", - env: storeEnv, + + await step("secret and secrets commands", () => { + run(["secret", "put", "--scope", "ns", "LIVE_NS_SECRET"], { + input: "ns-secret\n", + env: storeEnv, + }); + assert.ok( + /** @type {SecretListResult} */ ( + runJson(["secret", "list", "--scope", "ns", "--json"], { env: storeEnv }) + ).keys.includes("LIVE_NS_SECRET") + ); + run(["secrets", "list", "--scope", "ns"], { env: storeEnv }); + + run(["secret", "put", "--worker", appWorker, "LIVE_WORKER_SECRET"], { + input: "worker-secret\n", + env: storeEnv, + }); + assert.ok( + /** @type {SecretListResult} */ ( + runJson(["secret", "list", "--worker", appWorker, "--json"], { env: storeEnv }) + ).keys.includes("LIVE_WORKER_SECRET") + ); }); - assert.ok( - /** @type {SecretListResult} */ (runJson(["secret", "list", "--worker", appWorker, "--json"], { env: storeEnv })) - .keys.includes("LIVE_WORKER_SECRET") - ); - }); - await step("tenant runtime exercises D1, R2, and KV bindings", async () => { - const d1ViaWorker = /** @type {TenantD1Body} */ ( - await tenantJson(ctx, ns, appWorker, "/d1?name=alice", { method: "POST" }) - ); - assert.equal(d1ViaWorker.name, "alice"); - - const r2Put = /** @type {TenantR2Body} */ ( - await tenantJson(ctx, ns, appWorker, `/r2?key=${encodeURIComponent(objectKey)}`, { - method: "POST", - }) - ); - assert.equal(r2Put.key, objectKey); - - const kvPut = await tenantJson(ctx, ns, appWorker, "/kv?key=counter"); - assert.deepEqual(kvPut, { key: "counter", value: 1 }); - }); + await step("tenant runtime exercises D1, R2, and KV bindings", async () => { + const d1ViaWorker = /** @type {TenantD1Body} */ ( + await tenantJson(ctx, ns, appWorker, "/d1?name=alice", { method: "POST" }) + ); + assert.equal(d1ViaWorker.name, "alice"); + + const r2Put = /** @type {TenantR2Body} */ ( + await tenantJson(ctx, ns, appWorker, `/r2?key=${encodeURIComponent(objectKey)}`, { + method: "POST", + }) + ); + assert.equal(r2Put.key, objectKey); + + const kvPut = await tenantJson(ctx, ns, appWorker, "/kv?key=counter"); + assert.deepEqual(kvPut, { key: "counter", value: 1 }); + }); - await step("tenant runtime exercises assets and queue delivery", async () => { - const assetUrl = /** @type {{ url?: string }} */ (await tenantJson(ctx, ns, appWorker, "/asset-url")); - assert.match(String(assetUrl.url), /hello\.txt/); - - const queued = /** @type {{ id?: string }} */ ( - await tenantJson(ctx, ns, appWorker, "/queue/enqueue?id=live-1", { method: "POST" }) - ); - assert.equal(queued.id, "live-1"); - await waitForTenantJson(ctx, ns, appWorker, "/queue/jobs?id=live-1", (body) => { - const typed = /** @type {{ jobs?: Array<{ id?: string, queue?: string }> }} */ (body); - const jobs = typed.jobs || []; - return jobs.some((job) => job.id === "live-1" && job.queue === queueName); + await step("tenant runtime exercises assets and queue delivery", async () => { + const assetUrl = /** @type {{ url?: string }} */ (await tenantJson(ctx, ns, appWorker, "/asset-url")); + assert.match(String(assetUrl.url), /hello\.txt/); + + const queued = /** @type {{ id?: string }} */ ( + await tenantJson(ctx, ns, appWorker, "/queue/enqueue?id=live-1", { method: "POST" }) + ); + assert.equal(queued.id, "live-1"); + await waitForTenantJson(ctx, ns, appWorker, "/queue/jobs?id=live-1", (body) => { + const typed = /** @type {{ jobs?: Array<{ id?: string, queue?: string }> }} */ (body); + const jobs = typed.jobs || []; + return jobs.some((job) => job.id === "live-1" && job.queue === queueName); + }); }); - }); - await step("deploy command publishes Durable Object worker", async () => { - const doDeploy = run(["deploy", doDir], { env: storeEnv, timeoutMs: 5 * 60_000 }); - assertDeployPrintedLiveVersion(doDeploy.stdout); - const durableObjectHit = /** @type {{ storedHits?: number }} */ ( - await tenantJson(ctx, ns, doWorker, "/do?room=live") - ); - assert.equal(durableObjectHit.storedHits, 1); - }); + await step("deploy command publishes Durable Object worker", async () => { + const doDeploy = run(["deploy", doDir], { env: storeEnv, timeoutMs: 5 * 60_000 }); + assertDeployPrintedLiveVersion(doDeploy.stdout); + const durableObjectHit = /** @type {{ storedHits?: number }} */ ( + await tenantJson(ctx, ns, doWorker, "/do?room=live") + ); + assert.equal(durableObjectHit.storedHits, 1); + }); - await step("r2 commands list, head, get, delete objects", () => { - assert.ok(/** @type {R2BucketsResult} */ (runJson(["r2", "buckets", "list", "--json"], { env: storeEnv })) - .buckets.some((b) => b.name === bucket)); - assert.ok(/** @type {R2ObjectsResult} */ (runJson(["r2", "objects", "list", bucket, "--prefix", `objects/${ns}/`, "--json"], { - env: storeEnv, - })).objects.some((obj) => obj.key === objectKey)); - assert.equal(/** @type {R2HeadResult} */ (runJson(["r2", "objects", "head", bucket, objectKey, "--json"], { env: storeEnv })).key, objectKey); - const outFile = path.join(tempRoot, "r2-object.txt"); - run(["r2", "objects", "get", bucket, objectKey, "--out", outFile], { env: storeEnv }); - assert.equal(readFileSync(outFile, "utf8"), "live-r2-body"); - runJson(["r2", "objects", "delete", bucket, objectKey, "--yes", "--json"], { env: storeEnv }); - }); + await step("r2 commands list, head, get, delete objects", () => { + assert.ok( + /** @type {R2BucketsResult} */ (runJson(["r2", "buckets", "list", "--json"], { env: storeEnv })).buckets.some( + (b) => b.name === bucket + ) + ); + assert.ok( + /** @type {R2ObjectsResult} */ ( + runJson(["r2", "objects", "list", bucket, "--prefix", `objects/${ns}/`, "--json"], { + env: storeEnv, + }) + ).objects.some((obj) => obj.key === objectKey) + ); + assert.equal( + /** @type {R2HeadResult} */ ( + runJson(["r2", "objects", "head", bucket, objectKey, "--json"], { env: storeEnv }) + ).key, + objectKey + ); + const outFile = path.join(tempRoot, "r2-object.txt"); + run(["r2", "objects", "get", bucket, objectKey, "--out", outFile], { env: storeEnv }); + assert.equal(readFileSync(outFile, "utf8"), "live-r2-body"); + runJson(["r2", "objects", "delete", bucket, objectKey, "--yes", "--json"], { + env: storeEnv, + }); + }); - await step("tail command receives live logs", async () => { - await assertTailReceivesLog({ - ctx, ns, worker: appWorker, env: /** @type {NodeJS.ProcessEnv} */ (storeEnv), + await step("tail command receives live logs", async () => { + await assertTailReceivesLog({ + ctx, + ns, + worker: appWorker, + env: /** @type {NodeJS.ProcessEnv} */ (storeEnv), + }); }); - }); - await step("deploy --env publishes selected environment overrides", async () => { - const envDeploy = run(["deploy", envDir, "--env", "staging"], { env: storeEnv, timeoutMs: 5 * 60_000 }); - assertDeployPrintedLiveVersion(envDeploy.stdout); - await waitForTenantJson(ctx, ns, envWorker, "/health", (body) => - /** @type {{ label?: string }} */ (body).label === "cli-live-staging"); - }); + await step("deploy --env publishes selected environment overrides", async () => { + const envDeploy = run(["deploy", envDir, "--env", "staging"], { + env: storeEnv, + timeoutMs: 5 * 60_000, + }); + assertDeployPrintedLiveVersion(envDeploy.stdout); + await waitForTenantJson( + ctx, + ns, + envWorker, + "/health", + (body) => /** @type {{ label?: string }} */ (body).label === "cli-live-staging" + ); + }); - await step("workers and delete version commands", () => { - writeAppRevision(appDir, appWorker, "v2"); - const secondDeploy = run(["deploy", appDir], { env: storeEnv, timeoutMs: 5 * 60_000 }); - assertDeployPrintedLiveVersion(secondDeploy.stdout); - const workers = /** @type {WorkersListResult} */ (runJson(["workers", "--json"], { env: storeEnv })); - const app = workers.workers.find((worker) => worker.name === appWorker); - assert.ok(app?.activeVersion, `workers list did not include an active version for ${appWorker}`); - 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", "worker", appWorker, "--dry-run", "--json"], { env: storeEnv }); - }); + await step("workers and delete version commands", () => { + writeAppRevision(appDir, appWorker, "v2"); + const secondDeploy = run(["deploy", appDir], { env: storeEnv, timeoutMs: 5 * 60_000 }); + assertDeployPrintedLiveVersion(secondDeploy.stdout); + const workers = /** @type {WorkersListResult} */ (runJson(["workers", "--json"], { env: storeEnv })); + const app = workers.workers.find((worker) => worker.name === appWorker); + assert.ok(app?.activeVersion, `workers list did not include an active version for ${appWorker}`); + 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", "worker", appWorker, "--dry-run", "--json"], { env: storeEnv }); + }); - await step("secret delete commands", () => { - run(["secret", "delete", "--scope", "ns", "LIVE_NS_SECRET", "--yes"], { env: storeEnv }); - run(["secret", "delete", "--worker", appWorker, "LIVE_WORKER_SECRET", "--yes"], { env: storeEnv }); - }); + await step("secret delete commands", () => { + run(["secret", "delete", "--scope", "ns", "LIVE_NS_SECRET", "--yes"], { env: storeEnv }); + run(["secret", "delete", "--worker", appWorker, "LIVE_WORKER_SECRET", "--yes"], { + env: storeEnv, + }); + }); - await step("workflows commands", async () => { - run(["deploy", wfDir], { env: storeEnv, timeoutMs: 5 * 60_000 }); - assert.ok(/** @type {WorkflowsListResult} */ (runJson(["workflows", "list", "--json"], { env: storeEnv })) - .workflows.some((wf) => - wf.worker === wfWorker && wf.name === "orders" - )); - await waitForTenantJson(ctx, ns, wfWorker, "/health", (body) => - /** @type {TenantHealthBody} */ (body).worker === "workflow"); - await tenantJson(ctx, ns, wfWorker, "/workflow/start?id=live-wait&wait=1"); - await waitForWorkflowStatus(runJson, storeEnv, wfWorker, "orders", "live-wait", ["waiting", "queued", "running"]); - runJson(["workflows", "instances", wfWorker, "orders", "--limit", "5", "--json"], { env: storeEnv }); - runJson([ - "workflows", "status", wfWorker, "orders", "live-wait", - "--include-steps", "--step-limit", "10", "--json", - ], { env: storeEnv }); - runJson(["workflows", "pause", wfWorker, "orders", "live-wait", "--json"], { env: storeEnv }); - runJson(["workflows", "resume", wfWorker, "orders", "live-wait", "--json"], { env: storeEnv }); - runJson(["workflows", "restart", wfWorker, "orders", "live-wait", "--yes", "--json"], { env: storeEnv }); - runJson(["workflows", "terminate", wfWorker, "orders", "live-wait", "--yes", "--json"], { env: storeEnv }); - }); + await step("workflows commands", async () => { + run(["deploy", wfDir], { env: storeEnv, timeoutMs: 5 * 60_000 }); + assert.ok( + /** @type {WorkflowsListResult} */ ( + runJson(["workflows", "list", "--json"], { env: storeEnv }) + ).workflows.some((wf) => wf.worker === wfWorker && wf.name === "orders") + ); + await waitForTenantJson( + ctx, + ns, + wfWorker, + "/health", + (body) => /** @type {TenantHealthBody} */ (body).worker === "workflow" + ); + await tenantJson(ctx, ns, wfWorker, "/workflow/start?id=live-wait&wait=1"); + await waitForWorkflowStatus(runJson, storeEnv, wfWorker, "orders", "live-wait", [ + "waiting", + "queued", + "running", + ]); + runJson(["workflows", "instances", wfWorker, "orders", "--limit", "5", "--json"], { + env: storeEnv, + }); + runJson( + ["workflows", "status", wfWorker, "orders", "live-wait", "--include-steps", "--step-limit", "10", "--json"], + { env: storeEnv } + ); + runJson(["workflows", "pause", wfWorker, "orders", "live-wait", "--json"], { + env: storeEnv, + }); + runJson(["workflows", "resume", wfWorker, "orders", "live-wait", "--json"], { + env: storeEnv, + }); + runJson(["workflows", "restart", wfWorker, "orders", "live-wait", "--yes", "--json"], { + env: storeEnv, + }); + runJson(["workflows", "terminate", wfWorker, "orders", "live-wait", "--yes", "--json"], { + env: storeEnv, + }); + }); - await step("explicit cleanup commands", () => { - runJson(["delete", "worker", appWorker, "--yes", "--json"], { env: storeEnv }); - cleaned.appWorker = true; - runJson(["d1", "delete", dbName, "--yes", "--json"], { env: storeEnv }); - cleaned.d1 = true; - run(["token", "rm", "--ns", ns], { env: noCliEnv }); - }); - } finally { - for (const fn of cleanup.reverse()) await fn(); - rmSync(tempRoot, { recursive: true, force: true }); + await step("explicit cleanup commands", () => { + runJson(["delete", "worker", appWorker, "--yes", "--json"], { env: storeEnv }); + cleaned.appWorker = true; + runJson(["d1", "delete", dbName, "--yes", "--json"], { env: storeEnv }); + cleaned.d1 = true; + run(["token", "rm", "--ns", ns], { env: noCliEnv }); + }); + } finally { + for (const fn of cleanup.reverse()) await fn(); + rmSync(tempRoot, { recursive: true, force: true }); + } } -}); +); /** @returns {LiveContext} */ function createLiveContext() { const controlUrl = normalizeControlUrl(process.env.WDL_LIVE_CONTROL_URL || DEFAULT_LOCAL_CONTROL_URL); const controlHost = new URL(controlUrl).hostname; - const isLocalControl = controlHost === "localhost" || + const isLocalControl = + controlHost === "localhost" || controlHost === "127.0.0.1" || controlHost.endsWith(".test") || controlHost.endsWith(".local"); - const connectHost = process.env.WDL_LIVE_CONTROL_CONNECT_HOST || - (isLocalControl ? "localhost" : ""); + const connectHost = process.env.WDL_LIVE_CONTROL_CONNECT_HOST || (isLocalControl ? "localhost" : ""); if (connectHost) process.env.CONTROL_CONNECT_HOST = connectHost; else delete process.env.CONTROL_CONNECT_HOST; @@ -557,8 +626,8 @@ function runWdl(args, { cwd, env, input = "", timeoutMs = 120_000 }) { if (result.status !== 0) { throw new Error( `wdl ${args.join(" ")} failed with exit ${result.status}\n` + - `stdout:\n${result.stdout}\n` + - `stderr:\n${result.stderr}` + `stdout:\n${result.stdout}\n` + + `stderr:\n${result.stderr}` ); } return { stdout: result.stdout, stderr: result.stderr }; @@ -599,8 +668,8 @@ async function assertControlReachable(ctx) { } catch (err) { throw new Error( `live integration preflight could not reach ${ctx.controlUrl}; ` + - `start the local WDL dev stack or set WDL_LIVE_CONTROL_URL / token env vars. ` + - `Underlying error: ${errorMessage(err)}`, + `start the local WDL dev stack or set WDL_LIVE_CONTROL_URL / token env vars. ` + + `Underlying error: ${errorMessage(err)}`, { cause: err } ); } @@ -625,15 +694,17 @@ async function provisionTenantToken(ctx) { throw new Error("WDL live integration needs WDL_LIVE_ISSUER_TOKEN, WDL_LIVE_TENANT_TOKEN, or an admin token"); } const expiresAt = new Date(Date.now() + 60 * 60_000).toISOString(); - const issuer = /** @type {{ token: string, tokenId?: string }} */ (await controlJson(ctx, "/auth/tokens", ctx.adminToken, { - method: "POST", - body: { - kind: "token-issuer", - issueTemplates: [ctx.template], - label: "cli live integration issuer", - expiresAt, - }, - })); + const issuer = /** @type {{ token: string, tokenId?: string }} */ ( + await controlJson(ctx, "/auth/tokens", ctx.adminToken, { + method: "POST", + body: { + kind: "token-issuer", + issueTemplates: [ctx.template], + label: "cli live integration issuer", + expiresAt, + }, + }) + ); let delegated; try { delegated = await issueDelegatedTenantToken(ctx, issuer.token); @@ -641,9 +712,7 @@ async function provisionTenantToken(ctx) { try { await revokeIssuedTokens(ctx, [issuer.tokenId]); } catch (revokeErr) { - console.error( - `cleanup warning (temporary issuer token): ${errorMessage(revokeErr)}` - ); + console.error(`cleanup warning (temporary issuer token): ${errorMessage(revokeErr)}`); } throw err; } @@ -671,8 +740,8 @@ async function issueDelegatedTenantToken(ctx, issuerToken) { } catch (err) { throw new Error( `live integration could not issue delegated token from ${ctx.controlUrl}; ` + - `verify the control plane is reachable and the issuer token allows template ${ctx.template}. ` + - `Underlying error: ${errorMessage(err)}`, + `verify the control plane is reachable and the issuer token allows template ${ctx.template}. ` + + `Underlying error: ${errorMessage(err)}`, { cause: err } ); } @@ -741,11 +810,20 @@ function writeAppProject(root, { worker, dbName, bucket, kvId, queueName }) { mkdirSync(path.join(dir, "src"), { recursive: true }); mkdirSync(path.join(dir, "migrations"), { recursive: true }); mkdirSync(path.join(dir, "public"), { recursive: true }); - writeFileSync(path.join(dir, "package.json"), JSON.stringify({ - private: true, - type: "module", - }, null, 2) + "\n"); - writeFileSync(path.join(dir, "wrangler.toml"), ` + writeFileSync( + path.join(dir, "package.json"), + JSON.stringify( + { + private: true, + type: "module", + }, + null, + 2 + ) + "\n" + ); + writeFileSync( + path.join(dir, "wrangler.toml"), + ` name = "${worker}" main = "src/index.js" compatibility_date = "${LIVE_WORKER_COMPATIBILITY_DATE}" @@ -782,14 +860,18 @@ directory = "public" [vars] LABEL = "cli-live" -`); +` + ); writeFileSync(path.join(dir, "public", "hello.txt"), "hello from live assets\n"); - writeFileSync(path.join(dir, "migrations", "001_init.sql"), ` + writeFileSync( + path.join(dir, "migrations", "001_init.sql"), + ` create table if not exists cli_live_items ( name text primary key, count integer not null default 0 ); -`); +` + ); writeAppRevision(dir, worker, "v1"); return dir; } @@ -907,11 +989,20 @@ export default { function writeDurableObjectProject(root, { worker }) { const dir = path.join(root, "durable-object"); mkdirSync(path.join(dir, "src"), { recursive: true }); - writeFileSync(path.join(dir, "package.json"), JSON.stringify({ - private: true, - type: "module", - }, null, 2) + "\n"); - writeFileSync(path.join(dir, "wrangler.toml"), ` + writeFileSync( + path.join(dir, "package.json"), + JSON.stringify( + { + private: true, + type: "module", + }, + null, + 2 + ) + "\n" + ); + writeFileSync( + path.join(dir, "wrangler.toml"), + ` name = "${worker}" main = "src/index.js" compatibility_date = "${LIVE_WORKER_COMPATIBILITY_DATE}" @@ -923,7 +1014,8 @@ class_name = "Room" [[migrations]] tag = "v1" new_sqlite_classes = ["Room"] -`); +` + ); writeFileSync(path.join(dir, "src", "index.js"), durableObjectWorkerSource(worker)); return dir; } @@ -991,11 +1083,20 @@ export default { function writeEnvProject(root, { worker }) { const dir = path.join(root, "env-project"); mkdirSync(path.join(dir, "src"), { recursive: true }); - writeFileSync(path.join(dir, "package.json"), JSON.stringify({ - private: true, - type: "module", - }, null, 2) + "\n"); - writeFileSync(path.join(dir, "wrangler.toml"), ` + writeFileSync( + path.join(dir, "package.json"), + JSON.stringify( + { + private: true, + type: "module", + }, + null, + 2 + ) + "\n" + ); + writeFileSync( + path.join(dir, "wrangler.toml"), + ` name = "${worker}" main = "src/index.js" compatibility_date = "${LIVE_WORKER_COMPATIBILITY_DATE}" @@ -1005,8 +1106,11 @@ LABEL = "base" [env.staging.vars] LABEL = "cli-live-staging" -`); - writeFileSync(path.join(dir, "src", "index.js"), ` +` + ); + writeFileSync( + path.join(dir, "src", "index.js"), + ` export default { async fetch(_request, env) { return new Response(JSON.stringify({ label: env.LABEL }), { @@ -1014,7 +1118,8 @@ export default { }); }, }; -`); +` + ); return dir; } @@ -1026,11 +1131,20 @@ export default { function writeWorkflowProject(root, { worker }) { const dir = path.join(root, "workflow"); mkdirSync(path.join(dir, "src"), { recursive: true }); - writeFileSync(path.join(dir, "package.json"), JSON.stringify({ - private: true, - type: "module", - }, null, 2) + "\n"); - writeFileSync(path.join(dir, "wrangler.toml"), ` + writeFileSync( + path.join(dir, "package.json"), + JSON.stringify( + { + private: true, + type: "module", + }, + null, + 2 + ) + "\n" + ); + writeFileSync( + path.join(dir, "wrangler.toml"), + ` name = "${worker}" main = "src/index.js" compatibility_date = "${LIVE_WORKER_COMPATIBILITY_DATE}" @@ -1039,7 +1153,8 @@ compatibility_date = "${LIVE_WORKER_COMPATIBILITY_DATE}" name = "orders" binding = "ORDERS" class_name = "OrderWorkflow" -`); +` + ); writeFileSync(path.join(dir, "src", "index.js"), workflowWorkerSource()); return dir; } @@ -1138,23 +1253,28 @@ function tenantRequest(ctx, ns, worker, pathname, init = {}) { const requestPath = `/${worker}${pathname.startsWith("/") ? pathname : `/${pathname}`}`; return new Promise((resolve, reject) => { - const req = lib.request({ - hostname: base.hostname, - port: Number(base.port) || (base.protocol === "https:" ? 443 : 80), - protocol: base.protocol, - method: init.method || "GET", - path: requestPath, - headers, - }, (res) => { - /** @type {Buffer[]} */ - const chunks = []; - res.on("data", (/** @type {Buffer} */ chunk) => chunks.push(chunk)); - res.on("end", () => resolve({ - status: res.statusCode || 0, - headers: res.headers, - body: Buffer.concat(chunks).toString("utf8"), - })); - }); + const req = lib.request( + { + hostname: base.hostname, + port: Number(base.port) || (base.protocol === "https:" ? 443 : 80), + protocol: base.protocol, + method: init.method || "GET", + path: requestPath, + headers, + }, + (res) => { + /** @type {Buffer[]} */ + const chunks = []; + res.on("data", (/** @type {Buffer} */ chunk) => chunks.push(chunk)); + res.on("end", () => + resolve({ + status: res.statusCode || 0, + headers: res.headers, + body: Buffer.concat(chunks).toString("utf8"), + }) + ); + } + ); req.on("error", reject); req.setTimeout(TENANT_REQUEST_TIMEOUT_MS, () => { req.destroy(new Error(`tenant ${worker}${requestPath} timed out after ${TENANT_REQUEST_TIMEOUT_MS}ms`)); @@ -1177,8 +1297,12 @@ async function assertTailReceivesLog({ ctx, ns, worker, env }) { let stderr = ""; tail.stdout.setEncoding("utf8"); tail.stderr.setEncoding("utf8"); - tail.stdout.on("data", (/** @type {string} */ chunk) => { stdout += chunk; }); - tail.stderr.on("data", (/** @type {string} */ chunk) => { stderr += chunk; }); + tail.stdout.on("data", (/** @type {string} */ chunk) => { + stdout += chunk; + }); + tail.stderr.on("data", (/** @type {string} */ chunk) => { + stderr += chunk; + }); try { await waitUntil("tail connection", async () => stderr.includes("tail connected")); const id = randomBytes(3).toString("hex"); @@ -1216,7 +1340,9 @@ async function waitForWorkflowStatus(runJson, env, worker, workflow, instanceId, let body; await waitUntil(`workflow ${instanceId} status`, async () => { body = /** @type {WorkflowStatusResult} */ ( - runJson(["workflows", "status", worker, workflow, instanceId, "--include-steps", "--json"], { env }) + runJson(["workflows", "status", worker, workflow, instanceId, "--include-steps", "--json"], { + env, + }) ); return statuses.includes(body.status); }); diff --git a/tests/unit/cli-bundle-modules.test.js b/tests/unit/cli-bundle-modules.test.js index 3ca2e01..1cade18 100644 --- a/tests/unit/cli-bundle-modules.test.js +++ b/tests/unit/cli-bundle-modules.test.js @@ -1,9 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { - inferType, - toWireModule, -} from "../../lib/bundle-modules.js"; +import { inferType, toWireModule } from "../../lib/bundle-modules.js"; test("bundle module helpers infer file types", () => { assert.equal(inferType("worker.js"), "module"); @@ -19,9 +16,11 @@ test("bundle module helpers infer file types", () => { test("toWireModule encodes supported module types", () => { assert.equal(toWireModule(Buffer.from("export default {}"), "module"), "export default {}"); - assert.deepEqual(toWireModule(Buffer.from("module.exports = {}"), "cjs"), { cjs: "module.exports = {}" }); + assert.deepEqual(toWireModule(Buffer.from("module.exports = {}"), "cjs"), { + cjs: "module.exports = {}", + }); assert.deepEqual(toWireModule(Buffer.from("hello"), "text"), { text: "hello" }); - assert.deepEqual(toWireModule(Buffer.from("{\"a\":1}"), "json"), { json: { a: 1 } }); + assert.deepEqual(toWireModule(Buffer.from('{"a":1}'), "json"), { json: { a: 1 } }); assert.deepEqual(toWireModule(Buffer.from([1, 2]), "wasm"), { wasm_b64: "AQI=" }); assert.deepEqual(toWireModule(Buffer.from([3, 4]), "data"), { data_b64: "AwQ=" }); assert.throws(() => toWireModule(Buffer.from("print('hi')"), "py"), /Unknown module type "py"/); diff --git a/tests/unit/cli-command.test.js b/tests/unit/cli-command.test.js index 63a7ccf..d52d218 100644 --- a/tests/unit/cli-command.test.js +++ b/tests/unit/cli-command.test.js @@ -13,13 +13,13 @@ import { ESC, assertNoRawTerminalControls, response } from "./helpers.js"; const define = (spec) => defineCommand({ name: "t", summary: "t", ...spec }); test("defineCommand assembles flag presets and custom options", async () => { - let seen = /** @type {{ values: Record, positionals: string[] }} */ ( - /** @type {unknown} */ (null) - ); + let seen = /** @type {{ values: Record, positionals: string[] }} */ (/** @type {unknown} */ (null)); const cmd = define({ options: ["ns", "control", "json", "help", defineCliOption("tag", { type: "string" }, "--tag ", "Tag.")], usage: () => "usage", - run: ({ values, positionals }) => { seen = { values, positionals }; }, + run: ({ values, positionals }) => { + seen = { values, positionals }; + }, }); await cmd.run(["pos1", "--ns", "demo", "--json", "--tag", "x", "--token", "t"], { env: {} }); assert.equal(seen.values.ns, "demo"); @@ -30,7 +30,13 @@ test("defineCommand assembles flag presets and custom options", async () => { }); test("defineCommand exposes name/summary metadata and the parse schema", () => { - const cmd = defineCommand({ name: "workers", summary: "List workers.", options: ["ns", "help"], usage: () => "", run: () => {} }); + const cmd = defineCommand({ + name: "workers", + summary: "List workers.", + options: ["ns", "help"], + usage: () => "", + run: () => {}, + }); const { parseOptions, ...meta } = cmd.meta; assert.deepEqual(meta, { name: "workers", summary: "List workers.", autoloadEnv: true }); // The dispatcher pre-scans argv with this schema (ns overlay, help alias). @@ -54,34 +60,42 @@ test("defineCommand direct runner escapes parseArgs errors", async () => { assertNoRawTerminalControls(err.message, "direct runner errors"); assert.match(err.message, /--bad\\u001b\[2J\\nFORGED\\rBAD/); return true; - }, + } ); }); test("defineCommand exposes autoloadEnv metadata", () => { - const cmd = defineCommand({ name: "doctor", summary: "Check local state.", autoloadEnv: false, usage: () => "", run: () => {} }); + const cmd = defineCommand({ + name: "doctor", + summary: "Check local state.", + autoloadEnv: false, + usage: () => "", + run: () => {}, + }); const { parseOptions, ...meta } = cmd.meta; assert.deepEqual(meta, { name: "doctor", summary: "Check local state.", autoloadEnv: false }); assert.deepEqual(parseOptions, {}); }); test("defineCommand rejects an unknown option preset", () => { - assert.throws( - () => define({ options: ["bogus"], usage: () => "", run: () => {} }), - /unknown option preset "bogus"/, - ); + assert.throws(() => define({ options: ["bogus"], usage: () => "", run: () => {} }), /unknown option preset "bogus"/); }); test("defineCommand rejects raw parse option objects", () => { assert.throws( - () => define({ - // A raw parse-option object is not a valid OptionListItem; the command - // must reject it at runtime, so feed it through an unknown cast. - options: [/** @type {import("../../lib/common.js").OptionListItem} */ (/** @type {unknown} */ ({ tag: { type: "string" } }))], - usage: () => "", - run: () => {}, - }), - /option entries must be preset names or option specs/, + () => + define({ + // A raw parse-option object is not a valid OptionListItem; the command + // must reject it at runtime, so feed it through an unknown cast. + options: [ + /** @type {import("../../lib/common.js").OptionListItem} */ ( + /** @type {unknown} */ ({ tag: { type: "string" } }) + ), + ], + usage: () => "", + run: () => {}, + }), + /option entries must be preset names or option specs/ ); }); @@ -105,7 +119,9 @@ test("--help prints usage and skips the run body", async () => { const cmd = define({ options: ["help"], usage: () => "USAGE TEXT", - run: () => { ran = true; }, + run: () => { + ran = true; + }, }); await cmd.run(["--help"], { stdout: (/** @type {string} */ line) => lines.push(line) }); assert.equal(ran, false); @@ -119,7 +135,9 @@ test("positional help prints usage and skips the run body", async () => { const cmd = define({ options: ["help"], usage: () => "USAGE TEXT", - run: () => { ran = true; }, + run: () => { + ran = true; + }, }); await cmd.run(["help"], { stdout: (/** @type {string} */ line) => lines.push(line) }); assert.equal(ran, false); @@ -127,21 +145,21 @@ test("positional help prints usage and skips the run body", async () => { }); test("context applies dep defaults, injected overrides, and passthrough deps", async () => { - let ctx = /** @type {CommandContext & Record} */ ( - /** @type {unknown} */ (null) - ); + let ctx = /** @type {CommandContext & Record} */ (/** @type {unknown} */ (null)); const cmd = define({ options: ["help"], defaults: { custom: "default-custom" }, usage: () => "", - run: ({ context }) => { ctx = context; }, + run: ({ context }) => { + ctx = context; + }, }); const injectedStdout = () => {}; await cmd.run([], { stdout: injectedStdout, custom: "injected", extra: 42 }); - assert.equal(ctx.stdout, injectedStdout); // injected wins over default - assert.equal(typeof ctx.stderr, "function"); // standard default present - assert.equal(ctx.custom, "injected"); // injected wins over command default - assert.equal(ctx.extra, 42); // unknown dep passed through + assert.equal(ctx.stdout, injectedStdout); // injected wins over default + assert.equal(typeof ctx.stderr, "function"); // standard default present + assert.equal(ctx.custom, "injected"); // injected wins over command default + assert.equal(ctx.extra, 42); // unknown dep passed through }); test("context.nsUrl builds an encoded namespace URL", async () => { @@ -150,12 +168,13 @@ test("context.nsUrl builds an encoded namespace URL", async () => { const cmd = define({ options: ["ns", "control"], usage: () => "", - run: ({ context }) => { url = context.nsUrl("worker", "a b", "versions", "v/1"); }, + run: ({ context }) => { + url = context.nsUrl("worker", "a b", "versions", "v/1"); + }, + }); + await cmd.run(["--ns", "demo space", "--control-url", "http://ctl.test", "--token", "t"], { + env: {}, }); - await cmd.run( - ["--ns", "demo space", "--control-url", "http://ctl.test", "--token", "t"], - { env: {} }, - ); assert.equal(url, "http://ctl.test/ns/demo%20space/worker/a%20b/versions/v%2F1"); }); @@ -167,7 +186,7 @@ test("context.nsUrl throws when the namespace is unresolved", async () => { }); await assert.rejects( () => cmd.run(["--control-url", "http://ctl.test", "--token", "t"], { env: {} }), - /namespace not resolved/, + /namespace not resolved/ ); }); @@ -183,11 +202,17 @@ test("context.fetchJson fetches with the given init and parses JSON", async () = const body = await cmd.run([], { env: { CONTROL_CONNECT_HOST: "127.0.0.1:18080" }, /** @param {string} url @param {import("../../lib/control-fetch.js").ControlFetchInit} init */ - controlFetch: async (url, init) => { got = { url, init }; return response({ ok: 1 }); }, + controlFetch: async (url, init) => { + got = { url, init }; + return response({ ok: 1 }); + }, }); assert.deepEqual(body, { ok: 1 }); assert.equal(got.url, "http://x/y"); - assert.deepEqual(got.init, { headers: { a: "b" }, env: { CONTROL_CONNECT_HOST: "127.0.0.1:18080" } }); + assert.deepEqual(got.init, { + headers: { a: "b" }, + env: { CONTROL_CONNECT_HOST: "127.0.0.1:18080" }, + }); }); test("context.fetchJson throws a CliError on a non-2xx response", async () => { @@ -198,7 +223,7 @@ test("context.fetchJson throws a CliError on a non-2xx response", async () => { }); await assert.rejects( () => cmd.run([], { env: {}, controlFetch: async () => response({ error: "nope" }, 500) }), - /load failed/, + /load failed/ ); }); @@ -209,20 +234,25 @@ test("context.fetchJson escapes structured error context keys", async () => { run: ({ context }) => context.fetchJson("http://x", {}, "load"), }); await assert.rejects( - () => cmd.run([], { - env: {}, - controlFetch: async () => response({ - error: "nope\u001b[31m", - "bad\u001b]52;c;Y2xpcGJvYXJk\u0007key": "value\u001b[32m", - }, 500), - }), + () => + cmd.run([], { + env: {}, + controlFetch: async () => + response( + { + error: "nope\u001b[31m", + "bad\u001b]52;c;Y2xpcGJvYXJk\u0007key": "value\u001b[32m", + }, + 500 + ), + }), (err) => { assert(err instanceof CliError); assert.equal(err.message.includes("\u001b]52;c;Y2xpcGJvYXJk\u0007"), false); assert.match(err.message, /nope\\u001b\[31m/); assert.match(err.message, /bad\\u001b]52;c;Y2xpcGJvYXJk\\u0007key=value\\u001b\[32m/); return true; - }, + } ); }); @@ -233,27 +263,30 @@ test("context.fetchJson renders reserved module arrays from control errors", asy run: ({ context }) => context.fetchJson("http://x", {}, "deploy"), }); await assert.rejects( - () => cmd.run([], { - env: {}, - controlFetch: async () => response({ - error: "worker_code_invalid", - message: "reserved injected module name", - reserved_modules: ["_wdl-wrapper.js", "_wdl-init.js"], - }, 400), - }), + () => + cmd.run([], { + env: {}, + controlFetch: async () => + response( + { + error: "worker_code_invalid", + message: "reserved injected module name", + reserved_modules: ["_wdl-wrapper.js", "_wdl-init.js"], + }, + 400 + ), + }), (err) => { assert(err instanceof CliError); assert.match(err.message, /reserved_modules=\["_wdl-wrapper\.js","_wdl-init\.js"\]/); return true; - }, + } ); }); test("context.fetchStream returns the raw response after a status check", async () => { const ok = response("bytes"); - let got = /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ ( - /** @type {unknown} */ (null) - ); + let got = /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ (/** @type {unknown} */ (null)); const cmd = define({ options: [], usage: () => "", @@ -261,7 +294,10 @@ test("context.fetchStream returns the raw response after a status check", async }); const res = await cmd.run([], { env: { CONTROL_CONNECT_HOST: "127.0.0.1:18080" }, - controlFetch: async (/** @type {string} */ _url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init) => { + controlFetch: async ( + /** @type {string} */ _url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init + ) => { got = init; return ok; }, @@ -276,7 +312,7 @@ test("context.fetchStream returns the raw response after a status check", async }); await assert.rejects( () => bad.run([], { env: {}, controlFetch: async () => response({ error: "no" }, 404) }), - /get object failed/, + /get object failed/ ); }); @@ -297,10 +333,7 @@ test("context.resolveControl memoizes; resolveNamespace reads values then env", c2 = context.resolveControl(); }, }); - await cmd.run( - ["--ns", "demo", "--control-url", "http://ctl.test", "--token", "t"], - { env: {} }, - ); + await cmd.run(["--ns", "demo", "--control-url", "http://ctl.test", "--token", "t"], { env: {} }); assert.equal(nsFromFlag, "demo"); assert.equal(c1.controlUrl, "http://ctl.test"); assert.equal(c1, c2); // same object — resolved once @@ -308,7 +341,9 @@ test("context.resolveControl memoizes; resolveNamespace reads values then env", const envCmd = define({ options: ["ns"], usage: () => "", - run: ({ context }) => { nsFromEnv = context.resolveNamespace(); }, + run: ({ context }) => { + nsFromEnv = context.resolveNamespace(); + }, }); await envCmd.run([], { env: { WDL_NS: "envns" } }); assert.equal(nsFromEnv, "envns"); @@ -327,7 +362,9 @@ test("run errors propagate from the exported runner (main swallows separately)", const cmd = define({ options: [], usage: () => "", - run: () => { throw new CliError("boom"); }, + run: () => { + throw new CliError("boom"); + }, }); await assert.rejects(() => cmd.run([], { env: {} }), /boom/); }); diff --git a/tests/unit/cli-config-doctor.test.js b/tests/unit/cli-config-doctor.test.js index e497ce0..9699d52 100644 --- a/tests/unit/cli-config-doctor.test.js +++ b/tests/unit/cli-config-doctor.test.js @@ -30,13 +30,16 @@ function withTempDir(fn) { test("resolveCliConfigState reports .env section sources and masks token", () => { withTempDir((cwd) => { - writeFileSync(path.join(cwd, ".env"), [ - "CONTROL_URL=https://ctl.base.example", - "ADMIN_TOKEN=base-token", - "", - "[acme]", - "ADMIN_TOKEN=section-token", - ].join("\n")); + writeFileSync( + path.join(cwd, ".env"), + [ + "CONTROL_URL=https://ctl.base.example", + "ADMIN_TOKEN=base-token", + "", + "[acme]", + "ADMIN_TOKEN=section-token", + ].join("\n") + ); const state = resolveCliConfigState({ values: { ns: "acme" }, @@ -63,13 +66,10 @@ test("resolveCliConfigState resolves the .env namespace over the store default g demo: { CONTROL_URL: "https://demo.store.example", ADMIN_TOKEN: "demo-store-token" }, }, }); - writeFileSync(path.join(cwd, ".env"), [ - "WDL_NS=acme", - "", - "[acme]", - "CONTROL_URL=https://acme.env.example", - "ADMIN_TOKEN=acme-env-token", - ].join("\n")); + writeFileSync( + path.join(cwd, ".env"), + ["WDL_NS=acme", "", "[acme]", "CONTROL_URL=https://acme.env.example", "ADMIN_TOKEN=acme-env-token"].join("\n") + ); const state = resolveCliConfigState({ env: { WDL_NS: "", XDG_CONFIG_HOME: xdg }, @@ -86,18 +86,27 @@ test("resolveCliConfigState ignores the token store with WDL_TOKEN_STORE=off / - const xdg = path.join(cwd, "xdg"); writeTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg }), { defaultNs: "demo", - namespaces: { demo: { CONTROL_URL: "https://demo.store.example", ADMIN_TOKEN: "demo-store-token" } }, + namespaces: { + demo: { CONTROL_URL: "https://demo.store.example", ADMIN_TOKEN: "demo-store-token" }, + }, }); const baseline = resolveCliConfigState({ env: { WDL_NS: "", XDG_CONFIG_HOME: xdg }, cwd }); assert.equal(baseline.namespace.display, "demo", "baseline: store default namespace resolves"); assert.ok(baseline.token.value, "baseline: store token gap-fills"); - const viaEnv = resolveCliConfigState({ env: { WDL_NS: "", XDG_CONFIG_HOME: xdg, WDL_TOKEN_STORE: "off" }, cwd }); + const viaEnv = resolveCliConfigState({ + env: { WDL_NS: "", XDG_CONFIG_HOME: xdg, WDL_TOKEN_STORE: "off" }, + cwd, + }); assert.ok(!viaEnv.namespace.value, "WDL_TOKEN_STORE=off: no store default namespace"); assert.ok(!viaEnv.token.value, "WDL_TOKEN_STORE=off: no store token"); - const viaFlag = resolveCliConfigState({ values: { "no-token-store": true }, env: { WDL_NS: "", XDG_CONFIG_HOME: xdg }, cwd }); + const viaFlag = resolveCliConfigState({ + values: { "no-token-store": true }, + env: { WDL_NS: "", XDG_CONFIG_HOME: xdg }, + cwd, + }); assert.ok(!viaFlag.namespace.value, "--no-token-store: no store default namespace"); }); }); @@ -107,7 +116,9 @@ test("a project .env cannot disable the token store (WDL_TOKEN_STORE is not a .e const xdg = path.join(cwd, "xdg"); writeTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg }), { defaultNs: "demo", - namespaces: { demo: { CONTROL_URL: "https://demo.store.example", ADMIN_TOKEN: "demo-store-token" } }, + namespaces: { + demo: { CONTROL_URL: "https://demo.store.example", ADMIN_TOKEN: "demo-store-token" }, + }, }); writeFileSync(path.join(cwd, ".env"), "WDL_TOKEN_STORE=off\n"); @@ -120,7 +131,9 @@ test("a project .env cannot disable the token store (WDL_TOKEN_STORE is not a .e test("the dispatcher honors --no-token-store when autoloading credentials", async () => { const oldExit = process.exit; const oldError = console.error; - process.exit = (code) => { throw new Error(`exit:${code}`); }; + process.exit = (code) => { + throw new Error(`exit:${code}`); + }; console.error = () => {}; try { await withTempDir(async (cwd) => { @@ -153,14 +166,12 @@ test("the dispatcher honors --no-token-store when autoloading credentials", asyn } }); - test("config explain prints final values and sources", async () => { await withTempDir(async (cwd) => { - writeFileSync(path.join(cwd, ".env"), [ - "CONTROL_URL=https://ctl.base.example", - "[acme]", - "ADMIN_TOKEN=section-token", - ].join("\n")); + writeFileSync( + path.join(cwd, ".env"), + ["CONTROL_URL=https://ctl.base.example", "[acme]", "ADMIN_TOKEN=section-token"].join("\n") + ); /** @type {string[]} */ const lines = []; @@ -184,9 +195,24 @@ test("bin does not preload .env for local diagnostic commands", async () => { const oldLog = console.log; console.log = () => {}; try { - await wdlMain(["config", "--help"], { loadEnv: () => { calls.push("config"); return []; } }); - await wdlMain(["doctor", "--help"], { loadEnv: () => { calls.push("doctor"); return []; } }); - await wdlMain(["whoami", "--help"], { loadEnv: () => { calls.push("whoami"); return []; } }); + await wdlMain(["config", "--help"], { + loadEnv: () => { + calls.push("config"); + return []; + }, + }); + await wdlMain(["doctor", "--help"], { + loadEnv: () => { + calls.push("doctor"); + return []; + }, + }); + await wdlMain(["whoami", "--help"], { + loadEnv: () => { + calls.push("whoami"); + return []; + }, + }); } finally { console.log = oldLog; } @@ -246,11 +272,12 @@ test("whoami reports an unavailable namespace URL when control omits it", async cwd, env: {}, stdout: (/** @type {string} */ line) => lines.push(line), - controlFetch: async () => response({ - ok: true, - principal: { kind: "ns", ns: "acme" }, - urls: { control: "http://ctl.test" }, - }), + controlFetch: async () => + response({ + ok: true, + principal: { kind: "ns", ns: "acme" }, + urls: { control: "http://ctl.test" }, + }), }); assert.match(lines.join("\n"), /Namespace URL: \(unavailable\)/); @@ -266,13 +293,14 @@ test("whoami text reports configured namespace mismatch", async () => { env: { CONTROL_URL: "https://api.wdl.dev" }, /** @param {string} line */ stdout: (line) => lines.push(line), - controlFetch: async () => response({ - ok: true, - principal: { kind: "ns", ns: "actual" }, - tokenId: "tok_123", - minCliVersion: "0.7.1", - urls: { control: "https://api.wdl.dev" }, - }), + controlFetch: async () => + response({ + ok: true, + principal: { kind: "ns", ns: "actual" }, + tokenId: "tok_123", + minCliVersion: "0.7.1", + urls: { control: "https://api.wdl.dev" }, + }), }); assert.match(lines.join("\n"), /Configured NS: configured \(token principal is actual\)/); @@ -351,17 +379,19 @@ test("doctor --strict exits non-zero when any check fails", async () => { const lines = []; await assert.rejects( - () => runDoctorCommand(["--strict", "--ns", "acme", "--token", "secret-token"], { - cwd, - env: { CONTROL_URL: "https://api.wdl.dev" }, - execFile: () => "wrangler 3.99.0\n", - stdout: (/** @type {string} */ line) => lines.push(line), - controlFetch: async () => response({ - ok: true, - principal: { kind: "ns", ns: "acme" }, - minCliVersion: "0.7.1", + () => + runDoctorCommand(["--strict", "--ns", "acme", "--token", "secret-token"], { + cwd, + env: { CONTROL_URL: "https://api.wdl.dev" }, + execFile: () => "wrangler 3.99.0\n", + stdout: (/** @type {string} */ line) => lines.push(line), + controlFetch: async () => + response({ + ok: true, + principal: { kind: "ns", ns: "acme" }, + minCliVersion: "0.7.1", + }), }), - }), /doctor checks failed/ ); @@ -410,7 +440,11 @@ test("doctor reports the token store namespace count and the build-readable cave /** @param {string} line */ stdout: (line) => lines.push(line), controlFetch: async () => - response({ ok: true, principal: { kind: "ns", ns: "demo" }, urls: { control: "http://ctl.test" } }), + response({ + ok: true, + principal: { kind: "ns", ns: "demo" }, + urls: { control: "http://ctl.test" }, + }), }); const out = lines.join("\n"); assert.match(out, /Token store 2 namespaces/); @@ -424,20 +458,25 @@ test("doctor reports a corrupt token store as a failed check", async () => { 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"); + writeFileSync(storePath, '[demo]\nADMIN_TOKEN="unterminated\n'); /** @type {string[]} */ const lines = []; await assert.rejects( - () => runDoctorCommand(["--strict", "--ns", "demo", "--control-url", "http://ctl.test", "--token", "secret-token"], { - cwd, - env: { XDG_CONFIG_HOME: xdg }, - execFile: () => "4.94.0\n", - /** @param {string} line */ - stdout: (line) => lines.push(line), - controlFetch: async () => - response({ ok: true, principal: { kind: "ns", ns: "demo" }, urls: { control: "http://ctl.test" } }), - }), + () => + runDoctorCommand(["--strict", "--ns", "demo", "--control-url", "http://ctl.test", "--token", "secret-token"], { + cwd, + env: { XDG_CONFIG_HOME: xdg }, + execFile: () => "4.94.0\n", + /** @param {string} line */ + stdout: (line) => lines.push(line), + controlFetch: async () => + response({ + ok: true, + principal: { kind: "ns", ns: "demo" }, + urls: { control: "http://ctl.test" }, + }), + }), /doctor checks failed/ ); @@ -454,21 +493,22 @@ test("doctor reports a corrupt token store even when it blocks credential resolu 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"); + writeFileSync(storePath, '[demo]\nADMIN_TOKEN="unterminated\n'); /** @type {string[]} */ const lines = []; await assert.rejects( - () => runDoctorCommand(["--strict"], { - cwd, - env: { XDG_CONFIG_HOME: xdg, WDL_NS: "demo" }, - execFile: () => "4.94.0\n", - /** @param {string} line */ - stdout: (line) => lines.push(line), - controlFetch: async () => { - throw new Error("whoami should be skipped without resolved credentials"); - }, - }), + () => + runDoctorCommand(["--strict"], { + cwd, + env: { XDG_CONFIG_HOME: xdg, WDL_NS: "demo" }, + execFile: () => "4.94.0\n", + /** @param {string} line */ + stdout: (line) => lines.push(line), + controlFetch: async () => { + throw new Error("whoami should be skipped without resolved credentials"); + }, + }), /doctor checks failed/ ); @@ -500,8 +540,12 @@ test("doctor honors --no-token-store: reports the store disabled without reading /** @param {string} line */ stdout: (line) => lines.push(line), controlFetch: async () => - response({ ok: true, principal: { kind: "ns", ns: "demo" }, urls: { control: "http://ctl.test" } }), - }, + response({ + ok: true, + principal: { kind: "ns", ns: "demo" }, + urls: { control: "http://ctl.test" }, + }), + } ); const out = lines.join("\n"); assert.match(out, /Token store disabled/); @@ -540,13 +584,14 @@ test("doctor reports namespace mismatch from whoami", async () => { execFile: () => "4.94.0\n", /** @param {string} line */ stdout: (line) => lines.push(line), - controlFetch: async () => response({ - ok: true, - principal: { kind: "ns", ns: "actual" }, - tokenId: "tok_123", - minCliVersion: "0.7.1", - urls: { control: "https://api.wdl.dev" }, - }), + controlFetch: async () => + response({ + ok: true, + principal: { kind: "ns", ns: "actual" }, + tokenId: "tok_123", + minCliVersion: "0.7.1", + urls: { control: "https://api.wdl.dev" }, + }), }); assert.match(lines.join("\n"), /✗ Token namespace actual\n {2}configured namespace is configured/); @@ -563,11 +608,12 @@ test("doctor flags a Wrangler major below the deploy minimum", async () => { execFile: () => "3.99.0\n", /** @param {string} line */ stdout: (line) => lines.push(line), - controlFetch: async () => response({ - ok: true, - principal: { kind: "ns", ns: "acme" }, - minCliVersion: "0.7.1", - }), + controlFetch: async () => + response({ + ok: true, + principal: { kind: "ns", ns: "acme" }, + minCliVersion: "0.7.1", + }), }); const out = lines.join("\n"); @@ -594,24 +640,31 @@ test("ensureControlContextFromConfigState fails closed on an unresolved control const token = { value: "tok", display: "****", source: "--token", error: null }; // value:null with no error shouldn't happen, but must never be returned as a string. assert.throws( - () => ensureControlContextFromConfigState({ - controlUrl: { value: null, display: "(unset)", source: "(unset)", error: null }, - token, - }), + () => + ensureControlContextFromConfigState({ + controlUrl: { value: null, display: "(unset)", source: "(unset)", error: null }, + token, + }), /No control URL configured/ ); // An explicit resolver error is surfaced verbatim. assert.throws( - () => ensureControlContextFromConfigState({ - controlUrl: { value: null, display: "(unset)", source: "--control-url", error: "boom" }, - token, - }), + () => + ensureControlContextFromConfigState({ + controlUrl: { value: null, display: "(unset)", source: "--control-url", error: "boom" }, + token, + }), /boom/ ); // A fully resolved state yields the admin-token header. assert.deepEqual( ensureControlContextFromConfigState({ - controlUrl: { value: "https://api.example", display: "https://api.example", source: "--control-url", error: null }, + controlUrl: { + value: "https://api.example", + display: "https://api.example", + source: "--control-url", + error: null, + }, token, }), { @@ -675,11 +728,10 @@ test("whoami and doctor pass effective .env CONTROL_CONNECT_HOST to whoami reque }; await withTempDir(async (cwd) => { - writeFileSync(path.join(cwd, ".env"), [ - "CONTROL_URL=http://admin.test:8080", - "CONTROL_CONNECT_HOST=127.0.0.1:18080", - "ADMIN_TOKEN=env-token", - ].join("\n")); + writeFileSync( + path.join(cwd, ".env"), + ["CONTROL_URL=http://admin.test:8080", "CONTROL_CONNECT_HOST=127.0.0.1:18080", "ADMIN_TOKEN=env-token"].join("\n") + ); /** @type {import("../../lib/control-fetch.js").ControlFetchInit[]} */ const seen = []; await runWhoamiCommand(["--ns", "acme"], { @@ -688,7 +740,7 @@ test("whoami and doctor pass effective .env CONTROL_CONNECT_HOST to whoami reque stdout: () => {}, controlFetch: async ( /** @type {string} */ _url, - /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} ) => { seen.push(init); return response(whoamiBody); @@ -700,11 +752,10 @@ test("whoami and doctor pass effective .env CONTROL_CONNECT_HOST to whoami reque }); await withTempDir(async (cwd) => { - writeFileSync(path.join(cwd, ".env"), [ - "CONTROL_URL=http://admin.test:8080", - "CONTROL_CONNECT_HOST=127.0.0.1:18080", - "ADMIN_TOKEN=env-token", - ].join("\n")); + writeFileSync( + path.join(cwd, ".env"), + ["CONTROL_URL=http://admin.test:8080", "CONTROL_CONNECT_HOST=127.0.0.1:18080", "ADMIN_TOKEN=env-token"].join("\n") + ); /** @type {import("../../lib/control-fetch.js").ControlFetchInit[]} */ const seen = []; await runDoctorCommand(["--ns", "acme"], { @@ -714,7 +765,7 @@ test("whoami and doctor pass effective .env CONTROL_CONNECT_HOST to whoami reque stdout: () => {}, controlFetch: async ( /** @type {string} */ _url, - /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} ) => { seen.push(init); return response(whoamiBody); diff --git a/tests/unit/cli-control-fetch.test.js b/tests/unit/cli-control-fetch.test.js index 5a4404d..55b9f4c 100644 --- a/tests/unit/cli-control-fetch.test.js +++ b/tests/unit/cli-control-fetch.test.js @@ -3,11 +3,7 @@ import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import { CliError } from "../../lib/common.js"; -import { - DEFAULT_CONTROL_MAX_BODY_BYTES, - controlFetch, - readControlResponse, -} from "../../lib/control-fetch.js"; +import { DEFAULT_CONTROL_MAX_BODY_BYTES, controlFetch, readControlResponse } from "../../lib/control-fetch.js"; import { currentCliVersion } from "../../lib/package-info.js"; import { ESC, assertNoRawTerminalControls } from "./helpers.js"; @@ -70,7 +66,9 @@ test("readControlResponse aborts the stream and ignores further chunks once the const res = Object.assign(new EventEmitter(), { statusCode: 200, headers: {}, - destroy() { destroyed = true; }, + destroy() { + destroyed = true; + }, }); const promise = readControlResponse(res, { maxBodyBytes: 3 }); res.emit("data", Buffer.from("over")); // over the cap -> reject + destroy @@ -124,8 +122,7 @@ test("controlFetch wraps request socket errors as CliError", async () => { await assert.rejects( () => controlFetch("http://ctl.test/whoami", { transport }), - (err) => err instanceof CliError && - err.message === "control request failed: ECONNREFUSED connect refused" + (err) => err instanceof CliError && err.message === "control request failed: ECONNREFUSED connect refused" ); }); @@ -166,12 +163,13 @@ test("controlFetch rejects invalid header values before opening a socket", async }; await assert.rejects( - async () => controlFetch("http://ctl.test/whoami", { - headers: { "x-admin-token": "tok\nnext" }, - transport, - }), - (err) => err instanceof CliError && - err.message.includes('control request failed: invalid HTTP header "x-admin-token"') + async () => + controlFetch("http://ctl.test/whoami", { + headers: { "x-admin-token": "tok\nnext" }, + transport, + }), + (err) => + err instanceof CliError && err.message.includes('control request failed: invalid HTTP header "x-admin-token"') ); assert.equal(opened, false); }); @@ -186,8 +184,7 @@ test("controlFetch wraps synchronous request construction errors as CliError", a await assert.rejects( async () => controlFetch("http://ctl.test/whoami", { transport }), - (err) => err instanceof CliError && - err.message === "control request failed: ERR_BAD_REQUEST bad request options" + (err) => err instanceof CliError && err.message === "control request failed: ERR_BAD_REQUEST bad request options" ); }); @@ -221,14 +218,11 @@ test("controlFetch keeps timeout active while streaming the response body", asyn }); const body = /** @type {import("node:stream").Readable} */ (response.body); - await assert.rejects( - async () => { - for await (const _chunk of body) { - // Wait for the transport timeout to destroy the stalled stream. - } - }, - /control request timed out after 20ms/ - ); + await assert.rejects(async () => { + for await (const _chunk of body) { + // Wait for the transport timeout to destroy the stalled stream. + } + }, /control request timed out after 20ms/); assert.ok(requestDestroyError); assert.match(requestDestroyError.message, /control request timed out after 20ms/); }); @@ -332,14 +326,11 @@ test("controlFetch forwards streaming source errors to the consumer", async () = }); const body = /** @type {import("node:stream").Readable} */ (response.body); - await assert.rejects( - async () => { - for await (const _chunk of body) { - // Consume until the upstream response error is forwarded. - } - }, - /socket lost/ - ); + await assert.rejects(async () => { + for await (const _chunk of body) { + // Consume until the upstream response error is forwarded. + } + }, /socket lost/); }); test("controlFetch carries the URL port in Host and strips IPv6 brackets for the socket", async () => { @@ -349,9 +340,12 @@ test("controlFetch carries the URL port in Host and strips IPv6 brackets for the await controlFetch("http://127.0.0.1:9090/whoami", { transport }); await controlFetch("http://ctl.test/whoami", { transport }); - const first = /** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (seen[0]); - const second = /** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (seen[1]); - const third = /** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (seen[2]); + const first = + /** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (seen[0]); + const second = + /** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (seen[1]); + const third = + /** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (seen[2]); assert.equal(first.host, "::1"); assert.equal(first.port, 8080); assert.equal(first.headers.Host, "[::1]:8080"); @@ -373,7 +367,8 @@ test("controlFetch parses CONTROL_CONNECT_HOST host:port overrides", async () => else process.env.CONTROL_CONNECT_HOST = oldConnectHost; } - const opts = /** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (seen[0]); + const opts = + /** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (seen[0]); assert.equal(opts.host, "::1"); assert.equal(opts.port, 18443); assert.equal(opts.headers.Host, "ctl.example"); @@ -416,7 +411,8 @@ test("controlFetch uses init env for CONTROL_CONNECT_HOST overrides", async () = else process.env.CONTROL_CONNECT_HOST = oldConnectHost; } - const opts = /** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (seen[0]); + const opts = + /** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (seen[0]); assert.equal(opts.host, "127.0.0.1"); assert.equal(opts.port, 18080); assert.equal(opts.headers.Host, "admin.test:8080"); @@ -430,7 +426,8 @@ test("controlFetch accepts bare IPv6 CONTROL_CONNECT_HOST overrides", async () = transport, }); - const opts = /** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (seen[0]); + const opts = + /** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (seen[0]); assert.equal(opts.host, "::1"); assert.equal(opts.port, 443); assert.equal(opts.headers.Host, "ctl.example"); @@ -450,13 +447,14 @@ test("controlFetch rejects invalid CONTROL_CONNECT_HOST values before opening a ]) { process.env.CONTROL_CONNECT_HOST = value; assert.throws( - () => controlFetch("http://ctl.test/whoami", { - transport: { - request() { - throw new Error("request should not be opened"); + () => + controlFetch("http://ctl.test/whoami", { + transport: { + request() { + throw new Error("request should not be opened"); + }, }, - }, - }), + }), (err) => { assert(err instanceof CliError); assert.match(err.message, /Invalid CONTROL_CONNECT_HOST/); diff --git a/tests/unit/cli-credentials.test.js b/tests/unit/cli-credentials.test.js index d336ee9..94729f7 100644 --- a/tests/unit/cli-credentials.test.js +++ b/tests/unit/cli-credentials.test.js @@ -3,7 +3,16 @@ import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { isTokenStoreDisabled, loadCliControlEnv, loadCliDotEnv, protectedEnvKeys, resolveControlContext, resolveControlUrl, resolveNamespace, warnIfInsecureControlUrl } from "../../lib/credentials.js"; +import { + isTokenStoreDisabled, + loadCliControlEnv, + loadCliDotEnv, + protectedEnvKeys, + resolveControlContext, + resolveControlUrl, + resolveNamespace, + warnIfInsecureControlUrl, +} from "../../lib/credentials.js"; import { ESC, assertNoRawTerminalControls } from "./helpers.js"; test("isTokenStoreDisabled honors the flag and WDL_TOKEN_STORE=off", () => { @@ -20,14 +29,8 @@ function emptyEnv() { } test("resolveControlUrl strips trailing slashes from flags and env", () => { - assert.equal( - resolveControlUrl({ "control-url": "http://ctl.test///" }, {}), - "http://ctl.test" - ); - assert.equal( - resolveControlUrl({}, { CONTROL_URL: "http://ctl.test/" }), - "http://ctl.test" - ); + assert.equal(resolveControlUrl({ "control-url": "http://ctl.test///" }, {}), "http://ctl.test"); + assert.equal(resolveControlUrl({}, { CONTROL_URL: "http://ctl.test/" }), "http://ctl.test"); }); test("resolveControlUrl requires a configured endpoint", () => { @@ -47,62 +50,33 @@ 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": "ctl.example" }, {}), "https://ctl.example"); + assert.equal(resolveControlUrl({}, { CONTROL_URL: "ctl.uat.example/" }), "https://ctl.uat.example"); }); 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": "localhost:8080/" }, {}), - "http://localhost:8080" - ); - 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" - ); + assert.equal(resolveControlUrl({}, { CONTROL_URL: "ctl.test:8080" }), "http://ctl.test:8080"); + assert.equal(resolveControlUrl({ "control-url": "localhost:8080/" }, {}), "http://localhost:8080"); + 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"); }); test("resolveControlContext centralizes admin token and headers", () => { - assert.deepEqual( - resolveControlContext({ "control-url": "http://ctl.example/" }, { ADMIN_TOKEN: "tok" }), - { - controlUrl: "http://ctl.example", - token: "tok", - headers: { "x-admin-token": "tok" }, - } - ); - assert.throws( - () => resolveControlContext({}, {}), - /Missing admin token/ - ); + assert.deepEqual(resolveControlContext({ "control-url": "http://ctl.example/" }, { ADMIN_TOKEN: "tok" }), { + controlUrl: "http://ctl.example", + token: "tok", + headers: { "x-admin-token": "tok" }, + }); + assert.throws(() => resolveControlContext({}, {}), /Missing admin token/); }); test("warnIfInsecureControlUrl escapes control endpoint text before warning", () => { /** @type {string[]} */ const warnings = []; - warnIfInsecureControlUrl( - "http://localhost/\u001b[31m", - (line) => warnings.push(line), - { CONTROL_CONNECT_HOST: "evil.example\nsecond" }, - ); + warnIfInsecureControlUrl("http://localhost/\u001b[31m", (line) => warnings.push(line), { + CONTROL_CONNECT_HOST: "evil.example\nsecond", + }); assert.equal(warnings.length, 1); assert.match(warnings[0], /http:\/\/localhost\/\\u001b\[31m/); @@ -115,11 +89,9 @@ test("warnIfInsecureControlUrl treats local CONTROL_CONNECT_HOST host:port overr for (const connectHost of ["localhost:18080", "dev.local:18080", "[::1]:18080", "http://localhost:18080"]) { /** @type {string[]} */ const warnings = []; - warnIfInsecureControlUrl( - "http://admin.test:8080", - (line) => warnings.push(line), - { CONTROL_CONNECT_HOST: connectHost }, - ); + warnIfInsecureControlUrl("http://admin.test:8080", (line) => warnings.push(line), { + CONTROL_CONNECT_HOST: connectHost, + }); assert.deepEqual(warnings, []); } }); @@ -142,7 +114,7 @@ test("loadCliDotEnv loads an explicit .env without overriding explicit env", () "CONTROL_URL=https://ctl.example/", "export WDL_NS=demo", "CONTROL_CONNECT_HOST='localhost'", - "CLOUDFLARE_ENV=\"staging\" # ignored: not a WDL platform variable", + 'CLOUDFLARE_ENV="staging" # ignored: not a WDL platform variable', "IGNORED_VALUE=ignored", "", ].join("\n") @@ -150,11 +122,7 @@ test("loadCliDotEnv loads an explicit .env without overriding explicit env", () /** @type {NodeJS.ProcessEnv} */ const env = { ADMIN_TOKEN: "from-shell" }; - assert.deepEqual(loadCliDotEnv(env, file), [ - "CONTROL_URL", - "WDL_NS", - "CONTROL_CONNECT_HOST", - ]); + assert.deepEqual(loadCliDotEnv(env, file), ["CONTROL_URL", "WDL_NS", "CONTROL_CONNECT_HOST"]); assert.equal(env.ADMIN_TOKEN, "from-shell"); assert.equal(env.CONTROL_URL, "https://ctl.example/"); assert.equal(env.WDL_NS, "demo"); @@ -187,17 +155,11 @@ test("loadCliDotEnv rejects malformed quoted values", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-env-")); const file = path.join(dir, ".env"); try { - writeFileSync(file, "CONTROL_URL=\"https://ctl.example\" trailing\n"); - assert.throws( - () => loadCliDotEnv(emptyEnv(), file), - /Invalid \.env value: unexpected text after quoted value/ - ); + writeFileSync(file, 'CONTROL_URL="https://ctl.example" trailing\n'); + assert.throws(() => loadCliDotEnv(emptyEnv(), file), /Invalid \.env value: unexpected text after quoted value/); - writeFileSync(file, "CONTROL_URL=\"https://ctl.example\n"); - assert.throws( - () => loadCliDotEnv(emptyEnv(), file), - /Invalid \.env value: missing closing quote/ - ); + writeFileSync(file, 'CONTROL_URL="https://ctl.example\n'); + assert.throws(() => loadCliDotEnv(emptyEnv(), file), /Invalid \.env value: missing closing quote/); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -226,13 +188,16 @@ test("loadCliDotEnv ignores non-WDL dotenv lines it cannot parse", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-env-")); const file = path.join(dir, ".env"); try { - writeFileSync(file, [ - "ADMIN_TOKEN=tok", - "PRIVATE_KEY=\"-----BEGIN PRIVATE KEY-----", - "not a KEY=value continuation", - "-----END PRIVATE KEY-----\"", - "CONTROL_URL=https://ctl.example", - ].join("\n")); + writeFileSync( + file, + [ + "ADMIN_TOKEN=tok", + 'PRIVATE_KEY="-----BEGIN PRIVATE KEY-----', + "not a KEY=value continuation", + '-----END PRIVATE KEY-----"', + "CONTROL_URL=https://ctl.example", + ].join("\n") + ); const env = emptyEnv(); assert.deepEqual(loadCliDotEnv(env, file), ["ADMIN_TOKEN", "CONTROL_URL"]); @@ -266,11 +231,7 @@ test("loadCliDotEnv overlays the resolved namespace section over base", () => { const env = emptyEnv(); const protectedKeys = new Set(Object.keys(env)); - assert.deepEqual(loadCliDotEnv(env, file, { protectedKeys }), [ - "ADMIN_TOKEN", - "CONTROL_URL", - "WDL_NS", - ]); + assert.deepEqual(loadCliDotEnv(env, file, { protectedKeys }), ["ADMIN_TOKEN", "CONTROL_URL", "WDL_NS"]); const ns = resolveNamespace({}, env); assert.equal(ns, "demo"); assert.deepEqual(loadCliDotEnv(env, file, { resolvedNs: ns, loadBase: false, protectedKeys }), [ @@ -290,15 +251,7 @@ test("loadCliDotEnv supports section-only files", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-env-")); const file = path.join(dir, ".env"); try { - writeFileSync( - file, - [ - "[demo]", - "ADMIN_TOKEN=demo-token", - "CONTROL_URL=https://ctl.demo.example", - "", - ].join("\n") - ); + writeFileSync(file, ["[demo]", "ADMIN_TOKEN=demo-token", "CONTROL_URL=https://ctl.demo.example", ""].join("\n")); const env = emptyEnv(); /** @type {Set} */ @@ -336,11 +289,14 @@ test("loadCliDotEnv switches adjacent sections without blank lines", () => { /** @type {Set} */ const protectedKeys = new Set(); loadCliDotEnv(env, file, { protectedKeys }); - assert.deepEqual(loadCliDotEnv(env, file, { - resolvedNs: resolveNamespace({}, env), - loadBase: false, - protectedKeys, - }), ["ADMIN_TOKEN", "CONTROL_URL"]); + assert.deepEqual( + loadCliDotEnv(env, file, { + resolvedNs: resolveNamespace({}, env), + loadBase: false, + protectedKeys, + }), + ["ADMIN_TOKEN", "CONTROL_URL"] + ); assert.equal(env.ADMIN_TOKEN, "prod-token"); assert.equal(env.CONTROL_URL, "https://ctl.prod.example"); } finally { @@ -386,11 +342,14 @@ test("loadCliDotEnv keeps shell env above base and namespace sections", () => { }; const protectedKeys = new Set(Object.keys(env)); assert.deepEqual(loadCliDotEnv(env, file, { protectedKeys }), []); - assert.deepEqual(loadCliDotEnv(env, file, { - resolvedNs: resolveNamespace({}, env), - loadBase: false, - protectedKeys, - }), []); + assert.deepEqual( + loadCliDotEnv(env, file, { + resolvedNs: resolveNamespace({}, env), + loadBase: false, + protectedKeys, + }), + [] + ); assert.equal(env.ADMIN_TOKEN, "shell-token"); assert.equal(env.CONTROL_URL, "https://ctl.shell.example"); @@ -406,13 +365,7 @@ test("loadCliDotEnv loads only base when namespace is unresolved or missing", () try { writeFileSync( file, - [ - "CONTROL_URL=https://ctl.base.example", - "", - "[demo]", - "ADMIN_TOKEN=demo-token", - "", - ].join("\n") + ["CONTROL_URL=https://ctl.base.example", "", "[demo]", "ADMIN_TOKEN=demo-token", ""].join("\n") ); const env = emptyEnv(); @@ -420,11 +373,14 @@ test("loadCliDotEnv loads only base when namespace is unresolved or missing", () assert.equal(env.CONTROL_URL, "https://ctl.base.example"); assert.equal(env.ADMIN_TOKEN, undefined); - assert.deepEqual(loadCliDotEnv(env, file, { - resolvedNs: "prod", - loadBase: false, - protectedKeys: new Set(), - }), []); + assert.deepEqual( + loadCliDotEnv(env, file, { + resolvedNs: "prod", + loadBase: false, + protectedKeys: new Set(), + }), + [] + ); assert.equal(env.ADMIN_TOKEN, undefined); } finally { rmSync(dir, { recursive: true, force: true }); @@ -435,22 +391,17 @@ test("loadCliDotEnv accepts opaque operator reserved namespace sections", () => const dir = mkdtempSync(path.join(tmpdir(), "wdl-env-")); const file = path.join(dir, ".env"); try { - writeFileSync( - file, - [ - "WDL_NS=__reserved__", - "", - "[__reserved__]", - "ADMIN_TOKEN=reserved-token", - "", - ].join("\n") - ); + writeFileSync(file, ["WDL_NS=__reserved__", "", "[__reserved__]", "ADMIN_TOKEN=reserved-token", ""].join("\n")); const env = emptyEnv(); /** @type {Set} */ const protectedKeys = new Set(); loadCliDotEnv(env, file, { protectedKeys }); - loadCliDotEnv(env, file, { resolvedNs: resolveNamespace({}, env), loadBase: false, protectedKeys }); + loadCliDotEnv(env, file, { + resolvedNs: resolveNamespace({}, env), + loadBase: false, + protectedKeys, + }); assert.equal(env.ADMIN_TOKEN, "reserved-token"); } finally { rmSync(dir, { recursive: true, force: true }); @@ -463,10 +414,7 @@ test("loadCliDotEnv rejects invalid section names", () => { try { for (const name of ["Demo", "my ns", "", "admin"]) { writeFileSync(file, `[${name}]\nADMIN_TOKEN=tok\n`); - assert.throws( - () => loadCliDotEnv(emptyEnv(), file), - /Invalid \.env line 1: invalid section name/ - ); + assert.throws(() => loadCliDotEnv(emptyEnv(), file), /Invalid \.env line 1: invalid section name/); } } finally { rmSync(dir, { recursive: true, force: true }); @@ -477,17 +425,7 @@ test("loadCliDotEnv ignores WDL_NS in selected section with a warning", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-env-")); const file = path.join(dir, ".env"); try { - writeFileSync( - file, - [ - "WDL_NS=demo", - "", - "[demo]", - "WDL_NS=prod", - "ADMIN_TOKEN=demo-token", - "", - ].join("\n") - ); + writeFileSync(file, ["WDL_NS=demo", "", "[demo]", "WDL_NS=prod", "ADMIN_TOKEN=demo-token", ""].join("\n")); /** @type {string[]} */ const warnings = []; @@ -495,12 +433,15 @@ test("loadCliDotEnv ignores WDL_NS in selected section with a warning", () => { /** @type {Set} */ const protectedKeys = new Set(); loadCliDotEnv(env, file, { protectedKeys }); - assert.deepEqual(loadCliDotEnv(env, file, { - resolvedNs: "demo", - loadBase: false, - protectedKeys, - warn: (message) => warnings.push(message), - }), ["ADMIN_TOKEN"]); + assert.deepEqual( + loadCliDotEnv(env, file, { + resolvedNs: "demo", + loadBase: false, + protectedKeys, + warn: (message) => warnings.push(message), + }), + ["ADMIN_TOKEN"] + ); assert.equal(env.WDL_NS, "demo"); assert.equal(env.ADMIN_TOKEN, "demo-token"); @@ -514,17 +455,7 @@ test("loadCliDotEnv does not warn for WDL_NS in an unselected section", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-env-")); const file = path.join(dir, ".env"); try { - writeFileSync( - file, - [ - "WDL_NS=demo", - "", - "[prod]", - "WDL_NS=ignored", - "ADMIN_TOKEN=prod-token", - "", - ].join("\n") - ); + writeFileSync(file, ["WDL_NS=demo", "", "[prod]", "WDL_NS=ignored", "ADMIN_TOKEN=prod-token", ""].join("\n")); /** @type {string[]} */ const warnings = []; @@ -532,12 +463,15 @@ test("loadCliDotEnv does not warn for WDL_NS in an unselected section", () => { /** @type {Set} */ const protectedKeys = new Set(); loadCliDotEnv(env, file, { protectedKeys }); - assert.deepEqual(loadCliDotEnv(env, file, { - resolvedNs: "demo", - loadBase: false, - protectedKeys, - warn: (message) => warnings.push(message), - }), []); + assert.deepEqual( + loadCliDotEnv(env, file, { + resolvedNs: "demo", + loadBase: false, + protectedKeys, + warn: (message) => warnings.push(message), + }), + [] + ); assert.equal(env.WDL_NS, "demo"); assert.equal(env.ADMIN_TOKEN, undefined); @@ -640,8 +574,10 @@ test("loadCliControlEnv trusts a .env control endpoint when the token is also fr test("loadCliControlEnv keeps the documented multi-ns layout (URL in base, token in [ns])", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-multins-")); try { - writeFileSync(path.join(dir, ".env"), - "CONTROL_URL=https://ctl.shared.example\nWDL_NS=acme\n\n[acme]\nADMIN_TOKEN=acme-token\n"); + writeFileSync( + path.join(dir, ".env"), + "CONTROL_URL=https://ctl.shared.example\nWDL_NS=acme\n\n[acme]\nADMIN_TOKEN=acme-token\n" + ); /** @type {NodeJS.ProcessEnv} */ const env = {}; /** @type {string[]} */ @@ -669,7 +605,9 @@ test("loadCliControlEnv fills control URL and token from the store as a gap-fill loadCliControlEnv(env, { nsFromFlag: "acme", loadEnv: () => [], - readStore: () => ({ namespaces: { acme: { CONTROL_URL: "https://store.example", ADMIN_TOKEN: "store-tok" } } }), + readStore: () => ({ + namespaces: { acme: { CONTROL_URL: "https://store.example", ADMIN_TOKEN: "store-tok" } }, + }), }); assert.equal(env.CONTROL_URL, "https://store.example"); assert.equal(env.ADMIN_TOKEN, "store-tok"); @@ -753,7 +691,9 @@ test("loadCliControlEnv lets shell env win over the store (gap-fill only)", () = nsFromFlag: "acme", protectedKeys: new Set(["ADMIN_TOKEN"]), loadEnv: () => [], - readStore: () => ({ namespaces: { acme: { CONTROL_URL: "https://store.example", ADMIN_TOKEN: "store-tok" } } }), + readStore: () => ({ + namespaces: { acme: { CONTROL_URL: "https://store.example", ADMIN_TOKEN: "store-tok" } }, + }), }); assert.equal(env.ADMIN_TOKEN, "shell-tok", "shell token is not overwritten"); assert.equal(env.CONTROL_URL, "https://store.example", "the empty control URL slot is filled"); @@ -768,7 +708,9 @@ test("loadCliControlEnv does not fill a flag-covered slot from the store", () => nsFromFlag: "acme", controlUrlFromFlag: true, loadEnv: () => [], - readStore: () => ({ namespaces: { acme: { CONTROL_URL: "https://store.example", ADMIN_TOKEN: "store-tok" } } }), + readStore: () => ({ + namespaces: { acme: { CONTROL_URL: "https://store.example", ADMIN_TOKEN: "store-tok" } }, + }), }); assert.equal(env.CONTROL_URL, undefined, "flag-covered endpoint is not shadowed by the store"); assert.equal(env.ADMIN_TOKEN, "store-tok", "the uncovered token slot is still filled"); @@ -780,7 +722,10 @@ test("loadCliControlEnv does not read the store when ns and credentials are pres loadCliControlEnv(env, { nsFromFlag: "acme", loadEnv: () => [], - readStore: () => { reads += 1; return {}; }, + readStore: () => { + reads += 1; + return {}; + }, }); assert.equal(reads, 0, "the store is the lowest layer and untouched when nothing needs it"); }); @@ -794,18 +739,24 @@ test("loadCliControlEnv ignores a corrupt store when flags cover the credentials tokenFromFlag: true, controlUrlFromFlag: true, loadEnv: () => [], - readStore: () => { reads += 1; throw new Error("Invalid credentials line 3"); }, + readStore: () => { + reads += 1; + throw new Error("Invalid credentials line 3"); + }, }); assert.equal(reads, 0, "store never read"); }); test("loadCliControlEnv surfaces a corrupt store when it is the credential source", () => { assert.throws( - () => loadCliControlEnv(/** @type {NodeJS.ProcessEnv} */ ({}), { - nsFromFlag: "acme", - loadEnv: () => [], - readStore: () => { throw new Error("Invalid credentials line 3"); }, - }), + () => + loadCliControlEnv(/** @type {NodeJS.ProcessEnv} */ ({}), { + nsFromFlag: "acme", + loadEnv: () => [], + readStore: () => { + throw new Error("Invalid credentials line 3"); + }, + }), /Invalid credentials/ ); }); @@ -817,7 +768,9 @@ test("loadCliControlEnv tolerates a corrupt store when no namespace is needed", assert.doesNotThrow(() => loadCliControlEnv(env, { loadEnv: () => [], - readStore: () => { throw new Error("Invalid credentials line 3"); }, + readStore: () => { + throw new Error("Invalid credentials line 3"); + }, }) ); assert.equal(env.WDL_NS, undefined); @@ -845,7 +798,9 @@ test("an empty .env ADMIN_TOKEN does not mark a .env endpoint same-source", () = } return []; }, - readStore: () => ({ namespaces: { acme: { CONTROL_URL: "https://good.example", ADMIN_TOKEN: "store-tok" } } }), + readStore: () => ({ + namespaces: { acme: { CONTROL_URL: "https://good.example", ADMIN_TOKEN: "store-tok" } }, + }), onCrossOrigin: (line) => warnings.push(line), }); assert.equal(env.CONTROL_URL, "https://good.example", "evil endpoint dropped, store endpoint used"); @@ -875,7 +830,9 @@ test("a project .env endpoint is still dropped when the token comes from the sto } return []; }, - readStore: () => ({ namespaces: { acme: { CONTROL_URL: "https://good.example", ADMIN_TOKEN: "store-tok" } } }), + readStore: () => ({ + namespaces: { acme: { CONTROL_URL: "https://good.example", ADMIN_TOKEN: "store-tok" } }, + }), onCrossOrigin: (line) => warnings.push(line), }); assert.equal(env.CONTROL_URL, "https://good.example", "evil endpoint dropped, store endpoint used"); diff --git a/tests/unit/cli-d1.test.js b/tests/unit/cli-d1.test.js index ddef993..5129a2c 100644 --- a/tests/unit/cli-d1.test.js +++ b/tests/unit/cli-d1.test.js @@ -5,7 +5,15 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { runD1Command, serializeMigrationStatusRequest } from "../../commands/d1.js"; import { LONG_CONTROL_TIMEOUT_MS } from "../../lib/control-fetch.js"; -import { ESC, MODE_BITS_ENFORCED_ONLY, POSIX_ONLY, assertNoRawTerminalControls, assertUnreadable, mockDeps as sharedMockDeps, response } from "./helpers.js"; +import { + ESC, + MODE_BITS_ENFORCED_ONLY, + POSIX_ONLY, + assertNoRawTerminalControls, + assertUnreadable, + mockDeps as sharedMockDeps, + response, +} from "./helpers.js"; /** @typedef {import("../../lib/control-fetch.js").ControlFetchInit} ControlFetchInit */ /** @typedef {import("./helpers.js").ControlCall} RecordedCall */ @@ -109,18 +117,21 @@ test("d1 create posts a database name", async () => { test("d1 execute sends SQL mode and JSON params", async () => { const { calls, lines, deps } = mockDeps({ result: { results: [{ n: 1 }] } }); - await runD1Command([ - "execute", - "main", - "--sql", - "select ? as n", - "--params", - "[1]", - "--mode", - "all", - "--control-url", - "http://ctl.test", - ], deps); + await runD1Command( + [ + "execute", + "main", + "--sql", + "select ? as n", + "--params", + "[1]", + "--mode", + "all", + "--control-url", + "http://ctl.test", + ], + deps + ); assert.equal(calls[0].url, "http://ctl.test/ns/demo/d1/databases/main/query"); assert.equal(calls[0].init.method, "POST"); @@ -144,14 +155,15 @@ test("d1 execute rejects empty SQL before calling control", async () => { ["execute", "main", "--file", "empty.sql", "--control-url", "http://ctl.test"], ]) { await assert.rejects( - () => runD1Command(args, { - cwd: dir, - env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, - stdout: () => {}, - controlFetch: async () => { - throw new Error("controlFetch should not be called"); - }, - }), + () => + runD1Command(args, { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + stdout: () => {}, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }), /must contain non-empty SQL/ ); } @@ -172,13 +184,14 @@ test("d1 execute rejects empty --file and conflicting SQL sources before calling }, ]) { await assert.rejects( - () => runD1Command(args, { - env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, - stdout: () => {}, - controlFetch: async () => { - throw new Error("controlFetch should not be called"); - }, - }), + () => + runD1Command(args, { + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + stdout: () => {}, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }), message ); } @@ -191,14 +204,7 @@ test("d1 execute --file accepts a path inside the project", async () => { /** @type {RecordedCall[]} */ const calls = []; - await runD1Command([ - "execute", - "main", - "--file", - "inside.sql", - "--control-url", - "http://ctl.test", - ], { + await runD1Command(["execute", "main", "--file", "inside.sql", "--control-url", "http://ctl.test"], { cwd: dir, env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, stdout: () => {}, @@ -232,15 +238,7 @@ test("d1 migrations apply reads sorted SQL files from --dir", async () => { const calls = []; /** @type {string[]} */ const lines = []; - await runD1Command([ - "migrations", - "apply", - "main", - "--dir", - "migrations", - "--control-url", - "http://ctl.test", - ], { + await runD1Command(["migrations", "apply", "main", "--dir", "migrations", "--control-url", "http://ctl.test"], { cwd: dir, env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, /** @param {string} line */ @@ -255,10 +253,10 @@ test("d1 migrations apply reads sorted SQL files from --dir", async () => { assert.equal(calls[0].url, "http://ctl.test/ns/demo/d1/databases/main/migrations/apply"); assert.equal(calls[0].init.timeoutMs, LONG_CONTROL_TIMEOUT_MS); const body = parseMigrationsBody(calls[0].init.body); - assert.deepEqual(body.migrations.map((migration) => migration.id), [ - "001_init.sql", - "002_add.sql", - ]); + assert.deepEqual( + body.migrations.map((migration) => migration.id), + ["001_init.sql", "002_add.sql"] + ); assert.equal(body.migrations[0].sql, "create table users (id integer);"); assert.match(body.migrations[0].checksum, /^[a-f0-9]{64}$/); assert.deepEqual(lines, ["Applied 001_init.sql\tstatements=1"]); @@ -277,27 +275,23 @@ test("d1 migrations apply surfaces control request body size errors", async () = /** @type {RecordedCall[]} */ const calls = []; await assert.rejects( - () => runD1Command([ - "migrations", - "apply", - "main", - "--dir", - "migrations", - "--control-url", - "http://ctl.test", - ], { - cwd: dir, - env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, - stdout: () => {}, - /** @param {string} url @param {ControlFetchInit} [init] */ - controlFetch: async (url, init = {}) => { - calls.push({ url, init }); - return response({ - error: "request_body_too_large", - message: "migration request too large", - }, 413); - }, - }), + () => + runD1Command(["migrations", "apply", "main", "--dir", "migrations", "--control-url", "http://ctl.test"], { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + stdout: () => {}, + /** @param {string} url @param {ControlFetchInit} [init] */ + controlFetch: async (url, init = {}) => { + calls.push({ url, init }); + return response( + { + error: "request_body_too_large", + message: "migration request too large", + }, + 413 + ); + }, + }), /apply d1 migrations failed: 413 request_body_too_large: migration request too large/ ); @@ -323,10 +317,7 @@ test("d1 migrations status can send oversized metadata bodies to control", () => }; }); const body = serializeMigrationStatusRequest(migrations); - assert.ok( - body.length > 1_048_576, - "oversized local migration metadata is sent to control for canonical rejection" - ); + assert.ok(body.length > 1_048_576, "oversized local migration metadata is sent to control for canonical rejection"); assert.equal(body.includes("select 1;"), false); const parsed = /** @type {{ migrations: Array> }} */ (parseBody(body)); assert.equal(parsed.migrations.length, migrations.length); @@ -343,27 +334,23 @@ test("d1 migrations status surfaces control request body size errors", async () /** @type {RecordedCall[]} */ const calls = []; await assert.rejects( - () => runD1Command([ - "migrations", - "status", - "main", - "--dir", - "migrations", - "--control-url", - "http://ctl.test", - ], { - cwd: dir, - env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, - stdout: () => {}, - /** @param {string} url @param {ControlFetchInit} [init] */ - controlFetch: async (url, init = {}) => { - calls.push({ url, init }); - return response({ - error: "request_body_too_large", - message: "migration status request too large", - }, 413); - }, - }), + () => + runD1Command(["migrations", "status", "main", "--dir", "migrations", "--control-url", "http://ctl.test"], { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + stdout: () => {}, + /** @param {string} url @param {ControlFetchInit} [init] */ + controlFetch: async (url, init = {}) => { + calls.push({ url, init }); + return response( + { + error: "request_body_too_large", + message: "migration status request too large", + }, + 413 + ); + }, + }), /show d1 migration status failed: 413 request_body_too_large: migration status request too large/ ); @@ -384,13 +371,14 @@ test("d1 migrations apply rejects symlinked SQL files", POSIX_ONLY, async () => symlinkSync(target, path.join(migrations, `001_link${ESC}[2J\nFORGED\rBAD.sql`)); await assert.rejects( - () => runD1Command(["migrations", "apply", "main", "--dir", "migrations", "--control-url", "http://ctl.test"], { - cwd: dir, - env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, - controlFetch: async () => { - throw new Error("controlFetch should not be called"); - }, - }), + () => + runD1Command(["migrations", "apply", "main", "--dir", "migrations", "--control-url", "http://ctl.test"], { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }), (err) => { const message = /** @type {Error} */ (err).message; assertNoRawTerminalControls(message, "the error"); @@ -403,67 +391,72 @@ test("d1 migrations apply rejects symlinked SQL files", POSIX_ONLY, async () => } }); -test("d1 migrations apply escapes terminal controls in unreadable migration file errors", MODE_BITS_ENFORCED_ONLY, async () => { - const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-migrations-unreadable-")); - const badName = `001_bad${ESC}[2J\nFORGED\rBAD.sql`; - const badPath = path.join(dir, "migrations", badName); - try { - const migrations = path.join(dir, "migrations"); - mkdirSync(migrations); - writeFileSync(badPath, "create table t (id integer);"); - chmodSync(badPath, 0o000); - assertUnreadable(badPath); +test( + "d1 migrations apply escapes terminal controls in unreadable migration file errors", + MODE_BITS_ENFORCED_ONLY, + async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-migrations-unreadable-")); + const badName = `001_bad${ESC}[2J\nFORGED\rBAD.sql`; + const badPath = path.join(dir, "migrations", badName); + try { + const migrations = path.join(dir, "migrations"); + mkdirSync(migrations); + writeFileSync(badPath, "create table t (id integer);"); + chmodSync(badPath, 0o000); + assertUnreadable(badPath); - await assert.rejects( - () => runD1Command(["migrations", "apply", "main", "--dir", "migrations", "--control-url", "http://ctl.test"], { - cwd: dir, - env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, - controlFetch: async () => { - throw new Error("controlFetch should not be called"); - }, - }), - (err) => assertEscapedD1Error(err, /cannot read migration file 001_bad\\u001b\[2J\\nFORGED\\rBAD\.sql:/) - ); - } finally { - try { chmodSync(badPath, 0o600); } catch {} - rmSync(dir, { recursive: true, force: true }); + await assert.rejects( + () => + runD1Command(["migrations", "apply", "main", "--dir", "migrations", "--control-url", "http://ctl.test"], { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }), + (err) => assertEscapedD1Error(err, /cannot read migration file 001_bad\\u001b\[2J\\nFORGED\\rBAD\.sql:/) + ); + } finally { + try { + chmodSync(badPath, 0o600); + } catch {} + rmSync(dir, { recursive: true, force: true }); + } } -}); +); test("d1 migrations_dir from wrangler config cannot escape the project", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-contained-")); const outside = mkdtempSync(path.join(tmpdir(), "wdl-d1-outside-")); try { writeFileSync(path.join(outside, "001_leak.sql"), "select 'leak';"); - writeFileSync(path.join(dir, "wrangler.toml"), [ - 'name = "api"', - 'main = "src/index.js"', - "", - "[[d1_databases]]", - 'binding = "DB"', - 'database_name = "main"', - `migrations_dir = ${JSON.stringify(path.relative(dir, outside))}`, - "", - ].join("\n")); + writeFileSync( + path.join(dir, "wrangler.toml"), + [ + 'name = "api"', + 'main = "src/index.js"', + "", + "[[d1_databases]]", + 'binding = "DB"', + 'database_name = "main"', + `migrations_dir = ${JSON.stringify(path.relative(dir, outside))}`, + "", + ].join("\n") + ); /** @type {RecordedCall[]} */ const calls = []; await assert.rejects( - () => runD1Command([ - "migrations", - "apply", - "main", - "--control-url", - "http://ctl.test", - ], { - cwd: dir, - env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, - /** @param {string} url @param {ControlFetchInit} [init] */ - controlFetch: async (url, init = {}) => { - calls.push({ url, init }); - return response({}); - }, - }), + () => + runD1Command(["migrations", "apply", "main", "--control-url", "http://ctl.test"], { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + /** @param {string} url @param {ControlFetchInit} [init] */ + controlFetch: async (url, init = {}) => { + calls.push({ url, init }); + return response({}); + }, + }), /migrations_dir must stay inside the project/ ); assert.equal(calls.length, 0); @@ -477,25 +470,35 @@ test("d1 migrations_dir errors escape terminal controls from config fields", asy const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-config-escape-")); const badMigrationsDir = `../outside${ESC}[2J\nFORGED\rBAD`; try { - writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ - name: "api", - main: "src/index.js", - d1_databases: [{ - binding: "DB", - database_name: "main", - migrations_dir: badMigrationsDir, - }], - })); + writeFileSync( + path.join(dir, "wrangler.json"), + JSON.stringify({ + name: "api", + main: "src/index.js", + d1_databases: [ + { + binding: "DB", + database_name: "main", + migrations_dir: badMigrationsDir, + }, + ], + }) + ); await assert.rejects( - () => runD1Command(["migrations", "apply", "main", "--control-url", "http://ctl.test"], { - cwd: dir, - env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, - controlFetch: async () => { - throw new Error("controlFetch should not be called"); - }, - }), - (err) => assertEscapedD1Error(err, /migrations_dir must stay inside the project \(got "\.\.\/outside\\u001b\[2J\\nFORGED\\rBAD"\)/) + () => + runD1Command(["migrations", "apply", "main", "--control-url", "http://ctl.test"], { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }), + (err) => + assertEscapedD1Error( + err, + /migrations_dir must stay inside the project \(got "\.\.\/outside\\u001b\[2J\\nFORGED\\rBAD"\)/ + ) ); } finally { rmSync(dir, { recursive: true, force: true }); @@ -508,26 +511,34 @@ test("d1 migrations warns when multiple Wrangler configs exist", async () => { const migrations = path.join(dir, "schema"); mkdirSync(migrations); writeFileSync(path.join(migrations, "001_init.sql"), "create table t (id integer);"); - writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ - name: "api", - main: "src/index.js", - d1_databases: [{ - binding: "DB", - database_name: "main", - database_id: "main-id", - migrations_dir: "schema", - }], - })); - writeFileSync(path.join(dir, "wrangler.toml"), [ - 'name = "old"', - 'main = "old.js"', - "", - "[[d1_databases]]", - 'binding = "DB"', - 'database_name = "main"', - 'migrations_dir = "old-migrations"', - "", - ].join("\n")); + writeFileSync( + path.join(dir, "wrangler.json"), + JSON.stringify({ + name: "api", + main: "src/index.js", + d1_databases: [ + { + binding: "DB", + database_name: "main", + database_id: "main-id", + migrations_dir: "schema", + }, + ], + }) + ); + writeFileSync( + path.join(dir, "wrangler.toml"), + [ + 'name = "old"', + 'main = "old.js"', + "", + "[[d1_databases]]", + 'binding = "DB"', + 'database_name = "main"', + 'migrations_dir = "old-migrations"', + "", + ].join("\n") + ); /** @type {string[]} */ const warnings = []; @@ -551,13 +562,14 @@ test("d1 migrations --dir display errors escape terminal controls", async () => try { mkdirSync(path.join(dir, badDir)); await assert.rejects( - () => runD1Command(["migrations", "apply", "main", "--dir", badDir, "--control-url", "http://ctl.test"], { - cwd: dir, - env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, - controlFetch: async () => { - throw new Error("controlFetch should not be called"); - }, - }), + () => + runD1Command(["migrations", "apply", "main", "--dir", badDir, "--control-url", "http://ctl.test"], { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }), (err) => assertEscapedD1Error(err, /no \.sql migration files found in empty\\u001b\[2J\\nFORGED\\rBAD/) ); } finally { @@ -569,24 +581,30 @@ test("d1 migration config matching errors escape terminal controls", async () => const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-match-escape-")); const badRef = `main${ESC}[2J\nFORGED\rBAD`; try { - writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ - name: "api", - main: "src/index.js", - d1_databases: [{ - binding: "DB", - database_name: "other", - migrations_dir: "migrations", - }], - })); + writeFileSync( + path.join(dir, "wrangler.json"), + JSON.stringify({ + name: "api", + main: "src/index.js", + d1_databases: [ + { + binding: "DB", + database_name: "other", + migrations_dir: "migrations", + }, + ], + }) + ); await assert.rejects( - () => runD1Command(["migrations", "apply", badRef, "--control-url", "http://ctl.test"], { - cwd: dir, - env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, - controlFetch: async () => { - throw new Error("controlFetch should not be called"); - }, - }), + () => + runD1Command(["migrations", "apply", badRef, "--control-url", "http://ctl.test"], { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }), (err) => assertEscapedD1Error(err, /no matching \[\[d1_databases\]\] entry for "main\\u001b\[2J\\nFORGED\\rBAD"/) ); } finally { @@ -598,23 +616,27 @@ test("d1 migration multiple-match errors escape terminal controls", async () => const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-multi-match-escape-")); const badRef = `main${ESC}[2J\nFORGED\rBAD`; try { - writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ - name: "api", - main: "src/index.js", - d1_databases: [ - { binding: "DB1", database_name: badRef, migrations_dir: "migrations" }, - { binding: "DB2", database_name: badRef, migrations_dir: "migrations" }, - ], - })); + writeFileSync( + path.join(dir, "wrangler.json"), + JSON.stringify({ + name: "api", + main: "src/index.js", + d1_databases: [ + { binding: "DB1", database_name: badRef, migrations_dir: "migrations" }, + { binding: "DB2", database_name: badRef, migrations_dir: "migrations" }, + ], + }) + ); await assert.rejects( - () => runD1Command(["migrations", "apply", badRef, "--control-url", "http://ctl.test"], { - cwd: dir, - env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, - controlFetch: async () => { - throw new Error("controlFetch should not be called"); - }, - }), + () => + runD1Command(["migrations", "apply", badRef, "--control-url", "http://ctl.test"], { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }), (err) => assertEscapedD1Error(err, /multiple \[\[d1_databases\]\] entries match "main\\u001b\[2J\\nFORGED\\rBAD"/) ); } finally { @@ -628,23 +650,19 @@ test("d1 migrations --dir cannot escape the project", async () => { try { writeFileSync(path.join(outside, "001_leak.sql"), "select 'leak';"); await assert.rejects( - () => runD1Command([ - "migrations", - "apply", - "main", - "--dir", - path.relative(dir, outside), - "--control-url", - "http://ctl.test", - ], { - cwd: dir, - env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, - stdin: { isTTY: false }, - stdout: () => {}, - controlFetch: async () => { - throw new Error("controlFetch should not be called"); - }, - }), + () => + runD1Command( + ["migrations", "apply", "main", "--dir", path.relative(dir, outside), "--control-url", "http://ctl.test"], + { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + stdin: { isTTY: false }, + stdout: () => {}, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + } + ), /--dir must stay inside the project/ ); } finally { @@ -664,9 +682,7 @@ test("d1 migrations apply orders unpadded numeric prefixes numerically", async ( /** @type {RecordedCall[]} */ const calls = []; - await runD1Command([ - "migrations", "apply", "main", "--dir", "migrations", "--control-url", "http://ctl.test", - ], { + await runD1Command(["migrations", "apply", "main", "--dir", "migrations", "--control-url", "http://ctl.test"], { cwd: dir, env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, stdout: () => {}, @@ -678,11 +694,10 @@ test("d1 migrations apply orders unpadded numeric prefixes numerically", async ( }); const body = parseMigrationsBody(calls[0].init.body); - assert.deepEqual(body.migrations.map((m) => m.id), [ - "1_init.sql", - "2_two.sql", - "10_ten.sql", - ]); + assert.deepEqual( + body.migrations.map((m) => m.id), + ["1_init.sql", "2_two.sql", "10_ten.sql"] + ); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -697,9 +712,7 @@ test("d1 migrations --dir accepts a project subdirectory whose name starts with /** @type {RecordedCall[]} */ const calls = []; - await runD1Command([ - "migrations", "apply", "main", "--dir", "..hidden", "--control-url", "http://ctl.test", - ], { + await runD1Command(["migrations", "apply", "main", "--dir", "..hidden", "--control-url", "http://ctl.test"], { cwd: dir, env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, stdout: () => {}, @@ -711,7 +724,10 @@ test("d1 migrations --dir accepts a project subdirectory whose name starts with }); const body = parseMigrationsBody(calls[0].init.body); - assert.deepEqual(body.migrations.map((m) => m.id), ["0001_init.sql"]); + assert.deepEqual( + body.migrations.map((m) => m.id), + ["0001_init.sql"] + ); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -721,12 +737,13 @@ test("d1 execute --file rejects a path outside the project", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-file-escape-")); try { await assert.rejects( - () => runD1Command(["execute", "main", "--file", "../outside.sql", "--control-url", "http://ctl.test"], { - cwd: dir, - env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, - stdout: () => {}, - controlFetch: async () => response({}), - }), + () => + runD1Command(["execute", "main", "--file", "../outside.sql", "--control-url", "http://ctl.test"], { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + stdout: () => {}, + controlFetch: async () => response({}), + }), /--file must stay inside the project/ ); } finally { @@ -738,14 +755,15 @@ test("d1 execute --file wraps missing file read errors", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-file-missing-")); try { await assert.rejects( - () => runD1Command(["execute", "main", "--file", "missing.sql", "--control-url", "http://ctl.test"], { - cwd: dir, - env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, - stdout: () => {}, - controlFetch: async () => { - throw new Error("controlFetch should not be called"); - }, - }), + () => + runD1Command(["execute", "main", "--file", "missing.sql", "--control-url", "http://ctl.test"], { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + stdout: () => {}, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }), /cannot read SQL file missing\.sql:/ ); } finally { @@ -758,20 +776,21 @@ test("d1 execute --file escapes terminal controls in file read errors", async () const bad = `bad${ESC}[2J\nFORGED\rBAD.sql`; try { await assert.rejects( - () => runD1Command(["execute", "main", "--file", bad, "--control-url", "http://ctl.test"], { - cwd: dir, - env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, - stdout: () => {}, - controlFetch: async () => { - throw new Error("controlFetch should not be called"); - }, - }), + () => + runD1Command(["execute", "main", "--file", bad, "--control-url", "http://ctl.test"], { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + stdout: () => {}, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }), (err) => { const message = /** @type {Error} */ (err).message; assertNoRawTerminalControls(message, "the error"); assert.match(message, /bad\\u001b\[2J\\nFORGED\\rBAD\.sql/); return true; - }, + } ); } finally { rmSync(dir, { recursive: true, force: true }); @@ -819,9 +838,12 @@ test("d1 escapes terminal controls in unexpected positional errors", async () => (err) => { const message = /** @type {Error} */ (err).message; assertNoRawTerminalControls(message, "the error"); - assert.match(message, /d1 migrations apply\\u001b\[2J\\nFORGED\\rBAD received unexpected argument: bad\\u001b\[2J\\nFORGED\\rBAD/); + assert.match( + message, + /d1 migrations apply\\u001b\[2J\\nFORGED\\rBAD received unexpected argument: bad\\u001b\[2J\\nFORGED\\rBAD/ + ); return true; - }, + } ); }); @@ -847,11 +869,15 @@ test("d1 escapes terminal controls in unknown subcommand errors", async () => { test("d1 execute rejects an unknown --mode before calling control", async () => { let fetched = false; await assert.rejects( - () => runD1Command(["execute", "main", "--sql", "SELECT 1", "--mode", "bogus", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, - stdout: () => {}, - controlFetch: async () => { fetched = true; return response({}); }, - }), + () => + runD1Command(["execute", "main", "--sql", "SELECT 1", "--mode", "bogus", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + stdout: () => {}, + controlFetch: async () => { + fetched = true; + return response({}); + }, + }), /--mode must be one of/ ); assert.equal(fetched, false); @@ -861,16 +887,7 @@ test("d1 execute accepts all valid --mode values", async () => { for (const mode of ["all", "raw", "run", "exec"]) { /** @type {RecordedCall[]} */ const calls = []; - await runD1Command([ - "execute", - "main", - "--sql", - "SELECT 1", - "--mode", - mode, - "--control-url", - "http://ctl.test", - ], { + await runD1Command(["execute", "main", "--sql", "SELECT 1", "--mode", mode, "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, stdout: () => {}, /** @param {string} url @param {ControlFetchInit} [init] */ @@ -888,10 +905,29 @@ test("d1 execute accepts all valid --mode values", async () => { test("d1 execute rejects --mode exec with any --params before calling control", async () => { let fetched = false; /** @param {string} paramsJson */ - const run = (paramsJson) => runD1Command( - ["execute", "main", "--sql", "SELECT 1", "--mode", "exec", "--params", paramsJson, "--control-url", "http://ctl.test"], - { env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, stdout: () => {}, controlFetch: async () => { fetched = true; return response({}); } } - ); + const run = (paramsJson) => + runD1Command( + [ + "execute", + "main", + "--sql", + "SELECT 1", + "--mode", + "exec", + "--params", + paramsJson, + "--control-url", + "http://ctl.test", + ], + { + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + stdout: () => {}, + controlFetch: async () => { + fetched = true; + return response({}); + }, + } + ); await assert.rejects(() => run("[1]"), /--mode exec does not accept --params/); await assert.rejects(() => run("[]"), /--mode exec does not accept --params/); await assert.rejects(() => run(""), /--mode exec does not accept --params/); @@ -901,10 +937,29 @@ test("d1 execute rejects --mode exec with any --params before calling control", test("d1 execute rejects an invalid --params before calling control", async () => { let fetched = false; /** @param {string} paramsJson */ - const run = (paramsJson) => runD1Command( - ["execute", "main", "--sql", "SELECT 1", "--mode", "all", "--params", paramsJson, "--control-url", "http://ctl.test"], - { env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, stdout: () => {}, controlFetch: async () => { fetched = true; return response({}); } } - ); + const run = (paramsJson) => + runD1Command( + [ + "execute", + "main", + "--sql", + "SELECT 1", + "--mode", + "all", + "--params", + paramsJson, + "--control-url", + "http://ctl.test", + ], + { + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + stdout: () => {}, + controlFetch: async () => { + fetched = true; + return response({}); + }, + } + ); await assert.rejects(() => run(""), /--params must be a JSON array/); await assert.rejects(() => run("{}"), /--params must be a JSON array/); assert.equal(fetched, false); @@ -915,12 +970,13 @@ test("d1 migrations status reports an empty migrations dir like apply", async () try { mkdirSync(path.join(dir, "migrations")); await assert.rejects( - () => runD1Command(["migrations", "status", "main", "--dir", "migrations", "--control-url", "http://ctl.test"], { - cwd: dir, - env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, - stdout: () => {}, - controlFetch: async () => response({}), - }), + () => + runD1Command(["migrations", "status", "main", "--dir", "migrations", "--control-url", "http://ctl.test"], { + cwd: dir, + env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + stdout: () => {}, + controlFetch: async () => response({}), + }), /no \.sql migration files found/ ); } finally { diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 277a1e7..351d857 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -1,14 +1,19 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; -import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { - DEPLOY_JSON_BODY_MAX_BYTES, - runDeployCommand, - serializeDeployManifest, -} from "../../commands/deploy.js"; +import { DEPLOY_JSON_BODY_MAX_BYTES, runDeployCommand, serializeDeployManifest } from "../../commands/deploy.js"; import { collectAssets, collectModules, @@ -48,15 +53,12 @@ import { ESC, MODE_BITS_ENFORCED_ONLY, assertNoRawTerminalControls, assertUnread * @param {string} target */ function assertThrowsNoRawTerminalControls(fn, expected, target) { - assert.throws( - fn, - (err) => { - const message = /** @type {Error} */ (err).message; - assertNoRawTerminalControls(message, target); - assert.match(message, expected); - return true; - } - ); + assert.throws(fn, (err) => { + const message = /** @type {Error} */ (err).message; + assertNoRawTerminalControls(message, target); + assert.match(message, expected); + return true; + }); } /** @@ -198,22 +200,16 @@ test("parseTriggers: missing/empty yields []", () => { }); test("parseTriggers: [triggers] crons defaults timezone to UTC", () => { - assert.deepEqual( - parseTriggers({ crons: ["*/5 * * * *", "0 0 * * *"] }), - [ - { cron: "*/5 * * * *", timezone: "UTC" }, - { cron: "0 0 * * *", timezone: "UTC" }, - ] - ); + assert.deepEqual(parseTriggers({ crons: ["*/5 * * * *", "0 0 * * *"] }), [ + { cron: "*/5 * * * *", timezone: "UTC" }, + { cron: "0 0 * * *", timezone: "UTC" }, + ]); }); test("parseTriggers: [[triggers.schedules]] preserves timezone", () => { assert.deepEqual( parseTriggers({ - schedules: [ - { cron: "0 9 * * 1-5", timezone: "Asia/Shanghai" }, - { cron: "0 0 * * *" }, - ], + schedules: [{ cron: "0 9 * * 1-5", timezone: "Asia/Shanghai" }, { cron: "0 0 * * *" }], }), [ { cron: "0 9 * * 1-5", timezone: "Asia/Shanghai" }, @@ -253,10 +249,10 @@ test("parseQueues: missing/empty yields empty producers and consumers", () => { }); test("parseQueues: producers normalize delivery_delay", () => { - assert.deepEqual( - parseQueues({ producers: [{ binding: "MY_Q", queue: "orders", delivery_delay: 60 }] }), - { producers: [{ binding: "MY_Q", queue: "orders", deliveryDelaySeconds: 60 }], consumers: [] } - ); + assert.deepEqual(parseQueues({ producers: [{ binding: "MY_Q", queue: "orders", delivery_delay: 60 }] }), { + producers: [{ binding: "MY_Q", queue: "orders", deliveryDelaySeconds: 60 }], + consumers: [], + }); }); test("parseQueues: consumers normalize max_batch_timeout and retry_delay", () => { @@ -285,17 +281,16 @@ test("parseQueues: consumers normalize max_batch_timeout and retry_delay", () => }); test("parseQueues: forwards platform-range batch timeouts for control-side validation", () => { - assert.deepEqual( - parseQueues({ consumers: [{ queue: "orders", max_batch_timeout: 61 }] }).consumers, - [{ queue: "orders", maxBatchTimeoutMs: 61_000 }] - ); + assert.deepEqual(parseQueues({ consumers: [{ queue: "orders", max_batch_timeout: 61 }] }).consumers, [ + { queue: "orders", maxBatchTimeoutMs: 61_000 }, + ]); }); test("parseQueues: omits optional consumer fields when absent", () => { - assert.deepEqual( - parseQueues({ consumers: [{ queue: "orders" }] }), - { producers: [], consumers: [{ queue: "orders" }] } - ); + assert.deepEqual(parseQueues({ consumers: [{ queue: "orders" }] }), { + producers: [], + consumers: [{ queue: "orders" }], + }); }); test("parseQueues: validates delay fields and rejects unsupported concurrency loudly", () => { @@ -307,22 +302,10 @@ test("parseQueues: validates delay fields and rejects unsupported concurrency lo () => parseQueues({ producers: [{ binding: "Q", queue: "q", delivery_delay: "30" }] }), /delivery_delay/ ); - assert.throws( - () => parseQueues({ consumers: [{ queue: "q", retry_delay: -1 }] }), - /retry_delay/ - ); - assert.throws( - () => parseQueues({ consumers: [{ queue: "q", retry_delay: true }] }), - /retry_delay/ - ); - assert.throws( - () => parseQueues({ consumers: [{ queue: "q", max_batch_timeout: "5" }] }), - /max_batch_timeout/ - ); - assert.throws( - () => parseQueues({ consumers: [{ queue: "q", max_batch_timeout: true }] }), - /max_batch_timeout/ - ); + assert.throws(() => parseQueues({ consumers: [{ queue: "q", retry_delay: -1 }] }), /retry_delay/); + assert.throws(() => parseQueues({ consumers: [{ queue: "q", retry_delay: true }] }), /retry_delay/); + assert.throws(() => parseQueues({ consumers: [{ queue: "q", max_batch_timeout: "5" }] }), /max_batch_timeout/); + assert.throws(() => parseQueues({ consumers: [{ queue: "q", max_batch_timeout: true }] }), /max_batch_timeout/); assert.throws( () => parseQueues({ consumers: [{ queue: "q", max_concurrency: 4 }] }), /max_concurrency not supported/ @@ -368,9 +351,15 @@ test("parseD1DatabasesFromCfg: rejects wrong shape and missing fields", () => { assert.throws(() => parseD1DatabasesFromCfg({ d1_databases: {} }), /must be an array/); assert.throws(() => parseD1DatabasesFromCfg({ d1_databases: [null] }), /entry must be a table/); assert.throws(() => parseD1DatabasesFromCfg({ d1_databases: [{ database_name: "main" }] }), /binding is required/); - assert.throws(() => parseD1DatabasesFromCfg({ d1_databases: [{ binding: "DB" }] }), /database_name or database_id is required/); assert.throws( - () => parseD1DatabasesFromCfg({ d1_databases: [{ binding: "DB", database_name: "main", databsae_id: "oops" }] }), + () => parseD1DatabasesFromCfg({ d1_databases: [{ binding: "DB" }] }), + /database_name or database_id is required/ + ); + assert.throws( + () => + parseD1DatabasesFromCfg({ + d1_databases: [{ binding: "DB", database_name: "main", databsae_id: "oops" }], + }), /unknown field\(s\): databsae_id/ ); }); @@ -378,13 +367,15 @@ test("parseD1DatabasesFromCfg: rejects wrong shape and missing fields", () => { test("parseD1DatabasesFromCfg: accepts recognized wrangler-only fields without using them for deploy binding resolution", () => { assert.deepEqual( parseD1DatabasesFromCfg({ - d1_databases: [{ - binding: "DB", - database_name: "main", - preview_database_id: "preview-main", - migrations_dir: "schema", - migrations_table: "_migrations", - }], + d1_databases: [ + { + binding: "DB", + database_name: "main", + preview_database_id: "preview-main", + migrations_dir: "schema", + migrations_table: "_migrations", + }, + ], }), [{ binding: "DB", databaseId: "main" }] ); @@ -394,9 +385,7 @@ test("parseR2BucketsFromCfg: parses wrangler R2 bucket bindings", () => { assert.deepEqual(parseR2BucketsFromCfg({}), []); assert.deepEqual( parseR2BucketsFromCfg({ - r2_buckets: [ - { binding: "BUCKET", bucket_name: "uploads" }, - ], + r2_buckets: [{ binding: "BUCKET", bucket_name: "uploads" }], }), [{ binding: "BUCKET", bucketName: "uploads" }] ); @@ -409,32 +398,41 @@ test("parseR2BucketsFromCfg: parses wrangler R2 bucket bindings", () => { /bucket_name must match/ ); assert.throws( - () => parseR2BucketsFromCfg({ r2_buckets: [{ binding: "BUCKET", bucket_name: "uploads", preview_bucket_name: "preview" }] }), + () => + parseR2BucketsFromCfg({ + r2_buckets: [{ binding: "BUCKET", bucket_name: "uploads", preview_bucket_name: "preview" }], + }), /preview_bucket_name is not supported/ ); assert.throws( - () => parseR2BucketsFromCfg({ r2_buckets: [{ binding: "BUCKET", bucket_name: "uploads", jurisdiction: "eu" }] }), + () => + parseR2BucketsFromCfg({ + r2_buckets: [{ binding: "BUCKET", bucket_name: "uploads", jurisdiction: "eu" }], + }), /jurisdiction is not supported/ ); }); test("parse resource bindings reject runtime-internal WDL names", () => { assert.throws( - () => parseD1DatabasesFromCfg({ - d1_databases: [{ binding: "__WDL_RESERVED__", database_name: "main" }], - }), + () => + parseD1DatabasesFromCfg({ + d1_databases: [{ binding: "__WDL_RESERVED__", database_name: "main" }], + }), /runtime-internal bindings/ ); assert.throws( - () => parseR2BucketsFromCfg({ - r2_buckets: [{ binding: "__WDL_RESERVED__", bucket_name: "uploads" }], - }), + () => + parseR2BucketsFromCfg({ + r2_buckets: [{ binding: "__WDL_RESERVED__", bucket_name: "uploads" }], + }), /runtime-internal bindings/ ); assert.throws( - () => parseServicesFromCfg({ - services: [{ binding: "__WDL_RESERVED__", service: "target" }], - }), + () => + parseServicesFromCfg({ + services: [{ binding: "__WDL_RESERVED__", service: "target" }], + }), /runtime-internal bindings/ ); }); @@ -460,62 +458,68 @@ test("parseDurableObjectsFromCfg: parses local DO bindings with new_classes or n [{ binding: "ROOMS", className: "Room" }] ); assert.throws(() => parseDurableObjectsFromCfg({ durable_objects: [] }), /must be a table/); + assert.throws(() => parseDurableObjectsFromCfg({ durable_objects: { bindings: {} } }), /must be an array/); assert.throws( - () => parseDurableObjectsFromCfg({ durable_objects: { bindings: {} } }), - /must be an array/ - ); - assert.throws( - () => parseDurableObjectsFromCfg({ - durable_objects: { bindings: [{ class_name: "Room", script_name: "other" }] }, - migrations: [{ tag: "v1", new_classes: ["Room"] }], - }), + () => + parseDurableObjectsFromCfg({ + durable_objects: { bindings: [{ class_name: "Room", script_name: "other" }] }, + migrations: [{ tag: "v1", new_classes: ["Room"] }], + }), /\[\[durable_objects\.bindings\]\]\.name is required/ ); assert.throws( - () => parseDurableObjectsFromCfg({ - durable_objects: { bindings: [{ name: "ROOMS", class_name: "Room", script_name: "other" }] }, - migrations: [{ tag: "v1", new_classes: ["Room"] }], - }), + () => + parseDurableObjectsFromCfg({ + durable_objects: { + bindings: [{ name: "ROOMS", class_name: "Room", script_name: "other" }], + }, + migrations: [{ tag: "v1", new_classes: ["Room"] }], + }), /script_name is not supported/ ); assert.throws( - () => parseDurableObjectsFromCfg({ - durable_objects: { bindings: [{ name: "ROOMS", class_name: "Room" }] }, - migrations: [{ tag: "v1", new_classes: ["Other"] }], - }), + () => + parseDurableObjectsFromCfg({ + durable_objects: { bindings: [{ name: "ROOMS", class_name: "Room" }] }, + migrations: [{ tag: "v1", new_classes: ["Other"] }], + }), /must be listed in \[\[migrations\]\]\.new_classes or \[\[migrations\]\]\.new_sqlite_classes/ ); assert.throws( - () => parseDurableObjectsFromCfg({ - durable_objects: { bindings: [{ name: "ROOMS", class_name: "Room" }] }, - migrations: [{ tag: "v1", new_sqlite_classes: [42] }], - }), + () => + parseDurableObjectsFromCfg({ + durable_objects: { bindings: [{ name: "ROOMS", class_name: "Room" }] }, + migrations: [{ tag: "v1", new_sqlite_classes: [42] }], + }), /new_sqlite_classes entries must be valid JS class declaration names/ ); assert.throws( - () => parseDurableObjectsFromCfg({ - durable_objects: { bindings: [{ name: "ROOMS", class_name: "class" }] }, - migrations: [{ tag: "v1", new_classes: ["class"] }], - }), + () => + parseDurableObjectsFromCfg({ + durable_objects: { bindings: [{ name: "ROOMS", class_name: "class" }] }, + migrations: [{ tag: "v1", new_classes: ["class"] }], + }), /new_classes entries must be valid JS class declaration names/ ); assert.throws( - () => parseDurableObjectsFromCfg({ - durable_objects: { bindings: [{ name: "ROOMS", class_name: "Room" }] }, - migrations: [{ tag: "v2", renamed_classes: [{ from: "Old", to: "Room" }] }], - }), + () => + parseDurableObjectsFromCfg({ + durable_objects: { bindings: [{ name: "ROOMS", class_name: "Room" }] }, + migrations: [{ tag: "v2", renamed_classes: [{ from: "Old", to: "Room" }] }], + }), /renamed_classes is not supported/ ); }); test("parseDurableObjectsFromCfg: rejects runtime-internal binding names", () => { assert.throws( - () => parseDurableObjectsFromCfg({ - durable_objects: { - bindings: [{ name: "__WDL_RESERVED__", class_name: "Room" }], - }, - migrations: [{ tag: "v1", new_classes: ["Room"] }], - }), + () => + parseDurableObjectsFromCfg({ + durable_objects: { + bindings: [{ name: "__WDL_RESERVED__", class_name: "Room" }], + }, + migrations: [{ tag: "v1", new_classes: ["Room"] }], + }), /runtime-internal bindings/ ); }); @@ -523,15 +527,12 @@ test("parseDurableObjectsFromCfg: rejects runtime-internal binding names", () => test("collectRoutes: accepts strings and { pattern } tables, rejects non-arrays", () => { assert.deepEqual(collectRoutes({}, "wrangler.toml"), []); assert.deepEqual(collectRoutes({ route: "dev.example.com/*" }, "wrangler.toml"), ["dev.example.com/*"]); - assert.deepEqual( - collectRoutes({ routes: ["a.example.com/*", { pattern: "b.example.com/*" }] }, "wrangler.toml"), - ["a.example.com/*", "b.example.com/*"] - ); + assert.deepEqual(collectRoutes({ routes: ["a.example.com/*", { pattern: "b.example.com/*" }] }, "wrangler.toml"), [ + "a.example.com/*", + "b.example.com/*", + ]); // A non-array `routes` must fail fast, not be silently dropped. - assert.throws( - () => collectRoutes({ routes: "a.example.com/*" }, "wrangler.toml"), - /"routes" must be an array/ - ); + assert.throws(() => collectRoutes({ routes: "a.example.com/*" }, "wrangler.toml"), /"routes" must be an array/); assert.throws( () => collectRoutes({ routes: { pattern: "a.example.com/*" } }, "wrangler.toml"), /"routes" must be an array/ @@ -559,10 +560,7 @@ test("collectRoutes: accepts strings and { pattern } tables, rejects non-arrays" test("parseWorkersDev requires an explicit boolean and a route for opt-out", () => { assert.equal(parseWorkersDev({}, [], "wrangler.toml"), true); assert.equal(parseWorkersDev({ workers_dev: true }, [], "wrangler.toml"), true); - assert.equal( - parseWorkersDev({ workers_dev: false }, ["app.example/*"], "wrangler.toml"), - false - ); + assert.equal(parseWorkersDev({ workers_dev: false }, ["app.example/*"], "wrangler.toml"), false); assert.throws( () => parseWorkersDev({ workers_dev: "false" }, ["app.example/*"], "wrangler.toml"), /"workers_dev" must be a boolean/ @@ -576,10 +574,9 @@ test("parseWorkersDev requires an explicit boolean and a route for opt-out", () test("parseKvNamespacesFromCfg: validates shape and non-empty string binding/id", () => { assert.deepEqual(parseKvNamespacesFromCfg({}), []); assert.deepEqual(parseKvNamespacesFromCfg({ kv_namespaces: [] }), []); - assert.deepEqual( - parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: "abc" }] }), - [{ binding: "KV", id: "abc" }] - ); + assert.deepEqual(parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: "abc" }] }), [ + { binding: "KV", id: "abc" }, + ]); assert.deepEqual( // KV ids are control-plane resource ids, not runtime binding names; keep // the long-standing whitespace trim explicit and intentional. @@ -589,26 +586,39 @@ test("parseKvNamespacesFromCfg: validates shape and non-empty string binding/id" assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: {} }), /must be an array/); assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [null] }), /entry must be a table/); assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ id: "x" }] }), /needs a non-empty string 'binding'/); - assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "", id: "x" }] }), /needs a non-empty string 'binding'/); - assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: ["KV"], id: "x" }] }), /needs a non-empty string 'binding'/); - assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV" }] }), /'id' must be a non-empty string/); - assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: 123 }] }), /'id' must be a non-empty string/); + assert.throws( + () => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "", id: "x" }] }), + /needs a non-empty string 'binding'/ + ); + assert.throws( + () => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: ["KV"], id: "x" }] }), + /needs a non-empty string 'binding'/ + ); + assert.throws( + () => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV" }] }), + /'id' must be a non-empty string/ + ); + assert.throws( + () => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: 123 }] }), + /'id' must be a non-empty string/ + ); // binding name grammar still enforced - assert.throws(() => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "bad-kv", id: "x" }] }), /binding must match/); + assert.throws( + () => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "bad-kv", id: "x" }] }), + /binding must match/ + ); // unknown keys (typos) are rejected, like the d1/r2 parsers assert.throws( () => parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: "x", bindng: "typo" }] }), /unknown field\(s\): bindng/ ); // Wrangler's local-dev keys (preview_id, remote) are allowed but ignored - assert.deepEqual( - parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: "x", preview_id: "p" }] }), - [{ binding: "KV", id: "x" }] - ); - assert.deepEqual( - parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: "x", remote: true }] }), - [{ binding: "KV", id: "x" }] - ); + assert.deepEqual(parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: "x", preview_id: "p" }] }), [ + { binding: "KV", id: "x" }, + ]); + assert.deepEqual(parseKvNamespacesFromCfg({ kv_namespaces: [{ binding: "KV", id: "x", remote: true }] }), [ + { binding: "KV", id: "x" }, + ]); }); test("parseServicesFromCfg: parses wrangler [[services]] entries", () => { @@ -628,14 +638,8 @@ test("parseServicesFromCfg: parses wrangler [[services]] entries", () => { ); assert.throws(() => parseServicesFromCfg({ services: {} }), /must be an array/); assert.throws(() => parseServicesFromCfg({ services: [null] }), /entry must be a table/); - assert.throws( - () => parseServicesFromCfg({ services: [{ service: "x" }] }), - /needs both 'binding' and 'service'/ - ); - assert.throws( - () => parseServicesFromCfg({ services: [{ binding: "X" }] }), - /needs both 'binding' and 'service'/ - ); + assert.throws(() => parseServicesFromCfg({ services: [{ service: "x" }] }), /needs both 'binding' and 'service'/); + assert.throws(() => parseServicesFromCfg({ services: [{ binding: "X" }] }), /needs both 'binding' and 'service'/); // A present-but-empty value gets the specific non-empty-string error, not "needs both". assert.throws( () => parseServicesFromCfg({ services: [{ binding: "", service: "y" }] }), @@ -664,10 +668,9 @@ test("parseServicesFromCfg: parses wrangler [[services]] entries", () => { () => parseServicesFromCfg({ services: [{ binding: "X", service: "y", ns: "BAD NS" }] }), /ns must match/ ); - assert.deepEqual( - parseServicesFromCfg({ services: [{ binding: "SYS", service: "dash", ns: "__reserved__" }] }), - [{ binding: "SYS", service: "dash", ns: "__reserved__" }] - ); + assert.deepEqual(parseServicesFromCfg({ services: [{ binding: "SYS", service: "dash", ns: "__reserved__" }] }), [ + { binding: "SYS", service: "dash", ns: "__reserved__" }, + ]); assert.throws( () => parseServicesFromCfg({ services: [{ binding: "X", service: "y", ns: "admin" }] }), /ns must match/ @@ -686,7 +689,7 @@ test("parseServicesFromCfg: rejects runtime-reserved entrypoint names (__Wdl…_ services: [{ binding: "X", service: "t", entrypoint: reserved }], }), /reserved for runtime-injected/, - `expected reserved-entrypoint rejection for ${JSON.stringify(reserved)}`, + `expected reserved-entrypoint rejection for ${JSON.stringify(reserved)}` ); } // `__WdlNotReserved` lacks the trailing `__`, so it's user-controllable. @@ -694,7 +697,7 @@ test("parseServicesFromCfg: rejects runtime-reserved entrypoint names (__Wdl…_ assert.doesNotThrow(() => parseServicesFromCfg({ services: [{ binding: "X", service: "t", entrypoint: "__WdlNotReserved" }], - }), + }) ); }); @@ -722,15 +725,19 @@ test("wrangler binding parser diagnostics escape terminal controls", () => { "service diagnostics" ); assertThrowsNoRawTerminalControls( - () => parseDurableObjectsFromCfg({ - durable_objects: { bindings: [{ name: bad, class_name: "Room", script_name: "other" }] }, - migrations: [{ tag: "v1", new_classes: ["Room"] }], - }), + () => + parseDurableObjectsFromCfg({ + durable_objects: { bindings: [{ name: bad, class_name: "Room", script_name: "other" }] }, + migrations: [{ tag: "v1", new_classes: ["Room"] }], + }), /script_name is not supported/, "Durable Object diagnostics" ); assertThrowsNoRawTerminalControls( - () => parseWorkflowsFromCfg({ workflows: [{ name: bad, binding: "WF", class_name: "Flow", script_name: "other" }] }), + () => + parseWorkflowsFromCfg({ + workflows: [{ name: bad, binding: "WF", class_name: "Flow", script_name: "other" }], + }), /name must match/, "workflow diagnostics" ); @@ -805,10 +812,7 @@ test("collectModules: rejects Python Workers modules before upload", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-collect-py-")); try { writeFileSync(path.join(dir, "index.py"), "export default {}"); - assert.throws( - () => collectModules(dir), - /Python Workers modules are not supported by WDL \(index\.py\)/ - ); + assert.throws(() => collectModules(dir), /Python Workers modules are not supported by WDL \(index\.py\)/); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -823,10 +827,7 @@ test("collectAssets: recurses and preserves dotfiles as base64", () => { writeFileSync(path.join(dir, "img", "logo.bin"), Buffer.from([0, 1, 255])); const out = collectAssets(dir); - assert.equal( - out[".well-known/security.txt"], - Buffer.from("contact: ops@example.com").toString("base64") - ); + assert.equal(out[".well-known/security.txt"], Buffer.from("contact: ops@example.com").toString("base64")); assert.equal(out["img/logo.bin"], Buffer.from([0, 1, 255]).toString("base64")); } finally { rmSync(dir, { recursive: true, force: true }); @@ -926,14 +927,17 @@ test("collectAssets prunes an ignored symlink instead of rejecting it", () => { test("collectAssets honors .assetsignore patterns, negation, and never ships the file itself", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-assetsignore-")); try { - writeFileSync(path.join(dir, ".assetsignore"), [ - "*.map", - "drafts/", - "!keep.map", - "# comment", - "", - "!.env", // deliberate re-include of a default ignore - ].join("\n")); + writeFileSync( + path.join(dir, ".assetsignore"), + [ + "*.map", + "drafts/", + "!keep.map", + "# comment", + "", + "!.env", // deliberate re-include of a default ignore + ].join("\n") + ); writeFileSync(path.join(dir, "app.js"), "x"); writeFileSync(path.join(dir, "app.js.map"), "m"); writeFileSync(path.join(dir, "keep.map"), "m"); @@ -982,11 +986,11 @@ test("collectAssets character classes never match the path separator", () => { try { writeFileSync(path.join(dir, ".assetsignore"), "a[.-9]b\n[!-x]bc\n"); mkdirSync(path.join(dir, "a"), { recursive: true }); - writeFileSync(path.join(dir, "a", "b"), "x"); // range .-9 spans "/" — must NOT match across segments - writeFileSync(path.join(dir, "a.b"), "x"); // in-range, single segment — ignored - writeFileSync(path.join(dir, "Abc"), "x"); // [!-x]: A is neither "-" nor "x" — ignored - writeFileSync(path.join(dir, "-bc"), "x"); // literal "-" is in the negated set — kept - writeFileSync(path.join(dir, "xbc"), "x"); // "x" is in the negated set — kept + writeFileSync(path.join(dir, "a", "b"), "x"); // range .-9 spans "/" — must NOT match across segments + writeFileSync(path.join(dir, "a.b"), "x"); // in-range, single segment — ignored + writeFileSync(path.join(dir, "Abc"), "x"); // [!-x]: A is neither "-" nor "x" — ignored + writeFileSync(path.join(dir, "-bc"), "x"); // literal "-" is in the negated set — kept + writeFileSync(path.join(dir, "xbc"), "x"); // "x" is in the negated set — kept assert.deepEqual(Object.keys(collectAssets(dir)).toSorted(), ["-bc", "a/b", "xbc"]); } finally { rmSync(dir, { recursive: true, force: true }); @@ -1063,7 +1067,7 @@ test("collectAssets wraps native filesystem errors with escaped asset paths", MO test("collectAssets skips crash-leftover wdl temp configs by default", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-assets-tmpcfg-")); try { - writeFileSync(path.join(dir, ".wrangler.wdl-tmp-1234.json"), "{\"vars\":{}}"); + writeFileSync(path.join(dir, ".wrangler.wdl-tmp-1234.json"), '{"vars":{}}'); writeFileSync(path.join(dir, "index.html"), ""); assert.deepEqual(Object.keys(collectAssets(dir)), ["index.html"]); } finally { @@ -1081,7 +1085,10 @@ test("collectAssets reports ignored entries via onIgnore, excluding .assetsignor writeFileSync(path.join(dir, "node_modules", "x.js"), "x"); /** @type {string[]} */ const skipped = []; - collectAssets(dir, { onIgnore: (/** @type {string} */ relPath, /** @type {boolean} */ isDir) => skipped.push(isDir ? `${relPath}/` : relPath) }); + collectAssets(dir, { + onIgnore: (/** @type {string} */ relPath, /** @type {boolean} */ isDir) => + skipped.push(isDir ? `${relPath}/` : relPath), + }); assert.deepEqual(skipped.toSorted(), ["app.js.map", "node_modules/"]); } finally { rmSync(dir, { recursive: true, force: true }); @@ -1092,10 +1099,7 @@ test("resolveAssetsDir: rejects a missing, empty, or non-string assets.directory const project = mkdtempSync(path.join(tmpdir(), "wdl-assets-dir-type-")); try { for (const bad of ["", " ", 123, true, ["public"], { directory: "public" }, null, undefined]) { - assert.throws( - () => resolveAssetsDir(project, bad), - /assets\.directory must be a non-empty string/ - ); + assert.throws(() => resolveAssetsDir(project, bad), /assets\.directory must be a non-empty string/); } } finally { rmSync(project, { recursive: true, force: true }); @@ -1128,10 +1132,7 @@ test("resolveAssetsDir: rejects assets.directory that escapes project root", () try { mkdirSync(project, { recursive: true }); mkdirSync(path.join(parent, "outside")); - assert.throws( - () => resolveAssetsDir(project, "../outside"), - /outside the project root/ - ); + assert.throws(() => resolveAssetsDir(project, "../outside"), /outside the project root/); } finally { rmSync(parent, { recursive: true, force: true }); } @@ -1144,10 +1145,7 @@ test("resolveAssetsDir: rejects an assets.directory that is itself a symlink", ( mkdirSync(project, { recursive: true }); mkdirSync(path.join(parent, "real")); symlinkSync(path.join(parent, "real"), path.join(project, "public")); - assert.throws( - () => resolveAssetsDir(project, "public"), - /must not be a symlink/ - ); + assert.throws(() => resolveAssetsDir(project, "public"), /must not be a symlink/); } finally { rmSync(parent, { recursive: true, force: true }); } @@ -1168,14 +1166,14 @@ test("loadWranglerConfig: prefers wrangler.json when multiple config files exist const dir = mkdtempSync(path.join(tmpdir(), "wdl-config-")); try { writeFileSync(path.join(dir, "wrangler.toml"), 'name = "toml-demo"\nmain = "src/index.js"\n'); - writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ - name: "json-demo", - main: "src/index.js", - })); writeFileSync( - path.join(dir, "wrangler.jsonc"), - '{ "name": "jsonc-demo", "main": "src/index.js" }' + path.join(dir, "wrangler.json"), + JSON.stringify({ + name: "json-demo", + main: "src/index.js", + }) ); + writeFileSync(path.join(dir, "wrangler.jsonc"), '{ "name": "jsonc-demo", "main": "src/index.js" }'); const loaded = loadWranglerConfig(dir); const cfg = /** @type {{ name?: string, main?: string }} */ (loaded.cfg); @@ -1278,7 +1276,9 @@ test("loadWranglerConfig: escapes config read errors", () => { test("installTempFileCleanup removes temp files on process exit and signals", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-temp-cleanup-")); try { - const processLike = /** @type {EventEmitter & { off(event: string, listener: () => void): EventEmitter }} */ (new EventEmitter()); + const processLike = /** @type {EventEmitter & { off(event: string, listener: () => void): EventEmitter }} */ ( + new EventEmitter() + ); /** @type {string[]} */ const terminated = []; const sigintFile = path.join(dir, "sigint.json"); @@ -1303,11 +1303,15 @@ test("installTempFileCleanup removes temp files on process exit and signals", () test("installTempFileCleanup only swallows cleanup errors when explicitly requested or during process handlers", () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-temp-cleanup-error-")); try { - const exitProcess = /** @type {EventEmitter & { off(event: string, listener: () => void): EventEmitter }} */ (new EventEmitter()); + const exitProcess = /** @type {EventEmitter & { off(event: string, listener: () => void): EventEmitter }} */ ( + new EventEmitter() + ); installTempFileCleanup(dir, exitProcess); assert.doesNotThrow(() => exitProcess.emit("exit")); - const cleanupProcess = /** @type {EventEmitter & { off(event: string, listener: () => void): EventEmitter }} */ (new EventEmitter()); + const cleanupProcess = /** @type {EventEmitter & { off(event: string, listener: () => void): EventEmitter }} */ ( + new EventEmitter() + ); const cleanup = installTempFileCleanup(dir, cleanupProcess); assert.throws(() => cleanup(), /EISDIR|directory/i); assert.doesNotThrow(() => cleanup({ ignoreErrors: true })); @@ -1318,29 +1322,38 @@ test("installTempFileCleanup only swallows cleanup errors when explicitly reques test("resolveWranglerConfig: named environments require explicit selection", () => { assert.throws( - () => resolveWranglerConfig({ - name: "demo", - main: "src/index.js", - env: { staging: {} }, - }, null, "wrangler.toml"), + () => + resolveWranglerConfig( + { + name: "demo", + main: "src/index.js", + env: { staging: {} }, + }, + null, + "wrangler.toml" + ), /named environments found \(staging\)/ ); }); test("resolveWranglerConfig: selected environment inherits supported top-level keys", () => { - const { cfg, envName } = resolveWranglerConfig({ - name: "demo", - main: "src/index.js", - compatibility_date: "2026-06-17", - compatibility_flags: ["nodejs_compat"], - route: "dev.example.com/*", - triggers: { crons: ["*/5 * * * *"] }, - env: { - staging: { - compatibility_flags: ["nodejs_als"], + const { cfg, envName } = resolveWranglerConfig( + { + name: "demo", + main: "src/index.js", + compatibility_date: "2026-06-17", + compatibility_flags: ["nodejs_compat"], + route: "dev.example.com/*", + triggers: { crons: ["*/5 * * * *"] }, + env: { + staging: { + compatibility_flags: ["nodejs_als"], + }, }, }, - }, "staging", "wrangler.toml"); + "staging", + "wrangler.toml" + ); assert.equal(envName, "staging"); assert.equal(cfg.name, "demo"); @@ -1352,36 +1365,44 @@ test("resolveWranglerConfig: selected environment inherits supported top-level k }); test("resolveWranglerConfig: worker name stays as top-level name regardless of env", () => { - const { cfg } = resolveWranglerConfig({ - name: "demo", - main: "src/index.js", - env: { - staging: {}, + const { cfg } = resolveWranglerConfig( + { + name: "demo", + main: "src/index.js", + env: { + staging: {}, + }, }, - }, "staging", "wrangler.toml"); + "staging", + "wrangler.toml" + ); assert.equal(cfg.name, "demo"); }); test("resolveWranglerConfig: non-inheritable keys are env-scoped while inheritable keys carry through", () => { - const { cfg } = resolveWranglerConfig({ - name: "demo", - main: "src/index.js", - vars: { TOP: "1" }, - kv_namespaces: [{ binding: "KV", id: "top" }], - services: [{ binding: "AUTH", service: "auth" }], - queues: { producers: [{ binding: "Q", queue: "top-q" }] }, - assets: { directory: "./top-public" }, - route: "api.example/*", - workers_dev: false, - env: { - prod: { - vars: { ENV: "prod" }, - kv_namespaces: [{ binding: "KV", id: "prod" }], - queues: { consumers: [{ queue: "jobs" }] }, + const { cfg } = resolveWranglerConfig( + { + name: "demo", + main: "src/index.js", + vars: { TOP: "1" }, + kv_namespaces: [{ binding: "KV", id: "top" }], + services: [{ binding: "AUTH", service: "auth" }], + queues: { producers: [{ binding: "Q", queue: "top-q" }] }, + assets: { directory: "./top-public" }, + route: "api.example/*", + workers_dev: false, + env: { + prod: { + vars: { ENV: "prod" }, + kv_namespaces: [{ binding: "KV", id: "prod" }], + queues: { consumers: [{ queue: "jobs" }] }, + }, }, }, - }, "prod", "wrangler.jsonc"); + "prod", + "wrangler.jsonc" + ); assert.deepEqual(cfg.vars, { ENV: "prod" }); assert.deepEqual(cfg.kv_namespaces, [{ binding: "KV", id: "prod" }]); @@ -1393,87 +1414,114 @@ test("resolveWranglerConfig: non-inheritable keys are env-scoped while inheritab }); test("resolveWranglerConfig: selected environment can override inherited assets", () => { - const { cfg } = resolveWranglerConfig({ - name: "demo", - main: "src/index.js", - assets: { directory: "./top-public" }, - env: { - prod: { - assets: { directory: "./prod-public" }, + const { cfg } = resolveWranglerConfig( + { + name: "demo", + main: "src/index.js", + assets: { directory: "./top-public" }, + env: { + prod: { + assets: { directory: "./prod-public" }, + }, }, }, - }, "prod", "wrangler.jsonc"); + "prod", + "wrangler.jsonc" + ); assert.deepEqual(cfg.assets, { directory: "./prod-public" }); }); test("resolveWranglerConfig: selected environment can override inherited workers_dev", () => { - const { cfg } = resolveWranglerConfig({ - name: "demo", - main: "src/index.js", - workers_dev: true, - env: { - prod: { - workers_dev: false, + const { cfg } = resolveWranglerConfig( + { + name: "demo", + main: "src/index.js", + workers_dev: true, + env: { + prod: { + workers_dev: false, + }, }, }, - }, "prod", "wrangler.jsonc"); + "prod", + "wrangler.jsonc" + ); assert.equal(cfg.workers_dev, false); }); test("resolveWranglerConfig: selected environment can override durable object migrations", () => { - const { cfg } = resolveWranglerConfig({ - name: "demo", - main: "src/index.js", - migrations: [{ tag: "v1", new_classes: ["TopObject"] }], - env: { - prod: { - migrations: [{ tag: "v2", new_sqlite_classes: ["ProdObject"] }], + const { cfg } = resolveWranglerConfig( + { + name: "demo", + main: "src/index.js", + migrations: [{ tag: "v1", new_classes: ["TopObject"] }], + env: { + prod: { + migrations: [{ tag: "v2", new_sqlite_classes: ["ProdObject"] }], + }, }, }, - }, "prod", "wrangler.jsonc"); + "prod", + "wrangler.jsonc" + ); assert.deepEqual(cfg.migrations, [{ tag: "v2", new_sqlite_classes: ["ProdObject"] }]); }); test("resolveWranglerConfig: rejects unknown environment names", () => { assert.throws( - () => resolveWranglerConfig({ - name: "demo", - main: "src/index.js", - env: { staging: {} }, - }, "prod", "wrangler.toml"), + () => + resolveWranglerConfig( + { + name: "demo", + main: "src/index.js", + env: { staging: {} }, + }, + "prod", + "wrangler.toml" + ), /environment "prod" not found/ ); }); test("resolveWranglerConfig: rejects top-level-only keys inside an environment", () => { assert.throws( - () => resolveWranglerConfig({ - name: "demo", - main: "src/index.js", - env: { - staging: { - keep_vars: true, + () => + resolveWranglerConfig( + { + name: "demo", + main: "src/index.js", + env: { + staging: { + keep_vars: true, + }, + }, }, - }, - }, "staging", "wrangler.toml"), + "staging", + "wrangler.toml" + ), /env\.staging\.keep_vars is top-level only/ ); }); test("resolveWranglerConfig: rejects env-specific name overrides", () => { assert.throws( - () => resolveWranglerConfig({ - name: "demo", - main: "src/index.js", - env: { - staging: { - name: "foo", + () => + resolveWranglerConfig( + { + name: "demo", + main: "src/index.js", + env: { + staging: { + name: "foo", + }, + }, }, - }, - }, "staging", "wrangler.toml"), + "staging", + "wrangler.toml" + ), /env\.staging\.name is top-level only/ ); }); @@ -1498,14 +1546,16 @@ test("createWranglerBundleConfig projects WDL extensions without mutating source crons: ["*/5 * * * *"], schedules: [{ cron: "0 9 * * 1-5", timezone: "Asia/Shanghai" }], }, - services: [{ - binding: "AUTH", - service: "auth-worker", - entrypoint: "Auth", - ns: "shared", - props: { role: "caller" }, - remote: true, - }], + services: [ + { + binding: "AUTH", + service: "auth-worker", + entrypoint: "Auth", + ns: "shared", + props: { role: "caller" }, + remote: true, + }, + ], exports: [{ entrypoint: "Auth", allowed_callers: ["acme"] }], platform_bindings: [{ binding: "PAYMENT", platform: "STRIPE" }], env: { @@ -1532,66 +1582,87 @@ test("createWranglerBundleConfig projects WDL extensions without mutating source assert.deepEqual(projected.build, { command: "npm run build" }); assert.deepEqual(projected.vars, { MODE: "top" }); assert.deepEqual(projected.triggers, { crons: ["*/5 * * * *"] }); - assert.deepEqual(projected.services, [{ - binding: "AUTH", - service: "auth-worker", - entrypoint: "Auth", - props: { role: "caller" }, - remote: true, - }]); + assert.deepEqual(projected.services, [ + { + binding: "AUTH", + service: "auth-worker", + entrypoint: "Auth", + props: { role: "caller" }, + remote: true, + }, + ]); const projectedEnv = /** @type {Record>} */ (projected.env); assert.deepEqual(projectedEnv.staging.define, { BUILD_ENV: '"staging"' }); assert.deepEqual(projectedEnv.staging.triggers, { crons: ["0 * * * *"] }); - assert.deepEqual(projectedEnv.staging.services, [ - { binding: "API", service: "api-worker", remote: false }, - ]); + assert.deepEqual(projectedEnv.staging.services, [{ binding: "API", service: "api-worker", remote: false }]); assert.equal(projectedEnv.staging.exports, undefined); assert.equal(projectedEnv.staging.platform_bindings, undefined); }); test("validateUnsupportedWranglerConfig: workflows are supported at top-level and selected env", () => { - assert.doesNotThrow(() => validateUnsupportedWranglerConfig({ - name: "demo", - main: "src/index.js", - workflows: [{ binding: "WF" }], - env: { staging: { workflows: [{ binding: "WF" }] } }, - }, "staging", "wrangler.toml")); -}); + assert.doesNotThrow(() => + validateUnsupportedWranglerConfig( + { + name: "demo", + main: "src/index.js", + workflows: [{ binding: "WF" }], + env: { staging: { workflows: [{ binding: "WF" }] } }, + }, + "staging", + "wrangler.toml" + ) + ); +}); test("validateUnsupportedWranglerConfig: rejects unsupported top-level config even when env is selected", () => { assert.throws( - () => validateUnsupportedWranglerConfig({ - name: "demo", - main: "src/index.js", - analytics_engine_datasets: [{ binding: "AE" }], - env: { staging: {} }, - }, "staging", "wrangler.toml"), + () => + validateUnsupportedWranglerConfig( + { + name: "demo", + main: "src/index.js", + analytics_engine_datasets: [{ binding: "AE" }], + env: { staging: {} }, + }, + "staging", + "wrangler.toml" + ), /unsupported Wrangler field "analytics_engine_datasets"/ ); }); test("validateUnsupportedWranglerConfig: rejects unsupported config inside the selected environment", () => { assert.throws( - () => validateUnsupportedWranglerConfig({ - name: "demo", - main: "src/index.js", - env: { - staging: { - analytics_engine_datasets: [{ binding: "AE" }], + () => + validateUnsupportedWranglerConfig( + { + name: "demo", + main: "src/index.js", + env: { + staging: { + analytics_engine_datasets: [{ binding: "AE" }], + }, + }, }, - }, - }, "staging", "wrangler.toml"), + "staging", + "wrangler.toml" + ), /env\.staging uses unsupported Wrangler field "analytics_engine_datasets"/ ); }); test("validateUnsupportedWranglerConfig: top-level allowed_callers is rejected with the [[exports]] migration path", () => { assert.throws( - () => validateUnsupportedWranglerConfig({ - name: "demo", - main: "src/index.js", - allowed_callers: ["acme"], - }, null, "wrangler.toml"), + () => + validateUnsupportedWranglerConfig( + { + name: "demo", + main: "src/index.js", + allowed_callers: ["acme"], + }, + null, + "wrangler.toml" + ), /top-level allowed_callers — removed.*\[\[exports\]\]/ ); }); @@ -1599,11 +1670,16 @@ test("validateUnsupportedWranglerConfig: top-level allowed_callers is rejected w test("validateUnsupportedWranglerConfig: empty top-level allowed_callers is still rejected by presence", () => { for (const value of [[], null, false, ""]) { assert.throws( - () => validateUnsupportedWranglerConfig({ - name: "demo", - main: "src/index.js", - allowed_callers: value, - }, null, "wrangler.toml"), + () => + validateUnsupportedWranglerConfig( + { + name: "demo", + main: "src/index.js", + allowed_callers: value, + }, + null, + "wrangler.toml" + ), /top-level allowed_callers — removed/ ); } @@ -1611,11 +1687,16 @@ test("validateUnsupportedWranglerConfig: empty top-level allowed_callers is stil test("validateUnsupportedWranglerConfig: env-scoped allowed_callers is rejected too", () => { assert.throws( - () => validateUnsupportedWranglerConfig({ - name: "demo", - main: "src/index.js", - env: { staging: { allowed_callers: ["acme"] } }, - }, "staging", "wrangler.toml"), + () => + validateUnsupportedWranglerConfig( + { + name: "demo", + main: "src/index.js", + env: { staging: { allowed_callers: ["acme"] } }, + }, + "staging", + "wrangler.toml" + ), /env\.staging uses top-level allowed_callers — removed/ ); }); @@ -1623,11 +1704,16 @@ test("validateUnsupportedWranglerConfig: env-scoped allowed_callers is rejected test("validateUnsupportedWranglerConfig: empty env-scoped allowed_callers is still rejected by presence", () => { for (const value of [[], null, false, ""]) { assert.throws( - () => validateUnsupportedWranglerConfig({ - name: "demo", - main: "src/index.js", - env: { staging: { allowed_callers: value } }, - }, "staging", "wrangler.toml"), + () => + validateUnsupportedWranglerConfig( + { + name: "demo", + main: "src/index.js", + env: { staging: { allowed_callers: value } }, + }, + "staging", + "wrangler.toml" + ), /env\.staging uses top-level allowed_callers — removed/ ); } @@ -1646,12 +1732,7 @@ test("validateUnsupportedWranglerConfig rejects unmapped wrangler runtime/deploy "site", "unsafe_hello_world", ]); - const booleanShapeKeys = new Set([ - "first_party_worker", - "legacy_env", - "preview_urls", - "upload_source_maps", - ]); + const booleanShapeKeys = new Set(["first_party_worker", "legacy_env", "preview_urls", "upload_source_maps"]); for (const key of [ "addresses", "agent_memory", @@ -1689,21 +1770,31 @@ test("validateUnsupportedWranglerConfig rejects unmapped wrangler runtime/deploy "worker_loaders", ]) { assert.throws( - () => validateUnsupportedWranglerConfig({ - name: "demo", - main: "src/index.js", - [key]: unsupportedWranglerFixtureValue(key, objectShapeKeys, booleanShapeKeys), - }, null, "wrangler.toml"), + () => + validateUnsupportedWranglerConfig( + { + name: "demo", + main: "src/index.js", + [key]: unsupportedWranglerFixtureValue(key, objectShapeKeys, booleanShapeKeys), + }, + null, + "wrangler.toml" + ), new RegExp(`unsupported Wrangler field "${key}"`) ); } assert.throws( - () => validateUnsupportedWranglerConfig({ - name: "demo", - main: "src/index.js", - vectorize: [], - }, null, "wrangler.toml"), + () => + validateUnsupportedWranglerConfig( + { + name: "demo", + main: "src/index.js", + vectorize: [], + }, + null, + "wrangler.toml" + ), /unsupported Wrangler field "vectorize"/ ); @@ -1712,25 +1803,35 @@ test("validateUnsupportedWranglerConfig rejects unmapped wrangler runtime/deploy ["dependencies_instrumentation", null], ])) { assert.throws( - () => validateUnsupportedWranglerConfig({ - name: "demo", - main: "src/index.js", - [key]: value, - }, null, "wrangler.toml"), + () => + validateUnsupportedWranglerConfig( + { + name: "demo", + main: "src/index.js", + [key]: value, + }, + null, + "wrangler.toml" + ), new RegExp(`unsupported Wrangler field "${key}"`) ); } assert.throws( - () => validateUnsupportedWranglerConfig({ - name: "demo", - main: "src/index.js", - env: { - staging: { - preview_urls: false, + () => + validateUnsupportedWranglerConfig( + { + name: "demo", + main: "src/index.js", + env: { + staging: { + preview_urls: false, + }, + }, }, - }, - }, "staging", "wrangler.toml"), + "staging", + "wrangler.toml" + ), /env\.staging uses unsupported Wrangler field "preview_urls"/ ); @@ -1768,60 +1869,83 @@ function unsupportedWranglerFixtureValue(key, objectShapeKeys, booleanShapeKeys) test("validateUnsupportedWranglerConfig rejects module-binding and container sections", () => { assert.throws( - () => validateUnsupportedWranglerConfig( - { name: "demo", main: "src/index.js", wasm_modules: { MOD: "./m.wasm" } }, - null, - "wrangler.toml" - ), + () => + validateUnsupportedWranglerConfig( + { name: "demo", main: "src/index.js", wasm_modules: { MOD: "./m.wasm" } }, + null, + "wrangler.toml" + ), /unsupported Wrangler field "wasm_modules"/ ); assert.throws( - () => validateUnsupportedWranglerConfig( - { name: "demo", main: "src/index.js", containers: [{ class_name: "C" }] }, - null, - "wrangler.toml" - ), + () => + validateUnsupportedWranglerConfig( + { name: "demo", main: "src/index.js", containers: [{ class_name: "C" }] }, + null, + "wrangler.toml" + ), /unsupported Wrangler field "containers"/ ); }); test("parseWorkflowsFromCfg: parses local workflow declarations", () => { assert.deepEqual(parseWorkflowsFromCfg({}), []); - assert.deepEqual(parseWorkflowsFromCfg({ - workflows: [ - { name: "order-workflow", binding: "ORDER_WORKFLOW", class_name: "OrderWorkflow" }, - { name: "My_Workflow2", binding: "WF2", class_name: "MyWorkflow" }, - ], - }), [ - { name: "order-workflow", binding: "ORDER_WORKFLOW", className: "OrderWorkflow" }, - { name: "My_Workflow2", binding: "WF2", className: "MyWorkflow" }, - ]); + assert.deepEqual( + parseWorkflowsFromCfg({ + workflows: [ + { name: "order-workflow", binding: "ORDER_WORKFLOW", class_name: "OrderWorkflow" }, + { name: "My_Workflow2", binding: "WF2", class_name: "MyWorkflow" }, + ], + }), + [ + { name: "order-workflow", binding: "ORDER_WORKFLOW", className: "OrderWorkflow" }, + { name: "My_Workflow2", binding: "WF2", className: "MyWorkflow" }, + ] + ); }); test("parseWorkflowsFromCfg: rejects invalid names and unsupported script_name", () => { assert.throws(() => parseWorkflowsFromCfg({ workflows: {} }), /must be an array/); assert.throws( - () => parseWorkflowsFromCfg({ workflows: [{ binding: "WF", class_name: "Flow", script_name: "other" }] }), + () => + parseWorkflowsFromCfg({ + workflows: [{ binding: "WF", class_name: "Flow", script_name: "other" }], + }), /name must match/ ); assert.throws( - () => parseWorkflowsFromCfg({ workflows: [{ name: "bad:name", binding: "WF", class_name: "Flow" }] }), + () => + parseWorkflowsFromCfg({ + workflows: [{ name: "bad:name", binding: "WF", class_name: "Flow" }], + }), /name must match/ ); assert.throws( - () => parseWorkflowsFromCfg({ workflows: [{ name: "constructor", binding: "WF", class_name: "Flow" }] }), + () => + parseWorkflowsFromCfg({ + workflows: [{ name: "constructor", binding: "WF", class_name: "Flow" }], + }), /reserved Object\.prototype key/ ); assert.throws( - () => parseWorkflowsFromCfg({ workflows: [{ name: "flow", binding: "bad-binding", class_name: "Flow" }] }), + () => + parseWorkflowsFromCfg({ + workflows: [{ name: "flow", binding: "bad-binding", class_name: "Flow" }], + }), /binding must match/ ); assert.throws( - () => parseWorkflowsFromCfg({ workflows: [{ name: "flow", binding: "__WDL_WORKFLOWS_BACKEND__", class_name: "Flow" }] }), + () => + parseWorkflowsFromCfg({ + workflows: [{ name: "flow", binding: "__WDL_WORKFLOWS_BACKEND__", class_name: "Flow" }], + }), /runtime-internal bindings/ ); assert.throws( - () => parseWorkflowsFromCfg({ workflows: [{ name: "flow", binding: "WF", class_name: "not-valid" }] }), + () => + parseWorkflowsFromCfg({ + workflows: [{ name: "flow", binding: "WF", class_name: "not-valid" }], + }), /class_name must be a valid JS class declaration name/ ); assert.throws( @@ -1829,11 +1953,17 @@ test("parseWorkflowsFromCfg: rejects invalid names and unsupported script_name", /class_name must be a valid JS class declaration name/ ); assert.throws( - () => parseWorkflowsFromCfg({ workflows: [{ name: "flow", binding: "WF", class_name: "__WdlReserved__" }] }), + () => + parseWorkflowsFromCfg({ + workflows: [{ name: "flow", binding: "WF", class_name: "__WdlReserved__" }], + }), /reserved for runtime-injected entrypoints/ ); assert.throws( - () => parseWorkflowsFromCfg({ workflows: [{ name: "flow", binding: "WF", class_name: "Flow", script_name: "other" }] }), + () => + parseWorkflowsFromCfg({ + workflows: [{ name: "flow", binding: "WF", class_name: "Flow", script_name: "other" }], + }), /script_name is not supported/ ); }); @@ -1959,12 +2089,13 @@ test("checkWranglerVersion escapes unparsable version diagnostics", () => { /** @type {unknown} */ (() => `bad\u009b31m\nFORGED\rBAD`) ); assertThrowsNoRawTerminalControls( - () => checkWranglerVersion({ - execFile, - cwd: "/tmp/project", - env: {}, - wrangler: { command: "wrangler", args: [] }, - }), + () => + checkWranglerVersion({ + execFile, + cwd: "/tmp/project", + env: {}, + wrangler: { command: "wrangler", args: [] }, + }), /could not parse version/, "wrangler version parse" ); @@ -1972,22 +2103,25 @@ test("checkWranglerVersion escapes unparsable version diagnostics", () => { test("checkWranglerVersion escapes failed version probe diagnostics", () => { const execFile = /** @type {typeof import("node:child_process").execFileSync} */ ( - /** @type {unknown} */ (() => { - const err = new Error(`boom${ESC}[2J\nFORGED\rBAD\u009b`); - Object.assign(err, { - stdout: `out${ESC}[2J\nline\rBAD`, - stderr: "err\u009b31m", - }); - throw err; - }) + /** @type {unknown} */ ( + () => { + const err = new Error(`boom${ESC}[2J\nFORGED\rBAD\u009b`); + Object.assign(err, { + stdout: `out${ESC}[2J\nline\rBAD`, + stderr: "err\u009b31m", + }); + throw err; + } + ) ); assert.throws( - () => checkWranglerVersion({ - execFile, - cwd: "/tmp/project", - env: {}, - wrangler: { command: "wrangler", args: [] }, - }), + () => + checkWranglerVersion({ + execFile, + cwd: "/tmp/project", + env: {}, + wrangler: { command: "wrangler", args: [] }, + }), (err) => { const message = /** @type {Error} */ (err).message; assertNoRawTerminalControls(message, "wrangler version failure"); @@ -2000,28 +2134,33 @@ test("checkWranglerVersion escapes failed version probe diagnostics", () => { test("checkWranglerVersion ENOENT hint mentions the npx opt-in", () => { const execFile = /** @type {typeof import("node:child_process").execFileSync} */ ( - /** @type {unknown} */ (() => { - const err = new Error("spawn wrangler ENOENT"); - Object.assign(err, { code: "ENOENT" }); - throw err; - }) + /** @type {unknown} */ ( + () => { + const err = new Error("spawn wrangler ENOENT"); + Object.assign(err, { code: "ENOENT" }); + throw err; + } + ) ); assert.throws( - () => checkWranglerVersion({ - execFile, - cwd: "/tmp/project", - env: {}, - wrangler: { command: "wrangler", args: [] }, - }), + () => + checkWranglerVersion({ + execFile, + cwd: "/tmp/project", + env: {}, + wrangler: { command: "wrangler", args: [] }, + }), /WDL_ALLOW_NPX_WRANGLER=1/ ); }); test("formatWranglerFailure escapes captured dry-run diagnostics", () => { - const message = formatWranglerFailure(Object.assign(new Error(`boom${ESC}[2J\nFORGED\rBAD\u009b`), { - stdout: `out${ESC}[2J\nline\rBAD`, - stderr: "err\u009b31m", - })); + const message = formatWranglerFailure( + Object.assign(new Error(`boom${ESC}[2J\nFORGED\rBAD\u009b`), { + stdout: `out${ESC}[2J\nline\rBAD`, + stderr: "err\u009b31m", + }) + ); assertNoRawTerminalControls(message, "wrangler build failure"); assert.match(message, /boom\\u001b\[2J\\nFORGED\\rBAD\\u009b/); assert.match(message, /out\\u001b\[2J\nline\\rBAD\nerr\\u009b31m/); @@ -2153,10 +2292,11 @@ test("resolveWranglerCommand on win32 runs the wrangler JS entry via node instea mkdirSync(shimDir, { recursive: true }); writeFileSync(path.join(shimDir, "wrangler.cmd"), ""); - assert.deepEqual( - resolveWranglerCommand({ absProject: dir, env: {}, packageDirs: [], platform: "win32" }), - { command: process.execPath, args: [script], source: "project" } - ); + assert.deepEqual(resolveWranglerCommand({ absProject: dir, env: {}, packageDirs: [], platform: "win32" }), { + command: process.execPath, + args: [script], + source: "project", + }); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -2172,7 +2312,12 @@ test("resolveWranglerCommand on win32 prefers the package script next to a PATH writeFileSync(script, ""); assert.deepEqual( - resolveWranglerCommand({ absProject: "/project", env: { PATH: dir }, packageDirs: [], platform: "win32" }), + resolveWranglerCommand({ + absProject: "/project", + env: { PATH: dir }, + packageDirs: [], + platform: "win32", + }), { command: process.execPath, args: [script], source: "path" } ); } finally { @@ -2187,7 +2332,13 @@ test("resolveWranglerCommand on win32 fails loudly when only a bare PATH .cmd sh // A bare "wrangler" fallback would resolve back to the unrunnable shim // (or ENOENT); the resolver must refuse with an actionable error instead. assert.throws( - () => resolveWranglerCommand({ absProject: "/project", env: { PATH: dir }, packageDirs: [], platform: "win32" }), + () => + resolveWranglerCommand({ + absProject: "/project", + env: { PATH: dir }, + packageDirs: [], + platform: "win32", + }), /No runnable wrangler found/ ); // The npx opt-in still provides a working escape hatch. @@ -2295,37 +2446,40 @@ test("runDeployCommand resolves cwd-relative project dir and WDL_NS fallback", a const fetchCalls = []; /** @type {string[]} */ const lines = []; - await runDeployCommand( - ["sub", "--control-url", "http://ctl.test"], - { - env: { - ADMIN_TOKEN: "tok", - CONTROL_CONNECT_HOST: "127.0.0.1:18080", - WDL_NS: "demo space", - CLOUDFLARE_API_TOKEN: "real-cf-token", - }, - cwd: parent, - stdout: (/** @type {string} */ line) => lines.push(/** @type {string} */ line), - stderr: () => {}, - execFile: (/** @type {string} */ cmd, /** @type {readonly string[]} */ args, /** @type {ExecFileOpts} */ opts) => { - execCalls.push({ cmd, args, opts }); - if (args.includes("--version")) return "wrangler 4.94.0"; - const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice("--outdir=".length); - mkdirSync(outDir, { recursive: true }); - writeFileSync( - path.join(outDir, "index.js"), - 'export default { fetch() { return new Response("ok"); } };' - ); - }, - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { - fetchCalls.push({ url, init }); - if (fetchCalls.length === 1) { - return response({ version: "v1", warnings: [] }); - } - return response({ platformDomain: "workers.example" }); - }, - } - ); + await runDeployCommand(["sub", "--control-url", "http://ctl.test"], { + env: { + ADMIN_TOKEN: "tok", + CONTROL_CONNECT_HOST: "127.0.0.1:18080", + WDL_NS: "demo space", + CLOUDFLARE_API_TOKEN: "real-cf-token", + }, + cwd: parent, + stdout: (/** @type {string} */ line) => lines.push(/** @type {string} */ line), + stderr: () => {}, + execFile: ( + /** @type {string} */ cmd, + /** @type {readonly string[]} */ args, + /** @type {ExecFileOpts} */ opts + ) => { + execCalls.push({ cmd, args, opts }); + if (args.includes("--version")) return "wrangler 4.94.0"; + const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice( + "--outdir=".length + ); + mkdirSync(outDir, { recursive: true }); + writeFileSync(path.join(outDir, "index.js"), 'export default { fetch() { return new Response("ok"); } };'); + }, + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { + fetchCalls.push({ url, init }); + if (fetchCalls.length === 1) { + return response({ version: "v1", warnings: [] }); + } + return response({ platformDomain: "workers.example" }); + }, + }); assert.equal(execCalls.length, 2); assertWranglerVersionProbe(execCalls[0]); @@ -2363,21 +2517,17 @@ test("runDeployCommand resolves cwd-relative project dir and WDL_NS fallback", a assert.deepEqual(manifest.workflows, [ { name: "order-workflow", binding: "ORDER_WORKFLOW", className: "OrderWorkflow" }, ]); - assert.deepEqual(manifest.crons, [ - { cron: "0 9 * * 1-5", timezone: "Asia/Shanghai" }, - ]); - assert.deepEqual(manifest.exports, [ - { entrypoint: "default", allowedCallers: ["acme"] }, - ]); - assert.deepEqual(manifest.platformBindings, [ - { binding: "PAYMENT", platform: "STRIPE" }, - ]); + assert.deepEqual(manifest.crons, [{ cron: "0 9 * * 1-5", timezone: "Asia/Shanghai" }]); + assert.deepEqual(manifest.exports, [{ entrypoint: "default", allowedCallers: ["acme"] }]); + assert.deepEqual(manifest.platformBindings, [{ binding: "PAYMENT", platform: "STRIPE" }]); assert.equal(manifest.compatibilityDate, "2026-06-17"); assert.equal(fetchCalls[1].url, "http://ctl.test/ns/demo%20space/worker/api/promote"); assert.equal(fetchCalls[1].init.method, "POST"); assert.equal(fetchCalls[1].init.env?.CONTROL_CONNECT_HOST, "127.0.0.1:18080"); - assert.deepEqual(JSON.parse(/** @type {string} */ (fetchCalls[1].init.body)), { version: "v1" }); + assert.deepEqual(JSON.parse(/** @type {string} */ (fetchCalls[1].init.body)), { + version: "v1", + }); assert.ok(lines.includes(" bundled by wrangler")); assert.ok(lines.includes("✓ demo space/api@v1 live")); } finally { @@ -2387,12 +2537,13 @@ test("runDeployCommand resolves cwd-relative project dir and WDL_NS fallback", a test("runDeployCommand rejects unexpected positional arguments", async () => { await assert.rejects( - () => runDeployCommand([".", "extra", "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - execFile: () => { - throw new Error("execFile should not be called"); - }, - }), + () => + runDeployCommand([".", "extra", "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + execFile: () => { + throw new Error("execFile should not be called"); + }, + }), /deploy received unexpected argument: extra/ ); }); @@ -2400,18 +2551,19 @@ test("runDeployCommand rejects unexpected positional arguments", async () => { test("runDeployCommand escapes terminal controls in unexpected positional errors", async () => { const bad = `bad${ESC}[2J\nFORGED\rBAD`; await assert.rejects( - () => runDeployCommand([".", bad, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - execFile: () => { - throw new Error("execFile should not be called"); - }, - }), + () => + runDeployCommand([".", bad, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + execFile: () => { + throw new Error("execFile should not be called"); + }, + }), (err) => { const message = /** @type {Error} */ (err).message; assertNoRawTerminalControls(message, "deploy positional errors"); assert.match(message, /bad\\u001b\[2J\\nFORGED\\rBAD/); return true; - }, + } ); }); @@ -2421,16 +2573,20 @@ test("runDeployCommand sanitizes wrangler.name via temp --config so mixed-case w mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); writeFileSync(path.join(dir, ".wrangler.wdl-tmp.json"), "user-owned"); - writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ - name: "Mixed-Case-Worker", - main: "src/index.js", - vars: { GREETING: "hi" }, - exports: [{ entrypoint: "default", allowed_callers: ["*"] }], - })); + writeFileSync( + path.join(dir, "wrangler.json"), + JSON.stringify({ + name: "Mixed-Case-Worker", + main: "src/index.js", + vars: { GREETING: "hi" }, + exports: [{ entrypoint: "default", allowed_callers: ["*"] }], + }) + ); writeFileSync(path.join(dir, "wrangler.toml"), 'name = "old"\nmain = "old.js"\n'); let tmpConfigSeen = null; - let tmpConfigContentAtExec = /** @type {{ name?: string, main?: string, vars?: unknown, exports?: unknown } | null} */ (null); + let tmpConfigContentAtExec = + /** @type {{ name?: string, main?: string, vars?: unknown, exports?: unknown } | null} */ (null); /** @type {RecordedFetch[]} */ const fetchCalls = []; /** @type {string[]} */ @@ -2446,11 +2602,16 @@ test("runDeployCommand sanitizes wrangler.name via temp --config so mixed-case w tmpConfigSeen = args[cfgIdx + 1]; assert.ok(existsSync(tmpConfigSeen), "temp config must exist when wrangler runs"); tmpConfigContentAtExec = JSON.parse(readFileSync(tmpConfigSeen, "utf8")); - const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice("--outdir=".length); + const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice( + "--outdir=".length + ); mkdirSync(outDir, { recursive: true }); writeFileSync(path.join(outDir, "index.js"), "export default {}"); }, - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { fetchCalls.push({ url, init }); if (fetchCalls.length === 1) return response({ version: "v1", warnings: [] }); return response({ platformDomain: "workers.example" }); @@ -2482,31 +2643,35 @@ test("runDeployCommand removes the sanitized temp config when wrangler exec fail try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ - name: "Mixed-Case-Worker", - main: "src/index.js", - })); + writeFileSync( + path.join(dir, "wrangler.json"), + JSON.stringify({ + name: "Mixed-Case-Worker", + main: "src/index.js", + }) + ); let tmpConfigSeen = null; await assert.rejects( - () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - stdout: () => {}, - stderr: () => {}, - execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { - if (args.includes("--version")) return "wrangler 4.94.0"; - const cfgIdx = args.indexOf("--config"); - tmpConfigSeen = args[cfgIdx + 1]; - assert.ok(existsSync(tmpConfigSeen), "temp config must exist when wrangler runs"); - throw Object.assign(new Error("wrangler boom"), { - status: 1, - stderr: "fake wrangler failure", - }); - }, - controlFetch: async () => { - throw new Error("control should not be hit when bundling fails"); - }, - }), + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { + if (args.includes("--version")) return "wrangler 4.94.0"; + const cfgIdx = args.indexOf("--config"); + tmpConfigSeen = args[cfgIdx + 1]; + assert.ok(existsSync(tmpConfigSeen), "temp config must exist when wrangler runs"); + throw Object.assign(new Error("wrangler boom"), { + status: 1, + stderr: "fake wrangler failure", + }); + }, + controlFetch: async () => { + throw new Error("control should not be hit when bundling fails"); + }, + }), /wrangler build failed/ ); @@ -2522,30 +2687,34 @@ test("runDeployCommand does not mask a wrangler failure when temp config cleanup try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ - name: "api", - main: "src/index.js", - })); + writeFileSync( + path.join(dir, "wrangler.json"), + JSON.stringify({ + name: "api", + main: "src/index.js", + }) + ); await assert.rejects( - () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - stdout: () => {}, - stderr: () => {}, - execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { - if (args.includes("--version")) return "wrangler 4.94.0"; - const cfgIdx = args.indexOf("--config"); - rmSync(/** @type {string} */ (args[cfgIdx + 1]), { force: true }); - mkdirSync(/** @type {string} */ (args[cfgIdx + 1])); - throw Object.assign(new Error("wrangler boom"), { - status: 1, - stderr: "fake wrangler failure", - }); - }, - controlFetch: async () => { - throw new Error("control should not be hit when bundling fails"); - }, - }), + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { + if (args.includes("--version")) return "wrangler 4.94.0"; + const cfgIdx = args.indexOf("--config"); + rmSync(/** @type {string} */ (args[cfgIdx + 1]), { force: true }); + mkdirSync(/** @type {string} */ (args[cfgIdx + 1])); + throw Object.assign(new Error("wrangler boom"), { + status: 1, + stderr: "fake wrangler failure", + }); + }, + controlFetch: async () => { + throw new Error("control should not be hit when bundling fails"); + }, + }), /wrangler build failed/ ); } finally { @@ -2558,11 +2727,14 @@ test("runDeployCommand preserves prototype-shaped binding keys for control valid try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ - name: "api", - main: "src/index.js", - kv_namespaces: [{ binding: "__proto__", id: "kv-id" }], - })); + writeFileSync( + path.join(dir, "wrangler.json"), + JSON.stringify({ + name: "api", + main: "src/index.js", + kv_namespaces: [{ binding: "__proto__", id: "kv-id" }], + }) + ); /** @type {RecordedFetch[]} */ const fetchCalls = []; @@ -2571,7 +2743,10 @@ test("runDeployCommand preserves prototype-shaped binding keys for control valid stdout: () => {}, stderr: () => {}, execFile: fakeWranglerExecFile, - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { fetchCalls.push({ url, init }); if (fetchCalls.length === 1) return response({ version: "v1", warnings: [] }); return response({ platformDomain: "workers.example" }); @@ -2591,21 +2766,25 @@ test("runDeployCommand rejects a non-table [assets] before bundling", async () = try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ - name: "api", - main: "src/index.js", - assets: "public", - })); + writeFileSync( + path.join(dir, "wrangler.json"), + JSON.stringify({ + name: "api", + main: "src/index.js", + assets: "public", + }) + ); let execCalled = false; await assert.rejects( - () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - execFile: () => { - execCalled = true; - throw new Error("execFile should not be called"); - }, - }), + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + execFile: () => { + execCalled = true; + throw new Error("execFile should not be called"); + }, + }), { message: "wrangler.json: [assets] must be a table" } ); assert.equal(execCalled, false); @@ -2619,21 +2798,25 @@ test("runDeployCommand rejects non-object vars before bundling", async () => { try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ - name: "api", - main: "src/index.js", - vars: [], - })); + writeFileSync( + path.join(dir, "wrangler.json"), + JSON.stringify({ + name: "api", + main: "src/index.js", + vars: [], + }) + ); let execCalled = false; await assert.rejects( - () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - execFile: () => { - execCalled = true; - throw new Error("execFile should not be called"); - }, - }), + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + execFile: () => { + execCalled = true; + throw new Error("execFile should not be called"); + }, + }), { message: "[vars] must be an object" } ); assert.equal(execCalled, false); @@ -2647,41 +2830,47 @@ test("runDeployCommand preserves the local control scheme and port in the Worker try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), 'export default { fetch() { return new Response("ok"); } };'); - writeFileSync(path.join(dir, "wrangler.toml"), ['name = "api"', 'main = "src/index.js"', 'compatibility_date = "2026-06-17"'].join("\n")); + writeFileSync( + path.join(dir, "wrangler.toml"), + ['name = "api"', 'main = "src/index.js"', 'compatibility_date = "2026-06-17"'].join("\n") + ); /** @type {string[]} */ const lines = []; let fetchCount = 0; - await runDeployCommand( - [dir, "--ns", "demo", "--control-url", "https://localhost:8443"], - { - env: { ADMIN_TOKEN: "tok", CONTROL_CONNECT_HOST: "127.0.0.1:18080" }, - stdout: (/** @type {string} */ line) => lines.push(/** @type {string} */ line), - stderr: () => {}, - execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { - if (args.includes("--version")) return "wrangler 4.94.0"; - const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice("--outdir=".length); - mkdirSync(outDir, { recursive: true }); - writeFileSync(path.join(outDir, "index.js"), 'export default { fetch() { return new Response("ok"); } };'); - }, - controlFetch: async () => { - fetchCount += 1; - return fetchCount === 1 - ? response({ version: "v1", warnings: [] }) - : response({ - platformDomain: "workers.local", - workersDev: true, - urls: { - platform: "https://demo.workers.local/api/", - routes: [], - }, - }); - }, - } - ); + await runDeployCommand([dir, "--ns", "demo", "--control-url", "https://localhost:8443"], { + env: { ADMIN_TOKEN: "tok", CONTROL_CONNECT_HOST: "127.0.0.1:18080" }, + stdout: (/** @type {string} */ line) => lines.push(/** @type {string} */ line), + stderr: () => {}, + execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { + if (args.includes("--version")) return "wrangler 4.94.0"; + const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice( + "--outdir=".length + ); + mkdirSync(outDir, { recursive: true }); + writeFileSync(path.join(outDir, "index.js"), 'export default { fetch() { return new Response("ok"); } };'); + }, + controlFetch: async () => { + fetchCount += 1; + return fetchCount === 1 + ? response({ version: "v1", warnings: [] }) + : response({ + platformDomain: "workers.local", + workersDev: true, + urls: { + platform: "https://demo.workers.local/api/", + routes: [], + }, + }); + }, + }); assert.ok(lines.includes(" https://demo.workers.local:8443/api/")); - assert.equal(lines.some((line) => line.includes("curl -H")), false, "no curl hint"); + assert.equal( + lines.some((line) => line.includes("curl -H")), + false, + "no curl hint" + ); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -2692,40 +2881,36 @@ test("runDeployCommand preserves canonical URL authorities and route-pattern suf try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "wrangler.toml"), [ - 'name = "api"', - 'main = "src/index.js"', - 'routes = ["api.example/apiv1/*", "api.example/mcp", "127.0.0.1/ip"]', - ].join("\n")); + writeFileSync( + path.join(dir, "wrangler.toml"), + [ + 'name = "api"', + 'main = "src/index.js"', + 'routes = ["api.example/apiv1/*", "api.example/mcp", "127.0.0.1/ip"]', + ].join("\n") + ); /** @type {string[]} */ const lines = []; let fetchCount = 0; - await runDeployCommand( - [dir, "--ns", "demo", "--control-url", "https://control.example"], - { - env: { ADMIN_TOKEN: "tok" }, - stdout: (/** @type {string} */ line) => lines.push(/** @type {string} */ line), - stderr: () => {}, - execFile: fakeWranglerExecFile, - controlFetch: async () => { - fetchCount += 1; - return fetchCount === 1 - ? response({ version: "v1", warnings: [], workersDev: true }) - : response({ - platformDomain: "workers.example", - workersDev: true, - urls: { - routes: [ - "https://api.example/apiv1/*", - "https://api.example/mcp", - "https://127.0.0.1/ip", - ], - }, - }); - }, - } - ); + await runDeployCommand([dir, "--ns", "demo", "--control-url", "https://control.example"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: (/** @type {string} */ line) => lines.push(/** @type {string} */ line), + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async () => { + fetchCount += 1; + return fetchCount === 1 + ? response({ version: "v1", warnings: [], workersDev: true }) + : response({ + platformDomain: "workers.example", + workersDev: true, + urls: { + routes: ["https://api.example/apiv1/*", "https://api.example/mcp", "https://127.0.0.1/ip"], + }, + }); + }, + }); assert.ok(lines.includes(" https://demo.workers.example/api/")); assert.ok(lines.includes(" https://api.example/apiv1/*")); @@ -2741,11 +2926,10 @@ test("runDeployCommand omits non-canonical URL hints without failing a promoted try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "wrangler.toml"), [ - 'name = "api"', - 'main = "src/index.js"', - 'route = "app.example/hello/*"', - ].join("\n")); + writeFileSync( + path.join(dir, "wrangler.toml"), + ['name = "api"', 'main = "src/index.js"', 'route = "app.example/hello/*"'].join("\n") + ); const invalidRouteUrls = [ "HTTPS://API.EXAMPLE:443/apiv1/*", @@ -2764,42 +2948,31 @@ test("runDeployCommand omits non-canonical URL hints without failing a promoted /** @type {string[]} */ const warnings = []; let fetchCount = 0; - await runDeployCommand( - [dir, "--ns", "demo", "--control-url", "https://control.example"], - { - env: { ADMIN_TOKEN: "tok" }, - stdout: (/** @type {string} */ line) => lines.push(line), - stderr: (/** @type {string} */ line) => warnings.push(line), - execFile: fakeWranglerExecFile, - controlFetch: async () => { - fetchCount += 1; - return fetchCount === 1 - ? response({ version: "v1", warnings: [], workersDev: true }) - : response({ - platformDomain: "workers.example", - workersDev: true, - urls: { - platform: invalidPlatformUrl, - routes: [ - "https://valid.example/ok/*", - ...invalidRouteUrls, - "https://valid.example/after/*", - ], - }, - }); - }, - } - ); + await runDeployCommand([dir, "--ns", "demo", "--control-url", "https://control.example"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: (/** @type {string} */ line) => lines.push(line), + stderr: (/** @type {string} */ line) => warnings.push(line), + execFile: fakeWranglerExecFile, + controlFetch: async () => { + fetchCount += 1; + return fetchCount === 1 + ? response({ version: "v1", warnings: [], workersDev: true }) + : response({ + platformDomain: "workers.example", + workersDev: true, + urls: { + platform: invalidPlatformUrl, + routes: ["https://valid.example/ok/*", ...invalidRouteUrls, "https://valid.example/after/*"], + }, + }); + }, + }); assert.equal(fetchCount, 2); assert.ok(lines.includes("✓ demo/api@v1 live")); assert.deepEqual( lines.filter((line) => line.startsWith(" http")), - [ - " https://demo.workers.example/api/", - " https://valid.example/ok/*", - " https://valid.example/after/*", - ] + [" https://demo.workers.example/api/", " https://valid.example/ok/*", " https://valid.example/after/*"] ); assert.equal(warnings.length, invalidRouteUrls.length + 1); for (const warning of warnings) { @@ -2816,12 +2989,10 @@ test("runDeployCommand sends workers_dev opt-out and prints only route-pattern U try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "wrangler.toml"), [ - 'name = "api"', - 'main = "src/index.js"', - 'workers_dev = false', - 'route = "app.example/a/../b/*"', - ].join("\n")); + writeFileSync( + path.join(dir, "wrangler.toml"), + ['name = "api"', 'main = "src/index.js"', "workers_dev = false", 'route = "app.example/a/../b/*"'].join("\n") + ); for (const { label, controlUrl, platformDomain } of [ { label: "remote", controlUrl: "https://control.example", platformDomain: "workers.example" }, @@ -2831,25 +3002,25 @@ test("runDeployCommand sends workers_dev opt-out and prints only route-pattern U const lines = []; /** @type {RecordedFetch[]} */ const fetchCalls = []; - await runDeployCommand( - [dir, "--ns", "demo", "--control-url", controlUrl], - { - env: { ADMIN_TOKEN: "tok" }, - stdout: (/** @type {string} */ line) => lines.push(/** @type {string} */ line), - stderr: () => {}, - execFile: fakeWranglerExecFile, - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { - fetchCalls.push({ url, init }); - return fetchCalls.length === 1 - ? response({ version: "v1", warnings: [], workersDev: false }) - : response({ - platformDomain, - workersDev: false, - urls: { routes: ["https://app.example/a/../b/*"] }, - }); - }, - } - ); + await runDeployCommand([dir, "--ns", "demo", "--control-url", controlUrl], { + env: { ADMIN_TOKEN: "tok" }, + stdout: (/** @type {string} */ line) => lines.push(/** @type {string} */ line), + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { + fetchCalls.push({ url, init }); + return fetchCalls.length === 1 + ? response({ version: "v1", warnings: [], workersDev: false }) + : response({ + platformDomain, + workersDev: false, + urls: { routes: ["https://app.example/a/../b/*"] }, + }); + }, + }); const manifest = JSON.parse(/** @type {string} */ (fetchCalls[0].init.body)); assert.deepEqual(manifest.routes, ["app.example/a/../b/*"]); @@ -2860,11 +3031,9 @@ test("runDeployCommand sends workers_dev opt-out and prints only route-pattern U false, `${label} platform URL` ); - assert.ok(lines.includes( - label === "local" - ? " http://app.example:8443/a/../b/*" - : " https://app.example/a/../b/*" - )); + assert.ok( + lines.includes(label === "local" ? " http://app.example:8443/a/../b/*" : " https://app.example/a/../b/*") + ); } } finally { rmSync(dir, { recursive: true, force: true }); @@ -2876,29 +3045,27 @@ test("runDeployCommand does not promote when control omits the workers_dev opt-o try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "wrangler.toml"), [ - 'name = "api"', - 'main = "src/index.js"', - 'workers_dev = false', - 'route = "app.example/*"', - ].join("\n")); + writeFileSync( + path.join(dir, "wrangler.toml"), + ['name = "api"', 'main = "src/index.js"', "workers_dev = false", 'route = "app.example/*"'].join("\n") + ); /** @type {RecordedFetch[]} */ const fetchCalls = []; await assert.rejects( - runDeployCommand( - [dir, "--ns", "demo", "--control-url", "https://control.example"], - { - env: { ADMIN_TOKEN: "tok" }, - stdout: () => {}, - stderr: () => {}, - execFile: fakeWranglerExecFile, - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { - fetchCalls.push({ url, init }); - return response({ version: "v1", warnings: [] }); - }, - } - ), + runDeployCommand([dir, "--ns", "demo", "--control-url", "https://control.example"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { + fetchCalls.push({ url, init }); + return response({ version: "v1", warnings: [] }); + }, + }), /control did not confirm workers_dev = false.*NOT promoted/ ); assert.equal(fetchCalls.length, 1); @@ -2913,38 +3080,36 @@ test("runDeployCommand fails when promote does not preserve the workers_dev opt- try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "wrangler.toml"), [ - 'name = "api"', - 'main = "src/index.js"', - 'workers_dev = false', - 'route = "app.example/*"', - ].join("\n")); + writeFileSync( + path.join(dir, "wrangler.toml"), + ['name = "api"', 'main = "src/index.js"', "workers_dev = false", 'route = "app.example/*"'].join("\n") + ); /** @type {RecordedFetch[]} */ const fetchCalls = []; await assert.rejects( - runDeployCommand( - [dir, "--ns", "demo", "--control-url", "https://control.example"], - { - env: { ADMIN_TOKEN: "tok" }, - stdout: () => {}, - stderr: () => {}, - execFile: fakeWranglerExecFile, - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { - fetchCalls.push({ url, init }); - return fetchCalls.length === 1 - ? response({ version: "v1", warnings: [], workersDev: false }) - : response({ - platformDomain: "workers.example", - workersDev: true, - urls: { - platform: "https://demo.workers.example/api/", - routes: ["https://app.example/*"], - }, - }); - }, - } - ), + runDeployCommand([dir, "--ns", "demo", "--control-url", "https://control.example"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { + fetchCalls.push({ url, init }); + return fetchCalls.length === 1 + ? response({ version: "v1", warnings: [], workersDev: false }) + : response({ + platformDomain: "workers.example", + workersDev: true, + urls: { + platform: "https://demo.workers.example/api/", + routes: ["https://app.example/*"], + }, + }); + }, + }), /control promoted the worker without preserving workers_dev = false/ ); assert.equal(fetchCalls.length, 2); @@ -2959,37 +3124,36 @@ test("runDeployCommand detects local control by hostname only", async () => { try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), 'export default { fetch() { return new Response("ok"); } };'); - writeFileSync(path.join(dir, "wrangler.toml"), [ - 'name = "api"', - 'main = "src/index.js"', - ].join("\n")); + writeFileSync(path.join(dir, "wrangler.toml"), ['name = "api"', 'main = "src/index.js"'].join("\n")); /** @type {string[]} */ const lines = []; let fetchCount = 0; - await runDeployCommand( - [dir, "--ns", "demo", "--control-url", "https://ctl.example/localhost"], - { - env: { ADMIN_TOKEN: "tok" }, - stdout: (/** @type {string} */ line) => lines.push(/** @type {string} */ line), - stderr: () => {}, - execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { - if (args.includes("--version")) return "wrangler 4.94.0"; - const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice("--outdir=".length); - mkdirSync(outDir, { recursive: true }); - writeFileSync(path.join(outDir, "index.js"), 'export default { fetch() { return new Response("ok"); } };'); - }, - controlFetch: async () => { - fetchCount += 1; - return fetchCount === 1 - ? response({ version: "v1", warnings: [] }) - : response({ platformDomain: "workers.example" }); - }, - } - ); + await runDeployCommand([dir, "--ns", "demo", "--control-url", "https://ctl.example/localhost"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: (/** @type {string} */ line) => lines.push(/** @type {string} */ line), + stderr: () => {}, + execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { + if (args.includes("--version")) return "wrangler 4.94.0"; + const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice( + "--outdir=".length + ); + mkdirSync(outDir, { recursive: true }); + writeFileSync(path.join(outDir, "index.js"), 'export default { fetch() { return new Response("ok"); } };'); + }, + controlFetch: async () => { + fetchCount += 1; + return fetchCount === 1 + ? response({ version: "v1", warnings: [] }) + : response({ platformDomain: "workers.example" }); + }, + }); assert.ok(lines.includes(" https://demo.workers.example/api/")); - assert.equal(lines.some((line) => line.includes("curl -H")), false); + assert.equal( + lines.some((line) => line.includes("curl -H")), + false + ); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -3005,32 +3169,35 @@ test("runDeployCommand uses the default port from a local control URL", async () /** @type {string[]} */ const lines = []; let fetchCount = 0; - await runDeployCommand( - [dir, "--ns", "demo", "--control-url", "http://admin.test"], - { - env: { ADMIN_TOKEN: "tok" }, - stdout: (/** @type {string} */ line) => lines.push(/** @type {string} */ line), - stderr: () => {}, - execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { - if (args.includes("--version")) return "wrangler 4.94.0"; - const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice("--outdir=".length); - mkdirSync(outDir, { recursive: true }); - writeFileSync(path.join(outDir, "index.js"), 'export default { fetch() { return new Response("ok"); } };'); - }, - controlFetch: async () => { - fetchCount += 1; - return fetchCount === 1 - ? response({ version: "v1", warnings: [] }) - : response({ platformDomain: "workers.local" }); - }, - } - ); + await runDeployCommand([dir, "--ns", "demo", "--control-url", "http://admin.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: (/** @type {string} */ line) => lines.push(/** @type {string} */ line), + stderr: () => {}, + execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { + if (args.includes("--version")) return "wrangler 4.94.0"; + const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice( + "--outdir=".length + ); + mkdirSync(outDir, { recursive: true }); + writeFileSync(path.join(outDir, "index.js"), 'export default { fetch() { return new Response("ok"); } };'); + }, + controlFetch: async () => { + fetchCount += 1; + return fetchCount === 1 + ? response({ version: "v1", warnings: [] }) + : response({ platformDomain: "workers.local" }); + }, + }); assert.ok( lines.includes(" http://demo.workers.local/api/"), "a .test control host without an explicit port uses the http default" ); - assert.equal(lines.some((line) => line.startsWith(" https://")), false, "no production https URL for a local deploy"); + assert.equal( + lines.some((line) => line.startsWith(" https://")), + false, + "no production https URL for a local deploy" + ); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -3041,23 +3208,27 @@ test("runDeployCommand rejects non-scalar vars before bundling", async () => { try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ - name: "api", - main: "src/index.js", - vars: { - FOO: { nested: true }, - }, - })); + writeFileSync( + path.join(dir, "wrangler.json"), + JSON.stringify({ + name: "api", + main: "src/index.js", + vars: { + FOO: { nested: true }, + }, + }) + ); let execCalled = false; await assert.rejects( - () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - execFile: () => { - execCalled = true; - throw new Error("execFile should not be called"); - }, - }), + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + execFile: () => { + execCalled = true; + throw new Error("execFile should not be called"); + }, + }), { message: "[vars] FOO: only string/number/boolean values are supported" } ); assert.equal(execCalled, false); @@ -3072,23 +3243,27 @@ test("runDeployCommand escapes terminal controls in [vars] diagnostics", async ( try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ - name: "api", - main: "src/index.js", - vars: { - [bad]: { nested: true }, - }, - })); + writeFileSync( + path.join(dir, "wrangler.json"), + JSON.stringify({ + name: "api", + main: "src/index.js", + vars: { + [bad]: { nested: true }, + }, + }) + ); let execCalled = false; await assert.rejects( - () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - execFile: () => { - execCalled = true; - throw new Error("execFile should not be called"); - }, - }), + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + execFile: () => { + execCalled = true; + throw new Error("execFile should not be called"); + }, + }), (err) => { const message = /** @type {Error} */ (err).message; assertNoRawTerminalControls(message, "[vars] diagnostics"); @@ -3107,23 +3282,27 @@ test("runDeployCommand rejects runtime-internal vars before bundling", async () try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ - name: "api", - main: "src/index.js", - vars: { - __WDL_RESERVED__: "x", - }, - })); + writeFileSync( + path.join(dir, "wrangler.json"), + JSON.stringify({ + name: "api", + main: "src/index.js", + vars: { + __WDL_RESERVED__: "x", + }, + }) + ); let execCalled = false; await assert.rejects( - () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - execFile: () => { - execCalled = true; - throw new Error("execFile should not be called"); - }, - }), + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + execFile: () => { + execCalled = true; + throw new Error("execFile should not be called"); + }, + }), { message: "[vars] __WDL_RESERVED__: name is reserved for runtime-internal bindings" } ); assert.equal(execCalled, false); @@ -3137,20 +3316,18 @@ test("runDeployCommand rejects Object.prototype-shaped vars before bundling", as try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync( - path.join(dir, "wrangler.json"), - '{"name":"api","main":"src/index.js","vars":{"__proto__":"x"}}' - ); + writeFileSync(path.join(dir, "wrangler.json"), '{"name":"api","main":"src/index.js","vars":{"__proto__":"x"}}'); let execCalled = false; await assert.rejects( - () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - execFile: () => { - execCalled = true; - throw new Error("execFile should not be called"); - }, - }), + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + execFile: () => { + execCalled = true; + throw new Error("execFile should not be called"); + }, + }), { message: "[vars] __proto__: name is a reserved Object.prototype key" } ); assert.equal(execCalled, false); @@ -3164,22 +3341,28 @@ test("runDeployCommand rejects vars that collide with bindings before bundling", try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ - name: "api", - main: "src/index.js", - kv_namespaces: [{ binding: "CACHE", id: "kv-cache" }], - vars: { CACHE: "shadow" }, - })); + writeFileSync( + path.join(dir, "wrangler.json"), + JSON.stringify({ + name: "api", + main: "src/index.js", + kv_namespaces: [{ binding: "CACHE", id: "kv-cache" }], + vars: { CACHE: "shadow" }, + }) + ); let execCalled = false; await assert.rejects( - () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - stdout: () => {}, - stderr: () => {}, - execFile: () => { execCalled = true; }, - controlFetch: async () => response({}), - }), + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: () => { + execCalled = true; + }, + controlFetch: async () => response({}), + }), /binding name collision: CACHE/ ); assert.equal(execCalled, false); @@ -3195,25 +3378,29 @@ test("runDeployCommand rejects vars that collide with the implicit assets bindin mkdirSync(path.join(dir, "public"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); writeFileSync(path.join(dir, "public", "index.html"), ""); - writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ - name: "api", - main: "src/index.js", - assets: { directory: "public" }, - vars: { ASSETS: "shadow" }, - })); + writeFileSync( + path.join(dir, "wrangler.json"), + JSON.stringify({ + name: "api", + main: "src/index.js", + assets: { directory: "public" }, + vars: { ASSETS: "shadow" }, + }) + ); let fetched = false; await assert.rejects( - () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - stdout: () => {}, - stderr: () => {}, - execFile: fakeWranglerExecFile, - controlFetch: async () => { - fetched = true; - return response({}); - }, - }), + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async () => { + fetched = true; + return response({}); + }, + }), /binding name collision: ASSETS/ ); assert.equal(fetched, false); @@ -3229,25 +3416,29 @@ test("runDeployCommand rejects explicit bindings that collide with the implicit mkdirSync(path.join(dir, "public"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); writeFileSync(path.join(dir, "public", "index.html"), ""); - writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ - name: "api", - main: "src/index.js", - assets: { directory: "public" }, - kv_namespaces: [{ binding: "ASSETS", id: "kv-assets" }], - })); + writeFileSync( + path.join(dir, "wrangler.json"), + JSON.stringify({ + name: "api", + main: "src/index.js", + assets: { directory: "public" }, + kv_namespaces: [{ binding: "ASSETS", id: "kv-assets" }], + }) + ); let fetched = false; await assert.rejects( - () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - stdout: () => {}, - stderr: () => {}, - execFile: fakeWranglerExecFile, - controlFetch: async () => { - fetched = true; - return response({}); - }, - }), + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async () => { + fetched = true; + return response({}); + }, + }), /binding name collision: ASSETS/ ); assert.equal(fetched, false); @@ -3262,11 +3453,14 @@ test("runDeployCommand treats an empty assets directory as an implicit ASSETS bi mkdirSync(path.join(dir, "src"), { recursive: true }); mkdirSync(path.join(dir, "public"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ - name: "api", - main: "src/index.js", - assets: { directory: "public" }, - })); + writeFileSync( + path.join(dir, "wrangler.json"), + JSON.stringify({ + name: "api", + main: "src/index.js", + assets: { directory: "public" }, + }) + ); /** @type {RecordedFetch[]} */ const fetchCalls = []; @@ -3275,7 +3469,10 @@ test("runDeployCommand treats an empty assets directory as an implicit ASSETS bi stdout: () => {}, stderr: () => {}, execFile: fakeWranglerExecFile, - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { fetchCalls.push({ url, init }); if (fetchCalls.length === 1) return response({ version: "v1", warnings: [] }); return response({ platformDomain: "wdl.sh" }); @@ -3295,25 +3492,29 @@ test("runDeployCommand rejects vars that collide with empty declared assets", as mkdirSync(path.join(dir, "src"), { recursive: true }); mkdirSync(path.join(dir, "public"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ - name: "api", - main: "src/index.js", - assets: { directory: "public" }, - vars: { ASSETS: "shadow" }, - })); + writeFileSync( + path.join(dir, "wrangler.json"), + JSON.stringify({ + name: "api", + main: "src/index.js", + assets: { directory: "public" }, + vars: { ASSETS: "shadow" }, + }) + ); let fetched = false; await assert.rejects( - () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - stdout: () => {}, - stderr: () => {}, - execFile: fakeWranglerExecFile, - controlFetch: async () => { - fetched = true; - return response({}); - }, - }), + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async () => { + fetched = true; + return response({}); + }, + }), /binding name collision: ASSETS/ ); assert.equal(fetched, false); @@ -3329,11 +3530,14 @@ test("packWranglerProject escapes progress output fields", async () => { const dir = path.join(root, projectDir); try { mkdirSync(dir); - writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ - name: "api", - main: "src/index.js", - env: { [badEnv]: {} }, - })); + writeFileSync( + path.join(dir, "wrangler.json"), + JSON.stringify({ + name: "api", + main: "src/index.js", + env: { [badEnv]: {} }, + }) + ); /** @type {string[]} */ const stdoutLines = []; @@ -3344,12 +3548,16 @@ test("packWranglerProject escapes progress output fields", async () => { stdout: (line = "") => { stdoutLines.push(line); }, - execFile: /** @type {typeof import("node:child_process").execFileSync} */ ((/** @type {string} */ _cmd, /** @type {readonly string[]} */ args = []) => { - if (args.includes("--version")) return "wrangler 4.94.0"; - const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice("--outdir=".length); - mkdirSync(outDir, { recursive: true }); - writeFileSync(path.join(outDir, "index.js"), "export default {}"); - }), + execFile: /** @type {typeof import("node:child_process").execFileSync} */ ( + (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args = []) => { + if (args.includes("--version")) return "wrangler 4.94.0"; + const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice( + "--outdir=".length + ); + mkdirSync(outDir, { recursive: true }); + writeFileSync(path.join(outDir, "index.js"), "export default {}"); + } + ), }); const progress = stdoutLines.find((line) => line.includes("bundling via wrangler")); @@ -3366,23 +3574,31 @@ test("packWranglerProject escapes missing entry diagnostics", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-pack-entry-escape-")); const badMain = `src/bad${ESC}[2J\nFORGED\rBAD.ts`; try { - writeFileSync(path.join(dir, "wrangler.json"), JSON.stringify({ - name: "api", - main: badMain, - })); + writeFileSync( + path.join(dir, "wrangler.json"), + JSON.stringify({ + name: "api", + main: badMain, + }) + ); await assert.rejects( - () => packWranglerProject({ - cwd: dir, - projectDir: ".", - stdout: () => {}, - execFile: /** @type {typeof import("node:child_process").execFileSync} */ ((/** @type {string} */ _cmd, /** @type {readonly string[]} */ args = []) => { - if (args.includes("--version")) return "wrangler 4.94.0"; - const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice("--outdir=".length); - mkdirSync(outDir, { recursive: true }); - writeFileSync(path.join(outDir, "other.js"), "export default {}"); + () => + packWranglerProject({ + cwd: dir, + projectDir: ".", + stdout: () => {}, + execFile: /** @type {typeof import("node:child_process").execFileSync} */ ( + (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args = []) => { + if (args.includes("--version")) return "wrangler 4.94.0"; + const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice( + "--outdir=".length + ); + mkdirSync(outDir, { recursive: true }); + writeFileSync(path.join(outDir, "other.js"), "export default {}"); + } + ), }), - }), (err) => { const message = /** @type {Error} */ (err).message; assertNoRawTerminalControls(message, "missing entry diagnostics"); @@ -3408,10 +3624,16 @@ test("runDeployCommand passes through wrangler output in verbose mode", async () env: { ADMIN_TOKEN: "tok", CONTROL_URL: "http://ctl.test" }, stdout: () => {}, stderr: () => {}, - execFile: (/** @type {string} */ cmd, /** @type {readonly string[]} */ args, /** @type {ExecFileOpts} */ opts) => { + execFile: ( + /** @type {string} */ cmd, + /** @type {readonly string[]} */ args, + /** @type {ExecFileOpts} */ opts + ) => { execCalls.push({ cmd, args, opts }); if (args.includes("--version")) return "wrangler 4.94.0"; - const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice("--outdir=".length); + const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice( + "--outdir=".length + ); mkdirSync(outDir, { recursive: true }); writeFileSync(path.join(outDir, "index.js"), "export default {}"); }, @@ -3439,16 +3661,21 @@ test("runDeployCommand rejects wrangler v3 before dry-run", async () => { /** @type {string[]} */ const lines = []; await assert.rejects( - () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - stdout: (/** @type {string} */ line) => lines.push(/** @type {string} */ line), - stderr: () => {}, - execFile: (/** @type {string} */ cmd, /** @type {readonly string[]} */ args, /** @type {ExecFileOpts} */ opts) => { - execCalls.push({ cmd, args, opts }); - return "wrangler 3.114.0"; - }, - controlFetch: async () => response({}), - }), + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: (/** @type {string} */ line) => lines.push(/** @type {string} */ line), + stderr: () => {}, + execFile: ( + /** @type {string} */ cmd, + /** @type {readonly string[]} */ args, + /** @type {ExecFileOpts} */ opts + ) => { + execCalls.push({ cmd, args, opts }); + return "wrangler 3.114.0"; + }, + controlFetch: async () => response({}), + }), /requires Wrangler v4 \(wrangler@\^4\); found v3/ ); assert.equal(execCalls.length, 1); @@ -3467,20 +3694,21 @@ test("runDeployCommand reports captured wrangler output only when dry-run fails" writeFileSync(path.join(dir, "wrangler.toml"), 'name = "api"\nmain = "src/index.js"\n'); await assert.rejects( - () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - stdout: () => {}, - stderr: () => {}, - execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { - if (args.includes("--version")) return "wrangler 4.94.0"; - throw Object.assign(new Error("Command failed"), { - status: 1, - stdout: "wrangler stdout", - stderr: "wrangler stderr", - }); - }, - controlFetch: async () => response({}), - }), + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { + if (args.includes("--version")) return "wrangler 4.94.0"; + throw Object.assign(new Error("Command failed"), { + status: 1, + stdout: "wrangler stdout", + stderr: "wrangler stderr", + }); + }, + controlFetch: async () => response({}), + }), /wrangler build failed \(1\)\nwrangler stdout\nwrangler stderr/ ); } finally { @@ -3544,22 +3772,29 @@ test("runDeployCommand renders deploy warnings from error responses", async () = /** @type {string[]} */ const warnings = []; await assert.rejects( - () => runDeployCommand([dir, "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok", WDL_NS: badNs }, - stdout: () => {}, - stderr: (/** @type {string} */ line) => warnings.push(/** @type {string} */ line), - execFile: fakeWranglerExecFile, - controlFetch: async () => response({ - error: "deploy_failed", - message: "missing caller secrets", - warnings: [{ - binding: "PAYMENT", - platform: "STRIPE", - missingCallerSecrets: ["API_KEY"], - internalTaskId: "task-secret", - }], - }, 400), - }), + () => + runDeployCommand([dir, "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok", WDL_NS: badNs }, + stdout: () => {}, + stderr: (/** @type {string} */ line) => warnings.push(/** @type {string} */ line), + execFile: fakeWranglerExecFile, + controlFetch: async () => + response( + { + error: "deploy_failed", + message: "missing caller secrets", + warnings: [ + { + binding: "PAYMENT", + platform: "STRIPE", + missingCallerSecrets: ["API_KEY"], + internalTaskId: "task-secret", + }, + ], + }, + 400 + ), + }), (err) => { const message = /** @type {Error} */ (err).message; assert.match(message, /deploy failed: 400 deploy_failed: missing caller secrets/); @@ -3587,17 +3822,22 @@ test("runDeployCommand explains deploy env-budget failures at the command layer" writeFileSync(path.join(dir, "wrangler.toml"), 'name = "api"\nmain = "src/index.js"\n'); await assert.rejects( - () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - stdout: () => {}, - stderr: () => {}, - execFile: fakeWranglerExecFile, - controlFetch: async () => response({ - error: "worker_env_too_large", - message: "env too large", - source_version: "v2", - }, 400), - }), + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async () => + response( + { + error: "worker_env_too_large", + message: "env too large", + source_version: "v2", + }, + 400 + ), + }), (err) => { const message = /** @type {Error} */ (err).message; assert.match(message, /worker_env_too_large/); @@ -3613,13 +3853,24 @@ test("runDeployCommand explains deploy env-budget failures at the command layer" test("runDeployCommand explains worker code size and Python module failures", async () => { for (const { error, status, expected } of [ - { error: "worker_code_too_large", status: 413, expected: /reduce generated Worker code size or split the worker/ }, - { error: "python_workers_unsupported", status: 400, expected: /Python Workers modules are not supported by WDL/ }, + { + error: "worker_code_too_large", + status: 413, + expected: /reduce generated Worker code size or split the worker/, + }, + { + error: "python_workers_unsupported", + status: 400, + expected: /Python Workers modules are not supported by WDL/, + }, ]) { - const err = await rejectDeployWithControlBody({ - error, - message: "control rejected deploy", - }, status); + const err = await rejectDeployWithControlBody( + { + error, + message: "control rejected deploy", + }, + status + ); assert.match(err.message, new RegExp(error)); assert.match(err.message, expected); } @@ -3633,16 +3884,21 @@ test("runDeployCommand explains secret-envelope deploy failures at the command l writeFileSync(path.join(dir, "wrangler.toml"), 'name = "api"\nmain = "src/index.js"\n'); await assert.rejects( - () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - stdout: () => {}, - stderr: () => {}, - execFile: fakeWranglerExecFile, - controlFetch: async () => response({ - error: "secret_encryption_unconfigured", - message: "provider missing", - }, 503), - }), + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async () => + response( + { + error: "secret_encryption_unconfigured", + message: "provider missing", + }, + 503 + ), + }), (err) => { const message = /** @type {Error} */ (err).message; assert.match(message, /secret_encryption_unconfigured/); @@ -3663,16 +3919,21 @@ test("runDeployCommand keeps worker_code_invalid hints generic", async () => { writeFileSync(path.join(dir, "wrangler.toml"), 'name = "api"\nmain = "src/index.js"\n'); await assert.rejects( - () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - stdout: () => {}, - stderr: () => {}, - execFile: fakeWranglerExecFile, - controlFetch: async () => response({ - error: "worker_code_invalid", - message: "invalid generated module graph", - }, 400), - }), + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async () => + response( + { + error: "worker_code_invalid", + message: "invalid generated module graph", + }, + 400 + ), + }), (err) => { const message = /** @type {Error} */ (err).message; assert.match(message, /worker_code_invalid/); @@ -3696,25 +3957,34 @@ test("runDeployCommand leaves reserved module-shape rejection to control", async /** @type {RecordedFetch[]} */ const fetchCalls = []; await assert.rejects( - () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - stdout: () => {}, - stderr: () => {}, - execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { - if (args.includes("--version")) return "wrangler 4.94.0"; - const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice("--outdir=".length); - mkdirSync(outDir, { recursive: true }); - writeFileSync(path.join(outDir, "index.js"), "export default {}"); - writeFileSync(path.join(outDir, "_wdl-wrapper.js"), "export default {}"); - }, - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { - fetchCalls.push({ url, init }); - return response({ - error: "worker_code_invalid", - message: "reserved injected module name", - }, 400); - }, - }), + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { + if (args.includes("--version")) return "wrangler 4.94.0"; + const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice( + "--outdir=".length + ); + mkdirSync(outDir, { recursive: true }); + writeFileSync(path.join(outDir, "index.js"), "export default {}"); + writeFileSync(path.join(outDir, "_wdl-wrapper.js"), "export default {}"); + }, + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { + fetchCalls.push({ url, init }); + return response( + { + error: "worker_code_invalid", + message: "reserved injected module name", + }, + 400 + ); + }, + }), (err) => { const message = /** @type {Error} */ (err).message; assert.match(message, /worker_code_invalid/); @@ -3737,28 +4007,34 @@ test("runDeployCommand explains control-rejected experimental compatibility flag try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "wrangler.toml"), [ - 'name = "api"', - 'main = "src/index.js"', - 'compatibility_flags = ["experimental"]', - ].join("\n")); + writeFileSync( + path.join(dir, "wrangler.toml"), + ['name = "api"', 'main = "src/index.js"', 'compatibility_flags = ["experimental"]'].join("\n") + ); /** @type {RecordedFetch[]} */ const fetchCalls = []; await assert.rejects( - () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - stdout: () => {}, - stderr: () => {}, - execFile: fakeWranglerExecFile, - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { - fetchCalls.push({ url, init }); - return response({ - error: "experimental_compat_flag_unsupported", - message: "unsupported workerd experimental compatibility flag", - }, 400); - }, - }), + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { + fetchCalls.push({ url, init }); + return response( + { + error: "experimental_compat_flag_unsupported", + message: "unsupported workerd experimental compatibility flag", + }, + 400 + ); + }, + }), (err) => { const message = /** @type {Error} */ (err).message; assert.match(message, /experimental_compat_flag_unsupported/); @@ -3780,28 +4056,34 @@ test("runDeployCommand explains control-rejected unsupported compatibility flags try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "wrangler.toml"), [ - 'name = "api"', - 'main = "src/index.js"', - 'compatibility_flags = ["legacy_error_serialization"]', - ].join("\n")); + writeFileSync( + path.join(dir, "wrangler.toml"), + ['name = "api"', 'main = "src/index.js"', 'compatibility_flags = ["legacy_error_serialization"]'].join("\n") + ); /** @type {RecordedFetch[]} */ const fetchCalls = []; await assert.rejects( - () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - stdout: () => {}, - stderr: () => {}, - execFile: fakeWranglerExecFile, - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { - fetchCalls.push({ url, init }); - return response({ - error: "compatibility_flag_unsupported", - message: "unsupported compatibility flag: legacy_error_serialization", - }, 400); - }, - }), + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { + fetchCalls.push({ url, init }); + return response( + { + error: "compatibility_flag_unsupported", + message: "unsupported compatibility flag: legacy_error_serialization", + }, + 400 + ); + }, + }), (err) => { const message = /** @type {Error} */ (err).message; assert.match(message, /compatibility_flag_unsupported/); @@ -3838,11 +4120,13 @@ test("runDeployCommand projects unknown deploy warnings before printing", async if (fetchCount === 1) { return response({ version: "v2", - warnings: [{ - code: "unsupported_option", - message: "ignored field", - internalTaskId: "task-secret", - }], + warnings: [ + { + code: "unsupported_option", + message: "ignored field", + internalTaskId: "task-secret", + }, + ], }); } return response({}); @@ -3875,24 +4159,23 @@ test("runDeployCommand explains a failed promote after upload", async () => { const stderrLines = []; let fetchCount = 0; await assert.rejects( - () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - stdout: () => {}, - stderr: (/** @type {string} */ line) => stderrLines.push(/** @type {string} */ line), - execFile: fakeWranglerExecFile, - controlFetch: async () => { - fetchCount += 1; - if (fetchCount === 1) return response({ version: "v9", warnings: [] }); - return response({ error: "promote_failed", message: "routing unavailable" }, 503); - }, - }), + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: (/** @type {string} */ line) => stderrLines.push(/** @type {string} */ line), + execFile: fakeWranglerExecFile, + controlFetch: async () => { + fetchCount += 1; + if (fetchCount === 1) return response({ version: "v9", warnings: [] }); + return response({ error: "promote_failed", message: "routing unavailable" }, 503); + }, + }), /promote failed: 503 promote_failed: routing unavailable/ ); assert.equal(fetchCount, 2); - assert.ok(stderrLines.some((line) => - /version v9 was uploaded and retained but NOT promoted/.test(line) - )); + assert.ok(stderrLines.some((line) => /version v9 was uploaded and retained but NOT promoted/.test(line))); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -3903,7 +4186,9 @@ test("runDeployCommand warns that DO named entrypoints must be declared exports" try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export class Room {}; export default {}"); - writeFileSync(path.join(dir, "wrangler.toml"), ` + writeFileSync( + path.join(dir, "wrangler.toml"), + ` name = "chat" main = "src/index.js" @@ -3914,7 +4199,8 @@ class_name = "Room" [[migrations]] tag = "v1" new_classes = ["Room"] -`); +` + ); /** @type {string[]} */ const warnings = []; @@ -3925,7 +4211,9 @@ new_classes = ["Room"] stderr: (/** @type {string} */ line) => warnings.push(/** @type {string} */ line), execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { if (args.includes("--version")) return "wrangler 4.94.0"; - const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice("--outdir=".length); + const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice( + "--outdir=".length + ); mkdirSync(outDir, { recursive: true }); writeFileSync(path.join(outDir, "index.js"), "export class Room {}; export default {}"); }, @@ -3936,7 +4224,10 @@ new_classes = ["Room"] }); assert.equal(warnings.length, 1); - assert.match(warnings[0], /Durable Object workers expose named WorkerEntrypoint classes only when listed in \[\[exports\]\]/); + assert.match( + warnings[0], + /Durable Object workers expose named WorkerEntrypoint classes only when listed in \[\[exports\]\]/ + ); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -3947,7 +4238,9 @@ test("runDeployCommand rejects workflow binding collisions before bundling", asy try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "wrangler.toml"), ` + writeFileSync( + path.join(dir, "wrangler.toml"), + ` name = "api" main = "src/index.js" @@ -3959,14 +4252,17 @@ id = "sessions" name = "flow" binding = "FLOW" class_name = "Flow" -`); +` + ); let execCalled = false; await assert.rejects( runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdout: () => {}, stderr: () => {}, - execFile: () => { execCalled = true; }, + execFile: () => { + execCalled = true; + }, controlFetch: async () => response({}), }), /binding name collision: FLOW/ @@ -3982,7 +4278,9 @@ test("runDeployCommand rejects platform binding collisions wrangler cannot see", try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "wrangler.toml"), ` + writeFileSync( + path.join(dir, "wrangler.toml"), + ` name = "api" main = "src/index.js" @@ -3992,14 +4290,17 @@ id = "sessions" [[platform_bindings]] binding = "SHARED" -`); +` + ); let execCalled = false; await assert.rejects( runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdout: () => {}, stderr: () => {}, - execFile: () => { execCalled = true; }, + execFile: () => { + execCalled = true; + }, controlFetch: async () => response({}), }), /binding name collision: SHARED/ @@ -4024,7 +4325,10 @@ test("runDeployCommand maps a .mts main to the bundled .js entry", async () => { stdout: () => {}, stderr: () => {}, execFile: fakeWranglerExecFile, - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { fetchCalls.push({ url, init }); if (fetchCalls.length === 1) return response({ version: "v1", warnings: [] }); return response({ platformDomain: "wdl.sh" }); @@ -4046,8 +4350,10 @@ test("runDeployCommand notes skipped asset entries on stderr", async () => { mkdirSync(path.join(dir, "public", "node_modules"), { recursive: true }); writeFileSync(path.join(dir, "public", "index.html"), ""); writeFileSync(path.join(dir, "public", "node_modules", "x.js"), "x"); - writeFileSync(path.join(dir, "wrangler.toml"), - 'name = "api"\nmain = "src/index.js"\n\n[assets]\ndirectory = "./public"\n'); + writeFileSync( + path.join(dir, "wrangler.toml"), + 'name = "api"\nmain = "src/index.js"\n\n[assets]\ndirectory = "./public"\n' + ); /** @type {string[]} */ const stderrLines = []; @@ -4058,7 +4364,10 @@ test("runDeployCommand notes skipped asset entries on stderr", async () => { stdout: () => {}, stderr: (/** @type {string} */ line) => stderrLines.push(/** @type {string} */ line), execFile: fakeWranglerExecFile, - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { fetchCalls.push({ url, init }); if (fetchCalls.length === 1) return response({ version: "v1", warnings: [] }); return response({ platformDomain: "wdl.sh" }); @@ -4111,17 +4420,22 @@ test("runDeployCommand rejects a KV binding name that isn't a JS identifier", as try { mkdirSync(path.join(dir, "src"), { recursive: true }); writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); - writeFileSync(path.join(dir, "wrangler.toml"), - 'name = "api"\nmain = "src/index.js"\n\n[[kv_namespaces]]\nbinding = "bad-kv"\nid = "x"\n'); + writeFileSync( + path.join(dir, "wrangler.toml"), + 'name = "api"\nmain = "src/index.js"\n\n[[kv_namespaces]]\nbinding = "bad-kv"\nid = "x"\n' + ); let execCalled = false; await assert.rejects( - () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - stdout: () => {}, - stderr: () => {}, - execFile: () => { execCalled = true; }, - controlFetch: async () => response({}), - }), + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: () => { + execCalled = true; + }, + controlFetch: async () => response({}), + }), /\[\[kv_namespaces\]\] bad-kv: binding must match/ ); assert.equal(execCalled, false); diff --git a/tests/unit/cli-init.test.js b/tests/unit/cli-init.test.js index fdce4b7..1f275bc 100644 --- a/tests/unit/cli-init.test.js +++ b/tests/unit/cli-init.test.js @@ -30,8 +30,18 @@ async function captureExit(fn) { let errOutput = ""; const originalExit = process.exit; const originalErr = process.stderr.write.bind(process.stderr); - process.exit = /** @type {typeof process.exit} */ ((code) => { exitCode = code; throw new Error("__test_exit__"); }); - process.stderr.write = /** @type {typeof process.stderr.write} */ ((chunk) => { errOutput += chunk; return true; }); + process.exit = /** @type {typeof process.exit} */ ( + (code) => { + exitCode = code; + throw new Error("__test_exit__"); + } + ); + process.stderr.write = /** @type {typeof process.stderr.write} */ ( + (chunk) => { + errOutput += chunk; + return true; + } + ); try { await fn(); } catch (err) { @@ -44,21 +54,22 @@ async function captureExit(fn) { } test("parseArgs accepts --ns and --worker in both forms", () => { - assert.deepEqual( - parseArgs(["demo", "--ns", "acme", "--worker", "feature"]), - { target: "demo", ns: "acme", worker: "feature", help: false }, - ); - assert.deepEqual( - parseArgs(["demo", "--ns=acme", "--worker=feature"]), - { target: "demo", ns: "acme", worker: "feature", help: false }, - ); + assert.deepEqual(parseArgs(["demo", "--ns", "acme", "--worker", "feature"]), { + target: "demo", + ns: "acme", + worker: "feature", + help: false, + }); + assert.deepEqual(parseArgs(["demo", "--ns=acme", "--worker=feature"]), { + target: "demo", + ns: "acme", + worker: "feature", + help: false, + }); }); test("parseArgs accepts positional help alias", () => { - assert.deepEqual( - parseArgs(["help"]), - { target: null, ns: null, worker: null, help: true }, - ); + assert.deepEqual(parseArgs(["help"]), { target: null, ns: null, worker: null, help: true }); }); test("parseArgs rejects unknown flags", () => { @@ -74,7 +85,7 @@ test("parseArgs escapes terminal controls in argv errors", () => { assertNoRawTerminalControls(message, "the error"); assert.match(message, /bad\\u001b\[2J\\nFORGED\\rBAD/); return true; - }, + } ); }); @@ -106,7 +117,7 @@ test("validateNs escapes terminal controls in rejected namespace values", () => assertNoRawTerminalControls(message, "--ns errors"); assert.match(message, /--ns "bad\\u001b\[2J\\nFORGED\\rBAD" is not a valid tenant namespace/); return true; - }, + } ); }); @@ -131,7 +142,7 @@ test("validateWorker escapes terminal controls in rejected worker values", () => assertNoRawTerminalControls(message, "--worker errors"); assert.match(message, /--worker "bad\\u001b\[2J\\nFORGED\\rBAD" must match/); return true; - }, + } ); }); @@ -274,9 +285,7 @@ test("init refuses to overwrite a non-empty target", async () => { mkdirSync(dir); writeFileSync(path.join(dir, "existing.txt"), ""); - const { exitCode, errOutput } = await captureExit(() => - main(["demo", "--ns", "acme", "--worker", "site"]) - ); + const { exitCode, errOutput } = await captureExit(() => main(["demo", "--ns", "acme", "--worker", "site"])); assert.equal(exitCode, 1); assert.match(errOutput, /is not empty/); }); diff --git a/tests/unit/cli-lifecycle.test.js b/tests/unit/cli-lifecycle.test.js index c377cc8..5b20754 100644 --- a/tests/unit/cli-lifecycle.test.js +++ b/tests/unit/cli-lifecycle.test.js @@ -15,15 +15,8 @@ import { runWorkflowsCommand } from "../../commands/workflows.js"; import { main as wdlMain } from "../../bin/wdl.js"; import { CliError, readJsonOrFail } from "../../lib/common.js"; import { formatWorkerDelete } from "../../lib/delete-format.js"; -import { - LONG_CONTROL_TIMEOUT_MS, - UNLIMITED_CONTROL_BODY_BYTES, -} from "../../lib/control-fetch.js"; -import { - formatInstanceList, - formatInstanceStatus, - formatWorkflowList, -} from "../../lib/workflows-format.js"; +import { LONG_CONTROL_TIMEOUT_MS, UNLIMITED_CONTROL_BODY_BYTES } from "../../lib/control-fetch.js"; +import { formatInstanceList, formatInstanceStatus, formatWorkflowList } from "../../lib/workflows-format.js"; import { ESC, assertNoRawTerminalControls, mockDeps, response } from "./helpers.js"; /** @typedef {import("./helpers.js").ControlCall} ControlCall */ @@ -92,49 +85,66 @@ test("readJsonOrFail compacts redacted D1 lifecycle errors", async () => { upstreamStatus: 503, }; - await assert.rejects( - () => readJsonOrFail(response(errBody, 503), "create d1 database"), - { - message: "create d1 database failed: 503 d1_database_initialize_failed: Internal error namespace=demo databaseId=d1_test upstreamCode=backend-unavailable upstreamCategory=internal upstreamRetryable=true upstreamStatus=503", - } - ); + await assert.rejects(() => readJsonOrFail(response(errBody, 503), "create d1 database"), { + message: + "create d1 database failed: 503 d1_database_initialize_failed: Internal error namespace=demo databaseId=d1_test upstreamCode=backend-unavailable upstreamCategory=internal upstreamRetryable=true upstreamStatus=503", + }); }); test("readJsonOrFail omits nested details from compact control errors", async () => { await assert.rejects( - () => readJsonOrFail(response({ - error: "d1_database_initialize_failed", - message: "Internal error", - upstreamCode: "backend-unavailable", - detail: { - message: "unredacted upstream detail", - internalReference: "ref-1", - }, - }, 503), "create d1 database"), + () => + readJsonOrFail( + response( + { + error: "d1_database_initialize_failed", + message: "Internal error", + upstreamCode: "backend-unavailable", + detail: { + message: "unredacted upstream detail", + internalReference: "ref-1", + }, + }, + 503 + ), + "create d1 database" + ), { - message: "create d1 database failed: 503 d1_database_initialize_failed: Internal error upstreamCode=backend-unavailable", + message: + "create d1 database failed: 503 d1_database_initialize_failed: Internal error upstreamCode=backend-unavailable", } ); }); test("readJsonOrFail keeps diagnostic blockers in compact errors", async () => { - const blockers = [{ - version: "v2", - referrers: [{ - callerNs: "foo", - callerWorker: "caller", - callerVersion: "v1", - binding: "API", - }], - }]; + const blockers = [ + { + version: "v2", + referrers: [ + { + callerNs: "foo", + callerWorker: "caller", + callerVersion: "v1", + binding: "API", + }, + ], + }, + ]; await assert.rejects( - () => readJsonOrFail(response({ - error: "version_referenced", - namespace: "foo", - name: "bar", - blockers, - }, 409), "delete worker"), + () => + readJsonOrFail( + response( + { + error: "version_referenced", + namespace: "foo", + name: "bar", + blockers, + }, + 409 + ), + "delete worker" + ), { message: `delete worker failed: 409 version_referenced namespace=foo name=bar blockers=${JSON.stringify(blockers)}`, } @@ -143,10 +153,17 @@ test("readJsonOrFail keeps diagnostic blockers in compact errors", async () => { test("readJsonOrFail formats control error-code plus message convention", async () => { await assert.rejects( - () => readJsonOrFail(response({ - error: "invalid_request", - message: "Body must be { value: string }", - }, 400), "put secret"), + () => + readJsonOrFail( + response( + { + error: "invalid_request", + message: "Body must be { value: string }", + }, + 400 + ), + "put secret" + ), { message: "put secret failed: 400 invalid_request: Body must be { value: string }", } @@ -154,29 +171,42 @@ test("readJsonOrFail formats control error-code plus message convention", async }); test("readJsonOrFail avoids duplicate context when structured error has no summary field", async () => { - await assert.rejects( - () => readJsonOrFail(response({ host: "demo.workers.example", slot: "/" }, 409), "promote"), - { message: 'promote failed: 409 {"host":"demo.workers.example","slot":"/"}' } - ); + await assert.rejects(() => readJsonOrFail(response({ host: "demo.workers.example", slot: "/" }, 409), "promote"), { + message: 'promote failed: 409 {"host":"demo.workers.example","slot":"/"}', + }); }); test("readJsonOrFail quotes context values containing whitespace", async () => { await assert.rejects( - () => readJsonOrFail(response({ - error: "bad_trace", - traceId: "abc def ghi", - }, 400), "deploy"), + () => + readJsonOrFail( + response( + { + error: "bad_trace", + traceId: "abc def ghi", + }, + 400 + ), + "deploy" + ), { message: 'deploy failed: 400 bad_trace traceId="abc def ghi"' } ); }); test("readJsonOrFail escapes decoded terminal control bytes in structured errors", async () => { await assert.rejects( - () => readJsonOrFail(response({ - error: "bad\u001b[31m", - message: "line1\nline2", - traceId: "osc\u001b]0;pwn\u0007", - }, 400), "deploy"), + () => + readJsonOrFail( + response( + { + error: "bad\u001b[31m", + message: "line1\nline2", + traceId: "osc\u001b]0;pwn\u0007", + }, + 400 + ), + "deploy" + ), { message: "deploy failed: 400 bad\\u001b[31m: line1\\nline2 traceId=osc\\u001b]0;pwn\\u0007", } @@ -185,23 +215,31 @@ test("readJsonOrFail escapes decoded terminal control bytes in structured errors test("readJsonOrFail preserves non-json response bodies", async () => { await assert.rejects( - () => readJsonOrFail({ - status: 502, - ok: false, - text: async () => "bad gateway", - }, "deploy"), + () => + readJsonOrFail( + { + status: 502, + ok: false, + text: async () => "bad gateway", + }, + "deploy" + ), { message: "deploy failed: 502 bad gateway" } ); }); test("readJsonOrFail includes redirect locations in HTTP errors", async () => { await assert.rejects( - () => readJsonOrFail({ - status: 302, - ok: false, - headers: { location: "https://login.example/\u001b[31m" }, - text: async () => "", - }, "whoami"), + () => + readJsonOrFail( + { + status: 302, + ok: false, + headers: { location: "https://login.example/\u001b[31m" }, + text: async () => "", + }, + "whoami" + ), { message: "whoami failed: 302 location=https://login.example/\\u001b[31m" } ); }); @@ -215,12 +253,13 @@ test("readJsonOrFail wraps invalid JSON from successful responses", async () => test("nsUrl rejects dot path segments before calling control", async () => { await assert.rejects( - () => runSecretCommand(["list", "--ns", ".", "--scope", "ns", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - controlFetch: async () => { - throw new Error("controlFetch should not be called"); - }, - }), + () => + runSecretCommand(["list", "--ns", ".", "--scope", "ns", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }), /invalid URL path segment: "\."/ ); }); @@ -229,11 +268,18 @@ test("readJsonOrFail surfaces warnings arrays attached to error bodies", async ( const warnings = [{ code: "assets_cleanup_task_failed", message: "queue full" }]; await assert.rejects( - () => readJsonOrFail(response({ - error: "asset_upload_failed", - message: "Asset upload failed for logo.png", - warnings, - }, 502), "deploy"), + () => + readJsonOrFail( + response( + { + error: "asset_upload_failed", + message: "Asset upload failed for logo.png", + warnings, + }, + 502 + ), + "deploy" + ), { message: `deploy failed: 502 asset_upload_failed: Asset upload failed for logo.png warnings=${JSON.stringify(warnings)}`, } @@ -293,10 +339,10 @@ test("workers command lists namespace worker state", async () => { assert.equal(calls.length, 1); assert.equal(calls[0].url, "http://ctl.test/ns/demo/workers"); - assert.deepEqual(/** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ (calls[0].init).headers, { "x-admin-token": "tok" }); - assert.deepEqual(lines, [ - "api\tactive=v2\tversions=v1,v2\tsecrets=yes\tworkflow-defs=yes", - ]); + assert.deepEqual(/** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ (calls[0].init).headers, { + "x-admin-token": "tok", + }); + assert.deepEqual(lines, ["api\tactive=v2\tversions=v1,v2\tsecrets=yes\tworkflow-defs=yes"]); }); test("workers command does not double-slash paths when CONTROL_URL has a trailing slash", async () => { @@ -308,7 +354,10 @@ test("workers command does not double-slash paths when CONTROL_URL has a trailin CONTROL_URL: "http://ctl.test/", }, stdout: () => {}, - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { calls.push({ url, init }); return response({ namespace: "demo", workers: [] }); }, @@ -319,11 +368,12 @@ test("workers command does not double-slash paths when CONTROL_URL has a trailin test("workers command rejects unexpected positional arguments", async () => { await assert.rejects( - () => runWorkersCommand(["demo"], { - env: { ADMIN_TOKEN: "tok" }, - stdout: () => {}, - controlFetch: async () => response({ namespace: "demo", workers: [] }), - }), + () => + runWorkersCommand(["demo"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + controlFetch: async () => response({ namespace: "demo", workers: [] }), + }), /Usage:/ ); }); @@ -335,14 +385,17 @@ test("wdl workers escapes control sequences from the control plane but keeps tab await runWorkersCommand(["--ns", "demo", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdout: (/** @type {string} */ line) => lines.push(line), - controlFetch: async () => response({ - workers: [{ - name: `worker-${hostile}`, - activeVersion: `v2-${hostile}`, - versions: [`v1-${hostile}`], - hasSecrets: false, - }], - }), + controlFetch: async () => + response({ + workers: [ + { + name: `worker-${hostile}`, + activeVersion: `v2-${hostile}`, + versions: [`v1-${hostile}`], + hasSecrets: false, + }, + ], + }), }); const out = lines.join("\n"); assertNoRawTerminalControls(out, "workers output"); @@ -393,7 +446,10 @@ test("tenant lifecycle commands default namespace from WDL_NS", async () => { await runWorkersCommand(["--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, stdout: () => {}, - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { workerCalls.push({ url, init }); return response({ namespace: "demo", workers: [] }); }, @@ -405,7 +461,10 @@ test("tenant lifecycle commands default namespace from WDL_NS", async () => { await runSecretCommand(["list", "--worker", "api", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, stdout: () => {}, - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { secretCalls.push({ url, init }); return response({ keys: [] }); }, @@ -417,7 +476,10 @@ test("tenant lifecycle commands default namespace from WDL_NS", async () => { await runDeleteCommand(["version", "api", "v1", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, stdout: () => {}, - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { deleteCalls.push({ url, init }); return response({ namespace: "demo", @@ -439,10 +501,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", "--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"); @@ -460,13 +519,16 @@ test("delete output does not expose internal cleanup task ids", async () => { assets: { cleanupTaskId: "s3cleanup:internal", queueHint: "sent", warnings: [] }, }); - await runDeleteCommand( - ["worker", "--ns", "demo", "api", "--yes", "--control-url", "http://ctl.test"], - deps - ); + await runDeleteCommand(["worker", "--ns", "demo", "api", "--yes", "--control-url", "http://ctl.test"], deps); - assert.equal(lines.some((line) => line.includes("s3cleanup:internal")), false); - assert.equal(lines.some((line) => line.includes("cleanup task")), false); + assert.equal( + lines.some((line) => line.includes("s3cleanup:internal")), + false + ); + assert.equal( + lines.some((line) => line.includes("cleanup task")), + false + ); }); test("delete output projects asset warnings before printing", async () => { @@ -477,24 +539,26 @@ test("delete output projects asset warnings before printing", async () => { versionsDeleted: ["v1"], deleted: true, assets: { - warnings: [{ - code: "asset_cleanup_skipped", - message: "cleanup skipped", - internalTaskId: "s3cleanup:internal", - }], + warnings: [ + { + code: "asset_cleanup_skipped", + message: "cleanup skipped", + internalTaskId: "s3cleanup:internal", + }, + ], }, }); - await runDeleteCommand( - ["worker", "--ns", "demo", "api", "--yes", "--control-url", "http://ctl.test"], - deps - ); + await runDeleteCommand(["worker", "--ns", "demo", "api", "--yes", "--control-url", "http://ctl.test"], deps); assert.equal( lines.some((line) => line.includes('{"code":"asset_cleanup_skipped","message":"cleanup skipped"}')), - true, + true + ); + assert.equal( + lines.some((line) => line.includes("s3cleanup:internal")), + false ); - assert.equal(lines.some((line) => line.includes("s3cleanup:internal")), false); }); test("delete worker supports dry-run query and raw json output", async () => { @@ -544,14 +608,12 @@ test("delete worker dry-run reports state presence without overstating deletion" " workflow definitions present", ] ); - assert.deepEqual( - formatWorkerDelete({ ...base, deleted: false, hasWorkflowDefs: false }), - ["DRY RUN demo/api wouldDelete=no active=- versions=-"] - ); - assert.deepEqual( - formatWorkerDelete({ ...base, deleted: false }), - ["DRY RUN demo/api wouldDelete=no active=- versions=-"] - ); + assert.deepEqual(formatWorkerDelete({ ...base, deleted: false, hasWorkflowDefs: false }), [ + "DRY RUN demo/api wouldDelete=no active=- versions=-", + ]); + assert.deepEqual(formatWorkerDelete({ ...base, deleted: false }), [ + "DRY RUN demo/api wouldDelete=no active=- versions=-", + ]); }); test("delete worker dry-run renders workflow blockers in human output", async () => { @@ -566,15 +628,19 @@ test("delete worker dry-run renders workflow blockers in human output", async () affectedHosts: [`host-${hostile}.example`], hasWorkerSecrets: true, hasWorkflowDefs: true, - blockers: [{ - version: `v1-${hostile}`, - referrers: [{ - callerNs: `ns-${hostile}`, - callerWorker: `worker-${hostile}`, - callerVersion: `version-${hostile}`, - binding: `binding-${hostile}`, - }], - }], + blockers: [ + { + version: `v1-${hostile}`, + referrers: [ + { + callerNs: `ns-${hostile}`, + callerWorker: `worker-${hostile}`, + callerVersion: `version-${hostile}`, + binding: `binding-${hostile}`, + }, + ], + }, + ], workflowBlocker: { error: `workflow_instances_active-${hostile}`, message: `demo/api has active workflow instances ${hostile}`, @@ -584,10 +650,7 @@ test("delete worker dry-run renders workflow blockers in human output", async () }; const { lines, deps } = mockDeps(body); - await runDeleteCommand( - ["worker", "--ns", "demo", "api", "--dry-run", "--control-url", "http://ctl.test"], - deps - ); + await runDeleteCommand(["worker", "--ns", "demo", "api", "--dry-run", "--control-url", "http://ctl.test"], deps); const joined = lines.join("\n"); assert.doesNotMatch(joined, new RegExp(ESC), "raw ESC must not reach delete dry-run output"); @@ -606,14 +669,18 @@ test("delete worker requires confirmation unless --yes or --dry-run is used", as /** @type {ControlCall[]} */ const calls = []; await assert.rejects( - () => runDeleteCommand(["worker", "--ns", "demo", "api", "--control-url", "http://ctl.test"], { - 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({}); - }, - }), + () => + runDeleteCommand(["worker", "--ns", "demo", "api", "--control-url", "http://ctl.test"], { + 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({}); + }, + }), /Refusing to delete worker "demo\/api" without interactive confirmation/ ); assert.equal(calls.length, 0); @@ -621,7 +688,10 @@ test("delete worker requires confirmation unless --yes or --dry-run is used", as await runDeleteCommand(["worker", "--ns", "demo", "api", "--yes", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdout: () => {}, - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { calls.push({ url, init }); return response({ namespace: "demo", @@ -647,7 +717,10 @@ test("delete worker proceeds after interactive confirmation", async () => { stdin, stderr: (/** @type {string} */ text) => prompts.push(text), stdout: () => {}, - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { calls.push({ url, init }); return response({ namespace: "demo", @@ -665,17 +738,19 @@ test("delete worker proceeds after interactive confirmation", async () => { test("delete command exposes only documented destructive subcommands", async () => { await assert.rejects( - () => runDeleteCommand(["ver", "--ns", "demo", "api", "v1"], { - env: { ADMIN_TOKEN: "tok" }, - controlFetch: async () => response({}), - }), + () => + runDeleteCommand(["ver", "--ns", "demo", "api", "v1"], { + env: { ADMIN_TOKEN: "tok" }, + controlFetch: async () => response({}), + }), /unknown subcommand: ver/ ); await assert.rejects( - () => runDeleteCommand(["rm", "--ns", "demo", "api"], { - env: { ADMIN_TOKEN: "tok" }, - controlFetch: async () => response({}), - }), + () => + runDeleteCommand(["rm", "--ns", "demo", "api"], { + env: { ADMIN_TOKEN: "tok" }, + controlFetch: async () => response({}), + }), /unknown subcommand: rm/ ); }); @@ -724,20 +799,14 @@ test("commands escape terminal controls in unexpected positional errors", async await assert.rejects( () => runDeleteCommand(["version", "--ns", "demo", "api", "v1", bad], deps), - assertEscapedBadArg, + assertEscapedBadArg ); await assert.rejects( () => runSecretCommand(["list", "--ns", "demo", "--scope", "ns", bad], deps), - assertEscapedBadArg, - ); - await assert.rejects( - () => runR2Command(["buckets", "list", bad, "--ns", "demo"], deps), - assertEscapedBadArg, - ); - await assert.rejects( - () => runWorkflowsCommand(["list", "--ns", "demo", bad], deps), - assertEscapedBadArg, + assertEscapedBadArg ); + await assert.rejects(() => runR2Command(["buckets", "list", bad, "--ns", "demo"], deps), assertEscapedBadArg); + await assert.rejects(() => runWorkflowsCommand(["list", "--ns", "demo", bad], deps), assertEscapedBadArg); }); test("secret list accepts flags before the subcommand", async () => { @@ -753,17 +822,17 @@ test("secret list uses encoded namespace and worker path segments", async () => const calls = []; /** @type {string[]} */ const lines = []; - await runSecretCommand( - ["list", "--ns", "demo space", "--worker", "api/slash", "--control-url", "http://ctl.test"], - { - env: { ADMIN_TOKEN: "tok" }, - stdout: (/** @type {string} */ line) => lines.push(line), - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { - calls.push({ url, init }); - return response({ keys: ["A", "B"] }); - }, - } - ); + await runSecretCommand(["list", "--ns", "demo space", "--worker", "api/slash", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: (/** @type {string} */ line) => lines.push(line), + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { + calls.push({ url, init }); + return response({ keys: ["A", "B"] }); + }, + }); assert.equal(calls.length, 1); assert.equal(calls[0].url, "http://ctl.test/ns/demo%20space/worker/api%2Fslash/secrets"); @@ -774,14 +843,11 @@ test("secret list uses encoded namespace and worker path segments", async () => test("secret list supports raw json output", async () => { /** @type {string[]} */ const lines = []; - await runSecretCommand( - ["list", "--json", "--ns", "demo", "--scope", "ns", "--control-url", "http://ctl.test"], - { - env: { ADMIN_TOKEN: "tok" }, - stdout: (/** @type {string} */ line) => lines.push(line), - controlFetch: async () => response({ namespace: "demo", keys: ["A", "B"] }), - } - ); + await runSecretCommand(["list", "--json", "--ns", "demo", "--scope", "ns", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: (/** @type {string} */ line) => lines.push(line), + controlFetch: async () => response({ namespace: "demo", keys: ["A", "B"] }), + }); assert.deepEqual(lines, [JSON.stringify({ namespace: "demo", keys: ["A", "B"] }, null, 2)]); }); @@ -789,14 +855,11 @@ test("secret list supports raw json output", async () => { test("secret list tolerates a response without a keys array", async () => { /** @type {string[]} */ const lines = []; - await runSecretCommand( - ["list", "--ns", "demo", "--scope", "ns", "--control-url", "http://ctl.test"], - { - env: { ADMIN_TOKEN: "tok" }, - stdout: (/** @type {string} */ line) => lines.push(line), - controlFetch: async () => response({ namespace: "demo" }), - } - ); + await runSecretCommand(["list", "--ns", "demo", "--scope", "ns", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: (/** @type {string} */ line) => lines.push(line), + controlFetch: async () => response({ namespace: "demo" }), + }); assert.deepEqual(lines, ["(no secrets)"]); }); @@ -805,18 +868,18 @@ test("secret put reads stdin, trims one newline, and encodes key", async () => { const calls = []; /** @type {string[]} */ const lines = []; - await runSecretCommand( - ["put", "--ns", "demo", "--scope", "ns", "KEY/ONE", "--control-url", "http://ctl.test"], - { - env: { ADMIN_TOKEN: "tok", CONTROL_CONNECT_HOST: "127.0.0.1:18080" }, - stdin: stdinFrom("secret-value\n"), - stdout: (/** @type {string} */ line) => lines.push(line), - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { - calls.push({ url, init }); - return response({ deleted: false }); - }, - } - ); + await runSecretCommand(["put", "--ns", "demo", "--scope", "ns", "KEY/ONE", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok", CONTROL_CONNECT_HOST: "127.0.0.1:18080" }, + stdin: stdinFrom("secret-value\n"), + stdout: (/** @type {string} */ line) => lines.push(line), + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { + calls.push({ url, init }); + return response({ deleted: false }); + }, + }); assert.equal(calls.length, 1); assert.equal(calls[0].url, "http://ctl.test/ns/demo/secrets/KEY%2FONE"); @@ -830,15 +893,12 @@ test("secret put escapes terminal controls from a raw keyArg in the status line" const esc = String.fromCharCode(27); /** @type {string[]} */ const lines = []; - await runSecretCommand( - ["put", "--ns", "demo", "--scope", "ns", `KEY${esc}[2J`, "--control-url", "http://ctl.test"], - { - env: { ADMIN_TOKEN: "tok" }, - stdin: stdinFrom("v\n"), - stdout: (/** @type {string} */ line) => lines.push(line), - controlFetch: async () => response({ deleted: false }), - } - ); + await runSecretCommand(["put", "--ns", "demo", "--scope", "ns", `KEY${esc}[2J`, "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdin: stdinFrom("v\n"), + stdout: (/** @type {string} */ line) => lines.push(line), + controlFetch: async () => response({ deleted: false }), + }); assert.equal(lines.length, 1); assert.doesNotMatch(lines[0], new RegExp(esc), "raw ESC from keyArg must not reach stdout"); }); @@ -849,19 +909,19 @@ test("secret put reads one tty line without waiting for EOF", async () => { /** @type {string[]} */ const prompts = []; const stdin = ttyStdinLine("typed-value\n"); - await runSecretCommand( - ["put", "--ns", "demo", "--scope", "ns", "KEY", "--control-url", "http://ctl.test"], - { - env: { ADMIN_TOKEN: "tok" }, - stdin, - stdout: () => {}, - stderr: (/** @type {string} */ text) => prompts.push(text), - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { - calls.push({ url, init }); - return response({ deleted: false }); - }, - } - ); + await runSecretCommand(["put", "--ns", "demo", "--scope", "ns", "KEY", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdin, + stdout: () => {}, + stderr: (/** @type {string} */ text) => prompts.push(text), + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { + calls.push({ url, init }); + return response({ deleted: false }); + }, + }); assert.equal(calls.length, 1); assert.equal(calls[0].init.body, JSON.stringify({ value: "typed-value" })); @@ -875,18 +935,18 @@ test("secret put reports worker version promotion", async () => { const calls = []; /** @type {string[]} */ const lines = []; - await runSecretCommand( - ["put", "--ns", "demo", "--worker", "api", "KEY", "--control-url", "http://ctl.test"], - { - env: { ADMIN_TOKEN: "tok" }, - stdin: stdinFrom("secret-value\n"), - stdout: (/** @type {string} */ line) => lines.push(line), - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { - calls.push({ url, init }); - return response({ previousVersion: "v1", version: "v2" }); - }, - } - ); + await runSecretCommand(["put", "--ns", "demo", "--worker", "api", "KEY", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdin: stdinFrom("secret-value\n"), + stdout: (/** @type {string} */ line) => lines.push(line), + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { + calls.push({ url, init }); + return response({ previousVersion: "v1", version: "v2" }); + }, + }); assert.equal(calls.length, 1); assert.equal(calls[0].url, "http://ctl.test/ns/demo/worker/api/secrets/KEY"); @@ -896,19 +956,21 @@ test("secret put reports worker version promotion", async () => { test("secret put explains env-budget failures as unwritten mutations", async () => { await assert.rejects( - () => runSecretCommand( - ["put", "--ns", "demo", "--scope", "ns", "KEY", "--control-url", "http://ctl.test"], - { + () => + runSecretCommand(["put", "--ns", "demo", "--scope", "ns", "KEY", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdin: stdinFrom("secret-value\n"), - controlFetch: async () => response({ - error: "worker_env_too_large", - message: "env too large", - source_version: "v2", - estimated_version: "v9007199254740991", - }, 400), - } - ), + controlFetch: async () => + response( + { + error: "worker_env_too_large", + message: "env too large", + source_version: "v2", + estimated_version: "v9007199254740991", + }, + 400 + ), + }), (err) => { const message = /** @type {Error} */ (err).message; assert.match(message, /worker_env_too_large/); @@ -923,16 +985,21 @@ test("secret put explains env-budget failures as unwritten mutations", async () test("secret mutation errors explain retry and operator-repair cases", async () => { for (const error of ["secret_mutation_contention", "namespace_secret_mutation_contention"]) { await assert.rejects( - () => runSecretCommand( - ["delete", "--ns", "demo", "--worker", "api", "KEY", "--yes", "--control-url", "http://ctl.test"], - { - env: { ADMIN_TOKEN: "tok" }, - controlFetch: async () => response({ - error, - message: "active version changed", - }, 503), - } - ), + () => + runSecretCommand( + ["delete", "--ns", "demo", "--worker", "api", "KEY", "--yes", "--control-url", "http://ctl.test"], + { + env: { ADMIN_TOKEN: "tok" }, + controlFetch: async () => + response( + { + error, + message: "active version changed", + }, + 503 + ), + } + ), /Retry after concurrent worker metadata updates settle/ ); } @@ -945,16 +1012,21 @@ test("secret mutation errors explain retry and operator-repair cases", async () "unknown_kid", ]) { await assert.rejects( - () => runSecretCommand( - ["delete", "--ns", "demo", "--scope", "ns", "KEY", "--yes", "--control-url", "http://ctl.test"], - { - env: { ADMIN_TOKEN: "tok" }, - controlFetch: async () => response({ - error, - message: "bad envelope", - }, 503), - } - ), + () => + runSecretCommand( + ["delete", "--ns", "demo", "--scope", "ns", "KEY", "--yes", "--control-url", "http://ctl.test"], + { + env: { ADMIN_TOKEN: "tok" }, + controlFetch: async () => + response( + { + error, + message: "bad envelope", + }, + 503 + ), + } + ), /Secret-envelope configuration or stored secret data needs operator repair/ ); } @@ -971,7 +1043,10 @@ test("secret put and delete support raw json output", async () => { env: { ADMIN_TOKEN: "tok" }, stdin: stdinFrom("secret-value\n"), stdout: (/** @type {string} */ line) => putLines.push(line), - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { calls.push({ url, init }); return response({ previousVersion: "v1", version: "v2" }); }, @@ -986,7 +1061,10 @@ test("secret put and delete support raw json output", async () => { { env: { ADMIN_TOKEN: "tok" }, stdout: (/** @type {string} */ line) => deleteLines.push(line), - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { calls.push({ url, init }); return response({ deleted: true, previousVersion: "v2", version: "v3" }); }, @@ -999,13 +1077,17 @@ test("secret list refuses ambiguous scope before calling control", async () => { /** @type {ControlCall[]} */ const calls = []; await assert.rejects( - () => runSecretCommand(["list", "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { - calls.push({ url, init }); - return response({}); - }, - }), + () => + runSecretCommand(["list", "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { + calls.push({ url, init }); + return response({}); + }, + }), /must specify either --worker \(worker-level\) or --scope ns \(ns-level\)/ ); @@ -1039,7 +1121,10 @@ test("secret delete calls worker endpoint and reports promoted bump", async () = { env: { ADMIN_TOKEN: "tok" }, stdout: (/** @type {string} */ line) => lines.push(line), - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { calls.push({ url, init }); return response({ deleted: true, previousVersion: "v1", version: "v2" }); }, @@ -1056,14 +1141,18 @@ test("secret delete requires confirmation unless --yes is used", async () => { /** @type {ControlCall[]} */ const calls = []; await assert.rejects( - () => runSecretCommand(["delete", "--ns", "demo", "--worker", "api", "KEY", "--control-url", "http://ctl.test"], { - 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({}); - }, - }), + () => + runSecretCommand(["delete", "--ns", "demo", "--worker", "api", "KEY", "--control-url", "http://ctl.test"], { + 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({}); + }, + }), /Refusing to delete secret "demo\/api\/KEY" without interactive confirmation/ ); assert.equal(calls.length, 0); @@ -1081,7 +1170,10 @@ test("secret delete proceeds after interactive confirmation", async () => { stdin, stderr: (/** @type {string} */ text) => prompts.push(text), stdout: () => {}, - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { calls.push({ url, init }); return response({ deleted: true }); }, @@ -1101,35 +1193,32 @@ test("secret delete ignores obsolete deferred-promote warnings", async () => { { env: { ADMIN_TOKEN: "tok" }, stdout: (/** @type {string} */ line) => lines.push(line), - controlFetch: async () => response({ - deleted: false, - warnings: [ - { kind: "promote_failed", reason: "active version changed", nextPickup: "next deploy" }, - ], - }), + controlFetch: async () => + response({ + deleted: false, + warnings: [{ kind: "promote_failed", reason: "active version changed", nextPickup: "next deploy" }], + }), } ); - assert.deepEqual(lines, [ - "(KEY was not set)", - ]); + assert.deepEqual(lines, ["(KEY was not set)"]); }); test("secret put rejects an unexpected VALUE positional before reading stdin", async () => { let read = false; await assert.rejects( - () => runSecretCommand( - ["put", "--ns", "demo", "--scope", "ns", "KEY", "VALUE", "--control-url", "http://ctl.test"], - { + () => + runSecretCommand(["put", "--ns", "demo", "--scope", "ns", "KEY", "VALUE", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdin: Object.assign(new EventEmitter(), { - setEncoding() { read = true; }, + setEncoding() { + read = true; + }, }), controlFetch: async () => { throw new Error("controlFetch should not be called"); }, - } - ), + }), /secret put received unexpected argument: VALUE/ ); assert.equal(read, false); @@ -1152,10 +1241,18 @@ test("r2 buckets and objects commands call encoded control endpoints", async () env: { ADMIN_TOKEN: "tok", CONTROL_URL: "http://ctl.test" }, stdout: (/** @type {string} */ line) => lines.push(line), stdoutStream, - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { calls.push({ url, init }); if (init.method === "DELETE") { - return response({ namespace: "demo space", bucket: "uploads", key: "dir/file.txt", status: "ok" }); + return response({ + namespace: "demo space", + bucket: "uploads", + key: "dir/file.txt", + status: "ok", + }); } if (init.method === "HEAD") { return { @@ -1186,7 +1283,11 @@ test("r2 buckets and objects commands call encoded control endpoints", async () }; } if (url.endsWith("/r2/buckets?limit=5")) { - return response({ namespace: "demo space", buckets: [{ name: "uploads" }], truncated: false }); + return response({ + namespace: "demo space", + buckets: [{ name: "uploads" }], + truncated: false, + }); } return response({ namespace: "demo space", @@ -1222,7 +1323,10 @@ test("r2 buckets and objects commands call encoded control endpoints", async () ]); assert.ok(lines.includes("R2 object demo space/uploads/dir/file.txt:")); assert.ok(lines.includes(" customMetadata.source: unit")); - assert.ok(lines.includes(" customMetadata.__proto__: pwned"), "a control-supplied __proto__ metadata key is not dropped"); + assert.ok( + lines.includes(" customMetadata.__proto__: pwned"), + "a control-supplied __proto__ metadata key is not dropped" + ); assert.equal(lines.at(-1), "OK demo space/uploads/dir/file.txt deleted"); }); @@ -1244,7 +1348,10 @@ test("r2 object head --json keeps a __proto__ metadata key and drops a bare x-am text: async () => "", }), }; - await runR2Command(["objects", "head", "--ns", "demo", "uploads", "k", "--json", "--control-url", "http://ctl.test"], deps); + await runR2Command( + ["objects", "head", "--ns", "demo", "uploads", "k", "--json", "--control-url", "http://ctl.test"], + deps + ); const meta = JSON.parse(/** @type {string} */ (lines.find((l) => l.trim().startsWith("{")))).customMetadata; // JSON.parse re-materializes __proto__ as an own data property, so read the // descriptor — `meta.__proto__` would go through the prototype accessor instead. @@ -1268,11 +1375,16 @@ test("r2 list --limit is validated locally", async () => { 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), + () => + 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), + () => + 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); @@ -1319,13 +1431,14 @@ test("r2 object get refuses raw output to an interactive terminal", async () => }, }); await assert.rejects( - () => runR2Command(["objects", "get", "--ns", "demo", "uploads", "file.txt"], { - env: { ADMIN_TOKEN: "tok", CONTROL_URL: "http://ctl.test" }, - stdoutStream, - controlFetch: async () => { - throw new Error("controlFetch should not be called"); - }, - }), + () => + runR2Command(["objects", "get", "--ns", "demo", "uploads", "file.txt"], { + env: { ADMIN_TOKEN: "tok", CONTROL_URL: "http://ctl.test" }, + stdoutStream, + controlFetch: async () => { + throw new Error("controlFetch should not be called"); + }, + }), /refuses to write raw object bytes to an interactive terminal/ ); }); @@ -1336,7 +1449,7 @@ test("r2 object get --out escapes a control-char path in the success line", asyn const esc = String.fromCharCode(27); const outPath = path.join(dir, `file${esc}[2J.bin`); /** @type {string[]} */ - const lines = []; + const lines = []; await runR2Command( ["objects", "get", "--ns", "demo", "uploads", "file.txt", "--out", outPath, "--control-url", "http://ctl.test"], { @@ -1387,7 +1500,10 @@ test("r2 object key preserves empty path segments but rejects dot segments", asy const deps = { env: { ADMIN_TOKEN: "tok" }, stdout: () => {}, - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { calls.push({ url, init }); return { status: 200, @@ -1406,11 +1522,12 @@ test("r2 object key preserves empty path segments but rejects dot segments", asy assert.equal(calls[2].url, "http://ctl.test/ns/demo/r2/buckets/bkt/objects/a/"); await assert.rejects( - () => runR2Command(["objects", "get", "bkt", "a/./b", "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - stdout: () => {}, - controlFetch: async () => response({}), - }), + () => + runR2Command(["objects", "get", "bkt", "a/./b", "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + controlFetch: async () => response({}), + }), /must not contain \. or \.\. path segments/ ); }); @@ -1439,30 +1556,37 @@ test("r2 commands reject unexpected positional arguments", async () => { test("r2 streaming commands format JSON control errors", async () => { const deps = { env: { ADMIN_TOKEN: "tok", CONTROL_URL: "http://ctl.test" }, - controlFetch: async () => response({ - error: "r2_object_not_found", - message: "R2 object not found", - }, 404), + controlFetch: async () => + response( + { + error: "r2_object_not_found", + message: "R2 object not found", + }, + 404 + ), }; - await assert.rejects( - () => runR2Command(["objects", "get", "--ns", "demo", "uploads", "missing.txt"], deps), - { message: "get R2 object failed: 404 r2_object_not_found: R2 object not found" } - ); + await assert.rejects(() => runR2Command(["objects", "get", "--ns", "demo", "uploads", "missing.txt"], deps), { + message: "get R2 object failed: 404 r2_object_not_found: R2 object not found", + }); }); test("r2 object delete requires confirmation unless --yes is used", async () => { /** @type {ControlCall[]} */ const calls = []; await assert.rejects( - () => runR2Command(["objects", "delete", "--ns", "demo", "uploads", "a.txt", "--control-url", "http://ctl.test"], { - 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({}); - }, - }), + () => + runR2Command(["objects", "delete", "--ns", "demo", "uploads", "a.txt", "--control-url", "http://ctl.test"], { + 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({}); + }, + }), /Refusing to delete R2 object "demo\/uploads\/a.txt" without interactive confirmation/ ); assert.equal(calls.length, 0); @@ -1476,7 +1600,10 @@ test("workflows commands call encoded control endpoints", async () => { const deps = { env: { ADMIN_TOKEN: "tok", CONTROL_URL: "http://ctl.test" }, stdout: (/** @type {string} */ line) => lines.push(line), - controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { calls.push({ url, init }); if (url.endsWith("/workflows")) { return response({ @@ -1517,8 +1644,14 @@ test("workflows commands call encoded control endpoints", async () => { }; await runWorkflowsCommand(["list", "--ns", "demo space"], deps); - await runWorkflowsCommand(["instances", "--ns", "demo space", "api", "orders", "--limit", "5", "--cursor", "0"], deps); - await runWorkflowsCommand(["status", "--ns", "demo space", "api", "orders", "status-instance", "--include-steps", "--step-limit", "10"], deps); + await runWorkflowsCommand( + ["instances", "--ns", "demo space", "api", "orders", "--limit", "5", "--cursor", "0"], + deps + ); + await runWorkflowsCommand( + ["status", "--ns", "demo space", "api", "orders", "status-instance", "--include-steps", "--step-limit", "10"], + deps + ); await runWorkflowsCommand(["pause", "--ns", "demo space", "api", "orders", "order/1"], deps); await runWorkflowsCommand(["resume", "--ns", "demo space", "api", "orders", "order/1"], deps); await runWorkflowsCommand(["restart", "--ns", "demo space", "api", "orders", "order/1", "--yes"], deps); @@ -1526,7 +1659,10 @@ test("workflows commands call encoded control endpoints", async () => { assert.equal(calls[0].url, "http://ctl.test/ns/demo%20space/workflows"); assert.equal(calls[1].url, "http://ctl.test/ns/demo%20space/workflows/api/orders/instances?limit=5&cursor=0"); - assert.equal(calls[2].url, "http://ctl.test/ns/demo%20space/workflows/api/orders/instances/status-instance?includeSteps=true&stepLimit=10"); + assert.equal( + calls[2].url, + "http://ctl.test/ns/demo%20space/workflows/api/orders/instances/status-instance?includeSteps=true&stepLimit=10" + ); assert.equal(calls[3].url, "http://ctl.test/ns/demo%20space/workflows/api/orders/instances/order%2F1/pause"); assert.equal(calls[3].init.method, "POST"); assert.equal(calls[4].url, "http://ctl.test/ns/demo%20space/workflows/api/orders/instances/order%2F1/resume"); @@ -1547,15 +1683,17 @@ test("workflow formatters escape control fields but preserve their own layout", const hostile = `${ESC}[2J\nFORGED\rBAD\tCOLUMN\u009b`; const lines = [ ...formatWorkflowList({ - workflows: [{ - worker: hostile, - name: hostile, - binding: hostile, - className: hostile, - activeVersion: hostile, - workflowKey: hostile, - retired: true, - }], + workflows: [ + { + worker: hostile, + name: hostile, + binding: hostile, + className: hostile, + activeVersion: hostile, + workflowKey: hostile, + retired: true, + }, + ], }), ...formatInstanceList({ instances: [{ id: hostile, status: hostile }], @@ -1575,11 +1713,7 @@ test("workflow formatters escape control fields but preserve their own layout", assertNoRawTerminalControls(out, "workflow formatter output"); assert.ok(out.includes("\\u001b[2J\\nFORGED\\rBAD\\tCOLUMN\\u009b")); - assert.equal( - out.split("\t").length - 1, - 7, - "only formatter-owned column separators may remain as raw tabs" - ); + assert.equal(out.split("\t").length - 1, 7, "only formatter-owned column separators may remain as raw tabs"); }); test("workflow lifecycle status lines escape control fields and preserve JSON", async () => { @@ -1587,16 +1721,10 @@ test("workflow lifecycle status lines escape control fields and preserve JSON", const body = { id: `id-${hostile}`, status: `status-${hostile}` }; const human = mockDeps(body); - await runWorkflowsCommand([ - "pause", - "api", - "orders", - "instance", - "--ns", - "demo", - "--control-url", - "http://ctl.test", - ], human.deps); + await runWorkflowsCommand( + ["pause", "api", "orders", "instance", "--ns", "demo", "--control-url", "http://ctl.test"], + human.deps + ); assert.equal(human.lines.length, 1); assertNoRawTerminalControls(human.lines[0], "workflow lifecycle status"); @@ -1604,17 +1732,10 @@ test("workflow lifecycle status lines escape control fields and preserve JSON", assert.equal(human.lines[0].includes("\t"), false, "status lines must not preserve raw tabs"); const json = mockDeps(body); - await runWorkflowsCommand([ - "pause", - "api", - "orders", - "instance", - "--ns", - "demo", - "--control-url", - "http://ctl.test", - "--json", - ], json.deps); + await runWorkflowsCommand( + ["pause", "api", "orders", "instance", "--ns", "demo", "--control-url", "http://ctl.test", "--json"], + json.deps + ); assert.deepEqual(JSON.parse(json.lines[0]), body); }); @@ -1675,11 +1796,16 @@ test("wdl dispatcher routes documented commands and rejects unknown commands", a assert.ok(/** @type {string} */ (seen.at(-1)).includes("wdl [args] [options]")); // Top-level help must list the common control flags too, matching command // help — --no-token-store was missing here once. - assert.ok(/** @type {string} */ (seen.at(-1)).includes("--no-token-store"), "top-level help lists --no-token-store"); + assert.ok( + /** @type {string} */ (seen.at(-1)).includes("--no-token-store"), + "top-level help lists --no-token-store" + ); // The command table is derived from each command's { name, summary }; assert // the metadata content renders (and the alias note) without pinning column spacing. assert.ok(/** @type {string} */ (seen.at(-1)).includes("Manage D1 databases, SQL execution, and migrations.")); - assert.ok(/** @type {string} */ (seen.at(-1)).includes("Manage namespace-level or worker-level secrets. (alias: secrets)")); + assert.ok( + /** @type {string} */ (seen.at(-1)).includes("Manage namespace-level or worker-level secrets. (alias: secrets)") + ); assert.ok(/** @type {string} */ (seen.at(-1)).includes("Inspect and delete R2 virtual bucket data.")); assert.ok(/** @type {string} */ (seen.at(-1)).includes("Live-tail worker console output and uncaught exceptions.")); // workflows is the widest name, so its summary sits one space after it. @@ -1761,10 +1887,19 @@ test("wdl dispatcher loads base dotenv before namespace section overlay", async // dispatch harmless without needing a control-plane mock. await withMockedExit(async () => { await assert.rejects( - () => wdlMain(["secret", "--ns", "demo"], { - env: {}, - loadEnv: /** @type {LoadEnvFn} */ (/** @type {unknown} */ ((/** @type {NodeJS.ProcessEnv | undefined} */ _env, /** @type {string | undefined} */ _path, /** @type {LoadEnvOptions} */ options) => calls.push(options))), - }), + () => + wdlMain(["secret", "--ns", "demo"], { + env: {}, + loadEnv: /** @type {LoadEnvFn} */ ( + /** @type {unknown} */ ( + ( + /** @type {NodeJS.ProcessEnv | undefined} */ _env, + /** @type {string | undefined} */ _path, + /** @type {LoadEnvOptions} */ options + ) => calls.push(options) + ) + ), + }), /exit:1/ ); }); @@ -1785,10 +1920,19 @@ test("wdl dispatcher overlays the LAST --ns occurrence, matching parseArgs", asy const calls = []; await withMockedExit(async () => { await assert.rejects( - () => wdlMain(["secret", "--ns", "first", "--ns=last"], { - env: {}, - loadEnv: /** @type {LoadEnvFn} */ (/** @type {unknown} */ ((/** @type {NodeJS.ProcessEnv | undefined} */ _env, /** @type {string | undefined} */ _path, /** @type {LoadEnvOptions} */ options) => calls.push(options))), - }), + () => + wdlMain(["secret", "--ns", "first", "--ns=last"], { + env: {}, + loadEnv: /** @type {LoadEnvFn} */ ( + /** @type {unknown} */ ( + ( + /** @type {NodeJS.ProcessEnv | undefined} */ _env, + /** @type {string | undefined} */ _path, + /** @type {LoadEnvOptions} */ options + ) => calls.push(options) + ) + ), + }), /exit:1/ ); }); @@ -1805,17 +1949,41 @@ test("wdl dispatcher skips dotenv when help is requested", async () => { try { await wdlMain(["workers", "--ns", "demo", "--help"], { env: {}, - loadEnv: /** @type {LoadEnvFn} */ (/** @type {unknown} */ ((/** @type {NodeJS.ProcessEnv | undefined} */ _env, /** @type {string | undefined} */ _path, /** @type {LoadEnvOptions} */ options) => calls.push(options))), + loadEnv: /** @type {LoadEnvFn} */ ( + /** @type {unknown} */ ( + ( + /** @type {NodeJS.ProcessEnv | undefined} */ _env, + /** @type {string | undefined} */ _path, + /** @type {LoadEnvOptions} */ options + ) => calls.push(options) + ) + ), }); // The positional alias form must skip autoload too — including with // flags present — so a broken .env cannot block `wdl help`. await wdlMain(["workers", "help"], { env: {}, - loadEnv: /** @type {LoadEnvFn} */ (/** @type {unknown} */ ((/** @type {NodeJS.ProcessEnv | undefined} */ _env, /** @type {string | undefined} */ _path, /** @type {LoadEnvOptions} */ options) => calls.push(options))), + loadEnv: /** @type {LoadEnvFn} */ ( + /** @type {unknown} */ ( + ( + /** @type {NodeJS.ProcessEnv | undefined} */ _env, + /** @type {string | undefined} */ _path, + /** @type {LoadEnvOptions} */ options + ) => calls.push(options) + ) + ), }); await wdlMain(["workers", "--ns", "demo", "help"], { env: {}, - loadEnv: /** @type {LoadEnvFn} */ (/** @type {unknown} */ ((/** @type {NodeJS.ProcessEnv | undefined} */ _env, /** @type {string | undefined} */ _path, /** @type {LoadEnvOptions} */ options) => calls.push(options))), + loadEnv: /** @type {LoadEnvFn} */ ( + /** @type {unknown} */ ( + ( + /** @type {NodeJS.ProcessEnv | undefined} */ _env, + /** @type {string | undefined} */ _path, + /** @type {LoadEnvOptions} */ options + ) => calls.push(options) + ) + ), }); } finally { console.log = oldLog; @@ -1860,15 +2028,24 @@ test("wdl dispatcher skips dotenv for top-level help and unknown commands", asyn try { await assert.rejects( - () => wdlMain(["help"], { loadEnv: /** @type {LoadEnvFn} */ (/** @type {unknown} */ (() => calls.push("help"))) }), + () => + wdlMain(["help"], { + loadEnv: /** @type {LoadEnvFn} */ (/** @type {unknown} */ (() => calls.push("help"))), + }), /exit:0/ ); await assert.rejects( - () => wdlMain(["bogus"], { loadEnv: /** @type {LoadEnvFn} */ (/** @type {unknown} */ (() => calls.push("bogus"))) }), + () => + wdlMain(["bogus"], { + loadEnv: /** @type {LoadEnvFn} */ (/** @type {unknown} */ (() => calls.push("bogus"))), + }), /exit:1/ ); await assert.rejects( - () => wdlMain([`bad${ESC}[2J\nFORGED\rBAD`], { loadEnv: /** @type {LoadEnvFn} */ (/** @type {unknown} */ (() => calls.push("bad"))) }), + () => + wdlMain([`bad${ESC}[2J\nFORGED\rBAD`], { + loadEnv: /** @type {LoadEnvFn} */ (/** @type {unknown} */ (() => calls.push("bad"))), + }), /exit:1/ ); assert.deepEqual(calls, []); @@ -1896,10 +2073,7 @@ test("wdl dispatcher prints parseArgs errors without a Node stack", async () => console.error = (msg) => errors.push(String(msg)); try { - await assert.rejects( - () => wdlMain(["tail", `--dsf${ESC}[2J\nFORGED\rBAD`], { loadEnv: null }), - /exit:1/ - ); + await assert.rejects(() => wdlMain(["tail", `--dsf${ESC}[2J\nFORGED\rBAD`], { loadEnv: null }), /exit:1/); } finally { process.exit = oldExit; console.error = oldError; @@ -1916,14 +2090,14 @@ test("SseParser dispatches event/id/data on blank line per SSE rules", () => { const events = []; const parser = new SseParser((event) => events.push(event)); - parser.push("event: worker_console\nid: 1700000000000-0\ndata: {\"a\":"); + parser.push('event: worker_console\nid: 1700000000000-0\ndata: {"a":'); parser.push("1}\n"); - parser.push("data: \"trailing\"\n\n"); + parser.push('data: "trailing"\n\n'); parser.push(":hb\n\n"); parser.push("data: hello\n\n"); assert.deepEqual(events, [ - { event: "worker_console", id: "1700000000000-0", data: "{\"a\":1}\n\"trailing\"" }, + { event: "worker_console", id: "1700000000000-0", data: '{"a":1}\n"trailing"' }, { event: "message", id: "1700000000000-0", data: "hello" }, ]); }); @@ -1968,25 +2142,27 @@ test("wdl tail rejects errors raised while flushing a trailing SSE event", async }; await assert.rejects( - () => runTailCommand( - ["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], - { + () => + runTailCommand(["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], { env: {}, - stdout: () => { throw new CliError("stdout stop"); }, + stdout: () => { + throw new CliError("stdout stop"); + }, stderr: () => {}, transport: fakeTransport, - } - ), + }), { message: "stdout stop" } ); }); test("wdl tail rejects --since for multi-worker sessions", async () => { await assert.rejects( - () => runTailCommand( - ["foo", "bar", "--since", "1-0", "--ns", "demo", "--token", "t"], - { env: {}, stdout: () => {}, stderr: () => {} } - ), + () => + runTailCommand(["foo", "bar", "--since", "1-0", "--ns", "demo", "--token", "t"], { + env: {}, + stdout: () => {}, + stderr: () => {}, + }), /single-worker/i ); }); @@ -1994,11 +2170,11 @@ test("wdl tail rejects --since for multi-worker sessions", async () => { test("wdl tail rejects invalid max-reconnects input", async () => { const bad = `forever${ESC}[2J\nFORGED\rBAD\u009b`; await assert.rejects( - () => runTailCommand( - ["foo", "--max-reconnects", bad, "--ns", "demo", "--token", "t", - "--control-url", "http://ctl.test"], - { env: {}, stdout: () => {}, stderr: () => {} } - ), + () => + runTailCommand( + ["foo", "--max-reconnects", bad, "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], + { env: {}, stdout: () => {}, stderr: () => {} } + ), (err) => { const message = /** @type {Error} */ (err).message; assert.match(message, /--max-reconnects must be a non-negative integer/); @@ -2011,10 +2187,12 @@ test("wdl tail rejects invalid max-reconnects input", async () => { test("wdl tail requires at least one positional worker", async () => { await assert.rejects( - () => runTailCommand( - ["--ns", "demo", "--token", "t"], - { env: {}, stdout: () => {}, stderr: () => {} } - ), + () => + runTailCommand(["--ns", "demo", "--token", "t"], { + env: {}, + stdout: () => {}, + stderr: () => {}, + }), /Specify one or more worker names/ ); }); @@ -2022,14 +2200,11 @@ test("wdl tail requires at least one positional worker", async () => { test("wdl tail help short-circuits before max-reconnects validation", async () => { /** @type {string[]} */ const stdoutLines = []; - await runTailCommand( - ["--help", "--max-reconnects", "forever"], - { - env: {}, - stdout: (/** @type {string} */ line) => stdoutLines.push(line), - stderr: () => {}, - } - ); + await runTailCommand(["--help", "--max-reconnects", "forever"], { + env: {}, + stdout: (/** @type {string} */ line) => stdoutLines.push(line), + stderr: () => {}, + }); assert.ok(stdoutLines.some((line) => /--max-reconnects/.test(line))); }); @@ -2045,9 +2220,14 @@ test("wdl tail escapes control error details", async () => { setImmediate(() => { const res = Object.assign(fakeHttpRes(), { statusCode: 500 }); cb(res); - res.emit("data", Buffer.from(JSON.stringify({ - message: "bad\u001b[31m\nline", - }))); + res.emit( + "data", + Buffer.from( + JSON.stringify({ + message: "bad\u001b[31m\nline", + }) + ) + ); res.emit("end"); }); return req; @@ -2055,15 +2235,13 @@ test("wdl tail escapes control error details", async () => { }; await assert.rejects( - () => runTailCommand( - ["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], - { + () => + runTailCommand(["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], { env: {}, stdout: () => {}, stderr: () => {}, transport: fakeTransport, - } - ), + }), { message: "bad\\u001b[31m\\nline" } ); }); @@ -2071,21 +2249,25 @@ test("wdl tail escapes control error details", async () => { /** @returns {import("../../lib/control-fetch.js").ControlClientRequest} */ function fakeHttpReq() { return /** @type {import("../../lib/control-fetch.js").ControlClientRequest} */ ( - /** @type {unknown} */ (Object.assign(new EventEmitter(), { - end() {}, - destroy() {}, - })) + /** @type {unknown} */ ( + Object.assign(new EventEmitter(), { + end() {}, + destroy() {}, + }) + ) ); } /** @returns {import("node:http").IncomingMessage} */ function fakeHttpRes() { return /** @type {import("node:http").IncomingMessage} */ ( - /** @type {unknown} */ (Object.assign(new EventEmitter(), { - statusCode: 200, - headers: {}, - setEncoding() {}, - })) + /** @type {unknown} */ ( + Object.assign(new EventEmitter(), { + statusCode: 200, + headers: {}, + setEncoding() {}, + }) + ) ); } @@ -2142,21 +2324,22 @@ test("wdl tail renders fetch, scheduled, and queue invocation events", async () }; await assert.rejects( - () => runTailCommand( - ["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], - { + () => + runTailCommand(["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], { env: {}, stdout: (/** @type {string} */ line) => stdoutLines.push(line), stderr: () => {}, transport: fakeTransport, - } - ), + }), { message: "test stop" } ); assert.match(stdoutLines[0], /scheduled start cron="\*\/5 \* \* \* \*" scheduled_time=123/); assert.match(stdoutLines[1], /queue finish name=jobs batch_size=3 outcome=ok duration_ms=7/); - assert.match(stdoutLines[2], /fetch finish method=GET path="\/foo\/api\/inspections" \(truncated\) status=204 outcome=ok duration_ms=4/); + assert.match( + stdoutLines[2], + /fetch finish method=GET path="\/foo\/api\/inspections" \(truncated\) status=204 outcome=ok duration_ms=4/ + ); assert.ok(!stdoutLines.some((line) => line.includes('{"event"'))); }); @@ -2189,8 +2372,10 @@ test("wdl tail escapes terminal control sequences in rendered events", async () stack: "Error: boom\n at fetch (\u001b[2Jworker.js:1)", ts: 2, }); - res.emit("data", `event: worker_console\ndata: ${consoleEvent}\n\n` + - `event: worker_exception\ndata: ${exceptionEvent}\n\n`); + res.emit( + "data", + `event: worker_console\ndata: ${consoleEvent}\n\n` + `event: worker_exception\ndata: ${exceptionEvent}\n\n` + ); } res.emit("error", new CliError("test stop")); }); @@ -2199,16 +2384,14 @@ test("wdl tail escapes terminal control sequences in rendered events", async () }; await assert.rejects( - () => runTailCommand( - ["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], - { + () => + runTailCommand(["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], { env: {}, stdout: (/** @type {string} */ line) => stdoutLines.push(line), stderr: () => {}, transport: fakeTransport, sleepFn: async () => {}, - } - ), + }), { message: "test stop" } ); @@ -2240,9 +2423,8 @@ test("wdl tail accepts bare CONTROL_URL hosts by defaulting to https", async () }; await assert.rejects( - () => runTailCommand( - ["kv-demo"], - { + () => + runTailCommand(["kv-demo"], { env: { ADMIN_TOKEN: "tok", CONTROL_URL: "ctl.uat.example", @@ -2251,14 +2433,16 @@ test("wdl tail accepts bare CONTROL_URL hosts by defaulting to https", async () stdout: () => {}, stderr: () => {}, transport: fakeTransport, - } - ), + }), { message: "test stop" } ); assert.equal(requestsSeen[0].host, "ctl.uat.example"); assert.equal(requestsSeen[0].port, 443); - assert.equal(/** @type {import("node:http").OutgoingHttpHeaders} */ (requestsSeen[0].headers).Host, "ctl.uat.example"); + assert.equal( + /** @type {import("node:http").OutgoingHttpHeaders} */ (requestsSeen[0].headers).Host, + "ctl.uat.example" + ); assert.equal(requestsSeen[0].path, "/ns/demo/logs/tail?worker=kv-demo"); }); @@ -2283,9 +2467,8 @@ test("wdl tail uses effective CONTROL_CONNECT_HOST for SSE sockets", async () => }; await assert.rejects( - () => runTailCommand( - ["kv-demo"], - { + () => + runTailCommand(["kv-demo"], { env: { ADMIN_TOKEN: "tok", CONTROL_URL: "http://admin.test:8080", @@ -2295,14 +2478,16 @@ test("wdl tail uses effective CONTROL_CONNECT_HOST for SSE sockets", async () => stdout: () => {}, stderr: () => {}, transport: fakeTransport, - } - ), + }), { message: "test stop" } ); assert.equal(requestsSeen[0].host, "127.0.0.1"); assert.equal(requestsSeen[0].port, 18080); - assert.equal(/** @type {import("node:http").OutgoingHttpHeaders} */ (requestsSeen[0].headers).Host, "admin.test:8080"); + assert.equal( + /** @type {import("node:http").OutgoingHttpHeaders} */ (requestsSeen[0].headers).Host, + "admin.test:8080" + ); assert.equal(requestsSeen[0].path, "/ns/demo/logs/tail?worker=kv-demo"); }); @@ -2320,9 +2505,8 @@ test("wdl tail rejects invalid auth headers before opening an SSE request", asyn }; await assert.rejects( - () => runTailCommand( - ["foo", "--ns", "demo", "--token", "tok\nnext", "--control-url", "http://ctl.test"], - { + () => + runTailCommand(["foo", "--ns", "demo", "--token", "tok\nnext", "--control-url", "http://ctl.test"], { env: {}, stdout: () => {}, stderr: () => {}, @@ -2330,10 +2514,9 @@ test("wdl tail rejects invalid auth headers before opening an SSE request", asyn sleepFn: async () => { throw new Error("tail should not enter the reconnect loop"); }, - } - ), - (err) => err instanceof CliError && - err.message.includes('control request failed: invalid HTTP header "x-admin-token"') + }), + (err) => + err instanceof CliError && err.message.includes('control request failed: invalid HTTP header "x-admin-token"') ); assert.equal(opened, false); }); @@ -2351,35 +2534,33 @@ test("wdl tail abort destroys the SSE request with a tolerated abort error", asy requestCount += 1; const emitter = new EventEmitter(); const req = /** @type {import("../../lib/control-fetch.js").ControlClientRequest} */ ( - /** @type {unknown} */ (Object.assign(emitter, { - end() {}, - /** @param {Error & { code?: string }} [err] */ - destroy(err) { - if (err) destroyedWith.push(err); - setImmediate(() => emitter.emit( - "error", - err || Object.assign(new Error("socket closed"), { code: "ECONNRESET" }), - )); - }, - })) + /** @type {unknown} */ ( + Object.assign(emitter, { + end() {}, + /** @param {Error & { code?: string }} [err] */ + destroy(err) { + if (err) destroyedWith.push(err); + setImmediate(() => + emitter.emit("error", err || Object.assign(new Error("socket closed"), { code: "ECONNRESET" })) + ); + }, + }) + ) ); setImmediate(() => process.emit("SIGINT")); return req; }, }; - await runTailCommand( - ["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], - { - env: {}, - stdout: () => {}, - stderr: () => {}, - transport: fakeTransport, - sleepFn: async () => { - throw new Error("tail should not reconnect after abort"); - }, - } - ); + await runTailCommand(["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], { + env: {}, + stdout: () => {}, + stderr: () => {}, + transport: fakeTransport, + sleepFn: async () => { + throw new Error("tail should not reconnect after abort"); + }, + }); assert.equal(requestCount, 1); assert.equal(destroyedWith.length, 1); @@ -2396,7 +2577,10 @@ test("wdl tail sends --since on the initial URL, not duplicated as Last-Event-ID * @param {(res: import("node:http").IncomingMessage) => void} cb */ request(opts, cb) { - requestsSeen.push({ path: opts.path, headers: { .../** @type {import("node:http").OutgoingHttpHeaders} */ (opts.headers) } }); + requestsSeen.push({ + path: opts.path, + headers: { .../** @type {import("node:http").OutgoingHttpHeaders} */ (opts.headers) }, + }); const req = fakeHttpReq(); setImmediate(() => { const res = fakeHttpRes(); @@ -2408,16 +2592,13 @@ test("wdl tail sends --since on the initial URL, not duplicated as Last-Event-ID }; await assert.rejects( - () => runTailCommand( - ["foo", "--since", "100-0", "--ns", "demo", "--token", "t", - "--control-url", "http://ctl.test"], - { + () => + runTailCommand(["foo", "--since", "100-0", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], { env: {}, stdout: () => {}, stderr: () => {}, transport: fakeTransport, - } - ), + }), { message: "test stop" } ); @@ -2434,7 +2615,10 @@ test("wdl tail keeps --since on reconnect until the server provides an event id" * @param {(res: import("node:http").IncomingMessage) => void} cb */ request(opts, cb) { - requestsSeen.push({ path: opts.path, headers: { .../** @type {import("node:http").OutgoingHttpHeaders} */ (opts.headers) } }); + requestsSeen.push({ + path: opts.path, + headers: { .../** @type {import("node:http").OutgoingHttpHeaders} */ (opts.headers) }, + }); const req = fakeHttpReq(); setImmediate(() => { const res = fakeHttpRes(); @@ -2450,17 +2634,14 @@ test("wdl tail keeps --since on reconnect until the server provides an event id" }; await assert.rejects( - () => runTailCommand( - ["foo", "--since", "100-0", "--ns", "demo", "--token", "t", - "--control-url", "http://ctl.test"], - { + () => + runTailCommand(["foo", "--since", "100-0", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], { env: {}, stdout: () => {}, stderr: () => {}, transport: fakeTransport, sleepFn: async () => {}, - } - ), + }), { message: "test stop" } ); @@ -2478,18 +2659,24 @@ test("wdl tail switches from --since to Last-Event-ID after receiving an event i * @param {(res: import("node:http").IncomingMessage) => void} cb */ request(opts, cb) { - requestsSeen.push({ path: opts.path, headers: { .../** @type {import("node:http").OutgoingHttpHeaders} */ (opts.headers) } }); + requestsSeen.push({ + path: opts.path, + headers: { .../** @type {import("node:http").OutgoingHttpHeaders} */ (opts.headers) }, + }); const req = fakeHttpReq(); setImmediate(() => { const res = fakeHttpRes(); cb(res); if (requestsSeen.length === 1) { - res.emit("data", `id: 101-0\nevent: worker_console\ndata: ${JSON.stringify({ - event: "worker_console", - console_level: "log", - message: "hello", - ts: 1, - })}\n\n`); + res.emit( + "data", + `id: 101-0\nevent: worker_console\ndata: ${JSON.stringify({ + event: "worker_console", + console_level: "log", + message: "hello", + ts: 1, + })}\n\n` + ); res.emit("error", Object.assign(new Error("socket hang up"), { code: "ECONNRESET" })); return; } @@ -2500,17 +2687,14 @@ test("wdl tail switches from --since to Last-Event-ID after receiving an event i }; await assert.rejects( - () => runTailCommand( - ["foo", "--since", "100-0", "--ns", "demo", "--token", "t", - "--control-url", "http://ctl.test"], - { + () => + runTailCommand(["foo", "--since", "100-0", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], { env: {}, stdout: () => {}, stderr: () => {}, transport: fakeTransport, sleepFn: async () => {}, - } - ), + }), { message: "test stop" } ); @@ -2539,15 +2723,13 @@ test("wdl tail prints a connected status after SSE handshake", async () => { }; await assert.rejects( - () => runTailCommand( - ["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], - { + () => + runTailCommand(["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], { env: {}, stdout: () => {}, stderr: (/** @type {string} */ line) => stderrLines.push(line), transport: fakeTransport, - } - ), + }), { message: "test stop" } ); @@ -2565,21 +2747,32 @@ test("wdl tail reconnects with Last-Event-ID after transport errors", async () = * @param {(res: import("node:http").IncomingMessage) => void} cb */ request(opts, cb) { - requestsSeen.push({ path: opts.path, headers: { .../** @type {import("node:http").OutgoingHttpHeaders} */ (opts.headers) } }); + requestsSeen.push({ + path: opts.path, + headers: { .../** @type {import("node:http").OutgoingHttpHeaders} */ (opts.headers) }, + }); const req = fakeHttpReq(); setImmediate(() => { const res = fakeHttpRes(); cb(res); if (requestsSeen.length === 1) { setImmediate(() => { - res.emit("data", `id: 100-0\nevent: worker_console\ndata: ${JSON.stringify({ - event: "worker_console", - console_level: "log", - message: "hello", - ts: 1, - })}\n\n`); + res.emit( + "data", + `id: 100-0\nevent: worker_console\ndata: ${JSON.stringify({ + event: "worker_console", + console_level: "log", + message: "hello", + ts: 1, + })}\n\n` + ); setImmediate(() => { - res.emit("error", Object.assign(new Error(`socket hang up${ESC}[2J\nFORGED\rBAD`), { code: "ECONNRESET" })); + res.emit( + "error", + Object.assign(new Error(`socket hang up${ESC}[2J\nFORGED\rBAD`), { + code: "ECONNRESET", + }) + ); }); }); } else { @@ -2593,16 +2786,14 @@ test("wdl tail reconnects with Last-Event-ID after transport errors", async () = }; await assert.rejects( - () => runTailCommand( - ["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], - { + () => + runTailCommand(["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], { env: {}, stdout: () => {}, stderr: (/** @type {string} */ line) => stderrLines.push(line), transport: fakeTransport, sleepFn: async () => {}, - } - ), + }), { message: "test stop" } ); @@ -2634,11 +2825,14 @@ test("wdl tail treats session recycle warnings as control-initiated reconnects", cb(res); if (requestCount === 1) { setImmediate(() => { - res.emit("data", `event: tail_warning\ndata: ${JSON.stringify({ - event: "tail_warning", - code: "session_idle", - message: "client idle", - })}\n\n`); + res.emit( + "data", + `event: tail_warning\ndata: ${JSON.stringify({ + event: "tail_warning", + code: "session_idle", + message: "client idle", + })}\n\n` + ); res.emit("end"); }); } else { @@ -2652,16 +2846,14 @@ test("wdl tail treats session recycle warnings as control-initiated reconnects", }; await assert.rejects( - () => runTailCommand( - ["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], - { + () => + runTailCommand(["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], { env: {}, stdout: () => {}, stderr: (/** @type {string} */ line) => stderrLines.push(line), transport: fakeTransport, sleepFn: async (/** @type {number} */ ms) => sleepCalls.push(ms), - } - ), + }), { message: "test stop" } ); @@ -2689,11 +2881,14 @@ test("wdl tail --raw still treats session recycle warnings as control-initiated cb(res); if (requestCount <= 3) { setImmediate(() => { - res.emit("data", `event: tail_warning\ndata: ${JSON.stringify({ - event: "tail_warning", - code: "session_idle", - message: "client idle", - })}\n\n`); + res.emit( + "data", + `event: tail_warning\ndata: ${JSON.stringify({ + event: "tail_warning", + code: "session_idle", + message: "client idle", + })}\n\n` + ); res.emit("end"); }); } else { @@ -2707,16 +2902,14 @@ test("wdl tail --raw still treats session recycle warnings as control-initiated }; await assert.rejects( - () => runTailCommand( - ["foo", "--raw", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], - { + () => + runTailCommand(["foo", "--raw", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], { env: {}, stdout: (/** @type {string} */ line) => stdoutLines.push(line), stderr: () => {}, transport: fakeTransport, sleepFn: async (/** @type {number} */ ms) => sleepCalls.push(ms), - } - ), + }), { message: "test stop" } ); @@ -2760,22 +2953,21 @@ test("wdl tail --raw treats non-object SSE JSON payloads as raw values", async ( }; await assert.rejects( - () => runTailCommand( - ["foo", "--raw", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], - { + () => + runTailCommand(["foo", "--raw", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], { env: {}, stdout: (/** @type {string} */ line) => stdoutLines.push(line), stderr: () => {}, transport: fakeTransport, sleepFn: async () => {}, - } - ), + }), { message: "test stop" } ); - assert.deepEqual(stdoutLines.map((line) => JSON.parse(line)), [ - { event: "message", raw: null }, - ]); + assert.deepEqual( + stdoutLines.map((line) => JSON.parse(line)), + [{ event: "message", raw: null }] + ); }); test("wdl tail increases backoff until a stable session resets it", async () => { @@ -2814,9 +3006,8 @@ test("wdl tail increases backoff until a stable session resets it", async () => }; await assert.rejects( - () => runTailCommand( - ["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], - { + () => + runTailCommand(["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], { env: {}, stdout: () => {}, stderr: (/** @type {string} */ line) => stderrLines.push(line), @@ -2826,8 +3017,7 @@ test("wdl tail increases backoff until a stable session resets it", async () => sleepCalls.push(ms); nowMs += ms; }, - } - ), + }), { message: "test stop" } ); @@ -2857,16 +3047,17 @@ test("wdl tail gives up after reconnects repeatedly hit the cap", async () => { }; await assert.rejects( - () => runTailCommand( - ["foo", "--max-reconnects", "2", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], - { - env: {}, - stdout: () => {}, - stderr: () => {}, - transport: fakeTransport, - sleepFn: async (/** @type {number} */ ms) => sleepCalls.push(ms), - } - ), + () => + runTailCommand( + ["foo", "--max-reconnects", "2", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], + { + env: {}, + stdout: () => {}, + stderr: () => {}, + transport: fakeTransport, + sleepFn: async (/** @type {number} */ ms) => sleepCalls.push(ms), + } + ), /gave up after 2 consecutive reconnects/ ); @@ -2902,16 +3093,17 @@ test("wdl tail --max-reconnects 0 disables the cap", async () => { }; await assert.rejects( - () => runTailCommand( - ["foo", "--max-reconnects", "0", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], - { - env: {}, - stdout: () => {}, - stderr: () => {}, - transport: fakeTransport, - sleepFn: async (/** @type {number} */ ms) => sleepCalls.push(ms), - } - ), + () => + runTailCommand( + ["foo", "--max-reconnects", "0", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], + { + env: {}, + stdout: () => {}, + stderr: () => {}, + transport: fakeTransport, + sleepFn: async (/** @type {number} */ ms) => sleepCalls.push(ms), + } + ), { message: "test stop" } ); diff --git a/tests/unit/cli-ns-pattern.test.js b/tests/unit/cli-ns-pattern.test.js index 865dcf0..a856e6b 100644 --- a/tests/unit/cli-ns-pattern.test.js +++ b/tests/unit/cli-ns-pattern.test.js @@ -21,17 +21,7 @@ test("cli namespace pattern accepts tenant namespace grammar", () => { for (const ok of ["demo", "ns-1", "a", "0123456789", "a-b-c", "a".repeat(63)]) { assert.ok(re.test(ok), `expected "${ok}" to match`); } - for (const bad of [ - "", - "UPPER", - "with.dot", - "ns_underscore", - "ns space", - "a/b", - "-bad", - "bad-", - "a".repeat(64), - ]) { + for (const bad of ["", "UPPER", "with.dot", "ns_underscore", "ns space", "a/b", "-bad", "bad-", "a".repeat(64)]) { assert.ok(!re.test(bad), `expected "${bad}" to fail`); } }); @@ -41,14 +31,7 @@ test("cli reserved namespace helper is shape-only", () => { for (const ok of ["__reserved__", "__operator-1__", "__OPERATOR_1__"]) { assert.equal(isReservedNs(ok), true, `expected ${JSON.stringify(ok)} accepted`); } - for (const bad of [ - "__future", - "__reserved__:worker", - "__reserved__/worker", - "demo", - "", - undefined, - ]) { + for (const bad of ["__future", "__reserved__:worker", "__reserved__/worker", "demo", "", undefined]) { assert.equal(isReservedNs(bad), false, `expected ${JSON.stringify(bad)} rejected`); } }); diff --git a/tests/unit/cli-output.test.js b/tests/unit/cli-output.test.js index 76e1433..e31a5c3 100644 --- a/tests/unit/cli-output.test.js +++ b/tests/unit/cli-output.test.js @@ -13,10 +13,16 @@ test("writeStatusLine escapes terminal control bytes in the assembled line", () test("writeJsonOr emits JSON and reports handled, or defers to the human path", () => { /** @type {string[]} */ const out = []; - assert.equal(writeJsonOr(true, { a: 1 }, (/** @type {string} */ l) => out.push(l)), true); + assert.equal( + writeJsonOr(true, { a: 1 }, (/** @type {string} */ l) => out.push(l)), + true + ); assert.equal(out[0], JSON.stringify({ a: 1 }, null, 2)); out.length = 0; - assert.equal(writeJsonOr(false, { a: 1 }, (/** @type {string} */ l) => out.push(l)), false); + assert.equal( + writeJsonOr(false, { a: 1 }, (/** @type {string} */ l) => out.push(l)), + false + ); assert.equal(out.length, 0, "nothing written when not json"); }); diff --git a/tests/unit/cli-stdin.test.js b/tests/unit/cli-stdin.test.js index aee9a31..fbfab48 100644 --- a/tests/unit/cli-stdin.test.js +++ b/tests/unit/cli-stdin.test.js @@ -12,10 +12,16 @@ test("readTtyLine hides input by switching the TTY to raw mode", async () => { const stdin = Object.assign(new EventEmitter(), { isTTY: true, setEncoding() {}, - setRawMode(/** @type {boolean} */ v) { rawCalls.push(v); }, + setRawMode(/** @type {boolean} */ v) { + rawCalls.push(v); + }, pause() {}, }); - const pending = readTtyLine(stdin, { prompt: "tok: ", stderr: (s) => stderr.push(s), hidden: true }); + const pending = readTtyLine(stdin, { + prompt: "tok: ", + stderr: (s) => stderr.push(s), + hidden: true, + }); queueMicrotask(() => { stdin.emit("data", "sec"); stdin.emit("data", "X" + String.fromCharCode(127)); // typo, then backspace removes it @@ -73,7 +79,9 @@ test("readSecretStdin hides input on a TTY via raw mode", async () => { const stdin = Object.assign(new EventEmitter(), { isTTY: true, setEncoding() {}, - setRawMode(/** @type {boolean} */ v) { rawCalls.push(v); }, + setRawMode(/** @type {boolean} */ v) { + rawCalls.push(v); + }, pause() {}, }); queueMicrotask(() => { @@ -96,9 +104,17 @@ test("readTtyLine escapes terminal controls in the prompt at the write point", a test("confirmAction escapes terminal controls in its refusal message", async () => { const esc = String.fromCharCode(27); await assert.rejects( - () => confirmAction({ stdin: /** @type {import("../../lib/stdin.js").StdinLike} */ (/** @type {unknown} */ ({ isTTY: false })), action: `delete ${esc}[2J thing` }), + () => + confirmAction({ + stdin: /** @type {import("../../lib/stdin.js").StdinLike} */ (/** @type {unknown} */ ({ isTTY: false })), + action: `delete ${esc}[2J thing`, + }), (err) => { - assert.doesNotMatch(/** @type {Error} */ (err).message, new RegExp(esc), "raw ESC must not be in the refusal error"); + assert.doesNotMatch( + /** @type {Error} */ (err).message, + new RegExp(esc), + "raw ESC must not be in the refusal error" + ); assert.match(/** @type {Error} */ (err).message, /Refusing to delete/); return true; } diff --git a/tests/unit/cli-token-store.test.js b/tests/unit/cli-token-store.test.js index 1ebc5cd..add557d 100644 --- a/tests/unit/cli-token-store.test.js +++ b/tests/unit/cli-token-store.test.js @@ -1,7 +1,19 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + utimesSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { @@ -62,7 +74,10 @@ test("tokenStoreDir on win32 honors APPDATA", () => { test("readTokenStore returns an empty store when the file is absent", () => { withTempDir((dir) => { - assert.deepEqual(readTokenStore(path.join(dir, "credentials")), { defaultNs: null, namespaces: {} }); + assert.deepEqual(readTokenStore(path.join(dir, "credentials")), { + defaultNs: null, + namespaces: {}, + }); }); }); @@ -121,18 +136,15 @@ test("updateTokenStore serializes read-modify-write with a lock directory", () = beta: { ADMIN_TOKEN: "tok-beta" }, }, }); - assert.throws( - () => { + assert.throws(() => { + rmSync(lockDir, { recursive: true, force: true }); + mkdirSync(lockDir, { recursive: true }); + try { + updateTokenStore(p, () => {}, { lockTimeoutMs: 0 }); + } finally { rmSync(lockDir, { recursive: true, force: true }); - mkdirSync(lockDir, { recursive: true }); - try { - updateTokenStore(p, () => {}, { lockTimeoutMs: 0 }); - } finally { - rmSync(lockDir, { recursive: true, force: true }); - } - }, - /credential store is locked/ - ); + } + }, /credential store is locked/); }); }); @@ -166,11 +178,15 @@ updateTokenStore(${JSON.stringify(p)}, (store) => { resolve(undefined); return; } - reject(new Error([ - `child ${ns} exited ${code}`, - Buffer.concat(stdout).toString("utf8"), - Buffer.concat(stderr).toString("utf8"), - ].join("\n"))); + reject( + new Error( + [ + `child ${ns} exited ${code}`, + Buffer.concat(stdout).toString("utf8"), + Buffer.concat(stderr).toString("utf8"), + ].join("\n") + ) + ); }); }); }); @@ -178,9 +194,9 @@ updateTokenStore(${JSON.stringify(p)}, (store) => { const results = await Promise.allSettled(workers); const failures = results.filter((item) => item.status === "rejected"); if (failures.length > 0) { - assert.fail(failures.map((item) => - item.status === "rejected" ? String(item.reason?.stack || item.reason) : "" - ).join("\n")); + assert.fail( + failures.map((item) => (item.status === "rejected" ? String(item.reason?.stack || item.reason) : "")).join("\n") + ); } const store = readTokenStore(p); @@ -230,9 +246,13 @@ test("updateTokenStore recovers a stale lock directory", () => { const staleTime = new Date(Date.now() - 10_000); utimesSync(lockDir, staleTime, staleTime); - updateTokenStore(p, (store) => { - store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; - }, { lockTimeoutMs: 0, staleLockMs: 1 }); + updateTokenStore( + p, + (store) => { + store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; + }, + { lockTimeoutMs: 0, staleLockMs: 1 } + ); assert.deepEqual(readTokenStore(p), { defaultNs: null, @@ -252,9 +272,13 @@ test("updateTokenStore takeover clears stale temp files inside the lock", () => const staleTime = new Date(Date.now() - 10_000); utimesSync(lockDir, staleTime, staleTime); - updateTokenStore(p, (store) => { - store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; - }, { lockTimeoutMs: 0, staleLockMs: 1 }); + updateTokenStore( + p, + (store) => { + store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; + }, + { lockTimeoutMs: 0, staleLockMs: 1 } + ); assert.equal(existsSync(staleTmp), false); assert.deepEqual(readTokenStore(p), { @@ -313,9 +337,13 @@ test("updateTokenStore recovers an old lock whose owner pid appears alive", () = const staleTime = new Date(Date.now() - 10_000); utimesSync(lockDir, staleTime, staleTime); - updateTokenStore(p, (store) => { - store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; - }, { lockTimeoutMs: 0, staleLockMs: 1 }); + updateTokenStore( + p, + (store) => { + store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; + }, + { lockTimeoutMs: 0, staleLockMs: 1 } + ); assert.deepEqual(readTokenStore(p), { defaultNs: null, @@ -335,9 +363,13 @@ test("updateTokenStore recovers a stale lock with an unreadable owner file", MOD const staleTime = new Date(Date.now() - 10_000); utimesSync(lockDir, staleTime, staleTime); - updateTokenStore(p, (store) => { - store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; - }, { lockTimeoutMs: 0, staleLockMs: 1 }); + updateTokenStore( + p, + (store) => { + store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; + }, + { lockTimeoutMs: 0, staleLockMs: 1 } + ); assert.deepEqual(readTokenStore(p), { defaultNs: null, @@ -361,9 +393,13 @@ test("updateTokenStore recovers a stale lock with an unreadable release marker", utimesSync(lockDir, staleTime, staleTime); try { - updateTokenStore(p, (store) => { - store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; - }, { lockTimeoutMs: 0, staleLockMs: 1 }); + updateTokenStore( + p, + (store) => { + store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; + }, + { lockTimeoutMs: 0, staleLockMs: 1 } + ); } finally { if (existsSync(releasedPath)) chmodSync(releasedPath, 0o600); } @@ -384,9 +420,13 @@ test("updateTokenStore recovers a stale regular-file lock", () => { const staleTime = new Date(Date.now() - 10_000); utimesSync(lockDir, staleTime, staleTime); - updateTokenStore(p, (store) => { - store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; - }, { lockTimeoutMs: 0, staleLockMs: 1 }); + updateTokenStore( + p, + (store) => { + store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; + }, + { lockTimeoutMs: 0, staleLockMs: 1 } + ); assert.deepEqual(readTokenStore(p), { defaultNs: null, @@ -402,9 +442,13 @@ test("updateTokenStore recovers a dangling-symlink lock at a zero stale threshol mkdirSync(path.dirname(p), { recursive: true }); symlinkSync(path.join(dir, "missing-lock-target"), lockDir); - updateTokenStore(p, (store) => { - store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; - }, { lockTimeoutMs: 0, staleLockMs: 0 }); + updateTokenStore( + p, + (store) => { + store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; + }, + { lockTimeoutMs: 0, staleLockMs: 0 } + ); assert.deepEqual(readTokenStore(p), { defaultNs: null, @@ -413,30 +457,38 @@ test("updateTokenStore recovers a dangling-symlink lock at a zero stale threshol }); }); -test("updateTokenStore recovers a symlink lock at a zero stale threshold without chmodding its target", POSIX_ONLY, () => { - withTempDir((dir) => { - const p = path.join(dir, "wdl", "credentials"); - const lockDir = `${p}.lock`; - const target = path.join(dir, "symlink-target"); - mkdirSync(path.dirname(p), { recursive: true }); - writeFileSync(target, "target", { mode: 0o600 }); - symlinkSync(target, lockDir); +test( + "updateTokenStore recovers a symlink lock at a zero stale threshold without chmodding its target", + POSIX_ONLY, + () => { + withTempDir((dir) => { + const p = path.join(dir, "wdl", "credentials"); + const lockDir = `${p}.lock`; + const target = path.join(dir, "symlink-target"); + mkdirSync(path.dirname(p), { recursive: true }); + writeFileSync(target, "target", { mode: 0o600 }); + symlinkSync(target, lockDir); - updateTokenStore(p, (store) => { - store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; - }, { lockTimeoutMs: 0, staleLockMs: 0 }); + updateTokenStore( + p, + (store) => { + store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; + }, + { lockTimeoutMs: 0, staleLockMs: 0 } + ); - assert.equal((statSync(target).mode & 0o777), 0o600); - assert.deepEqual(readTokenStore(p), { - defaultNs: null, - namespaces: { acme: { ADMIN_TOKEN: "tok-acme" } }, + assert.equal(statSync(target).mode & 0o777, 0o600); + assert.deepEqual(readTokenStore(p), { + defaultNs: null, + namespaces: { acme: { ADMIN_TOKEN: "tok-acme" } }, + }); + assert.deepEqual( + readdirSync(path.dirname(p)).filter((name) => name.startsWith(`${path.basename(p)}.lock.recovered-`)), + [] + ); }); - assert.deepEqual( - readdirSync(path.dirname(p)).filter((name) => name.startsWith(`${path.basename(p)}.lock.recovered-`)), - [] - ); - }); -}); + } +); test("updateTokenStore does not spin on a fresh dangling-symlink lock", POSIX_ONLY, () => { withTempDir((dir) => { @@ -464,9 +516,13 @@ test("updateTokenStore recovers a stale lock with an unreadable directory", MODE chmodSync(lockDir, 0o000); assertUnreadable(path.join(lockDir, "owner")); - updateTokenStore(p, (store) => { - store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; - }, { lockTimeoutMs: 0, staleLockMs: 1 }); + updateTokenStore( + p, + (store) => { + store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; + }, + { lockTimeoutMs: 0, staleLockMs: 1 } + ); assert.deepEqual(readTokenStore(p), { defaultNs: null, @@ -494,12 +550,17 @@ test("updateTokenStore refuses to write or release after lock ownership changes" try { assert.throws( - () => updateTokenStore(p, (store) => { - store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; - rmSync(lockDir, { recursive: true, force: true }); - mkdirSync(lockDir, { recursive: true }); - writeLockOwner(lockDir, "new-owner"); - }, { lockTimeoutMs: 0 }), + () => + updateTokenStore( + p, + (store) => { + store.namespaces.acme = { ADMIN_TOKEN: "tok-acme" }; + rmSync(lockDir, { recursive: true, force: true }); + mkdirSync(lockDir, { recursive: true }); + writeLockOwner(lockDir, "new-owner"); + }, + { lockTimeoutMs: 0 } + ), /credential store is locked/ ); assert.deepEqual(readTokenStore(p), { @@ -560,7 +621,7 @@ test("round-trips a token containing literal backslash escape sequences", () => const store = { defaultNs: null, namespaces: { - acme: { ADMIN_TOKEN: "a\\nb\\tc\\\\d\\\"e", LABEL: "C:\\Users\\x" }, + acme: { ADMIN_TOKEN: 'a\\nb\\tc\\\\d\\"e', LABEL: "C:\\Users\\x" }, }, }; writeTokenStore(p, store); @@ -683,9 +744,10 @@ test("updateTokenStore escapes write-side filesystem errors", () => { const p = path.join(badXdg, "wdl", "credentials"); assert.throws( - () => updateTokenStore(p, (store) => { - store.namespaces.acme = { ADMIN_TOKEN: "tok" }; - }), + () => + updateTokenStore(p, (store) => { + store.namespaces.acme = { ADMIN_TOKEN: "tok" }; + }), (err) => { const message = /** @type {Error} */ (err).message; assert.match(message, /failed to update credential store/); @@ -728,14 +790,20 @@ 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'); - assert.deepEqual(readTokenStore(p), { defaultNs: "acme", namespaces: { acme: { ADMIN_TOKEN: "t" } } }); + assert.deepEqual(readTokenStore(p), { + defaultNs: "acme", + namespaces: { acme: { ADMIN_TOKEN: "t" } }, + }); }); }); 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"); - assert.deepEqual(readTokenStore(p), { defaultNs: null, namespaces: { acme: { ADMIN_TOKEN: "t" } } }); + writeFileSync(p, '# note\n[acme]\nADMIN_TOKEN="t"\nUNKNOWN=x\n'); + assert.deepEqual(readTokenStore(p), { + defaultNs: null, + namespaces: { acme: { ADMIN_TOKEN: "t" } }, + }); }); }); diff --git a/tests/unit/cli-token.test.js b/tests/unit/cli-token.test.js index e3273eb..4346166 100644 --- a/tests/unit/cli-token.test.js +++ b/tests/unit/cli-token.test.js @@ -1,7 +1,16 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; -import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { runTokenCommand } from "../../commands/token.js"; @@ -49,6 +58,12 @@ function deps(xdg, { stdin, controlFetch } = {}) { const warnings = []; /** @type {Array<{ url: string, init: WhoamiInit }>} */ const calls = []; + /** @type {FakeControlFetch} */ + const defaultControlFetch = async (url, init) => { + if (!init) throw new Error("expected whoami request init"); + calls.push({ url, init }); + return response({ ok: true, principal: { kind: "ns", ns: "acme" } }); + }; return { lines, warnings, @@ -62,10 +77,7 @@ function deps(xdg, { stdin, controlFetch } = {}) { /** @param {string} line */ warn: (line) => warnings.push(line), stdin, - controlFetch: controlFetch || (/** @param {string} url @param {WhoamiInit} init */ async (url, init) => { - calls.push({ url, init }); - return response({ ok: true, principal: { kind: "ns", ns: "acme" } }); - }), + controlFetch: controlFetch || defaultControlFetch, }, }; } @@ -81,7 +93,10 @@ test("token set reads stdin, validates via /whoami, and stores the credential", assert.equal(calls[0].init.headers["x-admin-token"], "tok-secret-1234"); const store = readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })); - assert.deepEqual(store.namespaces.acme, { CONTROL_URL: "https://api.example", ADMIN_TOKEN: "tok-secret-1234" }); + assert.deepEqual(store.namespaces.acme, { + CONTROL_URL: "https://api.example", + ADMIN_TOKEN: "tok-secret-1234", + }); assert.equal(store.defaultNs, "acme", "the first stored namespace becomes the default"); assert.match(lines.join("\n"), /Stored token for acme @ https:\/\/api\.example \(\*\*\*\*1234\)/); assert.match(lines.join("\n"), /acme is now the default namespace/); @@ -143,7 +158,10 @@ test("token set does not claim a deliberately-cleared default in an ambiguous st const p = tokenStorePath({ XDG_CONFIG_HOME: xdg }); // Default null but 2+ namespaces (e.g. the default was removed from an // ambiguous set); a later set without --default must not steal the default. - writeTokenStore(p, { defaultNs: null, namespaces: { acme: { ADMIN_TOKEN: "a" }, demo: { ADMIN_TOKEN: "d" } } }); + writeTokenStore(p, { + defaultNs: null, + namespaces: { acme: { ADMIN_TOKEN: "a" }, demo: { ADMIN_TOKEN: "d" } }, + }); await runTokenCommand( ["set", "--ns", "demo", "--control-url", "https://api.example"], deps(xdg, { @@ -158,7 +176,10 @@ test("token set does not claim a deliberately-cleared default in an ambiguous st test("token set with --default claims the default in a deliberately-cleared ambiguous store", async () => { await withTempXdg(async (xdg) => { const p = tokenStorePath({ XDG_CONFIG_HOME: xdg }); - writeTokenStore(p, { defaultNs: null, namespaces: { acme: { ADMIN_TOKEN: "a" }, demo: { ADMIN_TOKEN: "d" } } }); + writeTokenStore(p, { + defaultNs: null, + namespaces: { acme: { ADMIN_TOKEN: "a" }, demo: { ADMIN_TOKEN: "d" } }, + }); await runTokenCommand( ["set", "--ns", "demo", "--control-url", "https://api.example", "--default"], deps(xdg, { @@ -194,20 +215,25 @@ test("token set does not store a token that fails /whoami", async () => { await withTempXdg(async (xdg) => { const controlFetch = async () => response({ error: "unauthorized" }, 401); await assert.rejects( - () => runTokenCommand( - ["set", "--ns", "acme", "--control-url", "https://api.example"], - deps(xdg, { stdin: stdinFrom("bad-token\n"), controlFetch }).deps - ), + () => + runTokenCommand( + ["set", "--ns", "acme", "--control-url", "https://api.example"], + deps(xdg, { stdin: stdinFrom("bad-token\n"), controlFetch }).deps + ), /whoami/ ); - assert.deepEqual(readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })), { defaultNs: null, namespaces: {} }); + assert.deepEqual(readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })), { + defaultNs: null, + namespaces: {}, + }); }); }); test("token set requires --ns and a control URL", async () => { await withTempXdg(async (xdg) => { await assert.rejects( - () => runTokenCommand(["set", "--control-url", "https://api.example"], deps(xdg, { stdin: stdinFrom("t\n") }).deps), + () => + runTokenCommand(["set", "--control-url", "https://api.example"], deps(xdg, { stdin: stdinFrom("t\n") }).deps), /requires --ns/ ); await assert.rejects( @@ -221,13 +247,17 @@ test("token set rejects a token whose principal namespace differs from --ns", as await withTempXdg(async (xdg) => { const controlFetch = async () => response({ ok: true, principal: { kind: "ns", ns: "other" } }); await assert.rejects( - () => runTokenCommand( - ["set", "--ns", "acme", "--control-url", "https://api.example"], - deps(xdg, { stdin: stdinFrom("tok\n"), controlFetch }).deps - ), + () => + runTokenCommand( + ["set", "--ns", "acme", "--control-url", "https://api.example"], + deps(xdg, { stdin: stdinFrom("tok\n"), controlFetch }).deps + ), /namespace "other", not "acme"/ ); - assert.deepEqual(readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })), { defaultNs: null, namespaces: {} }); + assert.deepEqual(readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })), { + defaultNs: null, + namespaces: {}, + }); }); }); @@ -235,26 +265,34 @@ test("token set rejects a token that is not scoped to a namespace", async () => await withTempXdg(async (xdg) => { const controlFetch = async () => response({ ok: true, principal: { kind: "operator" } }); await assert.rejects( - () => runTokenCommand( - ["set", "--ns", "acme", "--control-url", "https://api.example"], - deps(xdg, { stdin: stdinFrom("tok\n"), controlFetch }).deps - ), + () => + runTokenCommand( + ["set", "--ns", "acme", "--control-url", "https://api.example"], + deps(xdg, { stdin: stdinFrom("tok\n"), controlFetch }).deps + ), /not scoped to namespace "acme"/ ); - assert.deepEqual(readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })), { defaultNs: null, namespaces: {} }); + assert.deepEqual(readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })), { + defaultNs: null, + namespaces: {}, + }); }); }); test("token set rejects a namespace that is not a valid section name", async () => { await withTempXdg(async (xdg) => { await assert.rejects( - () => runTokenCommand( - ["set", "--ns", "evil]x", "--control-url", "https://api.example"], - deps(xdg, { stdin: stdinFrom("tok\n") }).deps - ), + () => + runTokenCommand( + ["set", "--ns", "evil]x", "--control-url", "https://api.example"], + deps(xdg, { stdin: stdinFrom("tok\n") }).deps + ), /invalid namespace/ ); - assert.deepEqual(readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })), { defaultNs: null, namespaces: {} }); + assert.deepEqual(readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })), { + defaultNs: null, + namespaces: {}, + }); }); }); @@ -262,10 +300,11 @@ test("token set escapes terminal controls in a principal-mismatch error", async await withTempXdg(async (xdg) => { const controlFetch = async () => response({ ok: true, principal: { kind: "ns", ns: `other${ESC}[2J` } }); await assert.rejects( - () => runTokenCommand( - ["set", "--ns", "acme", "--control-url", "https://api.example"], - deps(xdg, { stdin: stdinFrom("tok\n"), controlFetch }).deps - ), + () => + runTokenCommand( + ["set", "--ns", "acme", "--control-url", "https://api.example"], + deps(xdg, { stdin: stdinFrom("tok\n"), controlFetch }).deps + ), (err) => { const message = /** @type {Error} */ (err).message; assertNoRawTerminalControls(message, "principal-mismatch errors"); @@ -306,17 +345,21 @@ test("writeTokenStore replaces a symlink instead of following it", POSIX_ONLY, a assert.equal(readFileSync(target, "utf8"), "outside\n"); assert.equal(lstatSync(p).isSymbolicLink(), false); - assert.deepEqual(readTokenStore(p), { defaultNs: "acme", namespaces: { acme: { ADMIN_TOKEN: "secret" } } }); + assert.deepEqual(readTokenStore(p), { + defaultNs: "acme", + namespaces: { acme: { ADMIN_TOKEN: "secret" } }, + }); }); }); test("token does not accept a --token flag (the token comes from stdin)", async () => { await withTempXdg(async (xdg) => { await assert.rejects( - () => runTokenCommand( - ["set", "--ns", "acme", "--control-url", "https://api.example", "--token", "x"], - deps(xdg, { stdin: stdinFrom("tok\n") }).deps - ), + () => + runTokenCommand( + ["set", "--ns", "acme", "--control-url", "https://api.example", "--token", "x"], + deps(xdg, { stdin: stdinFrom("tok\n") }).deps + ), /Unknown option|--token/ ); }); @@ -329,7 +372,11 @@ test("token list formats stored namespaces with masked tokens and marks the defa writeTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg }), { defaultNs: "acme", namespaces: { - acme: { CONTROL_URL: "https://api.example", ADMIN_TOKEN: "tok-abcd1234", LABEL: "production" }, + acme: { + CONTROL_URL: "https://api.example", + ADMIN_TOKEN: "tok-abcd1234", + LABEL: "production", + }, demo: { CONTROL_URL: "https://api.example", ADMIN_TOKEN: "tok-zzzz9999" }, }, }); @@ -478,18 +525,21 @@ test("token set requires an explicit --ns and ignores ambient WDL_NS", async () // without the guard a WDL_NS=acme would let this store under acme. const { deps: d } = deps(xdg, { stdin: stdinFrom("tok\n") }); d.env.WDL_NS = "acme"; - await assert.rejects( - () => runTokenCommand(["set", "--control-url", "https://api.example"], d), - /requires --ns/ - ); - assert.deepEqual(readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })), { defaultNs: null, namespaces: {} }); + await assert.rejects(() => runTokenCommand(["set", "--control-url", "https://api.example"], d), /requires --ns/); + assert.deepEqual(readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })), { + defaultNs: null, + namespaces: {}, + }); }); }); test("token use requires an explicit namespace and ignores ambient WDL_NS", async () => { await withTempXdg(async (xdg) => { const p = tokenStorePath({ XDG_CONFIG_HOME: xdg }); - writeTokenStore(p, { defaultNs: "demo", namespaces: { acme: { ADMIN_TOKEN: "a" }, demo: { ADMIN_TOKEN: "d" } } }); + writeTokenStore(p, { + defaultNs: "demo", + namespaces: { acme: { ADMIN_TOKEN: "a" }, demo: { ADMIN_TOKEN: "d" } }, + }); const { deps: d } = deps(xdg); // A stray WDL_NS must NOT make a bare `use` switch the default. d.env.WDL_NS = "acme"; @@ -503,7 +553,11 @@ test("token rm of the default promotes a sole survivor, clears it when ambiguous const p = tokenStorePath({ XDG_CONFIG_HOME: xdg }); writeTokenStore(p, { defaultNs: "acme", - namespaces: { acme: { ADMIN_TOKEN: "a" }, demo: { ADMIN_TOKEN: "d" }, prod: { ADMIN_TOKEN: "p" } }, + namespaces: { + acme: { ADMIN_TOKEN: "a" }, + demo: { ADMIN_TOKEN: "d" }, + prod: { ADMIN_TOKEN: "p" }, + }, }); // Three stored, default removed → two remain → ambiguous → default cleared. @@ -522,7 +576,11 @@ test("token rm promotes the sole survivor even after the default was already cle const p = tokenStorePath({ XDG_CONFIG_HOME: xdg }); writeTokenStore(p, { defaultNs: "acme", - namespaces: { acme: { ADMIN_TOKEN: "a" }, demo: { ADMIN_TOKEN: "d" }, prod: { ADMIN_TOKEN: "p" } }, + namespaces: { + acme: { ADMIN_TOKEN: "a" }, + demo: { ADMIN_TOKEN: "d" }, + prod: { ADMIN_TOKEN: "p" }, + }, }); await runTokenCommand(["rm", "--ns", "acme"], deps(xdg).deps); // default cleared, 2 remain assert.equal(readTokenStore(p).defaultNs, null); @@ -540,7 +598,9 @@ test("token rejects unknown subcommands", async () => { test("token use/list/rm handle a namespace named like an Object.prototype key", async () => { await withTempXdg(async (xdg) => { const p = tokenStorePath({ XDG_CONFIG_HOME: xdg }); - writeTokenStore(p, { namespaces: { constructor: { ADMIN_TOKEN: "c" }, acme: { ADMIN_TOKEN: "a" } } }); + writeTokenStore(p, { + namespaces: { constructor: { ADMIN_TOKEN: "c" }, acme: { ADMIN_TOKEN: "a" } }, + }); await runTokenCommand(["use", "constructor"], deps(xdg).deps); assert.equal(readTokenStore(p).defaultNs, "constructor"); diff --git a/tests/unit/helpers.js b/tests/unit/helpers.js index efb12d4..c2e56b8 100644 --- a/tests/unit/helpers.js +++ b/tests/unit/helpers.js @@ -8,7 +8,9 @@ import path from "node:path"; export const ESC = String.fromCharCode(27); -export const POSIX_ONLY = { skip: process.platform === "win32" ? "POSIX-only filesystem behavior" : false }; +export const POSIX_ONLY = { + skip: process.platform === "win32" ? "POSIX-only filesystem behavior" : false, +}; // The uid does not decide this — the effective uid, CAP_DAC_OVERRIDE, and // mode-ignoring mounts do. Ask tmpdir() what it actually does. @@ -93,8 +95,7 @@ export function response(body, status = 200) { ok: status >= 200 && status < 300, json: async () => JSON.parse(text), text: async () => text, - arrayBuffer: async () => - bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), }; } diff --git a/tsconfig.json b/tsconfig.json index 8ad41e2..d984d83 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,16 +11,6 @@ "lib": ["ES2024", "DOM", "DOM.Iterable"], "types": ["node"] }, - "include": [ - "bin/**/*.js", - "commands/**/*.js", - "lib/**/*.js", - "tests/**/*.js", - "eslint.config.js" - ], - "exclude": [ - "**/node_modules/**", - "**/.deploy-dist/**", - "**/.wrangler/**" - ] + "include": ["bin/**/*.js", "commands/**/*.js", "lib/**/*.js", "tests/**/*.js", "eslint.config.js"], + "exclude": ["**/node_modules/**", "**/.deploy-dist/**", "**/.wrangler/**"] }