diff --git a/.claude/rules/examples.md b/.claude/rules/examples.md index 37e7bbe..d231a82 100644 --- a/.claude/rules/examples.md +++ b/.claude/rules/examples.md @@ -14,7 +14,7 @@ structure from scratch. ## Available examples -Each lives under `examples//`. Prefer `wdl init --ns ` for +Each lives under `examples//`. Prefer `wdl init [--ns ]` for plain workers. Pick and copy the closest example when an example matches the requested feature set better than the minimal init template. diff --git a/.claude/skills/wdl-deploy/SKILL.md b/.claude/skills/wdl-deploy/SKILL.md index b59822e..2453bf7 100644 --- a/.claude/skills/wdl-deploy/SKILL.md +++ b/.claude/skills/wdl-deploy/SKILL.md @@ -20,6 +20,9 @@ Open the relevant doc before answering: commands. - `docs/secrets.md` — `wdl secret` (worker-level vs namespace-level), runtime secret precedence, `--json` automation output, anti-patterns. +- `docs/token.md` — `wdl token set/list/use/rm`, the local credential store + (`~/.config/wdl/credentials`), its default namespace, and where it sits in + credential resolution. - `docs/d1.md` — `[[d1_databases]]` config, `wdl d1` commands, migrations. - `docs/durable-objects.md` — `[[durable_objects.bindings]]`, migration class declarations, the DO runtime surface. diff --git a/AGENTS.md b/AGENTS.md index 8703b61..a9d6761 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,6 +123,18 @@ when behavior changes. ## Security & Configuration Tips +Credential resolution layers, highest precedence first: CLI flags, shell/CI env, +the project `./.env` (sectioned by namespace, with a cross-origin guard that +drops a `.env`-supplied endpoint when the effective token is not from the same +`.env`), then the global token store (`~/.config/wdl/credentials`, managed by +`wdl token`). The store is trusted (home directory, same-source token + +endpoint) and not subject to the guard; a project `.env` is not. The namespace +itself follows the same shape — `--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`. + Do not commit tenant tokens or generated secrets. Read credentials from the environment (`ADMIN_TOKEN`, `CONTROL_URL`, `WDL_NS`) and keep example configuration generic. When adding deploy features, validate unsupported diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a00122..bc4a3af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,55 @@ # Changelog +## Unreleased + +### Added + +- `wdl token set/list/use/rm` manages a local credential store at + `~/.config/wdl/credentials` (`$XDG_CONFIG_HOME`/`%APPDATA%` honored), so + commands resolve a control URL and token without a per-shell `ADMIN_TOKEN` + export or a token in every project's `.env`. `set` reads the token from stdin + (hidden on a TTY) and validates it against `/whoami` before storing it under + the namespace; `rm` deletes the local copy without revoking it. The store is + the same `dotenv`/INI dialect as a project `.env`, written `0600`, and is the + lowest-precedence credential layer: + `flag > shell env > project .env > token store`. It is trusted (home + directory, same-source token + endpoint) and is not subject to the + cross-origin `.env` guard, while a project `.env` endpoint is still dropped + when the token comes from the store. `wdl config explain` shows + `token store [].…` as a value's source. +- The store carries a default namespace (a base `WDL_NS`, the analogue of a + project `.env`'s base `WDL_NS`): the first stored namespace becomes the + default, `wdl token set --default` and `wdl token use ` change it, and + `wdl token list` marks it with `*`. With a default set, commands resolve a + namespace without `--ns`; the selection chain is + `--ns > shell WDL_NS > project .env WDL_NS > store default`, and + `wdl config explain` shows `token store default` as the namespace source. + +### 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 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. + +### Removed + +- **BREAKING:** the `--admin` flag and the `ADMIN_URL` environment variable — + legacy compatibility aliases for the control endpoint — are removed. Use + `--control-url ` and the `CONTROL_URL` environment variable instead. + `--admin` is now an unknown option and `ADMIN_URL` is no longer read from the + shell or `.env`. + +### Fixed + +- `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. + ## 1.0.0 Initial open-source release. diff --git a/GUIDE-zh.md b/GUIDE-zh.md index b7aa876..4015538 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -81,9 +81,9 @@ ADMIN_TOKEN= ADMIN_TOKEN= ``` -CLI 只会从 `.env` 读取 WDL 平台变量:`ADMIN_TOKEN`、`ADMIN_URL`、 `CONTROL_URL`、`CONTROL_CONNECT_HOST`、`WDL_NS`。优先级是 `CLI flag > shell/CI env > [resolved-ns] section > base .env`,都没有提供时命令直接报错——没有内置默认值。namespace 解析顺序是 `--ns`,然后是 shell 或 base `.env` 里的 `WDL_NS`。section 名可以是 `[acme]` 这类 tenant namespace,也可以是 `[__name__]` 这种运维保留的不透明 section。Tenant Wrangler 配置默认仍使用普通 tenant namespace 语法,除非运维方明确给了这种 namespace token;否则不要把 `__name__` 形态写进 `[[services]].ns`、`allowed_callers` 或命令示例。如果没有解析出 namespace,section 会全部跳过;后续命令如果需要 namespace 或 token,会按正常校验报错。只有临时切换 namespace 时才需要显式传 `--ns`。不带 scheme 的生产 control host(例如 `api.wdl.dev`)默认补 `https://`;`localhost:8080` 或 `*.test:8080` 这类本地开发地址默认补 `http://`。任何不带 scheme 的 `:8080` control URL 都会按本地 HTTP 处理。需要强制使用其它协议时,显式写 scheme。 +CLI 只会从 `.env` 读取 WDL 平台变量:`ADMIN_TOKEN`、`CONTROL_URL`、`CONTROL_CONNECT_HOST`、`WDL_NS`。优先级是 `CLI flag > shell/CI env > [resolved-ns] section > base .env > wdl token store`,都没有提供时命令直接报错——没有内置默认值。namespace 解析顺序是 `--ns`,然后是 shell 或 base `.env` 里的 `WDL_NS`,再然后是 token store 的默认 namespace。section 名可以是 `[acme]` 这类 tenant namespace,也可以是 `[__name__]` 这种运维保留的不透明 section。Tenant Wrangler 配置默认仍使用普通 tenant namespace 语法,除非运维方明确给了这种 namespace token;否则不要把 `__name__` 形态写进 `[[services]].ns`、`allowed_callers` 或命令示例。如果没有解析出 namespace,section 会全部跳过;后续命令如果需要 namespace 或 token,会按正常校验报错。只有临时切换 namespace 时才需要显式传 `--ns`。不带 scheme 的生产 control host(例如 `api.wdl.dev`)默认补 `https://`;`localhost:8080` 或 `*.test:8080` 这类本地开发地址默认补 `http://`。任何不带 scheme 的 `:8080` control URL 都会按本地 HTTP 处理。需要强制使用其它协议时,显式写 scheme。 -目前这些凭证来自 shell、`.env` 文件或命令行标志。后续版本(1.1)会新增 `wdl auth login`,用隐藏输入读取 token 并存入托管配置文件,使其不进入 shell 历史、也不落在项目文件里。 +推荐的做法是把这些凭证放进托管存储,而不是 shell export 或项目 `.env`:`wdl token set --ns --control-url ` 用隐藏输入读取 token、调 `/whoami` 校验后按 namespace 存入 `~/.config/wdl/credentials`(不进 shell 历史、也不落在项目文件里)。存储是优先级最低的层——命令行标志、shell env、项目 `.env` 仍然胜出——`wdl token list` / `wdl token rm` 管理它。第一个存入的 namespace 成为默认(一行 base `WDL_NS`,和项目 `.env` 一样),命令不带 `--ns` 也能跑;`wdl token use ` 切换默认。详见 [token-zh.md](./docs/token-zh.md)。 用 `wdl config explain` 查看最终 namespace、control URL、脱敏 token 以及每个值的来源。用 `wdl whoami` 调 control-plane `/whoami`,查看当前 authenticated principal、token id、platform version、最低支持 CLI version 和 URL hints。用 `wdl doctor` 做本地可用性检查,包括 Node.js、wdl-cli、Wrangler、配置文件是否存在、凭据是否能解析,以及 `/whoami` 是否可达。当 control plane 暴露 `/whoami` 时,`doctor` 可以发现 token 是否有效、principal namespace、platform version 和 CLI compatibility;更细的 capability 检查仍需要额外的 control endpoint。 @@ -99,7 +99,7 @@ npm install 它会写入: -- `package.json` —— `npm run deploy` 已把 `--ns` 烤进去,另有 `npm run dry-run` 本地打包检查;devDependencies 固定 `wrangler@^4` 和 `@wdl-dev/cli`。 +- `package.json` —— 传了 `--ns` 时 `npm run deploy` 会把它烤进去,否则就是 `wdl deploy .`(namespace 在部署期解析),另有 `npm run dry-run` 本地打包检查;devDependencies 固定 `wrangler@^4` 和 `@wdl-dev/cli`。 - `wrangler.jsonc` —— 顶层 `name` 是 worker 名(默认等于目录名,可用 `--worker ` 覆盖)。 - `src/index.js`、`.gitignore`,以及 `AGENTS.md`/`CLAUDE.md`,方便 AI 代理找到 `node_modules/@wdl-dev/cli/docs/` 下的分主题文档。 @@ -766,7 +766,7 @@ wdl tail hello | 现象 | 可能原因 | 检查方式 | | --- | --- | --- | -| `Missing admin token` | 没有提供 tenant token | 设置 `ADMIN_TOKEN`,或传 `--token` | +| `Missing admin token` | 没有提供 tenant token | 运行 `wdl token set --ns --control-url `(推荐),或设 `ADMIN_TOKEN` / 传 `--token` | | `wrangler build failed` | Wrangler 无法打包 Worker 项目 | 在 Worker 项目目录执行 `npx wrangler deploy --dry-run`,先修本地构建或配置错误 | | deploy 成功但 promote 失败 | route、自定义 host 或 binding 在 promote 阶段校验失败 | 确认自定义 host 已为你的 namespace 开通,service binding 目标存在 | | Worker URL 返回 404 | URL 形态或 worker name 不对 | 使用 `https://.//`,不要漏掉 worker name 这一段路径 | diff --git a/GUIDE.md b/GUIDE.md index a1bc2f4..d8dd10c 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -95,10 +95,11 @@ ADMIN_TOKEN= ``` The CLI loads only WDL platform variables from `.env`: `ADMIN_TOKEN`, -`ADMIN_URL`, `CONTROL_URL`, `CONTROL_CONNECT_HOST`, and `WDL_NS`. Precedence is -`CLI flag > shell/CI env > [resolved-ns] section > base .env`, 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`. Section +`CONTROL_URL`, `CONTROL_CONNECT_HOST`, and `WDL_NS`. Precedence is +`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 @@ -112,10 +113,16 @@ 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. -Today these credentials come from your shell, a `.env` file, or flags. A future -release (1.1) will add `wdl auth login`, which reads the token with hidden input -and stores it in a managed config file, so it never lands in shell history or a -project file. +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). 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 @@ -139,9 +146,10 @@ npm install It writes: -- `package.json` — `npm run deploy` with `--ns` baked in, plus an - `npm run dry-run` local bundle check; pins `wrangler@^4` and `@wdl-dev/cli` as - devDependencies. +- `package.json` — `npm run deploy` with `--ns` baked in when you pass it + (otherwise just `wdl deploy .`, with the namespace resolved at deploy time), + plus an `npm run dry-run` local bundle check; pins `wrangler@^4` and + `@wdl-dev/cli` as devDependencies. - `wrangler.jsonc` — top-level `name` is the worker name (defaults to the directory name; override with `--worker `). - `src/index.js`, `.gitignore`, and `AGENTS.md`/`CLAUDE.md` so AI agents can @@ -997,7 +1005,7 @@ wdl tail hello | Symptom | Likely cause | What to check | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| `Missing admin token` | No tenant token was provided | 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-zh.md b/README-zh.md index 7676c41..7284b08 100644 --- a/README-zh.md +++ b/README-zh.md @@ -60,16 +60,18 @@ wdl tail hello # 边访问 URL 边看实时日志 Worker 此时位于 `https://./hello/`。 -凭证也可以放进带命名空间分段的 `.env` 文件——复制 [`.env.example`](https://github.com/wdl-dev/cli/blob/main/.env.example),来源优先级(flag 高于 shell env,高于 `.env`)见 [docs/deploy.md](https://github.com/wdl-dev/cli/blob/main/docs/deploy.md)。 +凭证也可以放进带命名空间分段的 `.env` 文件——复制 [`.env.example`](https://github.com/wdl-dev/cli/blob/main/.env.example),来源优先级(flag 高于 shell env,高于 `.env`,高于 `wdl token` store)见 [docs/deploy.md](https://github.com/wdl-dev/cli/blob/main/docs/deploy.md)。 ## 命令 ```bash -wdl init --ns [--worker ] +wdl init [--ns ] [--worker ] wdl deploy [--ns ] [--env ] [--verbose] wdl tail [...] [--ns ] [--raw] wdl workers [--ns ] wdl secret (--worker | --scope ns) [KEY] [--json] +wdl token set --ns [--control-url ] [--label ] [--default] +wdl token list [--json] / wdl token use / wdl token rm --ns wdl d1 ... wdl r2 buckets list / wdl r2 objects ... wdl workflows ... @@ -124,9 +126,10 @@ Worker/项目目录名:[如果已知就填,例如 hello-counter;不知道 步骤: 1. 检查 Node.js >= 22 和 npm。缺 `wdl` 时执行 `npm i -g @wdl-dev/cli`;安装后确认 `command -v wdl` 可用。 -2. 确认 `WDL_NS`、`ADMIN_TOKEN`、`CONTROL_URL`。CLI 没有内置控制面地址,三个值都必须配置——由运维方提供。如果有缺失,引导我写入当前 shell 的 rc 文件(先看 `$SHELL`:zsh → `~/.zshrc`,bash → `~/.bashrc` 或 `~/.bash_profile`,fish → `~/.config/fish/config.fish`),然后让当前 shell 生效;已设置就跳过。 +2. 确认 namespace 和 control 凭证能解析出来——跑 `wdl doctor`。它们可以来自 shell/CI env(`WDL_NS`、`ADMIN_TOKEN`、`CONTROL_URL`)、项目 `.env`,或 `wdl token` store;control URL 和 token 由运维方提供(CLI 没有内置控制面地址)。都解析不出来时,最干净的做法是让我跑 `wdl token set --ns --control-url `,在隐藏提示里输入 token——它会经校验、以 `0600` 存入,并成为默认 namespace,之后 `wdl deploy` 不用再带 `--ns`。优先用这个,而不是把 token 写进 shell rc 文件。 3. 确认项目目录名以字母开头,后续只含字母、数字和连字符。执行: - `wdl init --ns "$WDL_NS" && cd && npm install` + `wdl init && cd && npm install` + (给 `wdl init` 加 `--ns ` 可把 namespace 烤进 deploy 脚本;否则部署期从 `wdl token` 默认或 `--ns` 解析。) 4. 立刻打开并阅读新目录里的 `AGENTS.md`,再根据我的功能打开 `node_modules/@wdl-dev/cli/docs/` 下相关文档和示例。注意:session 中新生成的 `AGENTS.md` 不会自动加载,必须显式读取。 5. 根据功能修改 `wrangler.jsonc` 和 `src/`。需要第三方 API 鉴权 secret 时用 `wdl secret put --worker ` 写入,不要把 token 放进源码、`wrangler.jsonc` 或 `.env`。 6. 先跑 `npm run dry-run` 修复本地 bundle 问题,再跑 `npm run deploy` 部署。 diff --git a/README.md b/README.md index dc7679f..fcbb6b8 100644 --- a/README.md +++ b/README.md @@ -91,16 +91,19 @@ The worker is now at `https://./hello/`. Credentials can also live in a `.env` file with per-namespace sections — copy [`.env.example`](https://github.com/wdl-dev/cli/blob/main/.env.example) and see [docs/deploy.md](https://github.com/wdl-dev/cli/blob/main/docs/deploy.md) for -source precedence (flags beat shell env, which beats `.env`). +source precedence (flags beat shell env, which beats `.env`, which beats the +`wdl token` store). ## Commands ```bash -wdl init --ns [--worker ] +wdl init [--ns ] [--worker ] wdl deploy [--ns ] [--env ] [--verbose] wdl tail [...] [--ns ] [--raw] wdl workers [--ns ] wdl secret (--worker | --scope ns) [KEY] [--json] +wdl token set --ns [--control-url ] [--label ] [--default] +wdl token list [--json] / wdl token use / wdl token rm --ns wdl d1 ... wdl r2 buckets list / wdl r2 objects ... wdl workflows ... @@ -159,9 +162,10 @@ Start executing right away — don't just hand me a plan. Follow these rules thr Steps: 1. Check Node.js >= 22 and npm. If `wdl` is missing, run `npm i -g @wdl-dev/cli`, then confirm `command -v wdl` works. -2. Confirm `WDL_NS`, `ADMIN_TOKEN`, and `CONTROL_URL`. The CLI has no built-in control endpoint, so all three are required — my operator provides them. If any are missing, guide me to add them to my shell rc file (check `$SHELL` first: zsh → `~/.zshrc`, bash → `~/.bashrc` or `~/.bash_profile`, fish → `~/.config/fish/config.fish`), then reload the current shell; skip if already set. +2. Confirm a namespace and control credentials resolve — run `wdl doctor`. They can come from shell/CI env (`WDL_NS`, `ADMIN_TOKEN`, `CONTROL_URL`), a project `.env`, or the `wdl token` store; my operator provides the control URL and token (the CLI has no built-in endpoint). If nothing resolves, the cleanest setup is for me to run `wdl token set --ns --control-url ` and enter the token at the hidden prompt — it is validated, stored `0600`, and becomes the default namespace, so later `wdl deploy` needs no `--ns`. Prefer this over writing the token into a shell rc file. 3. Confirm the project directory name starts with a letter and contains only letters, digits, and hyphens. Run: - `wdl init --ns "$WDL_NS" && cd && npm install` + `wdl init && cd && npm install` + (add `--ns ` to `wdl init` to bake the namespace into the deploy script; otherwise it resolves from the `wdl token` default or `--ns` at deploy time.) 4. Immediately open and read `AGENTS.md` in the new directory, then open the relevant docs and examples under `node_modules/@wdl-dev/cli/docs/` for my feature. Note: a freshly generated `AGENTS.md` is not loaded automatically mid-session — read it explicitly. 5. Edit `wrangler.jsonc` and `src/` for the feature. Push third-party API secrets with `wdl secret put --worker `; never put tokens in source, `wrangler.jsonc`, or `.env`. 6. Run `npm run dry-run` first and fix local bundle issues, then deploy with `npm run deploy`. diff --git a/bin/wdl.js b/bin/wdl.js index 84b783d..ca3aa71 100755 --- a/bin/wdl.js +++ b/bin/wdl.js @@ -13,14 +13,16 @@ import * as workflowsCmd from "../commands/workflows.js"; import * as configCmd from "../commands/config.js"; import * as doctorCmd from "../commands/doctor.js"; import * as whoamiCmd from "../commands/whoami.js"; +import * as tokenCmd from "../commands/token.js"; import { isHelpAlias } from "../lib/command.js"; -import { commonCliOptions, formatHelp, handleCliError, isMain, loadCliControlEnv } from "../lib/common.js"; +import { commonCliOptions, flagSet, formatHelp, handleCliError, isMain, loadCliControlEnv } from "../lib/common.js"; import { currentCliVersion } from "../lib/package-info.js"; +import { readTokenStore, tokenStorePath } 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, 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" }; @@ -65,7 +67,9 @@ export async function main(argv = process.argv.slice(2), deps = {}) { loadCliControlEnv(env, { nsFromFlag: scanned.ns, tokenFromFlag: scanned.tokenFromFlag, + controlUrlFromFlag: scanned.controlUrlFromFlag, loadEnv: loadEnvOverride, + readStore: (e) => readTokenStore(tokenStorePath(e)), }); } catch (err) { handleCliError(err); @@ -94,7 +98,10 @@ function scanCommandArgs(commandModule, args) { // A non-empty --token means the effective credential is NOT the .env one // (an empty --token "" falls back to env), so the cross-origin guard must // distrust .env control endpoints. Matches config-state's detection. - tokenFromFlag: typeof values.token === "string" && values.token.length > 0, + tokenFromFlag: flagSet(values, "token"), + // A --control-url means the store need not be consulted to fill the + // endpoint, so a corrupt store cannot block a fully flag-supplied command. + controlUrlFromFlag: flagSet(values, "control-url"), help: values.help === true || isHelpAlias(positionals), }; } diff --git a/commands/deploy.js b/commands/deploy.js index 0f9b074..72a531a 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -13,6 +13,7 @@ import { isMain, optionHelp, shellSingleQuote, + writeStatusLine, } from "../lib/common.js"; import { packWranglerProject } from "../lib/wrangler-pack.js"; @@ -45,9 +46,9 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, }; const deployBody = serializeDeployManifest(manifest); - stdout(`[2/3] uploading ${workerName} → ${controlUrl}/ns/${ns}`); - // `version` comes from the control response — escape every display site, - // but keep the raw value for the promote request body. + 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 } = await context.fetchJson( context.nsUrl("worker", workerName, "deploy"), { @@ -76,8 +77,7 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, } } - const displayVersion = escapeTerminalText(String(version)); - stdout(`[3/3] promoting ${displayVersion}`); + writeStatusLine(stdout, `[3/3] promoting ${version}`); let promoteBody; try { promoteBody = await context.fetchJson( @@ -91,7 +91,7 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, ); } catch (err) { stderr( - `note: version ${displayVersion} was uploaded and retained but NOT promoted; ` + + `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.` ); throw err; @@ -168,14 +168,14 @@ async function runDeploy({ values, positionals, context }) { }); stdout(""); - stdout(`✓ ${ns}/${workerName}@${escapeTerminalText(String(version))} live`); + writeStatusLine(stdout, `✓ ${ns}/${workerName}@${version} live`); const controlHost = new URL(controlUrl).hostname; const isLocal = controlHost === "localhost" || controlHost === "127.0.0.1"; if (isLocal) { const host = `${ns}.${platformDomain || "workers.local"}`; - stdout(` curl -H ${shellSingleQuote(`Host: ${escapeTerminalText(host)}`)} http://localhost:8080/${escapeTerminalText(workerName)}/`); + writeStatusLine(stdout, ` curl -H ${shellSingleQuote(`Host: ${host}`)} http://localhost:8080/${workerName}/`); } else if (platformDomain) { - stdout(` https://${escapeTerminalText(ns)}.${escapeTerminalText(platformDomain)}/${escapeTerminalText(workerName)}/`); + writeStatusLine(stdout, ` https://${ns}.${platformDomain}/${workerName}/`); } } diff --git a/commands/init.js b/commands/init.js index dd57802..98082f4 100644 --- a/commands/init.js +++ b/commands/init.js @@ -14,17 +14,19 @@ const DEFAULT_COMPATIBILITY_DATE = "2026-05-31"; const CLI_ROOT = path.resolve(fileURLToPath(import.meta.url), "../.."); const INIT_OPTIONS = [ - defineCliOption("ns", { type: "string" }, "--ns ", "Tenant namespace baked into the deploy scripts (required)."), + 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("help", { type: "boolean", short: "h" }, "-h, --help", "Show this help."), ]; // init is not a defineCommand (no control plane / namespace), so its metadata -// is declared directly for the bin registry's help table. +// is declared directly for the bin registry's help table. autoloadEnv is false: +// init only scaffolds files locally, so it must not load .env control vars or +// read the token store — a corrupt store must never block project scaffolding. export const meta = { name: "init", summary: "Scaffold a new WDL Worker project.", - autoloadEnv: true, + autoloadEnv: false, parseOptions: optionParseOptions(INIT_OPTIONS), }; @@ -38,9 +40,6 @@ export async function main(argv = process.argv.slice(2)) { if (!args.target) { throw new CliError("missing argument. Run `wdl init --help`."); } - if (!args.ns) { - throw new CliError("missing --ns . Pass the tenant namespace, e.g. `--ns acme`."); - } const { targetDir, packageName, isInPlace } = resolveTarget(args.target); @@ -51,8 +50,8 @@ export async function main(argv = process.argv.slice(2)) { ); } - const ns = args.ns.trim(); - validateNs(ns, "--ns"); + const ns = args.ns ? args.ns.trim() : null; + if (ns) validateNs(ns, "--ns"); const workerName = (args.worker ? args.worker.trim() : "") || packageName; validateWorker(workerName, args.worker ? "--worker" : "worker name"); @@ -167,7 +166,7 @@ async function writeStarter(targetDir, { packageName, workerName, ns }) { private: true, type: "module", scripts: { - deploy: `wdl deploy . --ns ${ns}`, + deploy: ns ? `wdl deploy . --ns ${ns}` : "wdl deploy .", "dry-run": "wrangler deploy --dry-run --outdir=.deploy-dist", }, devDependencies: { @@ -260,7 +259,7 @@ async function copyAgentsDoc(targetDir) { } function printNextSteps(target, { packageName, workerName, ns, isInPlace }) { - const url = `https://${ns}./${workerName}/`; + const url = `https://${ns || ""}./${workerName}/`; const lines = [ "", `Scaffolded ${packageName}.`, @@ -278,8 +277,14 @@ function printNextSteps(target, { packageName, workerName, ns, isInPlace }) { lines.push("the agent reads it to find the right per-feature docs under"); lines.push("node_modules/@wdl-dev/cli/docs/."); lines.push(""); - lines.push(`Deploy (--ns ${ns} is baked into the npm script):`); - lines.push(" npm run deploy"); + if (ns) { + lines.push(`Deploy (--ns ${ns} is baked into the npm script):`); + lines.push(" npm run deploy"); + } else { + lines.push("Deploy — pick a namespace (none is baked in):"); + lines.push(" npm run deploy -- --ns "); + lines.push(" # or set WDL_NS / a project .env / a `wdl token` default, then: npm run deploy"); + } lines.push(""); console.log(lines.join("\n")); } @@ -287,7 +292,7 @@ function printNextSteps(target, { packageName, workerName, ns, isInPlace }) { function printHelp(exitCode) { console.log(formatHelp({ usage: [ - "wdl init --ns [--worker ]", + "wdl init [--ns ] [--worker ]", "wdl init --help", ], description: diff --git a/commands/r2.js b/commands/r2.js index 2b9794c..85cee4c 100644 --- a/commands/r2.js +++ b/commands/r2.js @@ -15,6 +15,7 @@ import { isMain, optionHelp, writeResult, + writeStatusLine, } from "../lib/common.js"; import { formatBucketList, formatObjectHead, formatObjectList } from "../lib/r2-format.js"; @@ -92,7 +93,7 @@ async function runR2({ values, positionals, context }) { }, "get R2 object"); if (values.out) { const bytesWritten = await writeBodyToFile(res.body, values.out); - stdout(`OK wrote ${bytesWritten} bytes to ${values.out}`); + writeStatusLine(stdout, `OK wrote ${bytesWritten} bytes to ${values.out}`); } else { await writeBodyToStdout(res.body, stdoutStream); } diff --git a/commands/secret.js b/commands/secret.js index 27ca1b2..4c99c09 100644 --- a/commands/secret.js +++ b/commands/secret.js @@ -7,11 +7,12 @@ import { CliError, confirmAction, defineCliOption, - escapeTerminalText, formatHelp, isMain, optionHelp, - readTtyLine, + readSecretStdin, + writeJsonOr, + writeStatusLine, } from "../lib/common.js"; const SECRET_OPTIONS = [ @@ -66,21 +67,18 @@ async function runSecret({ values, positionals, context }) { if (subcommand === "list") { const body = await context.fetchJson(context.nsUrl(...secretPath), { headers }, "list"); - if (values.json) { - stdout(JSON.stringify(body, null, 2)); - return; - } + if (writeJsonOr(values.json, body, stdout)) return; const keys = Array.isArray(body.keys) ? body.keys : []; - if (keys.length === 0) stdout("(no secrets)"); - else for (const k of keys) stdout(escapeTerminalText(String(k))); + if (keys.length === 0) writeStatusLine(stdout, "(no secrets)"); + else for (const k of keys) writeStatusLine(stdout, String(k)); return; } if (subcommand === "put") { if (!keyArg) throw new CliError("put requires a KEY argument"); // Empty string is a set secret (≠ unset), matching wrangler. - const value = await readStdin(stdin, { - prompt: `Enter secret value for ${scopeLabel}/${keyArg}: `, + const value = await readSecretStdin(stdin, { + prompt: `Enter secret value for ${scopeLabel}/${keyArg} (input hidden): `, stderr, }); const body = await context.fetchJson(context.nsUrl(...secretPath, keyArg), { @@ -88,20 +86,17 @@ async function runSecret({ values, positionals, context }) { headers: { ...headers, "content-type": "application/json" }, body: JSON.stringify({ value }), }, "put"); - if (values.json) { - stdout(JSON.stringify(body, null, 2)); - return; - } + if (writeJsonOr(values.json, body, stdout)) return; const warning = pickPromoteWarning(body); if (warning) { - stdout(`⚠ ${scopeLabel}/${keyArg} set — stored, reload deferred: ${escapeTerminalText(warning.reason)}`); - stdout(` next pickup: ${escapeTerminalText(warning.nextPickup)}`); + writeStatusLine(stdout, `⚠ ${scopeLabel}/${keyArg} set — stored, reload deferred: ${warning.reason}`); + writeStatusLine(stdout, ` next pickup: ${warning.nextPickup}`); } else if (hasWorker && body.version) { - stdout(`✓ ${scopeLabel}/${keyArg} set — promoted ${escapeTerminalText(body.previousVersion)} → ${escapeTerminalText(body.version)}`); + writeStatusLine(stdout, `✓ ${scopeLabel}/${keyArg} set — promoted ${body.previousVersion} → ${body.version}`); } else if (hasWorker) { - stdout(`✓ ${scopeLabel}/${keyArg} set — stored; will apply on first deploy`); + writeStatusLine(stdout, `✓ ${scopeLabel}/${keyArg} set — stored; will apply on first deploy`); } else { - stdout(`✓ ${scopeLabel}/${keyArg} set — effect on next natural cold-load`); + writeStatusLine(stdout, `✓ ${scopeLabel}/${keyArg} set — effect on next natural cold-load`); } return; } @@ -119,23 +114,20 @@ async function runSecret({ values, positionals, context }) { method: "DELETE", headers, }, "delete"); - if (values.json) { - stdout(JSON.stringify(body, null, 2)); - return; - } + if (writeJsonOr(values.json, body, stdout)) return; const warning = pickPromoteWarning(body); - if (!body.deleted && !warning) stdout(`(${keyArg} was not set)`); + if (!body.deleted && !warning) writeStatusLine(stdout, `(${keyArg} was not set)`); else if (warning && body.deleted) { - stdout(`⚠ ${scopeLabel}/${keyArg} deleted — stored, reload deferred: ${escapeTerminalText(warning.reason)}`); - stdout(` next pickup: ${escapeTerminalText(warning.nextPickup)}`); + writeStatusLine(stdout, `⚠ ${scopeLabel}/${keyArg} deleted — stored, reload deferred: ${warning.reason}`); + writeStatusLine(stdout, ` next pickup: ${warning.nextPickup}`); } else if (warning) { - stdout(`⚠ ${scopeLabel}/${keyArg} unchanged — reload deferred: ${escapeTerminalText(warning.reason)}`); - stdout(` next pickup: ${escapeTerminalText(warning.nextPickup)}`); + writeStatusLine(stdout, `⚠ ${scopeLabel}/${keyArg} unchanged — reload deferred: ${warning.reason}`); + writeStatusLine(stdout, ` next pickup: ${warning.nextPickup}`); } - else if (hasWorker && body.version) stdout(`✓ ${scopeLabel}/${keyArg} deleted — promoted ${escapeTerminalText(body.previousVersion)} → ${escapeTerminalText(body.version)}`); - else if (hasWorker) stdout(`✓ ${scopeLabel}/${keyArg} deleted — no active worker version to promote`); - else stdout(`✓ ${scopeLabel}/${keyArg} deleted — effect on next natural cold-load`); + 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; } @@ -147,24 +139,6 @@ function pickPromoteWarning(body) { return warnings.find((w) => w?.kind === "promote_failed") || null; } -// Pipe/redirect mode reads until EOF so multi-line secrets work. TTY mode -// reads one line so typing a value and pressing Enter submits immediately. -/** - * @param {{ isTTY?: boolean, setEncoding: (encoding: string) => void, on: Function, off: Function, pause?: Function }} stdin - * @param {{ prompt?: string, stderr?: (text: string) => void }} [options] - */ -function readStdin(stdin, { prompt, stderr } = {}) { - if (stdin.isTTY) return readTtyLine(stdin, { prompt, stderr }); - - return new Promise((resolve, reject) => { - let data = ""; - stdin.setEncoding("utf8"); - stdin.on("data", (chunk) => (data += chunk)); - stdin.on("end", () => resolve(data.replace(/\r?\n$/, ""))); - stdin.on("error", reject); - }); -} - function usageText() { return formatHelp({ usage: [ diff --git a/commands/token.js b/commands/token.js new file mode 100644 index 0000000..0ccd085 --- /dev/null +++ b/commands/token.js @@ -0,0 +1,231 @@ +// The `wdl token` command set: store, list, switch the default for, and remove +// tokens in the global credential store (lib/token-store.js). There is no +// "login" — a token is operator-issued; `set` stores it after validating it +// against /whoami (and makes the first one the default namespace), `use` picks +// which stored namespace is the default when --ns is omitted, and `rm` deletes +// the local copy without revoking it. + +import { defineCommand } from "../lib/command.js"; +import { + CliError, + defineCliOption, + escapeTerminalText, + flagSet, + formatHelp, + isMain, + maskToken, + optionHelp, + readSecretStdin, + resolveControlUrl, + warnIfInsecureControlUrl, + writeResult, + writeStatusLine, +} from "../lib/common.js"; +import { isAdminAcceptableNs } from "../lib/ns-pattern.js"; +import { fetchWhoami, namespaceFromPrincipal } from "../lib/whoami.js"; +import { readTokenStore, tokenStorePath, writeTokenStore } from "../lib/token-store.js"; + +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)."), + // 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)."), + // `endpoint`, not `control`: the token is read from stdin, never a --token flag. + "endpoint", + // Custom json option: `list --json` prints the local store, not a control + // response, so the preset's description would be wrong here. + defineCliOption("json", { type: "boolean" }, "--json", "Print stored entries as JSON (tokens masked)."), + "help", +]; + +const command = defineCommand({ + name: "token", + summary: "Store, list, switch the default for, and remove control-plane tokens locally.", + options: TOKEN_OPTIONS, + autoloadEnv: false, + usage: usageText, + run: runToken, +}); + +export const main = command.main; +export const runTokenCommand = command.run; +export const meta = command.meta; + +/** @param {{ values: Record, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ +async function runToken({ values, positionals, context }) { + const [sub, ...rest] = positionals; + // `use` takes the namespace as a positional (`wdl token use acme`); the + // others take none beyond the subcommand. + if (sub !== "use" && rest.length > 0) throw new CliError(usageText()); + switch (sub) { + case "set": + return tokenSet({ values, context }); + case "list": + return tokenList({ values, context }); + case "use": + if (rest.length > 1) throw new CliError(usageText()); + return tokenUse({ values, context, nsArg: rest[0] }); + case "rm": + return tokenRemove({ values, context }); + default: + throw new CliError(usageText()); + } +} + +async function tokenSet({ values, context }) { + // set/use/rm mutate the global store, so they name the target namespace from + // an explicit --ns only -- never the ambient WDL_NS a user may have exported + // for an unrelated command. + const ns = flagSet(values, "ns") ? values.ns : null; + if (!ns) throw new CliError("token set requires --ns "); + // The namespace becomes a `[section]` key in the store file, so it must match + // the same grammar store/.env sections use (tenant namespaces plus operator- + // reserved `__name__` sections). A value with `]` or newlines (e.g. echoed + // back via --ns from a misconfigured control plane) would otherwise inject + // lines/sections and corrupt the file on the next read. + if (!isAdminAcceptableNs(ns)) { + throw new CliError(`invalid namespace "${escapeTerminalText(ns)}"`); + } + // The control URL comes from flags/shell only, never the store — we are + // writing the store, so it cannot supply its own endpoint. Give a token-set + // message rather than resolveControlUrl's generic "set it in .env" hint, + // which would be backwards here (the store exists to avoid a .env). + if (!values["control-url"] && !context.env.CONTROL_URL) { + throw new CliError( + `token set needs the control URL for ${ns}: pass --control-url (a stored token is scoped to one control plane).` + ); + } + const controlUrl = resolveControlUrl(values, context.env); + // Warn before a plaintext token travels unencrypted, like every other path + // that sends the token. + warnIfInsecureControlUrl(controlUrl, context.warn); + + 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, + // and confirm the token actually belongs to this namespace — otherwise it + // would be stored under [ns] while authenticating as someone else. + const whoami = await fetchWhoami({ + controlUrl, + headers: { "x-admin-token": token }, + controlFetch: context.controlFetch, + }); + const principalNs = namespaceFromPrincipal(whoami.principal); + if (principalNs !== ns) { + throw new CliError( + principalNs + ? `token principal is namespace "${escapeTerminalText(principalNs)}", not "${escapeTerminalText(ns)}" — run with --ns ${escapeTerminalText(principalNs)}` + : `this token is not scoped to namespace "${escapeTerminalText(ns)}"; wdl token stores tenant tokens under their own namespace` + ); + } + + const storePath = tokenStorePath(context.env); + const store = readTokenStore(storePath); + const previous = Object.hasOwn(store.namespaces, ns) ? store.namespaces[ns] : {}; + const wasEmpty = Object.keys(store.namespaces).length === 0; + // defineProperty, not `store.namespaces[ns] = …`: a namespace named "__proto__" + // would otherwise hit the prototype setter and never create an own section + // (mirrors readTokenStore's section creation). + Object.defineProperty(store.namespaces, ns, { + value: { + CONTROL_URL: controlUrl, + ADMIN_TOKEN: token, + LABEL: typeof values.label === "string" ? values.label : previous.LABEL, + }, + writable: true, + enumerable: true, + configurable: true, + }); + // Only the first stored namespace (an empty store) auto-becomes the default, + // or an explicit --default. A later set must NOT silently steal the default + // just because it is currently null — the default may have been deliberately + // cleared by removing it from an ambiguous set. + const becameDefault = Boolean(values.default) || wasEmpty; + if (becameDefault) store.defaultNs = ns; + writeTokenStore(storePath, store); + writeStatusLine(context.stdout, `Stored token for ${ns} @ ${controlUrl} (${maskToken(token)}).`); + if (becameDefault) { + writeStatusLine(context.stdout, `${ns} is now the default namespace (used when --ns is omitted).`); + } +} + +function tokenUse({ values, context, nsArg }) { + // For `use` the WDL_NS fallback is also pointless: it already overrides the + // store default at resolution time, so inheriting it here would only reswitch + // the default to a namespace the user did not name. + const ns = nsArg || (flagSet(values, "ns") ? values.ns : null); + if (!ns) throw new CliError("token use requires a namespace: wdl token use "); + const storePath = tokenStorePath(context.env); + const store = readTokenStore(storePath); + if (!Object.hasOwn(store.namespaces, ns)) { + throw new CliError(`no stored token for namespace "${escapeTerminalText(ns)}" — run \`wdl token set --ns ${escapeTerminalText(ns)}\` first`); + } + store.defaultNs = ns; + writeTokenStore(storePath, store); + writeStatusLine(context.stdout, `Default namespace set to ${ns} (used when --ns is omitted).`); +} + +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), + })); + writeResult(values.json, rows, () => formatTokenList(rows), context.stdout); +} + +function tokenRemove({ values, context }) { + // For `rm` the stakes are highest -- it deletes and rewrites with no + // confirmation -- so it likewise takes an explicit --ns only. + const ns = flagSet(values, "ns") ? values.ns : null; + if (!ns) throw new CliError("token rm requires an explicit --ns "); + const storePath = tokenStorePath(context.env); + const store = readTokenStore(storePath); + if (!Object.hasOwn(store.namespaces, ns)) throw new CliError(`no stored token for namespace "${escapeTerminalText(ns)}"`); + delete store.namespaces[ns]; + // Keep the "a lone stored namespace is the default" invariant after any + // removal: a sole survivor becomes the default even if an earlier removal + // already cleared it; removing the current default from a still-ambiguous set + // clears it (an explicit --ns or `wdl token use` is then needed). + const remaining = Object.keys(store.namespaces); + if (remaining.length === 1) store.defaultNs = remaining[0]; + else if (store.defaultNs === ns) store.defaultNs = null; + writeTokenStore(storePath, store); + writeStatusLine(context.stdout, `Removed the stored token for ${ns}. This does not revoke it on the control plane.`); +} + +// Returns an array of lines; writeResult escapes each one at its choke point. +function formatTokenList(rows) { + if (rows.length === 0) return ["(no stored tokens)"]; + const header = ["", "NAMESPACE", "LABEL", "CONTROL URL", "TOKEN"]; + const cells = [header, ...rows.map((r) => [r.default ? "*" : "", r.namespace, r.label, r.controlUrl, r.token])]; + const widths = header.map((_, col) => Math.max(...cells.map((l) => l[col].length))); + const lines = cells.map((l) => l.map((cell, col) => cell.padEnd(widths[col])).join(" ").trimEnd()); + if (rows.some((r) => r.default)) lines.push("", "* default namespace (used when --ns is omitted)"); + return lines; +} + +function usageText() { + return formatHelp({ + usage: [ + "wdl token set --ns [--control-url ] [--label ] [--default]", + "wdl token list [--json]", + "wdl token use ", + "wdl token rm --ns ", + ], + description: "Store, list, and remove tokens in the local credential store (~/.config/wdl/credentials).", + options: optionHelp(TOKEN_OPTIONS), + }); +} + +if (isMain(import.meta.url)) { + await main(); +} diff --git a/docs/README-zh.md b/docs/README-zh.md index 1fb7c7f..b93b94c 100644 --- a/docs/README-zh.md +++ b/docs/README-zh.md @@ -23,6 +23,7 @@ | 静态资源和 `env.ASSETS.url()` | [assets-zh.md](./assets-zh.md) | | WDL 环境覆盖 `[env.]` 规则 | [env-overrides-zh.md](./env-overrides-zh.md) | | Worker / namespace 运行时 secrets | [secrets-zh.md](./secrets-zh.md) | +| 本地存储控制面 token | [token-zh.md](./token-zh.md) | 组合功能时读多个专题。例如:queue 消费后写状态,读 [queues-zh.md](./queues-zh.md) 和 [kv-zh.md](./kv-zh.md);上传文件后记录索引,读 [r2-zh.md](./r2-zh.md) 和 [d1-zh.md](./d1-zh.md);带静态页面的管理工具,读 [assets-zh.md](./assets-zh.md) 和实际绑定对应的专题。 diff --git a/docs/README.md b/docs/README.md index dc3deb7..6ea6471 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,6 +32,7 @@ English set. | Static assets and `env.ASSETS.url()` | [assets.md](./assets.md) | | WDL `[env.]` override rules | [env-overrides.md](./env-overrides.md) | | Worker / namespace runtime secrets | [secrets.md](./secrets.md) | +| Storing control-plane tokens locally | [token.md](./token.md) | Combining features means reading several topics. For example: writing state after consuming a queue, read [queues.md](./queues.md) and [kv.md](./kv.md); diff --git a/docs/deploy-zh.md b/docs/deploy-zh.md index 38fd47d..babbe4f 100644 --- a/docs/deploy-zh.md +++ b/docs/deploy-zh.md @@ -16,7 +16,7 @@ wrangler 解析顺序是 `WDL_WRANGLER_BIN`、Worker 项目本地 wrangler、CLI 下面例子里,把 `wdl` 当作占位符,替换成你解析出的实际形式。 -## 凭证 —— 一次性配置 `.env` +## 凭证 —— 一次性配置 CLI 需要三个值: @@ -26,11 +26,15 @@ CLI 需要三个值: | `WDL_NS` | 租户命名空间,例如 `acme`、`demo-prod`。 | | `CONTROL_URL` | 控制面地址,由运维方提供(例如 `https://api.wdl.dev`)。CLI 没有内置默认值,必须配置。 | -**推荐路径(每个仓库一次性配置):** 复制 `.env.example` 到 `.env`,填写 `[]` 段。CLI 只读取运行 `wdl` 时所在目录下的 `./.env`(不会向上层目录查找),所以要在放置 `.env` 的目录里执行 `wdl`。之后命令只需要 `--ns `(或 `WDL_NS` 环境变量),token 不会再出现在 shell 历史中。 +**推荐路径:** `wdl token set --ns --control-url ` 在隐藏提示里读取 token、调 `/whoami` 校验后以 `0600` 存入 `~/.config/wdl/credentials` —— 它不会落在任何项目文件或 shell 历史里。第一个存入的 namespace 成为默认,之后 `wdl deploy` 不用再带 `--ns`。一份存储服务这台机器上所有项目;详见 [token-zh.md](./token-zh.md)。 + +**per-repo 备选:** 当某个项目需要锁定自己的 control URL / namespace 时,复制 `.env.example` 到 `.env`,填写 `[]` 段(提交的 `.env.example` 也向队友说明配置形状)。CLI 只读取运行 `wdl` 时所在目录下的 `./.env`(不会向上层目录查找),所以要在放置 `.env` 的目录里执行 `wdl`。token 留在被 gitignore 的 `.env` 里,绝不提交。 + +**CI / 自动化:** 把 `ADMIN_TOKEN`、`CONTROL_URL`、`WDL_NS` 作为环境变量从 CI secret store 注入 —— 不用交互式的 token store,也绝不提交 `.env`。 裸 control host 会自动补 scheme;生产 host 默认 `https://`,本地 `.test` / `.local` 或 `:8080` 默认 `http://`。如果要强制协议,直接显式写 `https://...` 或 `http://...`。 -优先级:`CLI 标志 > shell env > .env 中 [] 段 > .env 基础段`。都没有提供时命令直接报错——没有内置默认值。 +优先级:`CLI 标志 > shell env > .env 中 [] 段 > .env 基础段 > wdl token store`。都没有提供时命令直接报错——没有内置默认值。 不确定最终取了哪个值时,运行 `wdl config explain`;要确认 token 实际连到哪个 control、principal、platform version 和 URL hints,运行 `wdl whoami`;本机与远端基础排查运行 `wdl doctor`。当 control 支持 `/whoami` 时,`doctor` 会验证远端 token、principal namespace、platform version 和 CLI compatibility。 @@ -55,12 +59,12 @@ Worker 看到的路径是**剥掉 `/` 之后的路径**。除非运 | 删除 worker(预览) | `wdl delete worker --dry-run` | | 查看 Workflow 实例 | `wdl workflows instances ` | -只要 `WDL_NS` 通过 env 或 `.env` 设置了,`--ns` 就是可选的。每个子命令都实现了 `--help` —— 不知道用什么 flag 时直接跑。 +只要 `WDL_NS` 通过 env 或 `.env` 设置了,或者 `wdl token` store 有默认 namespace,`--ns` 就是可选的。每个子命令都实现了 `--help` —— 不知道用什么 flag 时直接跑。 ## 标准部署流程 1. **解析 CLI 调用形式**(上文)。 -2. **解析凭证** —— 优先用 `.env`,不要内联环境变量。 +2. **解析凭证** —— 优先用 `.env` 或 `wdl token` store,不要内联环境变量。 3. **wrangler 版本检查。** 打包步骤需要 `wrangler@^4`。如果项目 pin 了 v3,停下,告诉用户 —— 不要默默升级。 4. **安装 worker 依赖**(在 worker 目录下 `npm install`),如果 `node_modules` 不存在。 5. **预创建持久化绑定。** 读 wrangler 配置: @@ -102,7 +106,7 @@ Cron triggers 和 queue consumers 是 runtime dispatch 能力,只应声明在 | 现象 | 原因 / 修复 | | --- | --- | | `wdl: command not found` | CLI 不在 PATH。在 wdl-cli 仓库内用 `node /bin/wdl.js`;其他情况执行 `npm i -g @wdl-dev/cli`。 | -| `Missing admin token` | `ADMIN_TOKEN` 没设,且没传 `--token`。检查 `.env` 的 `[]` 段。 | +| `Missing admin token` | 没解析出 token。运行 `wdl token set --ns --control-url `(推荐),或设 `ADMIN_TOKEN` / 传 `--token` / 用 `.env` 的 `[]` 段。 | | `401 unknown_token: unauthorized` | Token 对这个控制平面 / 命名空间无效。重新检查 `ADMIN_TOKEN`。 | | `[vars] must be an object` | 用 `[vars]` 表/对象;数组不合法。 | | `[vars] : only string/number/boolean values are supported` | 移除嵌套值;敏感字符串改用 secret。 | diff --git a/docs/deploy.md b/docs/deploy.md index c511ca2..57974ab 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -25,7 +25,7 @@ Pick one in this order: In the examples below, treat `wdl` as a placeholder and substitute the form you resolved. -## Credentials — one-time `.env` setup +## Credentials — one-time setup The CLI needs three values: @@ -35,18 +35,31 @@ The CLI needs three values: | `WDL_NS` | Tenant namespace, e.g. `acme`, `demo-prod`. | | `CONTROL_URL` | Control-plane URL, provided by your operator (e.g. `https://api.wdl.dev`). The CLI has no built-in default; it must be configured. | -**Recommended path (one-time per repo):** copy `.env.example` to `.env` and fill -in the `[]` section. 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 -the `.env`. After that, commands only need `--ns ` (or the `WDL_NS` -environment variable), and the token never appears in shell history again. +**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 +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). + +**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 +`.env`, never committed. + +**CI / automation:** inject `ADMIN_TOKEN`, `CONTROL_URL`, and `WDL_NS` as +environment variables from your CI secret store — not the interactive token +store, and never a committed `.env`. Bare control hosts get a scheme automatically; production hosts default to `https://`, local `.test` / `.local` or `:8080` hosts default to `http://`. To force a protocol, write `https://...` or `http://...` explicitly. -Precedence: `CLI flag > shell env > .env [] section > .env base section`. 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. When unsure which value won, run `wdl config explain`; to confirm which control the token actually reaches, plus the principal, platform version, and URL hints, @@ -78,13 +91,15 @@ not add `route` / `routes` in a first-time setup. | 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`. 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** — prefer `.env`; do not inline environment variables. +2. **Resolve credentials** — prefer `.env` or the `wdl token` store; do not + inline environment variables. 3. **Wrangler version check.** The bundling step requires `wrangler@^4`. If the project pins v3, stop and tell the user — do not silently upgrade. 4. **Install worker dependencies** (`npm install` in the worker directory) if @@ -160,7 +175,7 @@ Deleting a worker does **not** delete R2 data — see [r2.md](./r2.md). | 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` | `ADMIN_TOKEN` is not set and `--token` was not passed. Check the `[]` section of `.env`. | +| `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. | diff --git a/docs/token-zh.md b/docs/token-zh.md new file mode 100644 index 0000000..25c0e30 --- /dev/null +++ b/docs/token-zh.md @@ -0,0 +1,78 @@ +# Tokens —— `wdl token` 参考 + +[English](./token.md) | 中文 + +## 是什么 + +`wdl token` 管理本地凭证存储 `~/.config/wdl/credentials`(`$XDG_CONFIG_HOME/wdl/credentials`,Windows 上为 `%APPDATA%\wdl\credentials`),让命令无需每个 shell 都 export `ADMIN_TOKEN`、也无需在每个项目的 `.env` 里放 token 就能解析出 control URL 和 token。 + +没有"登录"这回事。WDL token 由运维方签发;`wdl token set` 只是把它存起来(存前会调 `/whoami` 校验,并确认其 principal 就是你要存入的那个 namespace),`wdl token rm` 删的是本地副本——**不会吊销** token。 + +存储用的是和项目 `.env` 相同的 `dotenv`/INI 方言,按 namespace 为 key,每条自包含。开头(任何段之前)的一行 `WDL_NS` 指定**默认 namespace**——即不带 `--ns` 时使用的那个,和项目 `.env` 里的 base `WDL_NS` 行为完全一致: + +```ini +WDL_NS="acme" + +[acme] +CONTROL_URL="https://api.example" +ADMIN_TOKEN="" +LABEL="production" +``` + +它由命令独占:`wdl token` 会 canonical 重写整个文件(默认在前,然后排序、加引号的各段),所以项目专属的值请手编项目 `.env`。文件以 `0600` 权限写入。 + +## 命令 + +```bash +# 存 token。token 从 stdin 读(TTY 下隐藏输入)、调 /whoami 校验、确认属于 --ns 后再存。 +# control URL 来自 --control-url 或 CONTROL_URL —— 绝不来自存储本身。 +# 第一个存入的 namespace 自动成为默认;--default 可把任意一次 set 设为默认。 +wdl token set --ns acme --control-url https://api.example +wdl token set --ns acme --control-url https://api.example --label production +wdl token set --ns demo --control-url https://api.example --default +printf '%s' "$TOKEN" | wdl token set --ns acme --control-url https://api.example + +# 列出已存的 namespace 和脱敏 token;默认那个用 * 标记 +# (--json 供脚本用,每行带 "default" 布尔字段,仍脱敏)。 +wdl token list + +# 选择哪个已存 namespace 作为默认(不带 --ns 时使用)。 +wdl token use acme + +# 删除某 namespace 的本地副本(不会在控制面吊销)。 +wdl token rm --ns acme +``` + +## 在解析链中的位置 + +存储是优先级最低的凭证层: + +``` +CLI 标志 > shell/CI env > 项目 ./.env > 全局 token 存储 > 未配置(报错) +``` + +更高层的值总是胜出,存储只填空缺。解析按 namespace 进行:选中某条后,它同时提供 control URL 和 token。当某个值来自存储时,`wdl config explain` 会把来源显示为 `token store [].…`。 + +**选哪个 namespace** 走它自己的链,存储默认在最底层——和项目 `.env` 的 base `WDL_NS` 同形,只低一层: + +``` +--ns > shell/CI WDL_NS > 项目 ./.env 的 WDL_NS > 存储默认(base WDL_NS) +``` + +所以设了存储默认后,`wdl deploy`、`wdl doctor` 等不带 `--ns` 也能跑;要换别的就传 `--ns`(或 `wdl token use `)。当 namespace 来自存储默认时,`wdl config explain` 把来源显示为 `token store default`。 + +`wdl token` 子命令是这条链的例外:`set`、`use`、`rm` 会改动存储,所以它们只从显式 `--ns`(或 `use` 的位置参数)取 namespace —— 绝不取 ambient `WDL_NS` —— 以免一个游离的 shell 值写错、切错或删错条目。 + +存储是**可信**的(它在你的 home 目录、由你经 `wdl token` 写入,token 和端点同源)。项目 `.env` **不可信**:若一个 `.env` 提供了 control 端点却没同时提供 token,该端点仍会被丢弃——这样不可信的项目目录永远无法把你存的 token 重定向到它指定的主机。 + +## 反模式 + +- ❌ 把 `wdl token rm` 当吊销。它只删本地副本;在运维方吊销之前 token 仍然有效。 +- ❌ 手编 `~/.config/wdl/credentials`。下次 `wdl token` 写入时会被 canonical 重写,你的改动(含注释)会丢失。手管的覆盖值请用项目 `.env`。 +- ❌ 把 token 作为命令行参数传。`set` 从 stdin 读,避免进入 shell 历史——在提示符输入或用管道传入。 +- ❌ 指望存储能覆盖 shell 或项目 `.env` 里已设的 token。它是最低层,只填空缺。 + +## 相关 + +- [deploy-zh.md](./deploy-zh.md) —— `ADMIN_TOKEN` / `CONTROL_URL` 的优先级,以及存储所处其下的 `.env` 结构。 +- [secrets-zh.md](./secrets-zh.md) —— `wdl secret`,管理 worker 的运行时密钥(与这里管理的部署 token 是两回事)。 diff --git a/docs/token.md b/docs/token.md new file mode 100644 index 0000000..39edc29 --- /dev/null +++ b/docs/token.md @@ -0,0 +1,112 @@ +# Tokens — `wdl token` reference + +English | [中文](./token-zh.md) + +## What it is + +`wdl token` manages a local credential store at `~/.config/wdl/credentials` +(`$XDG_CONFIG_HOME/wdl/credentials`, or `%APPDATA%\wdl\credentials` on Windows) +so commands resolve a control URL and token without a per-shell `ADMIN_TOKEN` +export or a token in every project's `.env`. + +There is no "login". A WDL token is issued by your operator; `wdl token set` +just stores it (after checking it against `/whoami` and confirming its principal +is the namespace you are storing it under), and `wdl token rm` deletes the local +copy — it does not revoke the token. + +The store is the same `dotenv`/INI dialect a project `.env` uses, keyed by +namespace, with each entry self-contained. A base `WDL_NS` line (before any +section) names the default namespace — the one used when you do not pass `--ns` +— exactly as a base `WDL_NS` works in a project `.env`: + +```ini +WDL_NS="acme" + +[acme] +CONTROL_URL="https://api.example" +ADMIN_TOKEN="" +LABEL="production" +``` + +It is command-owned: `wdl token` rewrites it canonically (default first, then +sorted, quoted sections), so hand-edit a project `.env` for project-specific +values instead. The file is written with `0600` permissions. + +## Commands + +```bash +# Store a token. The token is read from stdin (hidden on a TTY), validated +# against /whoami, checked to belong to --ns, then stored. The control URL comes +# from --control-url or CONTROL_URL — never from the store itself. The first +# stored namespace becomes the default; --default makes any set the default. +wdl token set --ns acme --control-url https://api.example +wdl token set --ns acme --control-url https://api.example --label production +wdl token set --ns demo --control-url https://api.example --default +printf '%s' "$TOKEN" | wdl token set --ns acme --control-url https://api.example + +# List stored namespaces with masked tokens; the default is marked with * +# (--json for scripting; each row carries a "default" boolean, still masked). +wdl token list + +# Choose which stored namespace is the default (used when --ns is omitted). +wdl token use acme + +# Remove the local copy for a namespace (does not revoke on the control plane). +wdl token rm --ns acme +``` + +## Where it sits in resolution + +The store is the lowest-precedence credential layer: + +``` +CLI flag > shell/CI env > project ./.env > global token store > unset (error) +``` + +A value from a higher layer always wins; the store only fills gaps. Resolution +is per namespace: a namespace selects the entry, which supplies both the control +URL and the token. `wdl config explain` shows `token store [].…` as the +source when a value came from the store. + +Which namespace is selected follows its own chain, with the store's default at +the bottom — the same shape, one layer lower than a project `.env`'s base +`WDL_NS`: + +``` +--ns > shell/CI WDL_NS > project ./.env WDL_NS > store default (base WDL_NS) +``` + +So with a stored default you can run `wdl deploy`, `wdl doctor`, etc. without +`--ns`; pass `--ns` (or `wdl token use `) to pick a different one. When the +namespace comes from the store default, `wdl config explain` shows the source as +`token store default`. + +The `wdl token` subcommands are the exception to that chain: `set`, `use`, and +`rm` mutate the store, so they take the namespace from an explicit `--ns` (or +`use`'s positional) only — never the ambient `WDL_NS` — so a stray shell value +can't write, switch, or delete the wrong entry. + +The store is trusted (it lives in your home directory and you wrote it via +`wdl token`, so its token and endpoint are same-source). A project `.env` is +not: a `.env` that supplies a control endpoint without also supplying the token +is still dropped, so an untrusted project directory can never redirect your +stored token to a host it chose. + +## Anti-patterns + +- ❌ Treating `wdl token rm` as revocation. It deletes the local copy only; the + token still works until your operator revokes it. +- ❌ Hand-editing `~/.config/wdl/credentials`. It is rewritten on the next + `wdl token` write and your edits (including comments) are lost. Use a project + `.env` for hand-managed overrides. +- ❌ Passing the token as a command-line argument. `set` reads it from stdin so + it stays out of shell history; type it at the prompt or pipe it in. +- ❌ Expecting the store to override a token already set in your shell or a + project `.env`. It is the lowest layer and only fills gaps. + +## Related + +- [deploy.md](./deploy.md) — `ADMIN_TOKEN` / `CONTROL_URL` precedence and the + `.env` layout the store sits beneath. +- [secrets.md](./secrets.md) — `wdl secret`, for a worker's runtime secrets (a + different thing from the deploy token managed here). diff --git a/lib/command.js b/lib/command.js index 60efb00..f282d90 100644 --- a/lib/command.js +++ b/lib/command.js @@ -29,7 +29,7 @@ import { * Flag-preset names accepted in a command's `options` list; each expands to * the matching shared option specs: * "ns" -> --ns - * "control" -> --control-url, --admin (alias), --token + * "control" -> --control-url, --token * "env" -> --env * "json" -> --json * "yes" -> --yes diff --git a/lib/common.js b/lib/common.js index ddecee4..025f37f 100644 --- a/lib/common.js +++ b/lib/common.js @@ -5,16 +5,22 @@ import { isAdminAcceptableNs } from "./ns-pattern.js"; // Control-plane endpoint keys: where the admin token gets sent. A cwd .env // must not redirect these for a token that came from the shell/--token. -const CONTROL_ENDPOINT_KEYS = ["CONTROL_URL", "ADMIN_URL", "CONTROL_CONNECT_HOST"]; +const CONTROL_ENDPOINT_KEYS = ["CONTROL_URL", "CONTROL_CONNECT_HOST"]; export const CLI_DOTENV_KEYS = new Set([ "ADMIN_TOKEN", - "ADMIN_URL", "CONTROL_CONNECT_HOST", "CONTROL_URL", "WDL_NS", ]); +// Keys deleted from the Wrangler child env (lib/wrangler/command.js) so WDL +// control-plane config never reaches the bundler or the build scripts Wrangler +// runs. A superset of CLI_DOTENV_KEYS that also strips the legacy ADMIN_URL +// alias: it is no longer READ for config, but a user may still have it +// exported, and dropping an alias as an input must not turn it into a leak. +export const WRANGLER_SCRUB_KEYS = new Set([...CLI_DOTENV_KEYS, "ADMIN_URL"]); + export class CliError extends Error { constructor(message, exitCode = 1) { super(message); @@ -50,7 +56,7 @@ export function commonCliOptions({ namespace = true, controlUrl = true, token = export function commonCliOptionSpecs({ namespace = true, controlUrl = true, token = true, json = false, help = true } = {}) { const specs = []; if (namespace) specs.push(OPTION_DEFS.ns); - if (controlUrl) specs.push(OPTION_DEFS.controlUrl, OPTION_DEFS.admin); + if (controlUrl) specs.push(OPTION_DEFS.controlUrl); if (token) specs.push(OPTION_DEFS.token); if (json) specs.push(OPTION_DEFS.json); if (help) specs.push(OPTION_DEFS.help); @@ -77,8 +83,7 @@ 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; ADMIN_URL accepted for compatibility)."), - admin: defineCliOption("admin", { type: "string" }, "--admin ", "Alias for --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."), @@ -88,7 +93,10 @@ const OPTION_DEFS = { const CLI_OPTION_PRESETS = { ns: [OPTION_DEFS.ns], env: [OPTION_DEFS.env], - control: [OPTION_DEFS.controlUrl, OPTION_DEFS.admin, OPTION_DEFS.token], + control: [OPTION_DEFS.controlUrl, OPTION_DEFS.token], + // Control-plane endpoint flags without --token, for commands that read the + // token elsewhere (e.g. `wdl token set` reads it from stdin). + endpoint: [OPTION_DEFS.controlUrl], json: [OPTION_DEFS.json], yes: [OPTION_DEFS.yes], help: [OPTION_DEFS.help], @@ -164,12 +172,7 @@ function isParseArgsError(err) { } export function resolveControlUrl(values, env = process.env) { - const raw = ( - values["control-url"] || - values.admin || - env.CONTROL_URL || - env.ADMIN_URL - ); + const raw = values["control-url"] || env.CONTROL_URL; // 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) { @@ -218,7 +221,7 @@ function defaultSchemeForBareControlUrl(text) { export function resolveControlContext(values, env = process.env) { const token = values.token || env.ADMIN_TOKEN; if (!token) { - throw new CliError("Missing admin token. Pass --token or set ADMIN_TOKEN env."); + 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), @@ -274,6 +277,24 @@ function firstNonEmptyString(...values) { return undefined; } +// A CLI flag counts as "set" only when it holds a non-empty string: an empty +// `--token ""` (or a boolean/missing flag) falls back to env, and the +// cross-origin guard / store gap-fill must tell "flag supplied" from "present +// but empty". +export function flagSet(values, name) { + return typeof values[name] === "string" && values[name].length > 0; +} + +// Shell/CI env wins over `.env`, but only when actually set. A value that is not +// a non-empty string (e.g. WDL_NS="" in CI, or undefined on an injected env +// object) counts as absent everywhere else here — like firstNonEmptyString and +// flagSet — so it must not "protect" its key: protecting an unset value would +// block the `.env` value AND then let it fall through to a lower-precedence store +// default, inverting the `.env` > store-default order. +export function protectedEnvKeys(env) { + return new Set(Object.keys(env).filter((key) => typeof env[key] === "string" && env[key] !== "")); +} + export function loadCliDotEnv( env = process.env, path = ".env", @@ -290,7 +311,7 @@ export function loadCliDotEnv( const { resolvedNs, loadBase = true, - protectedKeys = new Set(Object.keys(env)), + protectedKeys = protectedEnvKeys(env), warn = (message) => console.warn(`warning: ${message}`), onLoad = null, } = options; @@ -348,19 +369,23 @@ export function loadCliDotEnv( * dotenvPath?: string, * nsFromFlag?: string, * tokenFromFlag?: boolean, + * controlUrlFromFlag?: boolean, * protectedKeys?: Set, * loadEnv?: typeof loadCliDotEnv, + * readStore?: (env: NodeJS.ProcessEnv) => { defaultNs?: string | null, namespaces?: Record> }, * warn?: (message: string) => void, * onCrossOrigin?: (line: string) => void, - * onLoad?: (entry: { key: string, value: string, section: string | null, line: number }) => void, + * 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, - protectedKeys = new Set(Object.keys(env)), + controlUrlFromFlag = false, + protectedKeys = protectedEnvKeys(env), loadEnv = loadCliDotEnv, + readStore = () => ({}), warn, onCrossOrigin = (line) => console.error(line), onLoad, @@ -372,11 +397,84 @@ export function loadCliControlEnv(env, { if (Array.isArray(result)) for (const key of result) loaded.add(key); }; record(loadEnv(env, dotenvPath, { protectedKeys, onLoad, warn })); - const ns = firstNonEmptyString(nsFromFlag, env.WDL_NS); + + // The global store is the lowest-precedence, optional layer, so read it + // lazily — only when it can actually contribute. Reading it eagerly would let + // a corrupt or unreadable ~/.config/wdl/credentials abort a command whose + // namespace and credentials already came from flags / shell / .env, with no + // way to work around it. Memoize so the at-most-one read is shared. + let storeData; + 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 + // source for *which* namespace to use, below --ns and shell/.env WDL_NS, and + // only when that default actually has a stored entry. Materialize it into env + // so the rest of the pipeline (control-URL resolution, the [ns] overlay, + // resolveNamespace in callers) sees the same namespace an explicit one would. + if (!ns) { + // The default namespace is the lowest-precedence, OPTIONAL namespace source, + // so a corrupt/unreadable store must not block a command that needs no + // namespace (e.g. `wdl whoami --control-url … --token …`, which gets it from + // /whoami). Tolerate a read failure here as "no default"; the gap-fill read + // below stays strict, so a store that is the actual credential source still + // surfaces its corruption. + let s; + try { + s = getStore(); + } catch { + // corrupt/unreadable store → no usable default; do not block the command + } + const namespaces = (s && s.namespaces) || {}; + const def = s && typeof s.defaultNs === "string" ? s.defaultNs : null; + if (def && Object.hasOwn(namespaces, def)) { + ns = def; + if (env.WDL_NS == null || env.WDL_NS === "") { + env.WDL_NS = ns; + if (onLoad) onLoad({ key: "WDL_NS", value: ns, section: ns, line: 0, origin: "store-default" }); + } + } + } + if (ns) { record(loadEnv(env, dotenvPath, { resolvedNs: ns, loadBase: false, protectedKeys, onLoad, warn })); } + // Drop untrusted project-.env endpoints BEFORE filling from the global store, + // so a dropped endpoint's slot is filled by the trusted store rather than + // staying shadowed by what the guard just removed. guardCrossOriginControlEnv(env, loaded, tokenFromFlag, onCrossOrigin); + // The store is trusted (you wrote it via `wdl token`, token + endpoint + // same-source) and not itself subject to the cross-origin guard, so it fills + // the gaps left by flags / shell / project .env / the guard — but only for a + // slot still empty AND not supplied by a flag (resolved later). That keeps + // the store unread when the credentials are already covered. + if (ns) { + const covered = { CONTROL_URL: controlUrlFromFlag, ADMIN_TOKEN: tokenFromFlag }; + const needsFill = STORE_ENV_KEYS.some((k) => !covered[k] && (env[k] == null || env[k] === "")); + if (needsFill) fillFromTokenStore(env, ns, getStore().namespaces || {}, onLoad, covered); + } +} + +// Only the control-plane endpoint and token are materialized into env from a +// store section; LABEL is store-only metadata for `wdl token list`. +const STORE_ENV_KEYS = ["CONTROL_URL", "ADMIN_TOKEN"]; + +/** @param {Record} [covered] */ +function fillFromTokenStore(env, ns, namespaces, onLoad, covered = {}) { + // hasOwn, not namespaces[ns]: a namespace named like an Object.prototype key + // (e.g. "constructor") must not resolve to an inherited member. + if (!Object.hasOwn(namespaces, ns)) return; + const entry = namespaces[ns]; + for (const key of STORE_ENV_KEYS) { + // A flag supplies this slot (resolved later); don't shadow it in env with + // the store value, which would mislead anyone reading env[key] directly. + if (covered[key]) continue; + const value = entry[key]; + if (value == null || value === "") continue; + if (env[key] != null && env[key] !== "") continue; // gap-fill only + env[key] = value; + if (onLoad) onLoad({ key, value, section: ns, line: 0, origin: "store" }); + } } // A control endpoint from a cwd .env is only trusted when the EFFECTIVE token @@ -388,7 +486,14 @@ export function loadCliControlEnv(env, { // .env endpoint (resolution falls back to shell/default) and warn. Same-source // .env (token + URL together, single-tenant) and shell-sourced URLs are fine. function guardCrossOriginControlEnv(env, loadedFromDotenv, tokenFromFlag, onCrossOrigin) { - const tokenIsFromDotenv = loadedFromDotenv.has("ADMIN_TOKEN") && !tokenFromFlag; + // A NON-EMPTY .env token, not merely a loaded `ADMIN_TOKEN=` key: an empty + // placeholder would otherwise mark the .env endpoint same-source while the + // real token gets gap-filled from the global store afterwards — letting an + // untrusted .env redirect a STORED token to a host it chose. + const tokenIsFromDotenv = + loadedFromDotenv.has("ADMIN_TOKEN") && + typeof env.ADMIN_TOKEN === "string" && env.ADMIN_TOKEN.length > 0 && + !tokenFromFlag; if (tokenIsFromDotenv) return; for (const key of CONTROL_ENDPOINT_KEYS) { if (!loadedFromDotenv.has(key)) continue; @@ -401,7 +506,7 @@ function guardCrossOriginControlEnv(env, loadedFromDotenv, tokenFromFlag, onCros } } -function parseDotEnvSection(line, lineNumber) { +export function parseDotEnvSection(line, lineNumber) { if (!line.startsWith("[")) return null; const match = line.match(/^\[([^\]]*)\]\s*(?:#.*)?$/); if (!match) { @@ -428,7 +533,7 @@ export async function confirmAction({ } = {}) { if (yes) return; if (!stdin.isTTY) { - throw new CliError(`Refusing to ${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 }); @@ -437,17 +542,29 @@ export async function confirmAction({ } /** - * @param {{ setEncoding: (encoding: string) => void, on: Function, off: Function, pause?: Function }} stdin - * @param {{ prompt?: string, stderr?: (text: string) => void }} [options] + * @param {{ isTTY?: boolean, setEncoding: (encoding: string) => void, setRawMode?: (mode: boolean) => void, on: Function, off: Function, pause?: Function }} stdin + * @param {{ prompt?: string, stderr?: (text: string) => void, hidden?: boolean }} [options] */ -export function readTtyLine(stdin, { prompt, stderr } = {}) { +export function readTtyLine(stdin, { prompt, stderr, hidden = false } = {}) { return new Promise((resolve, reject) => { let data = ""; + // Hidden input needs raw mode: a cooked TTY echoes keystrokes, so a token + // typed at the prompt would land in the terminal and scrollback. Raw mode + // disables echo and line editing, so we accumulate characters and handle + // newline / backspace / Ctrl-C ourselves, echoing nothing. Hidden input + // fails closed — if raw mode can't be enabled we reject rather than + // silently echo a secret, leaving the caller to fall back to a pipe. + const wantHidden = hidden && stdin.isTTY === true; + let raw = false; const cleanup = () => { stdin.off("data", onData); stdin.off("end", onEnd); stdin.off("error", onError); + if (raw) { + 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(); }; @@ -455,27 +572,71 @@ export function readTtyLine(stdin, { prompt, stderr } = {}) { cleanup(); resolve(value); }; - - const onData = (chunk) => { - data += chunk; - const newline = data.search(/\r?\n/); - if (newline !== -1) finish(data.slice(0, newline)); - }; - const onEnd = () => finish(data.replace(/\r?\n$/, "")); - const onError = (err) => { + const fail = (err) => { cleanup(); reject(err); }; + const onData = (chunk) => { + if (!raw) { + data += chunk; + const newline = data.search(/\r?\n/); + if (newline !== -1) finish(data.slice(0, newline)); + return; + } + for (const ch of chunk) { + 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 + data += ch; + } + }; + const onEnd = () => finish(raw ? data : data.replace(/\r?\n$/, "")); + const onError = (err) => fail(err); + stdin.setEncoding("utf8"); - if (prompt && stderr) stderr(prompt); + if (wantHidden) { + 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); + raw = true; + } catch { + return failClosed(); + } + } + // Single write point for every prompt: escape it here so callers can + // interpolate raw values (ns, keys, URLs) without per-field escaping, and a + // control byte in a user-supplied arg can't reach the terminal via a prompt. + if (prompt && stderr) stderr(escapeTerminalText(prompt)); stdin.on("data", onData); stdin.on("end", onEnd); stdin.on("error", onError); }); } -function parseDotEnvValue(value) { +// Read a single secret value from stdin: a TTY prompts with hidden (raw-mode, +// non-echoing) input; a pipe/redirect is read to EOF with one trailing newline +// trimmed, so `printf '%s' "$SECRET" | …` works. Shared by `wdl token set` and +// `wdl secret put`. +/** + * @param {{ isTTY?: boolean, setEncoding: (encoding: string) => void, setRawMode?: (mode: boolean) => void, on: Function, off: Function, pause?: Function }} stdin + * @param {{ prompt?: string, stderr?: (text: string) => void }} [options] + */ +export function readSecretStdin(stdin, { prompt, stderr } = {}) { + if (stdin.isTTY) return readTtyLine(stdin, { prompt, stderr, hidden: true }); + return new Promise((resolve, reject) => { + let data = ""; + stdin.setEncoding("utf8"); + stdin.on("data", (chunk) => (data += chunk)); + stdin.on("end", () => resolve(data.replace(/\r?\n$/, ""))); + stdin.on("error", reject); + }); +} + +export function parseDotEnvValue(value) { if (!value) return ""; const quote = value[0]; if (quote === "\"" || quote === "'") { @@ -490,16 +651,50 @@ function parseDotEnvValue(value) { const inner = value.slice(1, end); if (quote === "'") return inner; - return inner - .replaceAll("\\n", "\n") - .replaceAll("\\r", "\r") - .replaceAll("\\t", "\t") - .replaceAll('\\"', "\"") - .replaceAll("\\\\", "\\"); + return unescapeDoubleQuoted(inner); } return value.replace(/\s+#.*$/, ""); } +// Serialize a value into the double-quoted dialect — the inverse of +// parseDotEnvValue / unescapeDoubleQuoted, kept beside them so the round-trip +// invariant stays in one place. Backslash is escaped first so a value with +// quotes / newlines / tabs survives a read→write→read round trip. Used by the +// token store writer (a project `.env` is only ever read, never written). +export function quoteValue(value) { + const escaped = String(value) + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"') + .replaceAll("\n", "\\n") + .replaceAll("\r", "\\r") + .replaceAll("\t", "\\t"); + return `"${escaped}"`; +} + +// Single-pass unescape for the double-quoted dialect. A left-to-right scan is +// required: chaining replaceAll("\\n", "\n") before replaceAll("\\\\", "\\") +// would turn an escaped backslash followed by a literal "n" (stored as "\\n") +// into a newline, corrupting any value that legitimately contains a backslash +// (e.g. a token). The inverse of quoteValue above. +function unescapeDoubleQuoted(s) { + let out = ""; + for (let i = 0; i < s.length; i += 1) { + if (s[i] === "\\" && i + 1 < s.length) { + const next = s[i + 1]; + i += 1; + 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 out += "\\" + next; // preserve unknown escapes verbatim + } else { + out += s[i]; + } + } + return out; +} + function findClosingQuote(value, quote) { for (let i = 1; i < value.length; i += 1) { if (value[i] !== quote) continue; @@ -638,6 +833,16 @@ export function escapeTerminalText(value) { return escapeControlChars(text, false); } +// Mask a token for display: `****` plus the last 4 chars, but only when that +// reveals at most half the token (short tokens show no suffix). "(unset)" for +// an empty/absent token. +export function maskToken(token) { + if (!token) return "(unset)"; + const text = String(token); + const suffix = text.length <= 8 ? "" : text.slice(-4); + return `****${suffix}`; +} + // Shared escape walk. keepLayout leaves tab/newline intact for human output. function escapeControlChars(text, keepLayout) { let out = ""; @@ -685,9 +890,15 @@ export function shellSingleQuote(value) { return `'${String(value).replaceAll("'", `'\\''`)}'`; } +// Canonical machine-JSON output: one place defines the format so the JSON from +// writeResult / writeJsonOr can't drift apart. +export function writeJson(stdout, body) { + stdout(JSON.stringify(body, null, 2)); +} + export function writeResult(json, body, format, stdout) { if (json) { - stdout(JSON.stringify(body, null, 2)); + writeJson(stdout, body); return; } // Choke point: every human-readable command output flows through here, so @@ -696,6 +907,25 @@ export function writeResult(json, body, format, stdout) { for (const line of format()) stdout(escapeTerminalLines(line)); } +// Choke point for one-off human status lines — the non-JSON analogue of +// writeResult. Callers interpolate raw values and this escapes the assembled +// line once, so no interpolated field can be forgotten. escapeTerminalText (not +// -Lines): a status line is single-line, so an embedded newline is neutralized +// rather than allowed to split the line. +export function writeStatusLine(stdout, line) { + stdout(escapeTerminalText(line)); +} + +// The `--json` half of a compound command's output: emit the body as machine +// JSON (left raw, like writeResult) and return true so the caller early-returns; +// return false otherwise to let it write human status lines. Keeps the json +// branch out of every subcommand. +export function writeJsonOr(json, body, stdout) { + if (!json) return false; + writeJson(stdout, body); + return true; +} + export function encodePath(segment) { return encodeURIComponent(segment); } diff --git a/lib/config-state.js b/lib/config-state.js index 7f7614f..c28aafc 100644 --- a/lib/config-state.js +++ b/lib/config-state.js @@ -1,5 +1,6 @@ import path from "node:path"; -import { CliError, loadCliControlEnv, resolveControlUrl, resolveNamespace } from "./common.js"; +import { CliError, flagSet, loadCliControlEnv, maskToken, protectedEnvKeys, resolveControlUrl, resolveNamespace } from "./common.js"; +import { readTokenStore, tokenStorePath } from "./token-store.js"; /** * @param {{ @@ -12,15 +13,22 @@ import { CliError, loadCliControlEnv, resolveControlUrl, resolveNamespace } from */ export function resolveCliConfigState({ values = {}, env = process.env, cwd = process.cwd(), dotenvPath = ".env", warn = () => {} } = {}) { const workingEnv = { ...env }; - const protectedKeys = new Set(Object.keys(env)); + // The loader's helper, not a raw Object.keys set: diagnostics must resolve what + // an operating command would. See protectedEnvKeys. + const protectedKeys = protectedEnvKeys(env); const sources = new Map(); for (const key of Object.keys(env)) sources.set(key, `${key} env`); const resolvedDotenvPath = path.resolve(cwd, dotenvPath); - const recordDotenvLoad = ({ key, section }) => { - const label = section === null - ? `.env ${key}` - : `.env [${section}].${key}`; + /** @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}`; sources.set(key, label); }; @@ -31,8 +39,10 @@ export function resolveCliConfigState({ values = {}, env = process.env, cwd = pr loadCliControlEnv(workingEnv, { dotenvPath: resolvedDotenvPath, nsFromFlag: values.ns, - tokenFromFlag: typeof values.token === "string" && values.token.length > 0, + tokenFromFlag: flagSet(values, "token"), + controlUrlFromFlag: flagSet(values, "control-url"), protectedKeys, + readStore: (e) => readTokenStore(tokenStorePath(e)), onLoad: recordDotenvLoad, warn: () => {}, onCrossOrigin: warn, @@ -88,10 +98,8 @@ function sourceForNamespace(values, env, sources) { } function sourceForControlUrl(values, env, sources) { - if (typeof values["control-url"] === "string" && values["control-url"].length > 0) return "--control-url"; - if (typeof values.admin === "string" && values.admin.length > 0) return "--admin"; + if (flagSet(values, "control-url")) return "--control-url"; if (typeof env.CONTROL_URL === "string" && env.CONTROL_URL.length > 0) return sources.get("CONTROL_URL") || "CONTROL_URL env"; - if (typeof env.ADMIN_URL === "string" && env.ADMIN_URL.length > 0) return sources.get("ADMIN_URL") || "ADMIN_URL env"; return null; } @@ -100,7 +108,7 @@ function tokenValue(values, env) { } function sourceForToken(values, env, sources) { - if (typeof values.token === "string" && values.token.length > 0) return "--token"; + if (flagSet(values, "token")) return "--token"; if (typeof env.ADMIN_TOKEN === "string" && env.ADMIN_TOKEN.length > 0) return sources.get("ADMIN_TOKEN") || "ADMIN_TOKEN env"; return null; } @@ -111,11 +119,3 @@ function firstString(...values) { } return null; } - -export function maskToken(token) { - if (!token) return "(unset)"; - const text = String(token); - // Only show a suffix when it reveals at most half of the token. - const suffix = text.length <= 8 ? "" : text.slice(-4); - return `****${suffix}`; -} diff --git a/lib/token-store.js b/lib/token-store.js new file mode 100644 index 0000000..c99c7a7 --- /dev/null +++ b/lib/token-store.js @@ -0,0 +1,129 @@ +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { CliError, parseDotEnvSection, parseDotEnvValue, quoteValue } from "./common.js"; + +// The global credential store is the lowest-precedence layer of the same +// credential model as a project `.env`: a `dotenv`/INI-subset file in the +// user's config dir, command-managed by `wdl token`. Each `[namespace]` +// section is self-contained (its own control URL + token) because different +// namespaces can live on different control planes. `LABEL` is an optional +// human note shown by `wdl token list`. +// +// A base `WDL_NS` line (before any section) names the default namespace, used +// when no --ns/WDL_NS selects one — the store's analogue of a base WDL_NS in a +// project `.env`. The parsed store is `{ defaultNs, namespaces }`. +const STORE_KEYS = ["CONTROL_URL", "ADMIN_TOKEN", "LABEL"]; + +// Resolve the per-user config directory: %APPDATA%\wdl on Windows, else +// $XDG_CONFIG_HOME/wdl or ~/.config/wdl. `homedir` is injectable for tests. +export function tokenStoreDir(env = process.env, homedir = os.homedir) { + if (process.platform === "win32" && env.APPDATA) { + return path.join(env.APPDATA, "wdl"); + } + const base = + typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.length > 0 + ? env.XDG_CONFIG_HOME + : path.join(homedir(), ".config"); + return path.join(base, "wdl"); +} + +export function tokenStorePath(env = process.env, homedir = os.homedir) { + return path.join(tokenStoreDir(env, homedir), "credentials"); +} + +// Parse the store into { defaultNs, namespaces: { ns: { CONTROL_URL, +// ADMIN_TOKEN, LABEL } } } using the same section/value dialect primitives as +// project `.env`, so the two formats never diverge. A missing file is an empty +// store ({ defaultNs: null, namespaces: {} }). +/** @returns {{ defaultNs: string | null, namespaces: Record> }} */ +export function readTokenStore(storePath) { + let text; + try { + text = readFileSync(storePath, "utf8"); + } catch (err) { + if (err && err.code === "ENOENT") return { defaultNs: null, namespaces: {} }; + throw err; + } + + /** @type {Record>} */ + const namespaces = {}; + /** @type {string | null} */ + let defaultNs = null; + let section = null; + for (const [idx, rawLine] of text.replace(/^\uFEFF/, "").split(/\r?\n/).entries()) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + + const nextSection = parseDotEnvSection(line, idx + 1); + if (nextSection !== null) { + section = nextSection; + // defineProperty, never `namespaces[section] = {}`: a "__proto__" section + // would invoke the prototype setter (dropping the section), and a + // "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 }); + } + continue; + } + const eq = line.indexOf("="); + if (eq <= 0) { + throw new CliError(`Invalid credentials line ${idx + 1}: expected KEY=value`); + } + const key = line.slice(0, eq).trim(); + const value = parseDotEnvValue(line.slice(eq + 1).trim()); + if (section === null) { + // The only key allowed before any [namespace] is the default-namespace + // pointer, the store's analogue of a base WDL_NS in a project `.env`. + if (key === "WDL_NS") { + defaultNs = value; + continue; + } + throw new CliError(`Invalid credentials line ${idx + 1}: key outside a [namespace] section`); + } + if (!STORE_KEYS.includes(key)) continue; + namespaces[section][key] = value; + } + return { defaultNs, namespaces }; +} + +// Serialize the store back to the same dialect — every value double-quoted and +// escaped so it round-trips through parseDotEnvValue — and write it with 0600 +// perms (0700 dir). The file is command-owned, so it is rewritten canonically +// (default first, then sorted sections, fixed key order); user comments are not +// preserved (edit a project `.env` for hand-managed notes). +/** @param {{ defaultNs?: string | null, namespaces?: Record> }} store */ +export function writeTokenStore(storePath, store) { + const namespaces = store.namespaces || {}; + 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)) { + lines.push(`WDL_NS=${quoteValue(store.defaultNs)}`, ""); + } + for (const ns of Object.keys(namespaces).sort()) { + lines.push(`[${ns}]`); + for (const key of STORE_KEYS) { + const value = namespaces[ns][key]; + if (value == null || value === "") continue; + lines.push(`${key}=${quoteValue(value)}`); + } + lines.push(""); + } + mkdirSync(path.dirname(storePath), { recursive: true, mode: 0o700 }); + // Tighten an existing file to 0600 BEFORE writing token bytes: writeFileSync's + // mode only applies when it creates the file, so without this a pre-existing + // permissive (e.g. 0644) credentials file would receive the secret while still + // world-readable, with a window until a post-write chmod. A non-ENOENT failure + // aborts before the token is written. + try { + chmodSync(storePath, 0o600); + } catch (err) { + if (!err || err.code !== "ENOENT") throw err; + } + writeFileSync(storePath, lines.join("\n"), { mode: 0o600 }); +} diff --git a/lib/whoami.js b/lib/whoami.js index 5894b4c..efe0b18 100644 --- a/lib/whoami.js +++ b/lib/whoami.js @@ -107,7 +107,7 @@ function stringField(value) { export function ensureControlContextFromConfigState(state) { if (state.controlUrl.error) throw new CliError(state.controlUrl.error); - if (!state.token.value) throw new CliError("Missing admin token. Pass --token or set ADMIN_TOKEN env."); + 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/wrangler/command.js b/lib/wrangler/command.js index c1cd402..1dc4b7c 100644 --- a/lib/wrangler/command.js +++ b/lib/wrangler/command.js @@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { CLI_DOTENV_KEYS, CliError } from "../common.js"; +import { WRANGLER_SCRUB_KEYS, CliError } from "../common.js"; const CLI_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); export const MIN_WRANGLER_MAJOR = 4; @@ -83,7 +83,7 @@ export function checkWranglerVersion({ execFile = execFileSync, cwd, env, wrangl export function wranglerChildEnv(env) { const childEnv = { ...env, CLOUDFLARE_API_TOKEN: "dry-run-dummy" }; - for (const key of CLI_DOTENV_KEYS) { + for (const key of WRANGLER_SCRUB_KEYS) { delete childEnv[key]; } return childEnv; diff --git a/package-lock.json b/package-lock.json index 66c8b99..c8c6b54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2359,9 +2359,9 @@ } }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index f53b6ab..7d6873a 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "overrides": { "wrangler": { "esbuild": "^0.28.1" - } + }, + "ws": "^8.21.0" } } diff --git a/templates/AGENTS.md b/templates/AGENTS.md index 5f5ed94..a94c6ce 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -31,6 +31,7 @@ package. | Scheduled / cron jobs | `cron-triggers.md` | | WDL environment override rules (preview / production) | `env-overrides.md` | | Runtime secrets | `secrets.md` | +| Storing control-plane tokens locally | `token.md` | | Deploy / dry-run / list and delete workers | `deploy.md` | Open the relevant doc before editing `wrangler.jsonc` / `wrangler.toml` or @@ -78,9 +79,11 @@ npx wrangler deploy --dry-run --outdir=.deploy-dist # bundle check npm run deploy # deploy to WDL ``` -`wdl init` bakes `--ns ` into the `deploy` script in `package.json`. When -you need environment overrides, add `[env.]` config per `env-overrides.md` -and pass `--env ` explicitly in the script. +`wdl init` bakes `--ns ` into the `deploy` script in `package.json` when you +pass it; without `--ns` the script is `wdl deploy .` and the namespace is +resolved at deploy time (`--ns`, `WDL_NS`, a project `.env`, or a `wdl token` +default). When you need environment overrides, add `[env.]` config per +`env-overrides.md` and pass `--env ` explicitly in the script. To override `vars` / `assets` / bindings / `triggers` per environment, put them in the matching `env.` block. WDL differs from Cloudflare Workers / diff --git a/tests/unit/cli-config-doctor.test.js b/tests/unit/cli-config-doctor.test.js index 2636f95..8a2a4fc 100644 --- a/tests/unit/cli-config-doctor.test.js +++ b/tests/unit/cli-config-doctor.test.js @@ -7,7 +7,9 @@ import { runConfigCommand } from "../../commands/config.js"; import { runDoctorCommand } from "../../commands/doctor.js"; import { runWhoamiCommand } from "../../commands/whoami.js"; import { main as wdlMain } from "../../bin/wdl.js"; -import { maskToken, resolveCliConfigState } from "../../lib/config-state.js"; +import { resolveCliConfigState } from "../../lib/config-state.js"; +import { maskToken } from "../../lib/common.js"; +import { tokenStorePath, writeTokenStore } from "../../lib/token-store.js"; import { cliCompatibility, compareSemver } from "../../lib/whoami.js"; import { response } from "./helpers.js"; @@ -45,6 +47,34 @@ test("resolveCliConfigState reports .env section sources and masks token", () => }); }); +test("resolveCliConfigState resolves the .env namespace over the store default given an empty shell WDL_NS", () => { + withTempDir((cwd) => { + const xdg = path.join(cwd, "xdg"); + writeTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg }), { + defaultNs: "demo", + namespaces: { + acme: { CONTROL_URL: "https://acme.store.example", ADMIN_TOKEN: "acme-store-token" }, + 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")); + + const state = resolveCliConfigState({ + env: { WDL_NS: "", XDG_CONFIG_HOME: xdg }, + cwd, + }); + + assert.equal(state.namespace.display, "acme", "the .env namespace wins, not the demo store default"); + assert.equal(state.controlUrl.display, "https://acme.env.example", "control comes from the .env acme section"); + }); +}); + test("maskToken never reveals most of a short token", () => { assert.equal(maskToken("abcd"), "****"); assert.equal(maskToken("ab"), "****"); diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 6612bb9..2f2dbb8 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -1363,10 +1363,12 @@ test("wranglerChildEnv strips WDL control-plane environment", () => { assert.deepEqual( wranglerChildEnv({ ADMIN_TOKEN: "secret", - ADMIN_URL: "https://ctl.admin.example", CONTROL_CONNECT_HOST: "ctl.connect.example", CONTROL_URL: "https://ctl.example", WDL_NS: "tenant", + // Legacy alias the CLI no longer reads, but must still scrub so a stale + // export does not leak the control endpoint into the bundler. + ADMIN_URL: "https://legacy-admin.example", CLOUDFLARE_API_TOKEN: "real-cloudflare-token", PATH: "/bin", KEEP_ME: "ok", diff --git a/tests/unit/cli-init.test.js b/tests/unit/cli-init.test.js index 70fcb00..addd67d 100644 --- a/tests/unit/cli-init.test.js +++ b/tests/unit/cli-init.test.js @@ -173,11 +173,11 @@ test("init accepts a mixed-case project name end to end", async () => { }); }); -test("init exits with an error when --ns is missing", async () => { - await withTempCwd(async () => { - const { exitCode, errOutput } = await captureExit(() => main(["demo"])); - assert.equal(exitCode, 1); - assert.match(errOutput, /missing --ns/); +test("init scaffolds without --ns; the deploy script omits the namespace", async () => { + await withTempCwd(async (cwd) => { + await main(["demo"]); + const pkg = JSON.parse(readFileSync(path.join(cwd, "demo", "package.json"), "utf8")); + assert.equal(pkg.scripts.deploy, "wdl deploy ."); }); }); diff --git a/tests/unit/cli-lifecycle.test.js b/tests/unit/cli-lifecycle.test.js index 7572122..3b31a0a 100644 --- a/tests/unit/cli-lifecycle.test.js +++ b/tests/unit/cli-lifecycle.test.js @@ -15,12 +15,15 @@ import { runWorkflowsCommand } from "../../commands/workflows.js"; import { main as wdlMain } from "../../bin/wdl.js"; import { CliError, + confirmAction, loadCliControlEnv, loadCliDotEnv, readJsonOrFail, resolveControlContext, resolveControlUrl, resolveNamespace, + writeJsonOr, + writeStatusLine, } from "../../lib/common.js"; import { LONG_CONTROL_TIMEOUT_MS, @@ -48,6 +51,7 @@ function ttyStdinLine(value) { isTTY: true, paused: false, setEncoding(_encoding) {}, + setRawMode(_mode) {}, // a real TTY has this; hidden input requires it pause() { this.paused = true; }, @@ -109,7 +113,7 @@ test("resolveControlUrl keeps bare local dev control URLs on http", () => { test("resolveControlContext centralizes admin token and headers", () => { assert.deepEqual( - resolveControlContext({ admin: "http://ctl.example/" }, { ADMIN_TOKEN: "tok" }), + resolveControlContext({ "control-url": "http://ctl.example/" }, { ADMIN_TOKEN: "tok" }), { controlUrl: "http://ctl.example", token: "tok", @@ -1080,6 +1084,50 @@ test("secret put reads stdin, trims one newline, and encodes key", async () => { assert.deepEqual(lines, ["✓ demo (ns)/KEY/ONE set — effect on next natural cold-load"]); }); +test("writeStatusLine escapes terminal control bytes in the assembled line", () => { + const lines = []; + writeStatusLine((l) => lines.push(l), `ok ${String.fromCharCode(27)}[2J done`); + assert.equal(lines.length, 1); + assert.doesNotMatch(lines[0], new RegExp(String.fromCharCode(27)), "raw ESC must not pass through"); +}); + +test("writeJsonOr emits JSON and reports handled, or defers to the human path", () => { + const out = []; + assert.equal(writeJsonOr(true, { a: 1 }, (l) => out.push(l)), true); + assert.equal(out[0], JSON.stringify({ a: 1 }, null, 2)); + out.length = 0; + assert.equal(writeJsonOr(false, { a: 1 }, (l) => out.push(l)), false); + assert.equal(out.length, 0, "nothing written when not json"); +}); + +test("confirmAction escapes terminal controls in its refusal message", async () => { + const esc = String.fromCharCode(27); + await assert.rejects( + () => confirmAction({ stdin: /** @type {any} */ ({ 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.match(/** @type {Error} */ (err).message, /Refusing to delete/); + return true; + } + ); +}); + +test("secret put escapes terminal controls from a raw keyArg in the status line", async () => { + const esc = String.fromCharCode(27); + 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: (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"); +}); + test("secret put reads one tty line without waiting for EOF", async () => { const calls = []; const prompts = []; @@ -1100,7 +1148,8 @@ test("secret put reads one tty line without waiting for EOF", async () => { assert.equal(calls.length, 1); assert.equal(calls[0].init.body, JSON.stringify({ value: "typed-value" })); - assert.deepEqual(prompts, ["Enter secret value for demo (ns)/KEY: "]); + // The prompt, then a newline written when raw (hidden) mode is restored. + assert.deepEqual(prompts, ["Enter secret value for demo (ns)/KEY (input hidden): ", "\n"]); assert.equal(stdin.paused, true); }); @@ -1380,6 +1429,34 @@ test("r2 object get waits for stdout backpressure", async () => { assert.deepEqual(events, ["write:a", "drain", "write:b"]); }); +test("r2 object get --out escapes a control-char path in the success line", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-r2-out-escape-")); + try { + const esc = String.fromCharCode(27); + const outPath = path.join(dir, `file${esc}[2J.bin`); + const lines = []; + await runR2Command( + ["objects", "get", "--ns", "demo", "uploads", "file.txt", "--out", outPath, "--control-url", "http://ctl.test"], + { + env: { ADMIN_TOKEN: "tok" }, + stdout: (line) => lines.push(line), + controlFetch: async () => ({ + status: 200, + ok: true, + headers: {}, + body: Readable.from([Buffer.from("ab")]), + text: async () => "", + }), + } + ); + const out = lines.join("\n"); + assert.doesNotMatch(out, new RegExp(esc), "raw ESC from --out path must not reach stdout"); + assert.match(out, /OK wrote 2 bytes to/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("r2 object get, head, and delete reject blank keys", async () => { const deps = { env: { ADMIN_TOKEN: "tok", CONTROL_URL: "http://ctl.test" }, @@ -1608,11 +1685,11 @@ async function withMockedExit(fn) { test("wdl dispatcher loads base dotenv before namespace section overlay", async () => { const calls = []; - // init's missing- CliError fires after autoload, keeping the + // secret's missing-subcommand CliError fires after autoload, keeping the // dispatch harmless without needing a control-plane mock. await withMockedExit(async () => { await assert.rejects( - () => wdlMain(["init", "--ns", "demo"], { + () => wdlMain(["secret", "--ns", "demo"], { env: {}, loadEnv: (_env, _path, options) => calls.push(options), }), @@ -1635,7 +1712,7 @@ test("wdl dispatcher overlays the LAST --ns occurrence, matching parseArgs", asy const calls = []; await withMockedExit(async () => { await assert.rejects( - () => wdlMain(["init", "--ns", "first", "--ns=last"], { + () => wdlMain(["secret", "--ns", "first", "--ns=last"], { env: {}, loadEnv: (_env, _path, options) => calls.push(options), }), diff --git a/tests/unit/cli-token-store.test.js b/tests/unit/cli-token-store.test.js new file mode 100644 index 0000000..d834b99 --- /dev/null +++ b/tests/unit/cli-token-store.test.js @@ -0,0 +1,205 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + readTokenStore, + tokenStoreDir, + tokenStorePath, + writeTokenStore, +} from "../../lib/token-store.js"; +import { quoteValue } from "../../lib/common.js"; + +function withTempHome(fn) { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-token-store-")); + try { + return fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +test("tokenStoreDir honors XDG_CONFIG_HOME, then falls back to ~/.config", () => { + assert.equal( + tokenStoreDir({ XDG_CONFIG_HOME: "/x/cfg" }, () => "/home/u"), + path.join("/x/cfg", "wdl") + ); + assert.equal( + tokenStoreDir({}, () => "/home/u"), + path.join("/home/u", ".config", "wdl") + ); + assert.equal( + tokenStorePath({ XDG_CONFIG_HOME: "/x/cfg" }, () => "/home/u"), + path.join("/x/cfg", "wdl", "credentials") + ); +}); + +test("readTokenStore returns an empty store when the file is absent", () => { + withTempHome((dir) => { + assert.deepEqual(readTokenStore(path.join(dir, "credentials")), { defaultNs: null, namespaces: {} }); + }); +}); + +test("writeTokenStore then readTokenStore round-trips namespaces and fields", () => { + withTempHome((dir) => { + const p = path.join(dir, "wdl", "credentials"); + const store = { + defaultNs: null, + namespaces: { + acme: { CONTROL_URL: "https://api.example", ADMIN_TOKEN: "tok-acme", LABEL: "production" }, + "acme-staging": { CONTROL_URL: "https://api.example", ADMIN_TOKEN: "tok-stg" }, + }, + }; + writeTokenStore(p, store); + assert.deepEqual(readTokenStore(p), store); + }); +}); + +test("writeTokenStore then readTokenStore round-trips the default namespace", () => { + withTempHome((dir) => { + const p = path.join(dir, "credentials"); + const store = { + defaultNs: "acme", + namespaces: { + acme: { ADMIN_TOKEN: "tok-acme" }, + demo: { ADMIN_TOKEN: "tok-demo" }, + }, + }; + writeTokenStore(p, store); + assert.match(readFileSync(p, "utf8"), /^WDL_NS="acme"$/m); + assert.deepEqual(readTokenStore(p), store); + }); +}); + +test("writeTokenStore drops a default that has no stored entry", () => { + withTempHome((dir) => { + const p = path.join(dir, "credentials"); + writeTokenStore(p, { defaultNs: "ghost", namespaces: { acme: { ADMIN_TOKEN: "t" } } }); + assert.doesNotMatch(readFileSync(p, "utf8"), /WDL_NS=/); + assert.equal(readTokenStore(p).defaultNs, null); + }); +}); + +test("writeTokenStore quotes and escapes so odd token characters round-trip", () => { + withTempHome((dir) => { + const p = path.join(dir, "credentials"); + const store = { + defaultNs: null, + namespaces: { + acme: { ADMIN_TOKEN: 'tok with "quotes" and =sign #hash', LABEL: "multi\nline note" }, + }, + }; + writeTokenStore(p, store); + assert.deepEqual(readTokenStore(p), store); + }); +}); + +test("round-trips a token containing literal backslash escape sequences", () => { + withTempHome((dir) => { + const p = path.join(dir, "credentials"); + // The token literally contains `\n`, `\t`, `\\`, `\"` as backslash+char, + // plus a Windows-style path — none of which must be decoded as control chars. + const store = { + defaultNs: null, + namespaces: { + acme: { ADMIN_TOKEN: "a\\nb\\tc\\\\d\\\"e", LABEL: "C:\\Users\\x" }, + }, + }; + writeTokenStore(p, store); + assert.deepEqual(readTokenStore(p), store); + }); +}); + +test("preserves a namespace named like an Object.prototype key", () => { + withTempHome((dir) => { + const p = path.join(dir, "credentials"); + const store = { + defaultNs: "constructor", + namespaces: { + constructor: { ADMIN_TOKEN: "tok-ctor" }, + toString: { ADMIN_TOKEN: "tok-tostr" }, + }, + }; + writeTokenStore(p, store); + const back = readTokenStore(p); + assert.deepEqual(Object.keys(back.namespaces).sort(), ["constructor", "toString"]); + assert.equal(back.namespaces["constructor"].ADMIN_TOKEN, "tok-ctor"); + assert.equal(back.defaultNs, "constructor"); + }); +}); + +test("handles a __proto__ section without polluting the prototype", () => { + withTempHome((dir) => { + const p = path.join(dir, "credentials"); + writeFileSync(p, '[__proto__]\nADMIN_TOKEN="x"\n[acme]\nADMIN_TOKEN="a"\n'); + const back = readTokenStore(p); + assert.deepEqual(Object.keys(back.namespaces).sort(), ["__proto__", "acme"]); + assert.equal(back.namespaces["__proto__"].ADMIN_TOKEN, "x"); + assert.equal(Object.getPrototypeOf(back.namespaces), Object.prototype, "map prototype untouched"); + assert.equal(/** @type {any} */ ({}).ADMIN_TOKEN, undefined, "Object.prototype not polluted"); + }); +}); + +test("writeTokenStore writes canonical sorted output with a managed-by header", () => { + withTempHome((dir) => { + const p = path.join(dir, "credentials"); + writeTokenStore(p, { + namespaces: { + zeta: { ADMIN_TOKEN: "z" }, + alpha: { ADMIN_TOKEN: "a", LABEL: "first" }, + }, + }); + const text = readFileSync(p, "utf8"); + assert.match(text, /^# Managed by `wdl token`/); + assert.ok(text.indexOf("[alpha]") < text.indexOf("[zeta]"), "sections sorted"); + assert.match(text, /\[alpha\]\nADMIN_TOKEN="a"\nLABEL="first"/); + }); +}); + +test("writeTokenStore sets 0600 file permissions", () => { + if (process.platform === "win32") return; + withTempHome((dir) => { + const p = path.join(dir, "credentials"); + writeTokenStore(p, { namespaces: { acme: { ADMIN_TOKEN: "t" } } }); + assert.equal(statSync(p).mode & 0o777, 0o600); + // Re-write over an existing file keeps 0600. + writeTokenStore(p, { namespaces: { acme: { ADMIN_TOKEN: "t2" } } }); + assert.equal(statSync(p).mode & 0o777, 0o600); + // A pre-existing permissive (0644) file is tightened to 0600 before the + // token bytes are written, not after. + writeFileSync(p, "stale", { mode: 0o644 }); + chmodSync(p, 0o644); + writeTokenStore(p, { namespaces: { acme: { ADMIN_TOKEN: "t3" } } }); + assert.equal(statSync(p).mode & 0o777, 0o600); + }); +}); + +test("readTokenStore rejects a key outside any section", () => { + withTempHome((dir) => { + const p = path.join(dir, "credentials"); + writeFileSync(p, "ADMIN_TOKEN=loose\n"); + assert.throws(() => readTokenStore(p), /outside a \[namespace\] section/); + }); +}); + +test("readTokenStore reads a base WDL_NS as the default namespace", () => { + withTempHome((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" } } }); + }); +}); + +test("readTokenStore ignores unknown keys and comments", () => { + withTempHome((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" } } }); + }); +}); + +test("quoteValue escapes backslash before other sequences", () => { + assert.equal(quoteValue("a\\b"), '"a\\\\b"'); + assert.equal(quoteValue('q"q'), '"q\\"q"'); +}); diff --git a/tests/unit/cli-token.test.js b/tests/unit/cli-token.test.js new file mode 100644 index 0000000..79161b0 --- /dev/null +++ b/tests/unit/cli-token.test.js @@ -0,0 +1,726 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { runTokenCommand } from "../../commands/token.js"; +import { loadCliControlEnv, protectedEnvKeys, readSecretStdin, readTtyLine } from "../../lib/common.js"; +import { readTokenStore, tokenStorePath, writeTokenStore } from "../../lib/token-store.js"; +import { response } from "./helpers.js"; + +async function withTempXdg(fn) { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-token-cmd-")); + try { + return await fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +function stdinFrom(value) { + const stdin = Object.assign(new EventEmitter(), { setEncoding() {} }); + queueMicrotask(() => { + stdin.emit("data", value); + stdin.emit("end"); + }); + return stdin; +} + +/** @param {string} xdg @param {{ stdin?: any, controlFetch?: Function }} [opts] */ +function deps(xdg, { stdin, controlFetch } = {}) { + const lines = []; + const warnings = []; + const calls = []; + return { + lines, + warnings, + calls, + deps: { + env: { XDG_CONFIG_HOME: xdg }, + stdout: (line) => lines.push(line), + stderr: () => {}, + warn: (line) => warnings.push(line), + stdin, + controlFetch: controlFetch || (async (url, init = {}) => { + calls.push({ url, init }); + return response({ ok: true, principal: { kind: "ns", ns: "acme" } }); + }), + }, + }; +} + +// --- wdl token set --- + +test("token set reads stdin, validates via /whoami, and stores the credential", async () => { + await withTempXdg(async (xdg) => { + const { lines, calls, deps: d } = deps(xdg, { stdin: stdinFrom("tok-secret-1234\n") }); + await runTokenCommand(["set", "--ns", "acme", "--control-url", "https://api.example"], d); + + assert.match(calls[0].url, /https:\/\/api\.example\/whoami$/); + 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.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/); + }); +}); + +test("token set makes only the first namespace the default; later sets do not steal it", async () => { + await withTempXdg(async (xdg) => { + await runTokenCommand( + ["set", "--ns", "acme", "--control-url", "https://api.example"], + deps(xdg, { stdin: stdinFrom("tok-1\n") }).deps + ); + const second = deps(xdg, { + stdin: stdinFrom("tok-2\n"), + controlFetch: async () => response({ ok: true, principal: { kind: "ns", ns: "demo" } }), + }); + await runTokenCommand(["set", "--ns", "demo", "--control-url", "https://api.example"], second.deps); + + assert.equal(readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })).defaultNs, "acme"); + assert.doesNotMatch(second.lines.join("\n"), /default namespace/, "a non-first set is silent about the default"); + }); +}); + +test("token set --default makes an existing namespace the default", async () => { + await withTempXdg(async (xdg) => { + await runTokenCommand( + ["set", "--ns", "acme", "--control-url", "https://api.example"], + deps(xdg, { stdin: stdinFrom("tok-1\n") }).deps + ); + await runTokenCommand( + ["set", "--ns", "demo", "--control-url", "https://api.example", "--default"], + deps(xdg, { + stdin: stdinFrom("tok-2\n"), + controlFetch: async () => response({ ok: true, principal: { kind: "ns", ns: "demo" } }), + }).deps + ); + assert.equal(readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })).defaultNs, "demo"); + }); +}); + +test("token set creates a __proto__ namespace without hitting the prototype setter", async () => { + await withTempXdg(async (xdg) => { + await runTokenCommand( + ["set", "--ns", "__proto__", "--control-url", "https://api.example"], + deps(xdg, { + stdin: stdinFrom("tok-proto\n"), + controlFetch: async () => response({ ok: true, principal: { kind: "ns", ns: "__proto__" } }), + }).deps + ); + const store = readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })); + assert.equal(Object.hasOwn(store.namespaces, "__proto__"), true); + assert.equal(store.namespaces["__proto__"].ADMIN_TOKEN, "tok-proto"); + assert.equal(store.defaultNs, "__proto__"); + }); +}); + +test("token set does not claim a deliberately-cleared default in an ambiguous store", async () => { + await withTempXdg(async (xdg) => { + 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" } } }); + await runTokenCommand( + ["set", "--ns", "demo", "--control-url", "https://api.example"], + deps(xdg, { + stdin: stdinFrom("tok\n"), + controlFetch: async () => response({ ok: true, principal: { kind: "ns", ns: "demo" } }), + }).deps + ); + assert.equal(readTokenStore(p).defaultNs, null); + }); +}); + +test("token set stores and preserves a --label", async () => { + await withTempXdg(async (xdg) => { + await runTokenCommand( + ["set", "--ns", "acme", "--control-url", "https://api.example", "--label", "production"], + deps(xdg, { stdin: stdinFrom("tok-1\n") }).deps + ); + let store = readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })); + assert.equal(store.namespaces.acme.LABEL, "production"); + + // Re-set without --label keeps the existing label. + await runTokenCommand( + ["set", "--ns", "acme", "--control-url", "https://api.example"], + deps(xdg, { stdin: stdinFrom("tok-2\n") }).deps + ); + store = readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })); + assert.equal(store.namespaces.acme.LABEL, "production"); + assert.equal(store.namespaces.acme.ADMIN_TOKEN, "tok-2"); + }); +}); + +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 + ), + /whoami/ + ); + 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), + /requires --ns/ + ); + await assert.rejects( + () => runTokenCommand(["set", "--ns", "acme"], deps(xdg, { stdin: stdinFrom("t\n") }).deps), + /needs the control URL/ + ); + }); +}); + +test("token set rejects a token whose principal namespace differs from --ns", async () => { + 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 + ), + /namespace "other", not "acme"/ + ); + assert.deepEqual(readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })), { defaultNs: null, namespaces: {} }); + }); +}); + +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 + ), + /not scoped to namespace "acme"/ + ); + 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 + ), + /invalid namespace/ + ); + assert.deepEqual(readTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg })), { defaultNs: null, namespaces: {} }); + }); +}); + +test("token set escapes terminal controls in a principal-mismatch error", async () => { + await withTempXdg(async (xdg) => { + const esc = String.fromCharCode(27); + 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 + ), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.doesNotMatch(message, new RegExp(esc), "raw ESC must not be in the error"); + assert.match(message, /token principal is namespace/); + return true; + } + ); + }); +}); + +test("token set escapes a masked token suffix containing terminal controls", async () => { + await withTempXdg(async (xdg) => { + const esc = String.fromCharCode(27); + const { lines, deps: d } = deps(xdg, { stdin: stdinFrom(`tok-secret${esc}[2J\n`) }); + await runTokenCommand(["set", "--ns", "acme", "--control-url", "https://api.example"], d); + const out = lines.join("\n"); + assert.doesNotMatch(out, new RegExp(esc), "raw ESC must not reach stdout via the masked suffix"); + assert.match(out, /Stored token for acme/); + }); +}); + +test("token set warns before sending the token to a plain-http non-local host", async () => { + await withTempXdg(async (xdg) => { + const { warnings, deps: d } = deps(xdg, { stdin: stdinFrom("tok\n") }); + await runTokenCommand(["set", "--ns", "acme", "--control-url", "http://example.com"], d); + assert.match(warnings.join("\n"), /plain http on a non-local host/); + }); +}); + +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 + ), + /Unknown option|--token/ + ); + }); +}); + +// --- wdl token list / rm --- + +test("token list formats stored namespaces with masked tokens and marks the default", async () => { + await withTempXdg(async (xdg) => { + writeTokenStore(tokenStorePath({ XDG_CONFIG_HOME: xdg }), { + defaultNs: "acme", + namespaces: { + acme: { CONTROL_URL: "https://api.example", ADMIN_TOKEN: "tok-abcd1234", LABEL: "production" }, + demo: { CONTROL_URL: "https://api.example", ADMIN_TOKEN: "tok-zzzz9999" }, + }, + }); + const { lines, deps: d } = deps(xdg); + await runTokenCommand(["list"], d); + const out = lines.join("\n"); + assert.match(out, /NAMESPACE\s+LABEL\s+CONTROL URL\s+TOKEN/); + assert.match(out, /\*\s+acme\s+production\s+https:\/\/api\.example\s+\*\*\*\*1234/); + assert.match(out, /default namespace \(used when --ns is omitted\)/); + assert.doesNotMatch(out, /tok-abcd1234/, "raw token must not be printed"); + }); +}); + +test("token use switches the default namespace and rejects an unstored one", async () => { + await withTempXdg(async (xdg) => { + const p = tokenStorePath({ XDG_CONFIG_HOME: xdg }); + writeTokenStore(p, { + defaultNs: "acme", + namespaces: { acme: { ADMIN_TOKEN: "a" }, demo: { ADMIN_TOKEN: "d" } }, + }); + const { lines, deps: d } = deps(xdg); + await runTokenCommand(["use", "demo"], d); + assert.equal(readTokenStore(p).defaultNs, "demo"); + assert.match(lines.join("\n"), /Default namespace set to demo/); + + await assert.rejects( + () => runTokenCommand(["use", "ghost"], deps(xdg).deps), + /no stored token for namespace "ghost"/ + ); + }); +}); + +test("token list prints a placeholder when empty", async () => { + await withTempXdg(async (xdg) => { + const { lines, deps: d } = deps(xdg); + await runTokenCommand(["list"], d); + assert.deepEqual(lines, ["(no stored tokens)"]); + }); +}); + +test("token use/rm escape terminal controls in the not-found error", async () => { + await withTempXdg(async (xdg) => { + const esc = String.fromCharCode(27); + const bad = `ghost${esc}[2J`; + const noEsc = (err) => { + assert.doesNotMatch(/** @type {Error} */ (err).message, new RegExp(esc), "raw ESC must not reach the error"); + return true; + }; + await assert.rejects(() => runTokenCommand(["use", bad], deps(xdg).deps), noEsc); + await assert.rejects(() => runTokenCommand(["rm", "--ns", bad], deps(xdg).deps), noEsc); + }); +}); + +test("token rm removes a stored namespace and errors when absent", async () => { + await withTempXdg(async (xdg) => { + const p = tokenStorePath({ XDG_CONFIG_HOME: xdg }); + writeTokenStore(p, { namespaces: { acme: { ADMIN_TOKEN: "t" }, demo: { ADMIN_TOKEN: "d" } } }); + + const { lines, deps: d } = deps(xdg); + await runTokenCommand(["rm", "--ns", "acme"], d); + assert.deepEqual(Object.keys(readTokenStore(p).namespaces), ["demo"]); + assert.match(lines.join("\n"), /does not revoke it on the control plane/); + + await assert.rejects( + () => runTokenCommand(["rm", "--ns", "acme"], deps(xdg).deps), + /no stored token for namespace "acme"/ + ); + }); +}); + +test("token rm requires an explicit --ns and ignores ambient WDL_NS", async () => { + await withTempXdg(async (xdg) => { + const p = tokenStorePath({ XDG_CONFIG_HOME: xdg }); + writeTokenStore(p, { defaultNs: "acme", namespaces: { acme: { ADMIN_TOKEN: "a" } } }); + + const { deps: d } = deps(xdg); + // A stray WDL_NS must NOT make a bare `rm` delete that namespace's token. + d.env.WDL_NS = "acme"; + await assert.rejects(() => runTokenCommand(["rm"], d), /requires an explicit --ns/); + assert.deepEqual(Object.keys(readTokenStore(p).namespaces), ["acme"], "store untouched"); + }); +}); + +test("token set requires an explicit --ns and ignores ambient WDL_NS", async () => { + await withTempXdg(async (xdg) => { + // Default controlFetch authenticates the token as namespace "acme", so + // 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: {} }); + }); +}); + +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" } } }); + const { deps: d } = deps(xdg); + // A stray WDL_NS must NOT make a bare `use` switch the default. + d.env.WDL_NS = "acme"; + await assert.rejects(() => runTokenCommand(["use"], d), /requires a namespace/); + assert.equal(readTokenStore(p).defaultNs, "demo", "default unchanged"); + }); +}); + +test("token rm of the default promotes a sole survivor, clears it when ambiguous", async () => { + await withTempXdg(async (xdg) => { + const p = tokenStorePath({ XDG_CONFIG_HOME: xdg }); + writeTokenStore(p, { + defaultNs: "acme", + namespaces: { acme: { ADMIN_TOKEN: "a" }, demo: { ADMIN_TOKEN: "d" }, prod: { ADMIN_TOKEN: "p" } }, + }); + + // Three stored, default removed → two remain → ambiguous → default cleared. + await runTokenCommand(["rm", "--ns", "acme"], deps(xdg).deps); + assert.equal(readTokenStore(p).defaultNs, null); + + // Re-point the default, then remove down to one → the survivor is promoted. + await runTokenCommand(["use", "demo"], deps(xdg).deps); + await runTokenCommand(["rm", "--ns", "demo"], deps(xdg).deps); + assert.equal(readTokenStore(p).defaultNs, "prod"); + }); +}); + +test("token rm promotes the sole survivor even after the default was already cleared", async () => { + await withTempXdg(async (xdg) => { + const p = tokenStorePath({ XDG_CONFIG_HOME: xdg }); + writeTokenStore(p, { + defaultNs: "acme", + 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); + await runTokenCommand(["rm", "--ns", "demo"], deps(xdg).deps); // non-default removed, prod alone + assert.equal(readTokenStore(p).defaultNs, "prod", "sole survivor promoted even with no prior default"); + }); +}); + +test("token rejects unknown subcommands", async () => { + await withTempXdg(async (xdg) => { + await assert.rejects(() => runTokenCommand(["frobnicate"], deps(xdg).deps), /Usage:/); + }); +}); + +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" } } }); + + await runTokenCommand(["use", "constructor"], deps(xdg).deps); + assert.equal(readTokenStore(p).defaultNs, "constructor"); + + const { lines, deps: d } = deps(xdg); + await runTokenCommand(["list"], d); + assert.match(lines.join("\n"), /\*\s+constructor/); + + await runTokenCommand(["rm", "--ns", "constructor"], deps(xdg).deps); + assert.equal(Object.hasOwn(readTokenStore(p).namespaces, "constructor"), false); + }); +}); + +// --- hidden TTY input --- + +test("readTtyLine hides input by switching the TTY to raw mode", async () => { + const rawCalls = []; + const stderr = []; + const stdin = Object.assign(new EventEmitter(), { + isTTY: true, + setEncoding() {}, + setRawMode(v) { rawCalls.push(v); }, + pause() {}, + }); + 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 + stdin.emit("data", "ret" + String.fromCharCode(13)); // Enter + }); + assert.equal(await pending, "secret"); + assert.deepEqual(rawCalls, [true, false], "raw mode (echo off) enabled, then restored"); +}); + +test("readTtyLine fails closed when a TTY cannot hide input", async () => { + const stdin = Object.assign(new EventEmitter(), { + isTTY: true, + setEncoding() {}, + pause() {}, + // no setRawMode: cannot disable echo, so hidden input must reject, not leak + }); + await assert.rejects( + () => readTtyLine(stdin, { prompt: "tok: ", stderr: () => {}, hidden: true }), + /cannot hide input/ + ); +}); + +test("readSecretStdin reads a piped value to EOF, trimming one trailing newline", async () => { + const stdin = Object.assign(new EventEmitter(), { setEncoding() {} }); + queueMicrotask(() => { + stdin.emit("data", "sec"); + stdin.emit("data", "ret\n"); + stdin.emit("end"); + }); + assert.equal(await readSecretStdin(stdin), "secret"); +}); + +test("readSecretStdin trims only one trailing newline (multi-line value)", async () => { + const stdin = Object.assign(new EventEmitter(), { setEncoding() {} }); + queueMicrotask(() => { + stdin.emit("data", "a\nb\n\n"); + stdin.emit("end"); + }); + assert.equal(await readSecretStdin(stdin), "a\nb\n"); +}); + +test("readSecretStdin hides input on a TTY via raw mode", async () => { + const rawCalls = []; + const stdin = Object.assign(new EventEmitter(), { + isTTY: true, + setEncoding() {}, + setRawMode(v) { rawCalls.push(v); }, + pause() {}, + }); + queueMicrotask(() => { + stdin.emit("data", "tok"); + stdin.emit("data", "en\r"); + }); + assert.equal(await readSecretStdin(stdin, { stderr: () => {} }), "token"); + assert.deepEqual(rawCalls, [true, false], "raw mode (echo off) enabled, then restored"); +}); + +test("readTtyLine escapes terminal controls in the prompt at the write point", async () => { + const errs = []; + const stdin = Object.assign(new EventEmitter(), { setEncoding() {}, pause() {} }); + queueMicrotask(() => stdin.emit("data", "y\n")); + await readTtyLine(stdin, { prompt: `confirm ${String.fromCharCode(27)}[2J?`, stderr: (s) => errs.push(s) }); + assert.doesNotMatch(errs.join(""), new RegExp(String.fromCharCode(27)), "raw ESC from the prompt must not reach stderr"); +}); + +test("protectedEnvKeys protects only non-empty string values", () => { + const keys = protectedEnvKeys(/** @type {any} */ ({ A: "x", EMPTY: "", MISSING: undefined, B: "y" })); + assert.deepEqual([...keys].sort(), ["A", "B"]); +}); + +// --- resolution integration (the global store as the lowest-precedence layer) --- + +test("loadCliControlEnv fills control URL and token from the store as a gap-filler", () => { + const env = { WDL_NS: "acme" }; + loadCliControlEnv(env, { + nsFromFlag: "acme", + loadEnv: () => [], + 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"); +}); + +test("loadCliControlEnv selects the store's default namespace when nothing else does", () => { + const env = /** @type {NodeJS.ProcessEnv} */ ({}); + loadCliControlEnv(env, { + loadEnv: () => [], + readStore: () => ({ + defaultNs: "acme", + namespaces: { acme: { CONTROL_URL: "https://store.example", ADMIN_TOKEN: "store-tok" } }, + }), + }); + assert.equal(env.WDL_NS, "acme", "the default namespace is materialized for downstream resolution"); + assert.equal(env.CONTROL_URL, "https://store.example"); + assert.equal(env.ADMIN_TOKEN, "store-tok"); +}); + +test("loadCliControlEnv lets an explicit namespace override the store default", () => { + const env = { WDL_NS: "demo" }; + loadCliControlEnv(env, { + loadEnv: () => [], + readStore: () => ({ + defaultNs: "acme", + namespaces: { + acme: { CONTROL_URL: "https://acme.example", ADMIN_TOKEN: "acme-tok" }, + demo: { CONTROL_URL: "https://demo.example", ADMIN_TOKEN: "demo-tok" }, + }, + }), + }); + assert.equal(env.WDL_NS, "demo", "shell WDL_NS wins over the store default"); + assert.equal(env.CONTROL_URL, "https://demo.example"); + assert.equal(env.ADMIN_TOKEN, "demo-tok"); +}); + +test("loadCliControlEnv lets a project .env namespace beat the store default over an empty shell WDL_NS", () => { + const env = { WDL_NS: "" }; + loadCliControlEnv(env, { + // Simulate the real loader: only set the .env WDL_NS when it is not protected. + loadEnv: (e, _path, { protectedKeys }) => { + if (protectedKeys.has("WDL_NS")) return []; + e.WDL_NS = "acme"; + return ["WDL_NS"]; + }, + readStore: () => ({ + defaultNs: "demo", + namespaces: { + acme: { CONTROL_URL: "https://acme.example", ADMIN_TOKEN: "acme-tok" }, + demo: { CONTROL_URL: "https://demo.example", ADMIN_TOKEN: "demo-tok" }, + }, + }), + }); + assert.equal(env.WDL_NS, "acme", "the project .env namespace wins over the store default"); + assert.equal(env.CONTROL_URL, "https://acme.example", "creds come from acme, not the demo default"); + assert.equal(env.ADMIN_TOKEN, "acme-tok"); +}); + +test("loadCliControlEnv ignores a store default with no stored entry", () => { + const env = /** @type {NodeJS.ProcessEnv} */ ({}); + loadCliControlEnv(env, { + loadEnv: () => [], + readStore: () => ({ defaultNs: "ghost", namespaces: { acme: { ADMIN_TOKEN: "a" } } }), + }); + assert.equal(env.WDL_NS, undefined, "a dangling default does not select a namespace"); + assert.equal(env.ADMIN_TOKEN, undefined); +}); + +test("loadCliControlEnv lets shell env win over the store (gap-fill only)", () => { + const env = { WDL_NS: "acme", ADMIN_TOKEN: "shell-tok" }; + loadCliControlEnv(env, { + nsFromFlag: "acme", + protectedKeys: new Set(["ADMIN_TOKEN"]), + loadEnv: () => [], + 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"); +}); + +test("loadCliControlEnv does not fill a flag-covered slot from the store", () => { + const env = { WDL_NS: "acme" }; + // --control-url supplies the endpoint, so the store fills only the token and + // never writes its own CONTROL_URL into env. + loadCliControlEnv(env, { + nsFromFlag: "acme", + controlUrlFromFlag: true, + loadEnv: () => [], + 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"); +}); + +test("loadCliControlEnv does not read the store when ns and credentials are present", () => { + const env = { WDL_NS: "acme", CONTROL_URL: "https://shell.example", ADMIN_TOKEN: "shell-tok" }; + let reads = 0; + loadCliControlEnv(env, { + nsFromFlag: "acme", + loadEnv: () => [], + readStore: () => { reads += 1; return {}; }, + }); + assert.equal(reads, 0, "the store is the lowest layer and untouched when nothing needs it"); +}); + +test("loadCliControlEnv ignores a corrupt store when flags cover the credentials", () => { + let reads = 0; + // ns + both creds come from flags, so the store is never consulted and a + // corrupt ~/.config/wdl/credentials cannot abort the command. + loadCliControlEnv(/** @type {NodeJS.ProcessEnv} */ ({}), { + nsFromFlag: "acme", + tokenFromFlag: true, + controlUrlFromFlag: true, + loadEnv: () => [], + 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"); }, + }), + /Invalid credentials/ + ); +}); + +test("loadCliControlEnv tolerates a corrupt store when no namespace is needed", () => { + // No --ns/WDL_NS: the optional default-namespace lookup must not let a corrupt + // store abort a command that needs none (e.g. whoami --control-url … --token …). + const env = /** @type {NodeJS.ProcessEnv} */ ({}); + assert.doesNotThrow(() => + loadCliControlEnv(env, { + loadEnv: () => [], + readStore: () => { throw new Error("Invalid credentials line 3"); }, + }) + ); + assert.equal(env.WDL_NS, undefined); +}); + +test("an empty .env ADMIN_TOKEN does not mark a .env endpoint same-source", () => { + // Malicious cwd .env: a control endpoint + an EMPTY `ADMIN_TOKEN=` placeholder. + // The empty token must not make the endpoint same-source. + const env = /** @type {NodeJS.ProcessEnv} */ ({}); + const warnings = []; + loadCliControlEnv(env, { + nsFromFlag: "acme", + loadEnv: (e, _path, opts) => { + if (!opts.resolvedNs) { + e.CONTROL_URL = "https://evil.example"; + e.ADMIN_TOKEN = ""; + return ["CONTROL_URL", "ADMIN_TOKEN"]; + } + return []; + }, + 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"); + assert.equal(env.ADMIN_TOKEN, "store-tok"); + assert.equal(warnings.length, 1); +}); + +test("a project .env endpoint is still dropped when the token comes from the store", () => { + // Malicious cwd .env supplies an endpoint but no token; the store supplies the + // token (and a trusted endpoint). The guard must drop the project endpoint + // before the store fills it, so the store token is never sent to the .env host. + const env = /** @type {NodeJS.ProcessEnv} */ ({}); + const warnings = []; + loadCliControlEnv(env, { + nsFromFlag: "acme", + loadEnv: (e, _path, opts) => { + if (!opts.resolvedNs) { + e.CONTROL_URL = "https://evil.example"; + return ["CONTROL_URL"]; + } + return []; + }, + 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"); + assert.equal(env.ADMIN_TOKEN, "store-tok"); + assert.equal(warnings.length, 1); +});