From 7bddb802bd84946b0b5111f47f165deeefe34991 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 01:20:00 +0000 Subject: [PATCH 01/20] Add real JSDoc types to foundational lib for strict mode Annotate the core lib layer (credentials, control-fetch, command, config-state, output, dotenv, ns-pattern, stdin, token-store, common, bundle-modules, package-info) so it passes `tsc --strict` with real types and zero `any`/`@ts-` suppressions. Self-guarding validators take `unknown`; wire-parsed JSON stays `unknown` until narrowed. Introduces two shared typedefs the rest of the conversion references: - ControlResponse (control-fetch.js): the controlFetch/readControlResponse shape, with json() typed Promise. - TokenStore (token-store.js): the on-disk store reader shape. Behavior unchanged; types only. Downstream commands/tests now see the new typedefs and are converted in following commits (global strict flips on at the end of the series). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- lib/bundle-modules.js | 5 ++ lib/command.js | 66 ++++++++++++++------ lib/common.js | 141 +++++++++++++++++++++++++++++++++++++++--- lib/config-state.js | 42 ++++++++++++- lib/control-fetch.js | 110 +++++++++++++++++++++++++++++--- lib/credentials.js | 78 ++++++++++++++++++++--- lib/dotenv.js | 15 +++++ lib/ns-pattern.js | 7 +++ lib/output.js | 46 +++++++++++++- lib/package-info.js | 2 + lib/stdin.js | 16 ++++- lib/token-store.js | 42 +++++++++++-- 12 files changed, 516 insertions(+), 54 deletions(-) diff --git a/lib/bundle-modules.js b/lib/bundle-modules.js index 0fac34f..42b0bbc 100644 --- a/lib/bundle-modules.js +++ b/lib/bundle-modules.js @@ -2,6 +2,7 @@ import path from "node:path"; const TEXT_EXTS = new Set([".txt", ".css", ".html", ".htm", ".svg"]); +/** @param {string} filePath */ export function inferType(filePath) { const ext = path.extname(filePath).toLowerCase(); if (ext === ".js" || ext === ".mjs") return "module"; @@ -14,6 +15,10 @@ export function inferType(filePath) { } // Inverse of `control/lib.js::normalizeModule` - keep the two in sync. +/** + * @param {Buffer} buf + * @param {"module" | "cjs" | "py" | "json" | "text" | "wasm" | "data"} type + */ export function toWireModule(buf, type) { switch (type) { case "module": return buf.toString("utf8"); diff --git a/lib/command.js b/lib/command.js index f3ebdfc..6cc5e83 100644 --- a/lib/command.js +++ b/lib/command.js @@ -40,13 +40,13 @@ import { resolveControlContext, resolveNamespace, warnIfInsecureControlUrl } fro * @property {NodeJS.ReadStream} stdin * @property {string} cwd * @property {typeof defaultControlFetch} controlFetch - * @property {Record} values Parsed flag values. + * @property {Record} values Parsed flag values. * @property {string[]} positionals Parsed positional args. * @property {() => (string | undefined)} resolveNamespace * @property {() => { controlUrl: string, headers: Record, token: string }} resolveControl * @property {(...segments: string[]) => string} nsUrl Encoded /ns//... URL. - * @property {(url: string, init: object, label: string) => Promise} fetchJson controlFetch + readJsonOrFail. - * @property {(url: string, init: object, label: string) => Promise} fetchStream controlFetch + status check; returns the raw Response. + * @property {(url: string, init: import("./control-fetch.js").ControlFetchInit, label: string) => Promise} fetchJson controlFetch + readJsonOrFail. + * @property {(url: string, init: import("./control-fetch.js").ControlFetchInit, label: string) => Promise} fetchStream controlFetch + status check; returns the raw Response. */ // Injectable deps every command understands, with production defaults. @@ -54,12 +54,12 @@ import { resolveControlContext, resolveNamespace, warnIfInsecureControlUrl } fro function standardDefaults() { return { env: process.env, - stdout: (line = "") => console.log(line), - stderr: (text) => process.stderr.write(text), + stdout: (/** @type {string} */ line = "") => console.log(line), + stderr: (/** @type {string} */ text) => process.stderr.write(text), // Framework warnings go through this line-based channel rather than // stderr: commands override stderr with differing newline conventions // (raw write vs console.error), which a shared emitter can't know. - warn: (line) => console.error(line), + warn: (/** @type {string} */ line) => console.error(line), stdin: process.stdin, cwd: process.cwd(), controlFetch: defaultControlFetch, @@ -67,6 +67,7 @@ function standardDefaults() { } // options is a list mixing preset names and option specs. +/** @param {Iterable} options */ function buildParseOptions(options) { return optionParseOptions(options); } @@ -75,7 +76,7 @@ function buildParseOptions(options) { * @param {{ * name: string, * summary: string, - * options?: Array, + * options?: Array, * defaults?: Record, * autoloadEnv?: boolean, * usage: () => string, @@ -119,13 +120,22 @@ export function defineCommand(spec) { // Exported for the bin dispatcher's lenient pre-scan, which must classify // help requests with the same rule the strict parse uses. +/** @param {string[]} positionals */ export function isHelpAlias(positionals) { return positionals.length === 1 && positionals[0] === "help"; } -/** @returns {CommandContext} */ +/** + * @param {Record} deps + * @param {Record} commandDefaults + * @param {Record} values + * @param {string[]} positionals + * @returns {CommandContext} + */ function buildContext(deps, commandDefaults, values, positionals) { + /** @type {Record} */ const merged = { ...standardDefaults(), ...commandDefaults }; + /** @type {Record} */ const context = {}; // Known deps fall back to the merged defaults; an explicitly injected dep // (including one set to undefined) wins so tests can override anything. @@ -139,24 +149,34 @@ function buildContext(deps, commandDefaults, values, positionals) { if (!Object.hasOwn(context, key)) context[key] = deps[key]; } + // The dep-merge above produces dynamically-keyed members; read the framework + // members back with their contract types so the closures below are checked. + const env = /** @type {NodeJS.ProcessEnv} */ (context.env); + const warn = /** @type {(line: string) => void} */ (context.warn); + const controlFetch = /** @type {typeof defaultControlFetch} */ (context.controlFetch); + context.values = values; context.positionals = positionals; - context.resolveNamespace = () => resolveNamespace(values, context.env); + /** @returns {string | undefined} */ + const resolveNamespaceFn = () => resolveNamespace(values, env); + context.resolveNamespace = resolveNamespaceFn; + /** @type {ReturnType | undefined} */ let controlMemo; - context.resolveControl = () => (controlMemo ??= (() => { - const control = resolveControlContext(values, context.env); - warnIfInsecureControlUrl(control.controlUrl, context.warn); + const resolveControlFn = () => (controlMemo ??= (() => { + const control = resolveControlContext(values, env); + warnIfInsecureControlUrl(control.controlUrl, warn); return control; })()); + context.resolveControl = resolveControlFn; // Build a control URL under the resolved namespace, encoding each segment, e.g. // nsUrl("worker", name, "versions", v) -> .../ns//worker//versions/. // Fail-fast on an unresolved namespace: callers validate --ns and throw their // own usageText first, so this only fires if a command forgets that check — // better an internal-invariant error than a silent .../ns/undefined/... fetch. - context.nsUrl = (...segments) => { - const { controlUrl } = context.resolveControl(); - const ns = context.resolveNamespace(); + context.nsUrl = (/** @type {string[]} */ ...segments) => { + const { controlUrl } = resolveControlFn(); + const ns = resolveNamespaceFn(); if (!ns) throw new CliError("nsUrl: namespace not resolved (command must validate --ns first)"); const base = `${controlUrl}/ns/${encodePath(ns)}`; return segments.length === 0 @@ -165,18 +185,28 @@ function buildContext(deps, commandDefaults, values, positionals) { }; // controlFetch + readJsonOrFail in one call — the pair most commands repeat. + /** + * @param {string} url + * @param {import("./control-fetch.js").ControlFetchInit} init + * @param {string} label + */ context.fetchJson = async (url, init, label) => { - const res = await context.controlFetch(url, init); + const res = await controlFetch(url, init); return readJsonOrFail(res, label); }; // controlFetch + status check for non-JSON / streaming bodies (e.g. r2 get/head). // Returns the raw Response so the caller can consume res.body / res.headers. + /** + * @param {string} url + * @param {import("./control-fetch.js").ControlFetchInit} init + * @param {string} label + */ context.fetchStream = async (url, init, label) => { - const res = await context.controlFetch(url, init); + const res = await controlFetch(url, init); await throwHttpErrorIfNotOk(res, label); return res; }; - return /** @type {CommandContext} */ (context); + return /** @type {CommandContext} */ (/** @type {unknown} */ (context)); } diff --git a/lib/common.js b/lib/common.js index 78be9e3..6efe27b 100644 --- a/lib/common.js +++ b/lib/common.js @@ -6,8 +6,13 @@ import { escapeTerminalText } from "./output.js"; export class CliError extends Error { + /** + * @param {string} message + * @param {number} [exitCode] + */ constructor(message, exitCode = 1) { super(message); + /** @type {number} */ this.exitCode = exitCode; } } @@ -15,10 +20,17 @@ export class CliError extends Error { // The project's "set" predicate: a value is set only when it is a non-empty // string; "" or a non-string (undefined, a missing/boolean flag) counts as // absent. Centralized so the rule can't drift between its callers. +/** + * @param {unknown} value + * @returns {value is string} + */ export function isNonEmptyString(value) { return typeof value === "string" && value.length > 0; } +/** + * @param {{ usage: string[], description?: string, commands?: string[], options?: string[] }} spec + */ export function formatHelp({ usage, description, commands = [], options = [] }) { const lines = ["Usage:"]; for (const line of usage) lines.push(` ${line}`); @@ -40,10 +52,28 @@ export function formatHelp({ usage, description, commands = [], options = [] }) return lines.join("\n"); } +/** + * A single parsed CLI option spec: its parseArgs config plus the help line. + * @typedef {object} CliOptionSpec + * @property {import("node:util").ParseArgsOptionsConfig} parseOptions + * @property {string | null} help + */ + +/** + * An entry in an `options` list: either a preset name or an option spec. + * @typedef {string | CliOptionSpec} OptionListItem + */ + +/** + * @param {{ namespace?: boolean, controlUrl?: boolean, token?: boolean, noTokenStore?: boolean, json?: boolean, help?: boolean }} [options] + */ export function commonCliOptions({ namespace = true, controlUrl = true, token = true, noTokenStore = true, json = false, help = true } = {}) { return optionHelp(commonCliOptionSpecs({ namespace, controlUrl, token, noTokenStore, json, help })); } +/** + * @param {{ namespace?: boolean, controlUrl?: boolean, token?: boolean, noTokenStore?: boolean, json?: boolean, help?: boolean }} [options] + */ export function commonCliOptionSpecs({ namespace = true, controlUrl = true, token = true, noTokenStore = true, json = false, help = true } = {}) { const specs = []; if (namespace) specs.push(OPTION_DEFS.ns); @@ -57,10 +87,25 @@ export function commonCliOptionSpecs({ namespace = true, controlUrl = true, toke const OPTION_HELP_WIDTH = 21; +/** + * @param {string} flag + * @param {string} description + */ +/** + * @param {string} flag + * @param {string | null} description + */ export function formatOption(flag, description) { return `${flag}${" ".repeat(Math.max(1, OPTION_HELP_WIDTH - flag.length))}${description}`; } +/** + * @param {string} name + * @param {import("node:util").ParseArgsOptionsConfig[string]} parseConfig + * @param {string | null} flag + * @param {string | null} description + * @returns {CliOptionSpec} + */ export function defineCliOption(name, parseConfig, flag, description) { return { parseOptions: { [name]: parseConfig }, @@ -68,6 +113,11 @@ export function defineCliOption(name, parseConfig, flag, description) { }; } +/** + * @param {string} name + * @param {import("node:util").ParseArgsOptionsConfig[string]} parseConfig + * @returns {CliOptionSpec} + */ export function defineHiddenCliOption(name, parseConfig) { return defineCliOption(name, parseConfig, null, null); } @@ -95,13 +145,16 @@ const CLI_OPTION_PRESETS = { help: [OPTION_DEFS.help], }; -/** @returns {import("node:util").ParseArgsOptionsConfig} */ +/** + * @param {Iterable} options + * @returns {import("node:util").ParseArgsOptionsConfig} + */ export function optionParseOptions(options) { /** @type {import("node:util").ParseArgsOptionsConfig} */ const out = {}; for (const item of options) { if (typeof item === "string") { - const specs = CLI_OPTION_PRESETS[item]; + const specs = presetSpecs(item); if (!specs) throw new Error(`unknown option preset "${item}"`); Object.assign(out, optionParseOptions(specs)); continue; @@ -115,11 +168,16 @@ export function optionParseOptions(options) { return out; } +/** + * @param {Iterable} options + * @returns {string[]} + */ export function optionHelp(options) { + /** @type {string[]} */ const lines = []; for (const item of options) { if (typeof item === "string") { - const specs = CLI_OPTION_PRESETS[item]; + const specs = presetSpecs(item); if (!specs) throw new Error(`unknown option preset "${item}"`); lines.push(...optionHelp(specs)); continue; @@ -130,10 +188,35 @@ export function optionHelp(options) { return lines; } +/** + * @param {string} name + * @returns {CliOptionSpec[] | undefined} + */ +function presetSpecs(name) { + return Object.hasOwn(CLI_OPTION_PRESETS, name) + ? CLI_OPTION_PRESETS[/** @type {keyof typeof CLI_OPTION_PRESETS} */ (name)] + : undefined; +} + +/** + * @param {unknown} item + * @returns {item is CliOptionSpec} + */ function isCliOptionSpec(item) { return Boolean(item && typeof item === "object" && Object.hasOwn(item, "parseOptions")); } +/** + * Narrow an unknown caught value to an error carrying a string `code` + * (e.g. Node fs errors with `code === "ENOENT"`). + * @param {unknown} err + * @returns {err is { code: string }} + */ +export function hasErrorCode(err) { + return Boolean(err) && typeof err === "object" && typeof (/** @type {{ code?: unknown }} */ (err)).code === "string"; +} + +/** @param {unknown} err */ export function handleCliError(err) { if (err instanceof CliError) { console.error(`error: ${err.message}`); @@ -146,6 +229,10 @@ export function handleCliError(err) { throw err; } +/** + * @param {(argv: string[]) => Promise | unknown} run + * @param {string[]} [argv] + */ export async function runCliMain(run, argv = process.argv.slice(2)) { try { await run(argv); @@ -154,21 +241,43 @@ export async function runCliMain(run, argv = process.argv.slice(2)) { } } +/** + * @param {unknown} requested + * @param {string | (() => string)} usageText + * @param {(line: string) => void} [stdout] + */ export function printHelpIfRequested(requested, usageText, stdout = (line) => console.log(line)) { if (!requested) return false; stdout(typeof usageText === "function" ? usageText() : usageText); return true; } +/** + * @param {unknown} err + * @returns {err is Error & { code: string }} + */ function isParseArgsError(err) { - return err && typeof err.code === "string" && err.code.startsWith("ERR_PARSE_ARGS_"); + return Boolean(err) && typeof (/** @type {{ code?: unknown }} */ (err)).code === "string" && + /** @type {{ code: string }} */ (err).code.startsWith("ERR_PARSE_ARGS_"); } +/** + * @param {import("./control-fetch.js").ControlJsonResponse} res + * @param {string} label + * @returns {Promise} + */ export async function readJsonOrFail(res, label) { await throwHttpErrorIfNotOk(res, label); + // throwHttpErrorIfNotOk only returns for a 2xx response, which always carries + // a json reader; the optional type is for the error-path callers above it. + if (typeof res.json !== "function") throw new CliError(`${label} failed: response is not JSON`); return await res.json(); } +/** + * @param {import("./control-fetch.js").ControlResponseStatus} res + * @param {string} label + */ export async function throwHttpErrorIfNotOk(res, label) { if (res.ok) return; throw new CliError(`${label} failed: ${formatHttpError(res.status, await res.text())}`); @@ -188,10 +297,15 @@ const ARRAY_CONTEXT_KEYS = new Set([ "warnings", ]); +/** + * @param {number | undefined} status + * @param {unknown} text + */ function formatHttpError(status, text) { const raw = typeof text === "string" ? text.trim() : ""; if (!raw) return String(status); + /** @type {unknown} */ let body; try { body = JSON.parse(raw); @@ -203,8 +317,9 @@ function formatHttpError(status, text) { return `${status} ${escapeTerminalText(raw)}`; } - const error = escapeTerminalText(scalarString(body.error)); - const message = escapeTerminalText(scalarString(body.message)); + const record = /** @type {Record} */ (body); + const error = escapeTerminalText(scalarString(record.error)); + const message = escapeTerminalText(scalarString(record.message)); const parts = [String(status)]; let summary = error || message || ""; @@ -217,8 +332,9 @@ function formatHttpError(status, text) { return `${status} ${escapeTerminalText(raw)}`; } + /** @type {string[]} */ const context = []; - for (const [key, value] of Object.entries(body)) { + for (const [key, value] of Object.entries(record)) { if (ERROR_SUMMARY_KEYS.has(key)) continue; if (value == null) continue; const safeKey = escapeTerminalText(key); @@ -235,12 +351,14 @@ function formatHttpError(status, text) { return parts.join(" "); } +/** @param {unknown} value */ function scalarString(value) { if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") return String(value); return ""; } +/** @param {string | number | boolean} value */ function formatContextValue(value) { if (typeof value !== "string") return String(value); const escaped = escapeTerminalText(value); @@ -248,6 +366,7 @@ function formatContextValue(value) { return escaped; } +/** @param {string} segment */ export function encodePath(segment) { return encodeURIComponent(segment); } @@ -255,12 +374,20 @@ export function encodePath(segment) { // True when `target` is `root` or lives inside it. Bare startsWith("..") would // also reject siblings like "..hidden", so check the exact ".." and the // "../" prefix. Shared by the d1 migrations-dir and --file containment checks. +/** + * @param {string} root + * @param {string} target + */ export function isPathInside(root, target) { const rel = path.relative(root, target); if (rel === "") return true; return rel !== ".." && !rel.startsWith(".." + path.sep) && !path.isAbsolute(rel); } +/** + * @param {string} importMetaUrl + * @param {string[]} [argv] + */ export function isMain(importMetaUrl, argv = process.argv) { if (!argv[1]) return false; try { diff --git a/lib/config-state.js b/lib/config-state.js index fc3b3dc..bfe6dff 100644 --- a/lib/config-state.js +++ b/lib/config-state.js @@ -4,9 +4,14 @@ import { flagSet, isTokenStoreDisabled, loadCliControlEnv, protectedEnvKeys, res import { maskToken } from "./output.js"; import { tokenStoreReader } from "./token-store.js"; +/** + * A resolved config field with its display value and provenance. + * @typedef {{ value: string | null, display: string, source: string, error: string | null }} ConfigEntry + */ + /** * @param {{ - * values?: Record, + * values?: Record, * env?: NodeJS.ProcessEnv, * cwd?: string, * dotenvPath?: string, @@ -43,7 +48,7 @@ export function resolveCliConfigState({ values = {}, env = process.env, cwd = pr const tokenStoreDisabled = isTokenStoreDisabled(workingEnv, values["no-token-store"] === true); loadCliControlEnv(workingEnv, { dotenvPath: resolvedDotenvPath, - nsFromFlag: values.ns, + nsFromFlag: /** @type {string | undefined} */ (values.ns), tokenFromFlag: flagSet(values, "token"), controlUrlFromFlag: flagSet(values, "control-url"), protectedKeys, @@ -70,6 +75,12 @@ export function resolveCliConfigState({ values = {}, env = process.env, cwd = pr }; } +/** + * @param {Record} values + * @param {NodeJS.ProcessEnv} env + * @param {Map} sources + * @returns {ConfigEntry} + */ function controlUrlEntry(values, env, sources) { try { return configEntry({ @@ -88,6 +99,10 @@ function controlUrlEntry(values, env, sources) { } } +/** + * @param {{ value?: string | null, source?: string | null, display?: string | null, error?: string | null }} entry + * @returns {ConfigEntry} + */ function configEntry({ value, source, display = value, error = null }) { return { value: value ?? null, @@ -97,28 +112,51 @@ function configEntry({ value, source, display = value, error = null }) { }; } +/** + * @param {Record} values + * @param {NodeJS.ProcessEnv} env + * @param {Map} sources + */ function sourceForNamespace(values, env, sources) { if (isNonEmptyString(values.ns)) return "--ns"; if (isNonEmptyString(env.WDL_NS)) return sources.get("WDL_NS") || "WDL_NS env"; return null; } +/** + * @param {Record} values + * @param {NodeJS.ProcessEnv} env + * @param {Map} sources + */ function sourceForControlUrl(values, env, sources) { if (flagSet(values, "control-url")) return "--control-url"; if (isNonEmptyString(env.CONTROL_URL)) return sources.get("CONTROL_URL") || "CONTROL_URL env"; return null; } +/** + * @param {Record} values + * @param {NodeJS.ProcessEnv} env + */ function tokenValue(values, env) { return firstString(values.token, env.ADMIN_TOKEN); } +/** + * @param {Record} values + * @param {NodeJS.ProcessEnv} env + * @param {Map} sources + */ function sourceForToken(values, env, sources) { if (flagSet(values, "token")) return "--token"; if (isNonEmptyString(env.ADMIN_TOKEN)) return sources.get("ADMIN_TOKEN") || "ADMIN_TOKEN env"; return null; } +/** + * @param {...unknown} values + * @returns {string | null} + */ function firstString(...values) { for (const value of values) { if (isNonEmptyString(value)) return value; diff --git a/lib/control-fetch.js b/lib/control-fetch.js index b885151..c733954 100644 --- a/lib/control-fetch.js +++ b/lib/control-fetch.js @@ -15,6 +15,61 @@ export const LONG_CONTROL_TIMEOUT_MS = 5 * 60_000; export const DEFAULT_CONTROL_MAX_BODY_BYTES = 10 * 1024 * 1024; export const UNLIMITED_CONTROL_BODY_BYTES = 0; +/** + * The buffered-or-streamed control-plane response shape returned by + * {@link controlFetch}, {@link readControlResponse}, and + * {@link streamControlResponse}. `body` is present only on a streamed response. + * @typedef {object} ControlResponse + * @property {number | undefined} status HTTP status code (undefined for non-stream test fakes). + * @property {boolean} ok True for a 2xx status. + * @property {import("node:http").IncomingHttpHeaders} headers + * @property {import("node:stream").Readable} [body] Streamed body (streamResponse only). + * @property {() => Promise} text + * @property {() => Promise} json + * @property {() => Promise} arrayBuffer + */ + +/** + * The minimal control-response surface the status/error helpers read: enough to + * decide ok/error and read the textual body. Broader than {@link ControlResponse} + * on purpose so the unit-test response fakes (which omit `headers`) still fit. + * @typedef {object} ControlResponseStatus + * @property {boolean} ok + * @property {number} [status] + * @property {() => Promise} text + */ + +/** + * A control response whose JSON body can be read after the status check. `json` + * is optional because error-path callers only reach `text()` — a 2xx response + * always carries it. + * @typedef {ControlResponseStatus & { json?: () => Promise }} ControlJsonResponse + */ + +/** + * The transport surface `controlFetch` uses: just `request()`. Real `node:http` + * / `node:https` satisfy it, and so does the unit-test fake (which provides only + * `request`). + * @typedef {{ request: (options: import("node:https").RequestOptions, onResponse: (res: import("node:http").IncomingMessage) => void) => import("node:http").ClientRequest }} ControlTransport + */ + +/** + * @typedef {object} ControlFetchInit + * @property {ControlTransport} [transport] + * @property {number} [timeoutMs] + * @property {number} [maxBodyBytes] + * @property {AbortSignal} [signal] + * @property {string} [method] + * @property {import("node:http").OutgoingHttpHeaders} [headers] + * @property {boolean} [streamResponse] + * @property {string | Buffer | Uint8Array | null} [body] + */ + +/** + * @param {string} urlStr + * @param {ControlFetchInit} [init] + * @returns {Promise} + */ export function controlFetch(urlStr, init = {}) { const u = new URL(urlStr); const transport = init.transport || (u.protocol === "https:" ? https : http); @@ -28,8 +83,11 @@ export function controlFetch(urlStr, init = {}) { return new Promise((resolve, reject) => { let settled = false; + /** @type {ReturnType | null} */ let timer = null; + /** @type {import("node:http").IncomingMessage | null} */ let streamRes = null; + /** @type {import("node:stream").Transform | null} */ let streamBody = null; const cleanup = () => { @@ -37,6 +95,7 @@ export function controlFetch(urlStr, init = {}) { if (signal) signal.removeEventListener("abort", onAbort); }; + /** @param {Error} err */ const fail = (err) => { if (settled) return; settled = true; @@ -45,6 +104,7 @@ export function controlFetch(urlStr, init = {}) { reject(err); }; + /** @param {Error} err */ const failStream = (err) => { cleanup(); req.destroy(err); @@ -52,6 +112,7 @@ export function controlFetch(urlStr, init = {}) { if (streamBody) streamBody.destroy(err); }; + /** @param {Error} err */ const failRequestOrStream = (err) => { if (streamRes) { failStream(err); @@ -80,15 +141,16 @@ export function controlFetch(urlStr, init = {}) { if (settled) return; settled = true; streamRes = res; - streamBody = createTimeoutResetStream(resetTimer); - res.pipe(streamBody); + const body = createTimeoutResetStream(resetTimer); + streamBody = body; + res.pipe(body); res.once("end", cleanup); res.once("error", (err) => { cleanup(); - streamBody.destroy(err); + body.destroy(err); }); res.once("close", cleanup); - resolve(streamControlResponse(res, streamBody)); + resolve(streamControlResponse(res, body)); return; } readControlResponse(res, { maxBodyBytes }).then((response) => { @@ -111,8 +173,13 @@ export function controlFetch(urlStr, init = {}) { // CONTROL_CONNECT_HOST overrides the TCP target while the Host header and // SNI keep tracking the URL authority — the ALB's cert is issued for the // admin host. +/** + * @param {URL} u + * @returns {import("node:https").RequestOptions} + */ export function controlRequestOptions(u) { const isHttps = u.protocol === "https:"; + /** @type {import("node:https").RequestOptions} */ const opts = { host: process.env.CONTROL_CONNECT_HOST || bareHostname(u), port: Number(u.port) || (isHttps ? 443 : 80), @@ -127,11 +194,13 @@ export function controlRequestOptions(u) { // URL.hostname keeps IPv6 literals bracketed ("[::1]"), but the socket layer // (DNS lookup, SNI) needs the bare address. +/** @param {URL} u */ function bareHostname(u) { const match = /^\[(.*)\]$/.exec(u.hostname); return match ? match[1] : u.hostname; } +/** @param {() => void} resetTimer */ function createTimeoutResetStream(resetTimer) { return new Transform({ transform(chunk, _encoding, callback) { @@ -141,10 +210,16 @@ function createTimeoutResetStream(resetTimer) { }); } +/** + * @param {import("node:http").IncomingMessage} res + * @param {import("node:stream").Readable} body + * @returns {ControlResponse} + */ function streamControlResponse(res, body) { + const status = res.statusCode; return { - status: res.statusCode, - ok: res.statusCode >= 200 && res.statusCode < 300, + status, + ok: status !== undefined && status >= 200 && status < 300, headers: res.headers, body, text: async () => (await readControlResponse(body)).text(), @@ -155,12 +230,26 @@ function streamControlResponse(res, body) { }; } +/** + * The readable source `readControlResponse` drains: either a real + * `IncomingMessage` (status/headers populated) or the internal pipe stream used + * for a streamed body (status/headers absent, re-read off the already-captured + * `ControlResponse`). `destroy` is optional to tolerate the non-stream test fakes. + * @typedef {import("node:stream").Readable & { statusCode?: number, headers?: import("node:http").IncomingHttpHeaders, destroy?: () => void }} ControlBodySource + */ + +/** + * @param {ControlBodySource} res + * @param {{ maxBodyBytes?: number }} [options] + * @returns {Promise} + */ export function readControlResponse(res, { maxBodyBytes = DEFAULT_CONTROL_MAX_BODY_BYTES } = {}) { return new Promise((resolve, reject) => { + /** @type {Buffer[]} */ const chunks = []; let totalBytes = 0; let settled = false; - res.on("data", (c) => { + res.on("data", (/** @type {Buffer} */ c) => { if (settled) return; totalBytes += c.length; if (maxBodyBytes > 0 && totalBytes > maxBodyBytes) { @@ -178,10 +267,11 @@ export function readControlResponse(res, { maxBodyBytes = DEFAULT_CONTROL_MAX_BO if (settled) return; const buf = Buffer.concat(chunks); const text = () => buf.toString("utf8"); + const status = res.statusCode; resolve({ - status: res.statusCode, - ok: res.statusCode >= 200 && res.statusCode < 300, - headers: res.headers, + status, + ok: status !== undefined && status >= 200 && status < 300, + headers: res.headers ?? {}, text: async () => text(), json: async () => JSON.parse(text()), arrayBuffer: async () => buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength), diff --git a/lib/credentials.js b/lib/credentials.js index d9c6263..77331cb 100644 --- a/lib/credentials.js +++ b/lib/credentials.js @@ -3,7 +3,7 @@ // token-store pipeline with its cross-origin guard and store gap-fill). import { readFileSync } from "node:fs"; -import { CliError, isNonEmptyString } from "./common.js"; +import { CliError, hasErrorCode, isNonEmptyString } from "./common.js"; import { CLI_DOTENV_KEYS, parseDotEnvSection, parseDotEnvValue } from "./dotenv.js"; import { isAdminAcceptableNs } from "./ns-pattern.js"; @@ -11,6 +11,10 @@ import { isAdminAcceptableNs } from "./ns-pattern.js"; // must not redirect these for a token that came from the shell/--token. const CONTROL_ENDPOINT_KEYS = ["CONTROL_URL", "CONTROL_CONNECT_HOST"]; +/** + * @param {Record} values + * @param {NodeJS.ProcessEnv} [env] + */ export function resolveControlUrl(values, env = process.env) { const raw = values["control-url"] || env.CONTROL_URL; // No built-in default: a fallback host would silently receive the admin @@ -37,6 +41,7 @@ export function resolveControlUrl(values, env = process.env) { return normalized; } +/** @param {string} text */ function defaultSchemeForBareControlUrl(text) { const hostPort = text.split("/")[0] || text; let host = hostPort; @@ -58,8 +63,13 @@ function defaultSchemeForBareControlUrl(text) { return "https"; } +/** + * @param {Record} values + * @param {NodeJS.ProcessEnv} [env] + * @returns {{ controlUrl: string, token: string, headers: Record }} + */ export function resolveControlContext(values, env = process.env) { - const token = values.token || env.ADMIN_TOKEN; + const token = /** @type {string | undefined} */ (values.token) || env.ADMIN_TOKEN; if (!token) { throw new CliError("Missing admin token. Run 'wdl token set --ns --control-url ' (recommended), pass --token , or set ADMIN_TOKEN."); } @@ -75,6 +85,10 @@ export function resolveControlContext(values, env = process.env) { // doctor) reports the same way. `warn` receives one line WITHOUT a trailing // newline — the default console.error is line-buffered everywhere, unlike // the per-command stderr sinks whose newline conventions differ. +/** + * @param {string} controlUrl + * @param {(line: string) => void} [warn] + */ export function warnIfInsecureControlUrl(controlUrl, warn = (line) => console.error(line)) { if (!isInsecureControlUrl(controlUrl)) return; warn(`warning: control URL ${controlUrl} is plain http on a non-local host; the admin token will be sent unencrypted`); @@ -82,6 +96,7 @@ export function warnIfInsecureControlUrl(controlUrl, warn = (line) => console.er // True when the admin token would travel unencrypted to a host that doesn't // look like a local/dev target. +/** @param {string} controlUrl */ function isInsecureControlUrl(controlUrl) { let parsed; try { @@ -95,6 +110,7 @@ function isInsecureControlUrl(controlUrl) { // Loopback / dev-TLD hosts, shared by the bare-URL scheme default and the // plaintext-token warning so the two policies cannot drift. Accepts both the // bare IPv6 form and the bracketed form URL.hostname produces. +/** @param {string} host */ export function isLocalDevHost(host) { return ( host === "localhost" || @@ -106,16 +122,28 @@ export function isLocalDevHost(host) { ); } +/** + * @param {Record} values + * @param {NodeJS.ProcessEnv} [env] + */ export function resolveNamespace(values, env = process.env) { return firstNonEmptyString(values.ns, env.WDL_NS); } +/** + * @param {...unknown} values + * @returns {string | undefined} + */ function firstNonEmptyString(...values) { return values.find(isNonEmptyString); } // A flag is "set" only when non-empty: an empty `--token ""` (or a missing / // boolean flag) falls back to env. +/** + * @param {Record} values + * @param {string} name + */ export function flagSet(values, name) { return isNonEmptyString(values[name]); } @@ -123,10 +151,23 @@ export function flagSet(values, name) { // Shell/CI env wins over `.env`, but only when actually set — an empty/unset // value must not protect its key, or it blocks the `.env` value AND then lets a // lower-precedence store default win (inverting `.env` > store-default). +/** @param {NodeJS.ProcessEnv} env */ export function protectedEnvKeys(env) { return new Set(Object.keys(env).filter((key) => isNonEmptyString(env[key]))); } +/** + * @param {NodeJS.ProcessEnv} [env] + * @param {string} [path] + * @param {{ + * resolvedNs?: string, + * loadBase?: boolean, + * protectedKeys?: Set, + * warn?: (message: string) => void, + * onLoad?: ((entry: { key: string, value: string, section: string | null, line: number }) => void) | null, + * }} [options] + * @returns {string[]} + */ export function loadCliDotEnv( env = process.env, path = ".env", @@ -136,7 +177,7 @@ export function loadCliDotEnv( try { text = readFileSync(path, "utf8"); } catch (err) { - if (err && err.code === "ENOENT") return []; + if (hasErrorCode(err) && err.code === "ENOENT") return []; throw err; } @@ -148,7 +189,9 @@ export function loadCliDotEnv( onLoad = null, } = options; const selectedSection = firstNonEmptyString(resolvedNs); + /** @type {string[]} */ const loaded = []; + /** @type {string | null} */ let section = null; for (const [idx, rawLine] of text.replace(/^\uFEFF/, "").split(/\r?\n/).entries()) { const line = rawLine.trim(); @@ -204,7 +247,7 @@ export function loadCliDotEnv( * controlUrlFromFlag?: boolean, * protectedKeys?: Set, * loadEnv?: typeof loadCliDotEnv, - * readStore?: (env: NodeJS.ProcessEnv) => { defaultNs?: string | null, namespaces?: Record> }, + * readStore?: (env: NodeJS.ProcessEnv) => import("./token-store.js").TokenStore, * warn?: (message: string) => void, * onCrossOrigin?: (line: string) => void, * onLoad?: (entry: { key: string, value: string, section: string | null, line: number, origin?: "store" | "store-default" }) => void, @@ -222,9 +265,11 @@ export function loadCliControlEnv(env, { onCrossOrigin = (line) => console.error(line), onLoad, } = {}) { + /** @type {Set} */ const loaded = new Set(); // loadCliDotEnv returns the loaded keys; a test-injected loader may return // something else, so guard the type rather than assume an array. + /** @param {unknown} result */ const record = (result) => { if (Array.isArray(result)) for (const key of result) loaded.add(key); }; @@ -235,6 +280,7 @@ export function loadCliControlEnv(env, { // 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. + /** @type {import("./token-store.js").TokenStore | undefined} */ let storeData; const getStore = () => (storeData ??= (readStore(env) || {})); @@ -251,6 +297,7 @@ export function loadCliControlEnv(env, { // /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. + /** @type {import("./token-store.js").TokenStore | undefined} */ let s; try { s = getStore(); @@ -262,8 +309,8 @@ export function loadCliControlEnv(env, { 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" }); + env.WDL_NS = def; + if (onLoad) onLoad({ key: "WDL_NS", value: def, section: def, line: 0, origin: "store-default" }); } } } @@ -290,6 +337,10 @@ export function loadCliControlEnv(env, { // `--no-token-store` / `WDL_TOKEN_STORE=off` opt out of the global store for // credential RESOLUTION only. It does not hide the on-disk file from project // build code running as the same OS user (see docs/token.md). +/** + * @param {NodeJS.ProcessEnv} env + * @param {boolean} [flag] + */ export function isTokenStoreDisabled(env, flag = false) { if (flag) return true; return isNonEmptyString(env.WDL_TOKEN_STORE) && env.WDL_TOKEN_STORE.toLowerCase() === "off"; @@ -297,9 +348,16 @@ export function isTokenStoreDisabled(env, flag = false) { // Only the control-plane endpoint and token are materialized into env from a // store section; LABEL is store-only metadata for `wdl token list`. +/** @type {readonly ["CONTROL_URL", "ADMIN_TOKEN"]} */ const STORE_ENV_KEYS = ["CONTROL_URL", "ADMIN_TOKEN"]; -/** @param {Record} [covered] */ +/** + * @param {NodeJS.ProcessEnv} env + * @param {string} ns + * @param {Record>} namespaces + * @param {((entry: { key: string, value: string, section: string | null, line: number, origin?: "store" | "store-default" }) => void) | undefined} onLoad + * @param {Partial>} [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. @@ -325,6 +383,12 @@ function fillFromTokenStore(env, ns, namespaces, onLoad, covered = {}) { // could redirect a shell/--token credential to a host it chose — so drop the // .env endpoint (resolution falls back to shell/default) and warn. Same-source // .env (token + URL together, single-tenant) and shell-sourced URLs are fine. +/** + * @param {NodeJS.ProcessEnv} env + * @param {Set} loadedFromDotenv + * @param {boolean} tokenFromFlag + * @param {(line: string) => void} onCrossOrigin + */ function guardCrossOriginControlEnv(env, loadedFromDotenv, tokenFromFlag, onCrossOrigin) { // 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 diff --git a/lib/dotenv.js b/lib/dotenv.js index 0a4603f..88452d7 100644 --- a/lib/dotenv.js +++ b/lib/dotenv.js @@ -21,6 +21,10 @@ export const CLI_DOTENV_KEYS = new Set([ // an alias as an input must not turn it into a leak. export const WRANGLER_SCRUB_KEYS = new Set([...CLI_DOTENV_KEYS, "ADMIN_URL"]); +/** + * @param {string} line + * @param {number} lineNumber + */ export function parseDotEnvSection(line, lineNumber) { if (!line.startsWith("[")) return null; const match = line.match(/^\[([^\]]*)\]\s*(?:#.*)?$/); @@ -31,6 +35,7 @@ export function parseDotEnvSection(line, lineNumber) { } +/** @param {string} value */ export function parseDotEnvValue(value) { if (!value) return ""; const quote = value[0]; @@ -56,6 +61,7 @@ export function parseDotEnvValue(value) { // 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). +/** @param {unknown} value */ export function quoteValue(value) { const escaped = String(value) .replaceAll("\\", "\\\\") @@ -71,6 +77,7 @@ export function quoteValue(value) { // 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. +/** @param {string} s */ function unescapeDoubleQuoted(s) { let out = ""; for (let i = 0; i < s.length; i += 1) { @@ -90,6 +97,10 @@ function unescapeDoubleQuoted(s) { return out; } +/** + * @param {string} value + * @param {string} quote + */ function findClosingQuote(value, quote) { for (let i = 1; i < value.length; i += 1) { if (value[i] !== quote) continue; @@ -99,6 +110,10 @@ function findClosingQuote(value, quote) { return -1; } +/** + * @param {string} value + * @param {number} idx + */ function isEscaped(value, idx) { let count = 0; for (let i = idx - 1; i >= 0 && value[i] === "\\"; i -= 1) count += 1; diff --git a/lib/ns-pattern.js b/lib/ns-pattern.js index a20b549..79a8d48 100644 --- a/lib/ns-pattern.js +++ b/lib/ns-pattern.js @@ -18,6 +18,10 @@ export const BINDING_NAME_RE = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/; // Keep in sync with shared/ns-pattern.js#JS_IDENTIFIER_RE. export const JS_IDENTIFIER_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +/** + * @param {unknown} value + * @returns {value is string} + */ export function isValidJsIdentifier(value) { return typeof value === "string" && JS_IDENTIFIER_RE.test(value); } @@ -74,6 +78,7 @@ export const JS_CLASS_DECLARATION_RESERVED_WORDS = new Set([ "yield", ]); +/** @param {unknown} value */ export function isValidJsClassDeclarationName(value) { return isValidJsIdentifier(value) && !JS_CLASS_DECLARATION_RESERVED_WORDS.has(value); } @@ -110,10 +115,12 @@ export const WDL_RESERVED_ENTRYPOINT_RE = /^__Wdl[A-Za-z0-9_]*__$/; const NS_RE = new RegExp(`^${NS_PATTERN}$`); +/** @param {unknown} ns */ export function isReservedNs(ns) { return typeof ns === "string" && RESERVED_NS_SECTION_RE.test(ns); } +/** @param {unknown} ns */ export function isAdminAcceptableNs(ns) { if (typeof ns !== "string") return false; if (RESERVED_TENANT_NS.has(ns)) return false; diff --git a/lib/output.js b/lib/output.js index af80cb0..4985094 100644 --- a/lib/output.js +++ b/lib/output.js @@ -3,15 +3,21 @@ // writeStatusLine / writeJsonOr / writeJson), plus warning + token-display // formatting. Pure string/IO helpers — no imports. +/** + * @param {unknown} value + * @param {Iterable} keys + */ export function formatKnownWarning(value, keys) { if (!value || typeof value !== "object" || Array.isArray(value)) { return escapeTerminalText(String(value)); } + /** @type {Record>} */ const out = {}; + const record = /** @type {Record} */ (value); for (const key of keys) { - if (!Object.hasOwn(value, key)) continue; - const field = value[key]; + if (!Object.hasOwn(record, key)) continue; + const field = record[key]; if (field == null) continue; if (typeof field === "string" || typeof field === "number" || typeof field === "boolean") { out[key] = field; @@ -24,6 +30,10 @@ export function formatKnownWarning(value, keys) { return escapeTerminalText(JSON.stringify(out)); } +/** + * @param {unknown} value + * @returns {value is string | number | boolean | null} + */ function isScalarWarningField(value) { return value == null || typeof value === "string" || @@ -35,6 +45,7 @@ function isScalarWarningField(value) { // eslint-disable-next-line no-control-regex const TERMINAL_CONTROL_RE = /[\u0000-\u001f\u007f-\u009f]/; +/** @param {unknown} value */ export function escapeTerminalText(value) { const text = String(value); // Fast path: almost all text is clean, and callers sit on hot paths @@ -46,6 +57,7 @@ export function escapeTerminalText(value) { // Mask a token for display: `****` plus the last 4 chars, but only when that // reveals at most half the token (short tokens show no suffix). "(unset)" for // an empty/absent token. +/** @param {unknown} token */ export function maskToken(token) { if (!token) return "(unset)"; const text = String(token); @@ -54,6 +66,10 @@ export function maskToken(token) { } // Shared escape walk. keepLayout leaves tab/newline intact for human output. +/** + * @param {string} text + * @param {boolean} keepLayout + */ function escapeControlChars(text, keepLayout) { let out = ""; for (const ch of text) { @@ -64,6 +80,10 @@ function escapeControlChars(text, keepLayout) { return out; } +/** + * @param {string} ch + * @param {number} code + */ function escapeControlChar(ch, code) { switch (ch) { case "\n": return "\\n"; @@ -76,6 +96,7 @@ function escapeControlChar(ch, code) { } } +/** @param {number} code */ function isTerminalControlCode(code) { return (code >= 0x00 && code <= 0x1f) || (code >= 0x7f && code <= 0x9f); } @@ -89,6 +110,7 @@ const TERMINAL_LAYOUT_SAFE_RE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/; // Human-output variant of escapeTerminalText (keeps the layout chars per the // regex above), used at the writeResult choke point and by tail's renderer. +/** @param {unknown} value */ export function escapeTerminalLines(value) { const text = String(value); // Fast path: clean text (tabs/newlines allowed) needs no work. @@ -96,16 +118,27 @@ export function escapeTerminalLines(value) { return escapeControlChars(text, true); } +/** @param {unknown} value */ 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. +/** + * @param {(line: string) => void} stdout + * @param {unknown} body + */ export function writeJson(stdout, body) { stdout(JSON.stringify(body, null, 2)); } +/** + * @param {boolean} json + * @param {unknown} body + * @param {() => Iterable} format + * @param {(line: string) => void} stdout + */ export function writeResult(json, body, format, stdout) { if (json) { writeJson(stdout, body); @@ -122,6 +155,10 @@ export function writeResult(json, body, format, stdout) { // 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. +/** + * @param {(line: string) => void} stdout + * @param {unknown} line + */ export function writeStatusLine(stdout, line) { stdout(escapeTerminalText(line)); } @@ -130,6 +167,11 @@ export function writeStatusLine(stdout, line) { // 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. +/** + * @param {boolean} json + * @param {unknown} body + * @param {(line: string) => void} stdout + */ export function writeJsonOr(json, body, stdout) { if (!json) return false; writeJson(stdout, body); diff --git a/lib/package-info.js b/lib/package-info.js index 108e2f9..4cb87bc 100644 --- a/lib/package-info.js +++ b/lib/package-info.js @@ -4,10 +4,12 @@ import { fileURLToPath } from "node:url"; export const CLI_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +/** @returns {{ version?: string, [key: string]: unknown }} */ export function readCliPackageJson() { return JSON.parse(readFileSync(path.join(CLI_ROOT, "package.json"), "utf8")); } +/** @returns {string | undefined} */ export function currentCliVersion() { return readCliPackageJson().version; } diff --git a/lib/stdin.js b/lib/stdin.js index c08dffb..9c3f710 100644 --- a/lib/stdin.js +++ b/lib/stdin.js @@ -8,7 +8,13 @@ import { escapeTerminalText } from "./output.js"; /** * A duck-typed stdin (process.stdin or a test stub). setRawMode is optional — * only the hidden-input path (readTtyLine) needs it. - * @typedef {{ isTTY?: boolean, setEncoding: (encoding: string) => void, setRawMode?: (mode: boolean) => void, on: Function, off: Function, pause?: Function }} StdinLike + * @typedef {object} StdinLike + * @property {boolean} [isTTY] + * @property {(encoding: BufferEncoding) => unknown} setEncoding + * @property {(mode: boolean) => unknown} [setRawMode] + * @property {(event: string, listener: (...args: A) => void) => unknown} on + * @property {(event: string, listener: (...args: A) => void) => unknown} off + * @property {() => unknown} [pause] */ /** @@ -58,21 +64,24 @@ export function readTtyLine(stdin, { prompt, stderr, hidden = false } = {}) { stdin.off("end", onEnd); stdin.off("error", onError); if (raw) { - try { stdin.setRawMode(false); } catch { /* terminal already restored */ } + try { stdin.setRawMode?.(false); } catch { /* terminal already restored */ } if (stderr) stderr("\n"); // the un-echoed Enter still needs a line break } if (typeof stdin.pause === "function") stdin.pause(); }; + /** @param {string} value */ const finish = (value) => { cleanup(); resolve(value); }; + /** @param {unknown} err */ const fail = (err) => { cleanup(); reject(err); }; + /** @param {string} chunk */ const onData = (chunk) => { if (!raw) { data += chunk; @@ -89,6 +98,7 @@ export function readTtyLine(stdin, { prompt, stderr, hidden = false } = {}) { } }; const onEnd = () => finish(raw ? data : data.replace(/\r?\n$/, "")); + /** @param {unknown} err */ const onError = (err) => fail(err); stdin.setEncoding("utf8"); @@ -126,7 +136,7 @@ export function readSecretStdin(stdin, { prompt, stderr } = {}) { return new Promise((resolve, reject) => { let data = ""; stdin.setEncoding("utf8"); - stdin.on("data", (chunk) => (data += chunk)); + stdin.on("data", (/** @type {string} */ chunk) => (data += chunk)); stdin.on("end", () => resolve(data.replace(/\r?\n$/, ""))); stdin.on("error", reject); }); diff --git a/lib/token-store.js b/lib/token-store.js index ebae1fb..57f9fe3 100644 --- a/lib/token-store.js +++ b/lib/token-store.js @@ -1,7 +1,7 @@ import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { CliError, isNonEmptyString } from "./common.js"; +import { CliError, hasErrorCode, isNonEmptyString } from "./common.js"; import { parseDotEnvSection, parseDotEnvValue, quoteValue } from "./dotenv.js"; // The global credential store is the lowest-precedence layer of the same @@ -16,8 +16,19 @@ import { parseDotEnvSection, parseDotEnvValue, quoteValue } from "./dotenv.js"; // project `.env`. The parsed store is `{ defaultNs, namespaces }`. const STORE_KEYS = ["CONTROL_URL", "ADMIN_TOKEN", "LABEL"]; +/** + * The parsed credential store: an optional default-namespace pointer plus the + * per-`[namespace]` sections (each a flat key/value map). + * @typedef {{ defaultNs?: string | null, namespaces?: Record> }} TokenStore + */ + // Resolve the per-user config directory: %APPDATA%\wdl on Windows, else // $XDG_CONFIG_HOME/wdl or ~/.config/wdl. `homedir` is injectable for tests. +/** + * @param {NodeJS.ProcessEnv} [env] + * @param {() => string} [homedir] + * @param {NodeJS.Platform} [platform] + */ export function tokenStoreDir(env = process.env, homedir = os.homedir, platform = process.platform) { if (platform === "win32" && env.APPDATA) { return path.join(env.APPDATA, "wdl"); @@ -29,6 +40,11 @@ export function tokenStoreDir(env = process.env, homedir = os.homedir, platform return path.join(base, "wdl"); } +/** + * @param {NodeJS.ProcessEnv} [env] + * @param {() => string} [homedir] + * @param {NodeJS.Platform} [platform] + */ export function tokenStorePath(env = process.env, homedir = os.homedir, platform = process.platform) { return path.join(tokenStoreDir(env, homedir, platform), "credentials"); } @@ -37,13 +53,17 @@ export function tokenStorePath(env = process.env, homedir = os.homedir, platform // 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> }} */ +/** + * @param {string} storePath + * @returns {{ defaultNs: string | null, namespaces: Record> }} + */ export function readTokenStore(storePath) { + /** @type {string} */ let text; try { text = readFileSync(storePath, "utf8"); } catch (err) { - if (err && err.code === "ENOENT") return { defaultNs: null, namespaces: {} }; + if (hasErrorCode(err) && err.code === "ENOENT") return { defaultNs: null, namespaces: {} }; throw err; } @@ -51,6 +71,7 @@ export function readTokenStore(storePath) { const namespaces = {}; /** @type {string | null} */ let defaultNs = null; + /** @type {string | null} */ let section = null; for (const [idx, rawLine] of text.replace(/^\uFEFF/, "").split(/\r?\n/).entries()) { const line = rawLine.trim(); @@ -93,6 +114,10 @@ export function readTokenStore(storePath) { // dir another user can delete, replace, or symlink-swap the fixed-path file. So // refuse to write into a dir anyone else can write. (POSIX only — Windows mode // bits are not meaningful here.) +/** + * @param {string} storeDir + * @param {NodeJS.Platform} [platform] + */ export function assertStoreDirSecure(storeDir, platform = process.platform) { if (platform === "win32") return; if ((statSync(storeDir).mode & 0o022) !== 0) { @@ -107,7 +132,10 @@ export function assertStoreDirSecure(storeDir, platform = process.platform) { // 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 */ +/** + * @param {string} storePath + * @param {TokenStore} store + */ export function writeTokenStore(storePath, store) { const namespaces = store.namespaces || {}; const lines = [ @@ -147,7 +175,7 @@ export function writeTokenStore(storePath, store) { try { chmodSync(storePath, 0o600); } catch (err) { - if (!err || err.code !== "ENOENT") throw err; + if (!hasErrorCode(err) || err.code !== "ENOENT") throw err; } writeFileSync(storePath, lines.join("\n"), { mode: 0o600 }); } @@ -155,6 +183,10 @@ export function writeTokenStore(storePath, store) { // The `readStore` loadCliControlEnv expects: the real disk reader, or a no-op // when the store is disabled (--no-token-store / WDL_TOKEN_STORE=off). Shared so // the bin dispatcher and config-state never drift on what "disabled" reads. +/** + * @param {boolean} disabled + * @returns {(env: NodeJS.ProcessEnv) => TokenStore} + */ export function tokenStoreReader(disabled) { return disabled ? () => ({}) : (env) => readTokenStore(tokenStorePath(env)); } From 02ffb31a172b0c4d139ba9a8db0caba69021d128 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 01:40:12 +0000 Subject: [PATCH 02/20] Add real JSDoc types to lib/wrangler, commands, and bin for strict mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Annotate the remaining non-test source — lib/wrangler/* + wrangler-pack + d1-files, the lib formatters (d1/r2/delete/workers/workflows-format, whoami), all commands/*, and bin/wdl.js — so the whole source tree passes `tsc --strict` with real types and zero `any`/`@ts-` suppressions. Highlights: - New wire-shape typedefs where formatters/commands read fixed fields (WranglerConfig, D1Database, R2Object/R2Bucket, WhoamiBody, SseEvent/ TailPayload, DoctorCheck, etc.); parsers that re-validate their input take `unknown` and narrow. - defineCommand's run spec uses method syntax with `values: Record` (bivariant), so each command can type `values` from its own flags without an `any` escape in the framework contract. - EventEmitter surfaces (StdinLike, ControlClientRequest, ControlBodySource) use a generic `` listener instead of `any[]`. Behavior unchanged; types only. All 374 unit tests pass. Tests are converted and global strict flips on in the next commit. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- bin/wdl.js | 23 +++- commands/config.js | 23 +++- commands/d1.js | 159 ++++++++++++++++-------- commands/delete.js | 6 +- commands/deploy.js | 81 ++++++++++--- commands/doctor.js | 63 ++++++++-- commands/init.js | 47 +++++++- commands/r2.js | 98 ++++++++++++--- commands/secret.js | 45 +++++-- commands/tail.js | 121 +++++++++++++++++-- commands/token.js | 22 +++- commands/whoami.js | 9 +- commands/workers.js | 6 +- commands/workflows.js | 39 ++++-- lib/bundle-modules.js | 4 +- lib/command.js | 2 +- lib/control-fetch.js | 18 ++- lib/d1-files.js | 36 +++++- lib/d1-format.js | 36 ++++++ lib/delete-format.js | 73 ++++++++++- lib/r2-format.js | 41 +++++++ lib/whoami.js | 103 +++++++++++++++- lib/workers-format.js | 13 ++ lib/workflows-format.js | 41 +++++++ lib/wrangler-pack.js | 78 +++++++++++- lib/wrangler/assets.js | 37 ++++++ lib/wrangler/bindings.js | 255 +++++++++++++++++++++++++++++++-------- lib/wrangler/command.js | 87 +++++++++++-- lib/wrangler/config.js | 94 ++++++++++++--- lib/wrangler/modules.js | 4 + lib/wrangler/utils.js | 10 ++ 31 files changed, 1431 insertions(+), 243 deletions(-) diff --git a/bin/wdl.js b/bin/wdl.js index 79b835a..fb79a90 100755 --- a/bin/wdl.js +++ b/bin/wdl.js @@ -28,6 +28,7 @@ const REGISTRY = [initCmd, deployCmd, secretCmd, workersCmd, deleteCmd, d1Cmd, r // Alias -> canonical command name. const ALIASES = { secrets: "secret" }; +/** @type {Record} */ const COMMANDS = Object.fromEntries(REGISTRY.map((c) => [c.meta.name, c])); for (const [alias, target] of Object.entries(ALIASES)) COMMANDS[alias] = COMMANDS[target]; @@ -38,6 +39,19 @@ for (const c of REGISTRY) { if (!c.meta.parseOptions) throw new Error(`command "${c.meta.name}" is missing meta.parseOptions`); } +/** + * One entry of {@link REGISTRY}: a command module exposing its run entrypoint + * and the metadata the dispatcher reads. + * @typedef {{ + * main: (argv?: string[]) => Promise, + * meta: { name: string, summary: string, autoloadEnv: boolean, parseOptions: import("node:util").ParseArgsOptionsConfig }, + * }} CommandModule + */ + +/** + * @param {string[]} [argv] + * @param {{ env?: NodeJS.ProcessEnv, loadEnv?: NonNullable[1]>["loadEnv"] | null }} [deps] + */ export async function main(argv = process.argv.slice(2), deps = {}) { const [command, ...rest] = argv; @@ -60,7 +74,8 @@ export async function main(argv = process.argv.slice(2), deps = {}) { const scanned = scanCommandArgs(commandModule, rest); // Tests pass loadEnv: null to disable autoload; an injected loader (or the // real default when undefined) flows straight into loadCliControlEnv. - const loadEnvOverride = Object.hasOwn(deps, "loadEnv") ? deps.loadEnv : undefined; + /** @type {NonNullable[1]>["loadEnv"]} */ + const loadEnvOverride = (Object.hasOwn(deps, "loadEnv") ? deps.loadEnv : undefined) ?? undefined; const skipAutoload = Object.hasOwn(deps, "loadEnv") && !deps.loadEnv; // Help never needs credentials, so a malformed .env must not block it. if (!skipAutoload && commandModule.meta.autoloadEnv && !scanned.help) { @@ -86,6 +101,10 @@ export async function main(argv = process.argv.slice(2), deps = {}) { // positional alias `wdl [flags] help` — are recognized with the // framework's own isHelpAlias. strict:false never throws on argv input; only // a broken option schema can throw, and that should surface loudly. +/** + * @param {CommandModule} commandModule + * @param {string[]} args + */ function scanCommandArgs(commandModule, args) { const { values, positionals } = parseArgs({ args, @@ -108,8 +127,10 @@ function scanCommandArgs(commandModule, args) { }; } +/** @param {number} exitCode */ function usage(exitCode) { // Aliases grouped by the command they point at, for the "(alias: …)" note. + /** @type {Record} */ const aliasesByTarget = {}; for (const [alias, target] of Object.entries(ALIASES)) { (aliasesByTarget[target] ??= []).push(alias); diff --git a/commands/config.js b/commands/config.js index 6a25bd5..edfe6b9 100644 --- a/commands/config.js +++ b/commands/config.js @@ -18,7 +18,7 @@ export const main = command.main; export const runConfigCommand = command.run; export const meta = command.meta; -/** @param {{ values: Record, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ +/** @param {{ values: { json?: boolean }, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ async function runConfig({ values, positionals, context }) { const [subcommand, extra] = positionals; if (subcommand !== "explain" || extra) throw new CliError(usageText()); @@ -29,10 +29,22 @@ async function runConfig({ values, positionals, context }) { controlUrl: publicEntry(state.controlUrl), token: publicEntry(state.token), }; - writeResult(values.json, body, () => formatConfigExplain(body), context.stdout); + writeResult(values.json === true, body, () => formatConfigExplain(body), context.stdout); } +/** + * @typedef {object} PublicConfigEntry + * @property {string} value + * @property {string} source + * @property {string} [error] + */ + +/** + * @param {import("../lib/config-state.js").ConfigEntry} entry + * @returns {PublicConfigEntry} + */ function publicEntry(entry) { + /** @type {PublicConfigEntry} */ const out = { value: entry.display, source: entry.source, @@ -41,6 +53,9 @@ function publicEntry(entry) { return out; } +/** + * @param {{ namespace: PublicConfigEntry, controlUrl: PublicConfigEntry, token: PublicConfigEntry }} body + */ function formatConfigExplain(body) { return [ ...formatBlock("namespace", body.namespace), @@ -51,6 +66,10 @@ function formatConfigExplain(body) { ]; } +/** + * @param {string} name + * @param {PublicConfigEntry} entry + */ function formatBlock(name, entry) { const lines = [ `${name}:`, diff --git a/commands/d1.js b/commands/d1.js index 15163f6..f67639b 100644 --- a/commands/d1.js +++ b/commands/d1.js @@ -47,7 +47,19 @@ export const main = command.main; export const runD1Command = command.run; export const meta = command.meta; -/** @param {{ values: Record, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ +/** + * @typedef {object} D1Flags + * @property {string} [sql] + * @property {string} [file] + * @property {string} [mode] + * @property {string} [params] + * @property {string} [dir] + * @property {string} [env] + * @property {boolean} [yes] + * @property {boolean} [json] + */ + +/** @param {{ values: D1Flags, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ async function runD1({ values, positionals, context }) { const { stdout, stderr, stdin } = context; @@ -68,22 +80,26 @@ async function runD1({ values, positionals, context }) { } if (subcommand === "list") { - const body = await context.fetchJson(context.nsUrl("d1", "databases"), { headers }, "list d1 databases"); - writeResult(values.json, body, () => formatD1List(body), stdout); + const body = /** @type {Parameters[0]} */ ( + await context.fetchJson(context.nsUrl("d1", "databases"), { headers }, "list d1 databases") + ); + writeResult(values.json === true, body, () => formatD1List(body), stdout); return; } if (subcommand === "create") { const databaseName = firstArg; if (!databaseName) throw new CliError("d1 create requires "); - const body = await context.fetchJson(context.nsUrl("d1", "databases"), { - method: "POST", - headers, - body: JSON.stringify({ - databaseName, - }), - }, "create d1 database"); - writeResult(values.json, body, () => [ + const body = /** @type {{ namespace?: string, databaseId?: string, databaseName?: string }} */ ( + await context.fetchJson(context.nsUrl("d1", "databases"), { + method: "POST", + headers, + body: JSON.stringify({ + databaseName, + }), + }, "create d1 database") + ); + writeResult(values.json === true, body, () => [ `OK ${body.namespace}/${body.databaseId} created name=${body.databaseName || "-"}`, ], stdout); return; @@ -99,11 +115,13 @@ async function runD1({ values, positionals, context }) { prompt: `Are you sure you want to delete D1 database "${ns}/${databaseRef}"? [y/N] `, action: `delete D1 database "${ns}/${databaseRef}"`, }); - const body = await context.fetchJson(context.nsUrl("d1", "databases", databaseRef), { - method: "DELETE", - headers, - }, "delete d1 database"); - writeResult(values.json, body, () => [ + const body = /** @type {{ namespace?: string, databaseId?: string }} */ ( + await context.fetchJson(context.nsUrl("d1", "databases", databaseRef), { + method: "DELETE", + headers, + }, "delete d1 database") + ); + writeResult(values.json === true, body, () => [ `OK ${body.namespace}/${body.databaseId} deleted`, ], stdout); return; @@ -117,29 +135,35 @@ async function runD1({ values, positionals, context }) { if (!D1_EXECUTE_MODES.includes(mode)) { throw new CliError(`--mode must be one of ${D1_EXECUTE_MODES.join(", ")}`); } + /** @type {unknown[] | undefined} */ let params; if (values.params !== undefined) { if (mode === "exec") { throw new CliError("--mode exec does not accept --params"); } + /** @type {unknown} */ + let parsed; try { - params = JSON.parse(values.params); + parsed = JSON.parse(values.params); } catch { throw new CliError("--params must be a JSON array"); } - if (!Array.isArray(params)) throw new CliError("--params must be a JSON array"); + if (!Array.isArray(parsed)) throw new CliError("--params must be a JSON array"); + params = parsed; } - const body = await context.fetchJson(context.nsUrl("d1", "databases", databaseRef, "query"), { - method: "POST", - headers, - body: JSON.stringify({ - sql, - mode, - ...(params ? { params } : {}), - }), - timeoutMs: LONG_CONTROL_TIMEOUT_MS, - }, "execute d1 query"); - writeResult(values.json, body, () => formatD1Execute(body), stdout); + const body = /** @type {Parameters[0]} */ ( + await context.fetchJson(context.nsUrl("d1", "databases", databaseRef, "query"), { + method: "POST", + headers, + body: JSON.stringify({ + sql, + mode, + ...(params ? { params } : {}), + }), + timeoutMs: LONG_CONTROL_TIMEOUT_MS, + }, "execute d1 query") + ); + writeResult(values.json === true, body, () => formatD1Execute(body), stdout); return; } @@ -148,44 +172,65 @@ async function runD1({ values, positionals, context }) { /** @param {{ action: string, databaseRef: string, context: import("../lib/command.js").CommandContext }} arg */ async function runMigrationsCommand({ action, databaseRef, context }) { - const { values, env, stdout, cwd } = context; + const { env, stdout, cwd } = context; + const values = /** @type {D1Flags} */ (context.values); const { headers } = context.resolveControl(); const migrationsBase = context.nsUrl("d1", "databases", databaseRef, "migrations"); if (action === "list") { - const body = await context.fetchJson(migrationsBase, { headers }, "list d1 migrations"); - writeResult(values.json, body, () => formatD1MigrationList(body), stdout); + const body = /** @type {Parameters[0]} */ ( + await context.fetchJson(migrationsBase, { headers }, "list d1 migrations") + ); + writeResult(values.json === true, body, () => formatD1MigrationList(body), stdout); return; } if (action === "status") { const migrations = loadLocalMigrations({ values, env, cwd, databaseRef }); - const body = await context.fetchJson(`${migrationsBase}/status`, { - method: "POST", - headers, - body: JSON.stringify({ migrations: migrations.map(({ sql: _sql, ...rest }) => rest) }), - }, "show d1 migration status"); - writeResult(values.json, body, () => formatD1MigrationStatus(body), stdout); + const body = /** @type {Parameters[0]} */ ( + await context.fetchJson(`${migrationsBase}/status`, { + method: "POST", + headers, + body: JSON.stringify({ migrations: migrations.map(({ sql: _sql, ...rest }) => rest) }), + }, "show d1 migration status") + ); + writeResult(values.json === true, body, () => formatD1MigrationStatus(body), stdout); return; } if (action === "apply") { const migrations = loadLocalMigrations({ values, env, cwd, databaseRef }); - const body = await context.fetchJson(`${migrationsBase}/apply`, { - method: "POST", - headers, - body: JSON.stringify({ migrations }), - timeoutMs: LONG_CONTROL_TIMEOUT_MS, - }, "apply d1 migrations"); - writeResult(values.json, body, () => formatD1MigrationApply(body), stdout); + const body = /** @type {Parameters[0]} */ ( + await context.fetchJson(`${migrationsBase}/apply`, { + method: "POST", + headers, + body: JSON.stringify({ migrations }), + timeoutMs: LONG_CONTROL_TIMEOUT_MS, + }, "apply d1 migrations") + ); + writeResult(values.json === true, body, () => formatD1MigrationApply(body), stdout); return; } throw new CliError(`unknown d1 migrations subcommand: ${action}`); } +/** + * @typedef {{ values: D1Flags, env: NodeJS.ProcessEnv, cwd: string, databaseRef: string }} MigrationsDirArgs + */ + +/** + * A single `[[d1_databases]]` table entry as read from a parsed Wrangler config. + * @typedef {object} D1DatabaseEntry + * @property {string} [binding] + * @property {string} [database_id] + * @property {string} [database_name] + * @property {string} [migrations_dir] + */ + // status and apply share the same local-migrations contract: resolve the dir, // read the .sql files, and fail loudly on an empty/mis-pointed dir. +/** @param {MigrationsDirArgs} arg */ function loadLocalMigrations({ values, env, cwd, databaseRef }) { const { dir, display } = resolveMigrationsDir({ values, env, cwd, databaseRef }); const migrations = readMigrationFiles(dir); @@ -195,6 +240,10 @@ function loadLocalMigrations({ values, env, cwd, databaseRef }) { return migrations; } +/** + * @param {MigrationsDirArgs} arg + * @returns {{ dir: string, display: string }} + */ function resolveMigrationsDir({ values, env, cwd, databaseRef }) { if (values.dir) { return { @@ -212,26 +261,30 @@ function resolveMigrationsDir({ values, env, cwd, databaseRef }) { try { loaded = loadWranglerConfig(cwd); } catch (err) { - if (typeof err?.message === "string" && err.message.startsWith("no wrangler.")) { + const message = err instanceof Error ? err.message : String(err); + if (message.startsWith("no wrangler.")) { return fallback; } - throw new CliError(err.message || String(err)); + throw new CliError(message); } const configRel = path.basename(loaded.path); const selectedEnv = values.env || env.CLOUDFLARE_ENV || null; + /** @type {{ d1_databases?: unknown }} */ let cfg; let d1Bindings; try { ({ cfg } = resolveWranglerConfig(loaded.cfg, selectedEnv, configRel)); d1Bindings = parseD1DatabasesFromCfg(cfg, configRel); } catch (err) { - throw new CliError(err.message || String(err)); + throw new CliError(err instanceof Error ? err.message : String(err)); } if (d1Bindings.length === 0) return fallback; - const entries = cfg.d1_databases; + const entries = /** @type {D1DatabaseEntry[]} */ (cfg.d1_databases); + /** @type {D1DatabaseEntry[]} */ const byId = []; + /** @type {D1DatabaseEntry[]} */ const byName = []; for (const [idx, entry] of entries.entries()) { if (entry.migrations_dir != null && (typeof entry.migrations_dir !== "string" || !entry.migrations_dir.trim())) { @@ -271,6 +324,10 @@ function resolveMigrationsDir({ values, env, cwd, databaseRef }) { }; } +/** + * @param {{ cwd: string, dir: string }} arg + * @returns {string} + */ function resolveExplicitMigrationsDir({ cwd, dir }) { const root = realpathSync(cwd); const candidate = path.resolve(root, dir); @@ -281,6 +338,10 @@ function resolveExplicitMigrationsDir({ cwd, dir }) { return resolved; } +/** + * @param {{ configDir: string, migrationsDir: string, configRel: string, binding: string | undefined }} arg + * @returns {string} + */ function resolveConfiguredMigrationsDir({ configDir, migrationsDir, configRel, binding }) { const root = realpathSync(configDir); const candidate = path.resolve(root, migrationsDir); diff --git a/commands/delete.js b/commands/delete.js index 88b1a60..2db7712 100644 --- a/commands/delete.js +++ b/commands/delete.js @@ -27,7 +27,7 @@ export const main = command.main; export const runDeleteCommand = command.run; export const meta = command.meta; -/** @param {{ values: Record, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ +/** @param {{ values: { worker?: string, version?: string, "dry-run"?: boolean, yes?: boolean, json?: boolean }, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ async function runDelete({ values, positionals, context }) { const { stdout, stderr, stdin } = context; @@ -54,7 +54,7 @@ async function runDelete({ values, positionals, context }) { { method: "DELETE", headers }, "delete version", ); - writeResult(values.json, body, () => formatVersionDelete(body), stdout); + writeResult(values.json === true, body, () => formatVersionDelete(/** @type {Parameters[0]} */ (body)), stdout); return; } @@ -77,7 +77,7 @@ async function runDelete({ values, positionals, context }) { { method: "POST", headers }, dryRun ? "dry-run delete worker" : "delete worker", ); - writeResult(values.json, body, () => formatWorkerDelete(body), stdout); + writeResult(values.json === true, body, () => formatWorkerDelete(/** @type {Parameters[0]} */ (body)), stdout); return; } } diff --git a/commands/deploy.js b/commands/deploy.js index 8aee9b1..07de4fa 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -27,9 +27,32 @@ function usageText() { }); } +/** + * One platform-binding deploy warning surfaced by control. + * @typedef {object} DeployWarning + * @property {string} [code] + * @property {string} [message] + * @property {string} [binding] + * @property {string} [platform] + * @property {string} [className] + * @property {string} [entrypoint] + * @property {string[]} [missingCallerSecrets] + */ + // Upload a packed manifest to control + promote. Token rides authHeaders. // controlUrl is passed only for the readable upload log line; the fetch URLs // are built via context.nsUrl so segment encoding stays consistent. +/** + * @param {{ + * context: import("../lib/command.js").CommandContext, + * ns: string, + * workerName: string, + * manifest: unknown, + * controlUrl: string, + * authHeaders: Record, + * }} arg + * @returns {Promise<{ version: unknown, platformDomain: unknown }>} + */ export async function postArtifactToControl({ context, ns, workerName, manifest, controlUrl, authHeaders }) { const { stdout, stderr } = context; const jsonHeaders = { @@ -41,15 +64,17 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, writeStatusLine(stdout, `[2/3] uploading ${workerName} → ${controlUrl}/ns/${ns}`); // `version` comes from the control response; keep the raw value for the // promote request body — display sites escape via writeStatusLine. - const { version, warnings } = await context.fetchJson( - context.nsUrl("worker", workerName, "deploy"), - { - method: "POST", - headers: jsonHeaders, - body: deployBody, - timeoutMs: LONG_CONTROL_TIMEOUT_MS, - }, - "deploy", + const { version, warnings } = /** @type {{ version: unknown, warnings?: DeployWarning[] }} */ ( + await context.fetchJson( + context.nsUrl("worker", workerName, "deploy"), + { + method: "POST", + headers: jsonHeaders, + body: deployBody, + timeoutMs: LONG_CONTROL_TIMEOUT_MS, + }, + "deploy", + ) ); // Control's deploy warnings are the only signal for several binding // misconfigurations — surface them so failures don't defer to runtime. @@ -70,16 +95,19 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, } writeStatusLine(stdout, `[3/3] promoting ${version}`); + /** @type {{ platformDomain?: unknown }} */ let promoteBody; try { - promoteBody = await context.fetchJson( - context.nsUrl("worker", workerName, "promote"), - { - method: "POST", - headers: jsonHeaders, - body: JSON.stringify({ version }), - }, - "promote", + promoteBody = /** @type {{ platformDomain?: unknown }} */ ( + await context.fetchJson( + context.nsUrl("worker", workerName, "promote"), + { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ version }), + }, + "promote", + ) ); } catch (err) { stderr( @@ -91,6 +119,11 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, return { version, platformDomain: promoteBody.platformDomain }; } +/** + * @param {unknown} manifest + * @param {number} [maxBytes] + * @returns {string} + */ export function serializeDeployManifest(manifest, maxBytes = DEPLOY_JSON_BODY_MAX_BYTES) { const body = JSON.stringify(manifest); const bodyBytes = Buffer.byteLength(body); @@ -126,9 +159,12 @@ export const main = command.main; export const runDeployCommand = command.run; export const meta = command.meta; -/** @param {{ values: Record, positionals: string[], context: import("../lib/command.js").CommandContext & { execFile: typeof execFileSync } }} arg */ +/** @param {{ values: { env?: string, verbose?: boolean }, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ async function runDeploy({ values, positionals, context }) { - const { env, stdout, stderr, cwd, execFile } = context; + const { env, stdout, stderr, cwd } = context; + const { execFile } = /** @type {{ execFile: typeof execFileSync }} */ ( + /** @type {unknown} */ (context) + ); const ns = context.resolveNamespace(); const [projectDir] = positionals; @@ -139,7 +175,11 @@ async function runDeploy({ values, positionals, context }) { const { controlUrl, headers: authHeaders } = context.resolveControl(); const selectedEnv = values.env || env.CLOUDFLARE_ENV || null; - const { workerName, manifest } = await packWranglerProject({ + // packWranglerProject's parameter types are inferred from its defaults (e.g. + // envName defaults to null, stderr to (_line?) => void), which are narrower + // than the real values passed here; cast the option bag to its declared + // parameter type rather than weaken any of these honest local types. + const packOptions = /** @type {Parameters[0]} */ ({ cwd, projectDir, envName: selectedEnv, @@ -149,6 +189,7 @@ async function runDeploy({ values, positionals, context }) { stderr, verbose: values.verbose, }); + const { workerName, manifest } = await packWranglerProject(packOptions); const { version, platformDomain } = await postArtifactToControl({ context, diff --git a/commands/doctor.js b/commands/doctor.js index d73d89e..699910b 100644 --- a/commands/doctor.js +++ b/commands/doctor.js @@ -37,10 +37,17 @@ export const main = command.main; export const runDoctorCommand = command.run; export const meta = command.meta; -/** @param {{ values: Record, positionals: string[], context: import("../lib/command.js").CommandContext & { execFile: typeof execFileSync } }} arg */ -async function runDoctor({ values, positionals, context }) { +/** + * The doctor run context: the framework base plus the injectable `execFile` + * declared in this command's `defaults`. + * @typedef {import("../lib/command.js").CommandContext & { execFile: typeof execFileSync }} DoctorContext + */ + +/** @param {{ values: { ns?: string, control?: string, json?: boolean }, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ +async function runDoctor({ values, positionals, context: baseContext }) { if (positionals.length > 0) throw new CliError(usageText()); + const context = /** @type {DoctorContext} */ (baseContext); const state = resolveCliConfigState({ values, env: context.env, cwd: context.cwd, warn: context.warn }); const checks = [ checkNode(), @@ -60,12 +67,23 @@ async function runDoctor({ values, positionals, context }) { checks.push(...remote.checks); const body = { checks, whoami: remote.whoami, whoamiError: remote.error }; - writeResult(values.json, body, () => formatDoctor(checks), context.stdout); + writeResult(Boolean(values.json), body, () => formatDoctor(checks), context.stdout); } +/** + * The resolved CLI config state doctor inspects. + * @typedef {ReturnType} ConfigState + */ + +/** + * One readiness check row. + * @typedef {{ ok: boolean, label: string, detail: string }} DoctorCheck + */ + function checkNode() { const pkg = readCliPackageJson(); - const expected = pkg.engines?.node || "(unspecified)"; + const engines = /** @type {{ node?: string } | undefined} */ (pkg.engines); + const expected = engines?.node || "(unspecified)"; const ok = satisfiesNodeEngine(process.versions.node, expected); return check({ ok, @@ -81,6 +99,7 @@ function checkCliVersion() { }); } +/** @param {{ cwd: string, env: NodeJS.ProcessEnv, execFile: typeof execFileSync }} arg */ function checkWrangler({ cwd, env, execFile }) { // The resolver throws on win32 when nothing runnable exists; doctor must // report that as a failed check, not crash the whole run. @@ -91,7 +110,7 @@ function checkWrangler({ cwd, env, execFile }) { return check({ ok: false, label: "Wrangler", - detail: err?.message || String(err), + detail: err instanceof Error && err.message ? err.message : String(err), }); } try { @@ -118,11 +137,12 @@ function checkWrangler({ cwd, env, execFile }) { return check({ ok: false, label: "Wrangler", - detail: err?.message || String(err), + detail: err instanceof Error && err.message ? err.message : String(err), }); } } +/** @param {ConfigState} state */ function checkControlUrl(state) { return check({ ok: !state.controlUrl.error, @@ -131,6 +151,7 @@ function checkControlUrl(state) { }); } +/** @param {ConfigState} state */ function checkToken(state) { return check({ ok: Boolean(state.token.value), @@ -139,6 +160,7 @@ function checkToken(state) { }); } +/** @param {ConfigState} state */ function checkNamespace(state) { return check({ ok: Boolean(state.namespace.value), @@ -147,6 +169,7 @@ function checkNamespace(state) { }); } +/** @param {ConfigState} state */ function checkTokenStore(state) { if (state.tokenStoreDisabled) { // The opt-out promises the CLI never reads the store, so don't read it here @@ -178,6 +201,7 @@ function checkTokenStore(state) { }); } +/** @param {string} cwd */ function checkWranglerConfig(cwd) { const name = ["wrangler.toml", "wrangler.jsonc", "wrangler.json"].find((candidate) => existsSync(path.join(cwd, candidate)) @@ -189,6 +213,13 @@ function checkWranglerConfig(cwd) { }); } +/** + * @param {{ + * state: ConfigState, + * controlFetch: import("../lib/command.js").CommandContext["controlFetch"], + * warn: (line: string) => void, + * }} arg + */ async function checkRemoteWhoami({ state, controlFetch, warn }) { let control; try { @@ -196,7 +227,7 @@ async function checkRemoteWhoami({ state, controlFetch, warn }) { } catch (err) { return { whoami: null, - error: err?.message || String(err), + error: err instanceof Error && err.message ? err.message : String(err), checks: [], }; } @@ -208,7 +239,7 @@ async function checkRemoteWhoami({ state, controlFetch, warn }) { headers: control.headers, controlFetch, })); - const tokenNs = namespaceFromPrincipal(remote.principal); + const tokenNs = namespaceFromPrincipal(remote.principal ?? undefined); const checks = [ check({ ok: true, @@ -246,20 +277,25 @@ async function checkRemoteWhoami({ state, controlFetch, warn }) { } catch (err) { return { whoami: null, - error: err?.message || String(err), + error: err instanceof Error && err.message ? err.message : String(err), checks: [check({ ok: false, label: "Control /whoami", - detail: err?.message || String(err), + detail: err instanceof Error && err.message ? err.message : String(err), })], }; } } +/** + * @param {{ ok: boolean, label: string, detail?: string }} arg + * @returns {DoctorCheck} + */ function check({ ok, label, detail = "" }) { return { ok, label, detail }; } +/** @param {DoctorCheck[]} checks */ function formatDoctor(checks) { return checks.map((item) => { const line = `${item.ok ? "✓" : "✗"} ${item.label}`; @@ -267,6 +303,10 @@ function formatDoctor(checks) { }); } +/** + * @param {string} version + * @param {string} engine + */ function satisfiesNodeEngine(version, engine) { // Doctor only needs the package's current simple ">=N" engine shape. If the // project later adopts a richer range, avoid false negatives until a real @@ -276,15 +316,18 @@ function satisfiesNodeEngine(version, engine) { return Number(version.split(".")[0]) >= Number(min[1]); } +/** @param {unknown} output */ function formatWranglerVersion(output) { const text = String(output).trim(); const match = text.match(/\b\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\b/); return match ? match[0] : ""; } +/** @param {string} cwd */ function readInstalledWranglerVersion(cwd) { for (const dir of [cwd, CLI_ROOT]) { try { + /** @type {{ version?: unknown }} */ const pkg = JSON.parse(readFileSync(path.join(dir, "node_modules", "wrangler", "package.json"), "utf8")); if (isNonEmptyString(pkg.version)) return pkg.version; } catch {} diff --git a/commands/init.js b/commands/init.js index 6298d1c..cbe49ea 100644 --- a/commands/init.js +++ b/commands/init.js @@ -69,6 +69,10 @@ export async function main(argv = process.argv.slice(2)) { } } +/** + * @param {string[]} argv + * @returns {{ target: string | null, ns: string | null, worker: string | null, help: boolean }} + */ function parseArgs(argv) { let parsed; try { @@ -80,7 +84,7 @@ function parseArgs(argv) { } catch (err) { // node:util phrases this as "Unknown option '--x'."; re-map to the historical // "unknown flag: " wording (flag name best-effort from the message). - if (err && err.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") { + if (err instanceof Error && /** @type {{ code?: unknown }} */ (err).code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") { const flag = /'([^']+)'/.exec(err.message)?.[1] ?? ""; throw new CliError(`unknown flag: ${flag}`); } @@ -102,6 +106,10 @@ function parseArgs(argv) { }; } +/** + * @param {string} target + * @returns {{ targetDir: string, packageName: string, isInPlace: boolean }} + */ function resolveTarget(target) { if (target === ".") { const targetDir = process.cwd(); @@ -118,6 +126,10 @@ function resolveTarget(target) { }; } +/** + * @param {string} value + * @param {string} label + */ function validateNs(value, label) { if (!TENANT_NS_RE.test(value) || RESERVED_TENANT_NS.has(value) || isReservedNs(value)) { throw new CliError( @@ -127,6 +139,10 @@ function validateNs(value, label) { } } +/** + * @param {string} value + * @param {string} label + */ function validateWorker(value, label) { if (!WORKER_NAME_REGEX.test(value)) { throw new CliError( @@ -136,12 +152,16 @@ function validateWorker(value, label) { } } +/** + * @param {string} dir + * @param {boolean} isInPlace + */ async function ensureEmpty(dir, isInPlace) { let entries; try { entries = await fs.readdir(dir); } catch (err) { - if (err && err.code === "ENOENT") return; + if (err instanceof Error && /** @type {{ code?: unknown }} */ (err).code === "ENOENT") return; throw err; } const offending = entries.filter(name => !IGNORABLE_DIR_ENTRIES.has(name)); @@ -154,6 +174,10 @@ async function ensureEmpty(dir, isInPlace) { ); } +/** + * @param {string} targetDir + * @param {{ packageName: string, workerName: string, ns: string | null }} arg + */ async function writeStarter(targetDir, { packageName, workerName, ns }) { const [wdlCliDep, wranglerDep] = await Promise.all([ resolveWdlCliDep(process.env), @@ -215,6 +239,7 @@ async function writeStarter(targetDir, { packageName, workerName, ns }) { ]); } +/** @param {NodeJS.ProcessEnv} env */ async function resolveWdlCliDep(env) { const localPath = env && env.WDL_CLI_LOCAL_PATH; if (isNonEmptyString(localPath)) { @@ -232,22 +257,29 @@ async function resolveWranglerDep() { return dep; } +/** + * @returns {Promise<{ version: string, dependencies?: Record }>} + */ +/** + * @returns {Promise<{ version: string, dependencies?: Record }>} + */ async function readWdlCliPackage() { const text = await fs.readFile(path.join(CLI_ROOT, "package.json"), "utf8"); - const parsed = JSON.parse(text); + const parsed = /** @type {{ version?: unknown, dependencies?: Record }} */ (JSON.parse(text)); if (typeof parsed.version !== "string" || parsed.version.length === 0) { throw new CliError("could not read wdl-cli version from package.json"); } - return parsed; + return /** @type {{ version: string, dependencies?: Record }} */ (parsed); } +/** @param {string} targetDir */ async function copyAgentsDoc(targetDir) { const src = path.join(CLI_ROOT, "templates", "AGENTS.md"); const dest = path.join(targetDir, "AGENTS.md"); try { await fs.copyFile(src, dest); } catch (err) { - if (err && err.code === "ENOENT") { + if (err instanceof Error && /** @type {{ code?: unknown }} */ (err).code === "ENOENT") { throw new CliError( `templates/AGENTS.md missing from the wdl-cli package. ` + `If you installed from npm, please re-install; ` + @@ -258,6 +290,10 @@ async function copyAgentsDoc(targetDir) { } } +/** + * @param {string} target + * @param {{ packageName: string, workerName: string, ns: string | null, isInPlace: boolean }} arg + */ function printNextSteps(target, { packageName, workerName, ns, isInPlace }) { const url = `https://${ns || ""}./${workerName}/`; const lines = [ @@ -289,6 +325,7 @@ function printNextSteps(target, { packageName, workerName, ns, isInPlace }) { console.log(lines.join("\n")); } +/** @param {number} exitCode */ function printHelp(exitCode) { console.log(formatHelp({ usage: [ diff --git a/commands/r2.js b/commands/r2.js index c37f8ab..9e94171 100644 --- a/commands/r2.js +++ b/commands/r2.js @@ -38,9 +38,18 @@ export const main = command.main; export const runR2Command = command.run; export const meta = command.meta; -/** @param {{ values: Record, positionals: string[], context: import("../lib/command.js").CommandContext & { stdoutStream: NodeJS.WritableStream } }} arg */ +/** + * @param {{ + * values: { prefix?: string, delimiter?: string, cursor?: string, limit?: string, out?: string, yes?: boolean, json?: boolean }, + * positionals: string[], + * context: import("../lib/command.js").CommandContext, + * }} arg + */ async function runR2({ values, positionals, context }) { - const { stdout, stdoutStream, stderr, stdin } = context; + const { stdout, stderr, stdin } = context; + const { stdoutStream } = /** @type {{ stdoutStream: NodeJS.WritableStream }} */ ( + /** @type {unknown} */ (context) + ); const [group, action, bucket, key] = positionals; const ns = context.resolveNamespace(); @@ -49,7 +58,7 @@ async function runR2({ values, positionals, context }) { const { headers } = context.resolveControl(); // Object keys can contain "/" and must reject . / .. segments, so they use // encodeR2KeyPath rather than nsUrl's per-segment encodePath. - const objectUrl = (objectKey) => + const objectUrl = (/** @type {string} */ objectKey) => `${context.nsUrl("r2", "buckets", bucket, "objects")}/${encodeR2KeyPath(objectKey)}`; if (group === "buckets" && action === "list") { @@ -57,8 +66,10 @@ async function runR2({ values, positionals, context }) { cursor: values.cursor, limit: values.limit, }); - const body = await context.fetchJson(url, { headers }, "list R2 buckets"); - writeResult(values.json, body, () => formatBucketList(body), stdout); + const body = /** @type {Parameters[0]} */ ( + await context.fetchJson(url, { headers }, "list R2 buckets") + ); + writeResult(values.json === true, body, () => formatBucketList(body), stdout); return; } @@ -70,8 +81,10 @@ async function runR2({ values, positionals, context }) { cursor: values.cursor, limit: values.limit, }); - const body = await context.fetchJson(url, { headers }, "list R2 objects"); - writeResult(values.json, body, () => formatObjectList(body), stdout); + const body = /** @type {Parameters[0]} */ ( + await context.fetchJson(url, { headers }, "list R2 objects") + ); + writeResult(values.json === true, body, () => formatObjectList(body), stdout); return; } @@ -84,11 +97,13 @@ async function runR2({ values, positionals, context }) { maxBodyBytes: UNLIMITED_CONTROL_BODY_BYTES, streamResponse: true, }, "get R2 object"); + // streamResponse: true guarantees a body; narrow off the optional type. + const responseBody = /** @type {import("node:stream").Readable} */ (res.body); if (values.out) { - const bytesWritten = await writeBodyToFile(res.body, values.out); + const bytesWritten = await writeBodyToFile(responseBody, values.out); writeStatusLine(stdout, `OK wrote ${bytesWritten} bytes to ${values.out}`); } else { - await writeBodyToStdout(res.body, stdoutStream); + await writeBodyToStdout(responseBody, stdoutStream); } return; } @@ -106,7 +121,7 @@ async function runR2({ values, positionals, context }) { key: objectKey, headers: res.headers, }); - writeResult(values.json, body, () => formatObjectHead(body), stdout); + writeResult(values.json === true, body, () => formatObjectHead(body), stdout); return; } @@ -120,11 +135,13 @@ async function runR2({ values, positionals, context }) { prompt: `Are you sure you want to delete R2 object "${ns}/${bucket}/${objectKey}"? [y/N] `, action: `delete R2 object "${ns}/${bucket}/${objectKey}"`, }); - const body = await context.fetchJson(objectUrl(objectKey), { - method: "DELETE", - headers, - }, "delete R2 object"); - writeResult(values.json, body, () => [ + const body = /** @type {{ namespace?: string, bucket?: string, key?: string }} */ ( + await context.fetchJson(objectUrl(objectKey), { + method: "DELETE", + headers, + }, "delete R2 object") + ); + writeResult(values.json === true, body, () => [ `OK ${body.namespace}/${body.bucket}/${body.key} deleted`, ], stdout); return; @@ -133,6 +150,11 @@ async function runR2({ values, positionals, context }) { throw new CliError(`unknown r2 command: ${group} ${action}\n${usageText()}`); } +/** + * @param {string} url + * @param {Record} params + * @returns {string} + */ function withQuery(url, params) { const u = new URL(url); for (const [key, value] of Object.entries(params)) { @@ -141,6 +163,10 @@ function withQuery(url, params) { return u.toString(); } +/** + * @param {string | undefined} key + * @returns {string} + */ function requireR2ObjectKey(key) { if (key == null || !String(key).trim()) { throw new CliError("R2 object key is required"); @@ -148,6 +174,7 @@ function requireR2ObjectKey(key) { return String(key); } +/** @param {string} key */ function encodeR2KeyPath(key) { const segments = String(key).split("/"); if (segments.some((segment) => segment === "." || segment === "..")) { @@ -159,6 +186,14 @@ function encodeR2KeyPath(key) { return segments.map((segment) => encodeURIComponent(segment)).join("/"); } +/** + * Either a `fetch`-style Headers object or a Node `IncomingHttpHeaders` bag. + * @typedef {Headers | import("node:http").IncomingHttpHeaders} HeaderSource + */ + +/** + * @param {{ namespace: string, bucket: string, key: string, headers: HeaderSource }} arg + */ function objectHeadFromHeaders({ namespace, bucket, key, headers }) { // null-prototype: a control-supplied `x-amz-meta-__proto__` header becomes a real // own key instead of being swallowed by Object.prototype's __proto__ setter. @@ -189,29 +224,52 @@ function objectHeadFromHeaders({ namespace, bucket, key, headers }) { }; } +/** + * @param {HeaderSource} headers + * @param {string} name + * @returns {string | undefined} + */ function getHeader(headers, name) { if (!headers) return undefined; - if (typeof headers.get === "function") return headers.get(name) || undefined; - return headers[name.toLowerCase()] || headers[name] || undefined; + if (headers instanceof Headers) return headers.get(name) || undefined; + // IncomingHttpHeaders values are string | string[]; the headers read here are + // single-valued response headers, so coerce to a single string for callers. + const value = headers[name.toLowerCase()] || headers[name]; + if (!value) return undefined; + return Array.isArray(value) ? value[0] : value; } +/** + * @param {HeaderSource} headers + * @returns {Iterable<[string, unknown]>} + */ function headerEntries(headers) { if (!headers) return []; - if (typeof headers.entries === "function") return headers.entries(); + if (headers instanceof Headers) return headers.entries(); return Object.entries(headers); } +/** @param {string} etag */ function stripEtag(etag) { const s = String(etag || ""); return s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s; } +/** + * @param {import("node:stream").Readable} body + * @param {NodeJS.WritableStream} stdoutStream + */ async function writeBodyToStdout(body, stdoutStream) { for await (const chunk of body) { if (!stdoutStream.write(toBuffer(chunk))) await once(stdoutStream, "drain"); } } +/** + * @param {import("node:stream").Readable} body + * @param {string} outPath + * @returns {Promise} + */ async function writeBodyToFile(body, outPath) { let bytesWritten = 0; const counter = new Transform({ @@ -225,6 +283,10 @@ async function writeBodyToFile(body, outPath) { return bytesWritten; } +/** + * @param {Buffer | string | Uint8Array} chunk + * @returns {Buffer} + */ function toBuffer(chunk) { return Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); } diff --git a/commands/secret.js b/commands/secret.js index db4c9c4..70c87de 100644 --- a/commands/secret.js +++ b/commands/secret.js @@ -29,7 +29,26 @@ export const main = command.main; export const runSecretCommand = command.run; export const meta = command.meta; -/** @param {{ values: Record, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ +/** + * A deferred-reload warning surfaced by control on put/delete. + * @typedef {object} PromoteWarning + * @property {string} [kind] + * @property {string} [reason] + * @property {string} [nextPickup] + */ + +/** + * The fields this command reads off control's /secrets responses. Control may + * return more; only these are consumed here. + * @typedef {object} SecretResponse + * @property {string[]} [keys] + * @property {boolean} [deleted] + * @property {string} [version] + * @property {string} [previousVersion] + * @property {PromoteWarning[]} [warnings] + */ + +/** @param {{ values: { worker?: string, scope?: string, yes?: boolean, json?: boolean }, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ async function runSecret({ values, positionals, context }) { const { stdout, stderr, stdin } = context; @@ -52,14 +71,14 @@ async function runSecret({ values, positionals, context }) { } const { headers } = context.resolveControl(); - const secretPath = hasWorker + const secretPath = isNonEmptyString(values.worker) ? ["worker", values.worker, "secrets"] : ["secrets"]; - const scopeLabel = hasWorker ? `${ns}/${values.worker}` : `${ns} (ns)`; + const scopeLabel = isNonEmptyString(values.worker) ? `${ns}/${values.worker}` : `${ns} (ns)`; if (subcommand === "list") { - const body = await context.fetchJson(context.nsUrl(...secretPath), { headers }, "list"); - if (writeJsonOr(values.json, body, stdout)) return; + const body = /** @type {SecretResponse} */ (await context.fetchJson(context.nsUrl(...secretPath), { headers }, "list")); + if (writeJsonOr(Boolean(values.json), body, stdout)) return; const keys = Array.isArray(body.keys) ? body.keys : []; if (keys.length === 0) writeStatusLine(stdout, "(no secrets)"); else for (const k of keys) writeStatusLine(stdout, String(k)); @@ -73,12 +92,12 @@ async function runSecret({ values, positionals, context }) { prompt: `Enter secret value for ${scopeLabel}/${keyArg} (input hidden): `, stderr, }); - const body = await context.fetchJson(context.nsUrl(...secretPath, keyArg), { + const body = /** @type {SecretResponse} */ (await context.fetchJson(context.nsUrl(...secretPath, keyArg), { method: "PUT", headers: { ...headers, "content-type": "application/json" }, body: JSON.stringify({ value }), - }, "put"); - if (writeJsonOr(values.json, body, stdout)) return; + }, "put")); + if (writeJsonOr(Boolean(values.json), body, stdout)) return; const warning = pickPromoteWarning(body); if (warning) { writeStatusLine(stdout, `⚠ ${scopeLabel}/${keyArg} set — stored, reload deferred: ${warning.reason}`); @@ -102,11 +121,11 @@ async function runSecret({ values, positionals, context }) { prompt: `Are you sure you want to delete secret "${scopeLabel}/${keyArg}"? [y/N] `, action: `delete secret "${scopeLabel}/${keyArg}"`, }); - const body = await context.fetchJson(context.nsUrl(...secretPath, keyArg), { + const body = /** @type {SecretResponse} */ (await context.fetchJson(context.nsUrl(...secretPath, keyArg), { method: "DELETE", headers, - }, "delete"); - if (writeJsonOr(values.json, body, stdout)) return; + }, "delete")); + if (writeJsonOr(Boolean(values.json), body, stdout)) return; const warning = pickPromoteWarning(body); if (!body.deleted && !warning) writeStatusLine(stdout, `(${keyArg} was not set)`); else if (warning && body.deleted) { @@ -126,6 +145,10 @@ async function runSecret({ values, positionals, context }) { throw new CliError(`unknown subcommand: ${subcommand}`); } +/** + * @param {SecretResponse} body + * @returns {PromoteWarning | null} + */ function pickPromoteWarning(body) { const warnings = Array.isArray(body?.warnings) ? body.warnings : []; return warnings.find((w) => w?.kind === "promote_failed") || null; diff --git a/commands/tail.js b/commands/tail.js index db57b08..df62b77 100644 --- a/commands/tail.js +++ b/commands/tail.js @@ -32,10 +32,12 @@ const TAIL_OPTIONS = [ "help", ]; +/** @param {unknown} err */ function isExpectedAbortError(err) { - if (!err) return false; - if (err.name === "AbortError") return true; - if (typeof err.code === "string" && ABORT_TOLERATED_ERRORS.has(err.code)) return true; + if (!err || typeof err !== "object") return false; + const e = /** @type {{ name?: unknown, code?: unknown }} */ (err); + if (e.name === "AbortError") return true; + if (typeof e.code === "string" && ABORT_TOLERATED_ERRORS.has(e.code)) return true; return false; } @@ -45,8 +47,8 @@ const command = defineCommand({ options: TAIL_OPTIONS, // tail writes line-at-a-time to both streams with an explicit newline. defaults: { - stdout: (line) => process.stdout.write(line + "\n"), - stderr: (line) => process.stderr.write(line + "\n"), + stdout: (/** @type {string} */ line) => process.stdout.write(line + "\n"), + stderr: (/** @type {string} */ line) => process.stderr.write(line + "\n"), transport: null, sleepFn: sleep, now: () => Date.now(), @@ -59,8 +61,61 @@ export const main = command.main; export const runTailCommand = command.run; export const meta = command.meta; -/** @param {{ values: Record, positionals: string[], context: import("../lib/command.js").CommandContext & { transport: any, sleepFn: (ms: number, signal?: AbortSignal) => Promise, now: () => number } }} arg */ -async function runTail({ values, positionals, context }) { +/** + * A parsed SSE event handed to the renderer. + * @typedef {object} SseEvent + * @property {string} event The SSE `event:` field (defaults to "message"). + * @property {string | null} id The last seen SSE `id:` field, if any. + * @property {string} data The concatenated `data:` payload. + */ + +/** + * The shape this command reads off a decoded tail event payload. Tail events + * carry arbitrary worker-controlled fields; only the ones consumed here are + * declared, all optional and loosely typed since they cross the wire. + * @typedef {object} TailPayload + * @property {string} [event] + * @property {unknown} [raw] + * @property {string} [code] + * @property {string} [message] + * @property {number} [ts] + * @property {string} [worker] + * @property {string} [console_level] + * @property {string} [name] + * @property {string} [stack] + * @property {string} [phase] + * @property {unknown} [cron] + * @property {unknown} [scheduled_time] + * @property {string} [outcome] + * @property {unknown} [duration_ms] + * @property {unknown} [error] + * @property {string} [queue] + * @property {unknown} [batch_size] + * @property {string} [method] + * @property {string} [path] + * @property {boolean} [path_truncated] + * @property {unknown} [status] + */ + +/** + * The result of one SSE connection lifecycle: empty on a clean end, or + * `{ fatal }` carrying an error detail to surface and stop reconnecting. + * @typedef {{ fatal?: string }} StreamResult + */ + +/** + * The tail run context: the framework base plus the injectable transport and + * timing hooks declared in this command's `defaults`. + * @typedef {import("../lib/command.js").CommandContext & { + * transport: import("../lib/control-fetch.js").ControlTransport | null, + * sleepFn: (ms: number, signal?: AbortSignal) => Promise, + * now: () => number, + * }} TailContext + */ + +/** @param {{ values: { raw?: boolean, since?: string, "max-reconnects"?: string, ns?: string, control?: string }, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ +async function runTail({ values, positionals, context: baseContext }) { + const context = /** @type {TailContext} */ (baseContext); const { stdout, stderr, transport, sleepFn, now } = context; // Non-negative integer; 0 = unlimited. Reject other shapes loudly @@ -205,6 +260,7 @@ async function runTail({ values, positionals, context }) { } } +/** @param {{ baseUrl: string, workers: string[], since?: string }} arg */ function buildTailUrl({ baseUrl, workers, since }) { const u = new URL(baseUrl); for (const w of workers) u.searchParams.append("worker", w); @@ -212,6 +268,11 @@ function buildTailUrl({ baseUrl, workers, since }) { return u.toString(); } +/** + * @param {number} ms + * @param {AbortSignal} [signal] + * @returns {Promise} + */ function sleep(ms, signal) { return new Promise((resolve) => { if (signal?.aborted) return resolve(); @@ -227,8 +288,21 @@ function sleep(ms, signal) { // One SSE connection lifecycle. Returns when the body ends (clean) or on // a non-2xx status (returns {fatal} for caller to surface). Throws on // transport-level errors so the reconnect loop sees them. +/** + * @param {{ + * url: string, + * headers: Record, + * signal: AbortSignal | undefined, + * transport: import("../lib/control-fetch.js").ControlTransport | null, + * onEvent: (event: SseEvent) => void, + * onConnected?: () => void, + * }} arg + * @returns {Promise} + */ function streamSse({ url, headers, signal, transport, onEvent, onConnected }) { + /** @type {() => void} */ let onAbort; + /** @type {Promise} */ const promise = new Promise((resolve, reject) => { const u = new URL(url); const lib = transport || (u.protocol === "https:" ? https : http); @@ -236,24 +310,26 @@ function streamSse({ url, headers, signal, transport, onEvent, onConnected }) { reqOpts.method = "GET"; reqOpts.headers = { ...reqOpts.headers, Accept: "text/event-stream", ...headers }; - const req = lib.request(reqOpts, (res) => { + const req = lib.request(reqOpts, (/** @type {import("node:http").IncomingMessage} */ res) => { const status = res.statusCode || 0; + /** @param {unknown} err */ const onResponseError = (err) => { if (signal?.aborted && isExpectedAbortError(err)) return resolve({}); reject(err); }; res.on("error", onResponseError); if (status < 200 || status >= 300) { + /** @type {Buffer[]} */ const chunks = []; let total = 0; - res.on("data", (c) => { + res.on("data", (/** @type {Buffer} */ c) => { total += c.length; if (total <= TAIL_ERROR_BODY_MAX_BYTES) chunks.push(c); }); res.on("end", () => { let detail; try { - const body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + const body = /** @type {{ message?: string, error?: string }} */ (JSON.parse(Buffer.concat(chunks).toString("utf8"))); detail = escapeTerminalText(body.message || body.error || `HTTP ${status}`); } catch { detail = `HTTP ${status}`; @@ -265,10 +341,10 @@ function streamSse({ url, headers, signal, transport, onEvent, onConnected }) { onConnected?.(); const parser = new SseParser((event) => onEvent(event)); res.setEncoding("utf8"); - res.on("data", (chunk) => parser.push(chunk)); + res.on("data", (/** @type {string} */ chunk) => parser.push(chunk)); res.on("end", () => { parser.flush(); resolve({}); }); }); - req.on("error", (err) => { + req.on("error", (/** @type {unknown} */ err) => { if (signal?.aborted && isExpectedAbortError(err)) return resolve({}); reject(err); }); @@ -290,13 +366,17 @@ function streamSse({ url, headers, signal, transport, onEvent, onConnected }) { // Field-value parse rule: optional single space after the colon is // trimmed (per W3C SSE spec). export class SseParser { + /** @param {(event: SseEvent) => void} onEvent */ constructor(onEvent) { this.onEvent = onEvent; this.buffer = ""; this.event = "message"; + /** @type {string | null} */ this.id = null; + /** @type {string[]} */ this.data = []; } + /** @param {string} chunk */ push(chunk) { this.buffer += chunk; let idx; @@ -314,6 +394,7 @@ export class SseParser { } this.dispatch(); } + /** @param {string} line */ consumeLine(line) { if (line === "") { this.dispatch(); @@ -348,7 +429,17 @@ export class SseParser { } } +/** + * @param {{ + * event: SseEvent, + * raw: boolean, + * stdout: (line: string) => void, + * stderr: (line: string) => void, + * isMultiWorker: boolean, + * }} arg + */ function renderEvent({ event, raw, stdout, stderr, isMultiWorker }) { + /** @type {TailPayload} */ let payload; try { payload = JSON.parse(event.data); } catch { payload = { event: event.event, raw: event.data }; } @@ -439,6 +530,10 @@ function renderEvent({ event, raw, stdout, stderr, isMultiWorker }) { stdout(`${prefix}${ts} ${escapeTerminalText(eventType)} ${escapeTerminalText(JSON.stringify(payload))}`); } +/** + * @param {TailPayload} payload + * @returns {string | null} + */ function formatFetchDisplayPath(payload) { if (typeof payload.path !== "string") return null; if (typeof payload.worker !== "string" || payload.worker.length === 0) { @@ -451,6 +546,7 @@ function formatFetchDisplayPath(payload) { // Workerd's tail event surfaces console.log("a", "b") as message=["a","b"] // (varargs preserved). Render "console.log-style": one arg unwrapped, // many args space-separated, each non-string lossless via JSON. +/** @param {unknown} message */ function formatConsoleArgs(message) { if (Array.isArray(message)) { return message.map(stringifyMessage).join(" "); @@ -458,6 +554,7 @@ function formatConsoleArgs(message) { return stringifyMessage(message); } +/** @param {unknown} value */ function stringifyMessage(value) { if (value === null || value === undefined) return ""; if (typeof value === "string") return value; diff --git a/commands/token.js b/commands/token.js index dc9b433..10dd040 100644 --- a/commands/token.js +++ b/commands/token.js @@ -41,7 +41,11 @@ 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 */ +/** + * @typedef {{ label?: string, default?: boolean, ns?: string, "control-url"?: string, json?: boolean }} TokenValues + */ + +/** @param {{ values: TokenValues, 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 @@ -62,6 +66,7 @@ async function runToken({ values, positionals, context }) { } } +/** @param {{ values: TokenValues, context: import("../lib/command.js").CommandContext }} arg */ 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 @@ -143,6 +148,7 @@ async function tokenSet({ values, context }) { } } +/** @param {{ values: TokenValues, context: import("../lib/command.js").CommandContext, nsArg: string | undefined }} arg */ 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 @@ -159,6 +165,7 @@ function tokenUse({ values, context, nsArg }) { writeStatusLine(context.stdout, `Default namespace set to ${ns} (used when --ns is omitted).`); } +/** @param {{ values: TokenValues, context: import("../lib/command.js").CommandContext }} arg */ function tokenList({ values, context }) { const store = readTokenStore(tokenStorePath(context.env)); const rows = Object.keys(store.namespaces).sort().map((ns) => ({ @@ -168,9 +175,10 @@ function tokenList({ values, context }) { controlUrl: store.namespaces[ns].CONTROL_URL || "", token: maskToken(store.namespaces[ns].ADMIN_TOKEN), })); - writeResult(values.json, rows, () => formatTokenList(rows), context.stdout); + writeResult(Boolean(values.json), rows, () => formatTokenList(rows), context.stdout); } +/** @param {{ values: TokenValues, context: import("../lib/command.js").CommandContext }} arg */ 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. @@ -191,7 +199,17 @@ function tokenRemove({ values, context }) { writeStatusLine(context.stdout, `Removed the stored token for ${ns}. This does not revoke it on the control plane.`); } +/** + * @typedef {object} TokenListRow + * @property {boolean} default + * @property {string} namespace + * @property {string} label + * @property {string} controlUrl + * @property {string} token + */ + // Returns an array of lines; writeResult escapes each one at its choke point. +/** @param {TokenListRow[]} rows */ function formatTokenList(rows) { if (rows.length === 0) return ["(no stored tokens)"]; const header = ["", "NAMESPACE", "LABEL", "CONTROL URL", "TOKEN"]; diff --git a/commands/whoami.js b/commands/whoami.js index 7a4acca..e9d8cf1 100644 --- a/commands/whoami.js +++ b/commands/whoami.js @@ -26,7 +26,7 @@ export const main = command.main; export const runWhoamiCommand = command.run; export const meta = command.meta; -/** @param {{ values: Record, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ +/** @param {{ values: { json?: boolean }, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ async function runWhoami({ values, positionals, context }) { if (positionals.length > 0) throw new CliError(usageText()); @@ -39,9 +39,13 @@ async function runWhoami({ values, positionals, context }) { controlFetch: context.controlFetch, })); const body = buildWhoamiBody(state, remote); - writeResult(values.json, body, () => formatWhoami(body), context.stdout); + writeResult(values.json === true, body, () => formatWhoami(body), context.stdout); } +/** + * @param {ReturnType} state + * @param {ReturnType} remote + */ function buildWhoamiBody(state, remote) { const principalNamespace = namespaceFromPrincipal(remote.principal) || ""; return { @@ -72,6 +76,7 @@ function buildWhoamiBody(state, remote) { }; } +/** @param {ReturnType} body */ function formatWhoami(body) { const lines = [ `Control URL: ${displayRemoteValue(body.controlUrl.reached || body.controlUrl.value)}`, diff --git a/commands/workers.js b/commands/workers.js index ec98927..1400cfb 100644 --- a/commands/workers.js +++ b/commands/workers.js @@ -30,8 +30,10 @@ async function runWorkers({ positionals, context }) { /** @param {import("../lib/command.js").CommandContext} context */ async function printWorkersList(context) { const { headers } = context.resolveControl(); - const body = await context.fetchJson(context.nsUrl("workers"), { headers }, "list workers"); - writeResult(context.values.json, body, () => formatWorkersList(body), context.stdout); + const body = /** @type {{ workers?: import("../lib/workers-format.js").WorkerSummary[] }} */ ( + await context.fetchJson(context.nsUrl("workers"), { headers }, "list workers") + ); + writeResult(context.values.json === true, body, () => formatWorkersList(body), context.stdout); } function usageText() { diff --git a/commands/workflows.js b/commands/workflows.js index b74d3c6..0c64294 100644 --- a/commands/workflows.js +++ b/commands/workflows.js @@ -33,7 +33,7 @@ export const main = command.main; export const runWorkflowsCommand = command.run; export const meta = command.meta; -/** @param {{ values: Record, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ +/** @param {{ values: { limit?: string, cursor?: string, "include-steps"?: boolean, "step-limit"?: string, yes?: boolean, json?: boolean }, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ async function runWorkflows({ values, positionals, context }) { const { stdout, stderr, stdin } = context; @@ -45,8 +45,10 @@ async function runWorkflows({ values, positionals, context }) { if (subcommand === "list") { requireNoExtraPositionals(positionals, 1, "workflows list"); - const body = await context.fetchJson(context.nsUrl("workflows"), { headers }, "list workflows"); - writeResult(values.json, body, () => formatWorkflowList(body), stdout); + const body = /** @type {{ workflows?: import("../lib/workflows-format.js").WorkflowSummary[] }} */ ( + await context.fetchJson(context.nsUrl("workflows"), { headers }, "list workflows") + ); + writeResult(Boolean(values.json), body, () => formatWorkflowList(body), stdout); return; } @@ -55,8 +57,10 @@ async function runWorkflows({ values, positionals, context }) { const url = new URL(context.nsUrl("workflows", worker, workflow, "instances")); if (values.limit) url.searchParams.set("limit", values.limit); if (values.cursor) url.searchParams.set("cursor", values.cursor); - const body = await context.fetchJson(url.href, { headers }, "list workflow instances"); - writeResult(values.json, body, () => formatInstanceList(body), stdout); + const body = /** @type {{ instances?: import("../lib/workflows-format.js").WorkflowInstance[], cursor?: string }} */ ( + await context.fetchJson(url.href, { headers }, "list workflow instances") + ); + writeResult(Boolean(values.json), body, () => formatInstanceList(body), stdout); return; } @@ -65,8 +69,10 @@ async function runWorkflows({ values, positionals, context }) { const url = new URL(context.nsUrl("workflows", worker, workflow, "instances", instanceId)); if (values["include-steps"]) url.searchParams.set("includeSteps", "true"); if (values["step-limit"]) url.searchParams.set("stepLimit", values["step-limit"]); - const body = await context.fetchJson(url.href, { headers }, "get workflow instance status"); - writeResult(values.json, body, () => formatInstanceStatus(body), stdout); + const body = /** @type {Parameters[0]} */ ( + await context.fetchJson(url.href, { headers }, "get workflow instance status") + ); + writeResult(Boolean(values.json), body, () => formatInstanceStatus(body), stdout); return; } @@ -81,12 +87,12 @@ async function runWorkflows({ values, positionals, context }) { action: `${subcommand} workflow instance "${ns}/${worker}/${workflow}/${instanceId}"`, }); } - const body = await context.fetchJson( + const body = /** @type {{ id?: string, status?: string }} */ (await context.fetchJson( context.nsUrl("workflows", worker, workflow, "instances", instanceId, subcommand), { method: "POST", headers }, `${subcommand} workflow instance`, - ); - writeResult(values.json, body, () => [ + )); + writeResult(Boolean(values.json), body, () => [ `OK ${ns}/${worker}/${workflow}/${body.id || instanceId} ${subcommand} status=${body.status || "-"}`, ], stdout); return; @@ -95,6 +101,10 @@ async function runWorkflows({ values, positionals, context }) { throw new CliError(`unknown workflows subcommand: ${subcommand}\n${usageText()}`); } +/** + * @param {string[]} positionals + * @param {string} label + */ function requireWorkflowRef(positionals, label) { requireNoExtraPositionals(positionals, 3, label); const worker = positionals[1]; @@ -103,6 +113,10 @@ function requireWorkflowRef(positionals, label) { return { worker, workflow }; } +/** + * @param {string[]} positionals + * @param {string} label + */ function requireInstanceRef(positionals, label) { requireNoExtraPositionals(positionals, 4, label); const worker = positionals[1]; @@ -112,6 +126,11 @@ function requireInstanceRef(positionals, label) { return { worker, workflow, instanceId }; } +/** + * @param {string[]} positionals + * @param {number} expected + * @param {string} label + */ function requireNoExtraPositionals(positionals, expected, label) { if (positionals.length > expected) { throw new CliError(`${label} received unexpected argument: ${positionals[expected]}`); diff --git a/lib/bundle-modules.js b/lib/bundle-modules.js index 42b0bbc..34c3248 100644 --- a/lib/bundle-modules.js +++ b/lib/bundle-modules.js @@ -16,8 +16,10 @@ export function inferType(filePath) { // Inverse of `control/lib.js::normalizeModule` - keep the two in sync. /** + * `type` is normally an {@link inferType} result, but the default case rejects + * anything else, so the honest input type is `string`. * @param {Buffer} buf - * @param {"module" | "cjs" | "py" | "json" | "text" | "wasm" | "data"} type + * @param {string} type */ export function toWireModule(buf, type) { switch (type) { diff --git a/lib/command.js b/lib/command.js index 6cc5e83..a9444c7 100644 --- a/lib/command.js +++ b/lib/command.js @@ -80,7 +80,7 @@ function buildParseOptions(options) { * defaults?: Record, * autoloadEnv?: boolean, * usage: () => string, - * run: (ctx: { values: Record, positionals: string[], context: CommandContext }) => Promise | unknown, + * run(ctx: { values: Record, positionals: string[], context: CommandContext }): Promise | unknown, * }} spec * @returns {{ main: (argv?: string[]) => Promise, run: (argv?: string[], deps?: object) => Promise, meta: { name: string, summary: string, autoloadEnv: boolean, parseOptions: import("node:util").ParseArgsOptionsConfig } }} */ diff --git a/lib/control-fetch.js b/lib/control-fetch.js index c733954..ddb40b4 100644 --- a/lib/control-fetch.js +++ b/lib/control-fetch.js @@ -46,11 +46,21 @@ export const UNLIMITED_CONTROL_BODY_BYTES = 0; * @typedef {ControlResponseStatus & { json?: () => Promise }} ControlJsonResponse */ +/** + * The minimal client-request surface `controlFetch` drives. Real + * `http.ClientRequest` satisfies it; so does the unit-test fake. + * @typedef {object} ControlClientRequest + * @property {(event: string, listener: (...args: A) => void) => unknown} on + * @property {(chunk: string | Buffer | Uint8Array) => unknown} write + * @property {() => unknown} end + * @property {(error?: Error) => unknown} destroy + */ + /** * The transport surface `controlFetch` uses: just `request()`. Real `node:http` * / `node:https` satisfy it, and so does the unit-test fake (which provides only * `request`). - * @typedef {{ request: (options: import("node:https").RequestOptions, onResponse: (res: import("node:http").IncomingMessage) => void) => import("node:http").ClientRequest }} ControlTransport + * @typedef {{ request: (options: import("node:https").RequestOptions, onResponse: (res: import("node:http").IncomingMessage) => void) => ControlClientRequest }} ControlTransport */ /** @@ -235,7 +245,11 @@ function streamControlResponse(res, body) { * `IncomingMessage` (status/headers populated) or the internal pipe stream used * for a streamed body (status/headers absent, re-read off the already-captured * `ControlResponse`). `destroy` is optional to tolerate the non-stream test fakes. - * @typedef {import("node:stream").Readable & { statusCode?: number, headers?: import("node:http").IncomingHttpHeaders, destroy?: () => void }} ControlBodySource + * @typedef {object} ControlBodySource + * @property {(event: string, listener: (...args: A) => void) => unknown} on + * @property {number} [statusCode] + * @property {import("node:http").IncomingHttpHeaders} [headers] + * @property {() => void} [destroy] */ /** diff --git a/lib/d1-files.js b/lib/d1-files.js index 3ae6862..33860d2 100644 --- a/lib/d1-files.js +++ b/lib/d1-files.js @@ -4,13 +4,20 @@ import path from "node:path"; import { CliError, isPathInside } from "./common.js"; +/** + * @param {Record} values Parsed flag values (`--sql`, `--file`). + * @param {string} [cwd] + * @returns {string} + */ export function readSql(values, cwd = process.cwd()) { const hasSql = values.sql !== undefined; const hasFile = values.file !== undefined; if (hasSql && hasFile) throw new CliError("pass only one of --sql or --file"); if (hasSql) return requireSqlText(values.sql, "--sql"); if (hasFile) { - if (!values.file) throw new CliError("--file requires a path"); + if (typeof values.file !== "string" || !values.file) { + throw new CliError("--file requires a path"); + } // Keep --file inside the project, like the migrations-dir checks — a // relative/absolute path must not pull SQL from outside the repo. if (!existsSync(cwd)) throw new CliError(`working directory ${cwd} does not exist`); @@ -25,6 +32,11 @@ export function readSql(values, cwd = process.cwd()) { throw new CliError("d1 execute requires --sql or --file "); } +/** + * @param {unknown} sql + * @param {string} source + * @returns {string} + */ function requireSqlText(sql, source) { if (typeof sql !== "string" || !sql.trim()) { throw new CliError(`${source} must contain non-empty SQL`); @@ -32,13 +44,26 @@ function requireSqlText(sql, source) { return sql; } +/** + * @typedef {object} MigrationFile + * @property {string} id + * @property {string} name + * @property {string} checksum + * @property {string} sql + */ + +/** + * @param {string} [dir] + * @returns {MigrationFile[]} + */ export function readMigrationFiles(dir = "migrations") { const root = path.resolve(dir); let entries; try { entries = readdirSync(root, { withFileTypes: true }); } catch (err) { - throw new CliError(`cannot read migrations dir ${dir}: ${err.message}`); + const message = err instanceof Error ? err.message : String(err); + throw new CliError(`cannot read migrations dir ${dir}: ${message}`); } return entries .filter((entry) => entry.isFile() && entry.name.endsWith(".sql")) @@ -55,13 +80,18 @@ export function readMigrationFiles(dir = "migrations") { sql, }; }) - .filter(Boolean); + .filter(/** @returns {entry is MigrationFile} */ (entry) => entry != null); } // Order by the numeric prefix when both names have one ("2_x.sql" before // "10_y.sql" even without zero-padding); fall back to lexicographic so // non-numeric names keep plain string order. String-compare the trimmed // digits to stay exact beyond Number's integer precision. +/** + * @param {string} a + * @param {string} b + * @returns {number} + */ function compareMigrationFilenames(a, b) { const numA = /^\d+/.exec(a); const numB = /^\d+/.exec(b); diff --git a/lib/d1-format.js b/lib/d1-format.js index 5553f2e..094ef41 100644 --- a/lib/d1-format.js +++ b/lib/d1-format.js @@ -1,3 +1,23 @@ +/** + * @typedef {object} D1Database + * @property {string} [databaseId] + * @property {string} [databaseName] + * @property {string} [createdAt] + */ + +/** + * @typedef {object} D1Migration + * @property {string} [id] + * @property {string} [appliedAt] + * @property {string} [checksum] + * @property {string} [state] + * @property {number} [statementCount] + */ + +/** + * @param {{ databases?: D1Database[] }} body + * @returns {string[]} + */ export function formatD1List(body) { const databases = Array.isArray(body.databases) ? body.databases : []; if (databases.length === 0) return ["(no d1 databases)"]; @@ -6,10 +26,18 @@ export function formatD1List(body) { ); } +/** + * @param {{ result?: unknown }} body + * @returns {string[]} + */ export function formatD1Execute(body) { return [JSON.stringify(body.result, null, 2)]; } +/** + * @param {{ migrations?: D1Migration[] }} body + * @returns {string[]} + */ export function formatD1MigrationList(body) { const migrations = Array.isArray(body.migrations) ? body.migrations : []; if (migrations.length === 0) return ["(no d1 migrations applied)"]; @@ -18,6 +46,10 @@ export function formatD1MigrationList(body) { ); } +/** + * @param {{ migrations?: D1Migration[] }} body + * @returns {string[]} + */ export function formatD1MigrationStatus(body) { const migrations = Array.isArray(body.migrations) ? body.migrations : []; if (migrations.length === 0) return ["(no local migrations)"]; @@ -26,6 +58,10 @@ export function formatD1MigrationStatus(body) { ); } +/** + * @param {{ applied?: D1Migration[], skipped?: D1Migration[] }} body + * @returns {string[]} + */ export function formatD1MigrationApply(body) { const applied = Array.isArray(body.applied) ? body.applied : []; const skipped = Array.isArray(body.skipped) ? body.skipped : []; diff --git a/lib/delete-format.js b/lib/delete-format.js index 519e0c5..8b73f25 100644 --- a/lib/delete-format.js +++ b/lib/delete-format.js @@ -9,12 +9,65 @@ const ASSET_WARNING_KEYS = [ "reason", ]; +/** + * @typedef {object} DeleteAssetsSummary + * @property {boolean} [skippedSharedPrefix] + * @property {unknown[]} [warnings] + */ + +/** + * @typedef {object} DeleteBlockerReferrer + * @property {string} [callerNs] + * @property {string} [callerWorker] + * @property {string} [callerVersion] + * @property {string} [binding] + */ + +/** + * @typedef {object} DeleteBlocker + * @property {string} [version] + * @property {DeleteBlockerReferrer[]} [referrers] + * @property {number} [crossNamespaceReferrerCount] + */ + +/** + * @typedef {object} VersionDeleteBody + * @property {string} [namespace] + * @property {string} [name] + * @property {string} [version] + * @property {DeleteAssetsSummary} [assets] + */ + +/** + * @typedef {object} WorkerDeleteBody + * @property {string} [namespace] + * @property {string} [name] + * @property {boolean} [dryRun] + * @property {boolean} [deleted] + * @property {boolean} [noop] + * @property {boolean} [hasWorkerSecrets] + * @property {string[]} [versionsDeleted] + * @property {string} [activeDeleted] + * @property {string[]} [affectedHosts] + * @property {number} [queueConsumersRemoved] + * @property {DeleteBlocker[]} [blockers] + * @property {DeleteAssetsSummary} [assets] + */ + +/** + * @param {VersionDeleteBody} body + * @returns {string[]} + */ export function formatVersionDelete(body) { const lines = [`OK ${body.namespace}/${body.name}@${body.version} deleted`]; appendAssetsSummary(lines, body.assets); return lines; } +/** + * @param {WorkerDeleteBody} body + * @returns {string[]} + */ export function formatWorkerDelete(body) { if (body.dryRun) return formatDryRun(body); if (!body.deleted) { @@ -31,13 +84,17 @@ export function formatWorkerDelete(body) { if (Array.isArray(body.affectedHosts) && body.affectedHosts.length) { lines.push(` affected hosts: ${body.affectedHosts.join(",")}`); } - if (Number.isFinite(body.queueConsumersRemoved) && body.queueConsumersRemoved > 0) { + if (Number.isFinite(body.queueConsumersRemoved) && Number(body.queueConsumersRemoved) > 0) { lines.push(` queue consumers removed: ${body.queueConsumersRemoved}`); } appendAssetsSummary(lines, body.assets); return lines; } +/** + * @param {WorkerDeleteBody} body + * @returns {string[]} + */ function formatDryRun(body) { const versions = Array.isArray(body.versionsDeleted) && body.versionsDeleted.length ? body.versionsDeleted.join(",") @@ -51,13 +108,18 @@ function formatDryRun(body) { if (Array.isArray(body.affectedHosts) && body.affectedHosts.length) { lines.push(` affected hosts: ${body.affectedHosts.join(",")}`); } - if (Number.isFinite(body.queueConsumersRemoved) && body.queueConsumersRemoved > 0) { + if (Number.isFinite(body.queueConsumersRemoved) && Number(body.queueConsumersRemoved) > 0) { lines.push(` queue consumers removed: ${body.queueConsumersRemoved}`); } appendBlockers(lines, body.blockers); return lines; } +/** + * @param {string[]} lines + * @param {DeleteAssetsSummary | undefined} assets + * @returns {void} + */ function appendAssetsSummary(lines, assets) { if (!assets) return; if (assets.skippedSharedPrefix) { @@ -69,6 +131,11 @@ function appendAssetsSummary(lines, assets) { } } +/** + * @param {string[]} lines + * @param {DeleteBlocker[] | undefined} blockers + * @returns {void} + */ function appendBlockers(lines, blockers) { if (!Array.isArray(blockers) || blockers.length === 0) return; lines.push(" blockers:"); @@ -81,7 +148,7 @@ function appendBlockers(lines, blockers) { ); } if (Number.isFinite(blocker.crossNamespaceReferrerCount) && - blocker.crossNamespaceReferrerCount > 0) { + Number(blocker.crossNamespaceReferrerCount) > 0) { lines.push(` cross-namespace referrers: ${blocker.crossNamespaceReferrerCount}`); } } diff --git a/lib/r2-format.js b/lib/r2-format.js index 41382e2..ff781e9 100644 --- a/lib/r2-format.js +++ b/lib/r2-format.js @@ -1,5 +1,22 @@ // Human-readable rendering for `wdl r2`. (Response-header parsing lives in r2.js.) +/** + * @typedef {object} R2Bucket + * @property {string} name + */ + +/** + * @typedef {object} R2Object + * @property {string} key + * @property {number} [size] + * @property {string} [etag] + * @property {string} [uploaded] + */ + +/** + * @param {{ namespace?: string, buckets?: R2Bucket[], truncated?: boolean, cursor?: string }} body + * @returns {string[]} + */ export function formatBucketList(body) { const lines = [`R2 buckets in ${body.namespace}:`]; for (const bucket of body.buckets || []) lines.push(` ${bucket.name}`); @@ -7,6 +24,17 @@ export function formatBucketList(body) { return lines; } +/** + * @param {{ + * namespace?: string, + * bucket?: string, + * delimitedPrefixes?: string[], + * objects?: R2Object[], + * truncated?: boolean, + * cursor?: string, + * }} body + * @returns {string[]} + */ export function formatObjectList(body) { const lines = [`R2 objects in ${body.namespace}/${body.bucket}:`]; for (const prefix of body.delimitedPrefixes || []) lines.push(` ${prefix}`); @@ -17,6 +45,19 @@ export function formatObjectList(body) { return lines; } +/** + * @param {{ + * namespace?: string, + * bucket?: string, + * key?: string, + * size?: number, + * etag?: string, + * uploaded?: string, + * httpMetadata?: Record, + * customMetadata?: Record, + * }} body + * @returns {string[]} + */ export function formatObjectHead(body) { const lines = [`R2 object ${body.namespace}/${body.bucket}/${body.key}:`]; lines.push(` size: ${body.size}`); diff --git a/lib/whoami.js b/lib/whoami.js index 4f66535..ef0f867 100644 --- a/lib/whoami.js +++ b/lib/whoami.js @@ -2,13 +2,57 @@ import { CliError, isNonEmptyString, readJsonOrFail } from "./common.js"; import { escapeTerminalText } from "./output.js"; import { currentCliVersion } from "./package-info.js"; +/** + * @typedef {object} WhoamiPrincipal + * @property {string} kind + * @property {string} [ns] + */ + +/** + * Public-facing subset of a whoami principal, after stripping non-public fields. + * @typedef {object} PublicPrincipal + * @property {string} kind + * @property {string} [ns] + */ + +/** + * Raw whoami response body as returned by the control plane. + * @typedef {object} WhoamiBody + * @property {boolean} [ok] + * @property {WhoamiPrincipal} [principal] + * @property {string} [tokenId] + * @property {string} [requestId] + * @property {string} [platformVersion] + * @property {string} [minCliVersion] + * @property {Record} [urls] + */ + +/** + * @typedef {object} CliCompatibility + * @property {boolean} ok + * @property {string} label + * @property {string} detail + */ + +/** + * @param {{ + * controlUrl: string, + * headers: Record, + * controlFetch: typeof import("./control-fetch.js").controlFetch, + * }} options + * @returns {Promise} + */ export async function fetchWhoami({ controlUrl, headers, controlFetch }) { const res = await controlFetch(`${controlUrl}/whoami`, { headers }); - const body = await readJsonOrFail(res, "whoami"); + const body = /** @type {WhoamiBody} */ (await readJsonOrFail(res, "whoami")); if (body?.ok !== true) throw new CliError("whoami failed: invalid control response"); return body; } +/** + * @param {WhoamiBody | null | undefined} body + * @param {string} [cliVersion] + */ export function summarizeWhoami(body, cliVersion = currentCliVersion()) { const minCliVersion = stringField(body?.minCliVersion); return { @@ -25,6 +69,11 @@ export function summarizeWhoami(body, cliVersion = currentCliVersion()) { }; } +/** + * @param {string | undefined} cliVersion + * @param {string} minCliVersion + * @returns {CliCompatibility} + */ export function cliCompatibility(cliVersion, minCliVersion) { if (!minCliVersion) { return { @@ -50,6 +99,11 @@ export function cliCompatibility(cliVersion, minCliVersion) { }; } +/** + * @param {string | undefined} left + * @param {string} right + * @returns {number | null} + */ export function compareSemver(left, right) { const a = parseSemver(left); const b = parseSemver(right); @@ -64,27 +118,47 @@ export function compareSemver(left, right) { return 0; } +/** + * @param {unknown} principal + * @returns {string} + */ export function formatPrincipal(principal) { const publicShape = publicPrincipal(principal); if (!publicShape) return "(unavailable)"; return publicShape.ns ? `${publicShape.kind}/${publicShape.ns}` : publicShape.kind; } +/** + * @param {unknown} principal + * @returns {string | null} + */ export function namespaceFromPrincipal(principal) { const publicShape = publicPrincipal(principal); return publicShape?.ns || null; } +/** + * @param {unknown} principal + * @returns {PublicPrincipal | null} + */ function publicPrincipal(principal) { - if (!principal || typeof principal !== "object" || typeof principal.kind !== "string") return null; - if (isNonEmptyString(principal.ns)) { - return { kind: principal.kind, ns: principal.ns }; + if (!principal || typeof principal !== "object" || !("kind" in principal) || typeof principal.kind !== "string") { + return null; + } + const ns = /** @type {{ ns?: unknown }} */ (principal).ns; + if (isNonEmptyString(ns)) { + return { kind: principal.kind, ns }; } return { kind: principal.kind }; } +/** + * @param {Record | undefined} urls + * @returns {Record} + */ function publicUrls(urls) { if (!urls || typeof urls !== "object") return {}; + /** @type {Record} */ const out = {}; for (const key of ["control", "namespace", "assets"]) { const value = stringField(urls[key]); @@ -93,6 +167,10 @@ function publicUrls(urls) { return out; } +/** + * @param {unknown} value + * @returns {{ nums: number[], prerelease: boolean } | null} + */ function parseSemver(value) { const match = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(?:\+.*)?$/.exec(String(value || "")); if (!match) return null; @@ -102,20 +180,35 @@ function parseSemver(value) { }; } +/** + * @param {unknown} value + * @returns {string} + */ function stringField(value) { return isNonEmptyString(value) ? value : ""; } +/** + * @param {{ + * controlUrl: import("./config-state.js").ConfigEntry, + * token: import("./config-state.js").ConfigEntry, + * }} state + * @returns {{ controlUrl: string, token: string, headers: Record }} + */ export function ensureControlContextFromConfigState(state) { if (state.controlUrl.error) throw new CliError(state.controlUrl.error); 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, + controlUrl: /** @type {string} */ (state.controlUrl.value), token: state.token.value, headers: { "x-admin-token": state.token.value }, }; } +/** + * @param {string | null | undefined} value + * @returns {string} + */ export function displayRemoteValue(value) { return escapeTerminalText(value || "(unavailable)"); } diff --git a/lib/workers-format.js b/lib/workers-format.js index 66ecb7b..7640ed6 100644 --- a/lib/workers-format.js +++ b/lib/workers-format.js @@ -1,4 +1,17 @@ // Human-readable rendering for `wdl workers`. + +/** + * @typedef {object} WorkerSummary + * @property {string} [name] + * @property {string[]} [versions] + * @property {string} [activeVersion] + * @property {boolean} [hasSecrets] + */ + +/** + * @param {{ workers?: WorkerSummary[] }} body + * @returns {string[]} + */ export function formatWorkersList(body) { const workers = Array.isArray(body.workers) ? body.workers : []; if (workers.length === 0) return ["(no workers)"]; diff --git a/lib/workflows-format.js b/lib/workflows-format.js index fe00795..da38b70 100644 --- a/lib/workflows-format.js +++ b/lib/workflows-format.js @@ -1,3 +1,30 @@ +/** + * @typedef {object} WorkflowSummary + * @property {string} [worker] + * @property {string} [name] + * @property {string} [binding] + * @property {string} [className] + * @property {string} [activeVersion] + * @property {string} [workflowKey] + */ + +/** + * @typedef {object} WorkflowInstance + * @property {string} [id] + * @property {string} [status] + */ + +/** + * @typedef {object} WorkflowStep + * @property {number} ordinal + * @property {string} name + * @property {string} status + */ + +/** + * @param {{ workflows?: WorkflowSummary[] } | null | undefined} body + * @returns {string[]} + */ export function formatWorkflowList(body) { const workflows = Array.isArray(body?.workflows) ? body.workflows : []; if (workflows.length === 0) return ["(no workflows)"]; @@ -6,6 +33,10 @@ export function formatWorkflowList(body) { ); } +/** + * @param {{ instances?: WorkflowInstance[], cursor?: string } | null | undefined} body + * @returns {string[]} + */ export function formatInstanceList(body) { const instances = Array.isArray(body?.instances) ? body.instances : []; const lines = instances.length === 0 @@ -15,6 +46,16 @@ export function formatInstanceList(body) { return lines; } +/** + * @param {{ + * id?: string, + * status?: string, + * output?: unknown, + * error?: unknown, + * steps?: { entries?: WorkflowStep[], truncated?: boolean }, + * }} body + * @returns {string[]} + */ export function formatInstanceStatus(body) { const lines = [`${body.id || "-"}\tstatus=${body.status || "-"}`]; if (body.output !== undefined && body.output !== null) { diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index a1014c9..8b88ba9 100644 --- a/lib/wrangler-pack.js +++ b/lib/wrangler-pack.js @@ -76,6 +76,29 @@ export { collectModules } from "./wrangler/modules.js"; const WRANGLER_OUTPUT_MAX_BUFFER = 10 * 1024 * 1024; +/** + * The deploy manifest assembled from a wrangler project. Optional sections are + * only present when the config declares them. + * @typedef {object} WorkerManifest + * @property {string} mainModule + * @property {Record} modules + * @property {Record} [bindings] + * @property {Record} [vars] + * @property {unknown} [compatibilityDate] + * @property {unknown} [compatibilityFlags] + * @property {string[]} [routes] + * @property {Array<{ cron: string, timezone: string }>} [crons] + * @property {import("./wrangler/bindings.js").QueueConsumer[]} [queueConsumers] + * @property {Array<{ name: string, binding: string, className: unknown }>} [workflows] + * @property {import("./wrangler/bindings.js").ExportEntry[]} [exports] + * @property {Array<{ binding: string, platform: string }>} [platformBindings] + * @property {Record} [assets] + */ + +/** + * @param {unknown} vars + * @returns {Record} + */ function normalizeVars(vars) { if (vars == null) return {}; if (typeof vars !== "object" || Array.isArray(vars)) { @@ -97,6 +120,19 @@ function normalizeVars(vars) { return normalized; } +/** + * @param {{ + * cwd?: string, + * projectDir: string, + * envName?: string | null, + * env?: NodeJS.ProcessEnv, + * execFile?: typeof execFileSync, + * stdout?: (line?: string) => void, + * stderr?: (line?: string) => void, + * verbose?: boolean, + * }} options + * @returns {Promise<{ absProject: string, workerName: string, manifest: WorkerManifest }>} + */ export async function packWranglerProject({ cwd = process.cwd(), projectDir, @@ -122,7 +158,9 @@ export async function packWranglerProject({ const bindings = manifestMap(); // Every name a worker binds (manifest bindings, workflows, platform // bindings) shares the runtime env namespace; claim each one exactly once. + /** @type {Set} */ const claimedBindings = new Set(); + /** @param {string} name */ const claimBinding = (name) => { if (claimedBindings.has(name)) throw new CliError(`binding name collision: ${name}`); claimedBindings.add(name); @@ -152,6 +190,7 @@ export async function packWranglerProject({ const svcList = wrapCli(() => parseServicesFromCfg(cfg, configRel)); for (const svc of svcList) { claimBinding(svc.binding); + /** @type {{ type: string, service: unknown, entrypoint?: unknown, ns?: unknown }} */ const entry = { type: "service", service: svc.service }; if (svc.entrypoint && svc.entrypoint !== "default") entry.entrypoint = svc.entrypoint; if (svc.ns) entry.ns = svc.ns; @@ -177,6 +216,7 @@ export async function packWranglerProject({ ); for (const p of queueProducers) { claimBinding(p.binding); + /** @type {{ type: string, id: string, deliveryDelaySeconds?: number }} */ const binding = { type: "queue", id: p.queue }; if (p.deliveryDelaySeconds != null) binding.deliveryDelaySeconds = p.deliveryDelaySeconds; bindings[p.binding] = binding; @@ -201,7 +241,9 @@ export async function packWranglerProject({ `${path.relative(cwd, outDir)}` ); const tmpConfigPath = path.join(absProject, `.wrangler.wdl-tmp-${randomUUID()}.json`); - writeFileSync(tmpConfigPath, JSON.stringify({ ...rawCfg, name: "wdl-bundle-tmp" }), { + // resolveWranglerConfig already verified rawCfg is a plain object. + const rawCfgObject = /** @type {Record} */ (rawCfg); + writeFileSync(tmpConfigPath, JSON.stringify({ ...rawCfgObject, name: "wdl-bundle-tmp" }), { flag: "wx", }); try { @@ -243,6 +285,7 @@ export async function packWranglerProject({ ); } + /** @type {WorkerManifest} */ const manifest = { mainModule: entryName, modules }; if (Object.keys(bindings).length) manifest.bindings = bindings; if (Object.keys(vars).length) manifest.vars = vars; @@ -266,11 +309,17 @@ export async function packWranglerProject({ ); } - const assetsDirRel = cfg.assets && cfg.assets.directory; + const assetsCfg = + cfg.assets && typeof cfg.assets === "object" && !Array.isArray(cfg.assets) + ? /** @type {Record} */ (cfg.assets) + : null; + const assetsDirRel = assetsCfg ? assetsCfg.directory : undefined; if (assetsDirRel) { const assetsDir = wrapCli(() => - resolveAssetsDir(absProject, assetsDirRel, configRel) + // assets.directory is a config path; resolveAssetsDir validates it on disk. + resolveAssetsDir(absProject, /** @type {string} */ (assetsDirRel), configRel) ); + /** @type {string[]} */ const skippedAssets = []; const assets = wrapCli(() => collectAssets(assetsDir, { @@ -293,15 +342,27 @@ export async function packWranglerProject({ if (Object.keys(assets).length) manifest.assets = assets; } - return { absProject, workerName: cfg.name, manifest }; + // `name` was asserted present above and is a string by wrangler's schema. + return { absProject, workerName: /** @type {string} */ (cfg.name), manifest }; } +/** + * @param {import("./wrangler/config.js").WranglerConfig} cfg + * @param {string} configRel + * @returns {string[]} + */ function collectRoutes(cfg, configRel) { + /** @type {string[]} */ const collected = []; + /** + * @param {unknown} r + * @param {string} source + */ const pushEntry = (r, source) => { if (typeof r === "string") collected.push(r); - else if (r && typeof r === "object" && typeof r.pattern === "string") collected.push(r.pattern); - else throw new CliError(`unsupported ${source} entry: ${JSON.stringify(r)}`); + else if (r && typeof r === "object" && typeof (/** @type {Record} */ (r).pattern) === "string") { + collected.push(/** @type {string} */ (/** @type {Record} */ (r).pattern)); + } else throw new CliError(`unsupported ${source} entry: ${JSON.stringify(r)}`); }; if (cfg.route !== undefined && cfg.routes !== undefined) { throw new CliError(`${configRel}: specify either "route" or "routes", not both`); @@ -313,6 +374,11 @@ function collectRoutes(cfg, configRel) { return collected; } +/** + * @template T + * @param {() => T} fn + * @returns {T} + */ function wrapCli(fn) { try { return fn(); diff --git a/lib/wrangler/assets.js b/lib/wrangler/assets.js index 73ca371..cbdfe88 100644 --- a/lib/wrangler/assets.js +++ b/lib/wrangler/assets.js @@ -24,6 +24,12 @@ const DEFAULT_ASSET_IGNORE_PATTERNS = [ "**/.env.*", ]; +/** + * @param {string} absProject + * @param {string} assetsDirRel + * @param {string} [configRel] + * @returns {string} + */ export function resolveAssetsDir(absProject, assetsDirRel, configRel = "wrangler config") { const assetsDir = path.resolve(absProject, assetsDirRel); if (!existsSync(assetsDir)) { @@ -49,6 +55,11 @@ export function resolveAssetsDir(absProject, assetsDirRel, configRel = "wrangler return assetsDir; } +/** + * @param {string} dir + * @param {{ onIgnore?: ((relPath: string, isDir: boolean) => void) | null }} [options] + * @returns {Record} + */ export function collectAssets(dir, { onIgnore = null } = {}) { const rootReal = realpathSync(dir); const ignoreFile = path.join(dir, ASSETS_IGNORE_FILENAME); @@ -109,6 +120,10 @@ export function collectAssets(dir, { onIgnore = null } = {}) { return out; } +/** + * @param {string} text + * @returns {string[]} + */ function parseAssetIgnorePatterns(text) { const patterns = []; for (const rawLine of String(text).split(/\r?\n/)) { @@ -124,9 +139,18 @@ function parseAssetIgnorePatterns(text) { // anchors to the assets root, `*` `**` `?` globs, last match wins. As with // gitignore, an ignored directory prunes its whole subtree — a negation // cannot re-include files inside it. +/** + * @param {string[]} patterns + * @returns {{ ignores: (relPath: string, isDir: boolean) => boolean }} + */ function createAssetIgnoreMatcher(patterns) { const rules = patterns.map(compileAssetIgnoreRule); return { + /** + * @param {string} relPath + * @param {boolean} isDir + * @returns {boolean} + */ ignores(relPath, isDir) { let ignored = false; for (const rule of rules) { @@ -138,6 +162,10 @@ function createAssetIgnoreMatcher(patterns) { }; } +/** + * @param {string} pattern + * @returns {{ regex: RegExp, dirOnly: boolean, negated: boolean }} + */ function compileAssetIgnoreRule(pattern) { let negated = false; if (pattern.startsWith("!")) { @@ -164,6 +192,10 @@ function compileAssetIgnoreRule(pattern) { }; } +/** + * @param {string} pattern + * @returns {string} + */ function assetGlobToRegex(pattern) { let out = ""; for (let i = 0; i < pattern.length; i += 1) { @@ -212,6 +244,11 @@ function assetGlobToRegex(pattern) { // `]` literal when it is the first member. Returns null for an unterminated // class so the caller falls back to a literal `[`. (The body is never empty // at the terminator: a leading `]` is consumed as a literal member.) +/** + * @param {string} pattern + * @param {number} start + * @returns {{ regex: string, end: number } | null} + */ function parseGlobClass(pattern, start) { let i = start + 1; let negated = false; diff --git a/lib/wrangler/bindings.js b/lib/wrangler/bindings.js index a09e29e..37faa8e 100644 --- a/lib/wrangler/bindings.js +++ b/lib/wrangler/bindings.js @@ -11,14 +11,34 @@ import { isValidJsIdentifier, } from "../ns-pattern.js"; +/** @typedef {import("./config.js").WranglerConfig} WranglerConfig */ + const NS_RE = new RegExp(`^${NS_PATTERN}$`); const MAX_QUEUE_DELAY_SECONDS = 86_400; + +/** + * A non-null, non-array object viewed as a string-keyed record, or null. + * @param {unknown} value + * @returns {Record | null} + */ +function asRecord(value) { + return value && typeof value === "object" && !Array.isArray(value) + ? /** @type {Record} */ (value) + : null; +} // UPPER_SNAKE for `as` / `platform` / required_caller_secrets - narrower // than binding names to read as registered identifiers. const PLATFORM_KEY_RE = /^[A-Z_][A-Z0-9_]*$/; +/** + * The caller may hand any value: these helpers validate via regex, which + * stringifies its argument, so `binding` is the unvalidated config value. + * @param {string} configRel + * @param {string} scope + * @param {unknown} binding + */ export function assertNotRuntimeReservedBinding(configRel, scope, binding) { - if (WDL_RESERVED_BINDING_RE.test(binding)) { + if (WDL_RESERVED_BINDING_RE.test(String(binding))) { throw new Error( `${configRel}: ${scope} ${binding}: binding name is reserved for runtime-internal bindings` ); @@ -29,35 +49,48 @@ export function assertNotRuntimeReservedBinding(configRel, scope, binding) { // identifiers so `env.` resolves at runtime. workflows validate the same // way in their own parser; platform_bindings/exports use the narrower // PLATFORM_KEY_RE instead. +/** + * @param {string} configRel + * @param {string} scope + * @param {unknown} binding Unvalidated config value; regex `.test` stringifies it. + */ export function assertValidBindingName(configRel, scope, binding) { - if (!BINDING_NAME_RE.test(binding)) { + if (!BINDING_NAME_RE.test(String(binding))) { throw new Error(`${configRel}: ${scope} ${binding}: binding must match ${BINDING_NAME_RE}`); } } +/** + * @param {unknown} triggers + * @param {string} [configRel] + * @returns {Array<{ cron: string, timezone: string }>} + */ export function parseTriggers(triggers, configRel = "wrangler config") { if (triggers == null) return []; - if (typeof triggers !== "object" || Array.isArray(triggers)) { + const triggersTable = asRecord(triggers); + if (!triggersTable) { throw new Error(`${configRel}: [triggers] must be a table`); } + /** @type {Array<{ cron: string, timezone: string }>} */ const out = []; - if (triggers.crons != null) { - if (!Array.isArray(triggers.crons)) { + if (triggersTable.crons != null) { + if (!Array.isArray(triggersTable.crons)) { throw new Error(`${configRel}: triggers.crons must be an array of strings`); } - for (const entry of triggers.crons) { + for (const entry of triggersTable.crons) { if (typeof entry !== "string" || !entry.trim()) { throw new Error(`${configRel}: triggers.crons entries must be non-empty strings`); } out.push({ cron: entry.trim(), timezone: "UTC" }); } } - if (triggers.schedules != null) { - if (!Array.isArray(triggers.schedules)) { + if (triggersTable.schedules != null) { + if (!Array.isArray(triggersTable.schedules)) { throw new Error(`${configRel}: [[triggers.schedules]] must be an array of tables`); } - for (const entry of triggers.schedules) { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + for (const rawEntry of triggersTable.schedules) { + const entry = asRecord(rawEntry); + if (!entry) { throw new Error(`${configRel}: [[triggers.schedules]] entry must be a table`); } if (typeof entry.cron !== "string" || !entry.cron.trim()) { @@ -73,18 +106,43 @@ export function parseTriggers(triggers, configRel = "wrangler config") { return out; } +/** + * @typedef {object} QueueProducer + * @property {string} binding + * @property {string} queue + * @property {number} [deliveryDelaySeconds] + */ + +/** + * @typedef {object} QueueConsumer + * @property {string} queue + * @property {unknown} [maxBatchSize] + * @property {number} [maxBatchTimeoutMs] + * @property {unknown} [maxRetries] + * @property {number} [retryDelaySeconds] + * @property {unknown} [deadLetterQueue] + */ + +/** + * @param {unknown} queues + * @param {string} [configRel] + * @returns {{ producers: QueueProducer[], consumers: QueueConsumer[] }} + */ export function parseQueues(queues, configRel = "wrangler config") { if (queues == null) return { producers: [], consumers: [] }; - if (typeof queues !== "object" || Array.isArray(queues)) { + const queuesTable = asRecord(queues); + if (!queuesTable) { throw new Error(`${configRel}: [queues] must be a table`); } + /** @type {QueueProducer[]} */ const producers = []; - if (queues.producers != null) { - if (!Array.isArray(queues.producers)) { + if (queuesTable.producers != null) { + if (!Array.isArray(queuesTable.producers)) { throw new Error(`${configRel}: [[queues.producers]] must be an array of tables`); } - for (const p of queues.producers) { - if (!p || typeof p !== "object" || Array.isArray(p)) { + for (const rawProducer of queuesTable.producers) { + const p = asRecord(rawProducer); + if (!p) { throw new Error(`${configRel}: [[queues.producers]] entry must be a table`); } if (typeof p.binding !== "string" || !p.binding.trim()) { @@ -95,6 +153,7 @@ export function parseQueues(queues, configRel = "wrangler config") { if (typeof p.queue !== "string" || !p.queue.trim()) { throw new Error(`${configRel}: [[queues.producers]].queue is required`); } + /** @type {QueueProducer} */ const producer = { binding: p.binding, queue: p.queue }; if (p.delivery_delay != null) { producer.deliveryDelaySeconds = normalizeQueueDelayConfig( @@ -106,13 +165,15 @@ export function parseQueues(queues, configRel = "wrangler config") { producers.push(producer); } } + /** @type {QueueConsumer[]} */ const consumers = []; - if (queues.consumers != null) { - if (!Array.isArray(queues.consumers)) { + if (queuesTable.consumers != null) { + if (!Array.isArray(queuesTable.consumers)) { throw new Error(`${configRel}: [[queues.consumers]] must be an array of tables`); } - for (const c of queues.consumers) { - if (!c || typeof c !== "object" || Array.isArray(c)) { + for (const rawConsumer of queuesTable.consumers) { + const c = asRecord(rawConsumer); + if (!c) { throw new Error(`${configRel}: [[queues.consumers]] entry must be a table`); } if (typeof c.queue !== "string" || !c.queue.trim()) { @@ -123,6 +184,7 @@ export function parseQueues(queues, configRel = "wrangler config") { `${configRel}: [[queues.consumers]] ${c.queue}: max_concurrency not supported` ); } + /** @type {QueueConsumer} */ const entry = { queue: c.queue }; if (c.max_batch_size != null) entry.maxBatchSize = c.max_batch_size; if (c.max_batch_timeout != null) { @@ -147,6 +209,11 @@ export function parseQueues(queues, configRel = "wrangler config") { return { producers, consumers }; } +/** + * @param {WranglerConfig} cfg + * @param {string} [configRel] + * @returns {Array<{ binding: string, databaseId: string }>} + */ export function parseD1DatabasesFromCfg(cfg, configRel = "wrangler config") { if (cfg.d1_databases == null) return []; if (!Array.isArray(cfg.d1_databases)) { @@ -161,9 +228,11 @@ export function parseD1DatabasesFromCfg(cfg, configRel = "wrangler config") { "migrations_dir", "migrations_table", ]); + /** @type {Array<{ binding: string, databaseId: string }>} */ const out = []; - for (const entry of cfg.d1_databases) { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + for (const rawEntry of cfg.d1_databases) { + const entry = asRecord(rawEntry); + if (!entry) { throw new Error(`${configRel}: [[d1_databases]] entry must be a table`); } const unknownKeys = Object.keys(entry).filter((key) => !allowedKeys.has(key)); @@ -189,15 +258,22 @@ export function parseD1DatabasesFromCfg(cfg, configRel = "wrangler config") { return out; } +/** + * @param {WranglerConfig} cfg + * @param {string} [configRel] + * @returns {Array<{ binding: string, bucketName: string }>} + */ export function parseR2BucketsFromCfg(cfg, configRel = "wrangler config") { if (cfg.r2_buckets == null) return []; if (!Array.isArray(cfg.r2_buckets)) { throw new Error(`${configRel}: [[r2_buckets]] must be an array of tables`); } const allowedKeys = new Set(["binding", "bucket_name", "preview_bucket_name", "jurisdiction"]); + /** @type {Array<{ binding: string, bucketName: string }>} */ const out = []; - for (const entry of cfg.r2_buckets) { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + for (const rawEntry of cfg.r2_buckets) { + const entry = asRecord(rawEntry); + if (!entry) { throw new Error(`${configRel}: [[r2_buckets]] entry must be a table`); } const unknownKeys = Object.keys(entry).filter((key) => !allowedKeys.has(key)); @@ -235,14 +311,32 @@ export function parseR2BucketsFromCfg(cfg, configRel = "wrangler config") { return out; } +/** + * `binding` is validated against BINDING_NAME_RE; `service`, `entrypoint`, and + * `ns` are only truthiness/identifier checked, so they may be non-string values + * the original code passes through unchanged. + * @typedef {object} ServiceBinding + * @property {string} binding + * @property {unknown} service + * @property {unknown} [entrypoint] + * @property {unknown} [ns] + */ + +/** + * @param {WranglerConfig} cfg + * @param {string} [configRel] + * @returns {ServiceBinding[]} + */ export function parseServicesFromCfg(cfg, configRel = "wrangler config") { if (cfg.services == null) return []; if (!Array.isArray(cfg.services)) { throw new Error(`${configRel}: [[services]] must be an array of tables`); } + /** @type {ServiceBinding[]} */ const out = []; - for (const entry of cfg.services) { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + for (const rawEntry of cfg.services) { + const entry = asRecord(rawEntry); + if (!entry) { throw new Error(`${configRel}: [[services]] entry must be a table`); } if (!entry.binding || !entry.service) { @@ -269,7 +363,12 @@ export function parseServicesFromCfg(cfg, configRel = "wrangler config") { ); } } - const normalized = { binding: entry.binding, service: entry.service }; + // `entry.binding` matched BINDING_NAME_RE above, so it is a string here. + /** @type {ServiceBinding} */ + const normalized = { + binding: /** @type {string} */ (entry.binding), + service: entry.service, + }; if (entry.entrypoint != null) normalized.entrypoint = entry.entrypoint; if (entry.ns != null) normalized.ns = entry.ns; out.push(normalized); @@ -277,23 +376,31 @@ export function parseServicesFromCfg(cfg, configRel = "wrangler config") { return out; } +/** + * @param {WranglerConfig} cfg + * @param {string} [configRel] + * @returns {Array<{ binding: string, className: unknown }>} + */ export function parseDurableObjectsFromCfg(cfg, configRel = "wrangler config") { if (cfg.durable_objects == null) return []; - if (!cfg.durable_objects || typeof cfg.durable_objects !== "object" || Array.isArray(cfg.durable_objects)) { + const durableObjects = asRecord(cfg.durable_objects); + if (!durableObjects) { throw new Error(`${configRel}: [durable_objects] must be a table`); } - const bindingList = cfg.durable_objects.bindings; + const bindingList = durableObjects.bindings; if (bindingList == null) return []; if (!Array.isArray(bindingList)) { throw new Error(`${configRel}: [[durable_objects.bindings]] must be an array of tables`); } + /** @type {Set} */ const newClasses = new Set(); const migrations = cfg.migrations == null ? [] : cfg.migrations; if (!Array.isArray(migrations)) { throw new Error(`${configRel}: [[migrations]] must be an array of tables`); } - for (const migration of migrations) { - if (!migration || typeof migration !== "object" || Array.isArray(migration)) { + for (const rawMigration of migrations) { + const migration = asRecord(rawMigration); + if (!migration) { throw new Error(`${configRel}: [[migrations]] entry must be a table`); } for (const key of ["renamed_classes", "deleted_classes", "transferred_classes"]) { @@ -302,17 +409,18 @@ export function parseDurableObjectsFromCfg(cfg, configRel = "wrangler config") { } } for (const key of ["new_classes", "new_sqlite_classes"]) { - if (migration[key] == null) continue; - if (!Array.isArray(migration[key])) { + const classNames = migration[key]; + if (classNames == null) continue; + if (!Array.isArray(classNames)) { throw new Error(`${configRel}: [[migrations]].${key} must be an array of strings`); } - for (const className of migration[key]) { + for (const className of classNames) { if (!isValidJsClassDeclarationName(className)) { throw new Error( `${configRel}: [[migrations]].${key} entries must be valid JS class declaration names, got ${JSON.stringify(className)}` ); } - if (WDL_RESERVED_ENTRYPOINT_RE.test(className)) { + if (WDL_RESERVED_ENTRYPOINT_RE.test(String(className))) { throw new Error( `${configRel}: [[migrations]].${key} entry ${JSON.stringify(className)} is reserved for runtime-injected entrypoints` ); @@ -322,9 +430,11 @@ export function parseDurableObjectsFromCfg(cfg, configRel = "wrangler config") { } } + /** @type {Array<{ binding: string, className: unknown }>} */ const out = []; - for (const entry of bindingList) { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + for (const rawEntry of bindingList) { + const entry = asRecord(rawEntry); + if (!entry) { throw new Error(`${configRel}: [[durable_objects.bindings]] entry must be a table`); } if (entry.script_name != null) { @@ -340,7 +450,7 @@ export function parseDurableObjectsFromCfg(cfg, configRel = "wrangler config") { `${configRel}: [[durable_objects.bindings]] ${entry.name}: class_name must be a valid JS class declaration name, got ${JSON.stringify(entry.class_name)}` ); } - if (WDL_RESERVED_ENTRYPOINT_RE.test(entry.class_name)) { + if (WDL_RESERVED_ENTRYPOINT_RE.test(String(entry.class_name))) { throw new Error( `${configRel}: [[durable_objects.bindings]] ${entry.name}: class_name ${JSON.stringify(entry.class_name)} is reserved for runtime-injected entrypoints` ); @@ -355,16 +465,25 @@ export function parseDurableObjectsFromCfg(cfg, configRel = "wrangler config") { return out; } +/** + * @param {WranglerConfig} cfg + * @param {string} [configRel] + * @returns {Array<{ name: string, binding: string, className: unknown }>} + */ export function parseWorkflowsFromCfg(cfg, configRel = "wrangler config") { if (cfg.workflows == null) return []; if (!Array.isArray(cfg.workflows)) { throw new Error(`${configRel}: [[workflows]] must be an array of tables`); } + /** @type {Array<{ name: string, binding: string, className: unknown }>} */ const out = []; + /** @type {Set} */ const seenNames = new Set(); + /** @type {Set} */ const seenBindings = new Set(); - for (const entry of cfg.workflows) { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + for (const rawEntry of cfg.workflows) { + const entry = asRecord(rawEntry); + if (!entry) { throw new Error(`${configRel}: [[workflows]] entry must be a table`); } if (entry.script_name != null) { @@ -400,7 +519,7 @@ export function parseWorkflowsFromCfg(cfg, configRel = "wrangler config") { `${configRel}: [[workflows]] ${entry.name}: class_name must be a valid JS class declaration name, got ${JSON.stringify(entry.class_name)}` ); } - if (WDL_RESERVED_ENTRYPOINT_RE.test(entry.class_name)) { + if (WDL_RESERVED_ENTRYPOINT_RE.test(String(entry.class_name))) { throw new Error( `${configRel}: [[workflows]] ${entry.name}: class_name ${JSON.stringify(entry.class_name)} is reserved for runtime-injected entrypoints` ); @@ -414,14 +533,29 @@ export function parseWorkflowsFromCfg(cfg, configRel = "wrangler config") { return out; } +/** + * @typedef {object} ExportEntry + * @property {unknown} entrypoint Either "default" or a validated JS class name. + * @property {string[]} allowedCallers + * @property {string} [as] + * @property {string[]} [requiredCallerSecrets] + */ + +/** + * @param {WranglerConfig} cfg + * @param {string} [configRel] + * @returns {ExportEntry[]} + */ export function parseExportsFromCfg(cfg, configRel = "wrangler config") { if (cfg.exports == null) return []; if (!Array.isArray(cfg.exports)) { throw new Error(`${configRel}: [[exports]] must be an array of tables`); } + /** @type {ExportEntry[]} */ const out = []; - for (const entry of cfg.exports) { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + for (const rawEntry of cfg.exports) { + const entry = asRecord(rawEntry); + if (!entry) { throw new Error(`${configRel}: [[exports]] entry must be a table`); } if (entry.entrypoint !== "default" && !isValidJsClassDeclarationName(entry.entrypoint)) { @@ -429,24 +563,29 @@ export function parseExportsFromCfg(cfg, configRel = "wrangler config") { `${configRel}: [[exports]].entrypoint must be a valid JS class declaration name or "default", got ${JSON.stringify(entry.entrypoint)}` ); } - if (WDL_RESERVED_ENTRYPOINT_RE.test(entry.entrypoint)) { + if (WDL_RESERVED_ENTRYPOINT_RE.test(String(entry.entrypoint))) { throw new Error( `${configRel}: [[exports]] ${entry.entrypoint}: entrypoint is reserved for runtime-injected entrypoints` ); } - if (!Array.isArray(entry.allowed_callers)) { + const allowedCallers = entry.allowed_callers; + if (!Array.isArray(allowedCallers)) { throw new Error( `${configRel}: [[exports]] ${entry.entrypoint}: allowed_callers must be an array of strings` ); } - for (const c of entry.allowed_callers) { + for (const c of allowedCallers) { if (typeof c !== "string" || (c !== "*" && !NS_RE.test(c))) { throw new Error( `${configRel}: [[exports]] ${entry.entrypoint}: allowed_callers entries must be "*" or match ${NS_PATTERN}, got ${JSON.stringify(c)}` ); } } - const wire = { entrypoint: entry.entrypoint, allowedCallers: [...entry.allowed_callers] }; + /** @type {ExportEntry} */ + const wire = { + entrypoint: entry.entrypoint, + allowedCallers: /** @type {string[]} */ ([...allowedCallers]), + }; if (entry.as !== undefined) { if (typeof entry.as !== "string" || !PLATFORM_KEY_RE.test(entry.as)) { throw new Error( @@ -456,33 +595,41 @@ export function parseExportsFromCfg(cfg, configRel = "wrangler config") { wire.as = entry.as; } if (entry.required_caller_secrets !== undefined) { - if (!Array.isArray(entry.required_caller_secrets)) { + const requiredCallerSecrets = entry.required_caller_secrets; + if (!Array.isArray(requiredCallerSecrets)) { throw new Error( `${configRel}: [[exports]] ${entry.entrypoint}: required_caller_secrets must be an array` ); } - for (const k of entry.required_caller_secrets) { + for (const k of requiredCallerSecrets) { if (typeof k !== "string" || !PLATFORM_KEY_RE.test(k)) { throw new Error( `${configRel}: [[exports]] ${entry.entrypoint}: required_caller_secrets entries must match ${PLATFORM_KEY_RE}, got ${JSON.stringify(k)}` ); } } - wire.requiredCallerSecrets = [...entry.required_caller_secrets]; + wire.requiredCallerSecrets = /** @type {string[]} */ ([...requiredCallerSecrets]); } out.push(wire); } return out; } +/** + * @param {WranglerConfig} cfg + * @param {string} [configRel] + * @returns {Array<{ binding: string, platform: string }>} + */ export function parsePlatformBindingsFromCfg(cfg, configRel = "wrangler config") { if (cfg.platform_bindings == null) return []; if (!Array.isArray(cfg.platform_bindings)) { throw new Error(`${configRel}: [[platform_bindings]] must be an array of tables`); } + /** @type {Array<{ binding: string, platform: string }>} */ const out = []; - for (const entry of cfg.platform_bindings) { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + for (const rawEntry of cfg.platform_bindings) { + const entry = asRecord(rawEntry); + if (!entry) { throw new Error(`${configRel}: [[platform_bindings]] entry must be a table`); } if (typeof entry.binding !== "string" || !PLATFORM_KEY_RE.test(entry.binding)) { @@ -502,6 +649,12 @@ export function parsePlatformBindingsFromCfg(cfg, configRel = "wrangler config") return out; } +/** + * @param {unknown} value + * @param {string} configRel + * @param {string} field + * @returns {number} + */ function normalizeQueueDelayConfig(value, configRel, field) { if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > MAX_QUEUE_DELAY_SECONDS) { throw new Error(`${configRel}: ${field} must be an integer in [0, ${MAX_QUEUE_DELAY_SECONDS}]`); diff --git a/lib/wrangler/command.js b/lib/wrangler/command.js index ef99364..60abf00 100644 --- a/lib/wrangler/command.js +++ b/lib/wrangler/command.js @@ -8,6 +8,25 @@ import { WRANGLER_SCRUB_KEYS } from "../dotenv.js"; const CLI_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); export const MIN_WRANGLER_MAJOR = 4; +/** + * The subset of an `execFileSync` failure / spawn error the formatters read. + * @typedef {object} ExecFailure + * @property {number | null} [status] + * @property {NodeJS.Signals | null} [signal] + * @property {string} [message] + * @property {string} [code] + * @property {string | Buffer} [stdout] + * @property {string | Buffer} [stderr] + */ + +/** + * @param {unknown} err + * @returns {ExecFailure} + */ +function asExecFailure(err) { + return err && typeof err === "object" ? /** @type {ExecFailure} */ (err) : {}; +} + /** * @param {{ * absProject?: string, @@ -54,6 +73,14 @@ export function resolveWranglerCommand({ return { command: "wrangler", args: [], source: "path" }; } +/** + * @param {{ + * execFile?: typeof execFileSync, + * cwd: string, + * env: NodeJS.ProcessEnv, + * wrangler: { command: string, args: string[] }, + * }} options + */ export function checkWranglerVersion({ execFile = execFileSync, cwd, env, wrangler }) { let output; try { @@ -82,7 +109,12 @@ export function checkWranglerVersion({ execFile = execFileSync, cwd, env, wrangl } } +/** + * @param {NodeJS.ProcessEnv} env + * @returns {NodeJS.ProcessEnv} + */ export function wranglerChildEnv(env) { + /** @type {NodeJS.ProcessEnv} */ const childEnv = { ...env, CLOUDFLARE_API_TOKEN: "dry-run-dummy" }; for (const key of WRANGLER_SCRUB_KEYS) { delete childEnv[key]; @@ -90,9 +122,14 @@ export function wranglerChildEnv(env) { return childEnv; } -export function formatWranglerFailure(err) { - const reason = err?.status ?? err?.signal ?? err?.message ?? "unknown"; - const output = [err?.stdout, err?.stderr] +/** + * @param {unknown} rawErr + * @returns {string} + */ +export function formatWranglerFailure(rawErr) { + const err = asExecFailure(rawErr); + const reason = err.status ?? err.signal ?? err.message ?? "unknown"; + const output = [err.stdout, err.stderr] .map(toText) .filter(Boolean) .join("\n") @@ -101,6 +138,10 @@ export function formatWranglerFailure(err) { return `wrangler build failed (${reason})\n${truncateOutput(output)}`; } +/** + * @param {unknown} output + * @returns {number | null} + */ export function parseWranglerMajorVersion(output) { const text = toText(output); const match = text.match(/\b(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?\b/); @@ -108,6 +149,10 @@ export function parseWranglerMajorVersion(output) { return Number(match[1]); } +/** + * @param {Array} paths + * @returns {string[]} + */ function uniquePaths(paths) { const seen = new Set(); const out = []; @@ -124,6 +169,11 @@ function uniquePaths(paths) { // On win32 the node_modules/.bin entry is a .cmd shim, and Node >= 20.12 // refuses to execFile batch files without a shell (CVE-2024-27980 hardening). // Run the wrangler package's JS entry with the current Node instead. +/** + * @param {string} dir + * @param {NodeJS.Platform} platform + * @returns {{ command: string, args: string[] } | null} + */ function localWrangler(dir, platform) { if (platform === "win32") { const script = wranglerScript(dir); @@ -135,11 +185,20 @@ function localWrangler(dir, platform) { return script ? { command: process.execPath, args: [script] } : null; } +/** + * @param {string} dir + * @returns {string | null} + */ function wranglerScript(dir) { const script = path.join(dir, "node_modules", "wrangler", "bin", "wrangler.js"); return existsSync(script) ? script : null; } +/** + * @param {NodeJS.ProcessEnv} env + * @param {NodeJS.Platform} platform + * @returns {{ command: string, args: string[] } | null} + */ function pathWrangler(env, platform) { const pathValue = env.PATH || ""; for (const dir of pathValue.split(path.delimiter)) { @@ -160,9 +219,14 @@ function pathWrangler(env, platform) { return null; } -function formatWranglerVersionFailure(err) { - const reason = err?.status ?? err?.signal ?? err?.message ?? "unknown"; - const output = [err?.stdout, err?.stderr] +/** + * @param {unknown} rawErr + * @returns {string} + */ +function formatWranglerVersionFailure(rawErr) { + const err = asExecFailure(rawErr); + const reason = err.status ?? err.signal ?? err.message ?? "unknown"; + const output = [err.stdout, err.stderr] .map(toText) .filter(Boolean) .join("\n") @@ -170,7 +234,7 @@ function formatWranglerVersionFailure(err) { let message = output ? `wrangler version check failed (${reason})\n${truncateOutput(output)}` : `wrangler version check failed (${reason})`; - if (err?.code === "ENOENT") { + if (err.code === "ENOENT") { message += "\nNo runnable wrangler found. Install wrangler@^4 in the Worker project " + "(npm i -D wrangler), or set WDL_WRANGLER_BIN to a runnable wrangler entry."; @@ -178,11 +242,20 @@ function formatWranglerVersionFailure(err) { return message; } +/** + * @param {unknown} value + * @returns {string} + */ function toText(value) { if (!value) return ""; return Buffer.isBuffer(value) ? value.toString("utf8") : String(value); } +/** + * @param {string} text + * @param {number} [max] + * @returns {string} + */ function truncateOutput(text, max = 4000) { if (text.length <= max) return text; return `${text.slice(0, max)}\n... output truncated ...`; diff --git a/lib/wrangler/config.js b/lib/wrangler/config.js index e229492..3f8fd1b 100644 --- a/lib/wrangler/config.js +++ b/lib/wrangler/config.js @@ -2,6 +2,18 @@ import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { parse as parseToml } from "smol-toml"; +/** + * A parsed Wrangler config (`wrangler.toml`/`.jsonc`/`.json`). The CLI never + * trusts these fields' shapes: every binding parser re-validates the value it + * reads. Known sections (`name`, `main`, `kv_namespaces`, `d1_databases`, + * `r2_buckets`, `services`, `durable_objects`, `migrations`, `workflows`, + * `queues`, `exports`, `platform_bindings`, `vars`, `triggers`, `route`, + * `routes`, `assets`, `compatibility_date`, `compatibility_flags`, `env`, and + * the unsupported sections rejected by name) are read off this object and + * narrowed at the use site, so the honest value type is `unknown`. + * @typedef {Record} WranglerConfig + */ + const TOP_LEVEL_ONLY_ENV_KEYS = new Set([ "name", "keep_vars", @@ -61,6 +73,10 @@ const NON_INHERITABLE_ENV_KEYS = new Set([ "secrets_store_secrets", ]); +/** + * @param {string} dir + * @returns {{ path: string, cfg: unknown }} + */ export function loadWranglerConfig(dir) { const candidates = ["wrangler.toml", "wrangler.jsonc", "wrangler.json"]; for (const name of candidates) { @@ -72,23 +88,26 @@ export function loadWranglerConfig(dir) { if (name.endsWith(".jsonc")) return { path: p, cfg: parseJsonc(raw) }; return { path: p, cfg: JSON.parse(raw) }; } catch (err) { - throw new Error(`failed to parse ${name}: ${err.message}`, { cause: err }); + const message = err instanceof Error ? err.message : String(err); + throw new Error(`failed to parse ${name}: ${message}`, { cause: err }); } } throw new Error(`no wrangler.{toml,jsonc,json} found in ${dir}`); } +/** + * @param {unknown} rawCfg + * @param {string | null | undefined} envName + * @param {string} [configRel] + */ export function validateUnsupportedWranglerConfig(rawCfg, envName, configRel = "wrangler config") { + const cfg = asRecord(rawCfg); + const envTable = cfg ? asRecord(cfg.env) : null; const selectedEnvCfg = - envName && - rawCfg?.env && - typeof rawCfg.env === "object" && - !Array.isArray(rawCfg.env) - ? rawCfg.env[envName] - : null; + envName && envTable ? asRecord(envTable[envName]) : null; for (const key of UNSUPPORTED_WRANGLER_KEYS) { - if (hasConfiguredValue(rawCfg?.[key])) { + if (hasConfiguredValue(cfg?.[key])) { throw new Error( `${configRel} uses [${key}] — not supported. ${SUPPORTED_WRANGLER_SUMMARY}` ); @@ -105,7 +124,7 @@ export function validateUnsupportedWranglerConfig(rawCfg, envName, configRel = " const allowedCallersHint = 'Authorize cross-namespace service-binding callers on the target via ' + '[[exports]] (entrypoint = "default", allowed_callers = [...]).'; - if (hasConfiguredValue(rawCfg?.allowed_callers)) { + if (hasConfiguredValue(cfg?.allowed_callers)) { throw new Error(`${configRel} uses top-level allowed_callers — removed. ${allowedCallersHint}`); } if (hasConfiguredValue(selectedEnvCfg?.allowed_callers)) { @@ -113,12 +132,19 @@ export function validateUnsupportedWranglerConfig(rawCfg, envName, configRel = " } } +/** + * @param {unknown} rawCfg + * @param {string | null | undefined} envName + * @param {string} [configRel] + * @returns {{ cfg: WranglerConfig, envName: string | null }} + */ export function resolveWranglerConfig(rawCfg, envName, configRel = "wrangler config") { if (!rawCfg || typeof rawCfg !== "object" || Array.isArray(rawCfg)) { throw new Error(`${configRel}: config must be an object`); } + const cfg = /** @type {WranglerConfig} */ (rawCfg); - const availableEnvs = listNamedEnvironments(rawCfg); + const availableEnvs = listNamedEnvironments(cfg); if (!envName) { if (availableEnvs.length) { throw new Error( @@ -126,22 +152,23 @@ export function resolveWranglerConfig(rawCfg, envName, configRel = "wrangler con `pass --env or set CLOUDFLARE_ENV` ); } - return { cfg: rawCfg, envName: null }; + return { cfg, envName: null }; } if (!availableEnvs.length) { throw new Error(`${configRel}: environment "${envName}" requested but no [env] config exists`); } - if (!Object.hasOwn(rawCfg.env, envName)) { + const envTable = asRecord(cfg.env); + if (!envTable || !Object.hasOwn(envTable, envName)) { throw new Error( `${configRel}: environment "${envName}" not found ` + `(available: ${availableEnvs.join(", ")})` ); } - const envCfg = rawCfg.env[envName]; - if (!envCfg || typeof envCfg !== "object" || Array.isArray(envCfg)) { + const envCfg = asRecord(envTable[envName]); + if (!envCfg) { throw new Error(`${configRel}: env.${envName} must be an object/table`); } @@ -151,8 +178,9 @@ export function resolveWranglerConfig(rawCfg, envName, configRel = "wrangler con } } + /** @type {Record} */ const resolved = {}; - for (const [key, value] of Object.entries(rawCfg)) { + for (const [key, value] of Object.entries(cfg)) { if (key === "env" || key === "__proto__" || NON_INHERITABLE_ENV_KEYS.has(key)) continue; resolved[key] = value; } @@ -166,6 +194,10 @@ export function resolveWranglerConfig(rawCfg, envName, configRel = "wrangler con return { cfg: resolved, envName }; } +/** + * @param {string} src + * @returns {string} + */ export function stripJsonComments(src) { let out = ""; let i = 0; @@ -201,6 +233,10 @@ export function stripJsonComments(src) { return out; } +/** + * @param {string} src + * @returns {string} + */ export function stripTrailingCommas(src) { let out = ""; let i = 0; @@ -243,15 +279,39 @@ export function stripTrailingCommas(src) { return out; } +/** + * @param {string} src + * @returns {unknown} + */ export function parseJsonc(src) { return JSON.parse(stripTrailingCommas(stripJsonComments(src))); } +/** + * @param {WranglerConfig} cfg + * @returns {string[]} + */ function listNamedEnvironments(cfg) { - if (!cfg?.env || typeof cfg.env !== "object" || Array.isArray(cfg.env)) return []; - return Object.keys(cfg.env); + const env = asRecord(cfg.env); + if (!env) return []; + return Object.keys(env); +} + +/** + * A non-null, non-array object viewed as a string-keyed record, or null. + * @param {unknown} value + * @returns {Record | null} + */ +function asRecord(value) { + return value && typeof value === "object" && !Array.isArray(value) + ? /** @type {Record} */ (value) + : null; } +/** + * @param {unknown} value + * @returns {boolean} + */ function hasConfiguredValue(value) { return Array.isArray(value) ? value.length > 0 : Boolean(value); } diff --git a/lib/wrangler/modules.js b/lib/wrangler/modules.js index a7be22a..ea1006c 100644 --- a/lib/wrangler/modules.js +++ b/lib/wrangler/modules.js @@ -5,6 +5,10 @@ import { manifestMap } from "./utils.js"; // Skip only the known incidentals (.map, README.md). Dropping any other // artifact silently would crash the worker at runtime. +/** + * @param {string} dir + * @returns {Record} + */ export function collectModules(dir) { if (!existsSync(dir)) throw new Error(`wrangler produced no output at ${dir}`); const out = manifestMap(); diff --git a/lib/wrangler/utils.js b/lib/wrangler/utils.js index c550edb..4bfa8de 100644 --- a/lib/wrangler/utils.js +++ b/lib/wrangler/utils.js @@ -1,7 +1,17 @@ +/** + * A null-prototype map keyed by string. The null prototype keeps reserved keys + * like `__proto__` from colliding with `Object.prototype`. + * @returns {Record} + */ export function manifestMap() { return Object.create(null); } +/** + * @param {object} obj + * @param {PropertyKey} key + * @returns {boolean} + */ export function hasOwn(obj, key) { return Object.hasOwn(obj, key); } From 3f52838d36ec7daf3b93bcb9e8601f6f3abd51a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 01:56:24 +0000 Subject: [PATCH 03/20] Type test suite and enable global strict mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Annotate the unit + integration test suite (helpers.js and all cli-*.test.js, plus the hosted cli-live integration test) with real JSDoc types: typed mock deps/fakes, recorder arrays, response stubs (which fit the looser ControlResponseStatus/ControlJsonResponse shapes), and `catch (err)` narrowing. Flip tsconfig.json `strict` to true, so the whole tree — bin, commands, lib, tests — is now checked under `tsc --strict` and `npm run typecheck` (already run in CI) enforces it. Also widens WorkerSummary.activeVersion to `string | null` to match what the control plane actually sends for an undeployed worker. Whole repo: 0 strict errors, 0 `any`, 0 `@ts-` suppressions, eslint clean, 374/374 unit tests pass. Types only; no runtime behavior changed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- lib/workers-format.js | 2 +- tests/integration/cli-live.test.js | 355 +++++++++++++++++++++++---- tests/unit/cli-command.test.js | 55 ++++- tests/unit/cli-config-doctor.test.js | 50 +++- tests/unit/cli-control-fetch.test.js | 38 ++- tests/unit/cli-credentials.test.js | 40 ++- tests/unit/cli-d1.test.js | 79 +++++- tests/unit/cli-deploy.test.js | 144 +++++++---- tests/unit/cli-init.test.js | 13 +- tests/unit/cli-lifecycle.test.js | 321 +++++++++++++++++------- tests/unit/cli-output.test.js | 8 +- tests/unit/cli-stdin.test.js | 7 +- tests/unit/cli-token-store.test.js | 7 + tests/unit/cli-token.test.js | 26 +- tests/unit/helpers.js | 12 + tsconfig.json | 2 +- 16 files changed, 931 insertions(+), 228 deletions(-) diff --git a/lib/workers-format.js b/lib/workers-format.js index 7640ed6..7b58ac9 100644 --- a/lib/workers-format.js +++ b/lib/workers-format.js @@ -4,7 +4,7 @@ * @typedef {object} WorkerSummary * @property {string} [name] * @property {string[]} [versions] - * @property {string} [activeVersion] + * @property {string | null} [activeVersion] null when the worker has no deployed version. * @property {boolean} [hasSecrets] */ diff --git a/tests/integration/cli-live.test.js b/tests/integration/cli-live.test.js index f04d5e2..0c99bf9 100644 --- a/tests/integration/cli-live.test.js +++ b/tests/integration/cli-live.test.js @@ -11,6 +11,112 @@ import { fileURLToPath } from "node:url"; import { test } from "node:test"; import { controlFetch } from "../../lib/control-fetch.js"; +/** + * @typedef {object} LiveContext + * @property {string} controlUrl + * @property {string} controlConnectHost + * @property {string} adminToken + * @property {string} issuerToken + * @property {string} template + * @property {string} platformDomain + * @property {string} gatewayOrigin + */ + +/** + * A provisioned tenant token plus optional cleanup hooks. + * @typedef {object} TenantToken + * @property {string} ns + * @property {string} token + * @property {string} [tokenId] + * @property {(() => Promise) | undefined} [revoke] + */ + +/** + * Options accepted by the per-test `run`/`runJson` wrappers. + * @typedef {object} RunWrapperOptions + * @property {string} [cwd] + * @property {NodeJS.ProcessEnv | null} [env] + * @property {string} [input] + * @property {number} [timeoutMs] + */ + +/** + * The captured stdout/stderr of a finished `wdl` invocation. + * @typedef {object} RunResult + * @property {string} stdout + * @property {string} stderr + */ + +/** + * The buffered result of a single raw tenant HTTP request. + * @typedef {object} TenantResponse + * @property {number} status + * @property {import("node:http").IncomingHttpHeaders} headers + * @property {string} body + */ + +/** + * Request init for {@link controlJson}. + * @typedef {object} ControlInit + * @property {string} [method] + * @property {unknown} [body] + */ + +/** + * Request init for {@link tenantRequest}/{@link tenantJson}. + * @typedef {object} TenantInit + * @property {string} [method] + * @property {string | null} [body] + * @property {Record} [headers] + */ + +/** + * @typedef {object} TokenListEntry + * @property {string} [namespace] + */ + +/** + * @typedef {{ value: string }} ConfigField + * @typedef {{ namespace: ConfigField }} ConfigExplain + * @typedef {{ namespace: ConfigField & { matchesConfigured: boolean } }} WhoamiResult + * @typedef {{ checks: unknown[] }} DoctorResult + */ + +/** + * @typedef {object} D1CreateResult + * @property {string} databaseName + * @typedef {{ databases: Array<{ databaseName: string }> }} D1ListResult + */ + +/** + * @typedef {{ keys: string[] }} SecretListResult + */ + +/** + * @typedef {{ buckets: Array<{ name: string }> }} R2BucketsResult + * @typedef {{ objects: Array<{ key: string }> }} R2ObjectsResult + * @typedef {{ key: string }} R2HeadResult + */ + +/** + * @typedef {object} WorkerEntry + * @property {string} name + * @property {string} [activeVersion] + * @property {string[]} versions + * @typedef {{ workers: WorkerEntry[] }} WorkersListResult + */ + +/** + * @typedef {{ workflows: Array<{ worker: string, name: string }> }} WorkflowsListResult + * @typedef {{ status: string }} WorkflowStatusResult + */ + +/** + * @typedef {{ worker?: string }} TenantHealthBody + * @typedef {{ name?: string }} TenantD1Body + * @typedef {{ key?: string }} TenantR2Body + */ + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CLI_ROOT = path.resolve(__dirname, "../.."); const WDL_BIN = path.join(CLI_ROOT, "bin", "wdl.js"); @@ -27,25 +133,37 @@ test("live CLI integration covers command surface against a WDL control plane", }, async (t) => { const ctx = createLiveContext(); const tempRoot = mkdtempSync(path.join(os.tmpdir(), "wdl-cli-live-")); + /** @type {Array<() => Promise>} */ const cleanup = []; let appDir = ""; let wfDir = ""; let initDir = ""; + /** @type {NodeJS.ProcessEnv | null} */ let storeEnv = null; const cleaned = { appWorker: false, d1: false, }; + /** + * @param {string} label + * @param {() => unknown} fn + */ const cleanupStep = (label, fn) => { cleanup.push(async () => { try { await fn(); } catch (err) { - console.error(`cleanup warning (${label}): ${err?.message || String(err)}`); + console.error(`cleanup warning (${label}): ${errorMessage(err)}`); } }); }; + /** + * @template T + * @param {string} name + * @param {() => T | Promise} fn + * @returns {Promise} + */ const step = (name, fn) => runStep(t, name, fn); try { @@ -74,6 +192,11 @@ test("live CLI integration covers command surface against a WDL control plane", WDL_NS: ns, }); + /** + * @param {string[]} args + * @param {RunWrapperOptions} [options] + * @returns {RunResult} + */ const run = (args, options = {}) => runWdl(args, { cwd: options.cwd || CLI_ROOT, env: { @@ -83,6 +206,12 @@ test("live CLI integration covers command surface against a WDL control plane", input: options.input, timeoutMs: options.timeoutMs, }); + /** + * @template [T=unknown] + * @param {string[]} args + * @param {RunWrapperOptions} [options] + * @returns {T} + */ const runJson = (args, options = {}) => JSON.parse(run(args, options).stdout); await step("top-level help and command help", () => { @@ -116,18 +245,18 @@ test("live CLI integration covers command surface against a WDL control plane", const tokenList = runJson(["token", "list", "--json"], { env: noCliEnv, }); - assert.equal(tokenList[0]?.namespace, ns); + assert.equal(/** @type {TokenListEntry[]} */ (tokenList)[0]?.namespace, ns); run(["token", "use", ns], { env: noCliEnv }); storeEnv = noCliEnv; }); await step("config, whoami, and doctor commands", () => { - const config = runJson(["config", "explain", "--json"], { env: storeEnv }); + const config = /** @type {ConfigExplain} */ (runJson(["config", "explain", "--json"], { env: storeEnv })); assert.equal(config.namespace.value, ns); - const whoami = runJson(["whoami", "--json"], { env: storeEnv }); + const whoami = /** @type {WhoamiResult} */ (runJson(["whoami", "--json"], { env: storeEnv })); assert.equal(whoami.namespace.value, ns); assert.equal(whoami.namespace.matchesConfigured, true); - const doctor = runJson(["doctor", "--json"], { env: storeEnv, cwd: initDir }); + const doctor = /** @type {DoctorResult} */ (runJson(["doctor", "--json"], { env: storeEnv, cwd: initDir })); assert.ok(Array.isArray(doctor.checks)); }); @@ -145,8 +274,9 @@ test("live CLI integration covers command surface against a WDL control plane", cleanupStep("delete workflow worker", () => { try { run(["delete", "worker", wfWorker, "--yes", "--json"], { env: directTenantEnv }); - } catch (err) { - if (String(err?.message || err).includes("workflow_instances_active")) { + } catch (/** @type {unknown} */ err) { + const message = err instanceof Error ? err.message : undefined; + if (String(message || err).includes("workflow_instances_active")) { console.error(`cleanup note: ${ns}/${wfWorker} is retained until workflow instance retention expires`); return; } @@ -155,9 +285,9 @@ test("live CLI integration covers command surface against a WDL control plane", }); await step("d1 commands create, migrate, list, execute", () => { - const createdDb = runJson(["d1", "create", dbName, "--json"], { env: storeEnv }); + const createdDb = /** @type {D1CreateResult} */ (runJson(["d1", "create", dbName, "--json"], { env: storeEnv })); assert.equal(createdDb.databaseName, dbName); - assert.ok(runJson(["d1", "list", "--json"], { env: storeEnv }).databases.some((db) => + assert.ok(/** @type {D1ListResult} */ (runJson(["d1", "list", "--json"], { env: storeEnv })).databases.some((db) => db.databaseName === dbName )); runJson(["d1", "migrations", "status", dbName, "--dir", "migrations", "--json"], { @@ -177,7 +307,8 @@ test("live CLI integration covers command surface against a WDL control plane", await step("deploy command publishes app worker", async () => { const firstDeploy = run(["deploy", appDir], { env: storeEnv, timeoutMs: 5 * 60_000 }); assertDeployPrintedLiveVersion(firstDeploy.stdout); - await waitForTenantJson(ctx, ns, appWorker, "/health", (body) => body.worker === appWorker); + await waitForTenantJson(ctx, ns, appWorker, "/health", (body) => + /** @type {TenantHealthBody} */ (body).worker === appWorker); }); await step("secret and secrets commands", () => { @@ -186,7 +317,8 @@ test("live CLI integration covers command surface against a WDL control plane", env: storeEnv, }); assert.ok( - runJson(["secret", "list", "--scope", "ns", "--json"], { env: storeEnv }).keys.includes("LIVE_NS_SECRET") + /** @type {SecretListResult} */ (runJson(["secret", "list", "--scope", "ns", "--json"], { env: storeEnv })) + .keys.includes("LIVE_NS_SECRET") ); run(["secrets", "list", "--scope", "ns"], { env: storeEnv }); @@ -195,18 +327,22 @@ test("live CLI integration covers command surface against a WDL control plane", env: storeEnv, }); assert.ok( - runJson(["secret", "list", "--worker", appWorker, "--json"], { env: storeEnv }) + /** @type {SecretListResult} */ (runJson(["secret", "list", "--worker", appWorker, "--json"], { env: storeEnv })) .keys.includes("LIVE_WORKER_SECRET") ); }); await step("tenant runtime exercises D1, R2, and KV bindings", async () => { - const d1ViaWorker = await tenantJson(ctx, ns, appWorker, "/d1?name=alice", { method: "POST" }); + const d1ViaWorker = /** @type {TenantD1Body} */ ( + await tenantJson(ctx, ns, appWorker, "/d1?name=alice", { method: "POST" }) + ); assert.equal(d1ViaWorker.name, "alice"); - const r2Put = await tenantJson(ctx, ns, appWorker, `/r2?key=${encodeURIComponent(objectKey)}`, { - method: "POST", - }); + const r2Put = /** @type {TenantR2Body} */ ( + await tenantJson(ctx, ns, appWorker, `/r2?key=${encodeURIComponent(objectKey)}`, { + method: "POST", + }) + ); assert.equal(r2Put.key, objectKey); const kvPut = await tenantJson(ctx, ns, appWorker, "/kv?key=counter"); @@ -214,11 +350,12 @@ test("live CLI integration covers command surface against a WDL control plane", }); await step("r2 commands list, head, get, delete objects", () => { - assert.ok(runJson(["r2", "buckets", "list", "--json"], { env: storeEnv }).buckets.some((b) => b.name === bucket)); - assert.ok(runJson(["r2", "objects", "list", bucket, "--prefix", `objects/${ns}/`, "--json"], { + assert.ok(/** @type {R2BucketsResult} */ (runJson(["r2", "buckets", "list", "--json"], { env: storeEnv })) + .buckets.some((b) => b.name === bucket)); + assert.ok(/** @type {R2ObjectsResult} */ (runJson(["r2", "objects", "list", bucket, "--prefix", `objects/${ns}/`, "--json"], { env: storeEnv, - }).objects.some((obj) => obj.key === objectKey)); - assert.equal(runJson(["r2", "objects", "head", bucket, objectKey, "--json"], { env: storeEnv }).key, objectKey); + })).objects.some((obj) => obj.key === objectKey)); + assert.equal(/** @type {R2HeadResult} */ (runJson(["r2", "objects", "head", bucket, objectKey, "--json"], { env: storeEnv })).key, objectKey); const outFile = path.join(tempRoot, "r2-object.txt"); run(["r2", "objects", "get", bucket, objectKey, "--out", outFile], { env: storeEnv }); assert.equal(readFileSync(outFile, "utf8"), "live-r2-body"); @@ -226,14 +363,16 @@ test("live CLI integration covers command surface against a WDL control plane", }); await step("tail command receives live logs", async () => { - await assertTailReceivesLog({ ctx, ns, worker: appWorker, env: storeEnv }); + await assertTailReceivesLog({ + ctx, ns, worker: appWorker, env: /** @type {NodeJS.ProcessEnv} */ (storeEnv), + }); }); await step("workers and delete version commands", () => { writeAppRevision(appDir, appWorker, "v2"); const secondDeploy = run(["deploy", appDir], { env: storeEnv, timeoutMs: 5 * 60_000 }); assertDeployPrintedLiveVersion(secondDeploy.stdout); - const workers = runJson(["workers", "--json"], { env: storeEnv }); + const workers = /** @type {WorkersListResult} */ (runJson(["workers", "--json"], { env: storeEnv })); const app = workers.workers.find((worker) => worker.name === appWorker); assert.ok(app?.activeVersion, `workers list did not include an active version for ${appWorker}`); assert.ok(app.versions.includes(app.activeVersion)); @@ -250,10 +389,12 @@ test("live CLI integration covers command surface against a WDL control plane", await step("workflows commands", async () => { run(["deploy", wfDir], { env: storeEnv, timeoutMs: 5 * 60_000 }); - assert.ok(runJson(["workflows", "list", "--json"], { env: storeEnv }).workflows.some((wf) => - wf.worker === wfWorker && wf.name === "orders" - )); - await waitForTenantJson(ctx, ns, wfWorker, "/health", (body) => body.worker === "workflow"); + assert.ok(/** @type {WorkflowsListResult} */ (runJson(["workflows", "list", "--json"], { env: storeEnv })) + .workflows.some((wf) => + wf.worker === wfWorker && wf.name === "orders" + )); + await waitForTenantJson(ctx, ns, wfWorker, "/health", (body) => + /** @type {TenantHealthBody} */ (body).worker === "workflow"); await tenantJson(ctx, ns, wfWorker, "/workflow/start?id=live-wait&wait=1"); await waitForWorkflowStatus(runJson, storeEnv, wfWorker, "orders", "live-wait", ["waiting", "queued", "running"]); runJson(["workflows", "instances", wfWorker, "orders", "--limit", "5", "--json"], { env: storeEnv }); @@ -280,6 +421,7 @@ test("live CLI integration covers command surface against a WDL control plane", } }); +/** @returns {LiveContext} */ function createLiveContext() { const controlUrl = normalizeControlUrl(process.env.WDL_LIVE_CONTROL_URL || DEFAULT_LOCAL_CONTROL_URL); const controlHost = new URL(controlUrl).hostname; @@ -303,11 +445,17 @@ function createLiveContext() { }; } +/** @param {string} value */ function normalizeControlUrl(value) { const withScheme = /^[a-z][a-z\d+.-]*:\/\//i.test(value) ? value : `https://${value}`; return withScheme.replace(/\/+$/, ""); } +/** + * @param {LiveContext} ctx + * @param {NodeJS.ProcessEnv} [overlay] + * @returns {NodeJS.ProcessEnv} + */ function integrationEnv(ctx, overlay = {}) { /** @type {NodeJS.ProcessEnv} */ const env = { @@ -334,6 +482,10 @@ function integrationEnv(ctx, overlay = {}) { return env; } +/** + * @param {NodeJS.ProcessEnv} env + * @returns {NodeJS.ProcessEnv} + */ function withoutCliControlEnv(env) { const clean = { ...env }; delete clean.ADMIN_TOKEN; @@ -342,6 +494,11 @@ function withoutCliControlEnv(env) { return clean; } +/** + * @param {string[]} args + * @param {{ cwd: string, env: NodeJS.ProcessEnv, input?: string, timeoutMs?: number }} options + * @returns {RunResult} + */ function runWdl(args, { cwd, env, input = "", timeoutMs = 120_000 }) { const result = spawnSync(process.execPath, [WDL_BIN, ...args], { cwd, @@ -387,8 +544,10 @@ async function runStep(t, name, fn) { return /** @type {T} */ (result); } +/** @param {LiveContext} ctx */ async function assertControlReachable(ctx) { if (!ctx.adminToken) return; + /** @type {unknown} */ let body; try { body = await controlJson(ctx, "/whoami", ctx.adminToken); @@ -396,13 +555,17 @@ async function assertControlReachable(ctx) { throw new Error( `live integration preflight could not reach ${ctx.controlUrl}; ` + `start the local WDL dev stack or set WDL_LIVE_CONTROL_URL / token env vars. ` + - `Underlying error: ${err?.message || String(err)}`, + `Underlying error: ${errorMessage(err)}`, { cause: err } ); } - assert.equal(body.ok, true); + assert.equal(/** @type {{ ok?: unknown }} */ (body).ok, true); } +/** + * @param {LiveContext} ctx + * @returns {Promise} + */ async function provisionTenantToken(ctx) { if (process.env.WDL_LIVE_TENANT_TOKEN) { const ns = process.env.WDL_LIVE_NS || `cli-it-${randomBytes(3).toString("hex")}`; @@ -417,7 +580,7 @@ async function provisionTenantToken(ctx) { throw new Error("WDL live integration needs WDL_LIVE_ISSUER_TOKEN, WDL_LIVE_TENANT_TOKEN, or an admin token"); } const expiresAt = new Date(Date.now() + 60 * 60_000).toISOString(); - const issuer = await controlJson(ctx, "/auth/tokens", ctx.adminToken, { + const issuer = /** @type {{ token: string, tokenId?: string }} */ (await controlJson(ctx, "/auth/tokens", ctx.adminToken, { method: "POST", body: { kind: "token-issuer", @@ -425,7 +588,7 @@ async function provisionTenantToken(ctx) { label: "cli live integration issuer", expiresAt, }, - }); + })); let delegated; try { delegated = await issueDelegatedTenantToken(ctx, issuer.token); @@ -434,7 +597,7 @@ async function provisionTenantToken(ctx) { await revokeIssuedTokens(ctx, [issuer.tokenId]); } catch (revokeErr) { console.error( - `cleanup warning (temporary issuer token): ${revokeErr?.message || String(revokeErr)}` + `cleanup warning (temporary issuer token): ${errorMessage(revokeErr)}` ); } throw err; @@ -447,23 +610,35 @@ async function provisionTenantToken(ctx) { }; } +/** + * @param {LiveContext} ctx + * @param {string} issuerToken + * @returns {Promise<{ ns: string, token: string, tokenId?: string }>} + */ async function issueDelegatedTenantToken(ctx, issuerToken) { try { - return await controlJson(ctx, "/auth/delegated-tokens", issuerToken, { - method: "POST", - body: { template: ctx.template }, - }); + return /** @type {{ ns: string, token: string, tokenId?: string }} */ ( + await controlJson(ctx, "/auth/delegated-tokens", issuerToken, { + method: "POST", + body: { template: ctx.template }, + }) + ); } catch (err) { throw new Error( `live integration could not issue delegated token from ${ctx.controlUrl}; ` + `verify the control plane is reachable and the issuer token allows template ${ctx.template}. ` + - `Underlying error: ${err?.message || String(err)}`, + `Underlying error: ${errorMessage(err)}`, { cause: err } ); } } +/** + * @param {LiveContext} ctx + * @param {Array} tokenIds + */ async function revokeIssuedTokens(ctx, tokenIds) { + /** @type {unknown} */ let firstError = null; for (const tokenId of tokenIds) { if (tokenId) { @@ -477,8 +652,17 @@ async function revokeIssuedTokens(ctx, tokenIds) { if (firstError) throw firstError; } +/** + * @param {LiveContext} ctx + * @param {string} pathName + * @param {string} token + * @param {ControlInit} [init] + * @returns {Promise} + */ async function controlJson(ctx, pathName, token, init = {}) { + /** @type {Record} */ const headers = { "x-admin-token": token }; + /** @type {string | undefined} */ let body; if (init.body !== undefined) { headers["content-type"] = "application/json"; @@ -502,6 +686,11 @@ async function controlJson(ctx, pathName, token, init = {}) { return parsed; } +/** + * @param {string} root + * @param {{ worker: string, dbName: string, bucket: string, kvId: string }} fixture + * @returns {string} + */ function writeAppProject(root, { worker, dbName, bucket, kvId }) { const dir = path.join(root, "app"); mkdirSync(path.join(dir, "src"), { recursive: true }); @@ -541,10 +730,20 @@ create table if not exists cli_live_items ( return dir; } +/** + * @param {string} dir + * @param {string} worker + * @param {string} revision + */ function writeAppRevision(dir, worker, revision) { writeFileSync(path.join(dir, "src", "index.js"), appWorkerSource(worker, revision)); } +/** + * @param {string} worker + * @param {string} revision + * @returns {string} + */ function appWorkerSource(worker, revision) { return ` function json(value, init = {}) { @@ -600,6 +799,11 @@ export default { `; } +/** + * @param {string} root + * @param {{ worker: string }} fixture + * @returns {string} + */ function writeWorkflowProject(root, { worker }) { const dir = path.join(root, "workflow"); mkdirSync(path.join(dir, "src"), { recursive: true }); @@ -659,6 +863,14 @@ export default { `; } +/** + * @param {LiveContext} ctx + * @param {string} ns + * @param {string} worker + * @param {string} pathname + * @param {TenantInit} [init] + * @returns {Promise} + */ function tenantJson(ctx, ns, worker, pathname, init = {}) { return tenantRequest(ctx, ns, worker, pathname, init).then(({ status, body }) => { if (status < 200 || status >= 300) { @@ -668,15 +880,34 @@ function tenantJson(ctx, ns, worker, pathname, init = {}) { }); } +/** + * @param {LiveContext} ctx + * @param {string} ns + * @param {string} worker + * @param {string} pathname + * @param {(body: unknown) => boolean} predicate + * @returns {Promise} + */ async function waitForTenantJson(ctx, ns, worker, pathname, predicate) { + /** @type {unknown} */ let last; await waitUntil(`tenant ${worker}${pathname}`, async () => { - last = await tenantJson(ctx, ns, worker, pathname).catch((err) => ({ error: err.message })); + last = await tenantJson(ctx, ns, worker, pathname).catch( + /** @param {unknown} err */ (err) => ({ error: errorMessage(err) }) + ); return predicate(last); }); return last; } +/** + * @param {LiveContext} ctx + * @param {string} ns + * @param {string} worker + * @param {string} pathname + * @param {TenantInit} [init] + * @returns {Promise} + */ function tenantRequest(ctx, ns, worker, pathname, init = {}) { const platformHost = `${ns}.${ctx.platformDomain}`; const local = ctx.gatewayOrigin && new URL(ctx.gatewayOrigin).hostname === "localhost"; @@ -696,8 +927,9 @@ function tenantRequest(ctx, ns, worker, pathname, init = {}) { path: requestPath, headers, }, (res) => { + /** @type {Buffer[]} */ const chunks = []; - res.on("data", (chunk) => chunks.push(chunk)); + res.on("data", (/** @type {Buffer} */ chunk) => chunks.push(chunk)); res.on("end", () => resolve({ status: res.statusCode || 0, headers: res.headers, @@ -713,6 +945,9 @@ function tenantRequest(ctx, ns, worker, pathname, init = {}) { }); } +/** + * @param {{ ctx: LiveContext, ns: string, worker: string, env: NodeJS.ProcessEnv }} options + */ async function assertTailReceivesLog({ ctx, ns, worker, env }) { const tail = spawn(process.execPath, [WDL_BIN, "tail", worker, "--raw", "--max-reconnects", "1"], { cwd: CLI_ROOT, @@ -723,8 +958,8 @@ async function assertTailReceivesLog({ ctx, ns, worker, env }) { let stderr = ""; tail.stdout.setEncoding("utf8"); tail.stderr.setEncoding("utf8"); - tail.stdout.on("data", (chunk) => { stdout += chunk; }); - tail.stderr.on("data", (chunk) => { stderr += chunk; }); + tail.stdout.on("data", (/** @type {string} */ chunk) => { stdout += chunk; }); + tail.stderr.on("data", (/** @type {string} */ chunk) => { stderr += chunk; }); try { await waitUntil("tail connection", async () => stderr.includes("tail connected")); const id = randomBytes(3).toString("hex"); @@ -736,6 +971,7 @@ async function assertTailReceivesLog({ ctx, ns, worker, env }) { } } +/** @param {import("node:child_process").ChildProcess} child */ async function waitForExit(child) { if (child.exitCode !== null || child.signalCode !== null) return; await Promise.race([ @@ -747,17 +983,35 @@ async function waitForExit(child) { ]); } +/** + * @param {(args: string[], options?: RunWrapperOptions) => unknown} runJson + * @param {NodeJS.ProcessEnv | null} env + * @param {string} worker + * @param {string} workflow + * @param {string} instanceId + * @param {string[]} statuses + * @returns {Promise} + */ async function waitForWorkflowStatus(runJson, env, worker, workflow, instanceId, statuses) { + /** @type {WorkflowStatusResult | undefined} */ let body; await waitUntil(`workflow ${instanceId} status`, async () => { - body = runJson(["workflows", "status", worker, workflow, instanceId, "--include-steps", "--json"], { env }); + body = /** @type {WorkflowStatusResult} */ ( + runJson(["workflows", "status", worker, workflow, instanceId, "--include-steps", "--json"], { env }) + ); return statuses.includes(body.status); }); - return body; + return /** @type {WorkflowStatusResult} */ (body); } +/** + * @param {string} label + * @param {() => boolean | Promise} fn + * @param {{ timeoutMs?: number, intervalMs?: number }} [options] + */ async function waitUntil(label, fn, { timeoutMs = 60_000, intervalMs = 1_000 } = {}) { const started = Date.now(); + /** @type {unknown} */ let lastError; while (Date.now() - started < timeoutMs) { try { @@ -767,10 +1021,25 @@ async function waitUntil(label, fn, { timeoutMs = 60_000, intervalMs = 1_000 } = } await new Promise((resolve) => setTimeout(resolve, intervalMs)); } - throw new Error(`${label} timed out${lastError ? `: ${lastError.message}` : ""}`); + throw new Error(`${label} timed out${lastError ? `: ${errorMessage(lastError)}` : ""}`); } +/** @param {string} output */ function assertDeployPrintedLiveVersion(output) { const match = output.match(/@([^\s]+) live/); assert.ok(match, `deploy output did not include live version:\n${output}`); } + +/** + * Best-effort message extraction for an unknown thrown value. + * @param {unknown} err + * @returns {string} + */ +function errorMessage(err) { + if (err instanceof Error) return err.message; + if (err && typeof err === "object" && "message" in err) { + const { message } = /** @type {{ message?: unknown }} */ (err); + if (typeof message === "string") return message; + } + return String(err); +} diff --git a/tests/unit/cli-command.test.js b/tests/unit/cli-command.test.js index f7c0ff9..95b1907 100644 --- a/tests/unit/cli-command.test.js +++ b/tests/unit/cli-command.test.js @@ -4,12 +4,18 @@ import { defineCommand } from "../../lib/command.js"; import { CliError, defineCliOption } from "../../lib/common.js"; import { response } from "./helpers.js"; +/** @typedef {Parameters[0]} CommandSpec */ +/** @typedef {import("../../lib/command.js").CommandContext} CommandContext */ + // Most tests don't care about name/summary; default them so each case only // states the fields it exercises. +/** @param {Omit & { name?: string, summary?: string }} spec */ const define = (spec) => defineCommand({ name: "t", summary: "t", ...spec }); test("defineCommand assembles flag presets and custom options", async () => { - let seen = /** @type {any} */ (null); + let seen = /** @type {{ values: Record, positionals: string[] }} */ ( + /** @type {unknown} */ (null) + ); const cmd = define({ options: ["ns", "control", "json", "help", defineCliOption("tag", { type: "string" }, "--tag ", "Tag.")], usage: () => "usage", @@ -47,47 +53,62 @@ test("defineCommand rejects an unknown option preset", () => { test("defineCommand rejects raw parse option objects", () => { assert.throws( - () => define({ options: [{ tag: { type: "string" } }], usage: () => "", run: () => {} }), + () => define({ + // A raw parse-option object is not a valid OptionListItem; the command + // must reject it at runtime, so feed it through an unknown cast. + options: [/** @type {import("../../lib/common.js").OptionListItem} */ (/** @type {unknown} */ ({ tag: { type: "string" } }))], + usage: () => "", + run: () => {}, + }), /option entries must be preset names or option specs/, ); }); test("defineCommand validates required fields", () => { + /** @type {CommandSpec} */ const ok = { name: "n", summary: "s", usage: () => "", run: () => {} }; - assert.throws(() => defineCommand(/** @type {any} */ ({ ...ok, name: "" })), /name must be a non-empty string/); - assert.throws(() => defineCommand(/** @type {any} */ ({ ...ok, summary: "" })), /summary must be a non-empty string/); - assert.throws(() => defineCommand(/** @type {any} */ ({ ...ok, usage: undefined })), /usage must be a function/); - assert.throws(() => defineCommand(/** @type {any} */ ({ ...ok, run: undefined })), /run must be a function/); + // Each case feeds a deliberately invalid spec to exercise runtime validation; + // cast through unknown since the bad shapes do not satisfy CommandSpec. + /** @param {object} spec @returns {CommandSpec} */ + const badSpec = (spec) => /** @type {CommandSpec} */ (/** @type {unknown} */ (spec)); + assert.throws(() => defineCommand(badSpec({ ...ok, name: "" })), /name must be a non-empty string/); + assert.throws(() => defineCommand(badSpec({ ...ok, summary: "" })), /summary must be a non-empty string/); + assert.throws(() => defineCommand(badSpec({ ...ok, usage: undefined })), /usage must be a function/); + assert.throws(() => defineCommand(badSpec({ ...ok, run: undefined })), /run must be a function/); }); test("--help prints usage and skips the run body", async () => { let ran = false; + /** @type {string[]} */ const lines = []; const cmd = define({ options: ["help"], usage: () => "USAGE TEXT", run: () => { ran = true; }, }); - await cmd.run(["--help"], { stdout: (line) => lines.push(line) }); + await cmd.run(["--help"], { stdout: (/** @type {string} */ line) => lines.push(line) }); assert.equal(ran, false); assert.deepEqual(lines, ["USAGE TEXT"]); }); test("positional help prints usage and skips the run body", async () => { let ran = false; + /** @type {string[]} */ const lines = []; const cmd = define({ options: ["help"], usage: () => "USAGE TEXT", run: () => { ran = true; }, }); - await cmd.run(["help"], { stdout: (line) => lines.push(line) }); + await cmd.run(["help"], { stdout: (/** @type {string} */ line) => lines.push(line) }); assert.equal(ran, false); assert.deepEqual(lines, ["USAGE TEXT"]); }); test("context applies dep defaults, injected overrides, and passthrough deps", async () => { - let ctx = /** @type {any} */ (null); + let ctx = /** @type {CommandContext & Record} */ ( + /** @type {unknown} */ (null) + ); const cmd = define({ options: ["help"], defaults: { custom: "default-custom" }, @@ -103,6 +124,7 @@ test("context applies dep defaults, injected overrides, and passthrough deps", a }); test("context.nsUrl builds an encoded namespace URL", async () => { + /** @type {string | undefined} */ let url; const cmd = define({ options: ["ns", "control"], @@ -129,7 +151,9 @@ test("context.nsUrl throws when the namespace is unresolved", async () => { }); test("context.fetchJson fetches with the given init and parses JSON", async () => { - let got = /** @type {any} */ (null); + let got = /** @type {{ url: string, init: import("../../lib/control-fetch.js").ControlFetchInit }} */ ( + /** @type {unknown} */ (null) + ); const cmd = define({ options: [], usage: () => "", @@ -137,6 +161,7 @@ test("context.fetchJson fetches with the given init and parses JSON", async () = }); const body = await cmd.run([], { env: {}, + /** @param {string} url @param {import("../../lib/control-fetch.js").ControlFetchInit} init */ controlFetch: async (url, init) => { got = { url, init }; return response({ ok: 1 }); }, }); assert.deepEqual(body, { ok: 1 }); @@ -202,9 +227,13 @@ test("context.fetchStream returns the raw response after a status check", async }); test("context.resolveControl memoizes; resolveNamespace reads values then env", async () => { - let c1 = /** @type {any} */ (null); - let c2 = /** @type {any} */ (null); - let nsFromFlag, nsFromEnv; + /** @typedef {ReturnType} ResolvedControl */ + let c1 = /** @type {ResolvedControl} */ (/** @type {unknown} */ (null)); + let c2 = /** @type {ResolvedControl} */ (/** @type {unknown} */ (null)); + /** @type {string | undefined} */ + let nsFromFlag; + /** @type {string | undefined} */ + let nsFromEnv; const cmd = define({ options: ["ns", "control"], usage: () => "", diff --git a/tests/unit/cli-config-doctor.test.js b/tests/unit/cli-config-doctor.test.js index da39fb7..041a154 100644 --- a/tests/unit/cli-config-doctor.test.js +++ b/tests/unit/cli-config-doctor.test.js @@ -12,6 +12,13 @@ import { tokenStorePath, writeTokenStore } from "../../lib/token-store.js"; import { cliCompatibility, compareSemver } from "../../lib/whoami.js"; import { response } from "./helpers.js"; +/** @typedef {{ url: string, init: import("../../lib/control-fetch.js").ControlFetchInit }} ControlCall */ + +/** + * @template T + * @param {(dir: string) => T} fn + * @returns {T} + */ function withTempDir(fn) { const dir = mkdtempSync(path.join(tmpdir(), "wdl-config-test-")); try { @@ -126,12 +133,14 @@ test("the dispatcher honors --no-token-store when autoloading credentials", asyn // Assert what the dispatcher resolved from the store into the env it // autoloads. loadEnv isolates .env; `secret` with no subcommand fails its // arg check before any control fetch, so the command never hits the network. + /** @type {NodeJS.ProcessEnv} */ const baseEnv = { XDG_CONFIG_HOME: xdg, WDL_NS: "" }; await wdlMain(["secret"], { env: baseEnv, loadEnv: () => [] }).catch(() => {}); assert.equal(baseEnv.WDL_NS, "demo", "store default namespace applied"); assert.equal(baseEnv.CONTROL_URL, "http://ctl.test", "store control URL gap-filled"); assert.equal(baseEnv.ADMIN_TOKEN, "store-token", "store token gap-filled"); + /** @type {NodeJS.ProcessEnv} */ const offEnv = { XDG_CONFIG_HOME: xdg, WDL_NS: "" }; await wdlMain(["secret", "--no-token-store"], { env: offEnv, loadEnv: () => [] }).catch(() => {}); assert.equal(offEnv.WDL_NS, "", "--no-token-store: no store default namespace"); @@ -153,10 +162,12 @@ test("config explain prints final values and sources", async () => { "ADMIN_TOKEN=section-token", ].join("\n")); + /** @type {string[]} */ const lines = []; await runConfigCommand(["explain", "--ns", "acme"], { cwd, env: {}, + /** @param {string} line */ stdout: (line) => lines.push(line), }); @@ -168,13 +179,14 @@ test("config explain prints final values and sources", async () => { }); test("bin does not preload .env for local diagnostic commands", async () => { + /** @type {string[]} */ const calls = []; const oldLog = console.log; console.log = () => {}; try { - await wdlMain(["config", "--help"], { loadEnv: () => calls.push("config") }); - await wdlMain(["doctor", "--help"], { loadEnv: () => calls.push("doctor") }); - await wdlMain(["whoami", "--help"], { loadEnv: () => calls.push("whoami") }); + await wdlMain(["config", "--help"], { loadEnv: () => { calls.push("config"); return []; } }); + await wdlMain(["doctor", "--help"], { loadEnv: () => { calls.push("doctor"); return []; } }); + await wdlMain(["whoami", "--help"], { loadEnv: () => { calls.push("whoami"); return []; } }); } finally { console.log = oldLog; } @@ -183,12 +195,16 @@ test("bin does not preload .env for local diagnostic commands", async () => { test("whoami calls control introspection and prints platform compatibility", async () => { await withTempDir(async (cwd) => { + /** @type {ControlCall[]} */ const calls = []; + /** @type {string[]} */ const lines = []; await runWhoamiCommand(["--ns", "acme", "--control-url", "http://ctl.test", "--token", "secret-token"], { cwd, env: {}, + /** @param {string} line */ stdout: (line) => lines.push(line), + /** @param {string} url @param {import("../../lib/control-fetch.js").ControlFetchInit} [init] */ controlFetch: async (url, init = {}) => { calls.push({ url, init }); return response({ @@ -225,10 +241,12 @@ test("whoami calls control introspection and prints platform compatibility", asy test("whoami text reports configured namespace mismatch", async () => { await withTempDir(async (cwd) => { + /** @type {string[]} */ const lines = []; await runWhoamiCommand(["--ns", "configured", "--token", "secret-token"], { cwd, env: { CONTROL_URL: "https://api.wdl.dev" }, + /** @param {string} line */ stdout: (line) => lines.push(line), controlFetch: async () => response({ ok: true, @@ -248,10 +266,18 @@ test("doctor reports local checks plus remote whoami", async () => { mkdirSync(path.join(cwd, "node_modules", ".bin"), { recursive: true }); writeFileSync(path.join(cwd, "wrangler.jsonc"), "{}"); + /** @type {string[]} */ const lines = []; - let childEnv = /** @type {any} */ (null); + /** @type {NodeJS.ProcessEnv | undefined} */ + let childEnv; + /** @type {ControlCall[]} */ const calls = []; const mockWranglerVersion = "9.8.7"; + /** + * @param {string} _cmd + * @param {readonly string[]} _args + * @param {import("node:child_process").ExecFileSyncOptions} options + */ const execFile = (_cmd, _args, options) => { childEnv = options.env; return `${mockWranglerVersion}\n`; @@ -260,7 +286,9 @@ test("doctor reports local checks plus remote whoami", async () => { cwd, env: { CONTROL_URL: "https://api.wdl.dev" }, execFile, + /** @param {string} line */ stdout: (line) => lines.push(line), + /** @param {string} url @param {import("../../lib/control-fetch.js").ControlFetchInit} [init] */ controlFetch: async (url, init = {}) => { calls.push({ url, init }); return response({ @@ -308,11 +336,13 @@ test("doctor reports the token store namespace count and the build-readable cave other: { CONTROL_URL: "http://ctl.test", ADMIN_TOKEN: "t2" }, }, }); + /** @type {string[]} */ const lines = []; await runDoctorCommand(["--ns", "demo", "--control-url", "http://ctl.test", "--token", "secret-token"], { cwd, env: { XDG_CONFIG_HOME: xdg }, execFile: () => "4.94.0\n", + /** @param {string} line */ stdout: (line) => lines.push(line), controlFetch: async () => response({ ok: true, principal: { kind: "ns", ns: "demo" }, urls: { control: "http://ctl.test" } }), @@ -330,6 +360,7 @@ test("doctor honors --no-token-store: reports the store disabled without reading defaultNs: "demo", namespaces: { demo: { CONTROL_URL: "http://ctl.test", ADMIN_TOKEN: "t1" } }, }); + /** @type {string[]} */ const lines = []; await runDoctorCommand( ["--ns", "demo", "--control-url", "http://ctl.test", "--token", "secret-token", "--no-token-store"], @@ -337,6 +368,7 @@ test("doctor honors --no-token-store: reports the store disabled without reading cwd, env: { XDG_CONFIG_HOME: xdg }, execFile: () => "4.94.0\n", + /** @param {string} line */ stdout: (line) => lines.push(line), controlFetch: async () => response({ ok: true, principal: { kind: "ns", ns: "demo" }, urls: { control: "http://ctl.test" } }), @@ -350,11 +382,13 @@ test("doctor honors --no-token-store: reports the store disabled without reading test("doctor does not duplicate missing-token errors for skipped whoami", async () => { await withTempDir(async (cwd) => { + /** @type {string[]} */ const lines = []; await runDoctorCommand(["--ns", "acme"], { cwd, env: {}, execFile: () => "4.94.0\n", + /** @param {string} line */ stdout: (line) => lines.push(line), controlFetch: async () => { throw new Error("controlFetch should not be called"); @@ -369,11 +403,13 @@ test("doctor does not duplicate missing-token errors for skipped whoami", async test("doctor reports namespace mismatch from whoami", async () => { await withTempDir(async (cwd) => { + /** @type {string[]} */ const lines = []; await runDoctorCommand(["--ns", "configured", "--token", "secret-token"], { cwd, env: { CONTROL_URL: "https://api.wdl.dev" }, execFile: () => "4.94.0\n", + /** @param {string} line */ stdout: (line) => lines.push(line), controlFetch: async () => response({ ok: true, @@ -390,11 +426,13 @@ test("doctor reports namespace mismatch from whoami", async () => { test("doctor flags a Wrangler major below the deploy minimum", async () => { await withTempDir(async (cwd) => { + /** @type {string[]} */ const lines = []; await runDoctorCommand(["--ns", "acme", "--token", "secret-token"], { cwd, env: { CONTROL_URL: "https://api.wdl.dev" }, execFile: () => "3.99.0\n", + /** @param {string} line */ stdout: (line) => lines.push(line), controlFetch: async () => response({ ok: true, @@ -435,11 +473,13 @@ test("whoami and doctor warn when the token would travel over plain http to a no // Run in an empty temp cwd so no real repo-root .env feeds the cross-origin // guard (which would add a second, unrelated warning). await withTempDir(async (cwd) => { + /** @type {string[]} */ const whoamiWarnings = []; await runWhoamiCommand(["--ns", "acme", "--control-url", "http://ctl.prod.example", "--token", "secret-token"], { cwd, env: {}, stdout: () => {}, + /** @param {string} line */ warn: (line) => whoamiWarnings.push(line), controlFetch: async () => response(whoamiBody), }); @@ -448,12 +488,14 @@ test("whoami and doctor warn when the token would travel over plain http to a no }); await withTempDir(async (cwd) => { + /** @type {string[]} */ const doctorWarnings = []; await runDoctorCommand(["--ns", "acme", "--control-url", "http://ctl.prod.example", "--token", "secret-token"], { cwd, env: {}, execFile: () => "4.94.0\n", stdout: () => {}, + /** @param {string} line */ warn: (line) => doctorWarnings.push(line), controlFetch: async () => response(whoamiBody), }); diff --git a/tests/unit/cli-control-fetch.test.js b/tests/unit/cli-control-fetch.test.js index 803dc28..d42e1c3 100644 --- a/tests/unit/cli-control-fetch.test.js +++ b/tests/unit/cli-control-fetch.test.js @@ -4,6 +4,9 @@ import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import { controlFetch, readControlResponse } from "../../lib/control-fetch.js"; +/** + * @param {{ statusCode?: number, headers?: import("node:http").IncomingHttpHeaders }} [init] + */ function fakeResponse({ statusCode = 200, headers = {} } = {}) { return Object.assign(new EventEmitter(), { statusCode, headers }); } @@ -76,15 +79,17 @@ test("controlFetch keeps timeout active while streaming the response body", asyn statusCode: 200, headers: { "content-type": "application/octet-stream" }, }); - let requestDestroyError = /** @type {Error | null} */ (null); + let requestDestroyError = /** @type {Error | null | undefined} */ (null); + /** @type {import("../../lib/control-fetch.js").ControlTransport} */ const transport = { request(_opts, onResponse) { return Object.assign(new EventEmitter(), { write() {}, end() { - onResponse(res); + onResponse(/** @type {import("node:http").IncomingMessage} */ (/** @type {unknown} */ (res))); res.write("partial"); }, + /** @param {Error} [err] */ destroy(err) { requestDestroyError = err; }, @@ -98,9 +103,10 @@ test("controlFetch keeps timeout active while streaming the response body", asyn transport, }); + const body = /** @type {import("node:stream").Readable} */ (response.body); await assert.rejects( async () => { - for await (const _chunk of response.body) { + for await (const _chunk of body) { // Wait for the transport timeout to destroy the stalled stream. } }, @@ -115,12 +121,13 @@ test("controlFetch streaming timeout is idle-based after headers", async () => { statusCode: 200, headers: { "content-type": "application/octet-stream" }, }); + /** @type {import("../../lib/control-fetch.js").ControlTransport} */ const transport = { request(_opts, onResponse) { return Object.assign(new EventEmitter(), { write() {}, end() { - onResponse(res); + onResponse(/** @type {import("node:http").IncomingMessage} */ (/** @type {unknown} */ (res))); setTimeout(() => res.write("a"), 10); setTimeout(() => res.write("b"), 25); setTimeout(() => res.end("c"), 40); @@ -136,8 +143,10 @@ test("controlFetch streaming timeout is idle-based after headers", async () => { transport, }); + /** @type {string[]} */ const chunks = []; - for await (const chunk of response.body) { + const body = /** @type {import("node:stream").Readable} */ (response.body); + for await (const chunk of body) { chunks.push(Buffer.from(chunk).toString("utf8")); } assert.equal(chunks.join(""), "abc"); @@ -148,12 +157,13 @@ test("controlFetch buffers early streaming chunks until caller consumes body", a statusCode: 200, headers: { "content-type": "application/octet-stream" }, }); + /** @type {import("../../lib/control-fetch.js").ControlTransport} */ const transport = { request(_opts, onResponse) { return Object.assign(new EventEmitter(), { write() {}, end() { - onResponse(res); + onResponse(/** @type {import("node:http").IncomingMessage} */ (/** @type {unknown} */ (res))); res.write("early"); setTimeout(() => res.end("late"), 5); }, @@ -169,8 +179,10 @@ test("controlFetch buffers early streaming chunks until caller consumes body", a }); await new Promise((resolve) => setTimeout(resolve, 10)); + /** @type {string[]} */ const chunks = []; - for await (const chunk of response.body) { + const body = /** @type {import("node:stream").Readable} */ (response.body); + for await (const chunk of body) { chunks.push(Buffer.from(chunk).toString("utf8")); } assert.equal(chunks.join(""), "earlylate"); @@ -181,12 +193,13 @@ test("controlFetch forwards streaming source errors to the consumer", async () = statusCode: 200, headers: { "content-type": "application/octet-stream" }, }); + /** @type {import("../../lib/control-fetch.js").ControlTransport} */ const transport = { request(_opts, onResponse) { return Object.assign(new EventEmitter(), { write() {}, end() { - onResponse(res); + onResponse(/** @type {import("node:http").IncomingMessage} */ (/** @type {unknown} */ (res))); res.write("partial"); setTimeout(() => res.emit("error", new Error("socket lost")), 5); }, @@ -201,9 +214,10 @@ test("controlFetch forwards streaming source errors to the consumer", async () = transport, }); + const body = /** @type {import("node:stream").Readable} */ (response.body); await assert.rejects( async () => { - for await (const _chunk of response.body) { + for await (const _chunk of body) { // Consume until the upstream response error is forwarded. } }, @@ -212,15 +226,17 @@ test("controlFetch forwards streaming source errors to the consumer", async () = }); test("controlFetch carries the URL port in Host and strips IPv6 brackets for the socket", async () => { + /** @type {Array} */ const seen = []; + /** @type {import("../../lib/control-fetch.js").ControlTransport} */ const transport = { request(opts, onResponse) { - seen.push(opts); + seen.push(/** @type {import("node:https").RequestOptions & { headers: import("node:http").OutgoingHttpHeaders }} */ (opts)); return Object.assign(new EventEmitter(), { write() {}, end() { const res = fakeResponse(); - onResponse(res); + onResponse(/** @type {import("node:http").IncomingMessage} */ (/** @type {unknown} */ (res))); res.emit("data", Buffer.from("{}")); res.emit("end"); }, diff --git a/tests/unit/cli-credentials.test.js b/tests/unit/cli-credentials.test.js index c8a1add..cd0f8d4 100644 --- a/tests/unit/cli-credentials.test.js +++ b/tests/unit/cli-credentials.test.js @@ -106,6 +106,7 @@ test("loadCliDotEnv loads an explicit .env without overriding explicit env", () ].join("\n") ); + /** @type {NodeJS.ProcessEnv} */ const env = { ADMIN_TOKEN: "from-shell" }; assert.deepEqual(loadCliDotEnv(env, file), [ "CONTROL_URL", @@ -403,6 +404,7 @@ test("loadCliDotEnv ignores WDL_NS in selected section with a warning", () => { ].join("\n") ); + /** @type {string[]} */ const warnings = []; const env = emptyEnv(); const protectedKeys = new Set(); @@ -438,6 +440,7 @@ test("loadCliDotEnv does not warn for WDL_NS in an unselected section", () => { ].join("\n") ); + /** @type {string[]} */ const warnings = []; const env = emptyEnv(); const protectedKeys = new Set(); @@ -469,6 +472,7 @@ test("loadCliControlEnv drops a .env control endpoint when the token is from the writeFileSync(path.join(dir, ".env"), "CONTROL_URL=https://ctl.attacker.example\n"); /** @type {NodeJS.ProcessEnv} */ const env = { ADMIN_TOKEN: "shell-token" }; + /** @type {string[]} */ const warned = []; loadCliControlEnv(env, { dotenvPath: path.join(dir, ".env"), @@ -491,6 +495,7 @@ test("loadCliControlEnv treats a .env endpoint as cross-origin when --token is u writeFileSync(path.join(dir, ".env"), "ADMIN_TOKEN=decoy\nCONTROL_URL=https://ctl.attacker.example\n"); /** @type {NodeJS.ProcessEnv} */ const env = {}; + /** @type {string[]} */ const warned = []; loadCliControlEnv(env, { dotenvPath: path.join(dir, ".env"), @@ -511,6 +516,7 @@ test("loadCliControlEnv trusts a .env control endpoint when the token is also fr writeFileSync(path.join(dir, ".env"), "ADMIN_TOKEN=env-token\nCONTROL_URL=https://ctl.mine.example\n"); /** @type {NodeJS.ProcessEnv} */ const env = {}; + /** @type {string[]} */ const warned = []; loadCliControlEnv(env, { dotenvPath: path.join(dir, ".env"), @@ -531,6 +537,7 @@ test("loadCliControlEnv keeps the documented multi-ns layout (URL in base, token "CONTROL_URL=https://ctl.shared.example\nWDL_NS=acme\n\n[acme]\nADMIN_TOKEN=acme-token\n"); /** @type {NodeJS.ProcessEnv} */ const env = {}; + /** @type {string[]} */ const warned = []; loadCliControlEnv(env, { dotenvPath: path.join(dir, ".env"), @@ -545,11 +552,12 @@ test("loadCliControlEnv keeps the documented multi-ns layout (URL in base, token }); test("protectedEnvKeys protects only non-empty string values", () => { - const keys = protectedEnvKeys(/** @type {any} */ ({ A: "x", EMPTY: "", MISSING: undefined, B: "y" })); + const keys = protectedEnvKeys(/** @type {NodeJS.ProcessEnv} */ ({ A: "x", EMPTY: "", MISSING: undefined, B: "y" })); assert.deepEqual([...keys].sort(), ["A", "B"]); }); test("loadCliControlEnv fills control URL and token from the store as a gap-filler", () => { + /** @type {NodeJS.ProcessEnv} */ const env = { WDL_NS: "acme" }; loadCliControlEnv(env, { nsFromFlag: "acme", @@ -575,6 +583,7 @@ test("loadCliControlEnv selects the store's default namespace when nothing else }); test("loadCliControlEnv lets an explicit namespace override the store default", () => { + /** @type {NodeJS.ProcessEnv} */ const env = { WDL_NS: "demo" }; loadCliControlEnv(env, { loadEnv: () => [], @@ -592,10 +601,17 @@ test("loadCliControlEnv lets an explicit namespace override the store default", }); test("loadCliControlEnv lets a project .env namespace beat the store default over an empty shell WDL_NS", () => { + /** @type {NodeJS.ProcessEnv} */ 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 }) => { + /** + * @param {NodeJS.ProcessEnv} [e] + * @param {string} [_path] + * @param {{ protectedKeys?: Set }} [options] + * @returns {string[]} + */ + loadEnv: (e = process.env, _path, { protectedKeys = new Set() } = {}) => { if (protectedKeys.has("WDL_NS")) return []; e.WDL_NS = "acme"; return ["WDL_NS"]; @@ -624,6 +640,7 @@ test("loadCliControlEnv ignores a store default with no stored entry", () => { }); test("loadCliControlEnv lets shell env win over the store (gap-fill only)", () => { + /** @type {NodeJS.ProcessEnv} */ const env = { WDL_NS: "acme", ADMIN_TOKEN: "shell-tok" }; loadCliControlEnv(env, { nsFromFlag: "acme", @@ -636,6 +653,7 @@ test("loadCliControlEnv lets shell env win over the store (gap-fill only)", () = }); test("loadCliControlEnv does not fill a flag-covered slot from the store", () => { + /** @type {NodeJS.ProcessEnv} */ 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. @@ -702,10 +720,17 @@ 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} */ ({}); + /** @type {string[]} */ const warnings = []; loadCliControlEnv(env, { nsFromFlag: "acme", - loadEnv: (e, _path, opts) => { + /** + * @param {NodeJS.ProcessEnv} [e] + * @param {string} [_path] + * @param {{ resolvedNs?: string }} [opts] + * @returns {string[]} + */ + loadEnv: (e = process.env, _path, opts = {}) => { if (!opts.resolvedNs) { e.CONTROL_URL = "https://evil.example"; e.ADMIN_TOKEN = ""; @@ -726,10 +751,17 @@ test("a project .env endpoint is still dropped when the token comes from the sto // 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} */ ({}); + /** @type {string[]} */ const warnings = []; loadCliControlEnv(env, { nsFromFlag: "acme", - loadEnv: (e, _path, opts) => { + /** + * @param {NodeJS.ProcessEnv} [e] + * @param {string} [_path] + * @param {{ resolvedNs?: string }} [opts] + * @returns {string[]} + */ + loadEnv: (e = process.env, _path, opts = {}) => { if (!opts.resolvedNs) { e.CONTROL_URL = "https://evil.example"; return ["CONTROL_URL"]; diff --git a/tests/unit/cli-d1.test.js b/tests/unit/cli-d1.test.js index 64f9055..c5fd3e5 100644 --- a/tests/unit/cli-d1.test.js +++ b/tests/unit/cli-d1.test.js @@ -7,8 +7,47 @@ import { runD1Command } from "../../commands/d1.js"; import { LONG_CONTROL_TIMEOUT_MS } from "../../lib/control-fetch.js"; import { mockDeps as sharedMockDeps, response } from "./helpers.js"; +/** @typedef {import("../../lib/control-fetch.js").ControlFetchInit} ControlFetchInit */ +/** @typedef {{ url: string, init: ControlFetchInit }} RecordedCall */ + +// The shared mockDeps types recorded `init` as the broad `object`; the d1 tests +// read concrete request fields (method/body/headers/timeoutMs), so view a +// recorded call through the control-fetch init shape it actually carries. +/** + * @param {{ url: string, init: object }} call + * @returns {RecordedCall} + */ +const asCall = (call) => /** @type {RecordedCall} */ (call); + +// Request bodies in these tests are always JSON strings; narrow the broader +// `body` union before parsing. +/** + * @param {ControlFetchInit["body"]} body + * @returns {unknown} + */ +const parseBody = (body) => JSON.parse(typeof body === "string" ? body : String(body)); + +/** + * @typedef {object} MigrationEntry + * @property {string} id + * @property {string} sql + * @property {string} checksum + */ + +/** + * The migrations-apply request body the command sends. + * @typedef {{ migrations: MigrationEntry[] }} MigrationsBody + */ + +/** + * @param {ControlFetchInit["body"]} body + * @returns {MigrationsBody} + */ +const parseMigrationsBody = (body) => /** @type {MigrationsBody} */ (parseBody(body)); + // d1 commands resolve the namespace from WDL_NS, so the shared factory gets a // richer env than its bare-token default. +/** @param {unknown} body */ const mockDeps = (body) => sharedMockDeps(body, { ADMIN_TOKEN: "tok", WDL_NS: "demo" }); test("d1 list calls the namespace database endpoint", async () => { @@ -19,14 +58,16 @@ test("d1 list calls the namespace database endpoint", async () => { await runD1Command(["list", "--control-url", "http://ctl.test"], deps); assert.equal(calls[0].url, "http://ctl.test/ns/demo/d1/databases"); - assert.deepEqual(calls[0].init.headers, { "x-admin-token": "tok" }); + assert.deepEqual(asCall(calls[0]).init.headers, { "x-admin-token": "tok" }); assert.deepEqual(lines, ["d1_main\tname=main\tcreated=today"]); }); test("d1 positional help prints help without resolving control", async () => { + /** @type {string[]} */ const lines = []; await runD1Command(["help"], { env: {}, + /** @param {string} line */ stdout: (line) => lines.push(line), controlFetch: async () => { throw new Error("controlFetch should not be called"); @@ -58,8 +99,8 @@ test("d1 create posts a database name", async () => { await runD1Command(["create", "main", "--control-url", "http://ctl.test"], deps); - assert.equal(calls[0].init.method, "POST"); - assert.deepEqual(JSON.parse(calls[0].init.body), { databaseName: "main" }); + assert.equal(asCall(calls[0]).init.method, "POST"); + assert.deepEqual(parseBody(asCall(calls[0]).init.body), { databaseName: "main" }); assert.deepEqual(lines, ["OK demo/d1_main created name=main"]); }); @@ -80,9 +121,9 @@ test("d1 execute sends SQL mode and JSON params", async () => { ], deps); assert.equal(calls[0].url, "http://ctl.test/ns/demo/d1/databases/main/query"); - assert.equal(calls[0].init.method, "POST"); - assert.equal(calls[0].init.timeoutMs, LONG_CONTROL_TIMEOUT_MS); - assert.deepEqual(JSON.parse(calls[0].init.body), { + assert.equal(asCall(calls[0]).init.method, "POST"); + assert.equal(asCall(calls[0]).init.timeoutMs, LONG_CONTROL_TIMEOUT_MS); + assert.deepEqual(parseBody(asCall(calls[0]).init.body), { sql: "select ? as n", mode: "all", params: [1], @@ -145,6 +186,7 @@ test("d1 execute --file accepts a path inside the project", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-d1-file-inside-")); try { writeFileSync(path.join(dir, "inside.sql"), "SELECT 1;"); + /** @type {RecordedCall[]} */ const calls = []; await runD1Command([ @@ -158,6 +200,7 @@ test("d1 execute --file accepts a path inside the project", async () => { cwd: dir, env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, stdout: () => {}, + /** @param {string} url @param {ControlFetchInit} [init] */ controlFetch: async (url, init = {}) => { calls.push({ url, init }); return response({ result: { results: [] } }); @@ -166,7 +209,7 @@ test("d1 execute --file accepts a path inside the project", async () => { assert.equal(calls[0].url, "http://ctl.test/ns/demo/d1/databases/main/query"); assert.equal(calls[0].init.method, "POST"); - assert.deepEqual(JSON.parse(calls[0].init.body), { + assert.deepEqual(parseBody(calls[0].init.body), { sql: "SELECT 1;", mode: "all", }); @@ -183,7 +226,9 @@ test("d1 migrations apply reads sorted SQL files from --dir", async () => { writeFileSync(path.join(migrations, "002_add.sql"), "alter table users add column name text;"); writeFileSync(path.join(migrations, "001_init.sql"), "create table users (id integer);"); + /** @type {RecordedCall[]} */ const calls = []; + /** @type {string[]} */ const lines = []; await runD1Command([ "migrations", @@ -196,7 +241,9 @@ test("d1 migrations apply reads sorted SQL files from --dir", async () => { ], { cwd: dir, env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + /** @param {string} line */ stdout: (line) => lines.push(line), + /** @param {string} url @param {ControlFetchInit} [init] */ controlFetch: async (url, init = {}) => { calls.push({ url, init }); return response({ applied: [{ id: "001_init.sql", statementCount: 1 }], skipped: [] }); @@ -205,7 +252,7 @@ test("d1 migrations apply reads sorted SQL files from --dir", async () => { assert.equal(calls[0].url, "http://ctl.test/ns/demo/d1/databases/main/migrations/apply"); assert.equal(calls[0].init.timeoutMs, LONG_CONTROL_TIMEOUT_MS); - const body = JSON.parse(calls[0].init.body); + const body = parseMigrationsBody(calls[0].init.body); assert.deepEqual(body.migrations.map((migration) => migration.id), [ "001_init.sql", "002_add.sql", @@ -234,6 +281,7 @@ test("d1 migrations_dir from wrangler config cannot escape the project", async ( "", ].join("\n")); + /** @type {RecordedCall[]} */ const calls = []; await assert.rejects( () => runD1Command([ @@ -245,6 +293,7 @@ test("d1 migrations_dir from wrangler config cannot escape the project", async ( ], { cwd: dir, env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, + /** @param {string} url @param {ControlFetchInit} [init] */ controlFetch: async (url, init = {}) => { calls.push({ url, init }); return response({}); @@ -299,6 +348,7 @@ test("d1 migrations apply orders unpadded numeric prefixes numerically", async ( writeFileSync(path.join(migrations, name), `-- ${name}`); } + /** @type {RecordedCall[]} */ const calls = []; await runD1Command([ "migrations", "apply", "main", "--dir", "migrations", "--control-url", "http://ctl.test", @@ -306,13 +356,14 @@ test("d1 migrations apply orders unpadded numeric prefixes numerically", async ( cwd: dir, env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, stdout: () => {}, + /** @param {string} url @param {ControlFetchInit} [init] */ controlFetch: async (url, init = {}) => { calls.push({ url, init }); return response({ applied: [], skipped: [] }); }, }); - const body = JSON.parse(calls[0].init.body); + const body = parseMigrationsBody(calls[0].init.body); assert.deepEqual(body.migrations.map((m) => m.id), [ "1_init.sql", "2_two.sql", @@ -330,6 +381,7 @@ test("d1 migrations --dir accepts a project subdirectory whose name starts with mkdirSync(migrations); writeFileSync(path.join(migrations, "0001_init.sql"), "create table t (id integer);"); + /** @type {RecordedCall[]} */ const calls = []; await runD1Command([ "migrations", "apply", "main", "--dir", "..hidden", "--control-url", "http://ctl.test", @@ -337,13 +389,14 @@ test("d1 migrations --dir accepts a project subdirectory whose name starts with cwd: dir, env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, stdout: () => {}, + /** @param {string} url @param {ControlFetchInit} [init] */ controlFetch: async (url, init = {}) => { calls.push({ url, init }); return response({ applied: [], skipped: [] }); }, }); - const body = JSON.parse(calls[0].init.body); + const body = parseMigrationsBody(calls[0].init.body); assert.deepEqual(body.migrations.map((m) => m.id), ["0001_init.sql"]); } finally { rmSync(dir, { recursive: true, force: true }); @@ -382,6 +435,7 @@ test("d1 execute rejects an unknown --mode before calling control", async () => test("d1 execute accepts all valid --mode values", async () => { for (const mode of ["all", "raw", "run", "exec"]) { + /** @type {RecordedCall[]} */ const calls = []; await runD1Command([ "execute", @@ -395,6 +449,7 @@ test("d1 execute accepts all valid --mode values", async () => { ], { env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, stdout: () => {}, + /** @param {string} url @param {ControlFetchInit} [init] */ controlFetch: async (url, init = {}) => { calls.push({ url, init }); return response({ result: { results: [] } }); @@ -402,12 +457,13 @@ test("d1 execute accepts all valid --mode values", async () => { }); assert.equal(calls.length, 1); - assert.equal(JSON.parse(calls[0].init.body).mode, mode); + assert.equal(/** @type {{ mode: string }} */ (parseBody(calls[0].init.body)).mode, mode); } }); test("d1 execute rejects --mode exec with any --params before calling control", async () => { let fetched = false; + /** @param {string} paramsJson */ const run = (paramsJson) => runD1Command( ["execute", "main", "--sql", "SELECT 1", "--mode", "exec", "--params", paramsJson, "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, stdout: () => {}, controlFetch: async () => { fetched = true; return response({}); } } @@ -420,6 +476,7 @@ test("d1 execute rejects --mode exec with any --params before calling control", test("d1 execute rejects an invalid --params before calling control", async () => { let fetched = false; + /** @param {string} paramsJson */ const run = (paramsJson) => runD1Command( ["execute", "main", "--sql", "SELECT 1", "--mode", "all", "--params", paramsJson, "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, stdout: () => {}, controlFetch: async () => { fetched = true; return response({}); } } diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 42e0bb4..a342621 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -35,15 +35,47 @@ import { import { LONG_CONTROL_TIMEOUT_MS } from "../../lib/control-fetch.js"; import { response } from "./helpers.js"; +/** + * The options bag the deploy pipeline passes to its injected execFile dep. The + * fakes record whichever subset each test asserts on; every field the deploy + * pipeline sets is present, so reads here are unconditional. + * @typedef {object} ExecFileOpts + * @property {string} [cwd] + * @property {"inherit" | readonly ("ignore" | "pipe")[]} [stdio] + * @property {string} [encoding] + * @property {number} [maxBuffer] + * @property {NodeJS.ProcessEnv} env + */ + +/** + * A recorded execFile invocation captured by a fake. + * @typedef {object} RecordedExec + * @property {string} cmd + * @property {readonly string[]} args + * @property {ExecFileOpts} opts + */ + +/** + * A recorded controlFetch invocation captured by a fake. + * @typedef {object} RecordedFetch + * @property {string} url + * @property {import("../../lib/control-fetch.js").ControlFetchInit} init + */ + // Shared happy-path execFile stub: answers the version probe and writes the // bundled entry the deploy pipeline expects in --outdir. +/** + * @param {string} _cmd + * @param {readonly string[]} args + */ function fakeWranglerExecFile(_cmd, args) { if (args.includes("--version")) return "wrangler 4.94.0"; - const outDir = args.find((arg) => arg.startsWith("--outdir=")).slice("--outdir=".length); + const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice("--outdir=".length); mkdirSync(outDir, { recursive: true }); writeFileSync(path.join(outDir, "index.js"), "export default {}"); } +/** @param {string} cmd */ function assertWranglerCommand(cmd) { assert.ok( cmd === "wrangler" || path.basename(cmd) === (process.platform === "win32" ? "wrangler.cmd" : "wrangler"), @@ -728,8 +760,9 @@ test("collectAssets reports ignored entries via onIgnore, excluding .assetsignor writeFileSync(path.join(dir, "app.js.map"), "m"); mkdirSync(path.join(dir, "node_modules"), { recursive: true }); writeFileSync(path.join(dir, "node_modules", "x.js"), "x"); + /** @type {string[]} */ const skipped = []; - collectAssets(dir, { onIgnore: (relPath, isDir) => skipped.push(isDir ? `${relPath}/` : relPath) }); + collectAssets(dir, { onIgnore: (/** @type {string} */ relPath, /** @type {boolean} */ isDir) => skipped.push(isDir ? `${relPath}/` : relPath) }); assert.deepEqual(skipped.toSorted(), ["app.js.map", "node_modules/"]); } finally { rmSync(dir, { recursive: true, force: true }); @@ -788,8 +821,9 @@ test("loadWranglerConfig: prefers wrangler.toml when multiple config files exist ); const loaded = loadWranglerConfig(dir); + const cfg = /** @type {{ name?: string, main?: string }} */ (loaded.cfg); assert.equal(loaded.path, path.join(dir, "wrangler.toml")); - assert.equal(loaded.cfg.name, "toml-demo"); + assert.equal(cfg.name, "toml-demo"); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -808,9 +842,10 @@ test("loadWranglerConfig: parses JSONC when TOML is absent", () => { ); const loaded = loadWranglerConfig(dir); + const cfg = /** @type {{ name?: string, main?: string }} */ (loaded.cfg); assert.equal(loaded.path, path.join(dir, "wrangler.jsonc")); - assert.equal(loaded.cfg.name, "jsonc-demo"); - assert.equal(loaded.cfg.main, "src/index.js"); + assert.equal(cfg.name, "jsonc-demo"); + assert.equal(cfg.main, "src/index.js"); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -1032,9 +1067,10 @@ test("validateUnsupportedWranglerConfig rejects unmapped wrangler binding sectio ); assert.fail("expected vectorize rejection"); } catch (err) { - assert.match(err.message, /\[\[queues\.producers\]\]/); - assert.match(err.message, /\[\[platform_bindings\]\]/); - assert.match(err.message, /\[triggers\]/); + const { message } = /** @type {Error} */ (err); + assert.match(message, /\[\[queues\.producers\]\]/); + assert.match(message, /\[\[platform_bindings\]\]/); + assert.match(message, /\[triggers\]/); } }); @@ -1444,8 +1480,11 @@ test("runDeployCommand resolves cwd-relative project dir and WDL_NS fallback", a ].join("\n") ); + /** @type {RecordedExec[]} */ const execCalls = []; + /** @type {RecordedFetch[]} */ const fetchCalls = []; + /** @type {string[]} */ const lines = []; await runDeployCommand( ["sub", "--control-url", "http://ctl.test"], @@ -1456,19 +1495,19 @@ test("runDeployCommand resolves cwd-relative project dir and WDL_NS fallback", a CLOUDFLARE_API_TOKEN: "real-cf-token", }, cwd: parent, - stdout: (line) => lines.push(line), + stdout: (/** @type {string} */ line) => lines.push(/** @type {string} */ line), stderr: () => {}, - execFile: (cmd, args, opts) => { + execFile: (/** @type {string} */ cmd, /** @type {readonly string[]} */ args, /** @type {ExecFileOpts} */ opts) => { execCalls.push({ cmd, args, opts }); if (args.includes("--version")) return "wrangler 4.94.0"; - const outDir = args.find((arg) => arg.startsWith("--outdir=")).slice("--outdir=".length); + const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice("--outdir=".length); mkdirSync(outDir, { recursive: true }); writeFileSync( path.join(outDir, "index.js"), 'export default { fetch() { return new Response("ok"); } };' ); }, - controlFetch: async (url, init = {}) => { + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { fetchCalls.push({ url, init }); if (fetchCalls.length === 1) { return response({ version: "v1", warnings: [] }); @@ -1501,7 +1540,7 @@ test("runDeployCommand resolves cwd-relative project dir and WDL_NS fallback", a "content-type": "application/json", "x-admin-token": "tok", }); - const manifest = JSON.parse(fetchCalls[0].init.body); + const manifest = JSON.parse(/** @type {string} */ (fetchCalls[0].init.body)); assert.equal(manifest.mainModule, "index.js"); assert.equal(manifest.modules["index.js"], 'export default { fetch() { return new Response("ok"); } };'); assert.deepEqual(manifest.bindings, { @@ -1517,7 +1556,7 @@ test("runDeployCommand resolves cwd-relative project dir and WDL_NS fallback", a assert.equal(fetchCalls[1].url, "http://ctl.test/ns/demo%20space/worker/api/promote"); assert.equal(fetchCalls[1].init.method, "POST"); - assert.deepEqual(JSON.parse(fetchCalls[1].init.body), { version: "v1" }); + assert.deepEqual(JSON.parse(/** @type {string} */ (fetchCalls[1].init.body)), { version: "v1" }); assert.ok(lines.includes(" bundled by wrangler")); assert.ok(lines.includes("✓ demo space/api@v1 live")); } finally { @@ -1539,23 +1578,24 @@ test("runDeployCommand sanitizes wrangler.name via temp --config so mixed-case w let tmpConfigSeen = null; let tmpConfigContentAtExec = /** @type {{ name?: string, main?: string, vars?: unknown } | null} */ (null); + /** @type {RecordedFetch[]} */ const fetchCalls = []; await runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdout: () => {}, stderr: () => {}, - execFile: (_cmd, args) => { + execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { if (args.includes("--version")) return "wrangler 4.94.0"; const cfgIdx = args.indexOf("--config"); assert.notEqual(cfgIdx, -1, "wrangler bundle args must include --config"); tmpConfigSeen = args[cfgIdx + 1]; assert.ok(existsSync(tmpConfigSeen), "temp config must exist when wrangler runs"); tmpConfigContentAtExec = JSON.parse(readFileSync(tmpConfigSeen, "utf8")); - const outDir = args.find((arg) => arg.startsWith("--outdir=")).slice("--outdir=".length); + const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice("--outdir=".length); mkdirSync(outDir, { recursive: true }); writeFileSync(path.join(outDir, "index.js"), "export default {}"); }, - controlFetch: async (url, init = {}) => { + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { fetchCalls.push({ url, init }); if (fetchCalls.length === 1) return response({ version: "v1", warnings: [] }); return response({ platformDomain: "workers.example" }); @@ -1596,7 +1636,7 @@ test("runDeployCommand removes the sanitized temp config when wrangler exec fail env: { ADMIN_TOKEN: "tok" }, stdout: () => {}, stderr: () => {}, - execFile: (_cmd, args) => { + execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { if (args.includes("--version")) return "wrangler 4.94.0"; const cfgIdx = args.indexOf("--config"); tmpConfigSeen = args[cfgIdx + 1]; @@ -1631,20 +1671,21 @@ test("runDeployCommand preserves prototype-shaped binding keys for control valid kv_namespaces: [{ binding: "__proto__", id: "kv-id" }], })); + /** @type {RecordedFetch[]} */ const fetchCalls = []; await runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdout: () => {}, stderr: () => {}, execFile: fakeWranglerExecFile, - controlFetch: async (url, init = {}) => { + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { fetchCalls.push({ url, init }); if (fetchCalls.length === 1) return response({ version: "v1", warnings: [] }); return response({ platformDomain: "workers.example" }); }, }); - const manifest = JSON.parse(fetchCalls[0].init.body); + const manifest = JSON.parse(/** @type {string} */ (fetchCalls[0].init.body)); assert.equal(Object.hasOwn(manifest.bindings, "__proto__"), true); assert.deepEqual(manifest.bindings["__proto__"], { type: "kv", id: "kv-id" }); } finally { @@ -1687,17 +1728,18 @@ test("runDeployCommand prints a direct http URL for a local deploy", async () => writeFileSync(path.join(dir, "src", "index.js"), 'export default { fetch() { return new Response("ok"); } };'); writeFileSync(path.join(dir, "wrangler.toml"), ['name = "api"', 'main = "src/index.js"', 'compatibility_date = "2026-05-31"'].join("\n")); + /** @type {string[]} */ const lines = []; let fetchCount = 0; await runDeployCommand( [dir, "--ns", "demo", "--control-url", "http://localhost:8080"], { env: { ADMIN_TOKEN: "tok" }, - stdout: (line) => lines.push(line), + stdout: (/** @type {string} */ line) => lines.push(/** @type {string} */ line), stderr: () => {}, - execFile: (_cmd, args) => { + execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { if (args.includes("--version")) return "wrangler 4.94.0"; - const outDir = args.find((arg) => arg.startsWith("--outdir=")).slice("--outdir=".length); + const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice("--outdir=".length); mkdirSync(outDir, { recursive: true }); writeFileSync(path.join(outDir, "index.js"), 'export default { fetch() { return new Response("ok"); } };'); }, @@ -1727,17 +1769,18 @@ test("runDeployCommand detects local control by hostname only", async () => { 'main = "src/index.js"', ].join("\n")); + /** @type {string[]} */ const lines = []; let fetchCount = 0; await runDeployCommand( [dir, "--ns", "demo", "--control-url", "https://ctl.example/localhost"], { env: { ADMIN_TOKEN: "tok" }, - stdout: (line) => lines.push(line), + stdout: (/** @type {string} */ line) => lines.push(/** @type {string} */ line), stderr: () => {}, - execFile: (_cmd, args) => { + execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { if (args.includes("--version")) return "wrangler 4.94.0"; - const outDir = args.find((arg) => arg.startsWith("--outdir=")).slice("--outdir=".length); + const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice("--outdir=".length); mkdirSync(outDir, { recursive: true }); writeFileSync(path.join(outDir, "index.js"), 'export default { fetch() { return new Response("ok"); } };'); }, @@ -1764,17 +1807,18 @@ test("runDeployCommand treats a .test control host as local (http URL, not https writeFileSync(path.join(dir, "src", "index.js"), 'export default { fetch() { return new Response("ok"); } };'); writeFileSync(path.join(dir, "wrangler.toml"), ['name = "api"', 'main = "src/index.js"'].join("\n")); + /** @type {string[]} */ const lines = []; let fetchCount = 0; await runDeployCommand( [dir, "--ns", "demo", "--control-url", "http://admin.test"], { env: { ADMIN_TOKEN: "tok" }, - stdout: (line) => lines.push(line), + stdout: (/** @type {string} */ line) => lines.push(/** @type {string} */ line), stderr: () => {}, - execFile: (_cmd, args) => { + execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { if (args.includes("--version")) return "wrangler 4.94.0"; - const outDir = args.find((arg) => arg.startsWith("--outdir=")).slice("--outdir=".length); + const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice("--outdir=".length); mkdirSync(outDir, { recursive: true }); writeFileSync(path.join(outDir, "index.js"), 'export default { fetch() { return new Response("ok"); } };'); }, @@ -1891,15 +1935,16 @@ test("runDeployCommand passes through wrangler output in verbose mode", async () writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); writeFileSync(path.join(dir, "wrangler.toml"), 'name = "api"\nmain = "src/index.js"\n'); + /** @type {RecordedExec[]} */ const execCalls = []; await runDeployCommand([dir, "--ns", "demo", "--verbose"], { env: { ADMIN_TOKEN: "tok", CONTROL_URL: "http://ctl.test" }, stdout: () => {}, stderr: () => {}, - execFile: (cmd, args, opts) => { + execFile: (/** @type {string} */ cmd, /** @type {readonly string[]} */ args, /** @type {ExecFileOpts} */ opts) => { execCalls.push({ cmd, args, opts }); if (args.includes("--version")) return "wrangler 4.94.0"; - const outDir = args.find((arg) => arg.startsWith("--outdir=")).slice("--outdir=".length); + const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice("--outdir=".length); mkdirSync(outDir, { recursive: true }); writeFileSync(path.join(outDir, "index.js"), "export default {}"); }, @@ -1923,14 +1968,16 @@ test("runDeployCommand rejects wrangler v3 before dry-run", async () => { writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); writeFileSync(path.join(dir, "wrangler.toml"), 'name = "api"\nmain = "src/index.js"\n'); + /** @type {RecordedExec[]} */ const execCalls = []; + /** @type {string[]} */ const lines = []; await assert.rejects( () => runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, - stdout: (line) => lines.push(line), + stdout: (/** @type {string} */ line) => lines.push(/** @type {string} */ line), stderr: () => {}, - execFile: (cmd, args, opts) => { + execFile: (/** @type {string} */ cmd, /** @type {readonly string[]} */ args, /** @type {ExecFileOpts} */ opts) => { execCalls.push({ cmd, args, opts }); return "wrangler 3.114.0"; }, @@ -1959,7 +2006,7 @@ test("runDeployCommand reports captured wrangler output only when dry-run fails" env: { ADMIN_TOKEN: "tok" }, stdout: () => {}, stderr: () => {}, - execFile: (_cmd, args) => { + execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { if (args.includes("--version")) return "wrangler 4.94.0"; throw Object.assign(new Error("Command failed"), { status: 1, @@ -1983,12 +2030,13 @@ test("runDeployCommand warns with wdl secret hints for missing caller secrets", writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); writeFileSync(path.join(dir, "wrangler.toml"), 'name = "api"\nmain = "src/index.js"\n'); + /** @type {string[]} */ const warnings = []; let fetchCount = 0; await runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdout: () => {}, - stderr: (line) => warnings.push(line), + stderr: (/** @type {string} */ line) => warnings.push(/** @type {string} */ line), execFile: fakeWranglerExecFile, controlFetch: async () => { fetchCount += 1; @@ -2023,12 +2071,13 @@ test("runDeployCommand projects unknown deploy warnings before printing", async writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); writeFileSync(path.join(dir, "wrangler.toml"), 'name = "api"\nmain = "src/index.js"\n'); + /** @type {string[]} */ const warnings = []; let fetchCount = 0; await runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdout: () => {}, - stderr: (line) => warnings.push(line), + stderr: (/** @type {string} */ line) => warnings.push(/** @type {string} */ line), execFile: fakeWranglerExecFile, controlFetch: async () => { fetchCount += 1; @@ -2079,15 +2128,16 @@ tag = "v1" new_classes = ["Room"] `); + /** @type {string[]} */ const warnings = []; let fetchCount = 0; await runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdout: () => {}, - stderr: (line) => warnings.push(line), - execFile: (_cmd, args) => { + stderr: (/** @type {string} */ line) => warnings.push(/** @type {string} */ line), + execFile: (/** @type {string} */ _cmd, /** @type {readonly string[]} */ args) => { if (args.includes("--version")) return "wrangler 4.94.0"; - const outDir = args.find((arg) => arg.startsWith("--outdir=")).slice("--outdir=".length); + const outDir = /** @type {string} */ (args.find((arg) => arg.startsWith("--outdir="))).slice("--outdir=".length); mkdirSync(outDir, { recursive: true }); writeFileSync(path.join(outDir, "index.js"), "export class Room {}; export default {}"); }, @@ -2179,20 +2229,21 @@ test("runDeployCommand maps a .mts main to the bundled .js entry", async () => { writeFileSync(path.join(dir, "src", "index.mts"), "export default {}"); writeFileSync(path.join(dir, "wrangler.toml"), 'name = "api"\nmain = "src/index.mts"\n'); + /** @type {RecordedFetch[]} */ const fetchCalls = []; await runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdout: () => {}, stderr: () => {}, execFile: fakeWranglerExecFile, - controlFetch: async (url, init = {}) => { + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { fetchCalls.push({ url, init }); if (fetchCalls.length === 1) return response({ version: "v1", warnings: [] }); return response({ platformDomain: "wdl.sh" }); }, }); - const manifest = JSON.parse(fetchCalls[0].init.body); + const manifest = JSON.parse(/** @type {string} */ (fetchCalls[0].init.body)); assert.equal(manifest.mainModule, "index.js"); } finally { rmSync(dir, { recursive: true, force: true }); @@ -2210,14 +2261,16 @@ test("runDeployCommand notes skipped asset entries on stderr", async () => { writeFileSync(path.join(dir, "wrangler.toml"), 'name = "api"\nmain = "src/index.js"\n\n[assets]\ndirectory = "./public"\n'); + /** @type {string[]} */ const stderrLines = []; + /** @type {RecordedFetch[]} */ const fetchCalls = []; await runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdout: () => {}, - stderr: (line) => stderrLines.push(line), + stderr: (/** @type {string} */ line) => stderrLines.push(/** @type {string} */ line), execFile: fakeWranglerExecFile, - controlFetch: async (url, init = {}) => { + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { fetchCalls.push({ url, init }); if (fetchCalls.length === 1) return response({ version: "v1", warnings: [] }); return response({ platformDomain: "wdl.sh" }); @@ -2227,7 +2280,7 @@ test("runDeployCommand notes skipped asset entries on stderr", async () => { const note = stderrLines.find((line) => line.startsWith("note: assets: skipped")); assert.ok(note, `expected a skipped-assets note, got ${JSON.stringify(stderrLines)}`); assert.match(note, /skipped 1 ignored entry \(node_modules\/; a trailing \/ is a whole subtree\)/); - const manifest = JSON.parse(fetchCalls[0].init.body); + const manifest = JSON.parse(/** @type {string} */ (fetchCalls[0].init.body)); assert.deepEqual(Object.keys(manifest.assets), ["index.html"]); } finally { rmSync(dir, { recursive: true, force: true }); @@ -2241,11 +2294,12 @@ test("runDeployCommand escapes a control-supplied version before printing", asyn writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); writeFileSync(path.join(dir, "wrangler.toml"), 'name = "api"\nmain = "src/index.js"\n'); + /** @type {string[]} */ const stdoutLines = []; let fetchCount = 0; await runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, - stdout: (line) => stdoutLines.push(line), + stdout: (/** @type {string} */ line) => stdoutLines.push(/** @type {string} */ line), stderr: () => {}, execFile: fakeWranglerExecFile, controlFetch: async () => { diff --git a/tests/unit/cli-init.test.js b/tests/unit/cli-init.test.js index addd67d..f53a67b 100644 --- a/tests/unit/cli-init.test.js +++ b/tests/unit/cli-init.test.js @@ -9,6 +9,7 @@ import { main, __test__ } from "../../commands/init.js"; const { parseArgs, validateNs, validateWorker, resolveWdlCliDep } = __test__; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +/** @param {(dir: string) => Promise} fn */ async function withTempCwd(fn) { const dir = mkdtempSync(path.join(tmpdir(), "wdl-init-test-")); const previous = process.cwd(); @@ -21,17 +22,19 @@ async function withTempCwd(fn) { } } +/** @param {() => Promise} fn */ async function captureExit(fn) { + /** @type {string | number | null | undefined} */ let exitCode = null; let errOutput = ""; const originalExit = process.exit; const originalErr = process.stderr.write.bind(process.stderr); - process.exit = (code) => { exitCode = code; throw new Error("__test_exit__"); }; - process.stderr.write = (chunk) => { errOutput += chunk; return true; }; + process.exit = /** @type {typeof process.exit} */ ((code) => { exitCode = code; throw new Error("__test_exit__"); }); + process.stderr.write = /** @type {typeof process.stderr.write} */ ((chunk) => { errOutput += chunk; return true; }); try { await fn(); } catch (err) { - if (err.message !== "__test_exit__") throw err; + if (!(err instanceof Error) || err.message !== "__test_exit__") throw err; } finally { process.exit = originalExit; process.stderr.write = originalErr; @@ -183,9 +186,11 @@ test("init scaffolds without --ns; the deploy script omits the namespace", async test("init positional help prints usage to stdout and exits successfully", async () => { await withTempCwd(async () => { + /** @type {string[]} */ const logs = []; const oldLog = console.log; - console.log = (msg) => logs.push(String(msg)); + console.log = (/** @type {unknown} */ msg) => logs.push(String(msg)); + /** @type {string | number | null | undefined} */ let exitCode; try { ({ exitCode } = await captureExit(() => main(["help"]))); diff --git a/tests/unit/cli-lifecycle.test.js b/tests/unit/cli-lifecycle.test.js index 9b3738e..f8d186b 100644 --- a/tests/unit/cli-lifecycle.test.js +++ b/tests/unit/cli-lifecycle.test.js @@ -20,8 +20,30 @@ import { } from "../../lib/control-fetch.js"; import { mockDeps, response } from "./helpers.js"; +/** + * A recorded control-plane call: the URL and the request init the command + * passed to `controlFetch`. `init` is the loose `ControlFetchInit` shape, so + * fields like `method`, `headers`, `body`, `timeoutMs`, and `maxBodyBytes` are + * all optional. + * @typedef {{ url: string, init: import("../../lib/control-fetch.js").ControlFetchInit }} ControlCall + */ + +/** + * The options bag the dispatcher passes to an injected `loadEnv`. Matches the + * third parameter of `loadCliDotEnv`. + * @typedef {NonNullable[2]>} LoadEnvOptions + */ + +/** + * The `loadEnv` override shape accepted by `wdlMain`. The test fakes record the + * options and otherwise ignore the contract return value. + * @typedef {typeof import("../../lib/credentials.js").loadCliDotEnv} LoadEnvFn + */ + +/** @param {string} value */ function stdinFrom(value) { const stdin = Object.assign(new EventEmitter(), { + /** @param {string} _encoding */ setEncoding(_encoding) {}, }); queueMicrotask(() => { @@ -31,11 +53,14 @@ function stdinFrom(value) { return stdin; } +/** @param {string} value */ function ttyStdinLine(value) { const stdin = Object.assign(new EventEmitter(), { isTTY: true, paused: false, + /** @param {string} _encoding */ setEncoding(_encoding) {}, + /** @param {boolean} _mode */ setRawMode(_mode) {}, // a real TTY has this; hidden input requires it pause() { this.paused = true; @@ -167,21 +192,23 @@ test("readJsonOrFail surfaces warnings arrays attached to error bodies", async ( }); test("commands warn when the admin token would travel over plain http to a non-local host", async () => { + /** @type {string[]} */ const warnings = []; await runWorkersCommand(["--ns", "demo", "--control-url", "http://ctl.prod.example"], { env: { ADMIN_TOKEN: "tok" }, stdout: () => {}, - warn: (line) => warnings.push(line), + warn: (/** @type {string} */ line) => warnings.push(line), controlFetch: async () => response({ workers: [] }), }); assert.equal(warnings.length, 1); assert.match(warnings[0], /plain http on a non-local host/); + /** @type {string[]} */ const quiet = []; await runWorkersCommand(["--ns", "demo", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdout: () => {}, - warn: (line) => quiet.push(line), + warn: (/** @type {string} */ line) => quiet.push(line), controlFetch: async () => response({ workers: [] }), }); assert.deepEqual(quiet, []); @@ -200,11 +227,12 @@ test("workers command lists namespace worker state", async () => { assert.equal(calls.length, 1); assert.equal(calls[0].url, "http://ctl.test/ns/demo/workers"); - assert.deepEqual(calls[0].init.headers, { "x-admin-token": "tok" }); + assert.deepEqual(/** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ (calls[0].init).headers, { "x-admin-token": "tok" }); assert.deepEqual(lines, ["api\tactive=v2\tversions=v1,v2\tsecrets=yes"]); }); test("workers command does not double-slash paths when CONTROL_URL has a trailing slash", async () => { + /** @type {ControlCall[]} */ const calls = []; await runWorkersCommand(["--ns", "demo"], { env: { @@ -212,7 +240,7 @@ test("workers command does not double-slash paths when CONTROL_URL has a trailin CONTROL_URL: "http://ctl.test/", }, stdout: () => {}, - controlFetch: async (url, init = {}) => { + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { calls.push({ url, init }); return response({ namespace: "demo", workers: [] }); }, @@ -233,10 +261,11 @@ test("workers command rejects unexpected positional arguments", async () => { }); test("wdl workers escapes control sequences from the control plane but keeps tab columns", async () => { + /** @type {string[]} */ const lines = []; await runWorkersCommand(["--ns", "demo", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, - stdout: (line) => lines.push(line), + stdout: (/** @type {string} */ line) => lines.push(line), controlFetch: async () => response({ workers: [{ name: "ev\u001bil", activeVersion: "v1", versions: ["v1"], hasSecrets: false }], }), @@ -249,42 +278,52 @@ test("wdl workers escapes control sequences from the control plane but keeps tab test("formatWorkersList handles empty and deploy-only entries", () => { assert.deepEqual(formatWorkersList({ workers: [] }), ["(no workers)"]); + // NOTE: lib/workers-format.js types `activeVersion` as `string | undefined`, + // but the control plane (and this test) sends `null` for an undeployed + // worker. `formatWorkersList` handles it (`w.activeVersion || "-"`); the + // typedef just omits `null`. Cast through the real param type so the test + // keeps exercising the null path without widening the lib type here. assert.deepEqual( - formatWorkersList({ - workers: [{ name: "draft", activeVersion: null, versions: ["v1"], hasSecrets: false }], - }), + formatWorkersList(/** @type {Parameters[0]} */ ( + /** @type {unknown} */ ({ + workers: [{ name: "draft", activeVersion: null, versions: ["v1"], hasSecrets: false }], + }) + )), ["draft\tactive=-\tversions=v1\tsecrets=no"] ); }); test("tenant lifecycle commands default namespace from WDL_NS", async () => { + /** @type {ControlCall[]} */ const workerCalls = []; await runWorkersCommand(["--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, stdout: () => {}, - controlFetch: async (url, init = {}) => { + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { workerCalls.push({ url, init }); return response({ namespace: "demo", workers: [] }); }, }); assert.equal(workerCalls[0].url, "http://ctl.test/ns/demo/workers"); + /** @type {ControlCall[]} */ const secretCalls = []; await runSecretCommand(["list", "--worker", "api", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, stdout: () => {}, - controlFetch: async (url, init = {}) => { + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { secretCalls.push({ url, init }); return response({ keys: [] }); }, }); assert.equal(secretCalls[0].url, "http://ctl.test/ns/demo/worker/api/secrets"); + /** @type {ControlCall[]} */ const deleteCalls = []; await runDeleteCommand(["version", "api", "v1", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, stdout: () => {}, - controlFetch: async (url, init = {}) => { + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { deleteCalls.push({ url, init }); return response({ namespace: "demo", @@ -313,7 +352,7 @@ test("delete version calls the version hard-delete endpoint", async () => { assert.equal(calls.length, 1); assert.equal(calls[0].url, "http://ctl.test/ns/demo/worker/api/versions/v1"); - assert.equal(calls[0].init.method, "DELETE"); + assert.equal(/** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ (calls[0].init).method, "DELETE"); assert.deepEqual(lines, ["OK demo/api@v1 deleted"]); }); @@ -385,17 +424,18 @@ test("delete worker supports dry-run query and raw json output", async () => { assert.equal(calls.length, 1); assert.equal(calls[0].url, "http://ctl.test/ns/demo/worker/api/delete?dry_run=1"); - assert.equal(calls[0].init.method, "POST"); + assert.equal(/** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ (calls[0].init).method, "POST"); assert.deepEqual(lines, [JSON.stringify(body, null, 2)]); }); test("delete worker requires confirmation unless --yes or --dry-run is used", async () => { + /** @type {ControlCall[]} */ const calls = []; await assert.rejects( () => runDeleteCommand(["worker", "--ns", "demo", "api", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdin: stdinFrom(""), - controlFetch: async (url, init = {}) => { + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { calls.push({ url, init }); return response({}); }, @@ -407,7 +447,7 @@ test("delete worker requires confirmation unless --yes or --dry-run is used", as await runDeleteCommand(["worker", "--ns", "demo", "api", "--yes", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdout: () => {}, - controlFetch: async (url, init = {}) => { + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { calls.push({ url, init }); return response({ namespace: "demo", @@ -422,16 +462,18 @@ test("delete worker requires confirmation unless --yes or --dry-run is used", as }); test("delete worker proceeds after interactive confirmation", async () => { + /** @type {ControlCall[]} */ const calls = []; + /** @type {string[]} */ const prompts = []; const stdin = ttyStdinLine("yes\n"); await runDeleteCommand(["worker", "--ns", "demo", "api", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdin, - stderr: (text) => prompts.push(text), + stderr: (/** @type {string} */ text) => prompts.push(text), stdout: () => {}, - controlFetch: async (url, init = {}) => { + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { calls.push({ url, init }); return response({ namespace: "demo", @@ -473,14 +515,16 @@ test("secret list accepts flags before the subcommand", async () => { }); test("secret list uses encoded namespace and worker path segments", async () => { + /** @type {ControlCall[]} */ const calls = []; + /** @type {string[]} */ const lines = []; await runSecretCommand( ["list", "--ns", "demo space", "--worker", "api/slash", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, - stdout: (line) => lines.push(line), - controlFetch: async (url, init = {}) => { + stdout: (/** @type {string} */ line) => lines.push(line), + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { calls.push({ url, init }); return response({ keys: ["A", "B"] }); }, @@ -494,12 +538,13 @@ test("secret list uses encoded namespace and worker path segments", async () => }); test("secret list supports raw json output", async () => { + /** @type {string[]} */ const lines = []; await runSecretCommand( ["list", "--json", "--ns", "demo", "--scope", "ns", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, - stdout: (line) => lines.push(line), + stdout: (/** @type {string} */ line) => lines.push(line), controlFetch: async () => response({ namespace: "demo", keys: ["A", "B"] }), } ); @@ -508,12 +553,13 @@ test("secret list supports raw json output", async () => { }); test("secret list tolerates a response without a keys array", async () => { + /** @type {string[]} */ const lines = []; await runSecretCommand( ["list", "--ns", "demo", "--scope", "ns", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, - stdout: (line) => lines.push(line), + stdout: (/** @type {string} */ line) => lines.push(line), controlFetch: async () => response({ namespace: "demo" }), } ); @@ -521,15 +567,17 @@ test("secret list tolerates a response without a keys array", async () => { }); test("secret put reads stdin, trims one newline, and encodes key", async () => { + /** @type {ControlCall[]} */ const calls = []; + /** @type {string[]} */ const lines = []; await runSecretCommand( ["put", "--ns", "demo", "--scope", "ns", "KEY/ONE", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdin: stdinFrom("secret-value\n"), - stdout: (line) => lines.push(line), - controlFetch: async (url, init = {}) => { + stdout: (/** @type {string} */ line) => lines.push(line), + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { calls.push({ url, init }); return response({ deleted: false }); }, @@ -545,13 +593,14 @@ test("secret put reads stdin, trims one newline, and encodes key", async () => { test("secret put escapes terminal controls from a raw keyArg in the status line", async () => { const esc = String.fromCharCode(27); + /** @type {string[]} */ const lines = []; await runSecretCommand( ["put", "--ns", "demo", "--scope", "ns", `KEY${esc}[2J`, "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdin: stdinFrom("v\n"), - stdout: (line) => lines.push(line), + stdout: (/** @type {string} */ line) => lines.push(line), controlFetch: async () => response({ deleted: false }), } ); @@ -560,7 +609,9 @@ test("secret put escapes terminal controls from a raw keyArg in the status line" }); test("secret put reads one tty line without waiting for EOF", async () => { + /** @type {ControlCall[]} */ const calls = []; + /** @type {string[]} */ const prompts = []; const stdin = ttyStdinLine("typed-value\n"); await runSecretCommand( @@ -569,8 +620,8 @@ test("secret put reads one tty line without waiting for EOF", async () => { env: { ADMIN_TOKEN: "tok" }, stdin, stdout: () => {}, - stderr: (text) => prompts.push(text), - controlFetch: async (url, init = {}) => { + stderr: (/** @type {string} */ text) => prompts.push(text), + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { calls.push({ url, init }); return response({ deleted: false }); }, @@ -585,15 +636,17 @@ test("secret put reads one tty line without waiting for EOF", async () => { }); test("secret put reports worker version promotion", async () => { + /** @type {ControlCall[]} */ const calls = []; + /** @type {string[]} */ const lines = []; await runSecretCommand( ["put", "--ns", "demo", "--worker", "api", "KEY", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdin: stdinFrom("secret-value\n"), - stdout: (line) => lines.push(line), - controlFetch: async (url, init = {}) => { + stdout: (/** @type {string} */ line) => lines.push(line), + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { calls.push({ url, init }); return response({ previousVersion: "v1", version: "v2" }); }, @@ -607,15 +660,17 @@ test("secret put reports worker version promotion", async () => { }); test("secret put and delete support raw json output", async () => { + /** @type {ControlCall[]} */ const calls = []; + /** @type {string[]} */ const putLines = []; await runSecretCommand( ["put", "--json", "--ns", "demo", "--worker", "api", "KEY", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdin: stdinFrom("secret-value\n"), - stdout: (line) => putLines.push(line), - controlFetch: async (url, init = {}) => { + stdout: (/** @type {string} */ line) => putLines.push(line), + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { calls.push({ url, init }); return response({ previousVersion: "v1", version: "v2" }); }, @@ -623,13 +678,14 @@ test("secret put and delete support raw json output", async () => { ); assert.deepEqual(putLines, [JSON.stringify({ previousVersion: "v1", version: "v2" }, null, 2)]); + /** @type {string[]} */ const deleteLines = []; await runSecretCommand( ["delete", "--json", "--ns", "demo", "--worker", "api", "KEY", "--yes", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, - stdout: (line) => deleteLines.push(line), - controlFetch: async (url, init = {}) => { + stdout: (/** @type {string} */ line) => deleteLines.push(line), + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { calls.push({ url, init }); return response({ deleted: true, previousVersion: "v2", version: "v3" }); }, @@ -639,11 +695,12 @@ test("secret put and delete support raw json output", async () => { }); test("secret list refuses ambiguous scope before calling control", async () => { + /** @type {ControlCall[]} */ const calls = []; await assert.rejects( () => runSecretCommand(["list", "--ns", "demo", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, - controlFetch: async (url, init = {}) => { + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { calls.push({ url, init }); return response({}); }, @@ -655,14 +712,16 @@ test("secret list refuses ambiguous scope before calling control", async () => { }); test("secret delete calls worker endpoint and reports promoted bump", async () => { + /** @type {ControlCall[]} */ const calls = []; + /** @type {string[]} */ const lines = []; await runSecretCommand( ["delete", "--ns", "demo", "--worker", "api", "KEY", "--yes", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, - stdout: (line) => lines.push(line), - controlFetch: async (url, init = {}) => { + stdout: (/** @type {string} */ line) => lines.push(line), + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { calls.push({ url, init }); return response({ deleted: true, previousVersion: "v1", version: "v2" }); }, @@ -676,12 +735,13 @@ test("secret delete calls worker endpoint and reports promoted bump", async () = }); test("secret delete requires confirmation unless --yes is used", async () => { + /** @type {ControlCall[]} */ const calls = []; await assert.rejects( () => runSecretCommand(["delete", "--ns", "demo", "--worker", "api", "KEY", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdin: stdinFrom(""), - controlFetch: async (url, init = {}) => { + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { calls.push({ url, init }); return response({}); }, @@ -692,16 +752,18 @@ test("secret delete requires confirmation unless --yes is used", async () => { }); test("secret delete proceeds after interactive confirmation", async () => { + /** @type {ControlCall[]} */ const calls = []; + /** @type {string[]} */ const prompts = []; const stdin = ttyStdinLine("y\n"); await runSecretCommand(["delete", "--ns", "demo", "--scope", "ns", "KEY", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdin, - stderr: (text) => prompts.push(text), + stderr: (/** @type {string} */ text) => prompts.push(text), stdout: () => {}, - controlFetch: async (url, init = {}) => { + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { calls.push({ url, init }); return response({ deleted: true }); }, @@ -714,12 +776,13 @@ test("secret delete proceeds after interactive confirmation", async () => { }); test("secret delete warning does not claim deletion when control reports deleted=false", async () => { + /** @type {string[]} */ const lines = []; await runSecretCommand( ["delete", "--ns", "demo", "--worker", "api", "KEY", "--yes", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, - stdout: (line) => lines.push(line), + stdout: (/** @type {string} */ line) => lines.push(line), controlFetch: async () => response({ deleted: false, warnings: [ @@ -736,8 +799,11 @@ test("secret delete warning does not claim deletion when control reports deleted }); test("r2 buckets and objects commands call encoded control endpoints", async () => { + /** @type {ControlCall[]} */ const calls = []; + /** @type {string[]} */ const lines = []; + /** @type {string[]} */ const bytes = []; const stdoutStream = new Writable({ write(chunk, _encoding, callback) { @@ -747,9 +813,9 @@ test("r2 buckets and objects commands call encoded control endpoints", async () }); const deps = { env: { ADMIN_TOKEN: "tok", CONTROL_URL: "http://ctl.test" }, - stdout: (line) => lines.push(line), + stdout: (/** @type {string} */ line) => lines.push(line), stdoutStream, - controlFetch: async (url, init = {}) => { + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { calls.push({ url, init }); if (init.method === "DELETE") { return response({ namespace: "demo space", bucket: "uploads", key: "dir/file.txt", status: "ok" }); @@ -824,10 +890,11 @@ test("r2 buckets and objects commands call encoded control endpoints", async () }); test("r2 object head --json keeps a __proto__ metadata key and drops a bare x-amz-meta-", async () => { + /** @type {string[]} */ const lines = []; const deps = { env: { ADMIN_TOKEN: "tok", WDL_NS: "demo" }, - stdout: (line) => lines.push(line), + stdout: (/** @type {string} */ line) => lines.push(line), controlFetch: async () => ({ status: 200, ok: true, @@ -841,7 +908,7 @@ test("r2 object head --json keeps a __proto__ metadata key and drops a bare x-am }), }; await runR2Command(["objects", "head", "--ns", "demo", "uploads", "k", "--json", "--control-url", "http://ctl.test"], deps); - const meta = JSON.parse(lines.find((l) => l.trim().startsWith("{"))).customMetadata; + const meta = JSON.parse(/** @type {string} */ (lines.find((l) => l.trim().startsWith("{")))).customMetadata; // JSON.parse re-materializes __proto__ as an own data property, so read the // descriptor — `meta.__proto__` would go through the prototype accessor instead. assert.equal(Object.getOwnPropertyDescriptor(meta, "__proto__")?.value, "pwned"); @@ -858,8 +925,10 @@ test("r2 buckets list accepts flags before the group/action", async () => { }); test("r2 object get waits for stdout backpressure", async () => { + /** @type {string[]} */ const events = []; const stdoutStream = Object.assign(new EventEmitter(), { + /** @param {Buffer} chunk */ write(chunk) { events.push(`write:${Buffer.from(chunk).toString("utf8")}`); if (events.length === 1) { @@ -893,12 +962,13 @@ test("r2 object get --out escapes a control-char path in the success line", asyn try { const esc = String.fromCharCode(27); const outPath = path.join(dir, `file${esc}[2J.bin`); - const lines = []; + /** @type {string[]} */ + 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), + stdout: (/** @type {string} */ line) => lines.push(line), controlFetch: async () => ({ status: 200, ok: true, @@ -965,12 +1035,13 @@ test("r2 streaming commands format JSON control errors", async () => { }); test("r2 object delete requires confirmation unless --yes is used", async () => { + /** @type {ControlCall[]} */ const calls = []; await assert.rejects( () => runR2Command(["objects", "delete", "--ns", "demo", "uploads", "a.txt", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdin: stdinFrom(""), - controlFetch: async (url, init = {}) => { + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { calls.push({ url, init }); return response({}); }, @@ -981,12 +1052,14 @@ test("r2 object delete requires confirmation unless --yes is used", async () => }); test("workflows commands call encoded control endpoints", async () => { + /** @type {ControlCall[]} */ const calls = []; + /** @type {string[]} */ const lines = []; const deps = { env: { ADMIN_TOKEN: "tok", CONTROL_URL: "http://ctl.test" }, - stdout: (line) => lines.push(line), - controlFetch: async (url, init = {}) => { + stdout: (/** @type {string} */ line) => lines.push(line), + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { calls.push({ url, init }); if (url.endsWith("/workflows")) { return response({ @@ -1051,6 +1124,7 @@ test("workflows list accepts flags before the subcommand", async () => { }); test("workflows commands reject unexpected positional arguments", async () => { + /** @type {boolean[]} */ const calls = []; const deps = { env: { ADMIN_TOKEN: "tok", CONTROL_URL: "http://ctl.test" }, @@ -1078,6 +1152,7 @@ test("workflows commands reject unexpected positional arguments", async () => { test("wdl dispatcher routes documented commands and rejects unknown commands", async () => { const oldExit = process.exit; const oldError = console.error; + /** @type {string[]} */ const seen = []; process.exit = (code) => { @@ -1087,18 +1162,18 @@ test("wdl dispatcher routes documented commands and rejects unknown commands", a try { await assert.rejects(() => wdlMain(["help"], { loadEnv: null }), /exit:0/); - assert.ok(seen.at(-1).includes("wdl [args] [options]")); + assert.ok(/** @type {string} */ (seen.at(-1)).includes("wdl [args] [options]")); // Top-level help must list the common control flags too, matching command // help — --no-token-store was missing here once. - assert.ok(seen.at(-1).includes("--no-token-store"), "top-level help lists --no-token-store"); + assert.ok(/** @type {string} */ (seen.at(-1)).includes("--no-token-store"), "top-level help lists --no-token-store"); // The command table is derived from each command's { name, summary }; assert // the metadata content renders (and the alias note) without pinning column spacing. - assert.ok(seen.at(-1).includes("Manage D1 databases, SQL execution, and migrations.")); - assert.ok(seen.at(-1).includes("Manage namespace-level or worker-level secrets. (alias: secrets)")); - assert.ok(seen.at(-1).includes("Inspect and delete R2 virtual bucket data.")); - assert.ok(seen.at(-1).includes("Live-tail worker console output and uncaught exceptions.")); + assert.ok(/** @type {string} */ (seen.at(-1)).includes("Manage D1 databases, SQL execution, and migrations.")); + assert.ok(/** @type {string} */ (seen.at(-1)).includes("Manage namespace-level or worker-level secrets. (alias: secrets)")); + assert.ok(/** @type {string} */ (seen.at(-1)).includes("Inspect and delete R2 virtual bucket data.")); + assert.ok(/** @type {string} */ (seen.at(-1)).includes("Live-tail worker console output and uncaught exceptions.")); // workflows is the widest name, so its summary sits one space after it. - assert.ok(seen.at(-1).includes("workflows Inspect and control Workflow instances.")); + assert.ok(/** @type {string} */ (seen.at(-1)).includes("workflows Inspect and control Workflow instances.")); await assert.rejects(() => wdlMain(["del"], { loadEnv: null }), /exit:1/); assert.ok(seen.some((line) => line.includes("unknown command: del"))); @@ -1113,6 +1188,7 @@ test("wdl dispatcher routes documented commands and rejects unknown commands", a test("wdl dispatcher prints the CLI version for --version, -v, and version", async () => { const oldLog = console.log; + /** @type {string[]} */ const lines = []; console.log = (msg) => lines.push(String(msg)); try { @@ -1128,9 +1204,11 @@ test("wdl dispatcher prints the CLI version for --version, -v, and version", asy // Stub process.exit (throws `exit:`) and capture console.error lines // for dispatcher-level tests that drive bin/wdl.js end to end. +/** @param {(errors: string[]) => Promise} fn */ async function withMockedExit(fn) { const oldExit = process.exit; const oldError = console.error; + /** @type {string[]} */ const errors = []; process.exit = (code) => { throw new Error(`exit:${code}`); @@ -1146,6 +1224,7 @@ async function withMockedExit(fn) { } test("wdl dispatcher loads base dotenv before namespace section overlay", async () => { + /** @type {LoadEnvOptions[]} */ const calls = []; // secret's missing-subcommand CliError fires after autoload, keeping the // dispatch harmless without needing a control-plane mock. @@ -1153,7 +1232,7 @@ test("wdl dispatcher loads base dotenv before namespace section overlay", async await assert.rejects( () => wdlMain(["secret", "--ns", "demo"], { env: {}, - loadEnv: (_env, _path, options) => calls.push(options), + loadEnv: /** @type {LoadEnvFn} */ (/** @type {unknown} */ ((/** @type {NodeJS.ProcessEnv | undefined} */ _env, /** @type {string | undefined} */ _path, /** @type {LoadEnvOptions} */ options) => calls.push(options))), }), /exit:1/ ); @@ -1171,12 +1250,13 @@ test("wdl dispatcher loads base dotenv before namespace section overlay", async }); test("wdl dispatcher overlays the LAST --ns occurrence, matching parseArgs", async () => { + /** @type {LoadEnvOptions[]} */ const calls = []; await withMockedExit(async () => { await assert.rejects( () => wdlMain(["secret", "--ns", "first", "--ns=last"], { env: {}, - loadEnv: (_env, _path, options) => calls.push(options), + loadEnv: /** @type {LoadEnvFn} */ (/** @type {unknown} */ ((/** @type {NodeJS.ProcessEnv | undefined} */ _env, /** @type {string | undefined} */ _path, /** @type {LoadEnvOptions} */ options) => calls.push(options))), }), /exit:1/ ); @@ -1187,23 +1267,24 @@ test("wdl dispatcher overlays the LAST --ns occurrence, matching parseArgs", asy }); test("wdl dispatcher skips dotenv when help is requested", async () => { + /** @type {LoadEnvOptions[]} */ const calls = []; const oldLog = console.log; console.log = () => {}; try { await wdlMain(["workers", "--ns", "demo", "--help"], { env: {}, - loadEnv: (_env, _path, options) => calls.push(options), + loadEnv: /** @type {LoadEnvFn} */ (/** @type {unknown} */ ((/** @type {NodeJS.ProcessEnv | undefined} */ _env, /** @type {string | undefined} */ _path, /** @type {LoadEnvOptions} */ options) => calls.push(options))), }); // The positional alias form must skip autoload too — including with // flags present — so a broken .env cannot block `wdl help`. await wdlMain(["workers", "help"], { env: {}, - loadEnv: (_env, _path, options) => calls.push(options), + loadEnv: /** @type {LoadEnvFn} */ (/** @type {unknown} */ ((/** @type {NodeJS.ProcessEnv | undefined} */ _env, /** @type {string | undefined} */ _path, /** @type {LoadEnvOptions} */ options) => calls.push(options))), }); await wdlMain(["workers", "--ns", "demo", "help"], { env: {}, - loadEnv: (_env, _path, options) => calls.push(options), + loadEnv: /** @type {LoadEnvFn} */ (/** @type {unknown} */ ((/** @type {NodeJS.ProcessEnv | undefined} */ _env, /** @type {string | undefined} */ _path, /** @type {LoadEnvOptions} */ options) => calls.push(options))), }); } finally { console.log = oldLog; @@ -1234,7 +1315,9 @@ test("wdl dispatcher reports a malformed .env without a Node stack", async () => test("wdl dispatcher skips dotenv for top-level help and unknown commands", async () => { const oldExit = process.exit; const oldError = console.error; + /** @type {string[]} */ const errors = []; + /** @type {string[]} */ const calls = []; process.exit = (code) => { @@ -1244,11 +1327,11 @@ test("wdl dispatcher skips dotenv for top-level help and unknown commands", asyn try { await assert.rejects( - () => wdlMain(["help"], { loadEnv: () => calls.push("help") }), + () => wdlMain(["help"], { loadEnv: /** @type {LoadEnvFn} */ (/** @type {unknown} */ (() => calls.push("help"))) }), /exit:0/ ); await assert.rejects( - () => wdlMain(["bogus"], { loadEnv: () => calls.push("bogus") }), + () => wdlMain(["bogus"], { loadEnv: /** @type {LoadEnvFn} */ (/** @type {unknown} */ (() => calls.push("bogus"))) }), /exit:1/ ); assert.deepEqual(calls, []); @@ -1262,6 +1345,7 @@ test("wdl dispatcher skips dotenv for top-level help and unknown commands", asyn test("wdl dispatcher prints parseArgs errors without a Node stack", async () => { const oldExit = process.exit; const oldError = console.error; + /** @type {string[]} */ const errors = []; process.exit = (code) => { @@ -1285,6 +1369,7 @@ test("wdl dispatcher prints parseArgs errors without a Node stack", async () => }); test("SseParser dispatches event/id/data on blank line per SSE rules", () => { + /** @type {import("../../commands/tail.js").SseEvent[]} */ const events = []; const parser = new SseParser((event) => events.push(event)); @@ -1301,6 +1386,7 @@ test("SseParser dispatches event/id/data on blank line per SSE rules", () => { }); test("SseParser handles CRLF line endings and flushes trailing events", () => { + /** @type {import("../../commands/tail.js").SseEvent[]} */ const events = []; const parser = new SseParser((event) => events.push(event)); @@ -1347,12 +1433,13 @@ test("wdl tail requires at least one positional worker", async () => { }); test("wdl tail help short-circuits before max-reconnects validation", async () => { + /** @type {string[]} */ const stdoutLines = []; await runTailCommand( ["--help", "--max-reconnects", "forever"], { env: {}, - stdout: (line) => stdoutLines.push(line), + stdout: (/** @type {string} */ line) => stdoutLines.push(line), stderr: () => {}, } ); @@ -1362,6 +1449,10 @@ test("wdl tail help short-circuits before max-reconnects validation", async () = test("wdl tail escapes control error details", async () => { const fakeTransport = { + /** + * @param {import("node:https").RequestOptions} _opts + * @param {(res: import("node:http").IncomingMessage) => void} cb + */ request(_opts, cb) { const req = fakeHttpReq(); setImmediate(() => { @@ -1390,24 +1481,35 @@ test("wdl tail escapes control error details", async () => { ); }); +/** @returns {import("../../lib/control-fetch.js").ControlClientRequest} */ function fakeHttpReq() { - return Object.assign(new EventEmitter(), { - end() {}, - destroy() {}, - }); + return /** @type {import("../../lib/control-fetch.js").ControlClientRequest} */ ( + /** @type {unknown} */ (Object.assign(new EventEmitter(), { + end() {}, + destroy() {}, + })) + ); } +/** @returns {import("node:http").IncomingMessage} */ function fakeHttpRes() { - return Object.assign(new EventEmitter(), { - statusCode: 200, - headers: {}, - setEncoding() {}, - }); + return /** @type {import("node:http").IncomingMessage} */ ( + /** @type {unknown} */ (Object.assign(new EventEmitter(), { + statusCode: 200, + headers: {}, + setEncoding() {}, + })) + ); } test("wdl tail renders fetch, scheduled, and queue invocation events", async () => { + /** @type {string[]} */ const stdoutLines = []; const fakeTransport = { + /** + * @param {import("node:https").RequestOptions} _opts + * @param {(res: import("node:http").IncomingMessage) => void} cb + */ request(_opts, cb) { const req = fakeHttpReq(); setImmediate(() => { @@ -1457,7 +1559,7 @@ test("wdl tail renders fetch, scheduled, and queue invocation events", async () ["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], { env: {}, - stdout: (line) => stdoutLines.push(line), + stdout: (/** @type {string} */ line) => stdoutLines.push(line), stderr: () => {}, transport: fakeTransport, } @@ -1472,9 +1574,14 @@ test("wdl tail renders fetch, scheduled, and queue invocation events", async () }); test("wdl tail escapes terminal control sequences in rendered events", async () => { + /** @type {string[]} */ const stdoutLines = []; let emitted = false; const fakeTransport = { + /** + * @param {import("node:https").RequestOptions} _opts + * @param {(res: import("node:http").IncomingMessage) => void} cb + */ request(_opts, cb) { const req = fakeHttpReq(); setImmediate(() => { @@ -1509,7 +1616,7 @@ test("wdl tail escapes terminal control sequences in rendered events", async () ["foo", "--ns", "demo", "--token", "t", "--control-url", "http://ctl.test"], { env: {}, - stdout: (line) => stdoutLines.push(line), + stdout: (/** @type {string} */ line) => stdoutLines.push(line), stderr: () => {}, transport: fakeTransport, sleepFn: async () => {}, @@ -1526,8 +1633,13 @@ test("wdl tail escapes terminal control sequences in rendered events", async () }); test("wdl tail accepts bare CONTROL_URL hosts by defaulting to https", async () => { + /** @type {import("node:https").RequestOptions[]} */ const requestsSeen = []; const fakeTransport = { + /** + * @param {import("node:https").RequestOptions} opts + * @param {(res: import("node:http").IncomingMessage) => void} cb + */ request(opts, cb) { requestsSeen.push(opts); const req = fakeHttpReq(); @@ -1559,15 +1671,20 @@ test("wdl tail accepts bare CONTROL_URL hosts by defaulting to https", async () assert.equal(requestsSeen[0].host, "ctl.uat.example"); assert.equal(requestsSeen[0].port, 443); - assert.equal(requestsSeen[0].headers.Host, "ctl.uat.example"); + assert.equal(/** @type {import("node:http").OutgoingHttpHeaders} */ (requestsSeen[0].headers).Host, "ctl.uat.example"); assert.equal(requestsSeen[0].path, "/ns/demo/logs/tail?worker=kv-demo"); }); test("wdl tail sends --since on the initial URL, not duplicated as Last-Event-ID", async () => { + /** @type {Array<{ path: import("node:https").RequestOptions["path"], headers: import("node:http").OutgoingHttpHeaders }>} */ const requestsSeen = []; const fakeTransport = { + /** + * @param {import("node:https").RequestOptions} opts + * @param {(res: import("node:http").IncomingMessage) => void} cb + */ request(opts, cb) { - requestsSeen.push({ path: opts.path, headers: { ...opts.headers } }); + requestsSeen.push({ path: opts.path, headers: { .../** @type {import("node:http").OutgoingHttpHeaders} */ (opts.headers) } }); const req = fakeHttpReq(); setImmediate(() => { const res = fakeHttpRes(); @@ -1597,10 +1714,15 @@ test("wdl tail sends --since on the initial URL, not duplicated as Last-Event-ID }); test("wdl tail keeps --since on reconnect until the server provides an event id", async () => { + /** @type {Array<{ path: import("node:https").RequestOptions["path"], headers: import("node:http").OutgoingHttpHeaders }>} */ const requestsSeen = []; const fakeTransport = { + /** + * @param {import("node:https").RequestOptions} opts + * @param {(res: import("node:http").IncomingMessage) => void} cb + */ request(opts, cb) { - requestsSeen.push({ path: opts.path, headers: { ...opts.headers } }); + requestsSeen.push({ path: opts.path, headers: { .../** @type {import("node:http").OutgoingHttpHeaders} */ (opts.headers) } }); const req = fakeHttpReq(); setImmediate(() => { const res = fakeHttpRes(); @@ -1636,10 +1758,15 @@ test("wdl tail keeps --since on reconnect until the server provides an event id" }); test("wdl tail switches from --since to Last-Event-ID after receiving an event id", async () => { + /** @type {Array<{ path: import("node:https").RequestOptions["path"], headers: import("node:http").OutgoingHttpHeaders }>} */ const requestsSeen = []; const fakeTransport = { + /** + * @param {import("node:https").RequestOptions} opts + * @param {(res: import("node:http").IncomingMessage) => void} cb + */ request(opts, cb) { - requestsSeen.push({ path: opts.path, headers: { ...opts.headers } }); + requestsSeen.push({ path: opts.path, headers: { .../** @type {import("node:http").OutgoingHttpHeaders} */ (opts.headers) } }); const req = fakeHttpReq(); setImmediate(() => { const res = fakeHttpRes(); @@ -1681,8 +1808,13 @@ test("wdl tail switches from --since to Last-Event-ID after receiving an event i }); test("wdl tail prints a connected status after SSE handshake", async () => { + /** @type {string[]} */ const stderrLines = []; const fakeTransport = { + /** + * @param {import("node:https").RequestOptions} _opts + * @param {(res: import("node:http").IncomingMessage) => void} cb + */ request(_opts, cb) { const req = fakeHttpReq(); setImmediate(() => { @@ -1700,7 +1832,7 @@ test("wdl tail prints a connected status after SSE handshake", async () => { { env: {}, stdout: () => {}, - stderr: (line) => stderrLines.push(line), + stderr: (/** @type {string} */ line) => stderrLines.push(line), transport: fakeTransport, } ), @@ -1711,11 +1843,17 @@ test("wdl tail prints a connected status after SSE handshake", async () => { }); test("wdl tail reconnects with Last-Event-ID after transport errors", async () => { + /** @type {Array<{ path: import("node:https").RequestOptions["path"], headers: import("node:http").OutgoingHttpHeaders }>} */ const requestsSeen = []; + /** @type {string[]} */ const stderrLines = []; const fakeTransport = { + /** + * @param {import("node:https").RequestOptions} opts + * @param {(res: import("node:http").IncomingMessage) => void} cb + */ request(opts, cb) { - requestsSeen.push({ path: opts.path, headers: { ...opts.headers } }); + requestsSeen.push({ path: opts.path, headers: { .../** @type {import("node:http").OutgoingHttpHeaders} */ (opts.headers) } }); const req = fakeHttpReq(); setImmediate(() => { const res = fakeHttpRes(); @@ -1748,7 +1886,7 @@ test("wdl tail reconnects with Last-Event-ID after transport errors", async () = { env: {}, stdout: () => {}, - stderr: (line) => stderrLines.push(line), + stderr: (/** @type {string} */ line) => stderrLines.push(line), transport: fakeTransport, sleepFn: async () => {}, } @@ -1763,11 +1901,17 @@ test("wdl tail reconnects with Last-Event-ID after transport errors", async () = }); test("wdl tail increases backoff until a stable session resets it", async () => { + /** @type {number[]} */ const sleepCalls = []; + /** @type {string[]} */ const stderrLines = []; let nowMs = 0; let requestCount = 0; const fakeTransport = { + /** + * @param {import("node:https").RequestOptions} _opts + * @param {(res: import("node:http").IncomingMessage) => void} cb + */ request(_opts, cb) { requestCount += 1; const req = fakeHttpReq(); @@ -1797,10 +1941,10 @@ test("wdl tail increases backoff until a stable session resets it", async () => { env: {}, stdout: () => {}, - stderr: (line) => stderrLines.push(line), + stderr: (/** @type {string} */ line) => stderrLines.push(line), transport: fakeTransport, now: () => nowMs, - sleepFn: async (ms) => { + sleepFn: async (/** @type {number} */ ms) => { sleepCalls.push(ms); nowMs += ms; }, @@ -1844,7 +1988,9 @@ test("cli source imports stay inside the package and its declared dependencies", assert.deepEqual(offenders, []); }); +/** @param {string} source */ function importSpecifiers(source) { + /** @type {string[]} */ const specs = []; const patterns = [ /^\s*(?:import|export)\s+(?:[^"'()]*?\s+from\s+)?["']([^"']+)["']/gm, @@ -1852,12 +1998,14 @@ function importSpecifiers(source) { /\brequire\s*\(\s*["']([^"']+)["']\s*\)/g, ]; for (const pattern of patterns) { - for (const match of source.matchAll(pattern)) specs.push(match[1]); + for (const match of source.matchAll(pattern)) specs.push(/** @type {string} */ (match[1])); } return specs; } +/** @param {string} root */ function listCliJsFiles(root) { + /** @type {string[]} */ const out = []; for (const dir of ["bin", "commands", "lib"]) { out.push(...listJsFiles(path.join(root, dir))); @@ -1865,7 +2013,12 @@ function listCliJsFiles(root) { return out; } +/** + * @param {string} dir + * @returns {string[]} + */ function listJsFiles(dir) { + /** @type {string[]} */ const out = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); diff --git a/tests/unit/cli-output.test.js b/tests/unit/cli-output.test.js index eff0f1e..76e1433 100644 --- a/tests/unit/cli-output.test.js +++ b/tests/unit/cli-output.test.js @@ -3,18 +3,20 @@ import assert from "node:assert/strict"; import { maskToken, writeJsonOr, writeStatusLine } from "../../lib/output.js"; test("writeStatusLine escapes terminal control bytes in the assembled line", () => { + /** @type {string[]} */ const lines = []; - writeStatusLine((l) => lines.push(l), `ok ${String.fromCharCode(27)}[2J done`); + writeStatusLine((/** @type {string} */ 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", () => { + /** @type {string[]} */ const out = []; - assert.equal(writeJsonOr(true, { a: 1 }, (l) => out.push(l)), true); + assert.equal(writeJsonOr(true, { a: 1 }, (/** @type {string} */ l) => out.push(l)), true); assert.equal(out[0], JSON.stringify({ a: 1 }, null, 2)); out.length = 0; - assert.equal(writeJsonOr(false, { a: 1 }, (l) => out.push(l)), false); + assert.equal(writeJsonOr(false, { a: 1 }, (/** @type {string} */ l) => out.push(l)), false); assert.equal(out.length, 0, "nothing written when not json"); }); diff --git a/tests/unit/cli-stdin.test.js b/tests/unit/cli-stdin.test.js index beffb4a..38f7f7c 100644 --- a/tests/unit/cli-stdin.test.js +++ b/tests/unit/cli-stdin.test.js @@ -6,12 +6,13 @@ import { confirmAction, readSecretStdin, readTtyLine } from "../../lib/stdin.js" const ESC = String.fromCharCode(27); test("readTtyLine hides input by switching the TTY to raw mode", async () => { + /** @type {boolean[]} */ const rawCalls = []; const stderr = []; const stdin = Object.assign(new EventEmitter(), { isTTY: true, setEncoding() {}, - setRawMode(v) { rawCalls.push(v); }, + setRawMode(/** @type {boolean} */ v) { rawCalls.push(v); }, pause() {}, }); const pending = readTtyLine(stdin, { prompt: "tok: ", stderr: (s) => stderr.push(s), hidden: true }); @@ -67,11 +68,12 @@ test("readSecretStdin trims only one trailing newline (multi-line value)", async }); test("readSecretStdin hides input on a TTY via raw mode", async () => { + /** @type {boolean[]} */ const rawCalls = []; const stdin = Object.assign(new EventEmitter(), { isTTY: true, setEncoding() {}, - setRawMode(v) { rawCalls.push(v); }, + setRawMode(/** @type {boolean} */ v) { rawCalls.push(v); }, pause() {}, }); queueMicrotask(() => { @@ -83,6 +85,7 @@ test("readSecretStdin hides input on a TTY via raw mode", async () => { }); test("readTtyLine escapes terminal controls in the prompt at the write point", async () => { + /** @type {string[]} */ const errs = []; const stdin = Object.assign(new EventEmitter(), { setEncoding() {}, pause() {} }); queueMicrotask(() => stdin.emit("data", "y\n")); diff --git a/tests/unit/cli-token-store.test.js b/tests/unit/cli-token-store.test.js index fab5a79..7685d3f 100644 --- a/tests/unit/cli-token-store.test.js +++ b/tests/unit/cli-token-store.test.js @@ -11,6 +11,11 @@ import { writeTokenStore, } from "../../lib/token-store.js"; +/** + * @template T + * @param {(dir: string) => T} fn + * @returns {T} + */ function withTempDir(fn) { const dir = mkdtempSync(path.join(tmpdir(), "wdl-token-store-")); try { @@ -194,7 +199,9 @@ test("writeTokenStore sets 0600 file and 0700 dir permissions", () => { test("assertStoreDirSecure refuses a group/world-writable store dir", () => { if (process.platform === "win32") return; + /** @type {string[]} */ const made = []; + /** @param {number} mode */ const mkdir = (mode) => { const d = mkdtempSync(path.join(tmpdir(), "wdl-store-secure-")); chmodSync(d, mode); diff --git a/tests/unit/cli-token.test.js b/tests/unit/cli-token.test.js index 9e45453..dae4c67 100644 --- a/tests/unit/cli-token.test.js +++ b/tests/unit/cli-token.test.js @@ -10,6 +10,11 @@ import { response } from "./helpers.js"; const ESC = String.fromCharCode(27); +/** + * @template T + * @param {(dir: string) => Promise} fn + * @returns {Promise} + */ async function withTempXdg(fn) { const dir = mkdtempSync(path.join(tmpdir(), "wdl-token-cmd-")); try { @@ -19,6 +24,7 @@ async function withTempXdg(fn) { } } +/** @param {string} value @returns {import("../../lib/stdin.js").StdinLike} */ function stdinFrom(value) { const stdin = Object.assign(new EventEmitter(), { setEncoding() {} }); queueMicrotask(() => { @@ -28,22 +34,37 @@ function stdinFrom(value) { return stdin; } -/** @param {string} xdg @param {{ stdin?: any, controlFetch?: Function }} [opts] */ +/** + * The control-fetch surface `fetchWhoami` drives: it always supplies `headers`. + * @typedef {import("../../lib/control-fetch.js").ControlFetchInit & { headers: import("node:http").OutgoingHttpHeaders }} WhoamiInit + * @typedef {(url: string, init?: WhoamiInit) => Promise>} FakeControlFetch + */ + +/** + * @param {string} xdg + * @param {{ stdin?: import("../../lib/stdin.js").StdinLike, controlFetch?: FakeControlFetch }} [opts] + */ function deps(xdg, { stdin, controlFetch } = {}) { + /** @type {string[]} */ const lines = []; + /** @type {string[]} */ const warnings = []; + /** @type {Array<{ url: string, init: WhoamiInit }>} */ const calls = []; return { lines, warnings, calls, deps: { + /** @type {NodeJS.ProcessEnv} */ env: { XDG_CONFIG_HOME: xdg }, + /** @param {string} line */ stdout: (line) => lines.push(line), stderr: () => {}, + /** @param {string} line */ warn: (line) => warnings.push(line), stdin, - controlFetch: controlFetch || (async (url, init = {}) => { + controlFetch: controlFetch || (/** @param {string} url @param {WhoamiInit} init */ async (url, init) => { calls.push({ url, init }); return response({ ok: true, principal: { kind: "ns", ns: "acme" } }); }), @@ -338,6 +359,7 @@ test("token list prints a placeholder when empty", async () => { test("token use/rm escape terminal controls in the not-found error", async () => { await withTempXdg(async (xdg) => { const bad = `ghost${ESC}[2J`; + /** @param {unknown} err */ const noEsc = (err) => { assert.doesNotMatch(/** @type {Error} */ (err).message, new RegExp(ESC), "raw ESC must not reach the error"); return true; diff --git a/tests/unit/helpers.js b/tests/unit/helpers.js index ad101f3..cda4411 100644 --- a/tests/unit/helpers.js +++ b/tests/unit/helpers.js @@ -7,6 +7,10 @@ // representation like fetch does, so a string body must be valid JSON to be // consumed through json(), and callers never share a reference with the // fixture object. +/** + * @param {unknown} body Object (JSON-encoded) or pre-serialized string body. + * @param {number} [status] + */ export function response(body, status = 200) { const text = typeof body === "string" ? body : JSON.stringify(body); const bytes = Buffer.from(text); @@ -23,15 +27,23 @@ export function response(body, status = 200) { // Records control-plane calls and stdout lines, returning deps for a command // runner. env defaults to a bare admin token; pass a richer env (e.g. with // WDL_NS) when the command resolves the namespace from the environment. +/** + * @param {unknown} body + * @param {NodeJS.ProcessEnv} [env] + */ export function mockDeps(body, env = { ADMIN_TOKEN: "tok" }) { + /** @type {Array<{ url: string, init: object }>} */ const calls = []; + /** @type {string[]} */ const lines = []; return { calls, lines, deps: { env, + /** @param {string} line */ stdout: (line) => lines.push(line), + /** @param {string} url @param {object} [init] */ controlFetch: async (url, init = {}) => { calls.push({ url, init }); return response(body); diff --git a/tsconfig.json b/tsconfig.json index bdeeb1d..8ad41e2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "allowJs": true, "checkJs": true, "noEmit": true, - "strict": false, + "strict": true, "skipLibCheck": true, "lib": ["ES2024", "DOM", "DOM.Iterable"], "types": ["node"] From 94d457e7a4b40b0df7b3fc8565b179cb6269b4ce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 02:07:57 +0000 Subject: [PATCH 04/20] Harden service-binding validation and whoami control-URL guard Two real gaps surfaced while typing the code (behavior fixes, not types): - parseServicesFromCfg only truthiness-checked `binding`/`service`. A non-string truthy `service` flowed unchanged into the deploy manifest, and a non-string `binding` (e.g. ["AB"]) would be String()-coerced past the BINDING_NAME_RE check. Now both must be non-empty strings, matching the d1/r2 parsers. - ensureControlContextFromConfigState returned `controlUrl.value` (possibly null) typed as string. Now fails closed with "No control URL configured" when the value is unresolved and no error was recorded. Adds unit coverage for both. All 375 unit tests pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- lib/whoami.js | 7 +++++- lib/wrangler/bindings.js | 22 +++++++++++++----- tests/unit/cli-config-doctor.test.js | 34 +++++++++++++++++++++++++++- tests/unit/cli-deploy.test.js | 11 +++++++++ 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/lib/whoami.js b/lib/whoami.js index ef0f867..de38e08 100644 --- a/lib/whoami.js +++ b/lib/whoami.js @@ -197,9 +197,14 @@ function stringField(value) { */ export function ensureControlContextFromConfigState(state) { if (state.controlUrl.error) throw new CliError(state.controlUrl.error); + // Fail closed: a null value with no error shouldn't happen given config-state's + // resolver (it sets `error` on failure), but never return null typed as string. + if (!state.controlUrl.value) { + throw new CliError("No control URL configured. Set CONTROL_URL (e.g. in ./.env), or pass --control-url."); + } if (!state.token.value) throw new CliError("Missing admin token. Run 'wdl token set --ns --control-url ' (recommended), pass --token , or set ADMIN_TOKEN."); return { - controlUrl: /** @type {string} */ (state.controlUrl.value), + controlUrl: state.controlUrl.value, token: state.token.value, headers: { "x-admin-token": state.token.value }, }; diff --git a/lib/wrangler/bindings.js b/lib/wrangler/bindings.js index 37faa8e..22eb905 100644 --- a/lib/wrangler/bindings.js +++ b/lib/wrangler/bindings.js @@ -312,12 +312,13 @@ export function parseR2BucketsFromCfg(cfg, configRel = "wrangler config") { } /** - * `binding` is validated against BINDING_NAME_RE; `service`, `entrypoint`, and - * `ns` are only truthiness/identifier checked, so they may be non-string values - * the original code passes through unchanged. + * `binding` and `service` are validated as non-empty strings; `entrypoint` and + * `ns`, when present, are checked as a JS identifier / admin-acceptable namespace + * (both strings at that point) but stay `unknown` here since the validators are + * not TS type predicates. * @typedef {object} ServiceBinding * @property {string} binding - * @property {unknown} service + * @property {string} service * @property {unknown} [entrypoint] * @property {unknown} [ns] */ @@ -342,6 +343,16 @@ export function parseServicesFromCfg(cfg, configRel = "wrangler config") { if (!entry.binding || !entry.service) { throw new Error(`${configRel}: [[services]] entry needs both 'binding' and 'service'`); } + // Enforce string types like the d1/r2 parsers do. Without this a non-string + // truthy `service` flows unchanged into the deploy manifest, and a non-string + // `binding` (e.g. ["AB"]) would be silently String()-coerced past the + // BINDING_NAME_RE check below. + if (typeof entry.binding !== "string" || !entry.binding.trim()) { + throw new Error(`${configRel}: [[services]] binding must be a non-empty string, got ${JSON.stringify(entry.binding)}`); + } + if (typeof entry.service !== "string" || !entry.service.trim()) { + throw new Error(`${configRel}: [[services]] ${entry.binding}: service must be a non-empty string, got ${JSON.stringify(entry.service)}`); + } assertNotRuntimeReservedBinding(configRel, "[[services]]", entry.binding); assertValidBindingName(configRel, "[[services]]", entry.binding); if (entry.entrypoint != null) { @@ -363,10 +374,9 @@ export function parseServicesFromCfg(cfg, configRel = "wrangler config") { ); } } - // `entry.binding` matched BINDING_NAME_RE above, so it is a string here. /** @type {ServiceBinding} */ const normalized = { - binding: /** @type {string} */ (entry.binding), + binding: entry.binding, service: entry.service, }; if (entry.entrypoint != null) normalized.entrypoint = entry.entrypoint; diff --git a/tests/unit/cli-config-doctor.test.js b/tests/unit/cli-config-doctor.test.js index 041a154..fa2c22e 100644 --- a/tests/unit/cli-config-doctor.test.js +++ b/tests/unit/cli-config-doctor.test.js @@ -9,7 +9,7 @@ import { runWhoamiCommand } from "../../commands/whoami.js"; import { main as wdlMain } from "../../bin/wdl.js"; import { resolveCliConfigState } from "../../lib/config-state.js"; import { tokenStorePath, writeTokenStore } from "../../lib/token-store.js"; -import { cliCompatibility, compareSemver } from "../../lib/whoami.js"; +import { cliCompatibility, compareSemver, ensureControlContextFromConfigState } from "../../lib/whoami.js"; import { response } from "./helpers.js"; /** @typedef {{ url: string, init: import("../../lib/control-fetch.js").ControlFetchInit }} ControlCall */ @@ -461,6 +461,38 @@ test("cliCompatibility treats a pre-release CLI as older than the release minimu assert.equal(cliCompatibility("0.9.0", "0.9.0").ok, true); }); +test("ensureControlContextFromConfigState fails closed on an unresolved control URL", () => { + const token = { value: "tok", display: "****", source: "--token", error: null }; + // value:null with no error shouldn't happen, but must never be returned as a string. + assert.throws( + () => ensureControlContextFromConfigState({ + controlUrl: { value: null, display: "(unset)", source: "(unset)", error: null }, + token, + }), + /No control URL configured/ + ); + // An explicit resolver error is surfaced verbatim. + assert.throws( + () => ensureControlContextFromConfigState({ + controlUrl: { value: null, display: "(unset)", source: "--control-url", error: "boom" }, + token, + }), + /boom/ + ); + // A fully resolved state yields the admin-token header. + assert.deepEqual( + ensureControlContextFromConfigState({ + controlUrl: { value: "https://api.example", display: "https://api.example", source: "--control-url", error: null }, + token, + }), + { + controlUrl: "https://api.example", + token: "tok", + headers: { "x-admin-token": "tok" }, + } + ); +}); + test("whoami and doctor warn when the token would travel over plain http to a non-local host", async () => { const whoamiBody = { ok: true, diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index a342621..a97d0ae 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -474,6 +474,17 @@ test("parseServicesFromCfg: parses wrangler [[services]] entries", () => { () => parseServicesFromCfg({ services: [{ binding: "X" }] }), /needs both 'binding' and 'service'/ ); + // A non-string truthy `service` must be rejected, not passed into the manifest. + assert.throws( + () => parseServicesFromCfg({ services: [{ binding: "X", service: 123 }] }), + /service must be a non-empty string/ + ); + // A non-string `binding` (truthy array) must not be String()-coerced past the + // binding-name regex. + assert.throws( + () => parseServicesFromCfg({ services: [{ binding: ["AB"], service: "y" }] }), + /binding must be a non-empty string/ + ); assert.throws( () => parseServicesFromCfg({ services: [{ binding: "X", service: "y", entrypoint: "1bad" }] }), /entrypoint must be a JS identifier/ From 00b0c3a6a23cd10ef25ae67086932b7229d5dca2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 02:21:24 +0000 Subject: [PATCH 05/20] Apply code-review cleanups from the strict conversion Address findings from the post-conversion review (all low-severity): - d1-files.js: restore the empty-message fallback in readMigrationFiles' catch (`err instanceof Error && err.message ? err.message : String(err)`), matching the pattern used elsewhere, so an empty-message Error still prints a reason. - common.js / init.js: drop duplicate stacked JSDoc blocks left by the conversion (formatOption, readWdlCliPackage). - wrangler: consolidate the byte-identical `asRecord` helper (was duplicated in config.js and bindings.js) into its single owner lib/wrangler/utils.js. - tests: share the `ControlCall` typedef from helpers.js instead of redefining it in cli-config-doctor and cli-lifecycle. Behavior-preserving (the catch change restores prior behavior); 375 tests pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- commands/init.js | 3 --- lib/common.js | 4 ---- lib/d1-files.js | 2 +- lib/wrangler/bindings.js | 12 ++---------- lib/wrangler/config.js | 12 +----------- lib/wrangler/utils.js | 11 +++++++++++ tests/unit/cli-config-doctor.test.js | 2 +- tests/unit/cli-lifecycle.test.js | 8 +------- tests/unit/helpers.js | 6 ++++++ 9 files changed, 23 insertions(+), 37 deletions(-) diff --git a/commands/init.js b/commands/init.js index cbe49ea..ce4f513 100644 --- a/commands/init.js +++ b/commands/init.js @@ -257,9 +257,6 @@ async function resolveWranglerDep() { return dep; } -/** - * @returns {Promise<{ version: string, dependencies?: Record }>} - */ /** * @returns {Promise<{ version: string, dependencies?: Record }>} */ diff --git a/lib/common.js b/lib/common.js index 6efe27b..0aa192c 100644 --- a/lib/common.js +++ b/lib/common.js @@ -87,10 +87,6 @@ export function commonCliOptionSpecs({ namespace = true, controlUrl = true, toke const OPTION_HELP_WIDTH = 21; -/** - * @param {string} flag - * @param {string} description - */ /** * @param {string} flag * @param {string | null} description diff --git a/lib/d1-files.js b/lib/d1-files.js index 33860d2..956d05f 100644 --- a/lib/d1-files.js +++ b/lib/d1-files.js @@ -62,7 +62,7 @@ export function readMigrationFiles(dir = "migrations") { try { entries = readdirSync(root, { withFileTypes: true }); } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const message = err instanceof Error && err.message ? err.message : String(err); throw new CliError(`cannot read migrations dir ${dir}: ${message}`); } return entries diff --git a/lib/wrangler/bindings.js b/lib/wrangler/bindings.js index 22eb905..fbad222 100644 --- a/lib/wrangler/bindings.js +++ b/lib/wrangler/bindings.js @@ -11,21 +11,13 @@ import { isValidJsIdentifier, } from "../ns-pattern.js"; +import { asRecord } from "./utils.js"; + /** @typedef {import("./config.js").WranglerConfig} WranglerConfig */ const NS_RE = new RegExp(`^${NS_PATTERN}$`); const MAX_QUEUE_DELAY_SECONDS = 86_400; -/** - * A non-null, non-array object viewed as a string-keyed record, or null. - * @param {unknown} value - * @returns {Record | null} - */ -function asRecord(value) { - return value && typeof value === "object" && !Array.isArray(value) - ? /** @type {Record} */ (value) - : null; -} // UPPER_SNAKE for `as` / `platform` / required_caller_secrets - narrower // than binding names to read as registered identifiers. const PLATFORM_KEY_RE = /^[A-Z_][A-Z0-9_]*$/; diff --git a/lib/wrangler/config.js b/lib/wrangler/config.js index 3f8fd1b..c6bf41b 100644 --- a/lib/wrangler/config.js +++ b/lib/wrangler/config.js @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { parse as parseToml } from "smol-toml"; +import { asRecord } from "./utils.js"; /** * A parsed Wrangler config (`wrangler.toml`/`.jsonc`/`.json`). The CLI never @@ -297,17 +298,6 @@ function listNamedEnvironments(cfg) { return Object.keys(env); } -/** - * A non-null, non-array object viewed as a string-keyed record, or null. - * @param {unknown} value - * @returns {Record | null} - */ -function asRecord(value) { - return value && typeof value === "object" && !Array.isArray(value) - ? /** @type {Record} */ (value) - : null; -} - /** * @param {unknown} value * @returns {boolean} diff --git a/lib/wrangler/utils.js b/lib/wrangler/utils.js index 4bfa8de..9c73b06 100644 --- a/lib/wrangler/utils.js +++ b/lib/wrangler/utils.js @@ -15,3 +15,14 @@ export function manifestMap() { export function hasOwn(obj, key) { return Object.hasOwn(obj, key); } + +/** + * A non-null, non-array object viewed as a string-keyed record, or null. + * @param {unknown} value + * @returns {Record | null} + */ +export function asRecord(value) { + return value && typeof value === "object" && !Array.isArray(value) + ? /** @type {Record} */ (value) + : null; +} diff --git a/tests/unit/cli-config-doctor.test.js b/tests/unit/cli-config-doctor.test.js index fa2c22e..fcfec19 100644 --- a/tests/unit/cli-config-doctor.test.js +++ b/tests/unit/cli-config-doctor.test.js @@ -12,7 +12,7 @@ import { tokenStorePath, writeTokenStore } from "../../lib/token-store.js"; import { cliCompatibility, compareSemver, ensureControlContextFromConfigState } from "../../lib/whoami.js"; import { response } from "./helpers.js"; -/** @typedef {{ url: string, init: import("../../lib/control-fetch.js").ControlFetchInit }} ControlCall */ +/** @typedef {import("./helpers.js").ControlCall} ControlCall */ /** * @template T diff --git a/tests/unit/cli-lifecycle.test.js b/tests/unit/cli-lifecycle.test.js index f8d186b..88f339c 100644 --- a/tests/unit/cli-lifecycle.test.js +++ b/tests/unit/cli-lifecycle.test.js @@ -20,13 +20,7 @@ import { } from "../../lib/control-fetch.js"; import { mockDeps, response } from "./helpers.js"; -/** - * A recorded control-plane call: the URL and the request init the command - * passed to `controlFetch`. `init` is the loose `ControlFetchInit` shape, so - * fields like `method`, `headers`, `body`, `timeoutMs`, and `maxBodyBytes` are - * all optional. - * @typedef {{ url: string, init: import("../../lib/control-fetch.js").ControlFetchInit }} ControlCall - */ +/** @typedef {import("./helpers.js").ControlCall} ControlCall */ /** * The options bag the dispatcher passes to an injected `loadEnv`. Matches the diff --git a/tests/unit/helpers.js b/tests/unit/helpers.js index cda4411..72753c4 100644 --- a/tests/unit/helpers.js +++ b/tests/unit/helpers.js @@ -1,6 +1,12 @@ // Shared fixtures for the CLI unit tests. Not a test file itself (the test // runner only globs cli-*.test.js). +/** + * A recorded control-plane call: the URL and the init passed to controlFetch. + * Shared by the tests that assert on what mockDeps recorded. + * @typedef {{ url: string, init: import("../../lib/control-fetch.js").ControlFetchInit }} ControlCall + */ + // A minimal fetch Response stand-in. Accepts an object (JSON) or string body // and exposes json()/text()/arrayBuffer() so it works for control-plane JSON // responses and R2 streaming/byte tests alike. json() parses the text From f3af8c2dcdafaec3893ce18ca8c4a8c4302cdbe2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 02:31:29 +0000 Subject: [PATCH 06/20] Trim type-checker-mechanics comments from the strict conversion Remove prose that only explained why a JSDoc cast/narrowing was written (deploy.js pack-options cast, command.js dep-merge readback, r2.js stream body narrow, common.js json-reader guard). These describe the type checker, not runtime behavior, and don't match the repo's domain-comment style. Comments stating real runtime invariants are kept. No code or type change; 375 tests pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- commands/deploy.js | 4 ---- commands/r2.js | 2 +- lib/command.js | 2 -- lib/common.js | 3 +-- 4 files changed, 2 insertions(+), 9 deletions(-) diff --git a/commands/deploy.js b/commands/deploy.js index 07de4fa..2fb9317 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -175,10 +175,6 @@ async function runDeploy({ values, positionals, context }) { const { controlUrl, headers: authHeaders } = context.resolveControl(); const selectedEnv = values.env || env.CLOUDFLARE_ENV || null; - // packWranglerProject's parameter types are inferred from its defaults (e.g. - // envName defaults to null, stderr to (_line?) => void), which are narrower - // than the real values passed here; cast the option bag to its declared - // parameter type rather than weaken any of these honest local types. const packOptions = /** @type {Parameters[0]} */ ({ cwd, projectDir, diff --git a/commands/r2.js b/commands/r2.js index 9e94171..f8604c6 100644 --- a/commands/r2.js +++ b/commands/r2.js @@ -97,7 +97,7 @@ async function runR2({ values, positionals, context }) { maxBodyBytes: UNLIMITED_CONTROL_BODY_BYTES, streamResponse: true, }, "get R2 object"); - // streamResponse: true guarantees a body; narrow off the optional type. + // streamResponse: true always yields a body. const responseBody = /** @type {import("node:stream").Readable} */ (res.body); if (values.out) { const bytesWritten = await writeBodyToFile(responseBody, values.out); diff --git a/lib/command.js b/lib/command.js index a9444c7..346b13b 100644 --- a/lib/command.js +++ b/lib/command.js @@ -149,8 +149,6 @@ function buildContext(deps, commandDefaults, values, positionals) { if (!Object.hasOwn(context, key)) context[key] = deps[key]; } - // The dep-merge above produces dynamically-keyed members; read the framework - // members back with their contract types so the closures below are checked. const env = /** @type {NodeJS.ProcessEnv} */ (context.env); const warn = /** @type {(line: string) => void} */ (context.warn); const controlFetch = /** @type {typeof defaultControlFetch} */ (context.controlFetch); diff --git a/lib/common.js b/lib/common.js index 0aa192c..b0cf885 100644 --- a/lib/common.js +++ b/lib/common.js @@ -264,8 +264,7 @@ function isParseArgsError(err) { */ export async function readJsonOrFail(res, label) { await throwHttpErrorIfNotOk(res, label); - // throwHttpErrorIfNotOk only returns for a 2xx response, which always carries - // a json reader; the optional type is for the error-path callers above it. + // A 2xx response always carries a json reader. if (typeof res.json !== "function") throw new CliError(`${label} failed: response is not JSON`); return await res.json(); } From 519b65f34c11354c99a7128e360ae158b5ea2aab Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 02:35:17 +0000 Subject: [PATCH 07/20] Document the strict-typecheck bar for contributors CONTRIBUTING.md and AGENTS.md described `tsc --noEmit` but predated the `strict` flip. Note that typecheck now runs under `strict` and new code needs real JSDoc types (no implicit `any`; `unknown` + narrowing for runtime-validated values). Docs-only; English prose hard-wrapped at 80 per AGENTS.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- AGENTS.md | 5 ++++- CONTRIBUTING.md | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bd43057..31757ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,10 @@ indentation, double quotes, semicolons, and small named functions. Prefer dependency injection for testable command behavior, as seen in `runDeployCommand` and `runSecretCommand`. Use kebab-case CLI flags (`--control-url`) and uppercase environment variables (`ADMIN_TOKEN`, -`CONTROL_URL`, `WDL_NS`). +`CONTROL_URL`, `WDL_NS`). Types are JSDoc, checked by `npm run typecheck` +(`tsc --noEmit`) under `strict`: annotate new parameters and returns with real +types rather than `any`, and use `unknown` plus narrowing for values validated +at runtime. Markdown wrapping is bilingual by design, normalized with Prettier (`--embedded-language-formatting=off`; code blocks are hand-formatted) and kept diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a42e62..1a16f9c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,8 @@ resolution) is exercisable offline. To try commands end to end, point ## Architecture The CLI is plain ESM JavaScript — no build step; `tsc --noEmit` typechecks the -JSDoc types. +JSDoc types under `strict`, so new code needs real JSDoc annotations on +parameters and returns (no implicit `any`). | Path | Role | | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | From ba92e16a13dd79cdc80a96b8477504ec0069523c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 02:40:56 +0000 Subject: [PATCH 08/20] Finish asRecord consolidation and restore a deploy cast note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review of the branch found the asRecord dedup left one inline copy behind in wrangler-pack.js (the assets-config narrow); route it through the shared lib/wrangler/utils.js helper too, so the "non-null non-array object as record" logic now lives in exactly one place. Also restore a one-line rationale on deploy.js's packWranglerProject options cast — the earlier comment trim stripped it bare, unlike the kept one-liners on the r2/common casts. No behavior change; 375 tests pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- commands/deploy.js | 1 + lib/wrangler-pack.js | 7 ++----- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/commands/deploy.js b/commands/deploy.js index 2fb9317..cde26a3 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -175,6 +175,7 @@ async function runDeploy({ values, positionals, context }) { const { controlUrl, headers: authHeaders } = context.resolveControl(); const selectedEnv = values.env || env.CLOUDFLARE_ENV || null; + // Cast past packWranglerProject's defaults-inferred (narrower) param types. const packOptions = /** @type {Parameters[0]} */ ({ cwd, projectDir, diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index 8b88ba9..80b0915 100644 --- a/lib/wrangler-pack.js +++ b/lib/wrangler-pack.js @@ -37,7 +37,7 @@ import { validateUnsupportedWranglerConfig, } from "./wrangler/config.js"; import { collectModules } from "./wrangler/modules.js"; -import { hasOwn, manifestMap } from "./wrangler/utils.js"; +import { asRecord, hasOwn, manifestMap } from "./wrangler/utils.js"; // Keep `wrangler-pack.js` as the stable facade for deploy.js and existing // helper tests. New helper code should import from `./wrangler/.js` @@ -309,10 +309,7 @@ export async function packWranglerProject({ ); } - const assetsCfg = - cfg.assets && typeof cfg.assets === "object" && !Array.isArray(cfg.assets) - ? /** @type {Record} */ (cfg.assets) - : null; + const assetsCfg = asRecord(cfg.assets); const assetsDirRel = assetsCfg ? assetsCfg.directory : undefined; if (assetsDirRel) { const assetsDir = wrapCli(() => From da6ca0aeaa9fe31c13bad74bca64a00b2b258641 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 02:53:54 +0000 Subject: [PATCH 09/20] Apply full-branch review cleanups Address consistency/quality findings from the complete branch review (no correctness bugs were found): - Empty-message catch fallback: align the remaining `err instanceof Error ? err.message : String(err)` sites (config.js, wrangler-pack.js, d1.js x2) with the `&& err.message` form already used in d1-files.js, so an empty-message Error still yields a reason. - Injected-dep typing: deploy.js and r2.js read their command-specific deps (execFile / stdoutStream) via a named CommandContext intersection typedef and a single cast, matching doctor.js/tail.js, instead of casting context through `unknown` (which discarded the rest of the context's typing). - defineCommand: document why `run` uses method syntax (intentional bivariance). - Tests: type the shared mockDeps `calls` as ControlCall[] so cli-d1 no longer needs its RecordedCall/asCall re-narrowing shim; RecordedCall now aliases the shared helper type. Verified the "secret.js re-evaluates isNonEmptyString" finding is a false positive: isNonEmptyString is a `value is string` type predicate, so the inline calls are load-bearing for narrowing values.worker and cannot be replaced with the hasWorker boolean. Types/cleanup only; 375 tests pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- commands/d1.js | 4 ++-- commands/deploy.js | 13 ++++++++----- commands/r2.js | 13 ++++++++----- lib/command.js | 3 +++ lib/wrangler-pack.js | 2 +- lib/wrangler/config.js | 2 +- tests/unit/cli-d1.test.js | 23 +++++++---------------- tests/unit/helpers.js | 4 ++-- 8 files changed, 32 insertions(+), 32 deletions(-) diff --git a/commands/d1.js b/commands/d1.js index f67639b..38a0871 100644 --- a/commands/d1.js +++ b/commands/d1.js @@ -261,7 +261,7 @@ function resolveMigrationsDir({ values, env, cwd, databaseRef }) { try { loaded = loadWranglerConfig(cwd); } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const message = err instanceof Error && err.message ? err.message : String(err); if (message.startsWith("no wrangler.")) { return fallback; } @@ -277,7 +277,7 @@ function resolveMigrationsDir({ values, env, cwd, databaseRef }) { ({ cfg } = resolveWranglerConfig(loaded.cfg, selectedEnv, configRel)); d1Bindings = parseD1DatabasesFromCfg(cfg, configRel); } catch (err) { - throw new CliError(err instanceof Error ? err.message : String(err)); + throw new CliError(err instanceof Error && err.message ? err.message : String(err)); } if (d1Bindings.length === 0) return fallback; diff --git a/commands/deploy.js b/commands/deploy.js index cde26a3..80c52b5 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -159,12 +159,15 @@ export const main = command.main; export const runDeployCommand = command.run; export const meta = command.meta; +/** + * `execFile` is injected via this command's `defaults`. + * @typedef {import("../lib/command.js").CommandContext & { execFile: typeof execFileSync }} DeployContext + */ + /** @param {{ values: { env?: string, verbose?: boolean }, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ -async function runDeploy({ values, positionals, context }) { - const { env, stdout, stderr, cwd } = context; - const { execFile } = /** @type {{ execFile: typeof execFileSync }} */ ( - /** @type {unknown} */ (context) - ); +async function runDeploy({ values, positionals, context: baseContext }) { + const context = /** @type {DeployContext} */ (baseContext); + const { env, stdout, stderr, cwd, execFile } = context; const ns = context.resolveNamespace(); const [projectDir] = positionals; diff --git a/commands/r2.js b/commands/r2.js index f8604c6..9539bca 100644 --- a/commands/r2.js +++ b/commands/r2.js @@ -38,6 +38,11 @@ export const main = command.main; export const runR2Command = command.run; export const meta = command.meta; +/** + * `stdoutStream` is injected via this command's `defaults`. + * @typedef {import("../lib/command.js").CommandContext & { stdoutStream: NodeJS.WritableStream }} R2Context + */ + /** * @param {{ * values: { prefix?: string, delimiter?: string, cursor?: string, limit?: string, out?: string, yes?: boolean, json?: boolean }, @@ -45,11 +50,9 @@ export const meta = command.meta; * context: import("../lib/command.js").CommandContext, * }} arg */ -async function runR2({ values, positionals, context }) { - const { stdout, stderr, stdin } = context; - const { stdoutStream } = /** @type {{ stdoutStream: NodeJS.WritableStream }} */ ( - /** @type {unknown} */ (context) - ); +async function runR2({ values, positionals, context: baseContext }) { + const context = /** @type {R2Context} */ (baseContext); + const { stdout, stderr, stdin, stdoutStream } = context; const [group, action, bucket, key] = positionals; const ns = context.resolveNamespace(); diff --git a/lib/command.js b/lib/command.js index 346b13b..89bd379 100644 --- a/lib/command.js +++ b/lib/command.js @@ -72,6 +72,9 @@ function buildParseOptions(options) { return optionParseOptions(options); } +// `run` below uses method syntax (not `run: (ctx) => …`) on purpose: it makes +// the param bivariant, so a command can declare a narrowed `values` shape (e.g. +// `{ env?: string }`) and still satisfy this `Record` slot. /** * @param {{ * name: string, diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index 80b0915..ca44af8 100644 --- a/lib/wrangler-pack.js +++ b/lib/wrangler-pack.js @@ -381,7 +381,7 @@ function wrapCli(fn) { return fn(); } catch (err) { if (err instanceof CliError) throw err; - const message = err instanceof Error ? err.message : String(err); + const message = err instanceof Error && err.message ? err.message : String(err); throw new CliError(message); } } diff --git a/lib/wrangler/config.js b/lib/wrangler/config.js index c6bf41b..2dfed5b 100644 --- a/lib/wrangler/config.js +++ b/lib/wrangler/config.js @@ -89,7 +89,7 @@ export function loadWranglerConfig(dir) { if (name.endsWith(".jsonc")) return { path: p, cfg: parseJsonc(raw) }; return { path: p, cfg: JSON.parse(raw) }; } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const message = err instanceof Error && err.message ? err.message : String(err); throw new Error(`failed to parse ${name}: ${message}`, { cause: err }); } } diff --git a/tests/unit/cli-d1.test.js b/tests/unit/cli-d1.test.js index c5fd3e5..7caedff 100644 --- a/tests/unit/cli-d1.test.js +++ b/tests/unit/cli-d1.test.js @@ -8,16 +8,7 @@ import { LONG_CONTROL_TIMEOUT_MS } from "../../lib/control-fetch.js"; import { mockDeps as sharedMockDeps, response } from "./helpers.js"; /** @typedef {import("../../lib/control-fetch.js").ControlFetchInit} ControlFetchInit */ -/** @typedef {{ url: string, init: ControlFetchInit }} RecordedCall */ - -// The shared mockDeps types recorded `init` as the broad `object`; the d1 tests -// read concrete request fields (method/body/headers/timeoutMs), so view a -// recorded call through the control-fetch init shape it actually carries. -/** - * @param {{ url: string, init: object }} call - * @returns {RecordedCall} - */ -const asCall = (call) => /** @type {RecordedCall} */ (call); +/** @typedef {import("./helpers.js").ControlCall} RecordedCall */ // Request bodies in these tests are always JSON strings; narrow the broader // `body` union before parsing. @@ -58,7 +49,7 @@ test("d1 list calls the namespace database endpoint", async () => { await runD1Command(["list", "--control-url", "http://ctl.test"], deps); assert.equal(calls[0].url, "http://ctl.test/ns/demo/d1/databases"); - assert.deepEqual(asCall(calls[0]).init.headers, { "x-admin-token": "tok" }); + assert.deepEqual(calls[0].init.headers, { "x-admin-token": "tok" }); assert.deepEqual(lines, ["d1_main\tname=main\tcreated=today"]); }); @@ -99,8 +90,8 @@ test("d1 create posts a database name", async () => { await runD1Command(["create", "main", "--control-url", "http://ctl.test"], deps); - assert.equal(asCall(calls[0]).init.method, "POST"); - assert.deepEqual(parseBody(asCall(calls[0]).init.body), { databaseName: "main" }); + assert.equal(calls[0].init.method, "POST"); + assert.deepEqual(parseBody(calls[0].init.body), { databaseName: "main" }); assert.deepEqual(lines, ["OK demo/d1_main created name=main"]); }); @@ -121,9 +112,9 @@ test("d1 execute sends SQL mode and JSON params", async () => { ], deps); assert.equal(calls[0].url, "http://ctl.test/ns/demo/d1/databases/main/query"); - assert.equal(asCall(calls[0]).init.method, "POST"); - assert.equal(asCall(calls[0]).init.timeoutMs, LONG_CONTROL_TIMEOUT_MS); - assert.deepEqual(parseBody(asCall(calls[0]).init.body), { + assert.equal(calls[0].init.method, "POST"); + assert.equal(calls[0].init.timeoutMs, LONG_CONTROL_TIMEOUT_MS); + assert.deepEqual(parseBody(calls[0].init.body), { sql: "select ? as n", mode: "all", params: [1], diff --git a/tests/unit/helpers.js b/tests/unit/helpers.js index 72753c4..c5e3da4 100644 --- a/tests/unit/helpers.js +++ b/tests/unit/helpers.js @@ -38,7 +38,7 @@ export function response(body, status = 200) { * @param {NodeJS.ProcessEnv} [env] */ export function mockDeps(body, env = { ADMIN_TOKEN: "tok" }) { - /** @type {Array<{ url: string, init: object }>} */ + /** @type {ControlCall[]} */ const calls = []; /** @type {string[]} */ const lines = []; @@ -49,7 +49,7 @@ export function mockDeps(body, env = { ADMIN_TOKEN: "tok" }) { env, /** @param {string} line */ stdout: (line) => lines.push(line), - /** @param {string} url @param {object} [init] */ + /** @param {string} url @param {import("../../lib/control-fetch.js").ControlFetchInit} [init] */ controlFetch: async (url, init = {}) => { calls.push({ url, init }); return response(body); From ee8c9722fd95b498bc6987164d4f94980c3341b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 03:01:20 +0000 Subject: [PATCH 10/20] Drop a leftover type-mechanics comment in deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deploy pack-options cast carried a comment describing the type system (defaults-inferred narrower param types), not runtime behavior — the same category trimmed earlier. The `/** @type {Parameters<...>[0]} */` cast already reads as deliberate, so remove the note. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- commands/deploy.js | 1 - 1 file changed, 1 deletion(-) diff --git a/commands/deploy.js b/commands/deploy.js index 80c52b5..bd2f272 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -178,7 +178,6 @@ async function runDeploy({ values, positionals, context: baseContext }) { const { controlUrl, headers: authHeaders } = context.resolveControl(); const selectedEnv = values.env || env.CLOUDFLARE_ENV || null; - // Cast past packWranglerProject's defaults-inferred (narrower) param types. const packOptions = /** @type {Parameters[0]} */ ({ cwd, projectDir, From e0aa93940981a2c1ae242394839624f66231086c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 03:18:38 +0000 Subject: [PATCH 11/20] Fix phantom `control` flag in tail/doctor values JSDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `values` JSDoc for tail and doctor listed `control?: string`, but there is no `--control` flag — both commands use the `control` option preset, which provides `--control-url`, `--token`, and `--no-token-store`. Replace the phantom field with the real flag names so the strict JSDoc reflects the actual parsed shape. Per PR review feedback. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- commands/doctor.js | 2 +- commands/tail.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/commands/doctor.js b/commands/doctor.js index 699910b..5985c82 100644 --- a/commands/doctor.js +++ b/commands/doctor.js @@ -43,7 +43,7 @@ export const meta = command.meta; * @typedef {import("../lib/command.js").CommandContext & { execFile: typeof execFileSync }} DoctorContext */ -/** @param {{ values: { ns?: string, control?: string, json?: boolean }, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ +/** @param {{ values: { ns?: string, "control-url"?: string, token?: string, "no-token-store"?: boolean, json?: boolean }, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ async function runDoctor({ values, positionals, context: baseContext }) { if (positionals.length > 0) throw new CliError(usageText()); diff --git a/commands/tail.js b/commands/tail.js index df62b77..1372e32 100644 --- a/commands/tail.js +++ b/commands/tail.js @@ -113,7 +113,7 @@ export const meta = command.meta; * }} TailContext */ -/** @param {{ values: { raw?: boolean, since?: string, "max-reconnects"?: string, ns?: string, control?: string }, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ +/** @param {{ values: { raw?: boolean, since?: string, "max-reconnects"?: string, ns?: string, "control-url"?: string, token?: string, "no-token-store"?: boolean }, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ async function runTail({ values, positionals, context: baseContext }) { const context = /** @type {TailContext} */ (baseContext); const { stdout, stderr, transport, sleepFn, now } = context; From 8a4aa84304f0631301df4026649490cb8746945b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 03:27:51 +0000 Subject: [PATCH 12/20] Fail fast on a non-array `routes` in collectRoutes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit collectRoutes only iterated `cfg.routes` when it was an array, so a mistakenly non-array `routes` (string/object) was silently dropped and the worker would deploy with no routes. Reject it with a clear error instead, matching the "map or loudly reject — silent drops are bugs" contract the other wrangler parsers follow. Export collectRoutes and add focused coverage. Per PR review feedback. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- lib/wrangler-pack.js | 9 +++++++-- tests/unit/cli-deploy.test.js | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index ca44af8..e8ec8cc 100644 --- a/lib/wrangler-pack.js +++ b/lib/wrangler-pack.js @@ -348,7 +348,7 @@ export async function packWranglerProject({ * @param {string} configRel * @returns {string[]} */ -function collectRoutes(cfg, configRel) { +export function collectRoutes(cfg, configRel) { /** @type {string[]} */ const collected = []; /** @@ -365,7 +365,12 @@ function collectRoutes(cfg, configRel) { throw new CliError(`${configRel}: specify either "route" or "routes", not both`); } if (cfg.route !== undefined) pushEntry(cfg.route, "route"); - if (Array.isArray(cfg.routes)) { + if (cfg.routes !== undefined) { + // Loudly reject a non-array `routes` rather than silently dropping it (a + // worker would deploy with no routes). Matches the other parsers' contract. + if (!Array.isArray(cfg.routes)) { + throw new CliError(`${configRel}: "routes" must be an array of strings or { pattern } tables`); + } for (const r of cfg.routes) pushEntry(r, "routes"); } return collected; diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index a97d0ae..46624fe 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -11,6 +11,7 @@ import { import { collectAssets, collectModules, + collectRoutes, loadWranglerConfig, MAX_ASSET_FILE_BYTES, parseD1DatabasesFromCfg, @@ -449,6 +450,28 @@ test("parseDurableObjectsFromCfg: rejects runtime-internal binding names", () => ); }); +test("collectRoutes: accepts strings and { pattern } tables, rejects non-arrays", () => { + assert.deepEqual(collectRoutes({}, "wrangler.toml"), []); + assert.deepEqual(collectRoutes({ route: "dev.example.com/*" }, "wrangler.toml"), ["dev.example.com/*"]); + assert.deepEqual( + collectRoutes({ routes: ["a.example.com/*", { pattern: "b.example.com/*" }] }, "wrangler.toml"), + ["a.example.com/*", "b.example.com/*"] + ); + // A non-array `routes` must fail fast, not be silently dropped. + assert.throws( + () => collectRoutes({ routes: "a.example.com/*" }, "wrangler.toml"), + /"routes" must be an array/ + ); + assert.throws( + () => collectRoutes({ routes: { pattern: "a.example.com/*" } }, "wrangler.toml"), + /"routes" must be an array/ + ); + assert.throws( + () => collectRoutes({ route: "a", routes: ["b"] }, "wrangler.toml"), + /specify either "route" or "routes"/ + ); +}); + test("parseServicesFromCfg: parses wrangler [[services]] entries", () => { assert.deepEqual(parseServicesFromCfg({}), []); assert.deepEqual(parseServicesFromCfg({ services: [] }), []); From d97835180a1e64f4c8de8cd4cac003deefce2439 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 03:40:24 +0000 Subject: [PATCH 13/20] Validate assets.directory as a non-empty string `assets.directory` was read as `unknown` and passed to resolveAssetsDir on a truthiness check, so `directory: ""` was silently ignored and a non-string value hit path.resolve as a low-level TypeError. Validate it in resolveAssetsDir (the exported, tested seam): reject a missing/empty/non-string value with a clear `assets.directory must be a non-empty string` error, and gate the caller on "present" rather than truthy so an empty/invalid value fails loud instead of being dropped. Add focused coverage. Per PR review feedback. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- lib/wrangler-pack.js | 9 ++++----- lib/wrangler/assets.js | 7 ++++++- tests/unit/cli-deploy.test.js | 14 ++++++++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index e8ec8cc..997c49b 100644 --- a/lib/wrangler-pack.js +++ b/lib/wrangler-pack.js @@ -311,11 +311,10 @@ export async function packWranglerProject({ const assetsCfg = asRecord(cfg.assets); const assetsDirRel = assetsCfg ? assetsCfg.directory : undefined; - if (assetsDirRel) { - const assetsDir = wrapCli(() => - // assets.directory is a config path; resolveAssetsDir validates it on disk. - resolveAssetsDir(absProject, /** @type {string} */ (assetsDirRel), configRel) - ); + // A present `directory` (even "" or a non-string) is validated and resolved; + // resolveAssetsDir rejects a malformed value and checks the path on disk. + if (assetsDirRel !== undefined) { + const assetsDir = wrapCli(() => resolveAssetsDir(absProject, assetsDirRel, configRel)); /** @type {string[]} */ const skippedAssets = []; const assets = wrapCli(() => diff --git a/lib/wrangler/assets.js b/lib/wrangler/assets.js index cbdfe88..f038e63 100644 --- a/lib/wrangler/assets.js +++ b/lib/wrangler/assets.js @@ -26,11 +26,16 @@ const DEFAULT_ASSET_IGNORE_PATTERNS = [ /** * @param {string} absProject - * @param {string} assetsDirRel + * @param {unknown} assetsDirRel Raw config value; validated as a non-empty string here. * @param {string} [configRel] * @returns {string} */ export function resolveAssetsDir(absProject, assetsDirRel, configRel = "wrangler config") { + // Fail loudly on a malformed `assets.directory` instead of letting a non-string + // hit path.resolve as a low-level TypeError, or an empty string be ignored. + if (typeof assetsDirRel !== "string" || assetsDirRel.trim() === "") { + throw new Error(`${configRel} assets.directory must be a non-empty string`); + } const assetsDir = path.resolve(absProject, assetsDirRel); if (!existsSync(assetsDir)) { throw new Error(`${configRel} assets.directory "${assetsDirRel}" not found`); diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 46624fe..5348f2f 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -803,6 +803,20 @@ test("collectAssets reports ignored entries via onIgnore, excluding .assetsignor } }); +test("resolveAssetsDir: rejects a missing, empty, or non-string assets.directory", () => { + const project = mkdtempSync(path.join(tmpdir(), "wdl-assets-dir-type-")); + try { + for (const bad of ["", " ", 123, true, ["public"], { directory: "public" }, null, undefined]) { + assert.throws( + () => resolveAssetsDir(project, bad), + /assets\.directory must be a non-empty string/ + ); + } + } finally { + rmSync(project, { recursive: true, force: true }); + } +}); + test("resolveAssetsDir: rejects assets.directory that escapes project root", () => { const parent = mkdtempSync(path.join(tmpdir(), "wdl-assets-escape-")); const project = path.join(parent, "proj"); From 1fade065cf0d586b71af6017483721a09cdac4b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 04:52:56 +0000 Subject: [PATCH 14/20] Trim a redundant comment on the assets.directory gate The caller comment restated what resolveAssetsDir already documents (it validates the value and checks the path on disk). Keep only the non-obvious bit: why the gate is `!== undefined` rather than truthy. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- lib/wrangler-pack.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index 997c49b..8f350f4 100644 --- a/lib/wrangler-pack.js +++ b/lib/wrangler-pack.js @@ -311,8 +311,8 @@ export async function packWranglerProject({ const assetsCfg = asRecord(cfg.assets); const assetsDirRel = assetsCfg ? assetsCfg.directory : undefined; - // A present `directory` (even "" or a non-string) is validated and resolved; - // resolveAssetsDir rejects a malformed value and checks the path on disk. + // Gate on "present", not truthy, so an empty/malformed directory reaches the + // validator instead of being silently skipped. if (assetsDirRel !== undefined) { const assetsDir = wrapCli(() => resolveAssetsDir(absProject, assetsDirRel, configRel)); /** @type {string[]} */ From 8c9e5fafd83c937494f30fd2afe0f9626e94a8ad Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 05:23:02 +0000 Subject: [PATCH 15/20] Use a nullish presence check in parseServicesFromCfg `if (!entry.binding || !entry.service)` treated an empty string as missing, so `binding: ""` got the generic "needs both" error instead of the specific non-empty-string validation just below. Switch to a nullish check so a present-but-empty/invalid value falls through to the clearer error; genuinely missing fields still report "needs both". Add coverage for the empty-string case. Per PR review feedback. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- lib/wrangler/bindings.js | 5 ++++- tests/unit/cli-deploy.test.js | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/wrangler/bindings.js b/lib/wrangler/bindings.js index fbad222..38855f9 100644 --- a/lib/wrangler/bindings.js +++ b/lib/wrangler/bindings.js @@ -332,9 +332,12 @@ export function parseServicesFromCfg(cfg, configRel = "wrangler config") { if (!entry) { throw new Error(`${configRel}: [[services]] entry must be a table`); } - if (!entry.binding || !entry.service) { + if (entry.binding == null || entry.service == null) { throw new Error(`${configRel}: [[services]] entry needs both 'binding' and 'service'`); } + // Nullish (not truthy) above so a present-but-empty/invalid value falls + // through to the specific non-empty-string errors below rather than the + // generic "needs both" message. // Enforce string types like the d1/r2 parsers do. Without this a non-string // truthy `service` flows unchanged into the deploy manifest, and a non-string // `binding` (e.g. ["AB"]) would be silently String()-coerced past the diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 5348f2f..9898bc9 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -497,6 +497,15 @@ test("parseServicesFromCfg: parses wrangler [[services]] entries", () => { () => parseServicesFromCfg({ services: [{ binding: "X" }] }), /needs both 'binding' and 'service'/ ); + // A present-but-empty value gets the specific non-empty-string error, not "needs both". + assert.throws( + () => parseServicesFromCfg({ services: [{ binding: "", service: "y" }] }), + /binding must be a non-empty string/ + ); + assert.throws( + () => parseServicesFromCfg({ services: [{ binding: "X", service: "" }] }), + /service must be a non-empty string/ + ); // A non-string truthy `service` must be rejected, not passed into the manifest. assert.throws( () => parseServicesFromCfg({ services: [{ binding: "X", service: 123 }] }), From f2acd9315877d6c3c01ce782ad81f093b7869ec1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 05:42:14 +0000 Subject: [PATCH 16/20] Compute the worker-presence predicate once in secret.js secret.js called isNonEmptyString(values.worker) three times (for hasWorker and again when building secretPath/scopeLabel). The repeats were there to re-narrow values.worker to a string, since hasWorker is a plain boolean. Narrow once into a `worker` (string | null) const and derive hasWorker from it, so the predicate lives in one place and secretPath/scopeLabel use the narrowed value directly. No behavior change; 377 tests pass. Per PR review feedback. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- commands/secret.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/commands/secret.js b/commands/secret.js index 70c87de..f54c2e2 100644 --- a/commands/secret.js +++ b/commands/secret.js @@ -58,7 +58,10 @@ async function runSecret({ values, positionals, context }) { throw new CliError(usageText()); } - const hasWorker = isNonEmptyString(values.worker); + // Narrow values.worker to a string once; reuse `worker`/`hasWorker` below so + // the worker-presence predicate lives in exactly one place. + const worker = isNonEmptyString(values.worker) ? values.worker : null; + const hasWorker = worker !== null; const hasScopeNs = values.scope === "ns"; if (hasWorker && hasScopeNs) { throw new CliError("conflicting flags: --worker and --scope ns are mutually exclusive"); @@ -71,10 +74,8 @@ async function runSecret({ values, positionals, context }) { } const { headers } = context.resolveControl(); - const secretPath = isNonEmptyString(values.worker) - ? ["worker", values.worker, "secrets"] - : ["secrets"]; - const scopeLabel = isNonEmptyString(values.worker) ? `${ns}/${values.worker}` : `${ns} (ns)`; + const secretPath = worker ? ["worker", worker, "secrets"] : ["secrets"]; + const scopeLabel = worker ? `${ns}/${worker}` : `${ns} (ns)`; if (subcommand === "list") { const body = /** @type {SecretResponse} */ (await context.fetchJson(context.nsUrl(...secretPath), { headers }, "list")); From 604bbb73f9d23b300378fd3a48dd90fec7d04752 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 06:12:13 +0000 Subject: [PATCH 17/20] Merge two stacked comment blocks in parseServicesFromCfg The nullish-check rationale and the string-type-check rationale had become two adjacent comment blocks on consecutive lines. Combine them into one. Comment-only. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- lib/wrangler/bindings.js | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/lib/wrangler/bindings.js b/lib/wrangler/bindings.js index 38855f9..5742c37 100644 --- a/lib/wrangler/bindings.js +++ b/lib/wrangler/bindings.js @@ -335,13 +335,11 @@ export function parseServicesFromCfg(cfg, configRel = "wrangler config") { if (entry.binding == null || entry.service == null) { throw new Error(`${configRel}: [[services]] entry needs both 'binding' and 'service'`); } - // Nullish (not truthy) above so a present-but-empty/invalid value falls - // through to the specific non-empty-string errors below rather than the - // generic "needs both" message. - // Enforce string types like the d1/r2 parsers do. Without this a non-string - // truthy `service` flows unchanged into the deploy manifest, and a non-string - // `binding` (e.g. ["AB"]) would be silently String()-coerced past the - // BINDING_NAME_RE check below. + // Nullish (not truthy) above, so a present-but-empty/invalid value reaches + // these string checks instead of the generic "needs both" error. They match + // the d1/r2 parsers: without them a non-string truthy `service` flows into + // the manifest, and a non-string `binding` (e.g. ["AB"]) would be silently + // String()-coerced past the BINDING_NAME_RE check below. if (typeof entry.binding !== "string" || !entry.binding.trim()) { throw new Error(`${configRel}: [[services]] binding must be a non-empty string, got ${JSON.stringify(entry.binding)}`); } From 3acfb499beb8f33934ccbf2580379da9d605b0d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 15:48:25 +0000 Subject: [PATCH 18/20] Annotate empty Map/Set containers to avoid implicit any Empty `new Map()` / `new Set()` infer to `Map` / `Set` (unlike array literals, they don't evolve from later mutations), which undercut the no-`any` goal even though strict doesn't flag them. Give them explicit element types: config-state's `sources` (Map), uniquePaths' `seen`/`out` (Set / string[]), and the credentials-test `protectedKeys` fixtures (Set). Containers already in a typed argument position keep their contextual type. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- lib/config-state.js | 1 + lib/wrangler/command.js | 2 ++ tests/unit/cli-credentials.test.js | 5 +++++ 3 files changed, 8 insertions(+) diff --git a/lib/config-state.js b/lib/config-state.js index bfe6dff..fe8439a 100644 --- a/lib/config-state.js +++ b/lib/config-state.js @@ -23,6 +23,7 @@ export function resolveCliConfigState({ values = {}, env = process.env, cwd = pr // The loader's helper, not a raw Object.keys set: diagnostics must resolve what // an operating command would. See protectedEnvKeys. const protectedKeys = protectedEnvKeys(env); + /** @type {Map} */ const sources = new Map(); for (const key of Object.keys(env)) sources.set(key, `${key} env`); diff --git a/lib/wrangler/command.js b/lib/wrangler/command.js index 60abf00..e35ad78 100644 --- a/lib/wrangler/command.js +++ b/lib/wrangler/command.js @@ -154,7 +154,9 @@ export function parseWranglerMajorVersion(output) { * @returns {string[]} */ function uniquePaths(paths) { + /** @type {Set} */ const seen = new Set(); + /** @type {string[]} */ const out = []; for (const p of paths) { if (!p) continue; diff --git a/tests/unit/cli-credentials.test.js b/tests/unit/cli-credentials.test.js index cd0f8d4..1f60061 100644 --- a/tests/unit/cli-credentials.test.js +++ b/tests/unit/cli-credentials.test.js @@ -219,6 +219,7 @@ test("loadCliDotEnv supports section-only files", () => { ); const env = emptyEnv(); + /** @type {Set} */ const protectedKeys = new Set(); assert.deepEqual(loadCliDotEnv(env, file, { protectedKeys }), []); assert.deepEqual(loadCliDotEnv(env, file, { resolvedNs: "demo", loadBase: false, protectedKeys }), [ @@ -250,6 +251,7 @@ test("loadCliDotEnv switches adjacent sections without blank lines", () => { ); const env = emptyEnv(); + /** @type {Set} */ const protectedKeys = new Set(); loadCliDotEnv(env, file, { protectedKeys }); assert.deepEqual(loadCliDotEnv(env, file, { @@ -363,6 +365,7 @@ test("loadCliDotEnv accepts opaque operator reserved namespace sections", () => ); const env = emptyEnv(); + /** @type {Set} */ const protectedKeys = new Set(); loadCliDotEnv(env, file, { protectedKeys }); loadCliDotEnv(env, file, { resolvedNs: resolveNamespace({}, env), loadBase: false, protectedKeys }); @@ -407,6 +410,7 @@ test("loadCliDotEnv ignores WDL_NS in selected section with a warning", () => { /** @type {string[]} */ const warnings = []; const env = emptyEnv(); + /** @type {Set} */ const protectedKeys = new Set(); loadCliDotEnv(env, file, { protectedKeys }); assert.deepEqual(loadCliDotEnv(env, file, { @@ -443,6 +447,7 @@ test("loadCliDotEnv does not warn for WDL_NS in an unselected section", () => { /** @type {string[]} */ const warnings = []; const env = emptyEnv(); + /** @type {Set} */ const protectedKeys = new Set(); loadCliDotEnv(env, file, { protectedKeys }); assert.deepEqual(loadCliDotEnv(env, file, { From 58dc33e5de036218a76809e9da98c522b5959801 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 00:47:24 +0000 Subject: [PATCH 19/20] Replace the last three `any` casts in tests with unknown/Record The "zero any" claim missed three `/** @type {any} */` casts in test files, all reading an off-type property on a probe value: the prototype-pollution checks in cli-deploy and cli-token-store now cast to `Record`, and the partial stdin stub in cli-stdin casts through `unknown` to `StdinLike`. No more `any` anywhere in the tree. Per PR review feedback. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- tests/unit/cli-deploy.test.js | 2 +- tests/unit/cli-stdin.test.js | 2 +- tests/unit/cli-token-store.test.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 9898bc9..396581d 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -1042,7 +1042,7 @@ test("resolveWranglerConfig drops __proto__ keys instead of rewriting the merged ); const { cfg } = resolveWranglerConfig(rawCfg, "prod", "wrangler.jsonc"); assert.equal(Object.getPrototypeOf(cfg), Object.prototype); - assert.equal(/** @type {any} */ (cfg).polluted, undefined); + assert.equal(/** @type {Record} */ (cfg).polluted, undefined); assert.deepEqual(cfg.vars, { A: "1" }); }); diff --git a/tests/unit/cli-stdin.test.js b/tests/unit/cli-stdin.test.js index 38f7f7c..aee9a31 100644 --- a/tests/unit/cli-stdin.test.js +++ b/tests/unit/cli-stdin.test.js @@ -96,7 +96,7 @@ test("readTtyLine escapes terminal controls in the prompt at the write point", a test("confirmAction escapes terminal controls in its refusal message", async () => { const esc = String.fromCharCode(27); await assert.rejects( - () => confirmAction({ stdin: /** @type {any} */ ({ isTTY: false }), action: `delete ${esc}[2J thing` }), + () => confirmAction({ stdin: /** @type {import("../../lib/stdin.js").StdinLike} */ (/** @type {unknown} */ ({ isTTY: false })), action: `delete ${esc}[2J thing` }), (err) => { assert.doesNotMatch(/** @type {Error} */ (err).message, new RegExp(esc), "raw ESC must not be in the refusal error"); assert.match(/** @type {Error} */ (err).message, /Refusing to delete/); diff --git a/tests/unit/cli-token-store.test.js b/tests/unit/cli-token-store.test.js index 7685d3f..385f258 100644 --- a/tests/unit/cli-token-store.test.js +++ b/tests/unit/cli-token-store.test.js @@ -149,7 +149,7 @@ test("handles a __proto__ section without polluting the prototype", () => { 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"); + assert.equal(/** @type {Record} */ ({}).ADMIN_TOKEN, undefined, "Object.prototype not polluted"); }); }); From a53ffa0e395905e039ca79526fa618a58a47646a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 01:03:26 +0000 Subject: [PATCH 20/20] Validate cfg.name as a non-empty string in packWranglerProject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packWranglerProject declares it returns `workerName: string` but only truthiness-checked `cfg.name`, then asserted it to string. The dry-run bundle runs against a sanitized temp name, so Wrangler never validates the original cfg.name — a non-string truthy value would be returned as a bogus string workerName. Check `typeof === "string"` and non-empty, which also lets the return drop its `/** @type {string} */` cast. Per PR review feedback. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W9ZkKKDBaExknoWiERCDen --- lib/wrangler-pack.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index 8f350f4..a715379 100644 --- a/lib/wrangler-pack.js +++ b/lib/wrangler-pack.js @@ -152,7 +152,12 @@ export async function packWranglerProject({ validateUnsupportedWranglerConfig(rawCfg, selectedEnv, configRel); return resolveWranglerConfig(rawCfg, selectedEnv, configRel); }); - if (!cfg.name) throw new CliError(`${configRel} missing 'name'`); + // Validate the type, not just truthiness: the dry-run bundle uses a sanitized + // temp name, so Wrangler never checks the original cfg.name — a non-string + // would otherwise be asserted as the string workerName below. + if (typeof cfg.name !== "string" || !cfg.name.trim()) { + throw new CliError(`${configRel}: 'name' must be a non-empty string`); + } if (!cfg.main) throw new CliError(`${configRel} missing 'main'`); const bindings = manifestMap(); @@ -338,8 +343,7 @@ export async function packWranglerProject({ if (Object.keys(assets).length) manifest.assets = assets; } - // `name` was asserted present above and is a string by wrangler's schema. - return { absProject, workerName: /** @type {string} */ (cfg.name), manifest }; + return { absProject, workerName: cfg.name, manifest }; } /**