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 | | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 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..38a0871 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 ? 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 ? 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..bd2f272 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,8 +159,14 @@ 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 */ -async function runDeploy({ values, positionals, context }) { +/** + * `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: baseContext }) { + const context = /** @type {DeployContext} */ (baseContext); const { env, stdout, stderr, cwd, execFile } = context; const ns = context.resolveNamespace(); @@ -139,7 +178,7 @@ 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({ + const packOptions = /** @type {Parameters[0]} */ ({ cwd, projectDir, envName: selectedEnv, @@ -149,6 +188,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..5985c82 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-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()); + 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..ce4f513 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,26 @@ async function resolveWranglerDep() { return dep; } +/** + * @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 +287,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 +322,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..9539bca 100644 --- a/commands/r2.js +++ b/commands/r2.js @@ -38,9 +38,21 @@ 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 */ -async function runR2({ values, positionals, context }) { - const { stdout, stdoutStream, stderr, stdin } = context; +/** + * `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 }, + * positionals: string[], + * context: import("../lib/command.js").CommandContext, + * }} arg + */ +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(); @@ -49,7 +61,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 +69,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 +84,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 +100,13 @@ async function runR2({ values, positionals, context }) { maxBodyBytes: UNLIMITED_CONTROL_BODY_BYTES, streamResponse: true, }, "get R2 object"); + // streamResponse: true always yields a body. + const responseBody = /** @type {import("node:stream").Readable} */ (res.body); if (values.out) { - 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 +124,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 +138,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 +153,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 +166,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 +177,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 +189,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 +227,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 +286,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..f54c2e2 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; @@ -39,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"); @@ -52,14 +74,12 @@ async function runSecret({ values, positionals, context }) { } const { headers } = context.resolveControl(); - const secretPath = hasWorker - ? ["worker", values.worker, "secrets"] - : ["secrets"]; - const scopeLabel = hasWorker ? `${ns}/${values.worker}` : `${ns} (ns)`; + const secretPath = worker ? ["worker", worker, "secrets"] : ["secrets"]; + const scopeLabel = worker ? `${ns}/${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 +93,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 +122,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 +146,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..1372e32 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-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; // 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 0fac34f..34c3248 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,12 @@ 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 {string} 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..89bd379 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,19 +67,23 @@ function standardDefaults() { } // options is a list mixing preset names and option specs. +/** @param {Iterable} options */ 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, * summary: string, - * options?: Array, + * options?: Array, * 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 } }} */ @@ -119,13 +123,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 +152,32 @@ function buildContext(deps, commandDefaults, values, positionals) { if (!Object.hasOwn(context, key)) context[key] = deps[key]; } + 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 +186,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..b0cf885 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,21 @@ export function commonCliOptionSpecs({ namespace = true, controlUrl = true, toke const OPTION_HELP_WIDTH = 21; +/** + * @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 +109,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 +141,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 +164,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 +184,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 +225,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 +237,42 @@ 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); + // 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(); } +/** + * @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 +292,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 +312,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 +327,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 +346,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 +361,7 @@ function formatContextValue(value) { return escaped; } +/** @param {string} segment */ export function encodePath(segment) { return encodeURIComponent(segment); } @@ -255,12 +369,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..fe8439a 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, @@ -18,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`); @@ -43,7 +49,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 +76,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 +100,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 +113,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..ddb40b4 100644 --- a/lib/control-fetch.js +++ b/lib/control-fetch.js @@ -15,6 +15,71 @@ 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 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) => ControlClientRequest }} 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 +93,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 +105,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 +114,7 @@ export function controlFetch(urlStr, init = {}) { reject(err); }; + /** @param {Error} err */ const failStream = (err) => { cleanup(); req.destroy(err); @@ -52,6 +122,7 @@ export function controlFetch(urlStr, init = {}) { if (streamBody) streamBody.destroy(err); }; + /** @param {Error} err */ const failRequestOrStream = (err) => { if (streamRes) { failStream(err); @@ -80,15 +151,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 +183,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 +204,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 +220,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 +240,30 @@ 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 {object} ControlBodySource + * @property {(event: string, listener: (...args: A) => void) => unknown} on + * @property {number} [statusCode] + * @property {import("node:http").IncomingHttpHeaders} [headers] + * @property {() => void} [destroy] + */ + +/** + * @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 +281,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/d1-files.js b/lib/d1-files.js index 3ae6862..956d05f 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 ? 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/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/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/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)); } diff --git a/lib/whoami.js b/lib/whoami.js index 4f66535..de38e08 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,12 +180,28 @@ 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); + // 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: state.controlUrl.value, @@ -116,6 +210,10 @@ export function ensureControlContextFromConfigState(state) { }; } +/** + * @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..7b58ac9 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 | null} [activeVersion] null when the worker has no deployed version. + * @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..a715379 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` @@ -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, @@ -116,13 +152,20 @@ 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(); // 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 +195,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 +221,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 +246,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 +290,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 +314,13 @@ export async function packWranglerProject({ ); } - const assetsDirRel = cfg.assets && cfg.assets.directory; - if (assetsDirRel) { - const assetsDir = wrapCli(() => - resolveAssetsDir(absProject, assetsDirRel, configRel) - ); + const assetsCfg = asRecord(cfg.assets); + const assetsDirRel = assetsCfg ? assetsCfg.directory : undefined; + // 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[]} */ const skippedAssets = []; const assets = wrapCli(() => collectAssets(assetsDir, { @@ -296,29 +346,50 @@ export async function packWranglerProject({ return { absProject, workerName: cfg.name, manifest }; } -function collectRoutes(cfg, configRel) { +/** + * @param {import("./wrangler/config.js").WranglerConfig} cfg + * @param {string} configRel + * @returns {string[]} + */ +export 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`); } 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; } +/** + * @template T + * @param {() => T} fn + * @returns {T} + */ function wrapCli(fn) { try { 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/assets.js b/lib/wrangler/assets.js index 73ca371..f038e63 100644 --- a/lib/wrangler/assets.js +++ b/lib/wrangler/assets.js @@ -24,7 +24,18 @@ const DEFAULT_ASSET_IGNORE_PATTERNS = [ "**/.env.*", ]; +/** + * @param {string} absProject + * @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`); @@ -49,6 +60,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 +125,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 +144,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 +167,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 +197,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 +249,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..5742c37 100644 --- a/lib/wrangler/bindings.js +++ b/lib/wrangler/bindings.js @@ -11,14 +11,26 @@ 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; + // 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 +41,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 +98,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 +145,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 +157,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 +176,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 +201,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 +220,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 +250,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,19 +303,49 @@ export function parseR2BucketsFromCfg(cfg, configRel = "wrangler config") { return out; } +/** + * `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 {string} 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) { + 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 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)}`); + } + 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) { @@ -269,7 +367,11 @@ export function parseServicesFromCfg(cfg, configRel = "wrangler config") { ); } } - const normalized = { binding: entry.binding, service: entry.service }; + /** @type {ServiceBinding} */ + const normalized = { + binding: 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 +379,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 +412,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 +433,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 +453,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 +468,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 +522,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 +536,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 +566,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 +598,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 +652,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..e35ad78 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,8 +149,14 @@ export function parseWranglerMajorVersion(output) { return Number(match[1]); } +/** + * @param {Array} paths + * @returns {string[]} + */ function uniquePaths(paths) { + /** @type {Set} */ const seen = new Set(); + /** @type {string[]} */ const out = []; for (const p of paths) { if (!p) continue; @@ -124,6 +171,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 +187,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 +221,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 +236,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 +244,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..2dfed5b 100644 --- a/lib/wrangler/config.js +++ b/lib/wrangler/config.js @@ -1,6 +1,19 @@ 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 + * 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", @@ -61,6 +74,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 +89,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 ? 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 +125,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 +133,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 +153,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 +179,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 +195,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 +234,10 @@ export function stripJsonComments(src) { return out; } +/** + * @param {string} src + * @returns {string} + */ export function stripTrailingCommas(src) { let out = ""; let i = 0; @@ -243,15 +280,28 @@ 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); } +/** + * @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..9c73b06 100644 --- a/lib/wrangler/utils.js +++ b/lib/wrangler/utils.js @@ -1,7 +1,28 @@ +/** + * 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); } + +/** + * 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/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..fcfec19 100644 --- a/tests/unit/cli-config-doctor.test.js +++ b/tests/unit/cli-config-doctor.test.js @@ -9,9 +9,16 @@ 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 {import("./helpers.js").ControlCall} 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, @@ -423,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, @@ -435,11 +505,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 +520,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..1f60061 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", @@ -218,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 }), [ @@ -249,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, { @@ -362,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 }); @@ -403,8 +407,10 @@ test("loadCliDotEnv ignores WDL_NS in selected section with a warning", () => { ].join("\n") ); + /** @type {string[]} */ const warnings = []; const env = emptyEnv(); + /** @type {Set} */ const protectedKeys = new Set(); loadCliDotEnv(env, file, { protectedKeys }); assert.deepEqual(loadCliDotEnv(env, file, { @@ -438,8 +444,10 @@ test("loadCliDotEnv does not warn for WDL_NS in an unselected section", () => { ].join("\n") ); + /** @type {string[]} */ const warnings = []; const env = emptyEnv(); + /** @type {Set} */ const protectedKeys = new Set(); loadCliDotEnv(env, file, { protectedKeys }); assert.deepEqual(loadCliDotEnv(env, file, { @@ -469,6 +477,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 +500,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 +521,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 +542,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 +557,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 +588,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 +606,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 +645,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 +658,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 +725,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 +756,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..7caedff 100644 --- a/tests/unit/cli-d1.test.js +++ b/tests/unit/cli-d1.test.js @@ -7,8 +7,38 @@ 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 {import("./helpers.js").ControlCall} RecordedCall */ + +// 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 () => { @@ -24,9 +54,11 @@ test("d1 list calls the namespace database endpoint", async () => { }); 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"); @@ -59,7 +91,7 @@ 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.deepEqual(parseBody(calls[0].init.body), { databaseName: "main" }); assert.deepEqual(lines, ["OK demo/d1_main created name=main"]); }); @@ -82,7 +114,7 @@ test("d1 execute sends SQL mode and JSON params", async () => { 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.deepEqual(parseBody(calls[0].init.body), { sql: "select ? as n", mode: "all", params: [1], @@ -145,6 +177,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 +191,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 +200,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 +217,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 +232,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 +243,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 +272,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 +284,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 +339,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 +347,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 +372,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 +380,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 +426,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 +440,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 +448,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 +467,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..396581d 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, @@ -35,15 +36,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"), @@ -417,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: [] }), []); @@ -442,6 +497,26 @@ 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 }] }), + /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/ @@ -728,14 +803,29 @@ 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 }); } }); +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"); @@ -788,8 +878,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 +899,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 }); } @@ -950,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" }); }); @@ -1032,9 +1124,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 +1537,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 +1552,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 +1597,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 +1613,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 +1635,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 +1693,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 +1728,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 +1785,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 +1826,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 +1864,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 +1992,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 +2025,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 +2063,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 +2087,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 +2128,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 +2185,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 +2286,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 +2318,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 +2337,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 +2351,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..88f339c 100644 --- a/tests/unit/cli-lifecycle.test.js +++ b/tests/unit/cli-lifecycle.test.js @@ -20,8 +20,24 @@ import { } from "../../lib/control-fetch.js"; import { mockDeps, response } from "./helpers.js"; +/** @typedef {import("./helpers.js").ControlCall} 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 +47,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 +186,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 +221,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 +234,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 +255,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 +272,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 +346,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 +418,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 +441,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 +456,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 +509,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 +532,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 +547,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 +561,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 +587,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 +603,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 +614,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 +630,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 +654,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 +672,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 +689,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 +706,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 +729,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 +746,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 +770,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 +793,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 +807,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 +884,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 +902,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 +919,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 +956,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 +1029,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 +1046,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 +1118,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 +1146,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 +1156,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 +1182,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 +1198,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 +1218,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 +1226,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 +1244,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 +1261,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 +1309,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 +1321,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 +1339,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 +1363,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 +1380,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 +1427,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 +1443,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 +1475,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 +1553,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 +1568,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 +1610,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 +1627,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 +1665,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 +1708,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 +1752,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 +1802,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 +1826,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 +1837,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 +1880,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 +1895,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 +1935,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 +1982,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 +1992,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 +2007,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..aee9a31 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")); @@ -93,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 fab5a79..385f258 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 { @@ -144,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"); }); }); @@ -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..c5e3da4 100644 --- a/tests/unit/helpers.js +++ b/tests/unit/helpers.js @@ -1,12 +1,22 @@ // 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 // 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 +33,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 {ControlCall[]} */ const calls = []; + /** @type {string[]} */ const lines = []; return { calls, lines, deps: { 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(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"]