From 63728b006f1d8b3052f3d80c173cdaf5a880f3be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Kir=C3=A1=C4=BE?= <54802833+IvanKiral@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:01:07 +0200 Subject: [PATCH 01/25] refactor: send all logging to stderr through an injectable Logger (#66) * refactor: send all logging to stderr through an injectable Logger * refactor: give warnings a yellow Warning: prefix and raise user-facing ones to standard --- CLAUDE.md | 11 ++- src/commands/login/login.ts | 11 +-- src/commands/logout/logout.ts | 11 +-- src/commands/project/sample/bootstrap.ts | 48 +++++++---- src/commands/telemetry/disable.ts | 3 +- src/commands/telemetry/enable.ts | 3 +- src/commands/telemetry/status.ts | 3 +- src/core/iapi/authenticatedClient.ts | 6 +- src/core/login/login.ts | 53 +++++------- src/core/logout/logout.ts | 8 +- src/core/project/bootstrap.ts | 34 ++++---- src/core/telemetry/settings.ts | 33 +++----- src/core/user/user.ts | 8 +- src/index.ts | 8 +- src/lib/iapi/formatIapiError.ts | 8 +- src/lib/telemetry/tracking.ts | 8 +- src/lib/ui/prompts.ts | 36 ++++++++ src/log.ts | 101 ++++++++++++----------- test/integration/bootstrap.test.ts | 17 ++-- test/unit/formatIapiError.test.ts | 4 +- test/unit/login.test.ts | 11 ++- 21 files changed, 237 insertions(+), 188 deletions(-) create mode 100644 src/lib/ui/prompts.ts diff --git a/CLAUDE.md b/CLAUDE.md index 63142bc..6248239 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ Three layers, dependencies point downward only (`commands → core → lib`): - `src/index.ts` — composition root. Folds each command's `register` over yargs via `reduce`, wires shared `deps` (telemetry). - `src/commands/**` — yargs wiring + presentation only. Register the command, call core, format output, log, set `process.exitCode`, fire the telemetry tracker. No business logic. -- `src/core/**` — orchestration of business logic. Returns `Result`/`Option`; never writes to the console directly (logs only through passed `LogOptions`). **Exception:** interactive commands may drive their own terminal UI from core — e.g. `src/core/project/bootstrap.ts` uses `@clack/prompts` (spinners, `confirm`/`select`, notes) directly because the flow is inherently interactive. Keep non-interactive core free of direct console writes. +- `src/core/**` — orchestration of business logic. Returns `Result`/`Option`; never writes to the console directly (logs only through a passed `Logger`). **Exception:** interactive commands may drive their own terminal UI from core — e.g. `src/core/project/bootstrap.ts` uses the prompts of `src/lib/ui/prompts.ts` (spinners, `confirm`/`select`, notes) directly because the flow is inherently interactive. Keep non-interactive core free of direct console writes. - `src/lib/**` — reusable primitives: `auth/`, `iapi/`, `mapi/`, `config/`, `telemetry/`, plus `result.ts` and `option.ts`. Adding a command: export a `register: RegisterCommand` (see `src/commands/login/login.ts`), then add its import to the `register` array in the parent command or `src/index.ts`. @@ -31,7 +31,14 @@ Adding a command: export a `register: RegisterCommand` (see `src/commands/login/ - `mapi` (`src/lib/mapi`) — public Management API via `@kontent-ai/management-sdk`. - `@kontent-ai/core-sdk` — shared HTTP/SDK layer both clients build on. -**Commands build clients; core receives them.** The command builds the `iapiClient`/`mapiClient` and passes them into core (e.g. `performBootstrap(params, { iapiClient, mapiClient })`); core never constructs clients itself. Auth failure is handled in the command, not surfaced as a core `Result` error. +**Commands build clients; core receives them.** The command builds the `iapiClient`/`mapiClient` and passes them into core (e.g. `performBootstrap(params, { logger, iapiClient, mapiClient })`); core never constructs clients itself. Auth failure is handled in the command, not surfaced as a core `Result` error. + +### Output channels + +- **stdout** — the data the command exists to produce, and nothing else. It is never level-gated: `--logLevel none` must still print a payload, because a response body is not a log. +- **stderr** — everything said *about* producing it: progress, warnings, errors, verbose traces. This is the POSIX meaning of stderr (diagnostics, not errors), and how curl, git and npm behave. + +Every handler starts with `const logger = createLoggerFromArgs(args)` (`src/log.ts`) and passes that `Logger` down; core takes it as a parameter or inside its `deps` object. `createLoggerFromArgs` is the only place that resolves the `--logLevel`/`--verbose` pair; everything else builds a logger from a single `LogLevel` via `createLogger`. The `sink` parameter is a test seam, not a routing knob — never point a log at stdout. ## Conventions diff --git a/src/commands/login/login.ts b/src/commands/login/login.ts index e35a903..a52eeef 100644 --- a/src/commands/login/login.ts +++ b/src/commands/login/login.ts @@ -1,7 +1,7 @@ import { type LoginOutcome, performLogin } from "../../core/login/login.js"; import { formatAuthError } from "../../lib/auth/formatAuthError.js"; import { isErr } from "../../lib/result.js"; -import { logError, logInfo } from "../../log.js"; +import { createLoggerFromArgs } from "../../log.js"; import type { RegisterCommand } from "../../types/yargs.js"; export const register: RegisterCommand = (y, deps) => @@ -10,17 +10,18 @@ export const register: RegisterCommand = (y, deps) => describe: "Authenticate with Kontent.ai via Auth0 device flow", builder: (b) => b, handler: async (args) => { - const tracker = deps.telemetry.startCommandTracking("login", args); + const logger = createLoggerFromArgs(args); + const tracker = deps.telemetry.startCommandTracking("login", logger); - const result = await performLogin(args); + const result = await performLogin(logger); if (isErr(result)) { tracker.fail(result.error.kind); - logError(args, formatAuthError(result.error)); + logger.error(formatAuthError(result.error)); process.exitCode = 1; return; } tracker.succeed(); - logInfo(args, "standard", formatLoginOutcome(result.value)); + logger.info("standard", formatLoginOutcome(result.value)); }, }); diff --git a/src/commands/logout/logout.ts b/src/commands/logout/logout.ts index 5ecd643..d13e140 100644 --- a/src/commands/logout/logout.ts +++ b/src/commands/logout/logout.ts @@ -1,7 +1,7 @@ import { performLogout } from "../../core/logout/logout.js"; import { formatAuthError } from "../../lib/auth/formatAuthError.js"; import { isErr } from "../../lib/result.js"; -import { logError, logInfo } from "../../log.js"; +import { createLoggerFromArgs } from "../../log.js"; import type { RegisterCommand } from "../../types/yargs.js"; export const register: RegisterCommand = (y, deps) => @@ -10,16 +10,17 @@ export const register: RegisterCommand = (y, deps) => describe: "Clear stored authentication tokens", builder: (b) => b, handler: async (args) => { - const tracker = deps.telemetry.startCommandTracking("logout", args); + const logger = createLoggerFromArgs(args); + const tracker = deps.telemetry.startCommandTracking("logout", logger); - const result = await performLogout(args); + const result = await performLogout(logger); if (isErr(result)) { tracker.fail(result.error.kind); - logError(args, formatAuthError(result.error)); + logger.error(formatAuthError(result.error)); process.exitCode = 1; return; } tracker.succeed(); - logInfo(args, "standard", "Logged out."); + logger.info("standard", "Logged out."); }, }); diff --git a/src/commands/project/sample/bootstrap.ts b/src/commands/project/sample/bootstrap.ts index 87e8e15..8a0317d 100644 --- a/src/commands/project/sample/bootstrap.ts +++ b/src/commands/project/sample/bootstrap.ts @@ -1,4 +1,3 @@ -import { intro, note, outro } from "@clack/prompts"; import { match } from "ts-pattern"; import { getAuthenticatedIapiClient } from "../../../core/iapi/authenticatedClient.js"; import { @@ -12,7 +11,8 @@ import { formatIapiError } from "../../../lib/iapi/formatIapiError.js"; import { createMapiClient } from "../../../lib/mapi/client.js"; import { isErr } from "../../../lib/result.js"; import type { Telemetry } from "../../../lib/telemetry/tracking.js"; -import { logError } from "../../../log.js"; +import { intro, note, outro } from "../../../lib/ui/prompts.js"; +import { createLoggerFromArgs, type Logger } from "../../../log.js"; import type { RegisterCommand } from "../../../types/yargs.js"; export const register: RegisterCommand = (sub, deps) => @@ -31,27 +31,31 @@ export const register: RegisterCommand = (sub, deps) => default: "./karma-nextjs-app", describe: "Target directory for the cloned app (must be empty or non-existent)", }), - handler: async (args) => runBootstrap(args, deps.telemetry), + handler: async (args) => runBootstrap(args, createLoggerFromArgs(args), deps.telemetry), }); -const runBootstrap = async (params: BootstrapParams, telemetry: Telemetry): Promise => { - const tracker = telemetry.startCommandTracking("project sample bootstrap", params); +const runBootstrap = async ( + params: BootstrapParams, + logger: Logger, + telemetry: Telemetry, +): Promise => { + const tracker = telemetry.startCommandTracking("project sample bootstrap", logger); intro("Bootstrap a Kontent.ai project"); - const clientResult = await getAuthenticatedIapiClient(params); + const clientResult = await getAuthenticatedIapiClient(logger); if (isErr(clientResult)) { tracker.fail(`auth:${clientResult.error.kind}`, { project: params.envId }); - logError(params, formatAuthError(clientResult.error)); + logger.error(formatAuthError(clientResult.error)); process.exitCode = 1; return; } const iapiClient = clientResult.value; const mapiClient = createMapiClient({ token: iapiClient.token, envId: params.envId }); - const result = await performBootstrap(params, { iapiClient, mapiClient }); + const result = await performBootstrap(params, { logger, iapiClient, mapiClient }); if (isErr(result)) { tracker.fail(bootstrapErrorCode(result.error), { project: params.envId }); - handleBootstrapError(params, result.error); + handleBootstrapError(params, logger, result.error); return; } @@ -76,7 +80,11 @@ const bootstrapErrorCode = (error: BootstrapError): string => .with({ kind: "create-key-failed" }, (e) => `create-key-failed:${e.sdkError.details.reason}`) .otherwise((e) => e.kind); -const handleBootstrapError = (params: BootstrapParams, error: BootstrapError): void => +const handleBootstrapError = ( + params: BootstrapParams, + logger: Logger, + error: BootstrapError, +): void => match(error) // soft exits: the user chose to stop or the environment is not eligible .with({ kind: "aborted" }, (e) => { @@ -88,20 +96,24 @@ const handleBootstrapError = (params: BootstrapParams, error: BootstrapError): v ); }) .otherwise((hardError) => { - logError(params, formatBootstrapError(params, hardError)); + logger.error(formatBootstrapError(params, logger, hardError)); process.exitCode = 1; }); const formatBootstrapError = ( params: BootstrapParams, + logger: Logger, error: Exclude, -): string => - match(error) +): string => { + const context = { envId: params.envId, isVerbose: logger.isVerbose }; + + return match(error) .with({ kind: "target-not-usable" }, (e) => e.message) .with({ kind: "clone-failed" }, (e) => e.message) - .with({ kind: "project-info-failed" }, (e) => formatIapiError(e.sdkError, params)) - .with({ kind: "properties-failed" }, (e) => formatIapiError(e.sdkError, params)) - .with({ kind: "list-keys-failed" }, (e) => formatIapiError(e.sdkError, params)) - .with({ kind: "key-detail-failed" }, (e) => formatIapiError(e.sdkError, params)) - .with({ kind: "create-key-failed" }, (e) => formatIapiError(e.sdkError, params)) + .with({ kind: "project-info-failed" }, (e) => formatIapiError(e.sdkError, context)) + .with({ kind: "properties-failed" }, (e) => formatIapiError(e.sdkError, context)) + .with({ kind: "list-keys-failed" }, (e) => formatIapiError(e.sdkError, context)) + .with({ kind: "key-detail-failed" }, (e) => formatIapiError(e.sdkError, context)) + .with({ kind: "create-key-failed" }, (e) => formatIapiError(e.sdkError, context)) .exhaustive(); +}; diff --git a/src/commands/telemetry/disable.ts b/src/commands/telemetry/disable.ts index 9d82371..b79cfa4 100644 --- a/src/commands/telemetry/disable.ts +++ b/src/commands/telemetry/disable.ts @@ -1,4 +1,5 @@ import { setTelemetryStatus } from "../../core/telemetry/settings.js"; +import { createLoggerFromArgs } from "../../log.js"; import type { RegisterCommand } from "../../types/yargs.js"; export const register: RegisterCommand = (sub) => @@ -6,5 +7,5 @@ export const register: RegisterCommand = (sub) => command: "disable", describe: "Disable anonymous usage telemetry", builder: (b) => b, - handler: async (args) => setTelemetryStatus(args, false), + handler: async (args) => setTelemetryStatus(createLoggerFromArgs(args), false), }); diff --git a/src/commands/telemetry/enable.ts b/src/commands/telemetry/enable.ts index 7b03463..341dacf 100644 --- a/src/commands/telemetry/enable.ts +++ b/src/commands/telemetry/enable.ts @@ -1,4 +1,5 @@ import { setTelemetryStatus } from "../../core/telemetry/settings.js"; +import { createLoggerFromArgs } from "../../log.js"; import type { RegisterCommand } from "../../types/yargs.js"; export const register: RegisterCommand = (sub) => @@ -6,5 +7,5 @@ export const register: RegisterCommand = (sub) => command: "enable", describe: "Enable anonymous usage telemetry", builder: (b) => b, - handler: async (args) => setTelemetryStatus(args, true), + handler: async (args) => setTelemetryStatus(createLoggerFromArgs(args), true), }); diff --git a/src/commands/telemetry/status.ts b/src/commands/telemetry/status.ts index de704ba..b268401 100644 --- a/src/commands/telemetry/status.ts +++ b/src/commands/telemetry/status.ts @@ -1,4 +1,5 @@ import { showTelemetryStatus } from "../../core/telemetry/settings.js"; +import { createLoggerFromArgs } from "../../log.js"; import type { RegisterCommand } from "../../types/yargs.js"; export const register: RegisterCommand = (sub) => @@ -6,5 +7,5 @@ export const register: RegisterCommand = (sub) => command: "status", describe: "Show whether telemetry is enabled and why", builder: (b) => b, - handler: async (args) => showTelemetryStatus(args), + handler: async (args) => showTelemetryStatus(createLoggerFromArgs(args)), }); diff --git a/src/core/iapi/authenticatedClient.ts b/src/core/iapi/authenticatedClient.ts index b95421c..390d2e4 100644 --- a/src/core/iapi/authenticatedClient.ts +++ b/src/core/iapi/authenticatedClient.ts @@ -2,17 +2,17 @@ import { getValidAccessToken } from "../../lib/auth/tokenAccess.js"; import type { AuthError } from "../../lib/auth/types.js"; import { createIapiClient, type IapiClient } from "../../lib/iapi/client.js"; import { isErr, ok, type Result } from "../../lib/result.js"; -import type { LogOptions } from "../../log.js"; +import type { Logger } from "../../log.js"; import { ensureUserIdCached } from "../user/user.js"; export const getAuthenticatedIapiClient = async ( - params: LogOptions, + logger: Logger, ): Promise> => { const tokenResult = await getValidAccessToken(); if (isErr(tokenResult)) { return tokenResult; } const client = createIapiClient({ token: tokenResult.value }); - await ensureUserIdCached(params, { client }); + await ensureUserIdCached(logger, { client }); return ok(client); }; diff --git a/src/core/login/login.ts b/src/core/login/login.ts index 3f8d393..26e5a92 100644 --- a/src/core/login/login.ts +++ b/src/core/login/login.ts @@ -10,25 +10,21 @@ import type { AuthError, TokenSet } from "../../lib/auth/types.js"; import { errorMessage } from "../../lib/error.js"; import { createIapiClient } from "../../lib/iapi/client.js"; import { err, isErr, isOk, ok, type Result } from "../../lib/result.js"; -import { type LogOptions, logInfo, logWarning } from "../../log.js"; +import type { Logger } from "../../log.js"; import { ensureUserIdCached } from "../user/user.js"; -export type LoginParams = LogOptions; - export type LoginOutcome = Readonly<{ isAlreadyAuthenticated: boolean; identifier: string | null; }>; -export const performLogin = async ( - params: LoginParams, -): Promise> => { +export const performLogin = async (logger: Logger): Promise> => { const config = getAuth0Config(); const storage = createKeyringStorage(); const stored = await storage.read(); if (isErr(stored)) { - logWarning(params, "verbose", formatAuthError(stored.error)); + logger.warning("standard", formatAuthError(stored.error)); } const storedTokens = isOk(stored) ? stored.value : null; @@ -37,7 +33,7 @@ export const performLogin = async ( return match(decision) .with({ type: "use-existing-token" }, async () => { if (storedTokens !== null) { - await ensureUserIdCached(params, { + await ensureUserIdCached(logger, { client: createIapiClient({ token: storedTokens.accessToken }), }); } @@ -51,12 +47,12 @@ export const performLogin = async ( // at the same place, so surface the error and keep the stored session. return err(refreshed.error); } - logInfo(params, "standard", "Saved session expired, starting a new sign-in."); - logWarning(params, "verbose", formatAuthError(refreshed.error)); - return await runDeviceFlow(params, storage, config); + logger.info("standard", "Saved session expired, starting a new sign-in."); + logger.warning("verbose", formatAuthError(refreshed.error)); + return await runDeviceFlow(logger, storage, config); } - await persistTokens(params, storage, refreshed.value); - await ensureUserIdCached(params, { + await persistTokens(logger, storage, refreshed.value); + await ensureUserIdCached(logger, { client: createIapiClient({ token: refreshed.value.accessToken }), }); return ok({ @@ -64,22 +60,22 @@ export const performLogin = async ( identifier: identifierFromTokens(refreshed.value), }); }) - .with({ type: "login" }, async () => runDeviceFlow(params, storage, config)) + .with({ type: "login" }, async () => runDeviceFlow(logger, storage, config)) .exhaustive(); }; const runDeviceFlow = async ( - params: LoginParams, + logger: Logger, storage: TokenStorage, config: Auth0Config, ): Promise> => { - const result = await loginViaDeviceFlow(config, deviceFlowDeps(params)); + const result = await loginViaDeviceFlow(config, deviceFlowDeps(logger)); if (isErr(result)) { return err(result.error); } - await persistTokens(params, storage, result.value); + await persistTokens(logger, storage, result.value); // Fresh login may be a different account, so overwrite the cached userId. - await ensureUserIdCached(params, { + await ensureUserIdCached(logger, { client: createIapiClient({ token: result.value.accessToken }), shouldForceRefresh: true, }); @@ -90,22 +86,21 @@ const runDeviceFlow = async ( }; const persistTokens = async ( - params: LogOptions, + logger: Logger, storage: TokenStorage, tokens: TokenSet, ): Promise => { const written = await storage.write(tokens); if (isErr(written)) { - logWarning(params, "standard", formatAuthError(written.error)); + logger.warning("standard", formatAuthError(written.error)); } }; const identifierFromTokens = (tokens: TokenSet | null): string | null => tokens?.identifier ?? null; -const deviceFlowDeps = (params: LogOptions): DeviceFlowDeps => ({ +const deviceFlowDeps = (logger: Logger): DeviceFlowDeps => ({ onUserCode: async ({ userCode, expiresInSeconds, verificationUriComplete }, done) => { - logInfo( - params, + logger.info( "standard", `To sign in, open:\n ${verificationUriComplete}\n` + `Code: ${userCode} (expires in ${formatExpiry(expiresInSeconds)}).\n` + @@ -113,7 +108,7 @@ const deviceFlowDeps = (params: LogOptions): DeviceFlowDeps => ({ ); if (!process.stdin.isTTY) { - await tryOpen(params, verificationUriComplete); + await tryOpen(logger, verificationUriComplete); return; } @@ -121,7 +116,7 @@ const deviceFlowDeps = (params: LogOptions): DeviceFlowDeps => ({ // Re-open the browser on each Enter; once() rejects when `done` aborts (polling settled). while (!done.aborted) { await once(process.stdin, "data", { signal: done }); - await tryOpen(params, verificationUriComplete); + await tryOpen(logger, verificationUriComplete); } } catch { // `done` aborted (auth done, denied, or expired) — stop re-opening. @@ -135,14 +130,10 @@ const deviceFlowDeps = (params: LogOptions): DeviceFlowDeps => ({ const formatExpiry = (seconds: number): string => seconds % 60 === 0 ? `${seconds / 60} minutes` : `${seconds} seconds`; -const tryOpen = async (params: LogOptions, url: string): Promise => { +const tryOpen = async (logger: Logger, url: string): Promise => { try { await open(url); } catch (cause) { - logWarning( - params, - "verbose", - `Could not open the browser automatically: ${errorMessage(cause)}`, - ); + logger.warning("standard", `Could not open the browser automatically: ${errorMessage(cause)}`); } }; diff --git a/src/core/logout/logout.ts b/src/core/logout/logout.ts index 1410601..b2f7a1b 100644 --- a/src/core/logout/logout.ts +++ b/src/core/logout/logout.ts @@ -2,11 +2,9 @@ import { createKeyringStorage } from "../../lib/auth/storage.js"; import type { AuthError } from "../../lib/auth/types.js"; import { writeCliConfig } from "../../lib/config/cliConfig.js"; import { err, isErr, ok, type Result } from "../../lib/result.js"; -import { type LogOptions, logWarning } from "../../log.js"; +import type { Logger } from "../../log.js"; -export type LogoutParams = LogOptions; - -export const performLogout = async (params: LogoutParams): Promise> => { +export const performLogout = async (logger: Logger): Promise> => { const storage = createKeyringStorage(); const cleared = await storage.clear(); if (isErr(cleared)) { @@ -15,7 +13,7 @@ export const performLogout = async (params: LogoutParams): Promise; +export type BootstrapParams = Readonly<{ + envId: string; + path: string; +}>; -export type BootstrapClients = Readonly<{ +export type BootstrapDeps = Readonly<{ + logger: Logger; iapiClient: IapiClient; mapiClient: MapiClient; }>; @@ -49,9 +50,9 @@ const CREATE_NEW_KEY_VALUE = "__create_new_delivery_key__"; export const performBootstrap = async ( params: BootstrapParams, - clients: BootstrapClients, + deps: BootstrapDeps, ): Promise> => { - const { iapiClient, mapiClient } = clients; + const { logger, iapiClient, mapiClient } = deps; const targetCheck = await ensureTargetUsable(params.path); if (targetCheck.kind === "err") { @@ -99,17 +100,17 @@ export const performBootstrap = async ( } cloneSpinner.stop(`Cloned into ${params.path}`); - await wireEnvFile(params, sample, deliveryKey); + await wireEnvFile(params, logger, sample, deliveryKey); if (sample.previewSpace) { - await setupLocalhostSpace(params, mapiClient, sample.previewSpace); + await setupLocalhostSpace(logger, mapiClient, sample.previewSpace); } return ok({ subscriptionId, sampleProjectType: sampleValue }); }; const setupLocalhostSpace = async ( - params: BootstrapParams, + logger: Logger, mapiClient: MapiClient, previewSpace: PreviewSpaceConfig, ): Promise => { @@ -119,7 +120,7 @@ const setupLocalhostSpace = async ( if (isErr(result)) { spaceSpinner.error("Could not set up the localhost preview space"); - logWarning(params, "standard", spaceWarning(result.error)); + logger.warning("standard", spaceWarning(result.error)); return; } @@ -254,6 +255,7 @@ const ensureTargetUsable = async ( const wireEnvFile = async ( params: BootstrapParams, + logger: Logger, sample: SampleApp, deliveryKey: string, ): Promise => { @@ -273,7 +275,7 @@ const wireEnvFile = async ( return; } envSpinner.error(`Failed to read ${sample.envTemplateFile}`); - logError(params, errorMessage(cause)); + logger.error(errorMessage(cause)); return; } @@ -284,7 +286,7 @@ const wireEnvFile = async ( envSpinner.stop(`Wrote ${ENV_OUTPUT_FILE}`); } catch (cause) { envSpinner.error(`Failed to write ${ENV_OUTPUT_FILE}`); - logError(params, errorMessage(cause)); + logger.error(errorMessage(cause)); } }; diff --git a/src/core/telemetry/settings.ts b/src/core/telemetry/settings.ts index 7f46bdc..0a622ce 100644 --- a/src/core/telemetry/settings.ts +++ b/src/core/telemetry/settings.ts @@ -4,11 +4,9 @@ import { isTruthyEnv } from "../../lib/env.js"; import { isErr } from "../../lib/result.js"; import { formatTelemetryOffReason, resolveTelemetryConsent } from "../../lib/telemetry/consent.js"; import { amplitudeApiKey } from "../../lib/telemetry/context.js"; -import { type LogOptions, logError, logInfo, logWarning } from "../../log.js"; +import type { Logger } from "../../log.js"; -export type TelemetryCommandParams = LogOptions; - -export const showTelemetryStatus = async (params: TelemetryCommandParams): Promise => { +export const showTelemetryStatus = async (logger: Logger): Promise => { const config = await readCliConfig(); const consent = resolveTelemetryConsent(process.env, config, amplitudeApiKey, isCI); @@ -18,8 +16,7 @@ export const showTelemetryStatus = async (params: TelemetryCommandParams): Promi : "Reason: default (no opt-out detected)" : `Reason: ${formatTelemetryOffReason(consent.reason)}`; - logInfo( - params, + logger.info( "standard", [ `Telemetry: ${consent.isEnabled ? "enabled" : "disabled"}`, @@ -29,38 +26,30 @@ export const showTelemetryStatus = async (params: TelemetryCommandParams): Promi ); }; -export const setTelemetryStatus = async ( - params: TelemetryCommandParams, - isEnabled: boolean, -): Promise => { +export const setTelemetryStatus = async (logger: Logger, isEnabled: boolean): Promise => { const written = await writeCliConfig({ telemetryEnabled: isEnabled, telemetryNoticeShown: true, }); if (isErr(written)) { - logError(params, `Failed to update telemetry config: ${written.error}`); + logger.error(`Failed to update telemetry config: ${written.error}`); process.exitCode = 1; return; } - logInfo(params, "standard", isEnabled ? "Telemetry enabled." : "Telemetry disabled."); + logger.info("standard", isEnabled ? "Telemetry enabled." : "Telemetry disabled."); if (isEnabled) { - warnIfEnvForcesOff(params); + warnIfEnvForcesOff(logger); } }; -const warnIfEnvForcesOff = (params: TelemetryCommandParams): void => { +const warnIfEnvForcesOff = (logger: Logger): void => { if (isTruthyEnv(process.env.DO_NOT_TRACK)) { - logWarning( - params, - "standard", - "Note: DO_NOT_TRACK is set, so telemetry stays off in this environment.", - ); + logger.warning("standard", "DO_NOT_TRACK is set, so telemetry stays off in this environment."); } if (isTruthyEnv(process.env.KONTENT_DO_NOT_TRACK)) { - logWarning( - params, + logger.warning( "standard", - "Note: KONTENT_DO_NOT_TRACK is set, so telemetry stays off in this environment.", + "KONTENT_DO_NOT_TRACK is set, so telemetry stays off in this environment.", ); } }; diff --git a/src/core/user/user.ts b/src/core/user/user.ts index e053b78..e3dfb44 100644 --- a/src/core/user/user.ts +++ b/src/core/user/user.ts @@ -5,7 +5,7 @@ import { readCliConfig, writeCliConfig } from "../../lib/config/cliConfig.js"; import type { IapiClient } from "../../lib/iapi/client.js"; import { getUser, type UserInfo } from "../../lib/iapi/endpoints/getUser.js"; import { err, isErr, ok, type Result } from "../../lib/result.js"; -import { type LogOptions, logWarning } from "../../log.js"; +import type { Logger } from "../../log.js"; export type UserError = | { readonly kind: "auth-failed"; readonly authError: AuthError } @@ -15,7 +15,7 @@ type EnsureUserIdOptions = Readonly<{ client: IapiClient; shouldForceRefresh?: b // Best-effort: never throws, so a /user failure can't break login. export const ensureUserIdCached = async ( - params: LogOptions, + logger: Logger, options: EnsureUserIdOptions, ): Promise => { const cached = (await readCliConfig()).userId; @@ -25,13 +25,13 @@ export const ensureUserIdCached = async ( const result = await fetchUser(options.client); if (isErr(result)) { - logWarning(params, "verbose", `Could not cache userId: ${formatUserError(result.error)}`); + logger.warning("verbose", `Could not cache userId: ${formatUserError(result.error)}`); return; } const written = await writeCliConfig({ userId: result.value.userId }); if (isErr(written)) { - logWarning(params, "verbose", `Could not persist userId: ${written.error}`); + logger.warning("verbose", `Could not persist userId: ${written.error}`); } }; diff --git a/src/index.ts b/src/index.ts index dd6cb02..657a36f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import chalk from "chalk"; +import chalk, { chalkStderr } from "chalk"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; import { getKontentBaseDomain, validateKontentDomain } from "./lib/config/kontentUrl.js"; @@ -10,7 +10,7 @@ import { formatTelemetryMode, registerTelemetrySignalFlush, } from "./lib/telemetry/tracking.js"; -import { addLogLevelOptions, logInfo } from "./log.js"; +import { addLogLevelOptions, createLoggerFromArgs } from "./log.js"; import type { CommandDeps, RegisterCommand } from "./types/yargs.js"; const commandsToRegister: ReadonlyArray = [ @@ -50,7 +50,7 @@ const withHiddenEnvOptions = withLogLevel const kontentDomainResult = validateKontentDomain(getKontentBaseDomain()); if (isErr(kontentDomainResult)) { - console.error(`${chalk.red("Error:")} ${kontentDomainResult.error}`); + console.error(`${chalkStderr.red("Error:")} ${kontentDomainResult.error}`); process.exit(1); } @@ -60,7 +60,7 @@ registerTelemetrySignalFlush(telemetry); // Runs after parsing (so --verbose is known) and before the command handler. const withTelemetryModeLog = withHiddenEnvOptions.middleware((args) => { - logInfo(args, "verbose", formatTelemetryMode(mode)); + createLoggerFromArgs(args).info("verbose", formatTelemetryMode(mode)); }); await commandsToRegister diff --git a/src/lib/iapi/formatIapiError.ts b/src/lib/iapi/formatIapiError.ts index 5849f3a..e259067 100644 --- a/src/lib/iapi/formatIapiError.ts +++ b/src/lib/iapi/formatIapiError.ts @@ -1,9 +1,7 @@ import { inspect } from "node:util"; import type { KontentSdkError } from "@kontent-ai/core-sdk"; -import { isVerbose, type LogOptions } from "../../log.js"; - -export type IapiErrorContext = LogOptions & Readonly<{ envId: string }>; +export type IapiErrorContext = Readonly<{ envId: string; isVerbose: boolean }>; const httpStatusOf = (details: KontentSdkError["details"]): number | undefined => "status" in details ? details.status : undefined; @@ -21,7 +19,7 @@ export const formatIapiError = (error: KontentSdkError, context: IapiErrorContex return formatGenericIapiError(error, context); }; -const formatGenericIapiError = (error: KontentSdkError, options: LogOptions): string => { +const formatGenericIapiError = (error: KontentSdkError, context: IapiErrorContext): string => { const { details } = error; const apiResponse = "kontentErrorResponse" in details ? details.kontentErrorResponse : undefined; @@ -31,7 +29,7 @@ const formatGenericIapiError = (error: KontentSdkError, options: LogOptions): st apiResponse?.message ? `message: ${apiResponse.message}` : undefined, apiResponse?.request_id ? `request-id: ${apiResponse.request_id}` : undefined, `url: ${error.url}`, - isVerbose(options) + context.isVerbose ? `details: ${inspect(details, { depth: 5, colors: false, breakLength: 100 })}` : undefined, ]; diff --git a/src/lib/telemetry/tracking.ts b/src/lib/telemetry/tracking.ts index 7003acc..de27112 100644 --- a/src/lib/telemetry/tracking.ts +++ b/src/lib/telemetry/tracking.ts @@ -1,6 +1,6 @@ import { isCI } from "ci-info"; import { match } from "ts-pattern"; -import { type LogOptions, logInfo } from "../../log.js"; +import type { Logger } from "../../log.js"; import { readCliConfig, writeCliConfig } from "../config/cliConfig.js"; import { isOk } from "../result.js"; import { @@ -30,7 +30,7 @@ export type CommandTracker = Readonly<{ }>; export type Telemetry = Readonly<{ - startCommandTracking: (command: string, params: LogOptions) => CommandTracker; + startCommandTracking: (command: string, logger: Logger) => CommandTracker; flush: () => Promise; }>; @@ -64,7 +64,7 @@ export const createTelemetry = async (): Promise => { } const telemetry: Telemetry = { - startCommandTracking: (command, params) => { + startCommandTracking: (command, logger) => { const startedAtMs = Date.now(); let hasFinished = false; @@ -94,7 +94,7 @@ export const createTelemetry = async (): Promise => { ) .then((trackOutcome) => { if (trackOutcome.kind !== "skipped") { - logInfo(params, "verbose", formatTrackOutcome(trackOutcome)); + logger.info("verbose", formatTrackOutcome(trackOutcome)); } }) .catch(() => { diff --git a/src/lib/ui/prompts.ts b/src/lib/ui/prompts.ts new file mode 100644 index 0000000..4100e59 --- /dev/null +++ b/src/lib/ui/prompts.ts @@ -0,0 +1,36 @@ +import { + confirm as clackConfirm, + intro as clackIntro, + note as clackNote, + outro as clackOutro, + select as clackSelect, + spinner as clackSpinner, +} from "@clack/prompts"; + +/** + * clack defaults every prompt, spinner and note to stdout, which stdout must + * stay free of; there is no global setting for it, so the stderr stream is + * bound here once instead of at every call site. As a side effect the spinner + * keeps animating when stdout is piped, because clack's TTY check reads the + * stream it is handed. + * + * `stream.message/info/success` hardcode stdout and cannot be redirected - do + * not start using them. + */ +export const spinner: typeof clackSpinner = (options = {}) => + clackSpinner({ output: process.stderr, ...options }); + +export const confirm: typeof clackConfirm = async (options) => + clackConfirm({ output: process.stderr, ...options }); + +export const select: typeof clackSelect = async (options) => + clackSelect({ output: process.stderr, ...options }); + +export const note: typeof clackNote = (message, title, options = {}) => + clackNote(message, title, { output: process.stderr, ...options }); + +export const intro: typeof clackIntro = (title, options = {}) => + clackIntro(title, { output: process.stderr, ...options }); + +export const outro: typeof clackOutro = (message, options = {}) => + clackOutro(message, { output: process.stderr, ...options }); diff --git a/src/log.ts b/src/log.ts index 7058eb7..97ea2d8 100644 --- a/src/log.ts +++ b/src/log.ts @@ -1,25 +1,25 @@ -import chalk from "chalk"; +import type { Writable } from "node:stream"; +import { chalkStderr } from "chalk"; import type { Argv } from "yargs"; -export type LogLevel = "none" | "standard" | "verbose"; +export const allLogLevels = ["none", "standard", "verbose"] as const; -const logLevelsPriority: Readonly> = { - none: 0, - standard: 10, - verbose: 20, -}; +export type LogLevel = (typeof allLogLevels)[number]; -export const allLogLevels = Object.keys(logLevelsPriority); - -type LoggableLogLevel = Exclude; - -const defaultLogLevel: LogLevel = "standard"; +export type MessageLevel = Exclude; export type LogOptions = Readonly<{ - logLevel?: string; + logLevel?: LogLevel; verbose?: boolean; }>; +export type Logger = Readonly<{ + info: (logAtLevel: MessageLevel, ...messages: ReadonlyArray) => void; + warning: (logAtLevel: MessageLevel, ...messages: ReadonlyArray) => void; + error: (...messages: ReadonlyArray) => void; + isVerbose: boolean; +}>; + export const addLogLevelOptions = ( inputYargs: Argv, ): Argv => @@ -36,48 +36,51 @@ export const addLogLevelOptions = ( conflicts: "logLevel", }); -export const logError = (options: LogOptions, ...messages: ReadonlyArray) => - logInternal( - options, - "standard", - console.error, - ...messages.map((m) => `${chalk.red("Error:")} ${m}\n`), - ); +/** + * All output goes to stderr: stdout is reserved for command payloads, so a piped + * response body never carries diagnostics. `sink` exists so tests can capture + * output - it is not a routing knob. + */ +export const createLogger = (verbosity: LogLevel, sink: Writable = process.stderr): Logger => { + const write = (logAtLevel: MessageLevel, messages: ReadonlyArray): void => { + if (logLevelsPriority[verbosity] < logLevelsPriority[logAtLevel]) { + return; + } + sink.write(`${messages.join(" ")}\n`); + }; -export const logWarning = ( - options: LogOptions, - logAtLevel: LoggableLogLevel, - ...messages: ReadonlyArray -) => logInternal(options, logAtLevel, console.warn, ...messages); + return { + info: (logAtLevel, ...messages) => write(logAtLevel, messages), + warning: (logAtLevel, ...messages) => + write( + logAtLevel, + messages.map((message) => `${chalkStderr.yellow("Warning:")} ${message}`), + ), + error: (...messages) => + write( + "standard", + messages.map((message) => `${chalkStderr.red("Error:")} ${message}`), + ), + isVerbose: verbosity === "verbose", + }; +}; -export const logInfo = ( - options: LogOptions, - logAtLevel: LoggableLogLevel, - ...messages: ReadonlyArray -) => logInternal(options, logAtLevel, console.log, ...messages); +export const createLoggerFromArgs = (args: LogOptions, sink?: Writable): Logger => + createLogger(argsToVerbosity(args), sink); -const logInternal = ( - options: LogOptions, - thisMessageLogLevel: LoggableLogLevel, - logFnc: (...msgs: ReadonlyArray) => void, - ...messages: ReadonlyArray -) => { - if (logLevelsPriority[optionsToLogLevel(options)] >= logLevelsPriority[thisMessageLogLevel]) { - logFnc(...messages); - } +const logLevelsPriority: Readonly> = { + none: 0, + standard: 10, + verbose: 20, }; -export const isVerbose = (options: LogOptions): boolean => optionsToLogLevel(options) === "verbose"; +const defaultLogLevel: LogLevel = "standard"; -const optionsToLogLevel = (options: LogOptions): LogLevel => { - if (options.verbose) { +// `--verbose` and `--logLevel` are mutually exclusive at the parser level, so the +// precedence only matters for callers that build LogOptions by hand. +const argsToVerbosity = (args: LogOptions): LogLevel => { + if (args.verbose) { return "verbose"; } - const logLevel = options.logLevel ?? defaultLogLevel; - if (!isLogLevel(logLevel)) { - throw new Error(`CLI argument parsing error: log level "${options.logLevel}" is not valid.`); - } - return logLevel; + return args.logLevel ?? defaultLogLevel; }; - -const isLogLevel = (input: string): input is LogLevel => allLogLevels.includes(input); diff --git a/test/integration/bootstrap.test.ts b/test/integration/bootstrap.test.ts index 0484cd2..0541857 100644 --- a/test/integration/bootstrap.test.ts +++ b/test/integration/bootstrap.test.ts @@ -6,6 +6,7 @@ import { downloadTemplate } from "giget"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { performBootstrap } from "../../src/core/project/bootstrap.js"; import { createMapiClient } from "../../src/lib/mapi/client.js"; +import { createLogger } from "../../src/log.js"; import { type IapiRoute, iapiTestClient } from "../helpers/iapiTestClient.js"; vi.mock("@clack/prompts", () => ({ @@ -18,6 +19,8 @@ vi.mock("@clack/prompts", () => ({ confirm: vi.fn(), select: vi.fn(), note: vi.fn(), + intro: vi.fn(), + outro: vi.fn(), isCancel: vi.fn(() => false), })); @@ -59,7 +62,9 @@ const mapiClient = createMapiClient({ token: "test-token", envId: ENV_ID }); let targetDir = ""; -const makeParams = () => ({ logLevel: "none", envId: ENV_ID, path: targetDir }) as const; +const makeParams = () => ({ envId: ENV_ID, path: targetDir }) as const; + +const logger = createLogger("none"); const readEnvLocal = () => readFile(path.join(targetDir, ".env.local"), "utf8"); @@ -87,7 +92,7 @@ describe("performBootstrap", () => { ]); vi.mocked(select).mockResolvedValue("seed-123"); - const result = await performBootstrap(makeParams(), { iapiClient, mapiClient }); + const result = await performBootstrap(makeParams(), { logger, iapiClient, mapiClient }); expect(result.kind).toBe("ok"); if (result.kind !== "ok") { @@ -110,7 +115,7 @@ describe("performBootstrap", () => { ]); vi.mocked(confirm).mockResolvedValue(true); - const result = await performBootstrap(makeParams(), { iapiClient, mapiClient }); + const result = await performBootstrap(makeParams(), { logger, iapiClient, mapiClient }); expect(result.kind).toBe("ok"); const env = await readEnvLocal(); @@ -126,7 +131,7 @@ describe("performBootstrap", () => { vi.mocked(confirm).mockResolvedValue(true); vi.mocked(isCancel).mockReturnValue(true); - const result = await performBootstrap(makeParams(), { iapiClient, mapiClient }); + const result = await performBootstrap(makeParams(), { logger, iapiClient, mapiClient }); expect(result.kind).toBe("err"); if (result.kind !== "err") { @@ -142,7 +147,7 @@ describe("performBootstrap", () => { { method: "GET", path: /\/property$/, reply: [{ key: "SampleProjectType", value: "Nope" }] }, ]); - const result = await performBootstrap(makeParams(), { iapiClient, mapiClient }); + const result = await performBootstrap(makeParams(), { logger, iapiClient, mapiClient }); expect(result.kind).toBe("err"); if (result.kind !== "err") { @@ -157,7 +162,7 @@ describe("performBootstrap", () => { // Empty route table: any iapi request would throw, proving none is made. const iapiClient = iapiTestClient([]); - const result = await performBootstrap(makeParams(), { iapiClient, mapiClient }); + const result = await performBootstrap(makeParams(), { logger, iapiClient, mapiClient }); expect(result.kind).toBe("err"); if (result.kind !== "err") { diff --git a/test/unit/formatIapiError.test.ts b/test/unit/formatIapiError.test.ts index fac869b..72ccda1 100644 --- a/test/unit/formatIapiError.test.ts +++ b/test/unit/formatIapiError.test.ts @@ -27,7 +27,7 @@ const httpError = ( }, }); -const context = { envId: ENV_ID }; +const context = { envId: ENV_ID, isVerbose: false }; describe("formatIapiError", () => { it("maps 401 to a re-login hint without dumping transport detail", () => { @@ -63,7 +63,7 @@ describe("formatIapiError", () => { const error = httpError(500, "Internal Server Error", "invalidResponse"); expect(formatIapiError(error, context)).not.toContain(NOISE_HEADER); - expect(formatIapiError(error, { ...context, verbose: true })).toContain(NOISE_HEADER); + expect(formatIapiError(error, { ...context, isVerbose: true })).toContain(NOISE_HEADER); }); it("summarizes non-HTTP failures without a status line", () => { diff --git a/test/unit/login.test.ts b/test/unit/login.test.ts index 37d3e50..2c5a301 100644 --- a/test/unit/login.test.ts +++ b/test/unit/login.test.ts @@ -4,6 +4,7 @@ import { loginViaDeviceFlow, refreshTokens } from "../../src/lib/auth/auth0.js"; import { createKeyringStorage } from "../../src/lib/auth/storage.js"; import type { TokenSet } from "../../src/lib/auth/types.js"; import { err, ok } from "../../src/lib/result.js"; +import { createLogger } from "../../src/log.js"; vi.mock("../../src/lib/auth/storage.js", () => ({ createKeyringStorage: vi.fn(), @@ -32,6 +33,8 @@ const FRESH_TOKENS: TokenSet = { identifier: "new@example.com", }; +const logger = createLogger("none"); + const fakeStorage = (stored: TokenSet | null) => ({ read: vi.fn(async () => ok(stored)), write: vi.fn(async () => ok(undefined)), @@ -50,7 +53,7 @@ describe("performLogin with a rejected refresh token", () => { const storage = fakeStorage(EXPIRED_TOKENS); vi.mocked(createKeyringStorage).mockReturnValue(storage); - const result = await performLogin({}); + const result = await performLogin(logger); expect(loginViaDeviceFlow).toHaveBeenCalledOnce(); expect(result).toEqual(ok({ isAlreadyAuthenticated: false, identifier: "new@example.com" })); @@ -60,7 +63,7 @@ describe("performLogin with a rejected refresh token", () => { const storage = fakeStorage(EXPIRED_TOKENS); vi.mocked(createKeyringStorage).mockReturnValue(storage); - await performLogin({}); + await performLogin(logger); expect(storage.clear).toHaveBeenCalledOnce(); expect(storage.write).toHaveBeenCalledWith(FRESH_TOKENS); @@ -74,7 +77,7 @@ describe("performLogin when the refresh fails transiently", () => { const transientError = { kind: "refresh-failed", cause: new Error("ETIMEDOUT") } as const; vi.mocked(refreshTokens).mockResolvedValue(err(transientError)); - const result = await performLogin({}); + const result = await performLogin(logger); expect(loginViaDeviceFlow).not.toHaveBeenCalled(); expect(storage.clear).not.toHaveBeenCalled(); @@ -88,7 +91,7 @@ describe("performLogin when the refresh succeeds", () => { vi.mocked(createKeyringStorage).mockReturnValue(storage); vi.mocked(refreshTokens).mockResolvedValue(ok(FRESH_TOKENS)); - const result = await performLogin({}); + const result = await performLogin(logger); expect(loginViaDeviceFlow).not.toHaveBeenCalled(); expect(result).toEqual(ok({ isAlreadyAuthenticated: false, identifier: "new@example.com" })); From 6e55fa039a01fec7ed16fe877ba4d7afce9aed10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Kir=C3=A1=C4=BE?= <54802833+IvanKiral@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:29:57 +0200 Subject: [PATCH 02/25] feat: add `kontent mapi` raw Management API passthrough command (#67) * feat: add `kontent mapi` raw Management API passthrough command * refactor: polish raw mapi command and harden its tests --- CLAUDE.md | 2 +- TELEMETRY.md | 10 + src/commands/mapi/mapi.ts | 19 ++ src/commands/mapi/request.ts | 313 ++++++++++++++++++++++++++++++++ src/core/mapi/request.ts | 70 +++++++ src/index.ts | 1 + src/lib/mapi/raw/client.ts | 181 ++++++++++++++++++ src/lib/mapi/raw/endpoint.ts | 63 +++++++ src/lib/mapi/raw/headers.ts | 34 ++++ test/helpers/assertResult.ts | 17 ++ test/helpers/mapiTestAdapter.ts | 63 +++++++ test/integration/mapi.test.ts | 209 +++++++++++++++++++++ test/unit/endpoint.test.ts | 84 +++++++++ test/unit/headers.test.ts | 66 +++++++ test/unit/retryAfter.test.ts | 47 +++++ 15 files changed, 1178 insertions(+), 1 deletion(-) create mode 100644 src/commands/mapi/mapi.ts create mode 100644 src/commands/mapi/request.ts create mode 100644 src/core/mapi/request.ts create mode 100644 src/lib/mapi/raw/client.ts create mode 100644 src/lib/mapi/raw/endpoint.ts create mode 100644 src/lib/mapi/raw/headers.ts create mode 100644 test/helpers/assertResult.ts create mode 100644 test/helpers/mapiTestAdapter.ts create mode 100644 test/integration/mapi.test.ts create mode 100644 test/unit/endpoint.test.ts create mode 100644 test/unit/headers.test.ts create mode 100644 test/unit/retryAfter.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6248239..afb5ba3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ Adding a command: export a `register: RegisterCommand` (see `src/commands/login/ ### API clients - `iapi` (`src/lib/iapi`) — internal Kontent.ai API; hand-rolled client, one file per endpoint, over `@kontent-ai/core-sdk`. Endpoint validators (the `schema` field) must be **`zod/mini`** (`import * as z from "zod/mini"`) — classic `zod` won't infer the payload. -- `mapi` (`src/lib/mapi`) — public Management API via `@kontent-ai/management-sdk`. +- `mapi` (`src/lib/mapi`) — public Management API via `@kontent-ai/management-sdk`. `src/lib/mapi/raw` is the deliberate opposite: an adapter-backed passthrough (no schema, no response interpretation) behind `kontent mapi`, where a 4xx/5xx is a result, not an error. - `@kontent-ai/core-sdk` — shared HTTP/SDK layer both clients build on. **Commands build clients; core receives them.** The command builds the `iapiClient`/`mapiClient` and passes them into core (e.g. `performBootstrap(params, { logger, iapiClient, mapiClient })`); core never constructs clients itself. Auth failure is handled in the command, not surfaced as a core `Result` error. diff --git a/TELEMETRY.md b/TELEMETRY.md index 84d4ce1..203e267 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -49,6 +49,16 @@ identifiers (GUIDs) for the resource they act on — e.g. bootstrap adds `projec `subscription`, `sample-project-type`. These are never content, credentials, or command argument values. +`kontent mapi` emits `cli__mapi` with two extra properties: + +| Property | Description | +| ------------- | -------------------------------------------------------------------- | +| `status-code` | HTTP status code the Management API answered with | +| `auth-source` | Which credential was used: `header` (an `Authorization` header), `mapi-key` (`--mapiKey`/`KONTENT_MAPI_KEY`), or `login` (stored login token) | + +The requested endpoint path is never sent — it carries environment ids and +codenames. `auth-source` names the mechanism, never the credential. + ## What is NOT collected - Credentials of any kind: API keys, access tokens, passwords. diff --git a/src/commands/mapi/mapi.ts b/src/commands/mapi/mapi.ts new file mode 100644 index 0000000..caa6b9e --- /dev/null +++ b/src/commands/mapi/mapi.ts @@ -0,0 +1,19 @@ +import type { RegisterCommand } from "../../types/yargs.js"; +import { register as registerRequest } from "./request.js"; + +// A parent with a default child, not a flat `mapi `: flat siblings would +// both key on `mapi`, and the later registration would silently swallow every +// endpoint. Any future subcommand name is also permanently unreachable as an +// endpoint, so it must not collide with a Management API path segment. +const subcommandsToRegister: ReadonlyArray = [registerRequest]; + +export const register: RegisterCommand = (y, deps) => + y.command({ + command: "mapi", + describe: "Management API commands", + builder: (sub) => + subcommandsToRegister.reduce((current, registerSub) => registerSub(current, deps), sub), + handler: () => { + // parent command is a group; the default subcommand handles execution + }, + }); diff --git a/src/commands/mapi/request.ts b/src/commands/mapi/request.ts new file mode 100644 index 0000000..cba0d58 --- /dev/null +++ b/src/commands/mapi/request.ts @@ -0,0 +1,313 @@ +import { readFile } from "node:fs/promises"; +import type { Header, HttpMethod } from "@kontent-ai/core-sdk"; +import { match } from "ts-pattern"; +import { + type MapiRequestError, + type MapiResponse, + performRawMapiRequest, +} from "../../core/mapi/request.js"; +import { formatAuthError } from "../../lib/auth/formatAuthError.js"; +import { getValidAccessToken } from "../../lib/auth/tokenAccess.js"; +import type { AuthError } from "../../lib/auth/types.js"; +import { createMapiRawClient } from "../../lib/mapi/raw/client.js"; +import { parseHeaders } from "../../lib/mapi/raw/headers.js"; +import { err, isErr, map, ok, type Result, tryAsync } from "../../lib/result.js"; +import type { Telemetry } from "../../lib/telemetry/tracking.js"; +import { createLoggerFromArgs, type Logger, type LogOptions } from "../../log.js"; +import type { RegisterCommand } from "../../types/yargs.js"; + +type RequestArgs = LogOptions & + Readonly<{ + endpoint: string; + envId: string; + mapiKey?: string | undefined; + method?: string | undefined; + header?: ReadonlyArray | undefined; + input?: string | undefined; + include?: boolean | undefined; + }>; + +export const register: RegisterCommand = (sub, deps) => + sub.command({ + command: "$0 ", + describe: "Send an authenticated request to the Management API", + builder: (b) => + b + // `` only makes it required at runtime; demandOption narrows the type. + .positional("endpoint", { + type: "string", + demandOption: true, + describe: 'API path, e.g. "types" or "projects/{environment_id}/types"', + }) + .option("envId", { + type: "string", + demandOption: true, + describe: "Environment ID (Guid)", + }) + .option("mapiKey", { + type: "string", + describe: "Management API key. Defaults to the logged-in user's token", + }) + .option("method", { + type: "string", + alias: "X", + describe: "HTTP method. (default: GET, or POST with --input)", + }) + .option("header", { + type: "string", + array: true, + alias: "H", + describe: + 'Request header in the "Name: value" format. Repeatable. An Authorization header takes precedence over --mapiKey and the stored login token', + }) + .option("input", { + type: "string", + describe: 'File with the request body, or "-" to read stdin', + }) + // Without nargs, yargs-parser reads the lone "-" of `--input -` as a + // positional and .strict() then rejects it as an unknown argument. + .nargs("input", 1) + .option("include", { + type: "boolean", + alias: "i", + default: false, + describe: "Print the status line and response headers before the body", + }) + // A query string needs quoting: "?" is a glob character in zsh and bash. + .example("$0 mapi 'types?limit=10' --envId ", "List the first 10 content types") + .example( + "$0 mapi types --envId --input body.json", + "Create a content type from a file (--input implies POST)", + ) + .example("$0 mapi 'items/' -X DELETE --envId ", "Delete a content item") + .example( + "$0 mapi types -H 'X-Foo: 1' -H 'X-Bar: 2' --envId ", + "Send extra headers (-H is repeatable)", + ) + .example( + 'echo \'{"name":"Article"}\' | $0 mapi types --envId --input -', + "Create a content type from a piped body", + ), + handler: async (args) => runRequest(args, createLoggerFromArgs(args), deps.telemetry), + }); + +const runRequest = async ( + args: RequestArgs, + logger: Logger, + telemetry: Telemetry, +): Promise => { + const tracker = telemetry.startCommandTracking("mapi", logger); + + const prepared = await prepareRequest(args); + if (isErr(prepared)) { + tracker.fail(prepared.error.kind); + logger.error(prepared.error.message); + process.exitCode = 1; + return; + } + + const credential = await resolveCredential(prepared.value.headers, args.mapiKey); + if (isErr(credential)) { + tracker.fail(`auth:${credential.error.kind}`); + logger.error(formatAuthError(credential.error)); + process.exitCode = 1; + return; + } + const { token, source } = credential.value; + + const controller = new AbortController(); + const abortRequest = () => controller.abort(); + process.once("SIGINT", abortRequest); + + const result = await performRawMapiRequest( + { + ...prepared.value, + endpoint: args.endpoint, + envId: args.envId, + abortSignal: controller.signal, + }, + { logger, client: createMapiRawClient({ token }) }, + ); + process.off("SIGINT", abortRequest); + + if (isErr(result)) { + tracker.fail(result.error.kind, { "auth-source": source }); + logger.error(result.error.message); + process.exitCode = 1; + return; + } + + writeResponse(result.value, args.include === true); + + if (result.value.statusCode >= 400) { + tracker.fail(`http-${result.value.statusCode}`, { + "status-code": result.value.statusCode, + "auth-source": source, + }); + logger.error(formatFailure(result.value, source)); + process.exitCode = 1; + return; + } + + tracker.succeed({ "status-code": result.value.statusCode, "auth-source": source }); +}; + +type PreparedRequest = Readonly<{ + method: HttpMethod; + headers: ReadonlyArray
; + body: string | Blob | null; +}>; + +type AuthSource = "login" | "mapi-key" | "header"; + +type Credential = Readonly<{ token?: string | undefined; source: AuthSource }>; + +const httpMethods = [ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", +] as const satisfies ReadonlyArray; + +const prepareRequest = async ( + args: RequestArgs, +): Promise> => { + const method = resolveMethod(args.method, args.input !== undefined); + if (isErr(method)) { + return method; + } + + const headers = parseHeaders(args.header ?? []); + if (isErr(headers)) { + return err({ kind: "invalid-header", message: headers.error }); + } + + const body = args.input === undefined ? ok(null) : await readInput(args.input); + if (isErr(body)) { + return body; + } + + return ok({ + method: method.value, + // The default goes first so an explicit -H Content-Type wins the merge. + headers: + body.value === null + ? headers.value + : [{ name: "Content-Type", value: "application/json" }, ...headers.value], + body: body.value, + }); +}; + +/** + * Two rules, the same ones curl and `gh api` apply: + * + * - no `-X`: GET, or POST when `--input` supplies a body; + * - `-X` given: that method verbatim, body included if there is one - so + * `-X GET --input` sends a GET with a body rather than second-guessing it. + * + * A yargs `default` would break the first rule: it is indistinguishable from a + * typed `-X GET`, which would turn every `--input` into a GET with a body. + */ +const resolveMethod = ( + raw: string | undefined, + hasInput: boolean, +): Result => { + if (raw === undefined) { + return ok(hasInput ? "POST" : "GET"); + } + + const method = httpMethods.find((known) => known === raw.toUpperCase()); + if (method === undefined) { + return err({ + kind: "invalid-method", + message: `Unsupported HTTP method "${raw}". Use one of ${httpMethods.join(", ")}.`, + }); + } + return ok(method); +}; + +const readInput = async (input: string): Promise> => { + if (input !== "-") { + return await tryAsync( + async () => new Blob([Uint8Array.from(await readFile(input))]), + (cause) => ({ + kind: "unreadable-input" as const, + message: `Failed to read "${input}": ${describeCause(cause)}`, + }), + ); + } + + // Without this guard the command would wait forever for input nobody is piping. + if (process.stdin.isTTY) { + return err({ + kind: "unreadable-input", + message: "Nothing is piped to stdin. Pipe the body in, or pass --input .", + }); + } + + return await tryAsync( + async () => new Blob([await readStdin()]), + (cause) => ({ + kind: "unreadable-input" as const, + message: `Failed to read stdin: ${describeCause(cause)}`, + }), + ); +}; + +const readStdin = async (): Promise> => { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer); + } + return Uint8Array.from(Buffer.concat(chunks)); +}; + +// Each source suppresses the ones below it, so a supplied credential never triggers +// a keychain read that could fail on a machine that never ran `kontent login`. +const resolveCredential = async ( + headers: ReadonlyArray
, + mapiKey: string | undefined, +): Promise> => { + if (headers.some((header) => header.name.toLowerCase() === "authorization")) { + return ok({ source: "header" }); + } + if (mapiKey !== undefined) { + return ok({ token: mapiKey, source: "mapi-key" }); + } + return map(await getValidAccessToken(), (token) => ({ token, source: "login" }) as const); +}; + +const writeResponse = (response: MapiResponse, shouldIncludeHeaders: boolean): void => { + if (shouldIncludeHeaders) { + const headerLines = response.headers.map((header) => `${header.name}: ${header.value}`); + process.stdout.write( + [`HTTP/1.1 ${response.statusCode} ${response.statusText}`, ...headerLines, "", ""].join("\n"), + ); + } + + if (response.payload !== null) { + process.stdout.write(`${JSON.stringify(response.payload, null, 2)}\n`); + } +}; + +const formatFailure = (response: MapiResponse, source: AuthSource): string => { + const summary = `HTTP ${response.statusCode} ${response.statusText}${ + response.payload === null ? " (non-JSON response body omitted)" : "" + }`; + + if (response.statusCode !== 401) { + return summary; + } + + const hint = match(source) + .with("header", () => "Check the Authorization header you supplied.") + .with("mapi-key", () => "Check your Management API key.") + .with("login", () => "Run `kontent login` to sign in again.") + .exhaustive(); + + return `${summary}\n${hint}`; +}; + +const describeCause = (cause: unknown): string => + cause instanceof Error ? cause.message : String(cause); diff --git a/src/core/mapi/request.ts b/src/core/mapi/request.ts new file mode 100644 index 0000000..6cbbf40 --- /dev/null +++ b/src/core/mapi/request.ts @@ -0,0 +1,70 @@ +import type { Header, HttpMethod, JsonValue } from "@kontent-ai/core-sdk"; +import { executeRawRequest, type MapiRawClient } from "../../lib/mapi/raw/client.js"; +import { resolveEndpoint } from "../../lib/mapi/raw/endpoint.js"; +import { err, isErr, ok, type Result } from "../../lib/result.js"; +import type { Logger } from "../../log.js"; + +export type MapiRequestParams = Readonly<{ + endpoint: string; + envId: string; + method: HttpMethod; + headers: ReadonlyArray
; + body: string | Blob | null; + abortSignal?: AbortSignal; +}>; + +export type MapiResponse = Readonly<{ + statusCode: number; + statusText: string; + headers: ReadonlyArray
; + payload: JsonValue; +}>; + +/** + * The ways the command ends without an HTTP answer to show the user. + * + * `transport` is reserved for a request that could not be made; every status the + * API answers with, including 4xx and 5xx, is an `ok` result. The remaining kinds + * are raised by the command while it builds the params, before anything is sent. + */ +export type MapiRequestError = + | Readonly<{ kind: "invalid-endpoint"; message: string }> + | Readonly<{ kind: "invalid-header"; message: string }> + | Readonly<{ kind: "invalid-method"; message: string }> + | Readonly<{ kind: "unreadable-input"; message: string }> + | Readonly<{ kind: "transport"; message: string }>; + +export const performRawMapiRequest = async ( + params: MapiRequestParams, + deps: Readonly<{ logger: Logger; client: MapiRawClient }>, +): Promise> => { + const url = resolveEndpoint(params.endpoint, { + baseUrl: deps.client.baseUrl, + envId: params.envId, + }); + if (isErr(url)) { + return err({ kind: "invalid-endpoint", message: url.error.message }); + } + + const response = await executeRawRequest( + deps.client, + { + url: url.value, + method: params.method, + headers: params.headers, + body: params.body, + abortSignal: params.abortSignal, + }, + deps.logger, + ); + if (isErr(response)) { + return err({ kind: "transport", message: response.error }); + } + + return ok({ + statusCode: response.value.status, + statusText: response.value.statusText, + headers: response.value.responseHeaders, + payload: response.value.payload, + }); +}; diff --git a/src/index.ts b/src/index.ts index 657a36f..efdad53 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import type { CommandDeps, RegisterCommand } from "./types/yargs.js"; const commandsToRegister: ReadonlyArray = [ (await import("./commands/login/login.js")).register, (await import("./commands/logout/logout.js")).register, + (await import("./commands/mapi/mapi.js")).register, (await import("./commands/project/project.js")).register, (await import("./commands/telemetry/telemetry.js")).register, ]; diff --git a/src/lib/mapi/raw/client.ts b/src/lib/mapi/raw/client.ts new file mode 100644 index 0000000..59814df --- /dev/null +++ b/src/lib/mapi/raw/client.ts @@ -0,0 +1,181 @@ +import { + AdapterAbortError, + AdapterParseError, + type AdapterResponse, + createSdkIdHeader, + getDefaultHttpAdapter, + type Header, + type HttpAdapter, + type HttpMethod, + type JsonValue, + type SdkInfo, +} from "@kontent-ai/core-sdk"; + +// biome-ignore lint/correctness/useImportExtensions: JSON imports must keep the .json extension +import pkg from "../../../../package.json" with { type: "json" }; +import type { Logger } from "../../../log.js"; +import { kontentManagementUrl } from "../../config/kontentUrl.js"; +import { err, isErr, type Result, tryAsync } from "../../result.js"; + +const MAX_RETRY_ATTEMPTS = 3; +const DEFAULT_RETRY_DELAY_MS = 1000; +const TOO_MANY_REQUESTS = 429; + +const mapiSdkInfo: SdkInfo = { + name: pkg.name, + version: pkg.version, + host: "npmjs.com", +}; + +/** + * A passthrough transport for the Management API: no schema, no response + * interpretation. The typed, validated counterpart is `src/lib/mapi/client.ts`. + */ +export type MapiRawClient = Readonly<{ + baseUrl: string; + // Absent when the caller carries its own Authorization header; the client then adds none. + token?: string | undefined; + adapter: HttpAdapter; + sdkInfo: SdkInfo; +}>; + +export type RawRequest = Readonly<{ + url: URL; + method: HttpMethod; + headers: ReadonlyArray
; + body: string | Blob | null; + abortSignal?: AbortSignal; +}>; + +export const createMapiRawClient = ( + params: Readonly<{ token?: string | undefined; baseUrl?: string; adapter?: HttpAdapter }>, +): MapiRawClient => ({ + baseUrl: params.baseUrl ?? kontentManagementUrl(), + token: params.token, + adapter: params.adapter ?? getDefaultHttpAdapter(), + sdkInfo: mapiSdkInfo, +}); + +/** + * Sends the request and hands back whatever came off the wire. A 4xx/5xx is a + * result, not an error - only a request that could not be made at all fails. + * Retries 429 (which means the request was rejected, never executed) and nothing + * else, so a non-idempotent call is never sent twice. + */ +export const executeRawRequest = async ( + client: MapiRawClient, + request: RawRequest, + logger: Logger, +): Promise, string>> => { + const executeRequest = client.adapter.executeRequest; + if (executeRequest === undefined) { + return err("The configured HTTP adapter cannot execute requests."); + } + + const requestHeaders = mergeHeaders( + client.token === undefined + ? [createSdkIdHeader(client.sdkInfo)] + : [ + createSdkIdHeader(client.sdkInfo), + { name: "Authorization", value: `Bearer ${client.token}` }, + ], + request.headers, + ); + logger.info("verbose", formatTrace(request, requestHeaders)); + + const send = async (attempt: number): Promise, string>> => { + const response = await tryAsync( + async () => + executeRequest({ + url: request.url, + method: request.method, + body: request.body, + requestHeaders, + abortSignal: request.abortSignal, + }), + describeTransportError, + ); + + if (isErr(response) || response.value.status !== TOO_MANY_REQUESTS) { + return response; + } + + if (attempt >= MAX_RETRY_ATTEMPTS) { + return response; + } + + const delayMs = retryAfterMs(response.value.responseHeaders); + logger.warning( + "standard", + `Rate limited (429). Retrying in ${delayMs} ms (attempt ${attempt + 1}/${MAX_RETRY_ATTEMPTS}).`, + ); + await delay(delayMs); + return await send(attempt + 1); + }; + + return await send(0); +}; + +/** + * `Retry-After` comes in two legal forms: delta-seconds or an HTTP-date. Both are + * honored; anything absent or unparseable falls back to a second rather than + * retrying immediately. The clamp matters because a date already in the past - a + * slow hop, a skewed clock - would otherwise produce a negative delay. + */ +export const retryAfterMs = (headers: ReadonlyArray
): number => { + const raw = headers.find((header) => header.name.toLowerCase() === "retry-after")?.value.trim(); + if (raw === undefined) { + return DEFAULT_RETRY_DELAY_MS; + } + + const seconds = Number(raw); + if (Number.isFinite(seconds)) { + return Math.max(0, seconds * 1000); + } + + const dateMs = Date.parse(raw); + if (Number.isNaN(dateMs)) { + return DEFAULT_RETRY_DELAY_MS; + } + return Math.max(0, dateMs - Date.now()); +}; + +// Names are canonicalized to lowercase - what fetch (and HTTP/2) put on the wire +// anyway - so the merged set is deterministic regardless of the caller's casing. +const mergeHeaders = ( + base: ReadonlyArray
, + overrides: ReadonlyArray
, +): ReadonlyArray
=> [ + ...[...base, ...overrides] + .reduce((merged, header) => { + const name = header.name.toLowerCase(); + return merged.set(name, { name, value: header.value }); + }, new Map()) + .values(), +]; + +const formatTrace = (request: RawRequest, headers: ReadonlyArray
): string => { + const headerLines = headers.map( + (header) => + ` ${header.name}: ${header.name.toLowerCase() === "authorization" ? "" : header.value}`, + ); + return [`${request.method} ${request.url.toString()}`, ...headerLines].join("\n"); +}; + +const describeTransportError = (cause: unknown): string => { + if (cause instanceof AdapterAbortError) { + return "The request was aborted."; + } + if (cause instanceof AdapterParseError) { + return "The response could not be parsed as JSON."; + } + if (cause instanceof Error) { + return cause.message; + } + return String(cause); +}; + +const delay = async (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); diff --git a/src/lib/mapi/raw/endpoint.ts b/src/lib/mapi/raw/endpoint.ts new file mode 100644 index 0000000..23bfd32 --- /dev/null +++ b/src/lib/mapi/raw/endpoint.ts @@ -0,0 +1,63 @@ +import { err, fromThrowable, isOk, ok, type Result } from "../../result.js"; + +export type EndpointError = Readonly<{ + kind: "absolute-url" | "traversal" | "empty"; + message: string; +}>; + +/** + * Turns a user-supplied endpoint into an absolute Management API URL. A path that + * already starts with `projects/` is kept verbatim (with `{environment_id}` filled + * in); anything else is scoped to the environment. + */ +export const resolveEndpoint = ( + endpoint: string, + params: Readonly<{ baseUrl: string; envId: string }>, +): Result => { + const trimmed = endpoint.trim(); + + if (trimmed === "") { + return err({ kind: "empty", message: "The endpoint is empty." }); + } + + if (isAbsolute(trimmed)) { + return err({ + kind: "absolute-url", + message: `The endpoint "${endpoint}" must be a path, not an absolute URL. The host is always the Management API.`, + }); + } + + // Strip leading slashes so "/types" and "types" resolve identically - the base + // URL already carries the "/v2" prefix the path is appended to. + const relative = trimmed.replace(/^\/+/, ""); + + if (hasTraversal(relative)) { + return err({ + kind: "traversal", + message: `The endpoint "${endpoint}" must not contain ".." path segments.`, + }); + } + + const encodedEnvId = encodeURIComponent(params.envId); + const withEnvId = relative.replaceAll("{environment_id}", encodedEnvId); + const path = withEnvId.startsWith("projects/") + ? withEnvId + : `projects/${encodedEnvId}/${withEnvId}`; + + return ok(new URL(`${params.baseUrl.replace(/\/+$/, "")}/${path}`)); +}; + +const isAbsolute = (endpoint: string): boolean => + /^[a-z][a-z\d+\-.]*:/i.test(endpoint) || endpoint.startsWith("//"); + +const hasTraversal = (relative: string): boolean => + (relative.split("?")[0] ?? relative).split("/").some((segment) => decodeSafely(segment) === ".."); + +// A malformed percent-escape is not traversal; keep the raw segment and let the URL carry it. +const decodeSafely = (segment: string): string => { + const decoded = fromThrowable( + () => decodeURIComponent(segment), + () => segment, + ); + return isOk(decoded) ? decoded.value : decoded.error; +}; diff --git a/src/lib/mapi/raw/headers.ts b/src/lib/mapi/raw/headers.ts new file mode 100644 index 0000000..a5b5410 --- /dev/null +++ b/src/lib/mapi/raw/headers.ts @@ -0,0 +1,34 @@ +import type { Header } from "@kontent-ai/core-sdk"; +import { err, flatMap, map, ok, type Result } from "../../result.js"; + +/** + * Parses repeated `Name: value` command-line entries. Fails on the first malformed + * entry so a typo never reaches the API as a silently dropped header. + */ +export const parseHeaders = (raw: ReadonlyArray): Result, string> => + raw.reduce, string>>( + (acc, entry) => + flatMap(acc, (headers) => + map(parseHeader(entry), (header) => [...headers, header] as ReadonlyArray
), + ), + ok([]), + ); + +// RFC 9110 token characters. +const headerNamePattern = /^[!#$%&'*+\-.^_`|~\dA-Za-z]+$/; + +const parseHeader = (entry: string): Result => { + const separatorIndex = entry.indexOf(":"); + if (separatorIndex < 1) { + return err(`Invalid header "${entry}". Expected the "Name: value" format.`); + } + + const name = entry.slice(0, separatorIndex).trim(); + if (!headerNamePattern.test(name)) { + return err( + `Invalid header name "${name}". Names may contain only letters, digits and !#$%&'*+-.^_\`|~ (RFC 9110 token).`, + ); + } + + return ok({ name, value: entry.slice(separatorIndex + 1).trim() }); +}; diff --git a/test/helpers/assertResult.ts b/test/helpers/assertResult.ts new file mode 100644 index 0000000..0fdbb39 --- /dev/null +++ b/test/helpers/assertResult.ts @@ -0,0 +1,17 @@ +import type { Result } from "../../src/lib/result.js"; + +export function assertOk( + result: Result, +): asserts result is { readonly kind: "ok"; readonly value: T } { + if (result.kind !== "ok") { + throw new Error(`Expected an ok result, got err: ${JSON.stringify(result.error)}`); + } +} + +export function assertErr( + result: Result, +): asserts result is { readonly kind: "err"; readonly error: E } { + if (result.kind !== "err") { + throw new Error(`Expected an err result, got ok: ${JSON.stringify(result.value)}`); + } +} diff --git a/test/helpers/mapiTestAdapter.ts b/test/helpers/mapiTestAdapter.ts new file mode 100644 index 0000000..1b16289 --- /dev/null +++ b/test/helpers/mapiTestAdapter.ts @@ -0,0 +1,63 @@ +import type { AdapterRequestOptions, Header, HttpAdapter, JsonValue } from "@kontent-ai/core-sdk"; + +export type MapiReply = Readonly<{ + status?: number; + statusText?: string; + headers?: ReadonlyArray
; + payload?: JsonValue; + throws?: Error; +}>; + +export type MapiRoute = Readonly<{ + method: string; + path: RegExp; + // Consumed in order across calls to the same route; the last one repeats. + replies: ReadonlyArray; +}>; + +export type MapiTestAdapter = Readonly<{ + adapter: HttpAdapter; + requests: ReadonlyArray; +}>; + +// A fake at core-sdk's HttpAdapter seam, so the real client code runs against a +// declarative route table and every request is captured for assertions. +export const mapiTestAdapter = (routes: ReadonlyArray): MapiTestAdapter => { + const requests: AdapterRequestOptions[] = []; + const callCounts = new Map(); + + const adapter: HttpAdapter = { + executeRequest: (options) => { + requests.push(options); + + const route = routes.find( + (candidate) => + candidate.method === options.method && candidate.path.test(options.url.pathname), + ); + if (route === undefined) { + throw new Error(`No mapi stub for ${options.method} ${options.url.pathname}`); + } + + const callCount = callCounts.get(route) ?? 0; + callCounts.set(route, callCount + 1); + const reply = route.replies[Math.min(callCount, route.replies.length - 1)]; + if (reply === undefined) { + throw new Error(`Route ${route.method} ${route.path} has no replies`); + } + + if (reply.throws !== undefined) { + throw reply.throws; + } + + return Promise.resolve({ + payload: reply.payload ?? null, + responseHeaders: reply.headers ?? [], + status: reply.status ?? 200, + statusText: reply.statusText ?? "OK", + url: options.url, + }); + }, + }; + + return { adapter, requests }; +}; diff --git a/test/integration/mapi.test.ts b/test/integration/mapi.test.ts new file mode 100644 index 0000000..bd8786f --- /dev/null +++ b/test/integration/mapi.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from "vitest"; +import { type MapiRequestParams, performRawMapiRequest } from "../../src/core/mapi/request.js"; +import { createMapiRawClient } from "../../src/lib/mapi/raw/client.js"; +import { createLogger } from "../../src/log.js"; +import { assertErr, assertOk } from "../helpers/assertResult.js"; +import { type MapiRoute, mapiTestAdapter } from "../helpers/mapiTestAdapter.js"; + +const ENV_ID = "11111111-2222-3333-4444-555555555555"; +const BASE_URL = "https://manage.test/v2"; + +const logger = createLogger("none"); + +const makeParams = (overrides: Partial = {}): MapiRequestParams => ({ + endpoint: "types", + envId: ENV_ID, + method: "GET", + headers: [], + body: null, + ...overrides, +}); + +type RunOptions = Readonly<{ + params?: Partial; + token?: string | undefined; +}>; + +const run = async (routes: ReadonlyArray, options: RunOptions = {}) => { + const { adapter, requests } = mapiTestAdapter(routes); + const client = createMapiRawClient({ + // `token: undefined` means an explicitly tokenless client, distinct from omitting it. + token: "token" in options ? options.token : "secret-token", + baseUrl: BASE_URL, + adapter, + }); + const result = await performRawMapiRequest(makeParams(options.params), { logger, client }); + return { result, requests }; +}; + +const typesRoute: MapiRoute = { + method: "GET", + path: /\/types$/, + replies: [{ payload: { types: [] } }], +}; + +describe("performRawMapiRequest", () => { + it("sends an authenticated GET to the environment-scoped endpoint", async () => { + const { result, requests } = await run([typesRoute]); + + expect(result).toEqual({ + kind: "ok", + value: { statusCode: 200, statusText: "OK", headers: [], payload: { types: [] } }, + }); + expect(requests).toHaveLength(1); + expect(requests[0]?.url.toString()).toBe(`${BASE_URL}/projects/${ENV_ID}/types`); + expect(requests[0]?.requestHeaders).toContainEqual({ + name: "authorization", + value: "Bearer secret-token", + }); + expect(requests[0]?.requestHeaders?.map((header) => header.name)).toContain("x-kc-sdkid"); + }); + + it("sends one header per name, the last occurrence winning", async () => { + const { requests } = await run([typesRoute], { + params: { + headers: [ + { name: "Content-Type", value: "application/json" }, + { name: "content-type", value: "text/plain" }, + ], + }, + }); + + const contentTypes = (requests[0]?.requestHeaders ?? []).filter( + (header) => header.name === "content-type", + ); + expect(contentTypes.map((header) => header.value)).toEqual(["text/plain"]); + }); + + it("adds no Authorization of its own when the client has no token", async () => { + const { requests } = await run([typesRoute], { + token: undefined, + params: { headers: [{ name: "Authorization", value: "Bearer caller-token" }] }, + }); + + const authorizations = (requests[0]?.requestHeaders ?? []).filter( + (header) => header.name === "authorization", + ); + expect(authorizations.map((header) => header.value)).toEqual(["Bearer caller-token"]); + }); + + it("passes the body through untouched", async () => { + const { requests } = await run( + [{ method: "POST", path: /\/types$/, replies: [{ status: 201 }] }], + { + params: { + method: "POST", + body: '{"codename":"x"}', + }, + }, + ); + + expect(requests[0]?.body).toBe('{"codename":"x"}'); + }); + + it("reports a 4xx as a successful transport with the API payload", async () => { + const { result } = await run([ + { + method: "GET", + path: /\/types$/, + replies: [ + { + status: 404, + statusText: "Not Found", + payload: { message: "The requested content type was not found." }, + }, + ], + }, + ]); + + assertOk(result); + expect(result.value.statusCode).toBe(404); + expect(result.value.payload).toEqual({ + message: "The requested content type was not found.", + }); + }); + + it("retries a 429 honoring Retry-After and returns the next response", async () => { + const { result, requests } = await run([ + { + method: "GET", + path: /\/types$/, + replies: [ + { + status: 429, + statusText: "Too Many Requests", + headers: [{ name: "Retry-After", value: "0" }], + }, + { payload: { types: [] } }, + ], + }, + ]); + + expect(requests).toHaveLength(2); + assertOk(result); + expect(result.value.statusCode).toBe(200); + }); + + it("gives up on a persistent 429 after the retry budget", async () => { + const { result, requests } = await run([ + { + method: "GET", + path: /\/types$/, + replies: [ + { + status: 429, + statusText: "Too Many Requests", + headers: [{ name: "Retry-After", value: "0" }], + }, + ], + }, + ]); + + expect(requests).toHaveLength(4); + assertOk(result); + expect(result.value.statusCode).toBe(429); + }); + + it("does not retry a non-429 failure", async () => { + // If retrying ever leaks past 429, the second reply answers 201 and both asserts fail. + const { result, requests } = await run( + [ + { + method: "POST", + path: /\/types$/, + replies: [{ status: 503, statusText: "Service Unavailable" }, { status: 201 }], + }, + ], + { params: { method: "POST", body: "{}" } }, + ); + + expect(requests).toHaveLength(1); + assertOk(result); + expect(result.value.statusCode).toBe(503); + }); + + it("reports a failed request as a transport error", async () => { + const { result } = await run([ + { + method: "GET", + path: /\/types$/, + replies: [{ throws: new Error("socket hang up") }], + }, + ]); + + expect(result).toEqual({ + kind: "err", + error: { kind: "transport", message: "socket hang up" }, + }); + }); + + it("rejects an absolute endpoint before any request is made", async () => { + const { result, requests } = await run([typesRoute], { + params: { endpoint: "https://evil.example.com/types" }, + }); + + expect(requests).toHaveLength(0); + assertErr(result); + expect(result.error.kind).toBe("invalid-endpoint"); + }); +}); diff --git a/test/unit/endpoint.test.ts b/test/unit/endpoint.test.ts new file mode 100644 index 0000000..53f8c64 --- /dev/null +++ b/test/unit/endpoint.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { resolveEndpoint } from "../../src/lib/mapi/raw/endpoint.js"; + +const ENV_ID = "11111111-2222-3333-4444-555555555555"; +const params = { baseUrl: "https://manage.kontent.ai/v2", envId: ENV_ID } as const; + +const resolve = (endpoint: string) => resolveEndpoint(endpoint, params); + +describe("resolveEndpoint", () => { + it("scopes a bare path to the environment", () => { + const result = resolve("types"); + + expect(result.kind).toBe("ok"); + if (result.kind !== "ok") { + return; + } + expect(result.value.toString()).toBe(`https://manage.kontent.ai/v2/projects/${ENV_ID}/types`); + }); + + it("keeps a projects/ path verbatim and fills the environment placeholder", () => { + const result = resolve("projects/{environment_id}/types"); + + expect(result.kind).toBe("ok"); + if (result.kind !== "ok") { + return; + } + expect(result.value.toString()).toBe(`https://manage.kontent.ai/v2/projects/${ENV_ID}/types`); + }); + + it("keeps the query string", () => { + const result = resolve("types?limit=10"); + + expect(result.kind).toBe("ok"); + if (result.kind !== "ok") { + return; + } + expect(result.value.toString()).toBe( + `https://manage.kontent.ai/v2/projects/${ENV_ID}/types?limit=10`, + ); + expect(result.value.pathname).toBe(`/v2/projects/${ENV_ID}/types`); + expect(result.value.search).toBe("?limit=10"); + }); + + it("tolerates a leading slash", () => { + const result = resolve("/types"); + + expect(result.kind).toBe("ok"); + if (result.kind !== "ok") { + return; + } + expect(result.value.toString()).toBe(`https://manage.kontent.ai/v2/projects/${ENV_ID}/types`); + expect(result.value.pathname).toBe(`/v2/projects/${ENV_ID}/types`); + }); + + it("percent-encodes the environment id", () => { + const result = resolveEndpoint("types", { ...params, envId: "a b/c" }); + + expect(result.kind).toBe("ok"); + if (result.kind !== "ok") { + return; + } + expect(result.value.toString()).toBe("https://manage.kontent.ai/v2/projects/a%20b%2Fc/types"); + expect(result.value.pathname).toBe("/v2/projects/a%20b%2Fc/types"); + }); + + it.each([ + ["", "empty"], + [" ", "empty"], + ["https://evil.example.com/types", "absolute-url"], + ["//evil.example.com/types", "absolute-url"], + ["file:///etc/passwd", "absolute-url"], + ["../../admin", "traversal"], + ["types/../../admin", "traversal"], + ["types/%2e%2e/admin", "traversal"], + ])("rejects %j as %s", (endpoint, kind) => { + const result = resolve(endpoint); + + expect(result.kind).toBe("err"); + if (result.kind !== "err") { + return; + } + expect(result.error.kind).toBe(kind); + }); +}); diff --git a/test/unit/headers.test.ts b/test/unit/headers.test.ts new file mode 100644 index 0000000..edb73e3 --- /dev/null +++ b/test/unit/headers.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { parseHeaders } from "../../src/lib/mapi/raw/headers.js"; + +describe("parseHeaders", () => { + it("returns an empty list for no entries", () => { + const result = parseHeaders([]); + + expect(result).toEqual({ kind: "ok", value: [] }); + }); + + it("parses entries and trims around the separator", () => { + const result = parseHeaders(["Content-Type: application/json", "X-Foo:bar", "X-Empty:"]); + + expect(result).toEqual({ + kind: "ok", + value: [ + { name: "Content-Type", value: "application/json" }, + { name: "X-Foo", value: "bar" }, + { name: "X-Empty", value: "" }, + ], + }); + }); + + it("keeps colons inside the value", () => { + const result = parseHeaders(["X-Url: https://example.com/a:b"]); + + expect(result).toEqual({ + kind: "ok", + value: [{ name: "X-Url", value: "https://example.com/a:b" }], + }); + }); + + it.each(["no-separator", ": missing-name", ""])("rejects %j as a format error", (entry) => { + const result = parseHeaders([entry]); + + expect(result.kind).toBe("err"); + if (result.kind !== "err") { + return; + } + expect(result.error).toContain('Expected the "Name: value" format'); + }); + + it.each([ + "bad name: value", + "bad(name): value", + "naïve: value", + ])("rejects %j as a name error", (entry) => { + const result = parseHeaders([entry]); + + expect(result.kind).toBe("err"); + if (result.kind !== "err") { + return; + } + expect(result.error).toContain("Names may contain only letters, digits and"); + }); + + it("fails on the first malformed entry", () => { + const result = parseHeaders(["X-Good: 1", "oops"]); + + expect(result.kind).toBe("err"); + if (result.kind !== "err") { + return; + } + expect(result.error).toContain("oops"); + }); +}); diff --git a/test/unit/retryAfter.test.ts b/test/unit/retryAfter.test.ts new file mode 100644 index 0000000..e80a34e --- /dev/null +++ b/test/unit/retryAfter.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { retryAfterMs } from "../../src/lib/mapi/raw/client.js"; + +describe("retryAfterMs", () => { + it("defaults to one second without a Retry-After header", () => { + expect(retryAfterMs([])).toBe(1000); + }); + + it("converts a delay in seconds to milliseconds", () => { + expect(retryAfterMs([{ name: "Retry-After", value: "2" }])).toBe(2000); + }); + + it("matches the header name case-insensitively", () => { + expect(retryAfterMs([{ name: "retry-after", value: "3" }])).toBe(3000); + }); + + it("clamps a negative delay to zero", () => { + expect(retryAfterMs([{ name: "Retry-After", value: "-5" }])).toBe(0); + }); + + it("falls back to the default for an unparsable value", () => { + expect(retryAfterMs([{ name: "Retry-After", value: "garbage" }])).toBe(1000); + }); + + describe("with an HTTP-date value", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("waits until the given moment", () => { + expect(retryAfterMs([{ name: "Retry-After", value: "Thu, 01 Jan 2026 00:00:05 GMT" }])).toBe( + 5000, + ); + }); + + it("clamps a moment already in the past to zero", () => { + expect(retryAfterMs([{ name: "Retry-After", value: "Wed, 31 Dec 2025 23:59:00 GMT" }])).toBe( + 0, + ); + }); + }); +}); From 7b4549151eaa235baea3afa552d405ca0e3b0ff6 Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Tue, 18 Aug 2026 08:25:00 +0200 Subject: [PATCH 03/25] test: add e2e suite exercising `kontent mapi` against a cloned Kontent.ai environment --- .env.template | 14 ++ .github/workflows/e2e.yml | 51 ++++++++ CLAUDE.md | 2 + package.json | 1 + test/e2e/fixtures/kailogo.png | Bin 0 -> 7556 bytes test/e2e/helpers/config.ts | 17 +++ test/e2e/helpers/environment.ts | 71 +++++++++++ test/e2e/helpers/random.ts | 2 + test/e2e/helpers/runCli.ts | 66 ++++++++++ test/e2e/mapi.test.ts | 218 ++++++++++++++++++++++++++++++++ vitest.config.ts | 2 + vitest.e2e.config.ts | 28 ++++ 12 files changed, 472 insertions(+) create mode 100644 .github/workflows/e2e.yml create mode 100644 test/e2e/fixtures/kailogo.png create mode 100644 test/e2e/helpers/config.ts create mode 100644 test/e2e/helpers/environment.ts create mode 100644 test/e2e/helpers/random.ts create mode 100644 test/e2e/helpers/runCli.ts create mode 100644 test/e2e/mapi.test.ts create mode 100644 vitest.e2e.config.ts diff --git a/.env.template b/.env.template index 2044a32..56f2522 100644 --- a/.env.template +++ b/.env.template @@ -29,3 +29,17 @@ DO_NOT_TRACK= # Verbose telemetry logging for debugging. KONTENT_TELEMETRY_DEBUG= + +# --- E2E tests (pnpm test:e2e; the suite skips when these are unset) --- +# Unlike the runtime vars above, these ARE read from .env (by vitest.e2e.config.ts). + +# Management API key of the dedicated e2e project: access to all environments +# plus the Manage environments permission. +E2E_MAPI_KEY= + +# Empty template environment that each e2e run clones. Never written to. +E2E_SOURCE_ENV_ID= + +# Domain of the e2e project. The e2e run ignores KONTENT_URL above and defaults +# to production kontent.ai; set this only when the test project lives elsewhere. +E2E_KONTENT_URL=kontent.ai diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..46c7696 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,51 @@ +name: E2E + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +# A force-push cancels the superseded run; its cloned environment is still +# deleted by the always() cleanup step below. +concurrency: + group: e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e: + # Fork PRs cannot access the E2E_MAPI_KEY secret, so the job is skipped for + # them (grey check). Maintainers can run it via workflow_dispatch instead. + if: github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + env: + E2E_MAPI_KEY: ${{ secrets.E2E_MAPI_KEY }} + E2E_SOURCE_ENV_ID: ${{ vars.E2E_SOURCE_ENV_ID }} + # Empty is fine: vitest.e2e.config.ts falls back to production kontent.ai. + E2E_KONTENT_URL: ${{ vars.E2E_KONTENT_URL }} + E2E_ENV_ID_FILE: ${{ runner.temp }}/e2e-env-id + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Setup pnpm + uses: pnpm/action-setup@v5 + - name: Use Node.js from .nvmrc file + uses: actions/setup-node@v6 + with: + node-version-file: ".nvmrc" + cache: "pnpm" + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: E2E tests + run: pnpm test:e2e + # Deletes the cloned environment when the job died before afterAll could + # (cancellation, timeout). A normal run deletes it itself; the 404 here is fine. + - name: Delete leaked test environment + if: always() + run: | + if [ -s "$E2E_ENV_ID_FILE" ]; then + curl -s -X DELETE "https://manage.${E2E_KONTENT_URL:-kontent.ai}/v2/projects/$(cat "$E2E_ENV_ID_FILE")" \ + -H "Authorization: Bearer $E2E_MAPI_KEY" || true + fi diff --git a/CLAUDE.md b/CLAUDE.md index afb5ba3..0e4c9f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,8 @@ Every handler starts with `const logger = createLoggerFromArgs(args)` (`src/log. Vitest; `test/unit/` for pure unit tests, `test/integration/` for integration tests, `test/helpers/` for shared helpers. Run `pnpm test`. Inject fakes into core instead of real I/O — for iapi reuse `test/helpers/iapiTestClient.ts` (real client over core-sdk's `HttpAdapter` seam, declarative routes). +`test/e2e/` runs the built binary against a real Kontent.ai project (clone-per-run from an empty template env). Gated on `E2E_MAPI_KEY`/`E2E_SOURCE_ENV_ID` (green-skips when unset; deliberately not `KONTENT_`-prefixed — yargs `.env("KONTENT")` + `.strict()` would reject them). Run with `pnpm test:e2e` (own `vitest.e2e.config.ts`, loads `.env`); excluded from `pnpm test` and the before-halting gate. CI: `.github/workflows/e2e.yml` (master push, PRs, manual; fork PRs are skipped at the job level — no secret access). + ## Telemetry Amplitude-based, see `TELEMETRY.md`. New `KONTENT_*` env vars need a hidden yargs option registered in `src/index.ts` — `.strict()` + `.env("KONTENT")` rejects unknown env vars otherwise. diff --git a/package.json b/package.json index 64be098..bff8cdd 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "dev": "tsdown --watch", "typecheck": "tsc --noEmit", "test": "vitest run", + "test:e2e": "pnpm build && vitest run --config vitest.e2e.config.ts", "test:watch": "vitest", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/test/e2e/fixtures/kailogo.png b/test/e2e/fixtures/kailogo.png new file mode 100644 index 0000000000000000000000000000000000000000..78ac4ed5588b5beb77b9eeda4dd5162e8906cdfc GIT binary patch literal 7556 zcmX9@c|26#8y>q52?^OM5oKQzMo6+n_H9fWiLB9tF?LZTlr=JzWZ(B?Xqs$g4KuQg zv1FMsjAfW?{l@qA$Nk*Teb0TL`@H8o_c`~R`^Nl%!C5vzHUI!{*6{va3jlx)czUoh z(;~dL;PSN78Nd7X0RX@SiPMA5(BdkY7NiTXFwg~54P9EJT`)e?G1UP8YLnQHT$lg= zVFAOtI#xk+q*-G>r-@I!TmM>b_V=hN6-c|je6CUdIG*=Gj&$|cZ}u@vT=S-vDi|Ts zHHuC4^PkVl_j6QyjkUh-V)B@$#L8vM?yAt0Z#TYO<#3B-)3_n0XHlyZ_Q-g4f8g)j zhAOP#vkxUSCuC#avZyb`nzv;_8?mGPZ>Q^C1aMtMyBKk=x^L>ChcY3hb_24CfOy~@ z4UF_)0)HPI4PU;uUc#r;HlsZqIvl*OwKDw=8@h2cWg_`cdHR-+A<0R}Cud^QlQV0F z_~hlm=RmZNJyC z9Omgd({kV>)u=w_UMZjM{=-g5{Sqh`TghC^RHRQ%OU`Y%S3BeTwrbUGf)CwtF10^@ zl^_)J=U@`JWc~d!9Az-BM}) zrGfG4kqeMt5RDqPg}{HlFBguy$Um{iM%t2=Kr7UM@v7KvEiAomJ&>B7n-HfSe1>-X4jNp1 zydFju?>1PE9-WZPx-csmRn0Yr-1!w`_2KC&@0&;8_L|KY!CaG=f~)S~YY(s5rRQQ) z*U51)B1Vt)D}?`UZsMR>iUVwRTQ?)EMT|DyIv#sJrC^0+I%Vzthy$``2%BSv8z)E4 zQI{*I%a79HDPCCEFS9@crEBp07^fK2;K9s;`*jdD!+{aza-8z4T2@0}%9@c@KHa^T3z=x|P_vHKjfbU~Moi!k z&yZio{`G*>k{L|1q9aIHbfRj78}=36M$v-KQM-S1s7Z6f9fr$)zt-b;`-~RtBA3cszc4o9IXNE~)Nv*GO#o(+RBhsO>n0uR<8Ewj zh3+cd?^c~@&@S>Coz!u>T(Gzo+yt)$h`LwA3Do71y1Rz1Za^8V(!G*~ zQqvTvtXf~$i_UiaViL_~2`WmRw=Kh8*#*d@$sQT;z)8zXTuc#3MiVQx@xaap0d)X! z+TP!^<=D7=6jtEYod89=*4ZvBMN1}k2Uh;`D_ve{ho~3co7}jvw#BQQmjm>QLuBk6 zb<7e}*ZYiV9G_62&3?i6H2*vyODrzv_b}bbBJDxnA_E;kq4MX3`v6fci<6oM#AZw| zC3jDnx;nuS<(fShbXe{S)|SX6yOnFKs0#qTJ$}}>T4Lrci(?e^mbo+XA_lof4IWJ* z>XS)rhjl>aI$4139)%`Dv+oSASP&v^uWQqSU>fGZ8_1K{c3J+DFu7enz5S!J8b{*T z(Fh}TH7~c$hf9O+zC|Tv3*0ICu|Px@td9PvKy)? z!G-`Udlv?i050IQDnMpoG7P-oIBVY?JI?^P?cD?AE%%2Zj58S|U+wpMKzS9c~HyrKH{Uz~{(+3p*EnXruS8>(&*C55#n}Wuv>BIjg%80=A^4Wy2zQ$1Yw3ZD(zI7jI;1-UQvzOqN zZcjEAFN;;>?31sA4PeLe3l1XFX-Nz$;E+ZZM!h($nHK@3@iq(j1@=ZyCe+NywNrZT zJ_im<`XDpC>KQ%#`VT#2zIk{^V_dR3Xfq$_`JF}b-HPwgH7A6SMup2X-%qpUjQK#=E)YSDh8Sba2ucXvh=4*}W{p1v*ON8-=aPUpcGZ^Gnjgupl-wyS7Zl+k{It)XeV!Za`J_o`?ENCZ|(aRTGJp6qrlu7jLPu56IK1xxQZEb#mKrQ50#DA9$epX=W=AAbl zpy7zGE>?{!#LGp zoIp+$bGJ^-8?W;Vvet;ng~j+qbA~6FVAd8CzFv!oa8aA4H>%0 z3q^7yI(u_&Zu}7nVFU6!FNyRosIt8-TUO8>^0$XCTJHC9yf9CmE3J4ft^>+N>5q~c zO#w$c2S#&^kI92S$SnYgu~OpSh%ls6mejkz>RnSAUub2`p#GZ4`c`pOulXok0OI4g z7LtCp6Qhdtx{!Cb!TV1WsT)fJb4X2VZvV+FI_;F2D0d9w;JY~JtI@&s8VKwB;nz{!3QyTBVQcL)dx)N*4Ne<-^Z8VbUY4XrS2AUh)R8+Jt-Jv5X#m0t1Xv{iw@g{ zn=cjsn#(2-;X6^);(BlMq5}Ufe8Z}Ij|@5N!0L1Z~CAm42fn1CsdYLuGGv?1?zw=c{%FLTxkUre^ywUU<@)t#3gL| z|9KzGatRw%eJqA+1Bi_LpF@7Z)Mpb=^)5kiC1TNm{Je$^u|BofOQy?BzVNQ~^!}vi zv(#@i(G$%d#&X3#Ht2f!QN1W;P5c-->Y}tDFXVCn$-k!&uk<*xD^9o4SDUe8^;8$= zrLeLKG)r)}ZqBMTFp}mx3f0mYxb;^W%|dTIr+yN=op6=9A=K5f^x92~%K>}1_lc&v zhA;c=&80*pg2Rf@_<^YLYQDT-7fu9xDsRC$?y<5R2~_VJ+4x_jM83rQf(iL)cduW= zmG}g2P(+Rg$Tx%Kzm9#wzlJRbEQT!;4iYf)v2Rq_l9jXuT1b0QL|BuG)o%2^XKZC( zui9rYBDihfeHj?a3xd%o?aMCbZL)z%3m;g9>cAgpO2c$t{cC{@nGcSQw=n@Jnc2b% zb!FK7-;Y!Fs1LCk+sbGbX{r8p_j|EK`lY)>7)|0{py4JW?CJ11nDq;N%)5KLWN|r4 z^}$2z;c+Q56e;aVTj(gC0lJzIVTU3+g;fTt){d@TT6K*jvUg%><3EC1SRpD{@%}JU z7W|#bC#pCu0`wajRJr~>7We#PYKpRgBR~8xf!=j#&gWf62~hzKz-F$>qyPj)+i+Z` z@_mR!(_f2ksfy)r2l@Vkc(PTW;;{=T2-$CmLqFvg2&wTME>4u4MiW{Q1DWWvpP&82 zeB_5}d0U(e2bK_B#2o`sz4fZeB9%na_M?*3Qw4whHA%Rv@+mHYAA}u4zeSrV!2!lZ zj_UyCh5xh=&-eB`{C;=kbV4=JJ~qBUtCO~(~N z=%Gj<&NkR5Lny0nq&IHB1G!1I>`sct!sgIPBXhPc7>B?Xmuv-8oD+Qe`MN&R_a{c2 z5Xz^jsjMliuQFwyj99F~?f3U~C&eNrmA=VG$A8TF^w(fKIp2eBd*KIMJKX0B8HV6g z3UZf2bCi{+FEzD!{$Xz&<-3n3rW+{xTweQ9zOps@1~`Di2c z>MX3R|H!YXBO2NE#Rst&Hhm2zX4}skH;4tSB-k7{A&jbqHkLs<82Xeoo_t)K5@h?; zHtY!h4|Q~E_8RKxWgEEEJIvt=uh7wp&Vrgm8~E5kf(=<^!Gn%%_rSA0Z2XWGSI&To zuB}|93hJSYnWWy+D7XZ9^fknpX-lH^2}{s1U1N5(Wy@1so!kGi`_J}Y_8k%c-n=Q5 zdrH^s4pL5ed1`3Sf!u8aJpYw}*55wUL-NtdEeAO5+g+U(;192k|Ha3-h9N{LouH6= zv}ZDBguE=<39n*8a2u0RLI-~SqJ+?%Cn9KL^oaVXH8#rkz96G$M**c1j9l$S$8%nC z&D8l_Dg5k4)mCF1p9woYWezz@F2EI|IbNmddj7^8uc6~F zx|v(BbNa3z<9j9pnaFQxbYnBpF&)uoRkW$M@DauE(tU-FhMZ*)uaaO?FC7bfuf17t zyLex~IsRl&Yto!HIanVlS<(j)UahzxOZL~~qi;HTMk&f$25_m47Z_DGn)>*Cb#&O& zI2z~MLg|GVl_-~%5KRg_rYY-_{h`hX{k`o?{!s6E{*s;i8}PDyg@LcO3B-^xPqk}MU}~fp1?cVL zmKhn|HnDGo#V1_O;>}~E)leBgD@-7$x2Kl$@d+rpdUm+?u3Ksp<`Ac0{huCi>i-DS z*g#|=hBl2ufgiIT3UuM%IV(~vQb=Ndb5ojxOE7$gf?X6N= z^FM{yjOF!R|7oYj1+czX;6odS%|nu7H}>#FCh8>Gld*N=%byI}gD-s1NhH}HCbpsB z$HrTZxPvdRmzQ%Y8V0RpXdg|W)GW;5$#E}D+_bdHX%C+v%yx0BFwz?2;dbxMF-ggc z5Lz%eh#o(zgMhp@?BwPqEy<19S*`5}zi3tLqY;V&VlqEfZVpdalAj0ii<66R#e;(! za)aM%ots+AR+z5@wuRh}IKNYg>y?kT`t7FVyrtY+xPsRJ1|O%^%*iBBpCZ}_rv_vbo55MKqL=ut)7LDZFVdW9wFwDYE2BV5Mo0G% zFNn#})}r6=G*65Z&}_(XVLm48n-@Z?!@KRq!oSgXZWrMKAr`b0577#RQJy-sa<)$H ztO}y2>PwTOqa8*3qG-paA=0b?r>2Ya6=<6xQk;F%$~l2Hp{%2C3YDDy8;Hng%~J2{ zJsJ6;IiC_2X&WM)rlDAIaPBhA{cjKC5spLq4Sm?k zwbG3KyR!ioIQa^}Qk8bW#Af?j`=wmec_Gbqhr<|#e$LB^`{t!xCp>@8-cv*=)rsw$4 zzoS+GUqUDgr_Iw$lvtpD#`^<6P2cwrM%H8+cVV|0$WsYU4d%8F?WyC@)mqh?!gTaY zmPj*E8{Px5Me|g)Nl0U{;$KToL39}xiZ)cXk@H2Ws-uPPBTb^r5|Vqqzb!GyoT7Ag zh1tN7a`^cojQacK$R4Mx$`6gmSJ9j?Wl#hb0Ry`a4#M)Dt2-cmFI=z3kL{)RDJ$83 zp$Cq@5brAYbsnLOKQJWr8-DohU1K6s*em(Z<2@~Toq}~cF4;FUM;j+Sd7h_WyKJCK zistlrA!Z^naro_`^RwY1sA!;i77N%SXX&1S|Gd^F*HWS7rp$thGj%Hsc6C3jxM4rI zQlk`@Izgk=dOT4>%>7wB6OJX(13)b^C%b8Cn1AIweHog-}|C#iU7ge#!IK?HzYi)&U^iEXX)N|%CcpxMpO^YBY_JbI@R0Zc;>HA6bw}%b z1n)pX>x9E*00PJhK%Fij@{2oIftC9zrVY_(PBD`M+|Z8Elu^e=u#!TU%fQrXYS7*$ z(<3pA;9;We=^nRP5asokr8C|=ij4xS2L4>w;t@-nR`vr?qoL^d2(gxW4)rD6n89}? zoX_>Z+0ZQs1-E8^lmx%5WKvgcSX@xwbTK!VK|SmMZ7651d)sQazclFb4szPd29(v-+Jv&Pin_OEKw zdQQ(Q?F)FLb1NY`QDtf|Fd9Hk3`D|yZiRJY^TYF+CKh|6dFZ;_SGCO3M=Xol#pi9p z+Gl@kmiVe-d;qEP~%i09Mw3(xKA!7`<0ZE#jExS$GS^^NWo{FUE)_ zm;Z{(l^!hJu$W=@8$h0esl<*si&^eDvE~;?*-MPBu(;nj8?+Dm zY6ONvzE}&9{ybudn&@tsX8&A#BhdGdZy>%}4=fg%Z}*pup>{)S*X1=0gdJEUH~u>7 zZ!C9!1z`7Q+hoY!I(vTC)l$6U9ooLOri=G3PyQ^^s=lJ^Ntzm#<(L|06oabTN;vHP z_%w#hkp4mQVqfo5*Uy!TI(Z~n*(2wb5e7RG&xMpXqPJ*%-flsc=0Nv+f&E57+{NNq z@q5>qUwMRkP4rX&mYieR=`rV5y_dE_$#dJ|F`bUg3HZ{4;rc1}=7W7Pf zS*(0P{7K+)satsr5us9+4%8SK=bYLomG&Xy$H_CYcP`F6=y{-G>TNb*8yvt52l-7i zxZLNI=d{ijZv}VMX2`GL3uTYg^PTMeR7R-nvbl%owATC}GNG{1pZECHMEM~b{3io= zNt@tMyy*=FwHU$i z?+*~lFMCxFfOM~UuSPz7tEDR}2crFra1TdCuy(xN`Z92i?lq7*NPT`VGx`C*0n+rG zHA<_O+W@zL5n6fW+$-6;p z89sB`F&W}p`EcR~k%hrO`Ap?^x`|e@m9dbNv5JkO??OKy zpU~$Ze-1Z_1U!>U7xa*gnxls>OUKk+;-x6}BxiLHet0+q9O>X*DC9Wr@Bwhp$s!z3(WQ1y5vJXh&BkGR4 zWvlj_S{`4Y?1_y~yMJdU|8L&44H^Iz?X)v{Ok@){qPRYHuzm6@3;zL%+_71s^v2WYRUW673- z9Cgz^9d5YY63q?{JOWZ<9+A>-y(6Y52!W(9687Rl?A`A=!qUAmtz_|7+b4!&yqG_B=7ptL0+ucV) z`E;s*5^lwg2-EG~Q; + +// The E2E_* names deliberately avoid the KONTENT_ prefix: yargs maps every +// KONTENT_* env var to a CLI argument and .strict() rejects unknown ones. +export const readE2eConfig = (): Option => + flatMap(readVar("E2E_MAPI_KEY"), (mapiKey) => + map(readVar("E2E_SOURCE_ENV_ID"), (sourceEnvId) => ({ mapiKey, sourceEnvId })), + ); + +// An empty value counts as unset: .env.template ships the variables blank. +const readVar = (name: string): Option => + filter(fromNullable(process.env[name]), (value) => value !== ""); diff --git a/test/e2e/helpers/environment.ts b/test/e2e/helpers/environment.ts new file mode 100644 index 0000000..cd3800b --- /dev/null +++ b/test/e2e/helpers/environment.ts @@ -0,0 +1,71 @@ +import { writeFile } from "node:fs/promises"; +import { setTimeout as delay } from "node:timers/promises"; +import { createMapiClient } from "../../../src/lib/mapi/client.js"; +import type { E2eConfig } from "./config.js"; +import { randomSuffix } from "./random.js"; + +export type TestEnvironment = Readonly<{ + envId: string; + name: string; +}>; + +export const cloneTestEnvironment = async (config: E2eConfig): Promise => { + const name = `e2e-${Math.floor(Date.now() / 1000)}-${randomSuffix()}`; + const sourceClient = createMapiClient({ token: config.mapiKey, envId: config.sourceEnvId }); + const cloned = await sourceClient.cloneEnvironment().withData({ name }).toPromise(); + + await waitUntilCloned(config, cloned.data.id); + + return { envId: cloned.data.id, name }; +}; + +export const deleteTestEnvironment = async (config: E2eConfig, envId: string): Promise => { + try { + await createMapiClient({ token: config.mapiKey, envId }).deleteEnvironment().toPromise(); + } catch (error) { + // A missing environment means an earlier cleanup already won the race. + if (isNotFoundError(error)) { + return; + } + throw error; + } +}; + +// Hands the cloned environment id to the CI `if: always()` cleanup step, which +// deletes the clone even when the job is cancelled before afterAll runs. +export const recordEnvironmentId = async (envId: string): Promise => { + const filePath = process.env.E2E_ENV_ID_FILE; + if (filePath === undefined || filePath === "") { + return; + } + await writeFile(filePath, envId); +}; + +const POLL_DELAY_MS = 2000; +// Stays under the suite's 5-minute hookTimeout so the timeout error below wins. +const MAX_POLL_ATTEMPTS = 120; + +const waitUntilCloned = async (config: E2eConfig, envId: string): Promise => { + const client = createMapiClient({ token: config.mapiKey, envId }); + + for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) { + const state = await client.getEnvironmentCloningState().toPromise(); + if (state.data.cloningInfo.cloningState === "done") { + return; + } + await delay(POLL_DELAY_MS); + } + + throw new Error(`Environment ${envId} did not finish cloning in time.`); +}; + +// The SDK does not expose the HTTP status uniformly, so this checks the shapes +// seen in practice; a false negative only surfaces an already-deleted error. +const isNotFoundError = (error: unknown): boolean => { + const status = (error as { originalError?: { response?: { status?: number } } }).originalError + ?.response?.status; + if (status === 404) { + return true; + } + return error instanceof Error && /not found|404/i.test(error.message); +}; diff --git a/test/e2e/helpers/random.ts b/test/e2e/helpers/random.ts new file mode 100644 index 0000000..bf8ce99 --- /dev/null +++ b/test/e2e/helpers/random.ts @@ -0,0 +1,2 @@ +// Uniqueness suffix for per-run entity names, so parallel runs cannot collide. +export const randomSuffix = (): string => Math.random().toString(36).slice(2, 8); diff --git a/test/e2e/helpers/runCli.ts b/test/e2e/helpers/runCli.ts new file mode 100644 index 0000000..6162fa9 --- /dev/null +++ b/test/e2e/helpers/runCli.ts @@ -0,0 +1,66 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import type { JsonValue } from "@kontent-ai/core-sdk"; + +export type CliResult = Readonly<{ + exitCode: number; + stdout: string; + stderr: string; +}>; + +export type CliRunOptions = Readonly<{ + stdin?: string; + env?: Readonly>; +}>; + +// Spawns the built binary with a curated environment: no stray KONTENT_* vars +// (yargs would map them to CLI arguments), telemetry off. Resolves on any exit +// code - a non-zero exit is a result the tests assert on, not a failure. +export const runCli = ( + args: ReadonlyArray, + options: CliRunOptions = {}, +): Promise => + new Promise((resolve, reject) => { + const child = spawn(process.execPath, [cliEntryPath, ...args], { + env: { ...curatedEnv(), ...options.env }, + stdio: ["pipe", "pipe", "pipe"], + }); + + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)); + + child.on("error", reject); + child.on("close", (code) => { + resolve({ + exitCode: code ?? -1, + stdout: Buffer.concat(stdout).toString(), + stderr: Buffer.concat(stderr).toString(), + }); + }); + + if (options.stdin !== undefined) { + child.stdin.write(options.stdin); + } + child.stdin.end(); + }); + +// The stdout-purity assertion in one place: anything but a clean JSON payload +// on stdout fails here with the raw output in the message. +export const parseStdout = (result: CliResult): JsonValue => { + try { + return JSON.parse(result.stdout) as JsonValue; + } catch { + throw new Error(`stdout is not valid JSON:\n${result.stdout}`); + } +}; + +const cliEntryPath = fileURLToPath(new URL("../../../dist/index.mjs", import.meta.url)); + +const curatedEnv = (): Record => ({ + ...(process.env.PATH === undefined ? {} : { PATH: process.env.PATH }), + ...(process.env.HOME === undefined ? {} : { HOME: process.env.HOME }), + ...(process.env.KONTENT_URL === undefined ? {} : { KONTENT_URL: process.env.KONTENT_URL }), + DO_NOT_TRACK: "1", +}); diff --git a/test/e2e/mapi.test.ts b/test/e2e/mapi.test.ts new file mode 100644 index 0000000..40d0c7a --- /dev/null +++ b/test/e2e/mapi.test.ts @@ -0,0 +1,218 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { isNone } from "../../src/lib/option.js"; +import { type E2eConfig, readE2eConfig } from "./helpers/config.js"; +import { + cloneTestEnvironment, + deleteTestEnvironment, + recordEnvironmentId, + type TestEnvironment, +} from "./helpers/environment.js"; +import { randomSuffix } from "./helpers/random.js"; +import { type CliRunOptions, parseStdout, runCli } from "./helpers/runCli.js"; + +const config = readE2eConfig(); + +const runSuffix = randomSuffix(); +const taxonomyCodename = `colors_${runSuffix}`; +const typeCodename = `article_${runSuffix}`; +const itemCodename = `hello_${runSuffix}`; + +const imageFixturePath = fileURLToPath(new URL("./fixtures/kailogo.png", import.meta.url)); + +describe.skipIf(isNone(config))("kontent mapi e2e", () => { + let env: TestEnvironment | undefined; + let itemId: string; + let assetId: string; + + // The describe body runs at collection time even when the suite is skipped, + // so the config unwrap has to wait until the hooks. + const requireConfig = (): E2eConfig => { + if (isNone(config)) { + throw new Error("E2E config missing despite the skipIf gate."); + } + return config.value; + }; + + const requireEnv = (): TestEnvironment => { + if (env === undefined) { + throw new Error("The test environment was not cloned."); + } + return env; + }; + + const mapi = (endpoint: string, extraArgs: ReadonlyArray = [], options?: CliRunOptions) => + runCli( + [ + "mapi", + endpoint, + "--envId", + requireEnv().envId, + "--mapiKey", + requireConfig().mapiKey, + ...extraArgs, + ], + options, + ); + + beforeAll(async () => { + env = await cloneTestEnvironment(requireConfig()); + await recordEnvironmentId(env.envId); + }); + + afterAll(async () => { + if (env !== undefined) { + await deleteTestEnvironment(requireConfig(), env.envId); + } + }); + + it("starts from an empty clone", async () => { + const types = await mapi("types"); + const items = await mapi("items"); + + expect(types.exitCode).toBe(0); + expect(items.exitCode).toBe(0); + expect(parseStdout(types)).toMatchObject({ types: [] }); + expect(parseStdout(items)).toMatchObject({ items: [] }); + }); + + it("creates a taxonomy from a body file (implicit POST)", async () => { + const bodyDir = await mkdtemp(join(tmpdir(), "kontent-e2e-")); + const bodyPath = join(bodyDir, "taxonomy.json"); + await writeFile( + bodyPath, + JSON.stringify({ + name: `Colors ${runSuffix}`, + codename: taxonomyCodename, + terms: [{ name: "Red", codename: `red_${runSuffix}`, terms: [] }], + }), + ); + + const result = await mapi("taxonomies", ["--input", bodyPath]); + + expect(result.exitCode).toBe(0); + expect(parseStdout(result)).toMatchObject({ codename: taxonomyCodename }); + }); + + it("creates a content type from stdin (implicit POST)", async () => { + const body = JSON.stringify({ + name: `Article ${runSuffix}`, + codename: typeCodename, + elements: [ + { type: "text", name: "Title", codename: "title" }, + { type: "asset", name: "Image", codename: "image" }, + ], + }); + + const result = await mapi("types", ["--input", "-"], { stdin: body }); + + expect(result.exitCode).toBe(0); + expect(parseStdout(result)).toMatchObject({ codename: typeCodename }); + }); + + it("uploads a binary file and creates an asset from it", async () => { + const uploaded = await mapi("files/kailogo.png", [ + "--input", + imageFixturePath, + "-H", + "Content-Type: image/png", + ]); + + expect(uploaded.exitCode).toBe(0); + const fileReferenceId = (parseStdout(uploaded) as { id: string }).id; + expect(fileReferenceId).toBeTruthy(); + + const asset = await mapi("assets", ["--input", "-"], { + stdin: JSON.stringify({ + file_reference: { id: fileReferenceId, type: "internal" }, + title: `Asset ${runSuffix}`, + }), + }); + + expect(asset.exitCode).toBe(0); + assetId = (parseStdout(asset) as { id: string }).id; + expect(assetId).toBeTruthy(); + }); + + it("creates an item and upserts its language variant with -X PUT", async () => { + const created = await mapi("items", ["--input", "-"], { + stdin: JSON.stringify({ + name: `Hello ${runSuffix}`, + codename: itemCodename, + type: { codename: typeCodename }, + }), + }); + + expect(created.exitCode).toBe(0); + itemId = (parseStdout(created) as { id: string }).id; + expect(itemId).toBeTruthy(); + + const variant = await mapi( + `items/${itemId}/variants/codename/default`, + ["-X", "PUT", "--input", "-"], + { + stdin: JSON.stringify({ + elements: [ + { element: { codename: "title" }, value: "Hello world" }, + { element: { codename: "image" }, value: [{ id: assetId }] }, + ], + }), + }, + ); + + expect(variant.exitCode).toBe(0); + expect(parseStdout(variant)).toMatchObject({ item: { id: itemId } }); + }); + + it("prints the status line and headers with --include", async () => { + const result = await mapi(`items/${itemId}`, ["--include"]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/^HTTP\/1\.1 200 OK\n/); + + const [head, body] = splitOnce(result.stdout, "\n\n"); + expect(head).toMatch(/\ncontent-type: /i); + expect(JSON.parse(body)).toMatchObject({ id: itemId }); + }); + + it("deletes the item", async () => { + const result = await mapi(`items/${itemId}`, ["-X", "DELETE"]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(""); + }); + + it("reports the deleted item as a 404 payload with exit code 1", async () => { + const result = await mapi(`items/${itemId}`); + + expect(result.exitCode).toBe(1); + expect(parseStdout(result)).toMatchObject({ message: expect.any(String) }); + expect(result.stderr).toContain("HTTP 404"); + }); + + it("keeps the created model and asset, and no items", async () => { + const types = await mapi("types"); + const taxonomies = await mapi("taxonomies"); + const assets = await mapi("assets"); + const items = await mapi("items"); + + expect(parseStdout(types)).toMatchObject({ + types: [expect.objectContaining({ codename: typeCodename })], + }); + expect(parseStdout(taxonomies)).toMatchObject({ + taxonomies: [expect.objectContaining({ codename: taxonomyCodename })], + }); + expect(parseStdout(assets)).toMatchObject({ + assets: [expect.objectContaining({ id: assetId })], + }); + expect(parseStdout(items)).toMatchObject({ items: [] }); + }); +}); + +const splitOnce = (text: string, separator: string): readonly [string, string] => { + const index = text.indexOf(separator); + return index === -1 ? [text, ""] : [text.slice(0, index), text.slice(index + separator.length)]; +}; diff --git a/vitest.config.ts b/vitest.config.ts index 9920823..6e5473a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,8 @@ export default defineConfig({ test: { environment: "node", include: ["test/**/*.test.ts"], + // The e2e suite talks to a real Kontent.ai project; it runs via `pnpm test:e2e` only. + exclude: ["test/e2e/**"], clearMocks: true, }, }); diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts new file mode 100644 index 0000000..2a09c13 --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from "vitest/config"; + +// Local runs read the E2E_* gate variables from .env; CI sets real env vars, +// which take precedence. A missing .env is fine - the suite then skips. +try { + process.loadEnvFile(); +} catch { + // no .env file +} + +// The e2e domain is deliberately independent of the KONTENT_URL used for local +// CLI development: E2E_KONTENT_URL when set, production otherwise. Helpers and +// the spawned CLI both read KONTENT_URL, so one overwrite here covers both. +process.env.KONTENT_URL = + process.env.E2E_KONTENT_URL === undefined || process.env.E2E_KONTENT_URL === "" + ? "kontent.ai" + : process.env.E2E_KONTENT_URL; + +export default defineConfig({ + test: { + environment: "node", + include: ["test/e2e/**/*.test.ts"], + fileParallelism: false, + testTimeout: 30_000, + // Covers environment cloning, which the API performs asynchronously. + hookTimeout: 300_000, + }, +}); From c2310b6d37c930f8d2207b22d28acb79f0d46cdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Kir=C3=A1=C4=BE?= <54802833+IvanKiral@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:58:23 +0200 Subject: [PATCH 04/25] feat: generate command reference docs from yargs definitions (#68) * feat: generate command reference docs from yargs definitions into colocated READMEs * ci: fail when generated command docs are stale --- .github/workflows/ci.yml | 7 + CLAUDE.md | 4 +- README.md | 9 + package.json | 2 + pnpm-lock.yaml | 298 +++++++++++++++++++- pnpm-workspace.yaml | 2 + scripts/generateCommandDocs.ts | 382 ++++++++++++++++++++++++++ src/commands/login/README.md | 13 + src/commands/logout/README.md | 13 + src/commands/mapi/README.md | 49 ++++ src/commands/project/sample/README.md | 20 ++ src/commands/registry.ts | 14 + src/commands/telemetry/README.md | 35 +++ src/index.ts | 11 +- tsconfig.json | 2 +- 15 files changed, 842 insertions(+), 19 deletions(-) create mode 100644 scripts/generateCommandDocs.ts create mode 100644 src/commands/login/README.md create mode 100644 src/commands/logout/README.md create mode 100644 src/commands/mapi/README.md create mode 100644 src/commands/project/sample/README.md create mode 100644 src/commands/registry.ts create mode 100644 src/commands/telemetry/README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 365391a..ec96931 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,13 @@ jobs: run: pnpm lint - name: Biome run: pnpm biome:check + # git add -A first: a brand-new README the generator creates is untracked, + # and plain `git diff --exit-code` would not see it. + - name: Docs freshness + run: | + pnpm docs:generate + git add -A + git diff --cached --exit-code - name: Test run: pnpm test - name: Build diff --git a/CLAUDE.md b/CLAUDE.md index 0e4c9f6..2c0ff35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,12 +18,12 @@ Autofix is available: `pnpm lint:fix`, `pnpm biome:fix`. Build with `pnpm build` Three layers, dependencies point downward only (`commands → core → lib`): -- `src/index.ts` — composition root. Folds each command's `register` over yargs via `reduce`, wires shared `deps` (telemetry). +- `src/index.ts` — composition root. Folds each command's `register` (from `src/commands/registry.ts`) over yargs via `reduce`, wires shared `deps` (telemetry). - `src/commands/**` — yargs wiring + presentation only. Register the command, call core, format output, log, set `process.exitCode`, fire the telemetry tracker. No business logic. - `src/core/**` — orchestration of business logic. Returns `Result`/`Option`; never writes to the console directly (logs only through a passed `Logger`). **Exception:** interactive commands may drive their own terminal UI from core — e.g. `src/core/project/bootstrap.ts` uses the prompts of `src/lib/ui/prompts.ts` (spinners, `confirm`/`select`, notes) directly because the flow is inherently interactive. Keep non-interactive core free of direct console writes. - `src/lib/**` — reusable primitives: `auth/`, `iapi/`, `mapi/`, `config/`, `telemetry/`, plus `result.ts` and `option.ts`. -Adding a command: export a `register: RegisterCommand` (see `src/commands/login/login.ts`), then add its import to the `register` array in the parent command or `src/index.ts`. +Adding a command: export a `register: RegisterCommand` (see `src/commands/login/login.ts`), then add its import to the `register` array in the parent command or `src/commands/registry.ts`. Then run `pnpm docs:generate` (`scripts/generateCommandDocs.ts`) — it replays the registrations against a recording proxy and rewrites the generated docs: the marker-fenced command table in the root `README.md`, and the `` block in each command folder's `README.md` (created as a skeleton when missing). Prose outside the markers is handwritten — write command docs there, never inside the block. Two opt-out sets in the script: `commandsWithoutPage` (no colocated README) and `commandsWithoutIndexEntry` (no root-README table row; telemetry is there). The generator errors on a command-folder README with markers but no matching command (stale after rename/removal) — resolve by hand; it never deletes pages. ### API clients diff --git a/README.md b/README.md index b59bb2e..5f367e2 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,15 @@ kontent logout Run `kontent --help` for the full, always-current list. Each command supports `--help` for its own options. + +| Command | Description | +| --- | --- | +| [`kontent login`](src/commands/login/README.md) | Authenticate with Kontent.ai via Auth0 device flow | +| [`kontent logout`](src/commands/logout/README.md) | Clear stored authentication tokens | +| [`kontent mapi `](src/commands/mapi/README.md) | Send an authenticated request to the Management API | +| [`kontent project sample bootstrap`](src/commands/project/sample/README.md) | Clone a sample app for an environment and wire its .env | + + ## Global options - `--logLevel`, `-ll` — detail level: `none`, `standard` (default), `verbose` diff --git a/package.json b/package.json index bff8cdd..6d24b10 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "clean": "rimraf dist", "start": "node dist/index.mjs", "dev": "tsdown --watch", + "docs:generate": "tsx scripts/generateCommandDocs.ts", "typecheck": "tsc --noEmit", "test": "vitest run", "test:e2e": "pnpm build && vitest run --config vitest.e2e.config.ts", @@ -59,6 +60,7 @@ "eslint": "^9.39.4", "rimraf": "^6.1.3", "tsdown": "^0.21.10", + "tsx": "^4.20.6", "typescript": "^5.9.3", "vitest": "^4.1.9" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67cd5c7..e635b1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,12 +75,15 @@ importers: tsdown: specifier: ^0.21.10 version: 0.21.10(typescript@5.9.3) + tsx: + specifier: ^4.20.6 + version: 4.23.12 typescript: specifier: ^5.9.3 version: 5.9.3 vitest: specifier: ^4.1.9 - version: 4.1.9(@types/node@22.20.0)(vite@8.0.16(@types/node@22.20.0)) + version: 4.1.9(@types/node@22.20.0)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.2)(tsx@4.23.12)) packages: @@ -201,6 +204,162 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1004,6 +1163,11 @@ packages: resolution: {integrity: sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==} engines: {node: '>= 0.4'} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1911,6 +2075,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -2216,6 +2385,84 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': dependencies: eslint: 9.39.4 @@ -2639,13 +2886,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.0.16(@types/node@22.20.0))': + '@vitest/mocker@4.1.9(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.2)(tsx@4.23.12))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@22.20.0) + vite: 8.0.16(@types/node@22.20.0)(esbuild@0.28.2)(tsx@4.23.12) '@vitest/pretty-format@4.1.9': dependencies: @@ -3041,6 +3288,35 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} escape-string-regexp@4.0.0: {} @@ -4014,6 +4290,12 @@ snapshots: tslib@2.8.1: {} + tsx@4.23.12: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -4075,7 +4357,7 @@ snapshots: dependencies: punycode: 2.3.1 - vite@8.0.16(@types/node@22.20.0): + vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.2)(tsx@4.23.12): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -4084,12 +4366,14 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 22.20.0 + esbuild: 0.28.2 fsevents: 2.3.3 + tsx: 4.23.12 - vitest@4.1.9(@types/node@22.20.0)(vite@8.0.16(@types/node@22.20.0)): + vitest@4.1.9(@types/node@22.20.0)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.2)(tsx@4.23.12)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@22.20.0)) + '@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.2)(tsx@4.23.12)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -4106,7 +4390,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@22.20.0) + vite: 8.0.16(@types/node@22.20.0)(esbuild@0.28.2)(tsx@4.23.12) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 21a213e..385da0f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1 +1,3 @@ +allowBuilds: + esbuild: true verifyDepsBeforeRun: error diff --git a/scripts/generateCommandDocs.ts b/scripts/generateCommandDocs.ts new file mode 100644 index 0000000..a0e8d5b --- /dev/null +++ b/scripts/generateCommandDocs.ts @@ -0,0 +1,382 @@ +import { readdir, readFile, stat, writeFile } from "node:fs/promises"; +import { join, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { commandsToRegister } from "../src/commands/registry.js"; +import type { CommandDeps } from "../src/types/yargs.js"; + +type OptionConfig = Readonly<{ + type?: string; + alias?: string | ReadonlyArray; + describe?: string; + default?: unknown; + demandOption?: boolean; + choices?: ReadonlyArray; + array?: boolean; + hidden?: boolean; +}>; + +type RecordedOption = Readonly<{ name: string; config: OptionConfig }>; + +type RecordedCommand = Readonly<{ + command: string; + describe: string; + positionals: RecordedOption[]; + options: RecordedOption[]; + examples: Array; + children: RecordedCommand[]; +}>; + +type CommandModuleLike = Readonly<{ + command: string; + describe: string; + builder?: unknown; +}>; + +const scriptName = "kontent"; +// Both keyed by the top-level command segment. A command can have a colocated +// README without appearing in the root README's command index, and vice versa. +const commandsWithoutPage: ReadonlySet = new Set([]); +const commandsWithoutIndexEntry: ReadonlySet = new Set(["telemetry"]); +const commandsRoot = "src/commands"; +const readmeTableStart = ""; +const readmeTableEnd = ""; +const referenceStart = ""; +const referenceEnd = ""; +const editHint = + ""; + +const createRecordedCommand = (command: string, describe: string): RecordedCommand => ({ + command, + describe, + positionals: [], + options: [], + examples: [], + children: [], +}); + +// Stands in for the yargs Argv the registrations expect: known calls are recorded, +// everything else is a chainable no-op. Handlers never run, so deps stay untouched. +const createBuilderRecorder = (target: RecordedCommand): unknown => { + const handlers: Record) => void> = { + command: (module) => { + target.children.push(recordModule(module as CommandModuleLike)); + }, + positional: (name, config) => { + target.positionals.push({ name: name as string, config: config as OptionConfig }); + }, + option: (name, config) => { + target.options.push({ name: name as string, config: config as OptionConfig }); + }, + example: (command, description) => { + target.examples.push([command as string, description as string]); + }, + }; + + const recorder: unknown = new Proxy( + {}, + { + get: + (_, property: string) => + (...args: ReadonlyArray) => { + handlers[property]?.(...args); + return recorder; + }, + }, + ); + return recorder; +}; + +const recordModule = (module: CommandModuleLike): RecordedCommand => { + const recorded = createRecordedCommand(module.command, module.describe); + if (typeof module.builder === "function") { + module.builder(createBuilderRecorder(recorded)); + } + return recorded; +}; + +const recordCommandTree = (): ReadonlyArray => { + const root = createRecordedCommand("", ""); + const recorder = createBuilderRecorder(root); + const stubDeps = { telemetry: null } as unknown as CommandDeps; + for (const register of commandsToRegister) { + register(recorder as never, stubDeps); + } + return root.children; +}; + +// "$0 " is a default subcommand: it extends the parent path instead of +// adding a segment of its own. +const joinPath = (parentPath: string, command: string): string => + command.startsWith("$0") + ? `${parentPath}${command.slice("$0".length)}` + : `${parentPath} ${command}`; + +const commandName = (node: RecordedCommand): string => node.command.split(" ")[0] ?? node.command; + +const escapeTableCell = (text: string): string => text.replaceAll("|", "\\|"); + +const formatOptionNames = (option: RecordedOption): string => { + const aliases = + option.config.alias === undefined + ? [] + : [option.config.alias] + .flat() + .map((alias) => (alias.length === 1 ? `-${alias}` : `--${alias}`)); + return [`--${option.name}`, ...aliases].map((name) => `\`${name}\``).join(", "); +}; + +const formatOptionType = (option: RecordedOption): string => { + if (option.config.choices !== undefined) { + return option.config.choices.map((choice) => `\`${choice}\``).join(" \\| "); + } + const baseType = option.config.type ?? "string"; + return option.config.array === true ? `${baseType}[]` : baseType; +}; + +const formatOptionDescription = (option: RecordedOption): string => { + const requiredPrefix = option.config.demandOption === true ? "**Required.** " : ""; + const describe = option.config.describe ?? ""; + const hasDefault = option.config.default !== undefined && option.config.default !== false; + const separator = describe === "" || describe.endsWith(".") ? "" : "."; + const defaultSuffix = hasDefault + ? `${separator} Default: \`${String(option.config.default)}\`.` + : ""; + return `${requiredPrefix}${describe}${defaultSuffix}`; +}; + +const renderOptionsTable = (options: ReadonlyArray): ReadonlyArray => [ + "| Option | Type | Description |", + "| --- | --- | --- |", + ...options.map( + (option) => + `| ${formatOptionNames(option)} | ${formatOptionType(option)} | ${escapeTableCell(formatOptionDescription(option))} |`, + ), +]; + +const renderArgumentsTable = ( + positionals: ReadonlyArray, +): ReadonlyArray => [ + "| Argument | Type | Description |", + "| --- | --- | --- |", + ...positionals.map( + (positional) => + `| \`<${positional.name}>\` | ${positional.config.type ?? "string"} | ${escapeTableCell(positional.config.describe ?? "")} |`, + ), +]; + +const renderExamples = ( + examples: ReadonlyArray, +): ReadonlyArray => [ + "```sh", + ...examples.flatMap(([command, description], index) => [ + ...(index === 0 ? [] : [""]), + `# ${description}`, + command.replaceAll("$0", scriptName), + ]), + "```", +]; + +// A runnable leaf; groups only contribute path segments and directory levels. +type LeafDoc = Readonly<{ + path: string; + fileSegments: ReadonlyArray; + node: RecordedCommand; +}>; + +const collectLeaves = ( + node: RecordedCommand, + parentPath: string, + parentSegments: ReadonlyArray, +): ReadonlyArray => { + const path = joinPath(parentPath, node.command); + // A "$0" default subcommand keeps the parent's identity (e.g. the mapi folder). + const segments = node.command.startsWith("$0") + ? parentSegments + : [...parentSegments, commandName(node)]; + if (node.children.length > 0) { + return node.children.flatMap((child) => collectLeaves(child, path, segments)); + } + return [{ path, fileSegments: segments, node }]; +}; + +const hasPage = (leaf: LeafDoc): boolean => !commandsWithoutPage.has(leaf.fileSegments[0] ?? ""); + +const hasIndexEntry = (leaf: LeafDoc): boolean => + !commandsWithoutIndexEntry.has(leaf.fileSegments[0] ?? ""); + +// A top-level command's module lives in a folder of its own name; a nested leaf's +// module lives in its parent group's folder (e.g. project/sample/bootstrap.ts). +const leafFolderSegments = (leaf: LeafDoc): ReadonlyArray => + leaf.fileSegments.length === 1 ? leaf.fileSegments : leaf.fileSegments.slice(0, -1); + +const leafReadmePath = (leaf: LeafDoc): string => + join(commandsRoot, ...leafFolderSegments(leaf), "README.md"); + +const groupLeavesByReadme = ( + leaves: ReadonlyArray, +): ReadonlyMap> => + leaves.reduce((groups, leaf) => { + const path = leafReadmePath(leaf); + return new Map(groups).set(path, [...(groups.get(path) ?? []), leaf]); + }, new Map>()); + +const renderLeafSections = (leaf: LeafDoc, headingLevel: number): ReadonlyArray => { + const heading = "#".repeat(headingLevel); + const visibleOptions = leaf.node.options.filter((option) => option.config.hidden !== true); + return [ + `${heading} Usage`, + "", + "```sh", + `${leaf.path}${visibleOptions.length > 0 ? " [options]" : ""}`, + "```", + "", + ...(leaf.node.positionals.length > 0 + ? [`${heading} Arguments`, "", ...renderArgumentsTable(leaf.node.positionals), ""] + : []), + ...(visibleOptions.length > 0 + ? [`${heading} Options`, "", ...renderOptionsTable(visibleOptions), ""] + : []), + ...(leaf.node.examples.length > 0 + ? [`${heading} Examples`, "", ...renderExamples(leaf.node.examples), ""] + : []), + ]; +}; + +const renderReferenceBlock = (leaves: ReadonlyArray): string => { + const lines = + leaves.length === 1 && leaves[0] !== undefined + ? [leaves[0].node.describe, "", ...renderLeafSections(leaves[0], 2)] + : leaves.flatMap((leaf) => [ + `## \`${leaf.path}\``, + "", + leaf.node.describe, + "", + ...renderLeafSections(leaf, 3), + ]); + return lines + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .replace(/\n+$/, ""); +}; + +const spliceBetween = ( + content: string, + start: string, + end: string, + replacement: string, +): string | null => { + const startIndex = content.indexOf(start); + const endIndex = content.indexOf(end); + if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) { + return null; + } + const before = content.slice(0, startIndex + start.length); + const after = content.slice(endIndex); + return `${before}\n${replacement}\n${after}`; +}; + +const renderSkeleton = (leaves: ReadonlyArray, block: string): string => { + const title = + leaves.length === 1 && leaves[0] !== undefined + ? leaves[0].path + : `${scriptName} ${leafFolderSegments(leaves[0] as LeafDoc).join(" ")}`; + return [`# \`${title}\``, "", editHint, "", referenceStart, block, referenceEnd, ""].join("\n"); +}; + +const fileExists = async (path: string): Promise => + stat(path).then( + () => true, + () => false, + ); + +const updateCommandReadme = async ( + absolutePath: string, + leaves: ReadonlyArray, +): Promise => { + const block = renderReferenceBlock(leaves); + if (!(await fileExists(absolutePath))) { + await writeFile(absolutePath, renderSkeleton(leaves, block)); + return; + } + const content = await readFile(absolutePath, "utf8"); + const spliced = spliceBetween(content, referenceStart, referenceEnd, block); + if (spliced === null) { + throw new Error( + `${absolutePath} exists but is missing the ${referenceStart} / ${referenceEnd} markers. Add them where the generated reference belongs.`, + ); + } + if (spliced !== content) { + await writeFile(absolutePath, spliced); + } +}; + +const findStaleReadmes = async ( + root: string, + expectedPaths: ReadonlySet, +): Promise> => { + const entries = await readdir(join(root, commandsRoot), { recursive: true }); + const readmePaths = entries + .filter((entry) => entry === "README.md" || entry.endsWith(`${sep}README.md`)) + .map((entry) => join(commandsRoot, entry)); + const stale: string[] = []; + for (const path of readmePaths) { + if (expectedPaths.has(path)) { + continue; + } + const content = await readFile(join(root, path), "utf8"); + if (content.includes(referenceStart)) { + stale.push(path); + } + } + return stale; +}; + +const renderReadmeTable = (leaves: ReadonlyArray): string => { + const rows = leaves.filter(hasIndexEntry).map((leaf) => { + const label = `\`${leaf.path}\``; + const nameCell = hasPage(leaf) ? `[${label}](${leafReadmePath(leaf)})` : label; + return `| ${nameCell} | ${escapeTableCell(leaf.node.describe)} |`; + }); + return ["| Command | Description |", "| --- | --- |", ...rows].join("\n"); +}; + +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); + +const leaves = recordCommandTree().flatMap((node) => collectLeaves(node, scriptName, [])); +const readmeGroups = groupLeavesByReadme(leaves.filter(hasPage)); + +for (const [path, group] of readmeGroups) { + const folder = join(repoRoot, path, ".."); + if (!(await fileExists(folder))) { + throw new Error( + `Expected command folder ${relative(repoRoot, folder)} does not exist. Command folders must be named after their command path segments.`, + ); + } + await updateCommandReadme(join(repoRoot, path), group); +} + +// Deleting or renaming a command must not leave its old page behind unnoticed; pages +// hold handwritten prose, so flag them for a human instead of deleting. +const stalePaths = await findStaleReadmes(repoRoot, new Set(readmeGroups.keys())); +if (stalePaths.length > 0) { + throw new Error( + `Stale command README(s) with no matching command: ${stalePaths.join(", ")}. Move their handwritten content or delete them.`, + ); +} + +const rootReadmePath = join(repoRoot, "README.md"); +const rootReadme = await readFile(rootReadmePath, "utf8"); +const splicedRoot = spliceBetween( + rootReadme, + readmeTableStart, + readmeTableEnd, + renderReadmeTable(leaves), +); +if (splicedRoot === null) { + throw new Error(`README.md is missing the ${readmeTableStart} / ${readmeTableEnd} markers.`); +} +if (splicedRoot !== rootReadme) { + await writeFile(rootReadmePath, splicedRoot); +} + +console.error(`Updated ${readmeGroups.size} command README(s) and the root README table.`); diff --git a/src/commands/login/README.md b/src/commands/login/README.md new file mode 100644 index 0000000..911b861 --- /dev/null +++ b/src/commands/login/README.md @@ -0,0 +1,13 @@ +# `kontent login` + + + + +Authenticate with Kontent.ai via Auth0 device flow + +## Usage + +```sh +kontent login +``` + diff --git a/src/commands/logout/README.md b/src/commands/logout/README.md new file mode 100644 index 0000000..569115d --- /dev/null +++ b/src/commands/logout/README.md @@ -0,0 +1,13 @@ +# `kontent logout` + + + + +Clear stored authentication tokens + +## Usage + +```sh +kontent logout +``` + diff --git a/src/commands/mapi/README.md b/src/commands/mapi/README.md new file mode 100644 index 0000000..5a57a43 --- /dev/null +++ b/src/commands/mapi/README.md @@ -0,0 +1,49 @@ +# `kontent mapi ` + + + + +Send an authenticated request to the Management API + +## Usage + +```sh +kontent mapi [options] +``` + +## Arguments + +| Argument | Type | Description | +| --- | --- | --- | +| `` | string | API path, e.g. "types" or "projects/{environment_id}/types" | + +## Options + +| Option | Type | Description | +| --- | --- | --- | +| `--envId` | string | **Required.** Environment ID (Guid) | +| `--mapiKey` | string | Management API key. Defaults to the logged-in user's token | +| `--method`, `-X` | string | HTTP method. (default: GET, or POST with --input) | +| `--header`, `-H` | string[] | Request header in the "Name: value" format. Repeatable. An Authorization header takes precedence over --mapiKey and the stored login token | +| `--input` | string | File with the request body, or "-" to read stdin | +| `--include`, `-i` | boolean | Print the status line and response headers before the body | + +## Examples + +```sh +# List the first 10 content types +kontent mapi 'types?limit=10' --envId + +# Create a content type from a file (--input implies POST) +kontent mapi types --envId --input body.json + +# Delete a content item +kontent mapi 'items/' -X DELETE --envId + +# Send extra headers (-H is repeatable) +kontent mapi types -H 'X-Foo: 1' -H 'X-Bar: 2' --envId + +# Create a content type from a piped body +echo '{"name":"Article"}' | kontent mapi types --envId --input - +``` + diff --git a/src/commands/project/sample/README.md b/src/commands/project/sample/README.md new file mode 100644 index 0000000..ffccc9e --- /dev/null +++ b/src/commands/project/sample/README.md @@ -0,0 +1,20 @@ +# `kontent project sample bootstrap` + + + + +Clone a sample app for an environment and wire its .env + +## Usage + +```sh +kontent project sample bootstrap [options] +``` + +## Options + +| Option | Type | Description | +| --- | --- | --- | +| `--envId` | string | **Required.** Environment ID (Guid) | +| `--path` | string | Target directory for the cloned app (must be empty or non-existent). Default: `./karma-nextjs-app`. | + diff --git a/src/commands/registry.ts b/src/commands/registry.ts new file mode 100644 index 0000000..f66ee42 --- /dev/null +++ b/src/commands/registry.ts @@ -0,0 +1,14 @@ +import type { RegisterCommand } from "../types/yargs.js"; +import { register as registerLogin } from "./login/login.js"; +import { register as registerLogout } from "./logout/logout.js"; +import { register as registerMapi } from "./mapi/mapi.js"; +import { register as registerProject } from "./project/project.js"; +import { register as registerTelemetry } from "./telemetry/telemetry.js"; + +export const commandsToRegister: ReadonlyArray = [ + registerLogin, + registerLogout, + registerMapi, + registerProject, + registerTelemetry, +]; diff --git a/src/commands/telemetry/README.md b/src/commands/telemetry/README.md new file mode 100644 index 0000000..63f95c0 --- /dev/null +++ b/src/commands/telemetry/README.md @@ -0,0 +1,35 @@ +# `kontent telemetry` + + + + +## `kontent telemetry status` + +Show whether telemetry is enabled and why + +### Usage + +```sh +kontent telemetry status +``` + +## `kontent telemetry enable` + +Enable anonymous usage telemetry + +### Usage + +```sh +kontent telemetry enable +``` + +## `kontent telemetry disable` + +Disable anonymous usage telemetry + +### Usage + +```sh +kontent telemetry disable +``` + diff --git a/src/index.ts b/src/index.ts index efdad53..af11897 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import chalk, { chalkStderr } from "chalk"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; +import { commandsToRegister } from "./commands/registry.js"; import { getKontentBaseDomain, validateKontentDomain } from "./lib/config/kontentUrl.js"; import { isErr } from "./lib/result.js"; import { @@ -11,15 +12,7 @@ import { registerTelemetrySignalFlush, } from "./lib/telemetry/tracking.js"; import { addLogLevelOptions, createLoggerFromArgs } from "./log.js"; -import type { CommandDeps, RegisterCommand } from "./types/yargs.js"; - -const commandsToRegister: ReadonlyArray = [ - (await import("./commands/login/login.js")).register, - (await import("./commands/logout/logout.js")).register, - (await import("./commands/mapi/mapi.js")).register, - (await import("./commands/project/project.js")).register, - (await import("./commands/telemetry/telemetry.js")).register, -]; +import type { CommandDeps } from "./types/yargs.js"; const emptyYargs = yargs(hideBin(process.argv)); diff --git a/tsconfig.json b/tsconfig.json index 7cf6b97..be8196d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,6 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["src/**/*", "test/**/*", "vitest.config.ts"], + "include": ["src/**/*", "test/**/*", "scripts/**/*", "vitest.config.ts"], "exclude": ["node_modules", "dist"] } From 4b88237692bdb4ebf86785380d7c69576d724e37 Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Wed, 19 Aug 2026 10:06:04 +0200 Subject: [PATCH 05/25] fix: move E2E_ENV_ID_FILE to step-level env, runner context is invalid in job env --- .github/workflows/e2e.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 46c7696..984468b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -21,10 +21,9 @@ jobs: runs-on: ubuntu-latest env: E2E_MAPI_KEY: ${{ secrets.E2E_MAPI_KEY }} - E2E_SOURCE_ENV_ID: ${{ vars.E2E_SOURCE_ENV_ID }} + E2E_SOURCE_ENV_ID: ${{ secrets.E2E_SOURCE_ENV_ID }} # Empty is fine: vitest.e2e.config.ts falls back to production kontent.ai. E2E_KONTENT_URL: ${{ vars.E2E_KONTENT_URL }} - E2E_ENV_ID_FILE: ${{ runner.temp }}/e2e-env-id steps: - uses: actions/checkout@v6 with: @@ -39,11 +38,17 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - name: E2E tests + # The runner context is unavailable in job-level env, so the file path + # is set per step. + env: + E2E_ENV_ID_FILE: ${{ runner.temp }}/e2e-env-id run: pnpm test:e2e # Deletes the cloned environment when the job died before afterAll could # (cancellation, timeout). A normal run deletes it itself; the 404 here is fine. - name: Delete leaked test environment if: always() + env: + E2E_ENV_ID_FILE: ${{ runner.temp }}/e2e-env-id run: | if [ -s "$E2E_ENV_ID_FILE" ]; then curl -s -X DELETE "https://manage.${E2E_KONTENT_URL:-kontent.ai}/v2/projects/$(cat "$E2E_ENV_ID_FILE")" \ From 2f2661ce66a3b2dbff6d7d81b038a8627387bae3 Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Wed, 19 Aug 2026 12:35:11 +0200 Subject: [PATCH 06/25] test: fail e2e run with an error when E2E_* credentials are unset --- .env.template | 2 +- CLAUDE.md | 2 +- test/e2e/globalSetup.ts | 9 +++++++++ test/e2e/helpers/config.ts | 28 ++++++++++++++++++++-------- test/e2e/mapi.test.ts | 30 ++++++------------------------ vitest.e2e.config.ts | 5 ++++- 6 files changed, 41 insertions(+), 35 deletions(-) create mode 100644 test/e2e/globalSetup.ts diff --git a/.env.template b/.env.template index 56f2522..2890ca1 100644 --- a/.env.template +++ b/.env.template @@ -30,7 +30,7 @@ DO_NOT_TRACK= # Verbose telemetry logging for debugging. KONTENT_TELEMETRY_DEBUG= -# --- E2E tests (pnpm test:e2e; the suite skips when these are unset) --- +# --- E2E tests (pnpm test:e2e; fails with an error when these are unset) --- # Unlike the runtime vars above, these ARE read from .env (by vitest.e2e.config.ts). # Management API key of the dedicated e2e project: access to all environments diff --git a/CLAUDE.md b/CLAUDE.md index 2c0ff35..68399ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ Every handler starts with `const logger = createLoggerFromArgs(args)` (`src/log. Vitest; `test/unit/` for pure unit tests, `test/integration/` for integration tests, `test/helpers/` for shared helpers. Run `pnpm test`. Inject fakes into core instead of real I/O — for iapi reuse `test/helpers/iapiTestClient.ts` (real client over core-sdk's `HttpAdapter` seam, declarative routes). -`test/e2e/` runs the built binary against a real Kontent.ai project (clone-per-run from an empty template env). Gated on `E2E_MAPI_KEY`/`E2E_SOURCE_ENV_ID` (green-skips when unset; deliberately not `KONTENT_`-prefixed — yargs `.env("KONTENT")` + `.strict()` would reject them). Run with `pnpm test:e2e` (own `vitest.e2e.config.ts`, loads `.env`); excluded from `pnpm test` and the before-halting gate. CI: `.github/workflows/e2e.yml` (master push, PRs, manual; fork PRs are skipped at the job level — no secret access). +`test/e2e/` runs the built binary against a real Kontent.ai project (clone-per-run from an empty template env). Gated on `E2E_MAPI_KEY`/`E2E_SOURCE_ENV_ID` (fails fast with an error when unset; deliberately not `KONTENT_`-prefixed — yargs `.env("KONTENT")` + `.strict()` would reject them). Run with `pnpm test:e2e` (own `vitest.e2e.config.ts`, loads `.env`); excluded from `pnpm test` and the before-halting gate. CI: `.github/workflows/e2e.yml` (master push, PRs, manual; fork PRs are skipped at the job level — no secret access). ## Telemetry diff --git a/test/e2e/globalSetup.ts b/test/e2e/globalSetup.ts new file mode 100644 index 0000000..7b9e134 --- /dev/null +++ b/test/e2e/globalSetup.ts @@ -0,0 +1,9 @@ +import { requireE2eConfig } from "./helpers/config.js"; + +// Fails the whole run before any test file when credentials are missing. +// Fork PRs never reach this (job-level `if:` in .github/workflows/e2e.yml); +// everywhere else pnpm test:e2e is a deliberate opt-in, so missing +// credentials are a setup error, not a reason to skip. +export default (): void => { + requireE2eConfig(); +}; diff --git a/test/e2e/helpers/config.ts b/test/e2e/helpers/config.ts index 61427b4..cbcd5ad 100644 --- a/test/e2e/helpers/config.ts +++ b/test/e2e/helpers/config.ts @@ -1,5 +1,3 @@ -import { filter, flatMap, fromNullable, map, type Option } from "../../../src/lib/option.js"; - export type E2eConfig = Readonly<{ mapiKey: string; sourceEnvId: string; @@ -7,11 +5,25 @@ export type E2eConfig = Readonly<{ // The E2E_* names deliberately avoid the KONTENT_ prefix: yargs maps every // KONTENT_* env var to a CLI argument and .strict() rejects unknown ones. -export const readE2eConfig = (): Option => - flatMap(readVar("E2E_MAPI_KEY"), (mapiKey) => - map(readVar("E2E_SOURCE_ENV_ID"), (sourceEnvId) => ({ mapiKey, sourceEnvId })), - ); +export const requireE2eConfig = (): E2eConfig => { + const mapiKey = readVar("E2E_MAPI_KEY"); + const sourceEnvId = readVar("E2E_SOURCE_ENV_ID"); + if (mapiKey === undefined || sourceEnvId === undefined) { + const missing = [ + ...(mapiKey === undefined ? ["E2E_MAPI_KEY"] : []), + ...(sourceEnvId === undefined ? ["E2E_SOURCE_ENV_ID"] : []), + ]; + throw new Error( + `Missing e2e environment variables: ${missing.join(", ")}. ` + + "The e2e suite runs against a real Kontent.ai project and cannot start without them. " + + "Copy .env.template to .env and fill them in, or export them in the environment.", + ); + } + return { mapiKey, sourceEnvId }; +}; // An empty value counts as unset: .env.template ships the variables blank. -const readVar = (name: string): Option => - filter(fromNullable(process.env[name]), (value) => value !== ""); +const readVar = (name: string): string | undefined => { + const value = process.env[name]; + return value === "" ? undefined : value; +}; diff --git a/test/e2e/mapi.test.ts b/test/e2e/mapi.test.ts index 40d0c7a..c9c8c5e 100644 --- a/test/e2e/mapi.test.ts +++ b/test/e2e/mapi.test.ts @@ -3,8 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { isNone } from "../../src/lib/option.js"; -import { type E2eConfig, readE2eConfig } from "./helpers/config.js"; +import { requireE2eConfig } from "./helpers/config.js"; import { cloneTestEnvironment, deleteTestEnvironment, @@ -14,7 +13,7 @@ import { import { randomSuffix } from "./helpers/random.js"; import { type CliRunOptions, parseStdout, runCli } from "./helpers/runCli.js"; -const config = readE2eConfig(); +const config = requireE2eConfig(); const runSuffix = randomSuffix(); const taxonomyCodename = `colors_${runSuffix}`; @@ -23,20 +22,11 @@ const itemCodename = `hello_${runSuffix}`; const imageFixturePath = fileURLToPath(new URL("./fixtures/kailogo.png", import.meta.url)); -describe.skipIf(isNone(config))("kontent mapi e2e", () => { +describe("kontent mapi e2e", () => { let env: TestEnvironment | undefined; let itemId: string; let assetId: string; - // The describe body runs at collection time even when the suite is skipped, - // so the config unwrap has to wait until the hooks. - const requireConfig = (): E2eConfig => { - if (isNone(config)) { - throw new Error("E2E config missing despite the skipIf gate."); - } - return config.value; - }; - const requireEnv = (): TestEnvironment => { if (env === undefined) { throw new Error("The test environment was not cloned."); @@ -46,26 +36,18 @@ describe.skipIf(isNone(config))("kontent mapi e2e", () => { const mapi = (endpoint: string, extraArgs: ReadonlyArray = [], options?: CliRunOptions) => runCli( - [ - "mapi", - endpoint, - "--envId", - requireEnv().envId, - "--mapiKey", - requireConfig().mapiKey, - ...extraArgs, - ], + ["mapi", endpoint, "--envId", requireEnv().envId, "--mapiKey", config.mapiKey, ...extraArgs], options, ); beforeAll(async () => { - env = await cloneTestEnvironment(requireConfig()); + env = await cloneTestEnvironment(config); await recordEnvironmentId(env.envId); }); afterAll(async () => { if (env !== undefined) { - await deleteTestEnvironment(requireConfig(), env.envId); + await deleteTestEnvironment(config, env.envId); } }); diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 2a09c13..0a2fa68 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -1,7 +1,9 @@ import { defineConfig } from "vitest/config"; // Local runs read the E2E_* gate variables from .env; CI sets real env vars, -// which take precedence. A missing .env is fine - the suite then skips. +// which take precedence. A missing .env is fine when the shell provides the +// variables; test/e2e/globalSetup.ts fails the run when they are missing +// everywhere. try { process.loadEnvFile(); } catch { @@ -20,6 +22,7 @@ export default defineConfig({ test: { environment: "node", include: ["test/e2e/**/*.test.ts"], + globalSetup: ["test/e2e/globalSetup.ts"], fileParallelism: false, testTimeout: 30_000, // Covers environment cloning, which the API performs asynchronously. From f5aee2961b6189da715dc362c0b6c7c0fb054d78 Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Thu, 20 Aug 2026 08:56:56 +0200 Subject: [PATCH 07/25] fix: resolve KONTENT_* env vars explicitly instead of through yargs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.env("KONTENT")` turned every KONTENT_* variable in the shell into a CLI flag, and `.strict()` then rejected the ones the running command did not declare. An unrelated `KONTENT_PROJECT_ID` broke every command; a stray `KONTENT_INPUT` silently injected `--input` into `kontent mapi`, turning a plain listing into a POST of that file. Nothing depended on the mapping: every supported variable is already read from `process.env` where it applies (kontentUrl.ts, auth/config.ts, telemetry/consent.ts). The six hidden options existed only to stop `.strict()` rejecting vars `.env()` had invented, so they go too. `KONTENT_MAPI_KEY` was documented but only worked on `kontent mapi`; it now has a real implementation in `resolveCredential`, below `--mapiKey` and above the stored login token. It also keeps the key off argv, out of `ps` and shell history. Verified against the built binary: `KONTENT_PROJECT_ID`, `KONTENT_API_KEY`, `KONTENT_FOO`, `KONTENT_INPUT` and `KONTENT_METHOD` no longer affect an unrelated command (all exit 0, previously `Unknown argument: …` and exit 1); `KONTENT_INPUT` no longer turns a GET into a POST; `KONTENT_MAPI_KEY` still authenticates `kontent mapi`. Claude-Session: https://claude.ai/code/session_01EifeX4d1oLgEbZNo6vWdRa --- CLAUDE.md | 4 +- README.md | 9 ++++- src/commands/mapi/README.md | 2 +- src/commands/mapi/request.ts | 41 +++++++++++-------- src/index.ts | 20 +++------- test/e2e/helpers/config.ts | 4 +- test/e2e/helpers/runCli.ts | 6 +-- test/unit/resolveCredential.test.ts | 61 +++++++++++++++++++++++++++++ 8 files changed, 108 insertions(+), 39 deletions(-) create mode 100644 test/unit/resolveCredential.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 68399ef..61cc713 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,10 +58,10 @@ Every handler starts with `const logger = createLoggerFromArgs(args)` (`src/log. Vitest; `test/unit/` for pure unit tests, `test/integration/` for integration tests, `test/helpers/` for shared helpers. Run `pnpm test`. Inject fakes into core instead of real I/O — for iapi reuse `test/helpers/iapiTestClient.ts` (real client over core-sdk's `HttpAdapter` seam, declarative routes). -`test/e2e/` runs the built binary against a real Kontent.ai project (clone-per-run from an empty template env). Gated on `E2E_MAPI_KEY`/`E2E_SOURCE_ENV_ID` (fails fast with an error when unset; deliberately not `KONTENT_`-prefixed — yargs `.env("KONTENT")` + `.strict()` would reject them). Run with `pnpm test:e2e` (own `vitest.e2e.config.ts`, loads `.env`); excluded from `pnpm test` and the before-halting gate. CI: `.github/workflows/e2e.yml` (master push, PRs, manual; fork PRs are skipped at the job level — no secret access). +`test/e2e/` runs the built binary against a real Kontent.ai project (clone-per-run from an empty template env). Gated on `E2E_MAPI_KEY`/`E2E_SOURCE_ENV_ID` (fails fast with an error when unset). Run with `pnpm test:e2e` (own `vitest.e2e.config.ts`, loads `.env`); excluded from `pnpm test` and the before-halting gate. CI: `.github/workflows/e2e.yml` (master push, PRs, manual; fork PRs are skipped at the job level — no secret access). ## Telemetry -Amplitude-based, see `TELEMETRY.md`. New `KONTENT_*` env vars need a hidden yargs option registered in `src/index.ts` — `.strict()` + `.env("KONTENT")` rejects unknown env vars otherwise. +Amplitude-based, see `TELEMETRY.md`. Env vars are read from `process.env` where they apply, never mapped onto yargs options — `src/index.ts` deliberately does not call `.env()`, so a stray `KONTENT_*` var cannot break an unrelated command. Event names and the custom event-property keys we set are kebab-case (`cli__some-command`, `error-code`, `sample-project-type`); single words stay bare (`outcome`). Amplitude's built-in fields (`device_id`, `user_id`, `platform`, `app_version`, `os_name`, `os_version`) are the exception and keep `snake_case`. diff --git a/README.md b/README.md index 5f367e2..abb2e56 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,14 @@ Each command supports `--help` for its own options. - `--configFile` — path to a JSON file with CLI parameters - `--help`, `-h` / `--version`, `-v` -Options can also be supplied via `KONTENT_*` environment variables. +## Environment variables + +Environment variables are read individually where they apply — they are not +mapped onto option names, so an unrelated `KONTENT_*` variable in your shell +never reaches the parser. + +- `KONTENT_MAPI_KEY` — Management API key for [`kontent mapi`](src/commands/mapi/README.md), used when `--mapiKey` is absent +- `DO_NOT_TRACK`, `KONTENT_DO_NOT_TRACK`, `KONTENT_TELEMETRY_DEBUG` — see [TELEMETRY.md](./TELEMETRY.md) ## Telemetry diff --git a/src/commands/mapi/README.md b/src/commands/mapi/README.md index 5a57a43..2dec707 100644 --- a/src/commands/mapi/README.md +++ b/src/commands/mapi/README.md @@ -22,7 +22,7 @@ kontent mapi [options] | Option | Type | Description | | --- | --- | --- | | `--envId` | string | **Required.** Environment ID (Guid) | -| `--mapiKey` | string | Management API key. Defaults to the logged-in user's token | +| `--mapiKey` | string | Management API key. Falls back to the KONTENT_MAPI_KEY environment variable, then to the logged-in user's token | | `--method`, `-X` | string | HTTP method. (default: GET, or POST with --input) | | `--header`, `-H` | string[] | Request header in the "Name: value" format. Repeatable. An Authorization header takes precedence over --mapiKey and the stored login token | | `--input` | string | File with the request body, or "-" to read stdin | diff --git a/src/commands/mapi/request.ts b/src/commands/mapi/request.ts index cba0d58..fc3900a 100644 --- a/src/commands/mapi/request.ts +++ b/src/commands/mapi/request.ts @@ -46,7 +46,8 @@ export const register: RegisterCommand = (sub, deps) => }) .option("mapiKey", { type: "string", - describe: "Management API key. Defaults to the logged-in user's token", + describe: + "Management API key. Falls back to the KONTENT_MAPI_KEY environment variable, then to the logged-in user's token", }) .option("method", { type: "string", @@ -91,6 +92,29 @@ export const register: RegisterCommand = (sub, deps) => handler: async (args) => runRequest(args, createLoggerFromArgs(args), deps.telemetry), }); +/** + * Each source suppresses the ones below it, so a supplied credential never triggers + * a keychain read that could fail on a machine that never ran `kontent login`. + * + * `KONTENT_MAPI_KEY` is read here rather than through a yargs option: the CLI does + * not map env vars onto flags (see `src/index.ts`). It keeps the key off argv, so + * CI and shared shells do not leak it through `ps` or shell history. + */ +export const resolveCredential = async ( + headers: ReadonlyArray
, + mapiKey: string | undefined, + env: NodeJS.ProcessEnv = process.env, +): Promise> => { + if (headers.some((header) => header.name.toLowerCase() === "authorization")) { + return ok({ source: "header" }); + } + const suppliedKey = mapiKey ?? env.KONTENT_MAPI_KEY; + if (suppliedKey !== undefined && suppliedKey !== "") { + return ok({ token: suppliedKey, source: "mapi-key" }); + } + return map(await getValidAccessToken(), (token) => ({ token, source: "login" }) as const); +}; + const runRequest = async ( args: RequestArgs, logger: Logger, @@ -263,21 +287,6 @@ const readStdin = async (): Promise> => { return Uint8Array.from(Buffer.concat(chunks)); }; -// Each source suppresses the ones below it, so a supplied credential never triggers -// a keychain read that could fail on a machine that never ran `kontent login`. -const resolveCredential = async ( - headers: ReadonlyArray
, - mapiKey: string | undefined, -): Promise> => { - if (headers.some((header) => header.name.toLowerCase() === "authorization")) { - return ok({ source: "header" }); - } - if (mapiKey !== undefined) { - return ok({ token: mapiKey, source: "mapi-key" }); - } - return map(await getValidAccessToken(), (token) => ({ token, source: "login" }) as const); -}; - const writeResponse = (response: MapiResponse, shouldIncludeHeaders: boolean): void => { if (shouldIncludeHeaders) { const headerLines = response.headers.map((header) => `${header.name}: ${header.value}`); diff --git a/src/index.ts b/src/index.ts index af11897..1b50a9a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,9 +16,13 @@ import type { CommandDeps } from "./types/yargs.js"; const emptyYargs = yargs(hideBin(process.argv)); +// Deliberately no .env() prefix mapping: it turns every KONTENT_* variable in +// the shell into a flag, and .strict() then rejects the ones a given command +// does not declare - an unrelated KONTENT_PROJECT_ID would break the whole CLI. +// Each variable is read where it is used instead (lib/config/kontentUrl.ts, +// lib/auth/config.ts, lib/telemetry/consent.ts, commands/mapi/request.ts). const initialYargs = emptyYargs .wrap(emptyYargs.terminalWidth()) - .env("KONTENT") .scriptName("kontent") .epilogue("Docs: https://kontent.ai/learn | Contact: devrel@kontent.ai") .demandCommand(1, chalk.red("You need to provide a command to run.")) @@ -30,18 +34,6 @@ const initialYargs = emptyYargs const withLogLevel = addLogLevelOptions(initialYargs); -// Hidden options exist only so .strict() + .env("KONTENT") accept the -// KONTENT_* env vars; the resolvers read process.env directly. The auth0* ones -// are a developer escape hatch for pointing the CLI at a non-default tenant -// (e.g. QA) via KONTENT_AUTH0_* env vars. -const withHiddenEnvOptions = withLogLevel - .option("doNotTrack", { type: "boolean", hidden: true }) - .option("telemetryDebug", { type: "boolean", hidden: true }) - .option("url", { type: "string", hidden: true }) - .option("auth0Domain", { type: "string", hidden: true }) - .option("auth0ClientId", { type: "string", hidden: true }) - .option("auth0Audience", { type: "string", hidden: true }); - const kontentDomainResult = validateKontentDomain(getKontentBaseDomain()); if (isErr(kontentDomainResult)) { console.error(`${chalkStderr.red("Error:")} ${kontentDomainResult.error}`); @@ -53,7 +45,7 @@ const deps: CommandDeps = { telemetry }; registerTelemetrySignalFlush(telemetry); // Runs after parsing (so --verbose is known) and before the command handler. -const withTelemetryModeLog = withHiddenEnvOptions.middleware((args) => { +const withTelemetryModeLog = withLogLevel.middleware((args) => { createLoggerFromArgs(args).info("verbose", formatTelemetryMode(mode)); }); diff --git a/test/e2e/helpers/config.ts b/test/e2e/helpers/config.ts index cbcd5ad..d8d464d 100644 --- a/test/e2e/helpers/config.ts +++ b/test/e2e/helpers/config.ts @@ -3,8 +3,8 @@ export type E2eConfig = Readonly<{ sourceEnvId: string; }>; -// The E2E_* names deliberately avoid the KONTENT_ prefix: yargs maps every -// KONTENT_* env var to a CLI argument and .strict() rejects unknown ones. +// The E2E_* names keep the suite's own credentials distinct from the KONTENT_* +// ones the CLI reads, so a run never picks up a developer's working environment. export const requireE2eConfig = (): E2eConfig => { const mapiKey = readVar("E2E_MAPI_KEY"); const sourceEnvId = readVar("E2E_SOURCE_ENV_ID"); diff --git a/test/e2e/helpers/runCli.ts b/test/e2e/helpers/runCli.ts index 6162fa9..91026e0 100644 --- a/test/e2e/helpers/runCli.ts +++ b/test/e2e/helpers/runCli.ts @@ -13,9 +13,9 @@ export type CliRunOptions = Readonly<{ env?: Readonly>; }>; -// Spawns the built binary with a curated environment: no stray KONTENT_* vars -// (yargs would map them to CLI arguments), telemetry off. Resolves on any exit -// code - a non-zero exit is a result the tests assert on, not a failure. +// Spawns the built binary with a curated environment: only the vars the CLI +// actually reads, telemetry off, so a developer's shell cannot steer a run. +// Resolves on any exit code - a non-zero exit is a result the tests assert on. export const runCli = ( args: ReadonlyArray, options: CliRunOptions = {}, diff --git a/test/unit/resolveCredential.test.ts b/test/unit/resolveCredential.test.ts new file mode 100644 index 0000000..ae911ae --- /dev/null +++ b/test/unit/resolveCredential.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveCredential } from "../../src/commands/mapi/request.js"; +import { ok } from "../../src/lib/result.js"; +import { assertOk } from "../helpers/assertResult.js"; + +vi.mock("../../src/lib/auth/tokenAccess.js", () => ({ + getValidAccessToken: vi.fn(async () => ok("stored-login-token")), +})); + +const authorization = [{ name: "Authorization", value: "Bearer supplied" }]; + +describe("resolveCredential", () => { + it("prefers an Authorization header and adds no token of its own", async () => { + const result = await resolveCredential(authorization, "flag-key", { + KONTENT_MAPI_KEY: "env-key", + }); + + assertOk(result); + expect(result.value).toEqual({ source: "header" }); + }); + + it("matches the Authorization header case-insensitively", async () => { + const result = await resolveCredential( + [{ name: "authorization", value: "Bearer x" }], + undefined, + {}, + ); + + assertOk(result); + expect(result.value.source).toBe("header"); + }); + + it("prefers --mapiKey over the environment variable", async () => { + const result = await resolveCredential([], "flag-key", { KONTENT_MAPI_KEY: "env-key" }); + + assertOk(result); + expect(result.value).toEqual({ token: "flag-key", source: "mapi-key" }); + }); + + it("falls back to KONTENT_MAPI_KEY when --mapiKey is absent", async () => { + const result = await resolveCredential([], undefined, { KONTENT_MAPI_KEY: "env-key" }); + + assertOk(result); + expect(result.value).toEqual({ token: "env-key", source: "mapi-key" }); + }); + + it("falls back to the stored login token when nothing is supplied", async () => { + const result = await resolveCredential([], undefined, {}); + + assertOk(result); + expect(result.value).toEqual({ token: "stored-login-token", source: "login" }); + }); + + // An exported-but-empty variable is how a CI runner spells "unset". + it("treats an empty KONTENT_MAPI_KEY as unset", async () => { + const result = await resolveCredential([], undefined, { KONTENT_MAPI_KEY: "" }); + + assertOk(result); + expect(result.value).toEqual({ token: "stored-login-token", source: "login" }); + }); +}); From eba75509da7d5d75cfc024e870a18e16a9ac6ada Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Thu, 20 Aug 2026 08:59:02 +0200 Subject: [PATCH 08/25] fix: bound, abort, and correctly parse the 429 retry backoff Three defects in the same wait. The delay had no upper bound: `Retry-After: 3600` slept an hour, three times over. Past a minute the API is rationing quota rather than smoothing a burst, so the 429 now goes straight back to the caller with a warning naming the delay the API asked for. The sleep was a bare `setTimeout` that no signal could reach. Because the command installs a SIGINT handler, which suppresses Node's default kill, the first Ctrl+C did nothing at all until the sleep ran out. It now sleeps through `node:timers/promises` with the request's abort signal. `Number()` accepted values RFC 9110 delta-seconds does not: an empty `Retry-After:` parsed as 0 and fired every retry back-to-back. `-5` and `1.5` were worse - they missed the numeric path, then `Date.parse` read them as years, producing a past date and again an immediate retry. Parsing now requires `1*DIGIT`, and the HTTP-date branch requires a letter, which every legal date format has and no malformed number does. Claude-Session: https://claude.ai/code/session_01EifeX4d1oLgEbZNo6vWdRa --- src/lib/mapi/raw/client.ts | 46 +++++++++++++++++++++++++------- test/integration/mapi.test.ts | 50 +++++++++++++++++++++++++++++++++++ test/unit/retryAfter.test.ts | 20 +++++++++++--- 3 files changed, 103 insertions(+), 13 deletions(-) diff --git a/src/lib/mapi/raw/client.ts b/src/lib/mapi/raw/client.ts index 59814df..106db2c 100644 --- a/src/lib/mapi/raw/client.ts +++ b/src/lib/mapi/raw/client.ts @@ -1,3 +1,4 @@ +import { setTimeout as sleep } from "node:timers/promises"; import { AdapterAbortError, AdapterParseError, @@ -19,6 +20,9 @@ import { err, isErr, type Result, tryAsync } from "../../result.js"; const MAX_RETRY_ATTEMPTS = 3; const DEFAULT_RETRY_DELAY_MS = 1000; +// Past this the API is rationing quota, not smoothing a burst; a one-shot command +// has no business sleeping that long, so the 429 goes back to the caller instead. +const MAX_RETRY_DELAY_MS = 60_000; const TOO_MANY_REQUESTS = 429; const mapiSdkInfo: SdkInfo = { @@ -105,11 +109,31 @@ export const executeRawRequest = async ( } const delayMs = retryAfterMs(response.value.responseHeaders); + if (delayMs > MAX_RETRY_DELAY_MS) { + logger.warning( + "standard", + `Rate limited (429). The API asked for ${Math.round(delayMs / 1000)} s, beyond the ${ + MAX_RETRY_DELAY_MS / 1000 + } s retry limit - not retrying.`, + ); + return response; + } + logger.warning( "standard", `Rate limited (429). Retrying in ${delayMs} ms (attempt ${attempt + 1}/${MAX_RETRY_ATTEMPTS}).`, ); - await delay(delayMs); + + // A bare setTimeout would ignore Ctrl+C: the command installs a SIGINT handler, + // which suppresses the default kill, so an unabortable sleep swallows the signal. + const waited = await tryAsync( + async () => await sleep(delayMs, undefined, { signal: request.abortSignal }), + () => "The request was aborted.", + ); + if (isErr(waited)) { + return waited; + } + return await send(attempt + 1); }; @@ -128,9 +152,15 @@ export const retryAfterMs = (headers: ReadonlyArray
): number => { return DEFAULT_RETRY_DELAY_MS; } - const seconds = Number(raw); - if (Number.isFinite(seconds)) { - return Math.max(0, seconds * 1000); + if (deltaSecondsPattern.test(raw)) { + return Number(raw) * 1000; + } + + // Date.parse is lenient enough to read "-5" and "1.5" as years, which would turn + // a malformed delay into a past date and so into an immediate retry. Every legal + // HTTP-date carries a weekday and month name, so require a letter before trying. + if (!/[a-z]/i.test(raw)) { + return DEFAULT_RETRY_DELAY_MS; } const dateMs = Date.parse(raw); @@ -140,6 +170,9 @@ export const retryAfterMs = (headers: ReadonlyArray
): number => { return Math.max(0, dateMs - Date.now()); }; +// RFC 9110 delta-seconds: 1*DIGIT. Number() would also swallow "", "1e3" and "0x10". +const deltaSecondsPattern = /^\d+$/; + // Names are canonicalized to lowercase - what fetch (and HTTP/2) put on the wire // anyway - so the merged set is deterministic regardless of the caller's casing. const mergeHeaders = ( @@ -174,8 +207,3 @@ const describeTransportError = (cause: unknown): string => { } return String(cause); }; - -const delay = async (ms: number): Promise => - new Promise((resolve) => { - setTimeout(resolve, ms); - }); diff --git a/test/integration/mapi.test.ts b/test/integration/mapi.test.ts index bd8786f..a642e7f 100644 --- a/test/integration/mapi.test.ts +++ b/test/integration/mapi.test.ts @@ -164,6 +164,56 @@ describe("performRawMapiRequest", () => { expect(result.value.statusCode).toBe(429); }); + it("does not retry when Retry-After asks for longer than the retry limit", async () => { + const { result, requests } = await run([ + { + method: "GET", + path: /\/types$/, + replies: [ + { + status: 429, + statusText: "Too Many Requests", + headers: [{ name: "Retry-After", value: "3600" }], + }, + { payload: { types: [] } }, + ], + }, + ]); + + expect(requests).toHaveLength(1); + assertOk(result); + expect(result.value.statusCode).toBe(429); + }); + + it("abandons the backoff when the request is aborted mid-wait", async () => { + const controller = new AbortController(); + const pending = run( + [ + { + method: "GET", + path: /\/types$/, + replies: [ + { + status: 429, + statusText: "Too Many Requests", + // Long enough that only the abort can end the wait. + headers: [{ name: "Retry-After", value: "30" }], + }, + { payload: { types: [] } }, + ], + }, + ], + { params: { abortSignal: controller.signal } }, + ); + setTimeout(() => controller.abort(), 20); + + const { result, requests } = await pending; + + expect(requests).toHaveLength(1); + assertErr(result); + expect(result.error).toEqual({ kind: "transport", message: "The request was aborted." }); + }); + it("does not retry a non-429 failure", async () => { // If retrying ever leaks past 429, the second reply answers 201 and both asserts fail. const { result, requests } = await run( diff --git a/test/unit/retryAfter.test.ts b/test/unit/retryAfter.test.ts index e80a34e..24d6dda 100644 --- a/test/unit/retryAfter.test.ts +++ b/test/unit/retryAfter.test.ts @@ -14,12 +14,24 @@ describe("retryAfterMs", () => { expect(retryAfterMs([{ name: "retry-after", value: "3" }])).toBe(3000); }); - it("clamps a negative delay to zero", () => { - expect(retryAfterMs([{ name: "Retry-After", value: "-5" }])).toBe(0); + it.each([ + ["garbage", "not a number or a date"], + ["", "an empty value - Number() would read it as 0 and retry immediately"], + [" ", "a blank value"], + ["-5", "a negative delay - Date.parse would read it as the year 2001"], + ["1.5", "a fractional delay - Date.parse would read it as January 2001"], + ["1e3", "exponent notation, which delta-seconds does not allow"], + ["0x10", "hex notation, which delta-seconds does not allow"], + ])("falls back to the default for %j (%s)", (value) => { + expect(retryAfterMs([{ name: "Retry-After", value }])).toBe(1000); }); - it("falls back to the default for an unparsable value", () => { - expect(retryAfterMs([{ name: "Retry-After", value: "garbage" }])).toBe(1000); + it("reads a zero delay as an immediate retry", () => { + expect(retryAfterMs([{ name: "Retry-After", value: "0" }])).toBe(0); + }); + + it("passes a delay beyond the retry limit through unclamped, for the caller to reject", () => { + expect(retryAfterMs([{ name: "Retry-After", value: "3600" }])).toBe(3_600_000); }); describe("with an HTTP-date value", () => { From 33cfb7fb86262c709c060782df094523b83b4aaa Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Thu, 20 Aug 2026 09:00:09 +0200 Subject: [PATCH 09/25] fix: treat backslashes as separators in the endpoint traversal guard The guard split the endpoint on forward slashes only, so a backslash form walked straight past it. WHATWG treats backslashes as separators in an https URL, which means `kontent mapi 'types\..\..\secret'` resolved to `https://manage.kontent.ai/v2/projects/secret` - out of the `projects/{environment_id}` scope the guard exists to hold. The host stays pinned either way and the caller only escapes their own scoping, so this is not a privilege boundary. The guard was simply not doing what it claimed. Percent-encoded separators need no handling: `%2f` and `%5c` stay encoded in the resolved path and cannot traverse. Only `%2e%2e` decodes into a segment that can, and the per-segment decode already covered it. Verified against the built binary: the endpoint above is now rejected with `must not contain ".." path segments`. Claude-Session: https://claude.ai/code/session_01EifeX4d1oLgEbZNo6vWdRa --- src/lib/mapi/raw/endpoint.ts | 8 +++++++- test/unit/endpoint.test.ts | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/lib/mapi/raw/endpoint.ts b/src/lib/mapi/raw/endpoint.ts index 23bfd32..7afcc17 100644 --- a/src/lib/mapi/raw/endpoint.ts +++ b/src/lib/mapi/raw/endpoint.ts @@ -50,8 +50,14 @@ export const resolveEndpoint = ( const isAbsolute = (endpoint: string): boolean => /^[a-z][a-z\d+\-.]*:/i.test(endpoint) || endpoint.startsWith("//"); +// Split on backslashes too: WHATWG treats them as separators in an https URL, so +// "types\..\..\secret" collapses out of the projects/{environment_id} scope +// exactly like the forward-slash form. Percent-encoded separators (%2f, %5c) stay +// encoded in the path and cannot traverse, so only literal ones matter here. const hasTraversal = (relative: string): boolean => - (relative.split("?")[0] ?? relative).split("/").some((segment) => decodeSafely(segment) === ".."); + (relative.split("?")[0] ?? relative) + .split(/[/\\]/) + .some((segment) => decodeSafely(segment) === ".."); // A malformed percent-escape is not traversal; keep the raw segment and let the URL carry it. const decodeSafely = (segment: string): string => { diff --git a/test/unit/endpoint.test.ts b/test/unit/endpoint.test.ts index 53f8c64..2db10a2 100644 --- a/test/unit/endpoint.test.ts +++ b/test/unit/endpoint.test.ts @@ -72,6 +72,9 @@ describe("resolveEndpoint", () => { ["../../admin", "traversal"], ["types/../../admin", "traversal"], ["types/%2e%2e/admin", "traversal"], + ["types\\..\\..\\secret", "traversal"], + ["..\\..\\admin", "traversal"], + ["types/..\\admin", "traversal"], ])("rejects %j as %s", (endpoint, kind) => { const result = resolve(endpoint); From fd9585aec69e7879952a536803d5811fe2d0ef0c Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Thu, 20 Aug 2026 09:02:16 +0200 Subject: [PATCH 10/25] fix: correct argument handling in kontent mapi `--header` is an array option without nargs, so it kept consuming words: `kontent mapi -H 'X-Foo: 1' types --envId ` ate the endpoint and failed with `Not enough non-option arguments`. `--input` already had `.nargs(1)`; `--header` now does too, and the parsing is covered by tests that drive the real yargs wiring. `-X GET --input` was documented as curl parity - curl does send a GET with a body - but undici refuses one, so the command exited on a raw `Request with GET/HEAD method cannot have body.` It is now rejected up front, before the input file is even opened, and the comment says where parity stops. `--input` sends `Content-Type: application/json` unless a header overrides it. For a binary upload that is silently wrong: the Management API stores the header as the asset's MIME type, so a PNG uploaded without `-H` is served as JSON, with an exit code of 0 and no warning. The option's description now says so, and `pnpm docs:generate` carries it into the command reference. Also stops an EPIPE from a closed child stdin taking down the e2e worker: the CLI can exit before reading piped input, and the `error` event had no listener. Claude-Session: https://claude.ai/code/session_01EifeX4d1oLgEbZNo6vWdRa --- src/commands/mapi/README.md | 2 +- src/commands/mapi/request.ts | 24 +++++++- test/e2e/helpers/runCli.ts | 3 + test/integration/mapiCommand.test.ts | 87 ++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 test/integration/mapiCommand.test.ts diff --git a/src/commands/mapi/README.md b/src/commands/mapi/README.md index 2dec707..c10ce97 100644 --- a/src/commands/mapi/README.md +++ b/src/commands/mapi/README.md @@ -25,7 +25,7 @@ kontent mapi [options] | `--mapiKey` | string | Management API key. Falls back to the KONTENT_MAPI_KEY environment variable, then to the logged-in user's token | | `--method`, `-X` | string | HTTP method. (default: GET, or POST with --input) | | `--header`, `-H` | string[] | Request header in the "Name: value" format. Repeatable. An Authorization header takes precedence over --mapiKey and the stored login token | -| `--input` | string | File with the request body, or "-" to read stdin | +| `--input` | string | File with the request body, or "-" to read stdin. Sent as application/json unless a Content-Type header says otherwise - set one when uploading a binary file, since the Management API stores it as the asset's MIME type | | `--include`, `-i` | boolean | Print the status line and response headers before the body | ## Examples diff --git a/src/commands/mapi/request.ts b/src/commands/mapi/request.ts index fc3900a..da906a5 100644 --- a/src/commands/mapi/request.ts +++ b/src/commands/mapi/request.ts @@ -61,9 +61,13 @@ export const register: RegisterCommand = (sub, deps) => describe: 'Request header in the "Name: value" format. Repeatable. An Authorization header takes precedence over --mapiKey and the stored login token', }) + // Without nargs the array is greedy, so `-H 'X-Foo: 1' types` swallows the + // endpoint and yargs then reports it as a missing positional. + .nargs("header", 1) .option("input", { type: "string", - describe: 'File with the request body, or "-" to read stdin', + describe: + 'File with the request body, or "-" to read stdin. Sent as application/json unless a Content-Type header says otherwise - set one when uploading a binary file, since the Management API stores it as the asset\'s MIME type', }) // Without nargs, yargs-parser reads the lone "-" of `--input -` as a // positional and .strict() then rejects it as an unknown argument. @@ -202,6 +206,16 @@ const prepareRequest = async ( return method; } + // Checked before the input is read: there is no point opening a file the + // request can never carry. Only an explicit `-X GET` reaches this. + if (args.input !== undefined && method.value === "GET") { + return err({ + kind: "invalid-method", + message: + "A GET request cannot carry a body. Use -X POST, PUT or PATCH with --input, or drop --input.", + }); + } + const headers = parseHeaders(args.header ?? []); if (isErr(headers)) { return err({ kind: "invalid-header", message: headers.error }); @@ -227,11 +241,15 @@ const prepareRequest = async ( * Two rules, the same ones curl and `gh api` apply: * * - no `-X`: GET, or POST when `--input` supplies a body; - * - `-X` given: that method verbatim, body included if there is one - so - * `-X GET --input` sends a GET with a body rather than second-guessing it. + * - `-X` given: that method verbatim. * * A yargs `default` would break the first rule: it is indistinguishable from a * typed `-X GET`, which would turn every `--input` into a GET with a body. + * + * Where curl parity stops: curl does send `-X GET` with a body, we cannot. The + * fetch spec forbids one on GET, and undici throws before the request leaves, so + * `prepareRequest` rejects the pair with an explanation instead of surfacing a + * raw transport error. */ const resolveMethod = ( raw: string | undefined, diff --git a/test/e2e/helpers/runCli.ts b/test/e2e/helpers/runCli.ts index 91026e0..1d19a6b 100644 --- a/test/e2e/helpers/runCli.ts +++ b/test/e2e/helpers/runCli.ts @@ -40,6 +40,9 @@ export const runCli = ( }); }); + // The CLI may exit before reading stdin (a rejected argument, say). Without a + // listener the resulting EPIPE would take down the test worker. + child.stdin.on("error", () => {}); if (options.stdin !== undefined) { child.stdin.write(options.stdin); } diff --git a/test/integration/mapiCommand.test.ts b/test/integration/mapiCommand.test.ts new file mode 100644 index 0000000..41f54e3 --- /dev/null +++ b/test/integration/mapiCommand.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import yargs from "yargs"; +import { register } from "../../src/commands/mapi/request.js"; +import type { MapiRequestParams } from "../../src/core/mapi/request.js"; +import { performRawMapiRequest } from "../../src/core/mapi/request.js"; +import { ok } from "../../src/lib/result.js"; +import { noopTelemetry } from "../../src/lib/telemetry/tracking.js"; + +vi.mock("../../src/core/mapi/request.js", () => ({ + performRawMapiRequest: vi.fn(async () => + ok({ statusCode: 200, statusText: "OK", headers: [], payload: null }), + ), +})); + +vi.mock("../../src/lib/auth/tokenAccess.js", () => ({ + getValidAccessToken: vi.fn(async () => ok("stored-login-token")), +})); + +const ENV_ID = "11111111-2222-3333-4444-555555555555"; + +// Drives the real yargs wiring, so what the parser hands the handler is what is +// asserted on. The core call is faked; everything above it is production code. +const runCommand = async (argv: ReadonlyArray): Promise => { + const parser = register( + yargs([...argv]) + .strict() + .exitProcess(false) + .fail(false), + { + telemetry: noopTelemetry, + }, + ); + try { + await parser.parseAsync([...argv]); + return undefined; + } catch (cause) { + return cause instanceof Error ? cause.message : String(cause); + } +}; + +const lastParams = (): MapiRequestParams => + vi.mocked(performRawMapiRequest).mock.calls.at(-1)?.[0] as MapiRequestParams; + +describe("kontent mapi argument handling", () => { + beforeEach(() => { + process.exitCode = undefined; + }); + + it("keeps -H from swallowing the endpoint positional", async () => { + const failure = await runCommand(["-H", "X-Foo: 1", "types", "--envId", ENV_ID]); + + expect(failure).toBeUndefined(); + expect(lastParams().endpoint).toBe("types"); + expect(lastParams().headers).toContainEqual({ name: "X-Foo", value: "1" }); + }); + + it("accepts -H after the endpoint too", async () => { + const failure = await runCommand(["types", "-H", "X-Foo: 1", "--envId", ENV_ID]); + + expect(failure).toBeUndefined(); + expect(lastParams().endpoint).toBe("types"); + }); + + it("collects a repeated -H into one header list", async () => { + await runCommand(["-H", "X-Foo: 1", "-H", "X-Bar: 2", "types", "--envId", ENV_ID]); + + expect(lastParams().headers).toEqual([ + { name: "X-Foo", value: "1" }, + { name: "X-Bar", value: "2" }, + ]); + }); + + it("rejects a body on GET instead of letting the transport throw", async () => { + const errors: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + errors.push(String(chunk)); + return true; + }); + + await runCommand(["types", "-X", "GET", "--input", "body.json", "--envId", ENV_ID]); + vi.mocked(process.stderr.write).mockRestore(); + + expect(errors.join("")).toContain("A GET request cannot carry a body"); + expect(process.exitCode).toBe(1); + expect(performRawMapiRequest).not.toHaveBeenCalled(); + }); +}); From bcd5763d48d38414173e7614ff8b6d78ee5bf49c Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Thu, 20 Aug 2026 09:04:39 +0200 Subject: [PATCH 11/25] fix: send command payloads to stdout, unconditionally Two breaks of the output-channel contract this branch introduced: stdout carries the data the command exists to produce and is never level-gated; stderr carries everything said about producing it. `kontent telemetry status` reported through `logger.info("standard", ...)`, so its status block went to stderr and vanished entirely under `--logLevel none` - `kontent telemetry status | grep enabled` matched nothing. The report is the command's payload, so core now returns it and the command writes it to stdout. Moving it also drops the Logger from that call path, which core only needed in order to print. `enable`/`disable` keep logging: a confirmation is not a payload. The other break is quieter. The adapter parses only application/json, so any other body arrives as a null payload and nothing is printed. On a failure the summary already said "(non-JSON response body omitted)"; on a success the command exited 0 with empty stdout and no explanation. It now names the content type on stderr. Both paths share one predicate, so neither reports an omitted body for a response that simply had none - a 204 stays silent. Verified against the built binary: the status block is on stdout, stderr is empty, `--logLevel none` still prints it, and it pipes to grep. Claude-Session: https://claude.ai/code/session_01EifeX4d1oLgEbZNo6vWdRa --- CLAUDE.md | 5 +- src/commands/mapi/request.ts | 23 ++++++++- src/commands/telemetry/status.ts | 7 +-- src/core/telemetry/settings.ts | 19 +++---- test/integration/mapiCommand.test.ts | 50 +++++++++++++++--- test/integration/telemetryStatus.test.ts | 64 ++++++++++++++++++++++++ 6 files changed, 146 insertions(+), 22 deletions(-) create mode 100644 test/integration/telemetryStatus.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 61cc713..b1c2141 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,8 @@ Adding a command: export a `register: RegisterCommand` (see `src/commands/login/ - **stdout** — the data the command exists to produce, and nothing else. It is never level-gated: `--logLevel none` must still print a payload, because a response body is not a log. - **stderr** — everything said *about* producing it: progress, warnings, errors, verbose traces. This is the POSIX meaning of stderr (diagnostics, not errors), and how curl, git and npm behave. -Every handler starts with `const logger = createLoggerFromArgs(args)` (`src/log.ts`) and passes that `Logger` down; core takes it as a parameter or inside its `deps` object. `createLoggerFromArgs` is the only place that resolves the `--logLevel`/`--verbose` pair; everything else builds a logger from a single `LogLevel` via `createLogger`. The `sink` parameter is a test seam, not a routing knob — never point a log at stdout. +A handler that logs starts with `const logger = createLoggerFromArgs(args)` (`src/log.ts`) and passes that `Logger` down; one that only emits a payload takes no logger at all (`src/commands/telemetry/status.ts`). +Core takes the logger as a parameter or inside its `deps` object; `createLoggerFromArgs` is the only place that resolves the `--logLevel`/`--verbose` pair; everything else builds a logger from a single `LogLevel` via `createLogger`. The `sink` parameter is a test seam, not a routing knob — never point a log at stdout. ## Conventions @@ -56,7 +57,7 @@ Every handler starts with `const logger = createLoggerFromArgs(args)` (`src/log. ## Testing -Vitest; `test/unit/` for pure unit tests, `test/integration/` for integration tests, `test/helpers/` for shared helpers. Run `pnpm test`. Inject fakes into core instead of real I/O — for iapi reuse `test/helpers/iapiTestClient.ts` (real client over core-sdk's `HttpAdapter` seam, declarative routes). +Vitest; `test/unit/` for pure unit tests, `test/integration/` for integration tests, `test/helpers/` for shared helpers. Command-level behavior (argument parsing, exit codes, which stream a message lands on) is tested by folding a command's `register` over a real yargs instance and faking only the core call underneath — see `test/integration/mapiCommand.test.ts`. Run `pnpm test`. Inject fakes into core instead of real I/O — for iapi reuse `test/helpers/iapiTestClient.ts` (real client over core-sdk's `HttpAdapter` seam, declarative routes). `test/e2e/` runs the built binary against a real Kontent.ai project (clone-per-run from an empty template env). Gated on `E2E_MAPI_KEY`/`E2E_SOURCE_ENV_ID` (fails fast with an error when unset). Run with `pnpm test:e2e` (own `vitest.e2e.config.ts`, loads `.env`); excluded from `pnpm test` and the before-halting gate. CI: `.github/workflows/e2e.yml` (master push, PRs, manual; fork PRs are skipped at the job level — no secret access). diff --git a/src/commands/mapi/request.ts b/src/commands/mapi/request.ts index da906a5..b248356 100644 --- a/src/commands/mapi/request.ts +++ b/src/commands/mapi/request.ts @@ -167,6 +167,16 @@ const runRequest = async ( writeResponse(result.value, args.include === true); + // The adapter only parses application/json, so any other body is dropped on the + // floor. On a failure the summary says so; on a success stdout would otherwise be + // silently empty, which reads as "no content" rather than "not shown". + if (result.value.statusCode < 400 && hasOmittedBody(result.value)) { + logger.warning( + "standard", + `The response body is ${contentType(result.value) ?? "not JSON"} and is not shown.`, + ); + } + if (result.value.statusCode >= 400) { tracker.fail(`http-${result.value.statusCode}`, { "status-code": result.value.statusCode, @@ -318,9 +328,20 @@ const writeResponse = (response: MapiResponse, shouldIncludeHeaders: boolean): v } }; +// A null payload means either a non-JSON body the adapter dropped or no body at +// all; only the former is worth reporting, and the content type distinguishes them. +const hasOmittedBody = (response: MapiResponse): boolean => + response.payload === null && contentType(response) !== undefined; + +const contentType = (response: MapiResponse): string | undefined => + response.headers + .find((header) => header.name.toLowerCase() === "content-type") + ?.value.split(";")[0] + ?.trim(); + const formatFailure = (response: MapiResponse, source: AuthSource): string => { const summary = `HTTP ${response.statusCode} ${response.statusText}${ - response.payload === null ? " (non-JSON response body omitted)" : "" + hasOmittedBody(response) ? " (non-JSON response body omitted)" : "" }`; if (response.statusCode !== 401) { diff --git a/src/commands/telemetry/status.ts b/src/commands/telemetry/status.ts index b268401..eaaaea8 100644 --- a/src/commands/telemetry/status.ts +++ b/src/commands/telemetry/status.ts @@ -1,5 +1,4 @@ -import { showTelemetryStatus } from "../../core/telemetry/settings.js"; -import { createLoggerFromArgs } from "../../log.js"; +import { buildTelemetryStatusReport } from "../../core/telemetry/settings.js"; import type { RegisterCommand } from "../../types/yargs.js"; export const register: RegisterCommand = (sub) => @@ -7,5 +6,7 @@ export const register: RegisterCommand = (sub) => command: "status", describe: "Show whether telemetry is enabled and why", builder: (b) => b, - handler: async (args) => showTelemetryStatus(createLoggerFromArgs(args)), + handler: async () => { + process.stdout.write(`${await buildTelemetryStatusReport()}\n`); + }, }); diff --git a/src/core/telemetry/settings.ts b/src/core/telemetry/settings.ts index 0a622ce..fd82edb 100644 --- a/src/core/telemetry/settings.ts +++ b/src/core/telemetry/settings.ts @@ -6,7 +6,11 @@ import { formatTelemetryOffReason, resolveTelemetryConsent } from "../../lib/tel import { amplitudeApiKey } from "../../lib/telemetry/context.js"; import type { Logger } from "../../log.js"; -export const showTelemetryStatus = async (logger: Logger): Promise => { +/** + * Returns the report rather than logging it: it is the payload the command + * exists to produce, so it belongs on stdout, ungated by --logLevel. + */ +export const buildTelemetryStatusReport = async (): Promise => { const config = await readCliConfig(); const consent = resolveTelemetryConsent(process.env, config, amplitudeApiKey, isCI); @@ -16,14 +20,11 @@ export const showTelemetryStatus = async (logger: Logger): Promise => { : "Reason: default (no opt-out detected)" : `Reason: ${formatTelemetryOffReason(consent.reason)}`; - logger.info( - "standard", - [ - `Telemetry: ${consent.isEnabled ? "enabled" : "disabled"}`, - reasonLine, - `Config file: ${getCliConfigPath()}`, - ].join("\n"), - ); + return [ + `Telemetry: ${consent.isEnabled ? "enabled" : "disabled"}`, + reasonLine, + `Config file: ${getCliConfigPath()}`, + ].join("\n"); }; export const setTelemetryStatus = async (logger: Logger, isEnabled: boolean): Promise => { diff --git a/test/integration/mapiCommand.test.ts b/test/integration/mapiCommand.test.ts index 41f54e3..51135d3 100644 --- a/test/integration/mapiCommand.test.ts +++ b/test/integration/mapiCommand.test.ts @@ -38,12 +38,22 @@ const runCommand = async (argv: ReadonlyArray): Promise { + const chunks: string[] = []; + const spy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + chunks.push(String(chunk)); + return true; + }); + return { text: () => chunks.join(""), restore: () => spy.mockRestore() }; +}; + const lastParams = (): MapiRequestParams => vi.mocked(performRawMapiRequest).mock.calls.at(-1)?.[0] as MapiRequestParams; describe("kontent mapi argument handling", () => { beforeEach(() => { process.exitCode = undefined; + vi.mocked(performRawMapiRequest).mockClear(); }); it("keeps -H from swallowing the endpoint positional", async () => { @@ -70,17 +80,43 @@ describe("kontent mapi argument handling", () => { ]); }); + it("notes a non-JSON body on a success, where stdout would otherwise be empty", async () => { + vi.mocked(performRawMapiRequest).mockResolvedValueOnce( + ok({ + statusCode: 200, + statusText: "OK", + headers: [{ name: "Content-Type", value: "text/csv; charset=utf-8" }], + payload: null, + }), + ); + const captured = captureStderr(); + + await runCommand(["export", "--envId", ENV_ID]); + captured.restore(); + + expect(captured.text()).toContain("The response body is text/csv and is not shown."); + expect(process.exitCode).toBeUndefined(); + }); + + it("stays quiet when a success simply has no body", async () => { + vi.mocked(performRawMapiRequest).mockResolvedValueOnce( + ok({ statusCode: 204, statusText: "No Content", headers: [], payload: null }), + ); + const captured = captureStderr(); + + await runCommand(["items/x", "-X", "DELETE", "--envId", ENV_ID]); + captured.restore(); + + expect(captured.text()).toBe(""); + }); + it("rejects a body on GET instead of letting the transport throw", async () => { - const errors: string[] = []; - vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { - errors.push(String(chunk)); - return true; - }); + const captured = captureStderr(); await runCommand(["types", "-X", "GET", "--input", "body.json", "--envId", ENV_ID]); - vi.mocked(process.stderr.write).mockRestore(); + captured.restore(); - expect(errors.join("")).toContain("A GET request cannot carry a body"); + expect(captured.text()).toContain("A GET request cannot carry a body"); expect(process.exitCode).toBe(1); expect(performRawMapiRequest).not.toHaveBeenCalled(); }); diff --git a/test/integration/telemetryStatus.test.ts b/test/integration/telemetryStatus.test.ts new file mode 100644 index 0000000..71bcee4 --- /dev/null +++ b/test/integration/telemetryStatus.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from "vitest"; +import yargs from "yargs"; +import { register } from "../../src/commands/telemetry/status.js"; +import { noopTelemetry } from "../../src/lib/telemetry/tracking.js"; +import { addLogLevelOptions } from "../../src/log.js"; + +vi.mock("../../src/lib/config/cliConfig.js", () => ({ + readCliConfig: vi.fn(async () => ({ telemetryEnabled: true, telemetryNoticeShown: true })), + getCliConfigPath: () => "/tmp/kontent/config.json", +})); + +vi.mock("ci-info", () => ({ isCI: false })); + +// A build without an Amplitude key reports telemetry off whatever the config says. +vi.mock("../../src/lib/telemetry/context.js", () => ({ + amplitudeApiKey: "test-key", + cliVersion: "0.0.0-test", +})); + +const runStatus = async (argv: ReadonlyArray) => { + const streams = { stdout: "", stderr: "" }; + const capture = (key: "stdout" | "stderr") => + vi.spyOn(process[key], "write").mockImplementation((chunk) => { + streams[key] += String(chunk); + return true; + }); + const spies = [capture("stdout"), capture("stderr")]; + + try { + await register( + addLogLevelOptions( + yargs([...argv]) + .strict() + .exitProcess(false), + ), + { + telemetry: noopTelemetry, + }, + ).parseAsync([...argv]); + } finally { + for (const spy of spies) { + spy.mockRestore(); + } + } + return streams; +}; + +describe("kontent telemetry status", () => { + it("writes the report to stdout, not stderr", async () => { + const { stdout, stderr } = await runStatus(["status"]); + + expect(stdout).toContain("Telemetry: enabled"); + expect(stdout).toContain("Reason: enabled in the config file"); + expect(stdout).toContain("Config file: /tmp/kontent/config.json"); + expect(stderr).toBe(""); + }); + + // The report is the command's payload, so --logLevel must not be able to mute it. + it("still writes the report at --logLevel none", async () => { + const { stdout } = await runStatus(["status", "--logLevel", "none"]); + + expect(stdout).toContain("Telemetry: enabled"); + }); +}); From 7dcce04f4274142aa7408d5aa8d634810353add6 Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Thu, 20 Aug 2026 13:26:17 +0200 Subject: [PATCH 12/25] fix: return raw response bytes from `kontent mapi` core-sdk's default HttpAdapter parses application/json and drops every other body on the floor, which is an interpretation a passthrough command must not make. A CSV or a binary asset came back as an empty stdout and a warning explaining that the body existed but was not shown. Replace the adapter with a narrower RawTransport seam: the body arrives as the bytes that came off the wire, and the command decides once, on its way to stdout, how to present them. JSON is re-indented because a response body is the thing the user reads; everything else goes out byte for byte. Also extract resolveCredential out of the command and into src/lib/auth/mapiCredential.ts - it is auth logic, not presentation, and the command layer is meant to hold neither. Claude-Session: https://claude.ai/code/session_01EifeX4d1oLgEbZNo6vWdRa --- src/commands/mapi/request.ts | 75 ++++++---------- src/core/mapi/request.ts | 8 +- src/lib/auth/mapiCredential.ts | 32 +++++++ src/lib/mapi/raw/client.ts | 36 +++----- src/lib/mapi/raw/transport.ts | 49 +++++++++++ test/helpers/mapiTestAdapter.ts | 63 -------------- test/helpers/mapiTestTransport.ts | 87 +++++++++++++++++++ test/integration/mapi.test.ts | 16 ++-- test/integration/mapiCommand.test.ts | 71 ++++++++++++--- ...dential.test.ts => mapiCredential.test.ts} | 16 ++-- 10 files changed, 286 insertions(+), 167 deletions(-) create mode 100644 src/lib/auth/mapiCredential.ts create mode 100644 src/lib/mapi/raw/transport.ts delete mode 100644 test/helpers/mapiTestAdapter.ts create mode 100644 test/helpers/mapiTestTransport.ts rename test/unit/{resolveCredential.test.ts => mapiCredential.test.ts} (73%) diff --git a/src/commands/mapi/request.ts b/src/commands/mapi/request.ts index b248356..ef2501a 100644 --- a/src/commands/mapi/request.ts +++ b/src/commands/mapi/request.ts @@ -7,11 +7,10 @@ import { performRawMapiRequest, } from "../../core/mapi/request.js"; import { formatAuthError } from "../../lib/auth/formatAuthError.js"; -import { getValidAccessToken } from "../../lib/auth/tokenAccess.js"; -import type { AuthError } from "../../lib/auth/types.js"; +import { type AuthSource, resolveMapiCredential } from "../../lib/auth/mapiCredential.js"; import { createMapiRawClient } from "../../lib/mapi/raw/client.js"; import { parseHeaders } from "../../lib/mapi/raw/headers.js"; -import { err, isErr, map, ok, type Result, tryAsync } from "../../lib/result.js"; +import { err, fromThrowable, isErr, isOk, ok, type Result, tryAsync } from "../../lib/result.js"; import type { Telemetry } from "../../lib/telemetry/tracking.js"; import { createLoggerFromArgs, type Logger, type LogOptions } from "../../log.js"; import type { RegisterCommand } from "../../types/yargs.js"; @@ -96,29 +95,6 @@ export const register: RegisterCommand = (sub, deps) => handler: async (args) => runRequest(args, createLoggerFromArgs(args), deps.telemetry), }); -/** - * Each source suppresses the ones below it, so a supplied credential never triggers - * a keychain read that could fail on a machine that never ran `kontent login`. - * - * `KONTENT_MAPI_KEY` is read here rather than through a yargs option: the CLI does - * not map env vars onto flags (see `src/index.ts`). It keeps the key off argv, so - * CI and shared shells do not leak it through `ps` or shell history. - */ -export const resolveCredential = async ( - headers: ReadonlyArray
, - mapiKey: string | undefined, - env: NodeJS.ProcessEnv = process.env, -): Promise> => { - if (headers.some((header) => header.name.toLowerCase() === "authorization")) { - return ok({ source: "header" }); - } - const suppliedKey = mapiKey ?? env.KONTENT_MAPI_KEY; - if (suppliedKey !== undefined && suppliedKey !== "") { - return ok({ token: suppliedKey, source: "mapi-key" }); - } - return map(await getValidAccessToken(), (token) => ({ token, source: "login" }) as const); -}; - const runRequest = async ( args: RequestArgs, logger: Logger, @@ -134,7 +110,7 @@ const runRequest = async ( return; } - const credential = await resolveCredential(prepared.value.headers, args.mapiKey); + const credential = await resolveMapiCredential(prepared.value.headers, args.mapiKey); if (isErr(credential)) { tracker.fail(`auth:${credential.error.kind}`); logger.error(formatAuthError(credential.error)); @@ -167,16 +143,6 @@ const runRequest = async ( writeResponse(result.value, args.include === true); - // The adapter only parses application/json, so any other body is dropped on the - // floor. On a failure the summary says so; on a success stdout would otherwise be - // silently empty, which reads as "no content" rather than "not shown". - if (result.value.statusCode < 400 && hasOmittedBody(result.value)) { - logger.warning( - "standard", - `The response body is ${contentType(result.value) ?? "not JSON"} and is not shown.`, - ); - } - if (result.value.statusCode >= 400) { tracker.fail(`http-${result.value.statusCode}`, { "status-code": result.value.statusCode, @@ -196,10 +162,6 @@ type PreparedRequest = Readonly<{ body: string | Blob | null; }>; -type AuthSource = "login" | "mapi-key" | "header"; - -type Credential = Readonly<{ token?: string | undefined; source: AuthSource }>; - const httpMethods = [ "GET", "POST", @@ -323,15 +285,30 @@ const writeResponse = (response: MapiResponse, shouldIncludeHeaders: boolean): v ); } - if (response.payload !== null) { - process.stdout.write(`${JSON.stringify(response.payload, null, 2)}\n`); + if (response.body.length > 0) { + process.stdout.write(formatBody(response)); } }; -// A null payload means either a non-JSON body the adapter dropped or no body at -// all; only the former is worth reporting, and the content type distinguishes them. -const hasOmittedBody = (response: MapiResponse): boolean => - response.payload === null && contentType(response) !== undefined; +/** + * JSON is re-indented, because a response body is the thing the user reads. Every + * other type goes out byte for byte - a passthrough that reformatted a PNG or a + * CSV would be lying about what the API returned. + */ +const formatBody = (response: MapiResponse): string | Uint8Array => { + if (contentType(response) !== "application/json") { + return response.body; + } + + const text = new TextDecoder().decode(response.body); + const indented = fromThrowable( + () => JSON.stringify(JSON.parse(text) as unknown, null, 2), + () => text, + ); + // A body that claims to be JSON but does not parse is still shown, unchanged: + // malformed output is a clue, and swallowing it would hide the real answer. + return isOk(indented) ? `${indented.value}\n` : text; +}; const contentType = (response: MapiResponse): string | undefined => response.headers @@ -340,9 +317,7 @@ const contentType = (response: MapiResponse): string | undefined => ?.trim(); const formatFailure = (response: MapiResponse, source: AuthSource): string => { - const summary = `HTTP ${response.statusCode} ${response.statusText}${ - hasOmittedBody(response) ? " (non-JSON response body omitted)" : "" - }`; + const summary = `HTTP ${response.statusCode} ${response.statusText}`; if (response.statusCode !== 401) { return summary; diff --git a/src/core/mapi/request.ts b/src/core/mapi/request.ts index 6cbbf40..8df1873 100644 --- a/src/core/mapi/request.ts +++ b/src/core/mapi/request.ts @@ -1,4 +1,4 @@ -import type { Header, HttpMethod, JsonValue } from "@kontent-ai/core-sdk"; +import type { Header, HttpMethod } from "@kontent-ai/core-sdk"; import { executeRawRequest, type MapiRawClient } from "../../lib/mapi/raw/client.js"; import { resolveEndpoint } from "../../lib/mapi/raw/endpoint.js"; import { err, isErr, ok, type Result } from "../../lib/result.js"; @@ -17,7 +17,9 @@ export type MapiResponse = Readonly<{ statusCode: number; statusText: string; headers: ReadonlyArray
; - payload: JsonValue; + // The bytes exactly as they came off the wire. Nothing here decides what they + // mean; the command formats them once, on its way to stdout. + body: Uint8Array; }>; /** @@ -65,6 +67,6 @@ export const performRawMapiRequest = async ( statusCode: response.value.status, statusText: response.value.statusText, headers: response.value.responseHeaders, - payload: response.value.payload, + body: response.value.body, }); }; diff --git a/src/lib/auth/mapiCredential.ts b/src/lib/auth/mapiCredential.ts new file mode 100644 index 0000000..1dc01b9 --- /dev/null +++ b/src/lib/auth/mapiCredential.ts @@ -0,0 +1,32 @@ +import type { Header } from "@kontent-ai/core-sdk"; +import { map, ok, type Result } from "../result.js"; +import { getValidAccessToken } from "./tokenAccess.js"; +import type { AuthError } from "./types.js"; + +/** Which of the three credentials a request ended up authenticating with. */ +export type AuthSource = "login" | "mapi-key" | "header"; + +export type Credential = Readonly<{ token?: string | undefined; source: AuthSource }>; + +/** + * Each source suppresses the ones below it, so a supplied credential never triggers + * a keychain read that could fail on a machine that never ran `kontent login`. + * + * `KONTENT_MAPI_KEY` is read here rather than through a yargs option: the CLI does + * not map env vars onto flags (see `src/index.ts`). It keeps the key off argv, so + * CI and shared shells do not leak it through `ps` or shell history. + */ +export const resolveMapiCredential = async ( + headers: ReadonlyArray
, + mapiKey: string | undefined, + env: NodeJS.ProcessEnv = process.env, +): Promise> => { + if (headers.some((header) => header.name.toLowerCase() === "authorization")) { + return ok({ source: "header" }); + } + const suppliedKey = mapiKey ?? env.KONTENT_MAPI_KEY; + if (suppliedKey !== undefined && suppliedKey !== "") { + return ok({ token: suppliedKey, source: "mapi-key" }); + } + return map(await getValidAccessToken(), (token) => ({ token, source: "login" }) as const); +}; diff --git a/src/lib/mapi/raw/client.ts b/src/lib/mapi/raw/client.ts index 106db2c..21fecae 100644 --- a/src/lib/mapi/raw/client.ts +++ b/src/lib/mapi/raw/client.ts @@ -1,14 +1,8 @@ import { setTimeout as sleep } from "node:timers/promises"; import { - AdapterAbortError, - AdapterParseError, - type AdapterResponse, createSdkIdHeader, - getDefaultHttpAdapter, type Header, - type HttpAdapter, type HttpMethod, - type JsonValue, type SdkInfo, } from "@kontent-ai/core-sdk"; @@ -16,7 +10,13 @@ import { import pkg from "../../../../package.json" with { type: "json" }; import type { Logger } from "../../../log.js"; import { kontentManagementUrl } from "../../config/kontentUrl.js"; -import { err, isErr, type Result, tryAsync } from "../../result.js"; +import { isErr, type Result, tryAsync } from "../../result.js"; +import { + fetchTransport, + isAbortError, + type RawTransport, + type RawTransportResponse, +} from "./transport.js"; const MAX_RETRY_ATTEMPTS = 3; const DEFAULT_RETRY_DELAY_MS = 1000; @@ -39,7 +39,7 @@ export type MapiRawClient = Readonly<{ baseUrl: string; // Absent when the caller carries its own Authorization header; the client then adds none. token?: string | undefined; - adapter: HttpAdapter; + transport: RawTransport; sdkInfo: SdkInfo; }>; @@ -52,11 +52,11 @@ export type RawRequest = Readonly<{ }>; export const createMapiRawClient = ( - params: Readonly<{ token?: string | undefined; baseUrl?: string; adapter?: HttpAdapter }>, + params: Readonly<{ token?: string | undefined; baseUrl?: string; transport?: RawTransport }>, ): MapiRawClient => ({ baseUrl: params.baseUrl ?? kontentManagementUrl(), token: params.token, - adapter: params.adapter ?? getDefaultHttpAdapter(), + transport: params.transport ?? fetchTransport, sdkInfo: mapiSdkInfo, }); @@ -70,12 +70,7 @@ export const executeRawRequest = async ( client: MapiRawClient, request: RawRequest, logger: Logger, -): Promise, string>> => { - const executeRequest = client.adapter.executeRequest; - if (executeRequest === undefined) { - return err("The configured HTTP adapter cannot execute requests."); - } - +): Promise> => { const requestHeaders = mergeHeaders( client.token === undefined ? [createSdkIdHeader(client.sdkInfo)] @@ -87,10 +82,10 @@ export const executeRawRequest = async ( ); logger.info("verbose", formatTrace(request, requestHeaders)); - const send = async (attempt: number): Promise, string>> => { + const send = async (attempt: number): Promise> => { const response = await tryAsync( async () => - executeRequest({ + client.transport({ url: request.url, method: request.method, body: request.body, @@ -196,12 +191,9 @@ const formatTrace = (request: RawRequest, headers: ReadonlyArray
): strin }; const describeTransportError = (cause: unknown): string => { - if (cause instanceof AdapterAbortError) { + if (isAbortError(cause)) { return "The request was aborted."; } - if (cause instanceof AdapterParseError) { - return "The response could not be parsed as JSON."; - } if (cause instanceof Error) { return cause.message; } diff --git a/src/lib/mapi/raw/transport.ts b/src/lib/mapi/raw/transport.ts new file mode 100644 index 0000000..0feb5b0 --- /dev/null +++ b/src/lib/mapi/raw/transport.ts @@ -0,0 +1,49 @@ +import type { Header, HttpMethod } from "@kontent-ai/core-sdk"; + +/** + * What `kontent mapi` sends through, deliberately narrower than core-sdk's + * `HttpAdapter`: that one parses `application/json` and drops every other body on + * the floor, which is an interpretation a passthrough command must not make. The + * body arrives as the bytes that came off the wire and nothing decides what they + * mean until the command prints them. + */ +export type RawTransport = (options: RawTransportRequest) => Promise; + +export type RawTransportRequest = Readonly<{ + url: URL; + method: HttpMethod; + body: string | Blob | null; + requestHeaders: ReadonlyArray
; + abortSignal?: AbortSignal | undefined; +}>; + +export type RawTransportResponse = Readonly<{ + status: number; + statusText: string; + responseHeaders: ReadonlyArray
; + body: Uint8Array; +}>; + +export const fetchTransport: RawTransport = async (options) => { + const response = await fetch(options.url, { + method: options.method, + headers: new Headers( + options.requestHeaders.map((header): [string, string] => [header.name, header.value]), + ), + body: options.body, + signal: options.abortSignal ?? null, + }); + + return { + status: response.status, + statusText: response.statusText, + // Names arrive lowercased from fetch, matching how the request headers merge. + responseHeaders: [...response.headers].map(([name, value]) => ({ name, value })), + body: await response.bytes(), + }; +}; + +// An aborted fetch rejects with a DOMException rather than a named subclass, and +// aborting mid-body rejects the same way, so the name is the only thing to match on. +export const isAbortError = (cause: unknown): boolean => + cause instanceof Error && cause.name === "AbortError"; diff --git a/test/helpers/mapiTestAdapter.ts b/test/helpers/mapiTestAdapter.ts deleted file mode 100644 index 1b16289..0000000 --- a/test/helpers/mapiTestAdapter.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { AdapterRequestOptions, Header, HttpAdapter, JsonValue } from "@kontent-ai/core-sdk"; - -export type MapiReply = Readonly<{ - status?: number; - statusText?: string; - headers?: ReadonlyArray
; - payload?: JsonValue; - throws?: Error; -}>; - -export type MapiRoute = Readonly<{ - method: string; - path: RegExp; - // Consumed in order across calls to the same route; the last one repeats. - replies: ReadonlyArray; -}>; - -export type MapiTestAdapter = Readonly<{ - adapter: HttpAdapter; - requests: ReadonlyArray; -}>; - -// A fake at core-sdk's HttpAdapter seam, so the real client code runs against a -// declarative route table and every request is captured for assertions. -export const mapiTestAdapter = (routes: ReadonlyArray): MapiTestAdapter => { - const requests: AdapterRequestOptions[] = []; - const callCounts = new Map(); - - const adapter: HttpAdapter = { - executeRequest: (options) => { - requests.push(options); - - const route = routes.find( - (candidate) => - candidate.method === options.method && candidate.path.test(options.url.pathname), - ); - if (route === undefined) { - throw new Error(`No mapi stub for ${options.method} ${options.url.pathname}`); - } - - const callCount = callCounts.get(route) ?? 0; - callCounts.set(route, callCount + 1); - const reply = route.replies[Math.min(callCount, route.replies.length - 1)]; - if (reply === undefined) { - throw new Error(`Route ${route.method} ${route.path} has no replies`); - } - - if (reply.throws !== undefined) { - throw reply.throws; - } - - return Promise.resolve({ - payload: reply.payload ?? null, - responseHeaders: reply.headers ?? [], - status: reply.status ?? 200, - statusText: reply.statusText ?? "OK", - url: options.url, - }); - }, - }; - - return { adapter, requests }; -}; diff --git a/test/helpers/mapiTestTransport.ts b/test/helpers/mapiTestTransport.ts new file mode 100644 index 0000000..1111441 --- /dev/null +++ b/test/helpers/mapiTestTransport.ts @@ -0,0 +1,87 @@ +import type { Header, JsonValue } from "@kontent-ai/core-sdk"; +import type { RawTransport, RawTransportRequest } from "../../src/lib/mapi/raw/transport.js"; + +export type MapiReply = Readonly<{ + status?: number; + statusText?: string; + headers?: ReadonlyArray
; + // Convenience for the common case: encoded as JSON, with the matching content type. + payload?: JsonValue; + // The raw alternative, for asserting on bodies a JSON payload cannot express. + body?: string; + throws?: Error; +}>; + +export type MapiRoute = Readonly<{ + method: string; + path: RegExp; + // Consumed in order across calls to the same route; the last one repeats. + replies: ReadonlyArray; +}>; + +export type MapiTestTransport = Readonly<{ + transport: RawTransport; + requests: ReadonlyArray; +}>; + +// A fake at the RawTransport seam, so the real client code runs against a +// declarative route table and every request is captured for assertions. +export const mapiTestTransport = (routes: ReadonlyArray): MapiTestTransport => { + const requests: RawTransportRequest[] = []; + const callCounts = new Map(); + + const transport: RawTransport = (options) => { + requests.push(options); + + const route = routes.find( + (candidate) => + candidate.method === options.method && candidate.path.test(options.url.pathname), + ); + if (route === undefined) { + throw new Error(`No mapi stub for ${options.method} ${options.url.pathname}`); + } + + const callCount = callCounts.get(route) ?? 0; + callCounts.set(route, callCount + 1); + const reply = route.replies[Math.min(callCount, route.replies.length - 1)]; + if (reply === undefined) { + throw new Error(`Route ${route.method} ${route.path} has no replies`); + } + + if (reply.throws !== undefined) { + throw reply.throws; + } + + return Promise.resolve({ + status: reply.status ?? 200, + statusText: reply.statusText ?? "OK", + responseHeaders: replyHeaders(reply), + body: new TextEncoder().encode(replyBody(reply)), + }); + }; + + return { transport, requests }; +}; + +/** Decodes a response body the way the command does, for assertions. */ +export const decodeBody = (body: Uint8Array): string => new TextDecoder().decode(body); + +export const parseJsonBody = (body: Uint8Array): unknown => JSON.parse(decodeBody(body)); + +const replyBody = (reply: MapiReply): string => { + if (reply.body !== undefined) { + return reply.body; + } + return reply.payload === undefined ? "" : JSON.stringify(reply.payload); +}; + +// A JSON payload implies the content type, so routes do not have to repeat it; +// an explicitly supplied header still wins. +const replyHeaders = (reply: MapiReply): ReadonlyArray
=> { + const supplied = reply.headers ?? []; + const hasContentType = supplied.some((header) => header.name.toLowerCase() === "content-type"); + if (reply.payload === undefined || hasContentType) { + return supplied; + } + return [{ name: "content-type", value: "application/json" }, ...supplied]; +}; diff --git a/test/integration/mapi.test.ts b/test/integration/mapi.test.ts index a642e7f..7375d01 100644 --- a/test/integration/mapi.test.ts +++ b/test/integration/mapi.test.ts @@ -3,7 +3,7 @@ import { type MapiRequestParams, performRawMapiRequest } from "../../src/core/ma import { createMapiRawClient } from "../../src/lib/mapi/raw/client.js"; import { createLogger } from "../../src/log.js"; import { assertErr, assertOk } from "../helpers/assertResult.js"; -import { type MapiRoute, mapiTestAdapter } from "../helpers/mapiTestAdapter.js"; +import { type MapiRoute, mapiTestTransport, parseJsonBody } from "../helpers/mapiTestTransport.js"; const ENV_ID = "11111111-2222-3333-4444-555555555555"; const BASE_URL = "https://manage.test/v2"; @@ -25,12 +25,12 @@ type RunOptions = Readonly<{ }>; const run = async (routes: ReadonlyArray, options: RunOptions = {}) => { - const { adapter, requests } = mapiTestAdapter(routes); + const { transport, requests } = mapiTestTransport(routes); const client = createMapiRawClient({ // `token: undefined` means an explicitly tokenless client, distinct from omitting it. token: "token" in options ? options.token : "secret-token", baseUrl: BASE_URL, - adapter, + transport, }); const result = await performRawMapiRequest(makeParams(options.params), { logger, client }); return { result, requests }; @@ -46,10 +46,10 @@ describe("performRawMapiRequest", () => { it("sends an authenticated GET to the environment-scoped endpoint", async () => { const { result, requests } = await run([typesRoute]); - expect(result).toEqual({ - kind: "ok", - value: { statusCode: 200, statusText: "OK", headers: [], payload: { types: [] } }, - }); + assertOk(result); + expect(result.value.statusCode).toBe(200); + expect(result.value.statusText).toBe("OK"); + expect(parseJsonBody(result.value.body)).toEqual({ types: [] }); expect(requests).toHaveLength(1); expect(requests[0]?.url.toString()).toBe(`${BASE_URL}/projects/${ENV_ID}/types`); expect(requests[0]?.requestHeaders).toContainEqual({ @@ -118,7 +118,7 @@ describe("performRawMapiRequest", () => { assertOk(result); expect(result.value.statusCode).toBe(404); - expect(result.value.payload).toEqual({ + expect(parseJsonBody(result.value.body)).toEqual({ message: "The requested content type was not found.", }); }); diff --git a/test/integration/mapiCommand.test.ts b/test/integration/mapiCommand.test.ts index 51135d3..3729895 100644 --- a/test/integration/mapiCommand.test.ts +++ b/test/integration/mapiCommand.test.ts @@ -8,7 +8,7 @@ import { noopTelemetry } from "../../src/lib/telemetry/tracking.js"; vi.mock("../../src/core/mapi/request.js", () => ({ performRawMapiRequest: vi.fn(async () => - ok({ statusCode: 200, statusText: "OK", headers: [], payload: null }), + ok({ statusCode: 200, statusText: "OK", headers: [], body: new Uint8Array() }), ), })); @@ -38,15 +38,19 @@ const runCommand = async (argv: ReadonlyArray): Promise { +const captureStream = (stream: "stdout" | "stderr") => { const chunks: string[] = []; - const spy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { - chunks.push(String(chunk)); + const spy = vi.spyOn(process[stream], "write").mockImplementation((chunk) => { + chunks.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); return true; }); return { text: () => chunks.join(""), restore: () => spy.mockRestore() }; }; +const captureStderr = () => captureStream("stderr"); + +const encode = (text: string): Uint8Array => new TextEncoder().encode(text); + const lastParams = (): MapiRequestParams => vi.mocked(performRawMapiRequest).mock.calls.at(-1)?.[0] as MapiRequestParams; @@ -80,34 +84,75 @@ describe("kontent mapi argument handling", () => { ]); }); - it("notes a non-JSON body on a success, where stdout would otherwise be empty", async () => { + it("prints a non-JSON body verbatim rather than dropping it", async () => { vi.mocked(performRawMapiRequest).mockResolvedValueOnce( ok({ statusCode: 200, statusText: "OK", headers: [{ name: "Content-Type", value: "text/csv; charset=utf-8" }], - payload: null, + body: encode("a,b\n1,2\n"), }), ); - const captured = captureStderr(); + const stdout = captureStream("stdout"); + const stderr = captureStderr(); await runCommand(["export", "--envId", ENV_ID]); - captured.restore(); + stdout.restore(); + stderr.restore(); - expect(captured.text()).toContain("The response body is text/csv and is not shown."); + expect(stdout.text()).toBe("a,b\n1,2\n"); + expect(stderr.text()).toBe(""); expect(process.exitCode).toBeUndefined(); }); + it("re-indents a JSON body", async () => { + vi.mocked(performRawMapiRequest).mockResolvedValueOnce( + ok({ + statusCode: 200, + statusText: "OK", + headers: [{ name: "content-type", value: "application/json" }], + body: encode('{"name":"Article"}'), + }), + ); + const stdout = captureStream("stdout"); + + await runCommand(["types", "--envId", ENV_ID]); + stdout.restore(); + + expect(stdout.text()).toBe('{\n "name": "Article"\n}\n'); + }); + + // A body that claims JSON but does not parse is a clue, so it survives unchanged. + it("prints a malformed JSON body as it arrived", async () => { + vi.mocked(performRawMapiRequest).mockResolvedValueOnce( + ok({ + statusCode: 200, + statusText: "OK", + headers: [{ name: "content-type", value: "application/json" }], + body: encode("{not json"), + }), + ); + const stdout = captureStream("stdout"); + + await runCommand(["types", "--envId", ENV_ID]); + stdout.restore(); + + expect(stdout.text()).toBe("{not json"); + }); + it("stays quiet when a success simply has no body", async () => { vi.mocked(performRawMapiRequest).mockResolvedValueOnce( - ok({ statusCode: 204, statusText: "No Content", headers: [], payload: null }), + ok({ statusCode: 204, statusText: "No Content", headers: [], body: encode("") }), ); - const captured = captureStderr(); + const stdout = captureStream("stdout"); + const stderr = captureStderr(); await runCommand(["items/x", "-X", "DELETE", "--envId", ENV_ID]); - captured.restore(); + stdout.restore(); + stderr.restore(); - expect(captured.text()).toBe(""); + expect(stdout.text()).toBe(""); + expect(stderr.text()).toBe(""); }); it("rejects a body on GET instead of letting the transport throw", async () => { diff --git a/test/unit/resolveCredential.test.ts b/test/unit/mapiCredential.test.ts similarity index 73% rename from test/unit/resolveCredential.test.ts rename to test/unit/mapiCredential.test.ts index ae911ae..1b8782b 100644 --- a/test/unit/resolveCredential.test.ts +++ b/test/unit/mapiCredential.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { resolveCredential } from "../../src/commands/mapi/request.js"; +import { resolveMapiCredential } from "../../src/lib/auth/mapiCredential.js"; import { ok } from "../../src/lib/result.js"; import { assertOk } from "../helpers/assertResult.js"; @@ -9,9 +9,9 @@ vi.mock("../../src/lib/auth/tokenAccess.js", () => ({ const authorization = [{ name: "Authorization", value: "Bearer supplied" }]; -describe("resolveCredential", () => { +describe("resolveMapiCredential", () => { it("prefers an Authorization header and adds no token of its own", async () => { - const result = await resolveCredential(authorization, "flag-key", { + const result = await resolveMapiCredential(authorization, "flag-key", { KONTENT_MAPI_KEY: "env-key", }); @@ -20,7 +20,7 @@ describe("resolveCredential", () => { }); it("matches the Authorization header case-insensitively", async () => { - const result = await resolveCredential( + const result = await resolveMapiCredential( [{ name: "authorization", value: "Bearer x" }], undefined, {}, @@ -31,21 +31,21 @@ describe("resolveCredential", () => { }); it("prefers --mapiKey over the environment variable", async () => { - const result = await resolveCredential([], "flag-key", { KONTENT_MAPI_KEY: "env-key" }); + const result = await resolveMapiCredential([], "flag-key", { KONTENT_MAPI_KEY: "env-key" }); assertOk(result); expect(result.value).toEqual({ token: "flag-key", source: "mapi-key" }); }); it("falls back to KONTENT_MAPI_KEY when --mapiKey is absent", async () => { - const result = await resolveCredential([], undefined, { KONTENT_MAPI_KEY: "env-key" }); + const result = await resolveMapiCredential([], undefined, { KONTENT_MAPI_KEY: "env-key" }); assertOk(result); expect(result.value).toEqual({ token: "env-key", source: "mapi-key" }); }); it("falls back to the stored login token when nothing is supplied", async () => { - const result = await resolveCredential([], undefined, {}); + const result = await resolveMapiCredential([], undefined, {}); assertOk(result); expect(result.value).toEqual({ token: "stored-login-token", source: "login" }); @@ -53,7 +53,7 @@ describe("resolveCredential", () => { // An exported-but-empty variable is how a CI runner spells "unset". it("treats an empty KONTENT_MAPI_KEY as unset", async () => { - const result = await resolveCredential([], undefined, { KONTENT_MAPI_KEY: "" }); + const result = await resolveMapiCredential([], undefined, { KONTENT_MAPI_KEY: "" }); assertOk(result); expect(result.value).toEqual({ token: "stored-login-token", source: "login" }); From 45f746f13cea4e0402b6e2b78272b959f7cc22a8 Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Thu, 20 Aug 2026 14:46:21 +0200 Subject: [PATCH 13/25] fix: read the response body with arrayBuffer, not Response.bytes `Response.bytes()` landed in Node 22.3, but package.json allows >=22, so the raw transport threw on 22.0-22.2 for every request. `arrayBuffer()` has been there since fetch itself and needs one Uint8Array wrap. The rest follows the switch from core-sdk's HttpAdapter to the RawTransport seam. `Credential` becomes `MapiCredential` now that it sits at a lib boundary rather than inside the command, CLAUDE.md stops calling the passthrough adapter-backed, and `error.url` is stringified explicitly because Node's URL declares no toString of its own the way the DOM interface does - which needs "lib": ["ESNext"] in tsconfig to resolve consistently. Tests cover the transport directly for the first time: what it hands fetch, how it lowercases response headers, and that a body with no valid UTF-8 survives byte for byte. The command tests gain the same byte-fidelity check plus a case-insensitive media type, since RFC 9110 media types are case-insensitive and the API sends a charset parameter. The test helper loses the raw `body` escape hatch again - no route needed it. --- CLAUDE.md | 2 +- src/commands/mapi/request.ts | 7 +- src/lib/auth/mapiCredential.ts | 4 +- src/lib/iapi/formatIapiError.ts | 3 +- src/lib/mapi/raw/transport.ts | 3 +- test/helpers/mapiTestTransport.ts | 22 ++--- test/integration/mapiCommand.test.ts | 59 ++++++++++-- test/unit/transport.test.ts | 139 +++++++++++++++++++++++++++ tsconfig.json | 1 + 9 files changed, 209 insertions(+), 31 deletions(-) create mode 100644 test/unit/transport.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index b1c2141..41b1357 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ Adding a command: export a `register: RegisterCommand` (see `src/commands/login/ ### API clients - `iapi` (`src/lib/iapi`) — internal Kontent.ai API; hand-rolled client, one file per endpoint, over `@kontent-ai/core-sdk`. Endpoint validators (the `schema` field) must be **`zod/mini`** (`import * as z from "zod/mini"`) — classic `zod` won't infer the payload. -- `mapi` (`src/lib/mapi`) — public Management API via `@kontent-ai/management-sdk`. `src/lib/mapi/raw` is the deliberate opposite: an adapter-backed passthrough (no schema, no response interpretation) behind `kontent mapi`, where a 4xx/5xx is a result, not an error. +- `mapi` (`src/lib/mapi`) — public Management API via `@kontent-ai/management-sdk`. `src/lib/mapi/raw` is the deliberate opposite: a transport-backed passthrough (no schema, no response interpretation) behind `kontent mapi`, where a 4xx/5xx is a result, not an error. - `@kontent-ai/core-sdk` — shared HTTP/SDK layer both clients build on. **Commands build clients; core receives them.** The command builds the `iapiClient`/`mapiClient` and passes them into core (e.g. `performBootstrap(params, { logger, iapiClient, mapiClient })`); core never constructs clients itself. Auth failure is handled in the command, not surfaced as a core `Result` error. diff --git a/src/commands/mapi/request.ts b/src/commands/mapi/request.ts index ef2501a..bc6a726 100644 --- a/src/commands/mapi/request.ts +++ b/src/commands/mapi/request.ts @@ -302,19 +302,20 @@ const formatBody = (response: MapiResponse): string | Uint8Array => { const text = new TextDecoder().decode(response.body); const indented = fromThrowable( - () => JSON.stringify(JSON.parse(text) as unknown, null, 2), + () => `${JSON.stringify(JSON.parse(text) as unknown, null, 2)}\n`, () => text, ); // A body that claims to be JSON but does not parse is still shown, unchanged: // malformed output is a clue, and swallowing it would hide the real answer. - return isOk(indented) ? `${indented.value}\n` : text; + return isOk(indented) ? indented.value : indented.error; }; const contentType = (response: MapiResponse): string | undefined => response.headers .find((header) => header.name.toLowerCase() === "content-type") ?.value.split(";")[0] - ?.trim(); + ?.trim() + .toLowerCase(); const formatFailure = (response: MapiResponse, source: AuthSource): string => { const summary = `HTTP ${response.statusCode} ${response.statusText}`; diff --git a/src/lib/auth/mapiCredential.ts b/src/lib/auth/mapiCredential.ts index 1dc01b9..5b99460 100644 --- a/src/lib/auth/mapiCredential.ts +++ b/src/lib/auth/mapiCredential.ts @@ -6,7 +6,7 @@ import type { AuthError } from "./types.js"; /** Which of the three credentials a request ended up authenticating with. */ export type AuthSource = "login" | "mapi-key" | "header"; -export type Credential = Readonly<{ token?: string | undefined; source: AuthSource }>; +export type MapiCredential = Readonly<{ token?: string | undefined; source: AuthSource }>; /** * Each source suppresses the ones below it, so a supplied credential never triggers @@ -20,7 +20,7 @@ export const resolveMapiCredential = async ( headers: ReadonlyArray
, mapiKey: string | undefined, env: NodeJS.ProcessEnv = process.env, -): Promise> => { +): Promise> => { if (headers.some((header) => header.name.toLowerCase() === "authorization")) { return ok({ source: "header" }); } diff --git a/src/lib/iapi/formatIapiError.ts b/src/lib/iapi/formatIapiError.ts index e259067..8c6c66b 100644 --- a/src/lib/iapi/formatIapiError.ts +++ b/src/lib/iapi/formatIapiError.ts @@ -28,7 +28,8 @@ const formatGenericIapiError = (error: KontentSdkError, context: IapiErrorContex "status" in details ? `status: ${details.status} ${details.statusText}` : undefined, apiResponse?.message ? `message: ${apiResponse.message}` : undefined, apiResponse?.request_id ? `request-id: ${apiResponse.request_id}` : undefined, - `url: ${error.url}`, + // String(): Node's URL declares no toString of its own, unlike the DOM interface. + `url: ${String(error.url)}`, context.isVerbose ? `details: ${inspect(details, { depth: 5, colors: false, breakLength: 100 })}` : undefined, diff --git a/src/lib/mapi/raw/transport.ts b/src/lib/mapi/raw/transport.ts index 0feb5b0..febde8d 100644 --- a/src/lib/mapi/raw/transport.ts +++ b/src/lib/mapi/raw/transport.ts @@ -39,7 +39,8 @@ export const fetchTransport: RawTransport = async (options) => { statusText: response.statusText, // Names arrive lowercased from fetch, matching how the request headers merge. responseHeaders: [...response.headers].map(([name, value]) => ({ name, value })), - body: await response.bytes(), + // Not response.bytes(): that arrived in Node 22.3, past the >=22 floor in package.json. + body: new Uint8Array(await response.arrayBuffer()), }; }; diff --git a/test/helpers/mapiTestTransport.ts b/test/helpers/mapiTestTransport.ts index 1111441..e6f3a17 100644 --- a/test/helpers/mapiTestTransport.ts +++ b/test/helpers/mapiTestTransport.ts @@ -5,10 +5,8 @@ export type MapiReply = Readonly<{ status?: number; statusText?: string; headers?: ReadonlyArray
; - // Convenience for the common case: encoded as JSON, with the matching content type. + // Encoded as JSON, with the matching content type. payload?: JsonValue; - // The raw alternative, for asserting on bodies a JSON payload cannot express. - body?: string; throws?: Error; }>; @@ -56,24 +54,18 @@ export const mapiTestTransport = (routes: ReadonlyArray): MapiTestTra status: reply.status ?? 200, statusText: reply.statusText ?? "OK", responseHeaders: replyHeaders(reply), - body: new TextEncoder().encode(replyBody(reply)), + body: new TextEncoder().encode( + reply.payload === undefined ? "" : JSON.stringify(reply.payload), + ), }); }; return { transport, requests }; }; -/** Decodes a response body the way the command does, for assertions. */ -export const decodeBody = (body: Uint8Array): string => new TextDecoder().decode(body); - -export const parseJsonBody = (body: Uint8Array): unknown => JSON.parse(decodeBody(body)); - -const replyBody = (reply: MapiReply): string => { - if (reply.body !== undefined) { - return reply.body; - } - return reply.payload === undefined ? "" : JSON.stringify(reply.payload); -}; +/** Reads a response body the way the command does, for assertions. */ +export const parseJsonBody = (body: Uint8Array): unknown => + JSON.parse(new TextDecoder().decode(body)); // A JSON payload implies the content type, so routes do not have to repeat it; // an explicitly supplied header still wins. diff --git a/test/integration/mapiCommand.test.ts b/test/integration/mapiCommand.test.ts index 3729895..749e221 100644 --- a/test/integration/mapiCommand.test.ts +++ b/test/integration/mapiCommand.test.ts @@ -38,17 +38,21 @@ const runCommand = async (argv: ReadonlyArray): Promise { - const chunks: string[] = []; + const chunks: Uint8Array[] = []; const spy = vi.spyOn(process[stream], "write").mockImplementation((chunk) => { - chunks.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + chunks.push(typeof chunk === "string" ? encode(chunk) : chunk); return true; }); - return { text: () => chunks.join(""), restore: () => spy.mockRestore() }; + return { + text: () => new TextDecoder().decode(Buffer.concat(chunks)), + bytes: () => Buffer.concat(chunks), + restore: () => spy.mockRestore(), + }; }; -const captureStderr = () => captureStream("stderr"); - const encode = (text: string): Uint8Array => new TextEncoder().encode(text); const lastParams = (): MapiRequestParams => @@ -94,7 +98,7 @@ describe("kontent mapi argument handling", () => { }), ); const stdout = captureStream("stdout"); - const stderr = captureStderr(); + const stderr = captureStream("stderr"); await runCommand(["export", "--envId", ENV_ID]); stdout.restore(); @@ -140,12 +144,51 @@ describe("kontent mapi argument handling", () => { expect(stdout.text()).toBe("{not json"); }); + // Media types are case-insensitive (RFC 9110), and the API sends a charset + // parameter, so neither may decide whether the body is treated as JSON. + it("re-indents a JSON body whose media type is not lowercase", async () => { + vi.mocked(performRawMapiRequest).mockResolvedValueOnce( + ok({ + statusCode: 200, + statusText: "OK", + headers: [{ name: "Content-Type", value: "Application/JSON; charset=utf-8" }], + body: encode('{"name":"Article"}'), + }), + ); + const stdout = captureStream("stdout"); + + await runCommand(["types", "--envId", ENV_ID]); + stdout.restore(); + + expect(stdout.text()).toBe('{\n "name": "Article"\n}\n'); + }); + + // The reason the body is carried as bytes: a string round-trip would replace + // every byte that is not valid UTF-8 with U+FFFD and corrupt the download. + it("passes a binary body through byte for byte", async () => { + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe, 0x00]); + vi.mocked(performRawMapiRequest).mockResolvedValueOnce( + ok({ + statusCode: 200, + statusText: "OK", + headers: [{ name: "content-type", value: "image/png" }], + body: png, + }), + ); + const stdout = captureStream("stdout"); + + await runCommand(["assets/x", "--envId", ENV_ID]); + stdout.restore(); + + expect(Buffer.compare(stdout.bytes(), Buffer.from(png))).toBe(0); + }); + it("stays quiet when a success simply has no body", async () => { vi.mocked(performRawMapiRequest).mockResolvedValueOnce( ok({ statusCode: 204, statusText: "No Content", headers: [], body: encode("") }), ); const stdout = captureStream("stdout"); - const stderr = captureStderr(); + const stderr = captureStream("stderr"); await runCommand(["items/x", "-X", "DELETE", "--envId", ENV_ID]); stdout.restore(); @@ -156,7 +199,7 @@ describe("kontent mapi argument handling", () => { }); it("rejects a body on GET instead of letting the transport throw", async () => { - const captured = captureStderr(); + const captured = captureStream("stderr"); await runCommand(["types", "-X", "GET", "--input", "body.json", "--envId", ENV_ID]); captured.restore(); diff --git a/test/unit/transport.test.ts b/test/unit/transport.test.ts new file mode 100644 index 0000000..3134f1f --- /dev/null +++ b/test/unit/transport.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fetchTransport, isAbortError } from "../../src/lib/mapi/raw/transport.js"; + +const URL_UNDER_TEST = new URL("https://manage.test/v2/projects/x/types"); + +type FetchInit = Readonly<{ + method?: string; + headers?: Headers; + body?: unknown; + signal?: AbortSignal | null; +}>; + +// The transport is the one place that touches global fetch, so the seam under test +// is fetch itself. A real Response goes back, to exercise the header and body reads. +const stubFetch = (response: Response) => { + const spy = vi.fn(async (_url: URL, _init: FetchInit) => response); + vi.stubGlobal("fetch", spy); + return spy; +}; + +const lastInit = (spy: ReturnType): FetchInit => spy.mock.calls.at(-1)?.[1] ?? {}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("fetchTransport", () => { + it("forwards the method, headers, body and abort signal to fetch", async () => { + const spy = stubFetch(new Response(null, { status: 200 })); + const controller = new AbortController(); + const body = new Blob(['{"codename":"x"}']); + + await fetchTransport({ + url: URL_UNDER_TEST, + method: "POST", + body, + requestHeaders: [ + { name: "authorization", value: "Bearer token" }, + { name: "x-foo", value: "1" }, + ], + abortSignal: controller.signal, + }); + + expect(spy.mock.calls.at(-1)?.[0]).toBe(URL_UNDER_TEST); + const init = lastInit(spy); + expect(init.method).toBe("POST"); + expect(init.body).toBe(body); + expect(init.signal).toBe(controller.signal); + expect([...(init.headers ?? new Headers())]).toEqual([ + ["authorization", "Bearer token"], + ["x-foo", "1"], + ]); + }); + + it("passes a null signal when the caller supplies none", async () => { + const spy = stubFetch(new Response(null, { status: 200 })); + + await fetchTransport({ + url: URL_UNDER_TEST, + method: "GET", + body: null, + requestHeaders: [], + }); + + expect(lastInit(spy).signal).toBeNull(); + }); + + it("reports the status line and the response headers as a lowercased array", async () => { + stubFetch( + new Response(null, { + status: 404, + statusText: "Not Found", + headers: { "Content-Type": "application/json", "X-Request-Id": "abc" }, + }), + ); + + const response = await fetchTransport({ + url: URL_UNDER_TEST, + method: "GET", + body: null, + requestHeaders: [], + }); + + expect(response.status).toBe(404); + expect(response.statusText).toBe("Not Found"); + expect(response.responseHeaders).toEqual([ + { name: "content-type", value: "application/json" }, + { name: "x-request-id", value: "abc" }, + ]); + }); + + // The whole point of the transport: no parsing, no decoding, no interpretation. + it("returns the body as the bytes that came off the wire", async () => { + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0xff, 0xfe, 0x00]); + stubFetch(new Response(png, { status: 200 })); + + const response = await fetchTransport({ + url: URL_UNDER_TEST, + method: "GET", + body: null, + requestHeaders: [], + }); + + expect(Buffer.compare(response.body, Buffer.from(png))).toBe(0); + }); + + it("returns an empty body for a response that has none", async () => { + stubFetch(new Response(null, { status: 204, statusText: "No Content" })); + + const response = await fetchTransport({ + url: URL_UNDER_TEST, + method: "DELETE", + body: null, + requestHeaders: [], + }); + + expect(response.body).toHaveLength(0); + }); +}); + +describe("isAbortError", () => { + // What an aborted fetch actually rejects with - a DOMException, not a named subclass. + it("recognizes the DOMException an aborted fetch rejects with", () => { + expect(isAbortError(new DOMException("This operation was aborted.", "AbortError"))).toBe(true); + }); + + it("recognizes an AbortError that is a plain Error", () => { + const error = new Error("aborted"); + error.name = "AbortError"; + + expect(isAbortError(error)).toBe(true); + }); + + it("does not claim an unrelated failure was an abort", () => { + expect(isAbortError(new TypeError("fetch failed"))).toBe(false); + expect(isAbortError("AbortError")).toBe(false); + expect(isAbortError(undefined)).toBe(false); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index be8196d..d6ac1b0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "ESNext", + "lib": ["ESNext"], "module": "NodeNext", "moduleResolution": "NodeNext", "esModuleInterop": true, From 2a25ae70b9c0aebcb8c317c948c69407bebe2b3d Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Mon, 24 Aug 2026 12:07:16 +0200 Subject: [PATCH 14/25] fix: read MAPI responses through core-sdk's HttpAdapter again 7dcce04 replaced the adapter with a raw fetch transport so that a CSV or a binary asset would not come back as empty stdout. The Management API returns neither: every endpoint answers application/json, including the edge 401 and 404 that never reach the API itself, and binary only ever travels request-side, on an asset upload. management-sdk asks for bytes in exactly one place, and that request targets an asset's public URL rather than a MAPI path. So the adapter is enough, and the bespoke transport was carrying a cost - a second HTTP path to maintain, with its own abort and header handling - for a case that does not arise. The body is now decided from the headers rather than from the payload: core-sdk yields null for a body that was absent, for one it skipped as non-JSON, and for a literal JSON null alike. The content type decides whether to print, and a non-JSON response with a non-zero content length is reported on stderr instead of vanishing. Not getDefaultHttpService: it maps every non-2xx to an error and keeps the body only when it matches the Kontent error shape, which would lose exactly the 4xx bodies this command exists to show. Claude-Session: https://claude.ai/code/session_01TCaib3a5osMoKG6cctuFvR --- CLAUDE.md | 2 +- src/commands/mapi/request.ts | 52 ++++++---- src/core/mapi/request.ts | 12 ++- src/lib/mapi/raw/client.ts | 42 +++++--- src/lib/mapi/raw/transport.ts | 50 ---------- test/helpers/mapiTestAdapter.ts | 76 +++++++++++++++ test/helpers/mapiTestTransport.ts | 79 --------------- test/integration/mapi.test.ts | 10 +- test/integration/mapiCommand.test.ts | 82 +++++----------- test/unit/transport.test.ts | 139 --------------------------- 10 files changed, 170 insertions(+), 374 deletions(-) delete mode 100644 src/lib/mapi/raw/transport.ts create mode 100644 test/helpers/mapiTestAdapter.ts delete mode 100644 test/helpers/mapiTestTransport.ts delete mode 100644 test/unit/transport.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 41b1357..d936ed6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ Adding a command: export a `register: RegisterCommand` (see `src/commands/login/ ### API clients - `iapi` (`src/lib/iapi`) — internal Kontent.ai API; hand-rolled client, one file per endpoint, over `@kontent-ai/core-sdk`. Endpoint validators (the `schema` field) must be **`zod/mini`** (`import * as z from "zod/mini"`) — classic `zod` won't infer the payload. -- `mapi` (`src/lib/mapi`) — public Management API via `@kontent-ai/management-sdk`. `src/lib/mapi/raw` is the deliberate opposite: a transport-backed passthrough (no schema, no response interpretation) behind `kontent mapi`, where a 4xx/5xx is a result, not an error. +- `mapi` (`src/lib/mapi`) — public Management API via `@kontent-ai/management-sdk`. `src/lib/mapi/raw` is the deliberate opposite: an adapter-backed passthrough (no schema, no response interpretation) behind `kontent mapi`, where a 4xx/5xx is a result, not an error. core-sdk's `HttpAdapter` suffices because MAPI answers `application/json` on every status and binary only ever travels request-side, on an asset upload; a body of any other type is dropped by the adapter and reported on stderr. Not `getDefaultHttpService` — it maps every non-2xx to an error and keeps the body only when it matches the Kontent error shape, losing exactly the 4xx bodies this command exists to show. - `@kontent-ai/core-sdk` — shared HTTP/SDK layer both clients build on. **Commands build clients; core receives them.** The command builds the `iapiClient`/`mapiClient` and passes them into core (e.g. `performBootstrap(params, { logger, iapiClient, mapiClient })`); core never constructs clients itself. Auth failure is handled in the command, not surfaced as a core `Result` error. diff --git a/src/commands/mapi/request.ts b/src/commands/mapi/request.ts index bc6a726..126fefd 100644 --- a/src/commands/mapi/request.ts +++ b/src/commands/mapi/request.ts @@ -10,7 +10,7 @@ import { formatAuthError } from "../../lib/auth/formatAuthError.js"; import { type AuthSource, resolveMapiCredential } from "../../lib/auth/mapiCredential.js"; import { createMapiRawClient } from "../../lib/mapi/raw/client.js"; import { parseHeaders } from "../../lib/mapi/raw/headers.js"; -import { err, fromThrowable, isErr, isOk, ok, type Result, tryAsync } from "../../lib/result.js"; +import { err, isErr, ok, type Result, tryAsync } from "../../lib/result.js"; import type { Telemetry } from "../../lib/telemetry/tracking.js"; import { createLoggerFromArgs, type Logger, type LogOptions } from "../../log.js"; import type { RegisterCommand } from "../../types/yargs.js"; @@ -141,7 +141,7 @@ const runRequest = async ( return; } - writeResponse(result.value, args.include === true); + writeResponse(result.value, args.include === true, logger); if (result.value.statusCode >= 400) { tracker.fail(`http-${result.value.statusCode}`, { @@ -277,7 +277,16 @@ const readStdin = async (): Promise> => { return Uint8Array.from(Buffer.concat(chunks)); }; -const writeResponse = (response: MapiResponse, shouldIncludeHeaders: boolean): void => { +/** + * A body of a type core-sdk skipped never reached this point, so a non-zero + * content length is the only trace left that there was one - report it on stderr + * rather than leave stdout silently empty. + */ +const writeResponse = ( + response: MapiResponse, + shouldIncludeHeaders: boolean, + logger: Logger, +): void => { if (shouldIncludeHeaders) { const headerLines = response.headers.map((header) => `${header.name}: ${header.value}`); process.stdout.write( @@ -285,29 +294,30 @@ const writeResponse = (response: MapiResponse, shouldIncludeHeaders: boolean): v ); } - if (response.body.length > 0) { - process.stdout.write(formatBody(response)); + if (contentType(response) === "application/json") { + process.stdout.write(`${JSON.stringify(response.body, null, 2)}\n`); + return; + } + + const droppedBytes = contentLength(response); + if (droppedBytes > 0) { + logger.warning( + "standard", + `The response carried ${droppedBytes} bytes of ${contentType(response) ?? "an unknown type"}, which is not JSON and was not shown.`, + ); } }; -/** - * JSON is re-indented, because a response body is the thing the user reads. Every - * other type goes out byte for byte - a passthrough that reformatted a PNG or a - * CSV would be lying about what the API returned. - */ -const formatBody = (response: MapiResponse): string | Uint8Array => { - if (contentType(response) !== "application/json") { - return response.body; +const contentLength = (response: MapiResponse): number => { + const raw = response.headers.find( + (header) => header.name.toLowerCase() === "content-length", + )?.value; + if (raw === undefined) { + return 0; } - const text = new TextDecoder().decode(response.body); - const indented = fromThrowable( - () => `${JSON.stringify(JSON.parse(text) as unknown, null, 2)}\n`, - () => text, - ); - // A body that claims to be JSON but does not parse is still shown, unchanged: - // malformed output is a clue, and swallowing it would hide the real answer. - return isOk(indented) ? indented.value : indented.error; + const parsed = Number(raw); + return Number.isNaN(parsed) ? 0 : parsed; }; const contentType = (response: MapiResponse): string | undefined => diff --git a/src/core/mapi/request.ts b/src/core/mapi/request.ts index 8df1873..e45ab67 100644 --- a/src/core/mapi/request.ts +++ b/src/core/mapi/request.ts @@ -1,4 +1,4 @@ -import type { Header, HttpMethod } from "@kontent-ai/core-sdk"; +import type { Header, HttpMethod, JsonValue } from "@kontent-ai/core-sdk"; import { executeRawRequest, type MapiRawClient } from "../../lib/mapi/raw/client.js"; import { resolveEndpoint } from "../../lib/mapi/raw/endpoint.js"; import { err, isErr, ok, type Result } from "../../lib/result.js"; @@ -17,9 +17,11 @@ export type MapiResponse = Readonly<{ statusCode: number; statusText: string; headers: ReadonlyArray
; - // The bytes exactly as they came off the wire. Nothing here decides what they - // mean; the command formats them once, on its way to stdout. - body: Uint8Array; + // Null for a body that was absent, for one core-sdk skipped as non-JSON, and for + // a literal JSON null alike, so it cannot say whether the response carried + // anything. The command reads the headers instead: the content type decides + // whether to print, the content length whether something was dropped. + body: JsonValue; }>; /** @@ -67,6 +69,6 @@ export const performRawMapiRequest = async ( statusCode: response.value.status, statusText: response.value.statusText, headers: response.value.responseHeaders, - body: response.value.body, + body: response.value.payload, }); }; diff --git a/src/lib/mapi/raw/client.ts b/src/lib/mapi/raw/client.ts index 21fecae..2575ec3 100644 --- a/src/lib/mapi/raw/client.ts +++ b/src/lib/mapi/raw/client.ts @@ -1,8 +1,14 @@ import { setTimeout as sleep } from "node:timers/promises"; import { + AdapterAbortError, + AdapterParseError, + type AdapterResponse, createSdkIdHeader, + getDefaultHttpAdapter, type Header, + type HttpAdapter, type HttpMethod, + type JsonValue, type SdkInfo, } from "@kontent-ai/core-sdk"; @@ -10,13 +16,7 @@ import { import pkg from "../../../../package.json" with { type: "json" }; import type { Logger } from "../../../log.js"; import { kontentManagementUrl } from "../../config/kontentUrl.js"; -import { isErr, type Result, tryAsync } from "../../result.js"; -import { - fetchTransport, - isAbortError, - type RawTransport, - type RawTransportResponse, -} from "./transport.js"; +import { err, isErr, type Result, tryAsync } from "../../result.js"; const MAX_RETRY_ATTEMPTS = 3; const DEFAULT_RETRY_DELAY_MS = 1000; @@ -32,14 +32,18 @@ const mapiSdkInfo: SdkInfo = { }; /** - * A passthrough transport for the Management API: no schema, no response + * A passthrough client for the Management API: no schema, no response * interpretation. The typed, validated counterpart is `src/lib/mapi/client.ts`. + * + * core-sdk's adapter parses `application/json` and hands back a null payload for + * anything else, which is all this needs: the Management API answers JSON on + * every status, and binary only ever travels request-side, on an asset upload. */ export type MapiRawClient = Readonly<{ baseUrl: string; // Absent when the caller carries its own Authorization header; the client then adds none. token?: string | undefined; - transport: RawTransport; + adapter: HttpAdapter; sdkInfo: SdkInfo; }>; @@ -52,11 +56,11 @@ export type RawRequest = Readonly<{ }>; export const createMapiRawClient = ( - params: Readonly<{ token?: string | undefined; baseUrl?: string; transport?: RawTransport }>, + params: Readonly<{ token?: string | undefined; baseUrl?: string; adapter?: HttpAdapter }>, ): MapiRawClient => ({ baseUrl: params.baseUrl ?? kontentManagementUrl(), token: params.token, - transport: params.transport ?? fetchTransport, + adapter: params.adapter ?? getDefaultHttpAdapter(), sdkInfo: mapiSdkInfo, }); @@ -70,7 +74,12 @@ export const executeRawRequest = async ( client: MapiRawClient, request: RawRequest, logger: Logger, -): Promise> => { +): Promise, string>> => { + const executeRequest = client.adapter.executeRequest; + if (executeRequest === undefined) { + return err("The configured HTTP adapter cannot execute requests."); + } + const requestHeaders = mergeHeaders( client.token === undefined ? [createSdkIdHeader(client.sdkInfo)] @@ -82,10 +91,10 @@ export const executeRawRequest = async ( ); logger.info("verbose", formatTrace(request, requestHeaders)); - const send = async (attempt: number): Promise> => { + const send = async (attempt: number): Promise, string>> => { const response = await tryAsync( async () => - client.transport({ + executeRequest({ url: request.url, method: request.method, body: request.body, @@ -191,9 +200,12 @@ const formatTrace = (request: RawRequest, headers: ReadonlyArray
): strin }; const describeTransportError = (cause: unknown): string => { - if (isAbortError(cause)) { + if (cause instanceof AdapterAbortError) { return "The request was aborted."; } + if (cause instanceof AdapterParseError) { + return "The response could not be parsed as JSON."; + } if (cause instanceof Error) { return cause.message; } diff --git a/src/lib/mapi/raw/transport.ts b/src/lib/mapi/raw/transport.ts deleted file mode 100644 index febde8d..0000000 --- a/src/lib/mapi/raw/transport.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Header, HttpMethod } from "@kontent-ai/core-sdk"; - -/** - * What `kontent mapi` sends through, deliberately narrower than core-sdk's - * `HttpAdapter`: that one parses `application/json` and drops every other body on - * the floor, which is an interpretation a passthrough command must not make. The - * body arrives as the bytes that came off the wire and nothing decides what they - * mean until the command prints them. - */ -export type RawTransport = (options: RawTransportRequest) => Promise; - -export type RawTransportRequest = Readonly<{ - url: URL; - method: HttpMethod; - body: string | Blob | null; - requestHeaders: ReadonlyArray
; - abortSignal?: AbortSignal | undefined; -}>; - -export type RawTransportResponse = Readonly<{ - status: number; - statusText: string; - responseHeaders: ReadonlyArray
; - body: Uint8Array; -}>; - -export const fetchTransport: RawTransport = async (options) => { - const response = await fetch(options.url, { - method: options.method, - headers: new Headers( - options.requestHeaders.map((header): [string, string] => [header.name, header.value]), - ), - body: options.body, - signal: options.abortSignal ?? null, - }); - - return { - status: response.status, - statusText: response.statusText, - // Names arrive lowercased from fetch, matching how the request headers merge. - responseHeaders: [...response.headers].map(([name, value]) => ({ name, value })), - // Not response.bytes(): that arrived in Node 22.3, past the >=22 floor in package.json. - body: new Uint8Array(await response.arrayBuffer()), - }; -}; - -// An aborted fetch rejects with a DOMException rather than a named subclass, and -// aborting mid-body rejects the same way, so the name is the only thing to match on. -export const isAbortError = (cause: unknown): boolean => - cause instanceof Error && cause.name === "AbortError"; diff --git a/test/helpers/mapiTestAdapter.ts b/test/helpers/mapiTestAdapter.ts new file mode 100644 index 0000000..a005253 --- /dev/null +++ b/test/helpers/mapiTestAdapter.ts @@ -0,0 +1,76 @@ +import type { AdapterRequestOptions, Header, HttpAdapter, JsonValue } from "@kontent-ai/core-sdk"; + +export type MapiReply = Readonly<{ + status?: number; + statusText?: string; + headers?: ReadonlyArray
; + // Implies the JSON content type unless the route sets one of its own. + payload?: JsonValue; + throws?: Error; +}>; + +export type MapiRoute = Readonly<{ + method: string; + path: RegExp; + // Consumed in order across calls to the same route; the last one repeats. + replies: ReadonlyArray; +}>; + +export type MapiTestAdapter = Readonly<{ + adapter: HttpAdapter; + requests: ReadonlyArray; +}>; + +// A fake at core-sdk's HttpAdapter seam, so the real client code runs against a +// declarative route table and every request is captured for assertions. +export const mapiTestAdapter = (routes: ReadonlyArray): MapiTestAdapter => { + const requests: AdapterRequestOptions[] = []; + const callCounts = new Map(); + + const adapter: HttpAdapter = { + executeRequest: (options) => { + requests.push(options); + + const route = routes.find( + (candidate) => + candidate.method === options.method && candidate.path.test(options.url.pathname), + ); + if (route === undefined) { + throw new Error(`No mapi stub for ${options.method} ${options.url.pathname}`); + } + + const callCount = callCounts.get(route) ?? 0; + callCounts.set(route, callCount + 1); + const reply = route.replies[Math.min(callCount, route.replies.length - 1)]; + if (reply === undefined) { + throw new Error(`Route ${route.method} ${route.path} has no replies`); + } + + if (reply.throws !== undefined) { + throw reply.throws; + } + + return Promise.resolve({ + payload: reply.payload ?? null, + responseHeaders: replyHeaders(reply), + status: reply.status ?? 200, + statusText: reply.statusText ?? "OK", + url: options.url, + }); + }, + }; + + return { adapter, requests }; +}; + +// A JSON payload implies the content type, so routes do not have to repeat it; +// an explicitly supplied header still wins. +const replyHeaders = (reply: MapiReply): ReadonlyArray
=> { + const supplied = reply.headers ?? []; + const hasContentType = supplied.some((header) => header.name.toLowerCase() === "content-type"); + if (reply.payload === undefined || hasContentType) { + return supplied; + } + + return [{ name: "content-type", value: "application/json" }, ...supplied]; +}; diff --git a/test/helpers/mapiTestTransport.ts b/test/helpers/mapiTestTransport.ts deleted file mode 100644 index e6f3a17..0000000 --- a/test/helpers/mapiTestTransport.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { Header, JsonValue } from "@kontent-ai/core-sdk"; -import type { RawTransport, RawTransportRequest } from "../../src/lib/mapi/raw/transport.js"; - -export type MapiReply = Readonly<{ - status?: number; - statusText?: string; - headers?: ReadonlyArray
; - // Encoded as JSON, with the matching content type. - payload?: JsonValue; - throws?: Error; -}>; - -export type MapiRoute = Readonly<{ - method: string; - path: RegExp; - // Consumed in order across calls to the same route; the last one repeats. - replies: ReadonlyArray; -}>; - -export type MapiTestTransport = Readonly<{ - transport: RawTransport; - requests: ReadonlyArray; -}>; - -// A fake at the RawTransport seam, so the real client code runs against a -// declarative route table and every request is captured for assertions. -export const mapiTestTransport = (routes: ReadonlyArray): MapiTestTransport => { - const requests: RawTransportRequest[] = []; - const callCounts = new Map(); - - const transport: RawTransport = (options) => { - requests.push(options); - - const route = routes.find( - (candidate) => - candidate.method === options.method && candidate.path.test(options.url.pathname), - ); - if (route === undefined) { - throw new Error(`No mapi stub for ${options.method} ${options.url.pathname}`); - } - - const callCount = callCounts.get(route) ?? 0; - callCounts.set(route, callCount + 1); - const reply = route.replies[Math.min(callCount, route.replies.length - 1)]; - if (reply === undefined) { - throw new Error(`Route ${route.method} ${route.path} has no replies`); - } - - if (reply.throws !== undefined) { - throw reply.throws; - } - - return Promise.resolve({ - status: reply.status ?? 200, - statusText: reply.statusText ?? "OK", - responseHeaders: replyHeaders(reply), - body: new TextEncoder().encode( - reply.payload === undefined ? "" : JSON.stringify(reply.payload), - ), - }); - }; - - return { transport, requests }; -}; - -/** Reads a response body the way the command does, for assertions. */ -export const parseJsonBody = (body: Uint8Array): unknown => - JSON.parse(new TextDecoder().decode(body)); - -// A JSON payload implies the content type, so routes do not have to repeat it; -// an explicitly supplied header still wins. -const replyHeaders = (reply: MapiReply): ReadonlyArray
=> { - const supplied = reply.headers ?? []; - const hasContentType = supplied.some((header) => header.name.toLowerCase() === "content-type"); - if (reply.payload === undefined || hasContentType) { - return supplied; - } - return [{ name: "content-type", value: "application/json" }, ...supplied]; -}; diff --git a/test/integration/mapi.test.ts b/test/integration/mapi.test.ts index 7375d01..63c5768 100644 --- a/test/integration/mapi.test.ts +++ b/test/integration/mapi.test.ts @@ -3,7 +3,7 @@ import { type MapiRequestParams, performRawMapiRequest } from "../../src/core/ma import { createMapiRawClient } from "../../src/lib/mapi/raw/client.js"; import { createLogger } from "../../src/log.js"; import { assertErr, assertOk } from "../helpers/assertResult.js"; -import { type MapiRoute, mapiTestTransport, parseJsonBody } from "../helpers/mapiTestTransport.js"; +import { type MapiRoute, mapiTestAdapter } from "../helpers/mapiTestAdapter.js"; const ENV_ID = "11111111-2222-3333-4444-555555555555"; const BASE_URL = "https://manage.test/v2"; @@ -25,12 +25,12 @@ type RunOptions = Readonly<{ }>; const run = async (routes: ReadonlyArray, options: RunOptions = {}) => { - const { transport, requests } = mapiTestTransport(routes); + const { adapter, requests } = mapiTestAdapter(routes); const client = createMapiRawClient({ // `token: undefined` means an explicitly tokenless client, distinct from omitting it. token: "token" in options ? options.token : "secret-token", baseUrl: BASE_URL, - transport, + adapter, }); const result = await performRawMapiRequest(makeParams(options.params), { logger, client }); return { result, requests }; @@ -49,7 +49,7 @@ describe("performRawMapiRequest", () => { assertOk(result); expect(result.value.statusCode).toBe(200); expect(result.value.statusText).toBe("OK"); - expect(parseJsonBody(result.value.body)).toEqual({ types: [] }); + expect(result.value.body).toEqual({ types: [] }); expect(requests).toHaveLength(1); expect(requests[0]?.url.toString()).toBe(`${BASE_URL}/projects/${ENV_ID}/types`); expect(requests[0]?.requestHeaders).toContainEqual({ @@ -118,7 +118,7 @@ describe("performRawMapiRequest", () => { assertOk(result); expect(result.value.statusCode).toBe(404); - expect(parseJsonBody(result.value.body)).toEqual({ + expect(result.value.body).toEqual({ message: "The requested content type was not found.", }); }); diff --git a/test/integration/mapiCommand.test.ts b/test/integration/mapiCommand.test.ts index 749e221..dd9799b 100644 --- a/test/integration/mapiCommand.test.ts +++ b/test/integration/mapiCommand.test.ts @@ -8,7 +8,7 @@ import { noopTelemetry } from "../../src/lib/telemetry/tracking.js"; vi.mock("../../src/core/mapi/request.js", () => ({ performRawMapiRequest: vi.fn(async () => - ok({ statusCode: 200, statusText: "OK", headers: [], body: new Uint8Array() }), + ok({ statusCode: 200, statusText: "OK", headers: [], body: null }), ), })); @@ -38,23 +38,18 @@ const runCommand = async (argv: ReadonlyArray): Promise { - const chunks: Uint8Array[] = []; + const chunks: string[] = []; const spy = vi.spyOn(process[stream], "write").mockImplementation((chunk) => { - chunks.push(typeof chunk === "string" ? encode(chunk) : chunk); + chunks.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); return true; }); return { - text: () => new TextDecoder().decode(Buffer.concat(chunks)), - bytes: () => Buffer.concat(chunks), + text: () => chunks.join(""), restore: () => spy.mockRestore(), }; }; -const encode = (text: string): Uint8Array => new TextEncoder().encode(text); - const lastParams = (): MapiRequestParams => vi.mocked(performRawMapiRequest).mock.calls.at(-1)?.[0] as MapiRequestParams; @@ -88,25 +83,30 @@ describe("kontent mapi argument handling", () => { ]); }); - it("prints a non-JSON body verbatim rather than dropping it", async () => { + // core-sdk drops a body it does not recognize as JSON, so the only honest thing + // left to do is say on stderr that something was there. + it("reports a non-JSON body on stderr instead of printing nothing at all", async () => { vi.mocked(performRawMapiRequest).mockResolvedValueOnce( ok({ - statusCode: 200, - statusText: "OK", - headers: [{ name: "Content-Type", value: "text/csv; charset=utf-8" }], - body: encode("a,b\n1,2\n"), + statusCode: 502, + statusText: "Bad Gateway", + headers: [ + { name: "Content-Type", value: "text/html; charset=utf-8" }, + { name: "Content-Length", value: "137" }, + ], + body: null, }), ); const stdout = captureStream("stdout"); const stderr = captureStream("stderr"); - await runCommand(["export", "--envId", ENV_ID]); + await runCommand(["types", "--envId", ENV_ID]); stdout.restore(); stderr.restore(); - expect(stdout.text()).toBe("a,b\n1,2\n"); - expect(stderr.text()).toBe(""); - expect(process.exitCode).toBeUndefined(); + expect(stdout.text()).toBe(""); + expect(stderr.text()).toContain("137 bytes of text/html"); + expect(process.exitCode).toBe(1); }); it("re-indents a JSON body", async () => { @@ -115,7 +115,7 @@ describe("kontent mapi argument handling", () => { statusCode: 200, statusText: "OK", headers: [{ name: "content-type", value: "application/json" }], - body: encode('{"name":"Article"}'), + body: { name: "Article" }, }), ); const stdout = captureStream("stdout"); @@ -126,24 +126,6 @@ describe("kontent mapi argument handling", () => { expect(stdout.text()).toBe('{\n "name": "Article"\n}\n'); }); - // A body that claims JSON but does not parse is a clue, so it survives unchanged. - it("prints a malformed JSON body as it arrived", async () => { - vi.mocked(performRawMapiRequest).mockResolvedValueOnce( - ok({ - statusCode: 200, - statusText: "OK", - headers: [{ name: "content-type", value: "application/json" }], - body: encode("{not json"), - }), - ); - const stdout = captureStream("stdout"); - - await runCommand(["types", "--envId", ENV_ID]); - stdout.restore(); - - expect(stdout.text()).toBe("{not json"); - }); - // Media types are case-insensitive (RFC 9110), and the API sends a charset // parameter, so neither may decide whether the body is treated as JSON. it("re-indents a JSON body whose media type is not lowercase", async () => { @@ -152,7 +134,7 @@ describe("kontent mapi argument handling", () => { statusCode: 200, statusText: "OK", headers: [{ name: "Content-Type", value: "Application/JSON; charset=utf-8" }], - body: encode('{"name":"Article"}'), + body: { name: "Article" }, }), ); const stdout = captureStream("stdout"); @@ -163,29 +145,11 @@ describe("kontent mapi argument handling", () => { expect(stdout.text()).toBe('{\n "name": "Article"\n}\n'); }); - // The reason the body is carried as bytes: a string round-trip would replace - // every byte that is not valid UTF-8 with U+FFFD and corrupt the download. - it("passes a binary body through byte for byte", async () => { - const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe, 0x00]); - vi.mocked(performRawMapiRequest).mockResolvedValueOnce( - ok({ - statusCode: 200, - statusText: "OK", - headers: [{ name: "content-type", value: "image/png" }], - body: png, - }), - ); - const stdout = captureStream("stdout"); - - await runCommand(["assets/x", "--envId", ENV_ID]); - stdout.restore(); - - expect(Buffer.compare(stdout.bytes(), Buffer.from(png))).toBe(0); - }); - + // A 204 carries no content type, which is the same signal as a body that was + // dropped - only the absent Content-Length separates the two. it("stays quiet when a success simply has no body", async () => { vi.mocked(performRawMapiRequest).mockResolvedValueOnce( - ok({ statusCode: 204, statusText: "No Content", headers: [], body: encode("") }), + ok({ statusCode: 204, statusText: "No Content", headers: [], body: null }), ); const stdout = captureStream("stdout"); const stderr = captureStream("stderr"); diff --git a/test/unit/transport.test.ts b/test/unit/transport.test.ts deleted file mode 100644 index 3134f1f..0000000 --- a/test/unit/transport.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { fetchTransport, isAbortError } from "../../src/lib/mapi/raw/transport.js"; - -const URL_UNDER_TEST = new URL("https://manage.test/v2/projects/x/types"); - -type FetchInit = Readonly<{ - method?: string; - headers?: Headers; - body?: unknown; - signal?: AbortSignal | null; -}>; - -// The transport is the one place that touches global fetch, so the seam under test -// is fetch itself. A real Response goes back, to exercise the header and body reads. -const stubFetch = (response: Response) => { - const spy = vi.fn(async (_url: URL, _init: FetchInit) => response); - vi.stubGlobal("fetch", spy); - return spy; -}; - -const lastInit = (spy: ReturnType): FetchInit => spy.mock.calls.at(-1)?.[1] ?? {}; - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -describe("fetchTransport", () => { - it("forwards the method, headers, body and abort signal to fetch", async () => { - const spy = stubFetch(new Response(null, { status: 200 })); - const controller = new AbortController(); - const body = new Blob(['{"codename":"x"}']); - - await fetchTransport({ - url: URL_UNDER_TEST, - method: "POST", - body, - requestHeaders: [ - { name: "authorization", value: "Bearer token" }, - { name: "x-foo", value: "1" }, - ], - abortSignal: controller.signal, - }); - - expect(spy.mock.calls.at(-1)?.[0]).toBe(URL_UNDER_TEST); - const init = lastInit(spy); - expect(init.method).toBe("POST"); - expect(init.body).toBe(body); - expect(init.signal).toBe(controller.signal); - expect([...(init.headers ?? new Headers())]).toEqual([ - ["authorization", "Bearer token"], - ["x-foo", "1"], - ]); - }); - - it("passes a null signal when the caller supplies none", async () => { - const spy = stubFetch(new Response(null, { status: 200 })); - - await fetchTransport({ - url: URL_UNDER_TEST, - method: "GET", - body: null, - requestHeaders: [], - }); - - expect(lastInit(spy).signal).toBeNull(); - }); - - it("reports the status line and the response headers as a lowercased array", async () => { - stubFetch( - new Response(null, { - status: 404, - statusText: "Not Found", - headers: { "Content-Type": "application/json", "X-Request-Id": "abc" }, - }), - ); - - const response = await fetchTransport({ - url: URL_UNDER_TEST, - method: "GET", - body: null, - requestHeaders: [], - }); - - expect(response.status).toBe(404); - expect(response.statusText).toBe("Not Found"); - expect(response.responseHeaders).toEqual([ - { name: "content-type", value: "application/json" }, - { name: "x-request-id", value: "abc" }, - ]); - }); - - // The whole point of the transport: no parsing, no decoding, no interpretation. - it("returns the body as the bytes that came off the wire", async () => { - const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0xff, 0xfe, 0x00]); - stubFetch(new Response(png, { status: 200 })); - - const response = await fetchTransport({ - url: URL_UNDER_TEST, - method: "GET", - body: null, - requestHeaders: [], - }); - - expect(Buffer.compare(response.body, Buffer.from(png))).toBe(0); - }); - - it("returns an empty body for a response that has none", async () => { - stubFetch(new Response(null, { status: 204, statusText: "No Content" })); - - const response = await fetchTransport({ - url: URL_UNDER_TEST, - method: "DELETE", - body: null, - requestHeaders: [], - }); - - expect(response.body).toHaveLength(0); - }); -}); - -describe("isAbortError", () => { - // What an aborted fetch actually rejects with - a DOMException, not a named subclass. - it("recognizes the DOMException an aborted fetch rejects with", () => { - expect(isAbortError(new DOMException("This operation was aborted.", "AbortError"))).toBe(true); - }); - - it("recognizes an AbortError that is a plain Error", () => { - const error = new Error("aborted"); - error.name = "AbortError"; - - expect(isAbortError(error)).toBe(true); - }); - - it("does not claim an unrelated failure was an abort", () => { - expect(isAbortError(new TypeError("fetch failed"))).toBe(false); - expect(isAbortError("AbortError")).toBe(false); - expect(isAbortError(undefined)).toBe(false); - }); -}); From b29990f1046e55075725aee8d81e91c6580d2a1d Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Mon, 24 Aug 2026 13:27:48 +0200 Subject: [PATCH 15/25] fix: match core-sdk's JSON rule, copy no request body, bind prompts to stderr Claude-Session: https://claude.ai/code/session_01TCaib3a5osMoKG6cctuFvR --- src/commands/mapi/README.md | 17 ++++++++++ src/commands/mapi/request.ts | 20 ++++++------ src/lib/mapi/raw/contentType.ts | 8 +++++ src/lib/ui/prompts.ts | 19 +++++------ test/integration/mapiCommand.test.ts | 26 +++++++++++++++ test/unit/jsonContentType.test.ts | 47 ++++++++++++++++++++++++++++ tsconfig.json | 2 +- 7 files changed, 120 insertions(+), 19 deletions(-) create mode 100644 src/lib/mapi/raw/contentType.ts create mode 100644 test/unit/jsonContentType.test.ts diff --git a/src/commands/mapi/README.md b/src/commands/mapi/README.md index c10ce97..fa5b6e5 100644 --- a/src/commands/mapi/README.md +++ b/src/commands/mapi/README.md @@ -47,3 +47,20 @@ kontent mapi types -H 'X-Foo: 1' -H 'X-Bar: 2' --envId echo '{"name":"Article"}' | kontent mapi types --envId --input - ``` + +## Response output + +The response body is the only thing on stdout; everything said about the request +goes to stderr. `kontent mapi types --envId | jq` works, and `--logLevel none` +still prints the payload. + +- A JSON body is re-indented and printed. The Management API answers JSON on + every status, error responses included, so this is the normal case. +- A body of any other content type is **not** printed. It is reported on stderr + with its byte count instead, because the underlying HTTP adapter parses JSON + and nothing else. In practice this means an error page served by the edge in + front of the API (`text/html` from a gateway or a WAF) rather than anything the + API itself returns; to capture such a body, repeat the request with `curl`. +- `-i` prepends the status line and the response headers to stdout. +- A 4xx or 5xx sets the exit code to 1 and prints `HTTP ` on stderr. The + response body still goes to stdout, so a failing request stays scriptable. diff --git a/src/commands/mapi/request.ts b/src/commands/mapi/request.ts index 126fefd..7fa949d 100644 --- a/src/commands/mapi/request.ts +++ b/src/commands/mapi/request.ts @@ -9,6 +9,7 @@ import { import { formatAuthError } from "../../lib/auth/formatAuthError.js"; import { type AuthSource, resolveMapiCredential } from "../../lib/auth/mapiCredential.js"; import { createMapiRawClient } from "../../lib/mapi/raw/client.js"; +import { isJsonContentType } from "../../lib/mapi/raw/contentType.js"; import { parseHeaders } from "../../lib/mapi/raw/headers.js"; import { err, isErr, ok, type Result, tryAsync } from "../../lib/result.js"; import type { Telemetry } from "../../lib/telemetry/tracking.js"; @@ -244,7 +245,7 @@ const resolveMethod = ( const readInput = async (input: string): Promise> => { if (input !== "-") { return await tryAsync( - async () => new Blob([Uint8Array.from(await readFile(input))]), + async () => new Blob([await readFile(input)]), (cause) => ({ kind: "unreadable-input" as const, message: `Failed to read "${input}": ${describeCause(cause)}`, @@ -269,12 +270,12 @@ const readInput = async (input: string): Promise> ); }; -const readStdin = async (): Promise> => { +const readStdin = async (): Promise => { const chunks: Buffer[] = []; for await (const chunk of process.stdin) { chunks.push(chunk as Buffer); } - return Uint8Array.from(Buffer.concat(chunks)); + return Buffer.concat(chunks); }; /** @@ -294,7 +295,7 @@ const writeResponse = ( ); } - if (contentType(response) === "application/json") { + if (isJsonContentType(rawContentType(response))) { process.stdout.write(`${JSON.stringify(response.body, null, 2)}\n`); return; } @@ -320,12 +321,13 @@ const contentLength = (response: MapiResponse): number => { return Number.isNaN(parsed) ? 0 : parsed; }; +const rawContentType = (response: MapiResponse): string | undefined => + response.headers.find((header) => header.name.toLowerCase() === "content-type")?.value; + +// The media type alone, for a message a human reads; the decision to print uses +// the raw value, because that is what the adapter parsed by. const contentType = (response: MapiResponse): string | undefined => - response.headers - .find((header) => header.name.toLowerCase() === "content-type") - ?.value.split(";")[0] - ?.trim() - .toLowerCase(); + rawContentType(response)?.split(";")[0]?.trim().toLowerCase(); const formatFailure = (response: MapiResponse, source: AuthSource): string => { const summary = `HTTP ${response.statusCode} ${response.statusText}`; diff --git a/src/lib/mapi/raw/contentType.ts b/src/lib/mapi/raw/contentType.ts new file mode 100644 index 0000000..125ea7d --- /dev/null +++ b/src/lib/mapi/raw/contentType.ts @@ -0,0 +1,8 @@ +/** + * Mirrors core-sdk's `isApplicationJsonResponseType`, the rule its default + * adapter parses a response body by. core-sdk does not export it, so the rule is + * duplicated rather than imported - `test/unit/jsonContentType.test.ts` drives + * the real adapter to assert the two still agree. + */ +export const isJsonContentType = (rawContentType: string | undefined): boolean => + rawContentType?.toLowerCase().includes("application/json") ?? false; diff --git a/src/lib/ui/prompts.ts b/src/lib/ui/prompts.ts index 4100e59..218a0c8 100644 --- a/src/lib/ui/prompts.ts +++ b/src/lib/ui/prompts.ts @@ -10,27 +10,28 @@ import { /** * clack defaults every prompt, spinner and note to stdout, which stdout must * stay free of; there is no global setting for it, so the stderr stream is - * bound here once instead of at every call site. As a side effect the spinner - * keeps animating when stdout is piped, because clack's TTY check reads the - * stream it is handed. + * bound here once instead of at every call site. It is bound after the caller's + * options rather than before, so no call site can route a prompt back to + * stdout. As a side effect the spinner keeps animating when stdout is piped, + * because clack's TTY check reads the stream it is handed. * * `stream.message/info/success` hardcode stdout and cannot be redirected - do * not start using them. */ export const spinner: typeof clackSpinner = (options = {}) => - clackSpinner({ output: process.stderr, ...options }); + clackSpinner({ ...options, output: process.stderr }); export const confirm: typeof clackConfirm = async (options) => - clackConfirm({ output: process.stderr, ...options }); + clackConfirm({ ...options, output: process.stderr }); export const select: typeof clackSelect = async (options) => - clackSelect({ output: process.stderr, ...options }); + clackSelect({ ...options, output: process.stderr }); export const note: typeof clackNote = (message, title, options = {}) => - clackNote(message, title, { output: process.stderr, ...options }); + clackNote(message, title, { ...options, output: process.stderr }); export const intro: typeof clackIntro = (title, options = {}) => - clackIntro(title, { output: process.stderr, ...options }); + clackIntro(title, { ...options, output: process.stderr }); export const outro: typeof clackOutro = (message, options = {}) => - clackOutro(message, { output: process.stderr, ...options }); + clackOutro(message, { ...options, output: process.stderr }); diff --git a/test/integration/mapiCommand.test.ts b/test/integration/mapiCommand.test.ts index dd9799b..a7cd094 100644 --- a/test/integration/mapiCommand.test.ts +++ b/test/integration/mapiCommand.test.ts @@ -162,6 +162,32 @@ describe("kontent mapi argument handling", () => { expect(stderr.text()).toBe(""); }); + // A proxy that joins two Content-Type headers produces one comma-separated + // value. core-sdk parses that body, so the command has to print it rather than + // report it as a type it could not show. + it("prints a JSON body whose content type arrived duplicated", async () => { + vi.mocked(performRawMapiRequest).mockResolvedValueOnce( + ok({ + statusCode: 200, + statusText: "OK", + headers: [ + { name: "content-type", value: "application/json, application/json" }, + { name: "content-length", value: "21" }, + ], + body: { name: "Article" }, + }), + ); + const stdout = captureStream("stdout"); + const stderr = captureStream("stderr"); + + await runCommand(["types", "--envId", ENV_ID]); + stdout.restore(); + stderr.restore(); + + expect(stdout.text()).toBe('{\n "name": "Article"\n}\n'); + expect(stderr.text()).toBe(""); + }); + it("rejects a body on GET instead of letting the transport throw", async () => { const captured = captureStream("stderr"); diff --git a/test/unit/jsonContentType.test.ts b/test/unit/jsonContentType.test.ts new file mode 100644 index 0000000..852e6a8 --- /dev/null +++ b/test/unit/jsonContentType.test.ts @@ -0,0 +1,47 @@ +import { getDefaultHttpAdapter } from "@kontent-ai/core-sdk"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { isJsonContentType } from "../../src/lib/mapi/raw/contentType.js"; + +// isJsonContentType duplicates core-sdk's unexported parse rule, so the two can +// drift apart on a version bump. Driving the real adapter over a stubbed fetch +// is the only way to notice: a content type the command prints as JSON must be +// exactly one the adapter actually parsed. +const contentTypes = [ + "application/json", + "application/json; charset=utf-8", + "Application/JSON", + // A proxy that joins two Content-Type headers into one comma-separated value. + "application/json, application/json", + "application/problem+json", + "text/html; charset=utf-8", + "application/octet-stream", + "text/plain", +]; + +describe("isJsonContentType", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each(contentTypes)("agrees with core-sdk's adapter on %j", async (contentType) => { + vi.stubGlobal( + "fetch", + async () => + new Response(JSON.stringify({ name: "Article" }), { + headers: { "content-type": contentType }, + }), + ); + + const executeRequest = getDefaultHttpAdapter().executeRequest; + if (executeRequest === undefined) { + throw new Error("The default adapter cannot execute requests."); + } + const response = await executeRequest({ + url: new URL("https://manage.kontent.ai/v2/projects/x/types"), + method: "GET", + body: null, + }); + + expect(response.payload !== null).toBe(isJsonContentType(contentType)); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index d6ac1b0..6b57450 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,6 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["src/**/*", "test/**/*", "scripts/**/*", "vitest.config.ts"], + "include": ["src/**/*", "test/**/*", "scripts/**/*", "vitest.config.ts", "vitest.e2e.config.ts"], "exclude": ["node_modules", "dist"] } From 6dc209e4346f801a6104add2686a5a824cedc903 Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Mon, 24 Aug 2026 13:48:32 +0200 Subject: [PATCH 16/25] fix: report a dropped response body from its content type, not its length Claude-Session: https://claude.ai/code/session_01TCaib3a5osMoKG6cctuFvR --- src/commands/mapi/request.ts | 32 +++++++++++++++++----------- test/integration/mapiCommand.test.ts | 22 +++++++++++++++++++ 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/commands/mapi/request.ts b/src/commands/mapi/request.ts index 7fa949d..d1ca115 100644 --- a/src/commands/mapi/request.ts +++ b/src/commands/mapi/request.ts @@ -279,9 +279,10 @@ const readStdin = async (): Promise => { }; /** - * A body of a type core-sdk skipped never reached this point, so a non-zero - * content length is the only trace left that there was one - report it on stderr - * rather than leave stdout silently empty. + * A body of a type core-sdk skipped never reached this point, so the content type + * is the only trace left that there was one - report it on stderr rather than + * leave stdout silently empty. Not the content length: a chunked response sends + * none, and the body would then vanish without a word. */ const writeResponse = ( response: MapiResponse, @@ -300,25 +301,32 @@ const writeResponse = ( return; } - const droppedBytes = contentLength(response); - if (droppedBytes > 0) { - logger.warning( - "standard", - `The response carried ${droppedBytes} bytes of ${contentType(response) ?? "an unknown type"}, which is not JSON and was not shown.`, - ); + // A response that carried nothing at all - a 204, say - sends no content type + // either, and there is nothing to report. + const mediaType = contentType(response); + if (mediaType === undefined) { + return; } + + const droppedBytes = contentLength(response); + const carried = + droppedBytes === undefined ? `a ${mediaType} body` : `${droppedBytes} bytes of ${mediaType}`; + logger.warning( + "standard", + `The response carried ${carried}, which is not JSON and was not shown.`, + ); }; -const contentLength = (response: MapiResponse): number => { +const contentLength = (response: MapiResponse): number | undefined => { const raw = response.headers.find( (header) => header.name.toLowerCase() === "content-length", )?.value; if (raw === undefined) { - return 0; + return undefined; } const parsed = Number(raw); - return Number.isNaN(parsed) ? 0 : parsed; + return Number.isNaN(parsed) ? undefined : parsed; }; const rawContentType = (response: MapiResponse): string | undefined => diff --git a/test/integration/mapiCommand.test.ts b/test/integration/mapiCommand.test.ts index a7cd094..fe9281d 100644 --- a/test/integration/mapiCommand.test.ts +++ b/test/integration/mapiCommand.test.ts @@ -109,6 +109,28 @@ describe("kontent mapi argument handling", () => { expect(process.exitCode).toBe(1); }); + // A chunked response sends no Content-Length, so the content type is the only + // signal left that a body existed and was dropped. + it("reports a dropped body that came without a content length", async () => { + vi.mocked(performRawMapiRequest).mockResolvedValueOnce( + ok({ + statusCode: 200, + statusText: "OK", + headers: [{ name: "Content-Type", value: "text/csv" }], + body: null, + }), + ); + const stdout = captureStream("stdout"); + const stderr = captureStream("stderr"); + + await runCommand(["types", "--envId", ENV_ID]); + stdout.restore(); + stderr.restore(); + + expect(stdout.text()).toBe(""); + expect(stderr.text()).toContain("a text/csv body"); + }); + it("re-indents a JSON body", async () => { vi.mocked(performRawMapiRequest).mockResolvedValueOnce( ok({ From f0d655b222ecc7cd989b198e830e9138a72f4643 Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Mon, 24 Aug 2026 13:48:32 +0200 Subject: [PATCH 17/25] docs: say what --logLevel none does and does not silence Claude-Session: https://claude.ai/code/session_01TCaib3a5osMoKG6cctuFvR --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index abb2e56..3713d16 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,11 @@ Each command supports `--help` for its own options. - `--configFile` — path to a JSON file with CLI parameters - `--help`, `-h` / `--version`, `-v` +Everything the log level governs goes to stderr, and `none` silences errors along +with progress — a failed command is then visible only through its exit code. What +a command was asked to produce goes to stdout and is never gated by the log +level. + ## Environment variables Environment variables are read individually where they apply — they are not From c09503e3aecfbdb8caff933da05bf98c8ad6bc1f Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Mon, 24 Aug 2026 14:14:48 +0200 Subject: [PATCH 18/25] refactor: declare mapi request errors in the layer that raises them Claude-Session: https://claude.ai/code/session_01TCaib3a5osMoKG6cctuFvR --- CLAUDE.md | 4 +-- src/commands/mapi/request.ts | 61 ++++++++---------------------------- src/core/mapi/request.ts | 12 +++---- src/lib/mapi/raw/method.ts | 25 +++++++++++++++ test/unit/method.test.ts | 48 ++++++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 58 deletions(-) create mode 100644 src/lib/mapi/raw/method.ts create mode 100644 test/unit/method.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index d936ed6..1abce56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,10 +28,10 @@ Adding a command: export a `register: RegisterCommand` (see `src/commands/login/ ### API clients - `iapi` (`src/lib/iapi`) — internal Kontent.ai API; hand-rolled client, one file per endpoint, over `@kontent-ai/core-sdk`. Endpoint validators (the `schema` field) must be **`zod/mini`** (`import * as z from "zod/mini"`) — classic `zod` won't infer the payload. -- `mapi` (`src/lib/mapi`) — public Management API via `@kontent-ai/management-sdk`. `src/lib/mapi/raw` is the deliberate opposite: an adapter-backed passthrough (no schema, no response interpretation) behind `kontent mapi`, where a 4xx/5xx is a result, not an error. core-sdk's `HttpAdapter` suffices because MAPI answers `application/json` on every status and binary only ever travels request-side, on an asset upload; a body of any other type is dropped by the adapter and reported on stderr. Not `getDefaultHttpService` — it maps every non-2xx to an error and keeps the body only when it matches the Kontent error shape, losing exactly the 4xx bodies this command exists to show. +- `mapi` (`src/lib/mapi`) — public Management API via `@kontent-ai/management-sdk`. `src/lib/mapi/raw` is the deliberate opposite: a passthrough (no schema, no response interpretation) behind `kontent mapi`, where a 4xx/5xx is a result, not an error. Its doc comments carry the why: `raw/client.ts` for the choice of core-sdk's `HttpAdapter` over `getDefaultHttpService`, `raw/contentType.ts` for the rule that decides whether a body is printed. - `@kontent-ai/core-sdk` — shared HTTP/SDK layer both clients build on. -**Commands build clients; core receives them.** The command builds the `iapiClient`/`mapiClient` and passes them into core (e.g. `performBootstrap(params, { logger, iapiClient, mapiClient })`); core never constructs clients itself. Auth failure is handled in the command, not surfaced as a core `Result` error. +**Commands build clients; core receives them.** The command builds the `iapiClient`/`mapiClient` and passes them into core (e.g. `performBootstrap(params, { logger, iapiClient, mapiClient })`); core never constructs clients itself. Auth failure is handled in the command, not surfaced as a core `Result` error. Same split for arguments: pure parsers live in `lib` (`mapi/raw/headers.ts`, `mapi/raw/method.ts`), reading what the invocation points at stays in the command, and each layer declares only the error kinds it raises. ### Output channels diff --git a/src/commands/mapi/request.ts b/src/commands/mapi/request.ts index d1ca115..1f85180 100644 --- a/src/commands/mapi/request.ts +++ b/src/commands/mapi/request.ts @@ -1,16 +1,13 @@ import { readFile } from "node:fs/promises"; import type { Header, HttpMethod } from "@kontent-ai/core-sdk"; import { match } from "ts-pattern"; -import { - type MapiRequestError, - type MapiResponse, - performRawMapiRequest, -} from "../../core/mapi/request.js"; +import { type MapiResponse, performRawMapiRequest } from "../../core/mapi/request.js"; import { formatAuthError } from "../../lib/auth/formatAuthError.js"; import { type AuthSource, resolveMapiCredential } from "../../lib/auth/mapiCredential.js"; import { createMapiRawClient } from "../../lib/mapi/raw/client.js"; import { isJsonContentType } from "../../lib/mapi/raw/contentType.js"; import { parseHeaders } from "../../lib/mapi/raw/headers.js"; +import { parseMethod } from "../../lib/mapi/raw/method.js"; import { err, isErr, ok, type Result, tryAsync } from "../../lib/result.js"; import type { Telemetry } from "../../lib/telemetry/tracking.js"; import { createLoggerFromArgs, type Logger, type LogOptions } from "../../log.js"; @@ -163,22 +160,22 @@ type PreparedRequest = Readonly<{ body: string | Blob | null; }>; -const httpMethods = [ - "GET", - "POST", - "PUT", - "DELETE", - "PATCH", -] as const satisfies ReadonlyArray; + +type RequestArgsError = Readonly<{ + kind: "invalid-method" | "invalid-header" | "unreadable-input"; + message: string; +}>; const prepareRequest = async ( args: RequestArgs, -): Promise> => { - const method = resolveMethod(args.method, args.input !== undefined); +): Promise> => { + const method = parseMethod(args.method, args.input !== undefined); if (isErr(method)) { - return method; + return err({ kind: "invalid-method", message: method.error }); } + // Where curl parity stops: curl does send `-X GET` with a body, we cannot - the + // fetch spec forbids one on GET and undici throws before the request leaves. // Checked before the input is read: there is no point opening a file the // request can never carry. Only an explicit `-X GET` reaches this. if (args.input !== undefined && method.value === "GET") { @@ -210,39 +207,7 @@ const prepareRequest = async ( }); }; -/** - * Two rules, the same ones curl and `gh api` apply: - * - * - no `-X`: GET, or POST when `--input` supplies a body; - * - `-X` given: that method verbatim. - * - * A yargs `default` would break the first rule: it is indistinguishable from a - * typed `-X GET`, which would turn every `--input` into a GET with a body. - * - * Where curl parity stops: curl does send `-X GET` with a body, we cannot. The - * fetch spec forbids one on GET, and undici throws before the request leaves, so - * `prepareRequest` rejects the pair with an explanation instead of surfacing a - * raw transport error. - */ -const resolveMethod = ( - raw: string | undefined, - hasInput: boolean, -): Result => { - if (raw === undefined) { - return ok(hasInput ? "POST" : "GET"); - } - - const method = httpMethods.find((known) => known === raw.toUpperCase()); - if (method === undefined) { - return err({ - kind: "invalid-method", - message: `Unsupported HTTP method "${raw}". Use one of ${httpMethods.join(", ")}.`, - }); - } - return ok(method); -}; - -const readInput = async (input: string): Promise> => { +const readInput = async (input: string): Promise> => { if (input !== "-") { return await tryAsync( async () => new Blob([await readFile(input)]), diff --git a/src/core/mapi/request.ts b/src/core/mapi/request.ts index e45ab67..3057dee 100644 --- a/src/core/mapi/request.ts +++ b/src/core/mapi/request.ts @@ -25,17 +25,13 @@ export type MapiResponse = Readonly<{ }>; /** - * The ways the command ends without an HTTP answer to show the user. - * - * `transport` is reserved for a request that could not be made; every status the - * API answers with, including 4xx and 5xx, is an `ok` result. The remaining kinds - * are raised by the command while it builds the params, before anything is sent. + * The ways a request ends without an HTTP answer to show the user. `transport` is + * reserved for a request that could not be made: every status the API answers + * with, including 4xx and 5xx, is an `ok` result. Whatever goes wrong while the + * command reads its own arguments never reaches here. */ export type MapiRequestError = | Readonly<{ kind: "invalid-endpoint"; message: string }> - | Readonly<{ kind: "invalid-header"; message: string }> - | Readonly<{ kind: "invalid-method"; message: string }> - | Readonly<{ kind: "unreadable-input"; message: string }> | Readonly<{ kind: "transport"; message: string }>; export const performRawMapiRequest = async ( diff --git a/src/lib/mapi/raw/method.ts b/src/lib/mapi/raw/method.ts new file mode 100644 index 0000000..3c267c1 --- /dev/null +++ b/src/lib/mapi/raw/method.ts @@ -0,0 +1,25 @@ +import type { HttpMethod } from "@kontent-ai/core-sdk"; +import { err, ok, type Result } from "../../result.js"; + +export const parseMethod = ( + raw: string | undefined, + hasBody: boolean, +): Result => { + if (raw === undefined) { + return ok(hasBody ? "POST" : "GET"); + } + + const method = httpMethods.find((known) => known === raw.toUpperCase()); + if (method === undefined) { + return err(`Unsupported HTTP method "${raw}". Use one of ${httpMethods.join(", ")}.`); + } + return ok(method); +}; + +const httpMethods = [ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", +] as const satisfies ReadonlyArray; diff --git a/test/unit/method.test.ts b/test/unit/method.test.ts new file mode 100644 index 0000000..654d6e7 --- /dev/null +++ b/test/unit/method.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { parseMethod } from "../../src/lib/mapi/raw/method.js"; +import { assertErr, assertOk } from "../helpers/assertResult.js"; + +describe("parseMethod", () => { + it("defaults to GET without a body", () => { + const result = parseMethod(undefined, false); + + assertOk(result); + expect(result.value).toBe("GET"); + }); + + it("defaults to POST when a body is supplied", () => { + const result = parseMethod(undefined, true); + + assertOk(result); + expect(result.value).toBe("POST"); + }); + + it.each(["GET", "POST", "PUT", "DELETE", "PATCH"])("accepts %s", (method) => { + const result = parseMethod(method, false); + + assertOk(result); + expect(result.value).toBe(method); + }); + + it("uppercases what the user typed", () => { + const result = parseMethod("delete", false); + + assertOk(result); + expect(result.value).toBe("DELETE"); + }); + + // An explicit method wins even when it contradicts the body-implied default. + it("keeps an explicit method over the body-implied one", () => { + const result = parseMethod("PUT", true); + + assertOk(result); + expect(result.value).toBe("PUT"); + }); + + it.each(["FOO", "", "HEAD", "OPTIONS"])("rejects %j", (method) => { + const result = parseMethod(method, false); + + assertErr(result); + expect(result.error).toContain("Use one of GET, POST, PUT, DELETE, PATCH."); + }); +}); From 0c1644d02e0fcd309dd7e72ad401ba9318efb619 Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Mon, 24 Aug 2026 14:21:10 +0200 Subject: [PATCH 19/25] refactor: format the mapi response before writing it Claude-Session: https://claude.ai/code/session_01TCaib3a5osMoKG6cctuFvR --- src/commands/mapi/presentResponse.ts | 72 +++++++++++++++++ src/commands/mapi/request.ts | 70 ++--------------- test/integration/mapiCommand.test.ts | 103 +++---------------------- test/unit/presentResponse.test.ts | 111 +++++++++++++++++++++++++++ 4 files changed, 203 insertions(+), 153 deletions(-) create mode 100644 src/commands/mapi/presentResponse.ts create mode 100644 test/unit/presentResponse.test.ts diff --git a/src/commands/mapi/presentResponse.ts b/src/commands/mapi/presentResponse.ts new file mode 100644 index 0000000..bbb0417 --- /dev/null +++ b/src/commands/mapi/presentResponse.ts @@ -0,0 +1,72 @@ +import type { MapiResponse } from "../../core/mapi/request.js"; +import { isJsonContentType } from "../../lib/mapi/raw/contentType.js"; + +export type PresentedResponse = Readonly<{ + /** Everything destined for stdout, ready to be written in one go. */ + payload: string; + droppedBodyWarning?: string; +}>; + +/** + * Decides what the response looks like without writing anything, so the two + * streams are chosen in one place and the rules can be asserted on as values. + * + * A body of a type core-sdk skipped never reached this point, so the content type + * is the only trace left that there was one - report it rather than leave stdout + * silently empty. Not the content length: a chunked response sends none, and the + * body would then vanish without a word. + */ +export const presentResponse = ( + response: MapiResponse, + shouldIncludeHeaders: boolean, +): PresentedResponse => { + const statusBlock = shouldIncludeHeaders ? formatStatusBlock(response) : ""; + + if (isJsonContentType(rawContentType(response))) { + return { payload: `${statusBlock}${JSON.stringify(response.body, null, 2)}\n` }; + } + + // A response that carried nothing at all - a 204, say - sends no content type + // either, and there is nothing to report. + const mediaType = contentType(response); + if (mediaType === undefined) { + return { payload: statusBlock }; + } + + const droppedBytes = contentLength(response); + const carried = + droppedBytes === undefined ? `a ${mediaType} body` : `${droppedBytes} bytes of ${mediaType}`; + return { + payload: statusBlock, + droppedBodyWarning: `The response carried ${carried}, which is not JSON and was not shown.`, + }; +}; + +// The version is not the negotiated one: Node's fetch does not expose it. +const formatStatusBlock = (response: MapiResponse): string => + [ + `HTTP/1.1 ${response.statusCode} ${response.statusText}`, + ...response.headers.map((header) => `${header.name}: ${header.value}`), + "", + "", + ].join("\n"); + +const contentLength = (response: MapiResponse): number | undefined => { + const raw = response.headers.find( + (header) => header.name.toLowerCase() === "content-length", + )?.value; + if (raw === undefined) { + return undefined; + } + + const parsed = Number(raw); + return Number.isNaN(parsed) ? undefined : parsed; +}; + +const rawContentType = (response: MapiResponse): string | undefined => + response.headers.find((header) => header.name.toLowerCase() === "content-type")?.value; + +// The media type alone, for a message a human reads; the decision to print uses +// the raw value, because that is what the adapter parsed by. +const contentType = (response: MapiResponse): string | undefined => + rawContentType(response)?.split(";")[0]?.trim().toLowerCase(); diff --git a/src/commands/mapi/request.ts b/src/commands/mapi/request.ts index 1f85180..1fb0088 100644 --- a/src/commands/mapi/request.ts +++ b/src/commands/mapi/request.ts @@ -5,13 +5,13 @@ import { type MapiResponse, performRawMapiRequest } from "../../core/mapi/reques import { formatAuthError } from "../../lib/auth/formatAuthError.js"; import { type AuthSource, resolveMapiCredential } from "../../lib/auth/mapiCredential.js"; import { createMapiRawClient } from "../../lib/mapi/raw/client.js"; -import { isJsonContentType } from "../../lib/mapi/raw/contentType.js"; import { parseHeaders } from "../../lib/mapi/raw/headers.js"; import { parseMethod } from "../../lib/mapi/raw/method.js"; import { err, isErr, ok, type Result, tryAsync } from "../../lib/result.js"; import type { Telemetry } from "../../lib/telemetry/tracking.js"; import { createLoggerFromArgs, type Logger, type LogOptions } from "../../log.js"; import type { RegisterCommand } from "../../types/yargs.js"; +import { presentResponse } from "./presentResponse.js"; type RequestArgs = LogOptions & Readonly<{ @@ -139,7 +139,13 @@ const runRequest = async ( return; } - writeResponse(result.value, args.include === true, logger); + const presented = presentResponse(result.value, args.include === true); + if (presented.payload !== "") { + process.stdout.write(presented.payload); + } + if (presented.droppedBodyWarning !== undefined) { + logger.warning("standard", presented.droppedBodyWarning); + } if (result.value.statusCode >= 400) { tracker.fail(`http-${result.value.statusCode}`, { @@ -160,7 +166,6 @@ type PreparedRequest = Readonly<{ body: string | Blob | null; }>; - type RequestArgsError = Readonly<{ kind: "invalid-method" | "invalid-header" | "unreadable-input"; message: string; @@ -243,65 +248,6 @@ const readStdin = async (): Promise => { return Buffer.concat(chunks); }; -/** - * A body of a type core-sdk skipped never reached this point, so the content type - * is the only trace left that there was one - report it on stderr rather than - * leave stdout silently empty. Not the content length: a chunked response sends - * none, and the body would then vanish without a word. - */ -const writeResponse = ( - response: MapiResponse, - shouldIncludeHeaders: boolean, - logger: Logger, -): void => { - if (shouldIncludeHeaders) { - const headerLines = response.headers.map((header) => `${header.name}: ${header.value}`); - process.stdout.write( - [`HTTP/1.1 ${response.statusCode} ${response.statusText}`, ...headerLines, "", ""].join("\n"), - ); - } - - if (isJsonContentType(rawContentType(response))) { - process.stdout.write(`${JSON.stringify(response.body, null, 2)}\n`); - return; - } - - // A response that carried nothing at all - a 204, say - sends no content type - // either, and there is nothing to report. - const mediaType = contentType(response); - if (mediaType === undefined) { - return; - } - - const droppedBytes = contentLength(response); - const carried = - droppedBytes === undefined ? `a ${mediaType} body` : `${droppedBytes} bytes of ${mediaType}`; - logger.warning( - "standard", - `The response carried ${carried}, which is not JSON and was not shown.`, - ); -}; - -const contentLength = (response: MapiResponse): number | undefined => { - const raw = response.headers.find( - (header) => header.name.toLowerCase() === "content-length", - )?.value; - if (raw === undefined) { - return undefined; - } - - const parsed = Number(raw); - return Number.isNaN(parsed) ? undefined : parsed; -}; - -const rawContentType = (response: MapiResponse): string | undefined => - response.headers.find((header) => header.name.toLowerCase() === "content-type")?.value; - -// The media type alone, for a message a human reads; the decision to print uses -// the raw value, because that is what the adapter parsed by. -const contentType = (response: MapiResponse): string | undefined => - rawContentType(response)?.split(";")[0]?.trim().toLowerCase(); - const formatFailure = (response: MapiResponse, source: AuthSource): string => { const summary = `HTTP ${response.statusCode} ${response.statusText}`; diff --git a/test/integration/mapiCommand.test.ts b/test/integration/mapiCommand.test.ts index fe9281d..ddfcaa9 100644 --- a/test/integration/mapiCommand.test.ts +++ b/test/integration/mapiCommand.test.ts @@ -83,9 +83,9 @@ describe("kontent mapi argument handling", () => { ]); }); - // core-sdk drops a body it does not recognize as JSON, so the only honest thing - // left to do is say on stderr that something was there. - it("reports a non-JSON body on stderr instead of printing nothing at all", async () => { + // presentResponse decides the wording; what matters here is that its two halves + // reach different streams and that the command still fails. + it("keeps a dropped-body warning off stdout", async () => { vi.mocked(performRawMapiRequest).mockResolvedValueOnce( ok({ statusCode: 502, @@ -109,105 +109,26 @@ describe("kontent mapi argument handling", () => { expect(process.exitCode).toBe(1); }); - // A chunked response sends no Content-Length, so the content type is the only - // signal left that a body existed and was dropped. - it("reports a dropped body that came without a content length", async () => { + it("prints a 4xx body on stdout and its diagnosis on stderr", async () => { vi.mocked(performRawMapiRequest).mockResolvedValueOnce( ok({ - statusCode: 200, - statusText: "OK", - headers: [{ name: "Content-Type", value: "text/csv" }], - body: null, - }), - ); - const stdout = captureStream("stdout"); - const stderr = captureStream("stderr"); - - await runCommand(["types", "--envId", ENV_ID]); - stdout.restore(); - stderr.restore(); - - expect(stdout.text()).toBe(""); - expect(stderr.text()).toContain("a text/csv body"); - }); - - it("re-indents a JSON body", async () => { - vi.mocked(performRawMapiRequest).mockResolvedValueOnce( - ok({ - statusCode: 200, - statusText: "OK", + statusCode: 404, + statusText: "Not Found", headers: [{ name: "content-type", value: "application/json" }], - body: { name: "Article" }, - }), - ); - const stdout = captureStream("stdout"); - - await runCommand(["types", "--envId", ENV_ID]); - stdout.restore(); - - expect(stdout.text()).toBe('{\n "name": "Article"\n}\n'); - }); - - // Media types are case-insensitive (RFC 9110), and the API sends a charset - // parameter, so neither may decide whether the body is treated as JSON. - it("re-indents a JSON body whose media type is not lowercase", async () => { - vi.mocked(performRawMapiRequest).mockResolvedValueOnce( - ok({ - statusCode: 200, - statusText: "OK", - headers: [{ name: "Content-Type", value: "Application/JSON; charset=utf-8" }], - body: { name: "Article" }, - }), - ); - const stdout = captureStream("stdout"); - - await runCommand(["types", "--envId", ENV_ID]); - stdout.restore(); - - expect(stdout.text()).toBe('{\n "name": "Article"\n}\n'); - }); - - // A 204 carries no content type, which is the same signal as a body that was - // dropped - only the absent Content-Length separates the two. - it("stays quiet when a success simply has no body", async () => { - vi.mocked(performRawMapiRequest).mockResolvedValueOnce( - ok({ statusCode: 204, statusText: "No Content", headers: [], body: null }), - ); - const stdout = captureStream("stdout"); - const stderr = captureStream("stderr"); - - await runCommand(["items/x", "-X", "DELETE", "--envId", ENV_ID]); - stdout.restore(); - stderr.restore(); - - expect(stdout.text()).toBe(""); - expect(stderr.text()).toBe(""); - }); - - // A proxy that joins two Content-Type headers produces one comma-separated - // value. core-sdk parses that body, so the command has to print it rather than - // report it as a type it could not show. - it("prints a JSON body whose content type arrived duplicated", async () => { - vi.mocked(performRawMapiRequest).mockResolvedValueOnce( - ok({ - statusCode: 200, - statusText: "OK", - headers: [ - { name: "content-type", value: "application/json, application/json" }, - { name: "content-length", value: "21" }, - ], - body: { name: "Article" }, + body: { message: "The requested content type was not found." }, }), ); const stdout = captureStream("stdout"); const stderr = captureStream("stderr"); - await runCommand(["types", "--envId", ENV_ID]); + await runCommand(["types/missing", "--envId", ENV_ID]); stdout.restore(); stderr.restore(); - expect(stdout.text()).toBe('{\n "name": "Article"\n}\n'); - expect(stderr.text()).toBe(""); + expect(stdout.text()).toContain("The requested content type was not found."); + expect(stderr.text()).toContain("HTTP 404 Not Found"); + expect(stderr.text()).not.toContain("The requested content type was not found."); + expect(process.exitCode).toBe(1); }); it("rejects a body on GET instead of letting the transport throw", async () => { diff --git a/test/unit/presentResponse.test.ts b/test/unit/presentResponse.test.ts new file mode 100644 index 0000000..b8ff26b --- /dev/null +++ b/test/unit/presentResponse.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { presentResponse } from "../../src/commands/mapi/presentResponse.js"; +import type { MapiResponse } from "../../src/core/mapi/request.js"; + +const response = (overrides: Partial = {}): MapiResponse => ({ + statusCode: 200, + statusText: "OK", + headers: [{ name: "content-type", value: "application/json" }], + body: { name: "Article" }, + ...overrides, +}); + +describe("presentResponse", () => { + it("re-indents a JSON body", () => { + const presented = presentResponse(response(), false); + + expect(presented.payload).toBe('{\n "name": "Article"\n}\n'); + expect(presented.droppedBodyWarning).toBeUndefined(); + }); + + // Media types are case-insensitive (RFC 9110) and the API sends a charset + // parameter, so neither may decide whether the body counts as JSON. + it.each([ + "Application/JSON; charset=utf-8", + "application/json;charset=utf-8", + // What a proxy produces when it joins two Content-Type headers. + "application/json, application/json", + ])("treats %j as JSON", (value) => { + const presented = presentResponse( + response({ headers: [{ name: "Content-Type", value }] }), + false, + ); + + expect(presented.payload).toBe('{\n "name": "Article"\n}\n'); + }); + + // core-sdk yields null for an absent body, a skipped one and a literal JSON + // null alike; with a JSON content type the honest reading is the literal. + it("prints a literal null body", () => { + const presented = presentResponse(response({ body: null }), false); + + expect(presented.payload).toBe("null\n"); + }); + + it("reports a dropped body with its byte count when the length is known", () => { + const presented = presentResponse( + response({ + statusCode: 502, + statusText: "Bad Gateway", + headers: [ + { name: "Content-Type", value: "text/html; charset=utf-8" }, + { name: "Content-Length", value: "137" }, + ], + body: null, + }), + false, + ); + + expect(presented.payload).toBe(""); + expect(presented.droppedBodyWarning).toBe( + "The response carried 137 bytes of text/html, which is not JSON and was not shown.", + ); + }); + + // A chunked response sends no Content-Length, so the content type is the only + // signal left that a body existed and was dropped. + it("reports a dropped body that came without a content length", () => { + const presented = presentResponse( + response({ headers: [{ name: "Content-Type", value: "text/csv" }], body: null }), + false, + ); + + expect(presented.payload).toBe(""); + expect(presented.droppedBodyWarning).toBe( + "The response carried a text/csv body, which is not JSON and was not shown.", + ); + }); + + it("stays silent when the response carried nothing at all", () => { + const presented = presentResponse( + response({ statusCode: 204, statusText: "No Content", headers: [], body: null }), + false, + ); + + expect(presented.payload).toBe(""); + expect(presented.droppedBodyWarning).toBeUndefined(); + }); + + it("puts the status line and headers before the body when asked", () => { + const presented = presentResponse(response(), true); + + expect(presented.payload).toBe( + 'HTTP/1.1 200 OK\ncontent-type: application/json\n\n{\n "name": "Article"\n}\n', + ); + }); + + it("still writes the status line when the body was dropped", () => { + const presented = presentResponse( + response({ + statusCode: 502, + statusText: "Bad Gateway", + headers: [{ name: "Content-Type", value: "text/html" }], + body: null, + }), + true, + ); + + expect(presented.payload).toBe("HTTP/1.1 502 Bad Gateway\nContent-Type: text/html\n\n"); + expect(presented.droppedBodyWarning).toContain("text/html"); + }); +}); From c73c808d56ee11dece2090bad174ef9dccfa2291 Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Mon, 24 Aug 2026 15:15:53 +0200 Subject: [PATCH 20/25] refactor: hand the raw mapi retry loop to core-sdk Claude-Session: https://claude.ai/code/session_01DefychaMvSyET6AGVJYJoi --- CLAUDE.md | 2 +- package.json | 2 +- pnpm-lock.yaml | 11 +- pnpm-workspace.yaml | 2 + src/commands/mapi/request.ts | 4 +- src/core/mapi/request.ts | 2 +- src/lib/mapi/raw/client.ts | 250 ++++++++++++------------------ test/integration/mapi.test.ts | 75 +++++---- test/unit/formatIapiError.test.ts | 1 + test/unit/retryAfter.test.ts | 59 ------- 10 files changed, 164 insertions(+), 244 deletions(-) delete mode 100644 test/unit/retryAfter.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1abce56..68b6118 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ Adding a command: export a `register: RegisterCommand` (see `src/commands/login/ ### API clients - `iapi` (`src/lib/iapi`) — internal Kontent.ai API; hand-rolled client, one file per endpoint, over `@kontent-ai/core-sdk`. Endpoint validators (the `schema` field) must be **`zod/mini`** (`import * as z from "zod/mini"`) — classic `zod` won't infer the payload. -- `mapi` (`src/lib/mapi`) — public Management API via `@kontent-ai/management-sdk`. `src/lib/mapi/raw` is the deliberate opposite: a passthrough (no schema, no response interpretation) behind `kontent mapi`, where a 4xx/5xx is a result, not an error. Its doc comments carry the why: `raw/client.ts` for the choice of core-sdk's `HttpAdapter` over `getDefaultHttpService`, `raw/contentType.ts` for the rule that decides whether a body is printed. +- `mapi` (`src/lib/mapi`) — public Management API via `@kontent-ai/management-sdk`. `src/lib/mapi/raw` is the deliberate opposite: a passthrough (no schema, no response interpretation) behind `kontent mapi`, where a 4xx/5xx is a result, not an error. It builds on core-sdk's `getDefaultHttpService` and turns the non-2xx it reports as errors back into results, reading the body off `error.details.adapterResponse`; retry, `Retry-After` and header merging are core-sdk's. Its doc comments carry the why: `raw/client.ts` for which SDK error reasons stay errors, `raw/contentType.ts` for the rule that decides whether a body is printed. - `@kontent-ai/core-sdk` — shared HTTP/SDK layer both clients build on. **Commands build clients; core receives them.** The command builds the `iapiClient`/`mapiClient` and passes them into core (e.g. `performBootstrap(params, { logger, iapiClient, mapiClient })`); core never constructs clients itself. Auth failure is handled in the command, not surfaced as a core `Result` error. Same split for arguments: pure parsers live in `lib` (`mapi/raw/headers.ts`, `mapi/raw/method.ts`), reading what the invocation points at stays in the command, and each layer declares only the error kinds it raises. diff --git a/package.json b/package.json index 6d24b10..165d91a 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "dependencies": { "@amplitude/analytics-node": "^1.5.59", "@clack/prompts": "^1.2.0", - "@kontent-ai/core-sdk": "12.0.0-preview.40", + "@kontent-ai/core-sdk": "12.0.0-preview.43", "@kontent-ai/core-sdk-v10": "npm:@kontent-ai/core-sdk@10.12.8", "@kontent-ai/management-sdk": "^8.5.4", "@napi-rs/keyring": "^1.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e635b1c..f132c27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^1.2.0 version: 1.6.0 '@kontent-ai/core-sdk': - specifier: 12.0.0-preview.40 - version: 12.0.0-preview.40(ts-pattern@5.9.0)(zod@4.4.3) + specifier: 12.0.0-preview.43 + version: 12.0.0-preview.43(ts-pattern@5.9.0)(zod@4.4.3) '@kontent-ai/core-sdk-v10': specifier: npm:@kontent-ai/core-sdk@10.12.8 version: '@kontent-ai/core-sdk@10.12.8' @@ -440,8 +440,8 @@ packages: resolution: {integrity: sha512-xf0/xFoFETcXk0GLmUng04Nn0UuAWBaR13G5jKrYYHi82tTZ7gRDtPXt+msBxX/HgOQDm10g1KRExd/MiS69Tg==} engines: {node: '>= 20'} - '@kontent-ai/core-sdk@12.0.0-preview.40': - resolution: {integrity: sha512-W8k5iijCvknnzHGX7q7VD0zzk2dh7W5elml2P76iroPj48OvxZuscR9Fzyg2W/Scefpe5Yy70ek4k/uw+RN77A==} + '@kontent-ai/core-sdk@12.0.0-preview.43': + resolution: {integrity: sha512-Ex/RkNNsZY+8RRUEvBV6VM4e4WE2pun0CWd9khSL3IfKT99JzWF8oS9mNo9VwfxVG/HDcHFvAV+6su8/VZG/hQ==} engines: {node: '>=22'} peerDependencies: ts-pattern: ^5 @@ -1201,6 +1201,7 @@ packages: eslint@9.39.4: resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -2550,7 +2551,7 @@ snapshots: - debug - supports-color - '@kontent-ai/core-sdk@12.0.0-preview.40(ts-pattern@5.9.0)(zod@4.4.3)': + '@kontent-ai/core-sdk@12.0.0-preview.43(ts-pattern@5.9.0)(zod@4.4.3)': dependencies: ts-pattern: 5.9.0 zod: 4.4.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 385da0f..887489a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,5 @@ allowBuilds: esbuild: true +minimumReleaseAgeExclude: + - '@kontent-ai/*' verifyDepsBeforeRun: error diff --git a/src/commands/mapi/request.ts b/src/commands/mapi/request.ts index 1fb0088..3257c7a 100644 --- a/src/commands/mapi/request.ts +++ b/src/commands/mapi/request.ts @@ -128,7 +128,7 @@ const runRequest = async ( envId: args.envId, abortSignal: controller.signal, }, - { logger, client: createMapiRawClient({ token }) }, + { logger, client: createMapiRawClient({ token, logger }) }, ); process.off("SIGINT", abortRequest); @@ -163,7 +163,7 @@ const runRequest = async ( type PreparedRequest = Readonly<{ method: HttpMethod; headers: ReadonlyArray
; - body: string | Blob | null; + body: Blob | null; }>; type RequestArgsError = Readonly<{ diff --git a/src/core/mapi/request.ts b/src/core/mapi/request.ts index 3057dee..77dfb31 100644 --- a/src/core/mapi/request.ts +++ b/src/core/mapi/request.ts @@ -9,7 +9,7 @@ export type MapiRequestParams = Readonly<{ envId: string; method: HttpMethod; headers: ReadonlyArray
; - body: string | Blob | null; + body: Blob | null; abortSignal?: AbortSignal; }>; diff --git a/src/lib/mapi/raw/client.ts b/src/lib/mapi/raw/client.ts index 2575ec3..580038b 100644 --- a/src/lib/mapi/raw/client.ts +++ b/src/lib/mapi/raw/client.ts @@ -1,29 +1,29 @@ -import { setTimeout as sleep } from "node:timers/promises"; import { - AdapterAbortError, - AdapterParseError, + type AdapterPayload, type AdapterResponse, createSdkIdHeader, - getDefaultHttpAdapter, + getDefaultHttpService, type Header, type HttpAdapter, type HttpMethod, + type HttpService, type JsonValue, + type KontentSdkError, type SdkInfo, } from "@kontent-ai/core-sdk"; +import { match, P } from "ts-pattern"; // biome-ignore lint/correctness/useImportExtensions: JSON imports must keep the .json extension import pkg from "../../../../package.json" with { type: "json" }; import type { Logger } from "../../../log.js"; import { kontentManagementUrl } from "../../config/kontentUrl.js"; -import { err, isErr, type Result, tryAsync } from "../../result.js"; +import { err, ok, type Result } from "../../result.js"; const MAX_RETRY_ATTEMPTS = 3; -const DEFAULT_RETRY_DELAY_MS = 1000; -// Past this the API is rationing quota, not smoothing a burst; a one-shot command -// has no business sleeping that long, so the 429 goes back to the caller instead. +// Past this the API is rationing quota, not smoothing a burst. core-sdk clamps a +// longer `Retry-After` to the cap rather than giving up, so a rationing 429 costs +// at most MAX_RETRY_ATTEMPTS waits of this length before the status reaches the caller. const MAX_RETRY_DELAY_MS = 60_000; -const TOO_MANY_REQUESTS = 429; const mapiSdkInfo: SdkInfo = { name: pkg.name, @@ -35,161 +35,126 @@ const mapiSdkInfo: SdkInfo = { * A passthrough client for the Management API: no schema, no response * interpretation. The typed, validated counterpart is `src/lib/mapi/client.ts`. * - * core-sdk's adapter parses `application/json` and hands back a null payload for - * anything else, which is all this needs: the Management API answers JSON on + * core-sdk's http service parses `application/json` and hands back a null payload + * for anything else, which is all this needs: the Management API answers JSON on * every status, and binary only ever travels request-side, on an asset upload. */ export type MapiRawClient = Readonly<{ baseUrl: string; - // Absent when the caller carries its own Authorization header; the client then adds none. - token?: string | undefined; - adapter: HttpAdapter; - sdkInfo: SdkInfo; + // The headers the service adds to every request; kept for the verbose trace, + // which has to show what goes on the wire, not just what the caller passed. + requestHeaders: ReadonlyArray
; + httpService: HttpService; }>; export type RawRequest = Readonly<{ url: URL; method: HttpMethod; headers: ReadonlyArray
; - body: string | Blob | null; + body: Blob | null; abortSignal?: AbortSignal; }>; +/** What came off the wire, whatever the status says about it. */ +export type RawResponse = Readonly<{ + status: number; + statusText: string; + responseHeaders: ReadonlyArray
; + payload: JsonValue; +}>; + export const createMapiRawClient = ( - params: Readonly<{ token?: string | undefined; baseUrl?: string; adapter?: HttpAdapter }>, -): MapiRawClient => ({ - baseUrl: params.baseUrl ?? kontentManagementUrl(), - token: params.token, - adapter: params.adapter ?? getDefaultHttpAdapter(), - sdkInfo: mapiSdkInfo, -}); + params: Readonly<{ + logger: Logger; + // Absent when the caller carries its own Authorization header; the client then adds none. + token?: string | undefined; + baseUrl?: string; + adapter?: HttpAdapter; + }>, +): MapiRawClient => { + const requestHeaders = + params.token === undefined + ? [createSdkIdHeader(mapiSdkInfo)] + : [ + createSdkIdHeader(mapiSdkInfo), + { name: "Authorization", value: `Bearer ${params.token}` }, + ]; + + return { + baseUrl: params.baseUrl ?? kontentManagementUrl(), + requestHeaders, + httpService: getDefaultHttpService({ + requestHeaders, + ...(params.adapter === undefined ? {} : { adapter: params.adapter }), + retryStrategy: { + maxRetries: MAX_RETRY_ATTEMPTS, + maxRetryDelayMs: MAX_RETRY_DELAY_MS, + // 429 is the only status core-sdk retries, and leaving `canRetryAdapterError` + // at its default keeps it that way - so a non-idempotent call is never sent twice. + logRetryAttempt: (retryAttempt, _url, retryInMs) => + params.logger.warning( + "standard", + `Rate limited (429). Retrying in ${retryInMs} ms (attempt ${retryAttempt}/${MAX_RETRY_ATTEMPTS}).`, + ), + }, + }), + }; +}; /** * Sends the request and hands back whatever came off the wire. A 4xx/5xx is a * result, not an error - only a request that could not be made at all fails. - * Retries 429 (which means the request was rejected, never executed) and nothing - * else, so a non-idempotent call is never sent twice. */ export const executeRawRequest = async ( client: MapiRawClient, request: RawRequest, logger: Logger, -): Promise, string>> => { - const executeRequest = client.adapter.executeRequest; - if (executeRequest === undefined) { - return err("The configured HTTP adapter cannot execute requests."); +): Promise> => { + logger.info("verbose", formatTrace(request, [...client.requestHeaders, ...request.headers])); + + const response = await client.httpService.request({ + url: request.url, + method: request.method, + body: request.body, + requestHeaders: request.headers, + ...(request.abortSignal === undefined ? {} : { abortSignal: request.abortSignal }), + }); + + if (response.success) { + return ok(toRawResponse(response.response.adapterResponse)); } - - const requestHeaders = mergeHeaders( - client.token === undefined - ? [createSdkIdHeader(client.sdkInfo)] - : [ - createSdkIdHeader(client.sdkInfo), - { name: "Authorization", value: `Bearer ${client.token}` }, - ], - request.headers, - ); - logger.info("verbose", formatTrace(request, requestHeaders)); - - const send = async (attempt: number): Promise, string>> => { - const response = await tryAsync( - async () => - executeRequest({ - url: request.url, - method: request.method, - body: request.body, - requestHeaders, - abortSignal: request.abortSignal, - }), - describeTransportError, - ); - - if (isErr(response) || response.value.status !== TOO_MANY_REQUESTS) { - return response; - } - - if (attempt >= MAX_RETRY_ATTEMPTS) { - return response; - } - - const delayMs = retryAfterMs(response.value.responseHeaders); - if (delayMs > MAX_RETRY_DELAY_MS) { - logger.warning( - "standard", - `Rate limited (429). The API asked for ${Math.round(delayMs / 1000)} s, beyond the ${ - MAX_RETRY_DELAY_MS / 1000 - } s retry limit - not retrying.`, - ); - return response; - } - - logger.warning( - "standard", - `Rate limited (429). Retrying in ${delayMs} ms (attempt ${attempt + 1}/${MAX_RETRY_ATTEMPTS}).`, - ); - - // A bare setTimeout would ignore Ctrl+C: the command installs a SIGINT handler, - // which suppresses the default kill, so an unabortable sleep swallows the signal. - const waited = await tryAsync( - async () => await sleep(delayMs, undefined, { signal: request.abortSignal }), - () => "The request was aborted.", - ); - if (isErr(waited)) { - return waited; - } - - return await send(attempt + 1); - }; - - return await send(0); + return fromSdkError(response.error); }; /** - * `Retry-After` comes in two legal forms: delta-seconds or an HTTP-date. Both are - * honored; anything absent or unparseable falls back to a second rather than - * retrying immediately. The clamp matters because a date already in the past - a - * slow hop, a skewed clock - would otherwise produce a negative delay. + * core-sdk reports every non-2xx as an error; for this command most of them are + * the answer. Only the reasons that mean no answer arrived stay errors. */ -export const retryAfterMs = (headers: ReadonlyArray
): number => { - const raw = headers.find((header) => header.name.toLowerCase() === "retry-after")?.value.trim(); - if (raw === undefined) { - return DEFAULT_RETRY_DELAY_MS; - } - - if (deltaSecondsPattern.test(raw)) { - return Number(raw) * 1000; - } - - // Date.parse is lenient enough to read "-5" and "1.5" as years, which would turn - // a malformed delay into a past date and so into an immediate retry. Every legal - // HTTP-date carries a weekday and month name, so require a letter before trying. - if (!/[a-z]/i.test(raw)) { - return DEFAULT_RETRY_DELAY_MS; - } - - const dateMs = Date.parse(raw); - if (Number.isNaN(dateMs)) { - return DEFAULT_RETRY_DELAY_MS; - } - return Math.max(0, dateMs - Date.now()); -}; - -// RFC 9110 delta-seconds: 1*DIGIT. Number() would also swallow "", "1e3" and "0x10". -const deltaSecondsPattern = /^\d+$/; - -// Names are canonicalized to lowercase - what fetch (and HTTP/2) put on the wire -// anyway - so the merged set is deterministic regardless of the caller's casing. -const mergeHeaders = ( - base: ReadonlyArray
, - overrides: ReadonlyArray
, -): ReadonlyArray
=> [ - ...[...base, ...overrides] - .reduce((merged, header) => { - const name = header.name.toLowerCase(); - return merged.set(name, { name, value: header.value }); - }, new Map()) - .values(), -]; +const fromSdkError = (error: KontentSdkError): Result => + match(error.details) + .returnType>() + .with( + { reason: P.union("unauthorized", "notFound", "invalidResponse") }, + ({ adapterResponse }) => + adapterResponse === undefined ? err(error.message) : ok(toRawResponse(adapterResponse)), + ) + .with({ reason: "aborted" }, () => err("The request was aborted.")) + .with({ reason: "parseError" }, () => err("The response could not be parsed as JSON.")) + // core-sdk's own message only points at the wrapped error; the cause is what the user can act on. + .with({ reason: "adapterError" }, ({ originalError }) => err(describeCause(originalError))) + .otherwise(() => err(error.message)); + +const describeCause = (cause: unknown): string => + cause instanceof Error ? cause.message : String(cause); + +// A Blob payload is unreachable here - only `downloadFile` produces one, and it +// widens the shared response type - but narrowing beats asserting it away. +const toRawResponse = (response: AdapterResponse): RawResponse => ({ + status: response.status, + statusText: response.statusText, + responseHeaders: response.responseHeaders, + payload: response.payload instanceof Blob ? null : response.payload, +}); const formatTrace = (request: RawRequest, headers: ReadonlyArray
): string => { const headerLines = headers.map( @@ -198,16 +163,3 @@ const formatTrace = (request: RawRequest, headers: ReadonlyArray
): strin ); return [`${request.method} ${request.url.toString()}`, ...headerLines].join("\n"); }; - -const describeTransportError = (cause: unknown): string => { - if (cause instanceof AdapterAbortError) { - return "The request was aborted."; - } - if (cause instanceof AdapterParseError) { - return "The response could not be parsed as JSON."; - } - if (cause instanceof Error) { - return cause.message; - } - return String(cause); -}; diff --git a/test/integration/mapi.test.ts b/test/integration/mapi.test.ts index 63c5768..7e72d4f 100644 --- a/test/integration/mapi.test.ts +++ b/test/integration/mapi.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { type MapiRequestParams, performRawMapiRequest } from "../../src/core/mapi/request.js"; import { createMapiRawClient } from "../../src/lib/mapi/raw/client.js"; import { createLogger } from "../../src/log.js"; @@ -7,6 +7,8 @@ import { type MapiRoute, mapiTestAdapter } from "../helpers/mapiTestAdapter.js"; const ENV_ID = "11111111-2222-3333-4444-555555555555"; const BASE_URL = "https://manage.test/v2"; +// Mirrors the cap the client configures on core-sdk's retry strategy. +const MAX_RETRY_DELAY_MS = 60_000; const logger = createLogger("none"); @@ -31,6 +33,7 @@ const run = async (routes: ReadonlyArray, options: RunOptions = {}) = token: "token" in options ? options.token : "secret-token", baseUrl: BASE_URL, adapter, + logger, }); const result = await performRawMapiRequest(makeParams(options.params), { logger, client }); return { result, requests }; @@ -53,10 +56,10 @@ describe("performRawMapiRequest", () => { expect(requests).toHaveLength(1); expect(requests[0]?.url.toString()).toBe(`${BASE_URL}/projects/${ENV_ID}/types`); expect(requests[0]?.requestHeaders).toContainEqual({ - name: "authorization", + name: "Authorization", value: "Bearer secret-token", }); - expect(requests[0]?.requestHeaders?.map((header) => header.name)).toContain("x-kc-sdkid"); + expect(requests[0]?.requestHeaders?.map((header) => header.name)).toContain("X-KC-SDKID"); }); it("sends one header per name, the last occurrence winning", async () => { @@ -70,7 +73,7 @@ describe("performRawMapiRequest", () => { }); const contentTypes = (requests[0]?.requestHeaders ?? []).filter( - (header) => header.name === "content-type", + (header) => header.name.toLowerCase() === "content-type", ); expect(contentTypes.map((header) => header.value)).toEqual(["text/plain"]); }); @@ -82,7 +85,7 @@ describe("performRawMapiRequest", () => { }); const authorizations = (requests[0]?.requestHeaders ?? []).filter( - (header) => header.name === "authorization", + (header) => header.name.toLowerCase() === "authorization", ); expect(authorizations.map((header) => header.value)).toEqual(["Bearer caller-token"]); }); @@ -93,12 +96,14 @@ describe("performRawMapiRequest", () => { { params: { method: "POST", - body: '{"codename":"x"}', + body: new Blob(['{"codename":"x"}']), }, }, ); - expect(requests[0]?.body).toBe('{"codename":"x"}'); + const sentBody = requests[0]?.body; + expect(sentBody).toBeInstanceOf(Blob); + await expect((sentBody as Blob).text()).resolves.toBe('{"codename":"x"}'); }); it("reports a 4xx as a successful transport with the API payload", async () => { @@ -164,25 +169,43 @@ describe("performRawMapiRequest", () => { expect(result.value.statusCode).toBe(429); }); - it("does not retry when Retry-After asks for longer than the retry limit", async () => { - const { result, requests } = await run([ - { - method: "GET", - path: /\/types$/, - replies: [ - { - status: 429, - statusText: "Too Many Requests", - headers: [{ name: "Retry-After", value: "3600" }], - }, - { payload: { types: [] } }, - ], - }, - ]); + it("clamps a Retry-After that asks for longer than the retry limit", async () => { + vi.useFakeTimers(); + try { + const { adapter, requests } = mapiTestAdapter([ + { + method: "GET", + path: /\/types$/, + replies: [ + { + status: 429, + statusText: "Too Many Requests", + headers: [{ name: "Retry-After", value: "3600" }], + }, + { payload: { types: [] } }, + ], + }, + ]); + const client = createMapiRawClient({ + token: "secret-token", + baseUrl: BASE_URL, + adapter, + logger, + }); + const pending = performRawMapiRequest(makeParams(), { logger, client }); - expect(requests).toHaveLength(1); - assertOk(result); - expect(result.value.statusCode).toBe(429); + // The API asked for an hour; the wait ends at the cap, not at what it asked for. + await vi.advanceTimersByTimeAsync(MAX_RETRY_DELAY_MS - 1); + expect(requests).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + + const result = await pending; + expect(requests).toHaveLength(2); + assertOk(result); + expect(result.value.statusCode).toBe(200); + } finally { + vi.useRealTimers(); + } }); it("abandons the backoff when the request is aborted mid-wait", async () => { @@ -224,7 +247,7 @@ describe("performRawMapiRequest", () => { replies: [{ status: 503, statusText: "Service Unavailable" }, { status: 201 }], }, ], - { params: { method: "POST", body: "{}" } }, + { params: { method: "POST", body: new Blob(["{}"]) } }, ); expect(requests).toHaveLength(1); diff --git a/test/unit/formatIapiError.test.ts b/test/unit/formatIapiError.test.ts index 72ccda1..33cce55 100644 --- a/test/unit/formatIapiError.test.ts +++ b/test/unit/formatIapiError.test.ts @@ -24,6 +24,7 @@ const httpError = ( statusText, responseHeaders: [{ name: NOISE_HEADER, value: "cache-vie6340-VIE" }], kontentErrorResponse, + adapterResponse: undefined, }, }); diff --git a/test/unit/retryAfter.test.ts b/test/unit/retryAfter.test.ts deleted file mode 100644 index 24d6dda..0000000 --- a/test/unit/retryAfter.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { retryAfterMs } from "../../src/lib/mapi/raw/client.js"; - -describe("retryAfterMs", () => { - it("defaults to one second without a Retry-After header", () => { - expect(retryAfterMs([])).toBe(1000); - }); - - it("converts a delay in seconds to milliseconds", () => { - expect(retryAfterMs([{ name: "Retry-After", value: "2" }])).toBe(2000); - }); - - it("matches the header name case-insensitively", () => { - expect(retryAfterMs([{ name: "retry-after", value: "3" }])).toBe(3000); - }); - - it.each([ - ["garbage", "not a number or a date"], - ["", "an empty value - Number() would read it as 0 and retry immediately"], - [" ", "a blank value"], - ["-5", "a negative delay - Date.parse would read it as the year 2001"], - ["1.5", "a fractional delay - Date.parse would read it as January 2001"], - ["1e3", "exponent notation, which delta-seconds does not allow"], - ["0x10", "hex notation, which delta-seconds does not allow"], - ])("falls back to the default for %j (%s)", (value) => { - expect(retryAfterMs([{ name: "Retry-After", value }])).toBe(1000); - }); - - it("reads a zero delay as an immediate retry", () => { - expect(retryAfterMs([{ name: "Retry-After", value: "0" }])).toBe(0); - }); - - it("passes a delay beyond the retry limit through unclamped, for the caller to reject", () => { - expect(retryAfterMs([{ name: "Retry-After", value: "3600" }])).toBe(3_600_000); - }); - - describe("with an HTTP-date value", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("waits until the given moment", () => { - expect(retryAfterMs([{ name: "Retry-After", value: "Thu, 01 Jan 2026 00:00:05 GMT" }])).toBe( - 5000, - ); - }); - - it("clamps a moment already in the past to zero", () => { - expect(retryAfterMs([{ name: "Retry-After", value: "Wed, 31 Dec 2025 23:59:00 GMT" }])).toBe( - 0, - ); - }); - }); -}); From df80c4c2cf770b05da5d1bc1c0d8ab19e888308d Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Thu, 3 Sep 2026 10:28:55 +0200 Subject: [PATCH 21/25] fix: survive a closed stdout pipe, stream --input instead of buffering Claude-Session: https://claude.ai/code/session_01XicMCTPtBUFEBrGAhkypyQ --- CLAUDE.md | 2 ++ src/commands/mapi/request.ts | 10 ++---- src/core/mapi/request.ts | 2 ++ src/index.ts | 21 ++++++++++++ test/integration/mapi.test.ts | 7 +++- test/integration/mapiCommand.test.ts | 45 ++++++++++++++++++++++++- test/integration/stdout.test.ts | 50 ++++++++++++++++++++++++++++ 7 files changed, 127 insertions(+), 10 deletions(-) create mode 100644 test/integration/stdout.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 68b6118..c21c6f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,8 @@ Adding a command: export a `register: RegisterCommand` (see `src/commands/login/ - **stdout** — the data the command exists to produce, and nothing else. It is never level-gated: `--logLevel none` must still print a payload, because a response body is not a log. - **stderr** — everything said *about* producing it: progress, warnings, errors, verbose traces. This is the POSIX meaning of stderr (diagnostics, not errors), and how curl, git and npm behave. +A reader closing the pipe early (`| head`) is that reader exiting normally, not a write failure: `src/index.ts` swallows `EPIPE` on both streams so it never becomes a stack trace, and leaves `process.exitCode` to the command. + A handler that logs starts with `const logger = createLoggerFromArgs(args)` (`src/log.ts`) and passes that `Logger` down; one that only emits a payload takes no logger at all (`src/commands/telemetry/status.ts`). Core takes the logger as a parameter or inside its `deps` object; `createLoggerFromArgs` is the only place that resolves the `--logLevel`/`--verbose` pair; everything else builds a logger from a single `LogLevel` via `createLogger`. The `sink` parameter is a test seam, not a routing knob — never point a log at stdout. diff --git a/src/commands/mapi/request.ts b/src/commands/mapi/request.ts index 3257c7a..b15bae0 100644 --- a/src/commands/mapi/request.ts +++ b/src/commands/mapi/request.ts @@ -1,4 +1,4 @@ -import { readFile } from "node:fs/promises"; +import { openAsBlob } from "node:fs"; import type { Header, HttpMethod } from "@kontent-ai/core-sdk"; import { match } from "ts-pattern"; import { type MapiResponse, performRawMapiRequest } from "../../core/mapi/request.js"; @@ -117,20 +117,14 @@ const runRequest = async ( } const { token, source } = credential.value; - const controller = new AbortController(); - const abortRequest = () => controller.abort(); - process.once("SIGINT", abortRequest); - const result = await performRawMapiRequest( { ...prepared.value, endpoint: args.endpoint, envId: args.envId, - abortSignal: controller.signal, }, { logger, client: createMapiRawClient({ token, logger }) }, ); - process.off("SIGINT", abortRequest); if (isErr(result)) { tracker.fail(result.error.kind, { "auth-source": source }); @@ -215,7 +209,7 @@ const prepareRequest = async ( const readInput = async (input: string): Promise> => { if (input !== "-") { return await tryAsync( - async () => new Blob([await readFile(input)]), + async () => await openAsBlob(input), (cause) => ({ kind: "unreadable-input" as const, message: `Failed to read "${input}": ${describeCause(cause)}`, diff --git a/src/core/mapi/request.ts b/src/core/mapi/request.ts index 77dfb31..37d5262 100644 --- a/src/core/mapi/request.ts +++ b/src/core/mapi/request.ts @@ -10,6 +10,8 @@ export type MapiRequestParams = Readonly<{ method: HttpMethod; headers: ReadonlyArray
; body: Blob | null; + // No production caller: the telemetry SIGINT handler exits before an abort could + // land. Kept for the abort test and a handler that cooperates. abortSignal?: AbortSignal; }>; diff --git a/src/index.ts b/src/index.ts index 1b50a9a..0a3ce5b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,27 @@ import { import { addLogLevelOptions, createLoggerFromArgs } from "./log.js"; import type { CommandDeps } from "./types/yargs.js"; +// A reader that stops early (`... | head`) exits and takes the read end of the +// pipe with it, while we are still writing. The write then fails with EPIPE and +// process.stdout emits 'error' - and an 'error' event with no listener is thrown +// by EventEmitter, so the CLI dies with a stack trace instead of the payload. +// Attaching any listener is the fix; the code check only keeps real failures +// loud. process.exitCode stays the command's to set. +const ignoreClosedPipe = (stream: NodeJS.WriteStream): void => { + stream.on("error", (error: NodeJS.ErrnoException) => { + // Same closed pipe, one step later: once the stream is torn down the write is + // rejected rather than attempted. + if (error.code !== "EPIPE" && error.code !== "ERR_STREAM_DESTROYED") { + // Node prints the resulting uncaught exception to stderr - visible when stdout + // broke, lost when stderr is the stream that broke. + throw error; + } + }); +}; + +ignoreClosedPipe(process.stdout); +ignoreClosedPipe(process.stderr); + const emptyYargs = yargs(hideBin(process.argv)); // Deliberately no .env() prefix mapping: it turns every KONTENT_* variable in diff --git a/test/integration/mapi.test.ts b/test/integration/mapi.test.ts index 7e72d4f..6e77319 100644 --- a/test/integration/mapi.test.ts +++ b/test/integration/mapi.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it, vi } from "vitest"; +// biome-ignore lint/correctness/useImportExtensions: JSON imports must keep the .json extension +import pkg from "../../package.json" with { type: "json" }; import { type MapiRequestParams, performRawMapiRequest } from "../../src/core/mapi/request.js"; import { createMapiRawClient } from "../../src/lib/mapi/raw/client.js"; import { createLogger } from "../../src/log.js"; @@ -59,7 +61,10 @@ describe("performRawMapiRequest", () => { name: "Authorization", value: "Bearer secret-token", }); - expect(requests[0]?.requestHeaders?.map((header) => header.name)).toContain("X-KC-SDKID"); + expect(requests[0]?.requestHeaders).toContainEqual({ + name: "X-KC-SDKID", + value: `npmjs.com;${pkg.name};${pkg.version}`, + }); }); it("sends one header per name, the last occurrence winning", async () => { diff --git a/test/integration/mapiCommand.test.ts b/test/integration/mapiCommand.test.ts index ddfcaa9..9f8dec5 100644 --- a/test/integration/mapiCommand.test.ts +++ b/test/integration/mapiCommand.test.ts @@ -1,4 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import yargs from "yargs"; import { register } from "../../src/commands/mapi/request.js"; import type { MapiRequestParams } from "../../src/core/mapi/request.js"; @@ -54,6 +57,16 @@ const lastParams = (): MapiRequestParams => vi.mocked(performRawMapiRequest).mock.calls.at(-1)?.[0] as MapiRequestParams; describe("kontent mapi argument handling", () => { + let tempDir: string; + + beforeAll(async () => { + tempDir = await mkdtemp(join(tmpdir(), "kontent-mapi-")); + }); + + afterAll(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + beforeEach(() => { process.exitCode = undefined; vi.mocked(performRawMapiRequest).mockClear(); @@ -131,6 +144,36 @@ describe("kontent mapi argument handling", () => { expect(process.exitCode).toBe(1); }); + it("sends the file at --input as the request body", async () => { + const path = join(tempDir, "body.json"); + await writeFile(path, '{"name":"Article"}'); + + await runCommand(["types", "--input", path, "--envId", ENV_ID]); + + const params = lastParams(); + expect(params.method).toBe("POST"); + expect(await params.body?.text()).toBe('{"name":"Article"}'); + }); + + it("wires no abort of its own, leaving SIGINT to the telemetry handler", async () => { + await runCommand(["types", "--envId", ENV_ID]); + + expect(lastParams().abortSignal).toBeUndefined(); + }); + + it("reports an unreadable --input file without calling the API", async () => { + // Inside the suite's temp dir, so "missing" is a fact rather than a guess about /tmp. + const path = join(tempDir, "absent", "body.json"); + const captured = captureStream("stderr"); + + await runCommand(["types", "--input", path, "--envId", ENV_ID]); + captured.restore(); + + expect(captured.text()).toContain(path); + expect(process.exitCode).toBe(1); + expect(performRawMapiRequest).not.toHaveBeenCalled(); + }); + it("rejects a body on GET instead of letting the transport throw", async () => { const captured = captureStream("stderr"); diff --git a/test/integration/stdout.test.ts b/test/integration/stdout.test.ts new file mode 100644 index 0000000..31180bd --- /dev/null +++ b/test/integration/stdout.test.ts @@ -0,0 +1,50 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +// A closed pipe exists only between two processes, so this test spawns one. It +// runs the source through tsx rather than dist/, which is gitignored and not +// built by `pnpm test`: a bundle-based test would fail on CI for a missing +// artifact and pass locally against a stale one. Bundling goes untested here; +// the e2e suite covers it. +const runWithClosedStdout = ( + args: ReadonlyArray, +): Promise> => + new Promise((resolve, reject) => { + const child = spawn(process.execPath, ["--import", "tsx", entryPath, ...args], { + env: { + ...(process.env.PATH === undefined ? {} : { PATH: process.env.PATH }), + ...(process.env.HOME === undefined ? {} : { HOME: process.env.HOME }), + DO_NOT_TRACK: "1", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + // Closing the read end first: reproducing `... | head` otherwise needs a payload + // bigger than the pipe buffer, which this command's output never is. + child.stdout.destroy(); + + const stderr: Buffer[] = []; + child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)); + + child.on("error", reject); + child.on("close", (code) => { + resolve({ exitCode: code ?? -1, stderr: Buffer.concat(stderr).toString() }); + }); + }); + +const entryPath = fileURLToPath(new URL("../../src/index.ts", import.meta.url)); + +// `telemetry status` writes to stdout and talks to nothing, so a failure here is +// the pipe handling and not the network. +describe("a reader that closes the pipe early", () => { + // tsx compiles the CLI's import graph on every spawn - about a second idle, but + // CPU-bound, so a loaded machine stretches it well past the 5s default. + it("does not turn into an EPIPE crash", async () => { + const result = await runWithClosedStdout(["telemetry", "status"]); + + // The exit code is the real check - an unhandled stream error is fatal. The + // EPIPE match only names the bug in the failure message. + expect(result.stderr).not.toContain("EPIPE"); + expect(result.exitCode).toBe(0); + }, 30_000); +}); From 7abc4849bd9ccec6cd2ba70422de8e02971a3abe Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Thu, 3 Sep 2026 10:42:28 +0200 Subject: [PATCH 22/25] ci: install pnpm and Node through pnpm/setup, dropping the npmrc auth line Claude-Session: https://claude.ai/code/session_01XicMCTPtBUFEBrGAhkypyQ --- .github/workflows/ci.yml | 13 +++++-------- .github/workflows/e2e.yml | 13 +++++-------- .github/workflows/release.yml | 15 +++++---------- 3 files changed, 15 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec96931..5ab72fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,15 +12,12 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false - - name: Setup pnpm - uses: pnpm/action-setup@v5 - - name: Use Node.js from .nvmrc file - uses: actions/setup-node@v6 + - name: Setup pnpm and Node.js + uses: pnpm/setup@v2 with: - node-version-file: ".nvmrc" - cache: "pnpm" - - name: Install dependencies - run: pnpm install --frozen-lockfile + runtime: node@lts + cache: true + require-lockfile: true - name: Typecheck run: pnpm typecheck - name: ESLint diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 984468b..abc6ede 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -28,15 +28,12 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false - - name: Setup pnpm - uses: pnpm/action-setup@v5 - - name: Use Node.js from .nvmrc file - uses: actions/setup-node@v6 + - name: Setup pnpm and Node.js + uses: pnpm/setup@v2 with: - node-version-file: ".nvmrc" - cache: "pnpm" - - name: Install dependencies - run: pnpm install --frozen-lockfile + runtime: node@lts + cache: true + require-lockfile: true - name: E2E tests # The runner context is unavailable in job-level env, so the file path # is set per step. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 030f15d..2557fa0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,17 +16,12 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false - - name: Setup pnpm - uses: pnpm/action-setup@v5 - - name: Use Node.js from .nvmrc file - uses: actions/setup-node@v6 + - name: Setup pnpm and Node.js + uses: pnpm/setup@v2 with: - node-version-file: ".nvmrc" - registry-url: "https://registry.npmjs.org" - cache: "pnpm" - - - name: Install dependencies - run: pnpm install --frozen-lockfile + runtime: node@lts + cache: true + require-lockfile: true - name: Verify tag matches package.json version run: | From dbb370b7fcbeb159ceee919252704c3588e15e8b Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Thu, 3 Sep 2026 13:42:05 +0200 Subject: [PATCH 23/25] fix: read --version from package.json instead of yargs' lookup yargs resolves `--version` by walking up from its own location to the nearest package.json, which in a bundled install is not ours. Pass the same `cliVersion` telemetry already reads, so the flag and the reported version cannot drift. Also ignore `*.tgz`, the output of `pnpm pack`. Claude-Session: https://claude.ai/code/session_01EFdF6byTgMidaELCkpcRLA --- .gitignore | 3 +++ src/index.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 823f7af..869d6a3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ my-kickstart/ # Claude Code local state; plans are carried into worktrees via .worktreeinclude .claude/worktrees/ .claude/plans/ + +# pnpm pack output +*.tgz diff --git a/src/index.ts b/src/index.ts index 0a3ce5b..b76f339 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { hideBin } from "yargs/helpers"; import { commandsToRegister } from "./commands/registry.js"; import { getKontentBaseDomain, validateKontentDomain } from "./lib/config/kontentUrl.js"; import { isErr } from "./lib/result.js"; +import { cliVersion } from "./lib/telemetry/context.js"; import { createTelemetry, formatTelemetryMode, @@ -51,6 +52,7 @@ const initialYargs = emptyYargs .config("configFile", "Path to a JSON file with CLI parameters.") .help("h") .alias("h", "help") + .version(cliVersion) .alias("v", "version"); const withLogLevel = addLogLevelOptions(initialYargs); From 2ad3cc4ad0e21a99756ac840464077fe51c51948 Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Thu, 3 Sep 2026 14:01:30 +0200 Subject: [PATCH 24/25] fix: report why a mapi request failed, guard the env id Claude-Session: https://claude.ai/code/session_01EFdF6byTgMidaELCkpcRLA --- CLAUDE.md | 2 +- src/commands/mapi/request.ts | 47 ++++++++++++++++++++------ src/lib/auth/formatAuthError.ts | 27 +++++---------- src/lib/error.ts | 38 +++++++++++++++++++-- src/lib/mapi/raw/client.ts | 6 ++-- src/lib/mapi/raw/endpoint.ts | 7 ++++ test/integration/mapiCommand.test.ts | 40 +++++++++++++++++++++- test/unit/endpoint.test.ts | 12 +++++++ test/unit/error.test.ts | 50 ++++++++++++++++++++++++++++ 9 files changed, 192 insertions(+), 37 deletions(-) create mode 100644 test/unit/error.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index c21c6f6..14112c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ Run and pass these (same gate as CI, in order): pnpm typecheck && pnpm lint && pnpm biome:check && pnpm test ``` -Autofix is available: `pnpm lint:fix`, `pnpm biome:fix`. Build with `pnpm build` (tsdown). Node version is `.nvmrc` (`lts/*`). Always use pnpm, never npm/yarn. +Autofix is available: `pnpm lint:fix`, `pnpm biome:fix`. Build with `pnpm build` (tsdown). Node is `lts` — CI pins it through `pnpm/setup`'s `runtime:` input in each workflow, `.nvmrc` covers local `nvm use`; keep the two in step. Always use pnpm, never npm/yarn. ## Architecture diff --git a/src/commands/mapi/request.ts b/src/commands/mapi/request.ts index b15bae0..167bfa0 100644 --- a/src/commands/mapi/request.ts +++ b/src/commands/mapi/request.ts @@ -1,9 +1,11 @@ import { openAsBlob } from "node:fs"; +import { open, stat } from "node:fs/promises"; import type { Header, HttpMethod } from "@kontent-ai/core-sdk"; import { match } from "ts-pattern"; import { type MapiResponse, performRawMapiRequest } from "../../core/mapi/request.js"; import { formatAuthError } from "../../lib/auth/formatAuthError.js"; import { type AuthSource, resolveMapiCredential } from "../../lib/auth/mapiCredential.js"; +import { errorMessage } from "../../lib/error.js"; import { createMapiRawClient } from "../../lib/mapi/raw/client.js"; import { parseHeaders } from "../../lib/mapi/raw/headers.js"; import { parseMethod } from "../../lib/mapi/raw/method.js"; @@ -208,13 +210,7 @@ const prepareRequest = async ( const readInput = async (input: string): Promise> => { if (input !== "-") { - return await tryAsync( - async () => await openAsBlob(input), - (cause) => ({ - kind: "unreadable-input" as const, - message: `Failed to read "${input}": ${describeCause(cause)}`, - }), - ); + return await readFileInput(input); } // Without this guard the command would wait forever for input nobody is piping. @@ -229,11 +225,43 @@ const readInput = async (input: string): Promise> async () => new Blob([await readStdin()]), (cause) => ({ kind: "unreadable-input" as const, - message: `Failed to read stdin: ${describeCause(cause)}`, + message: `Failed to read stdin: ${errorMessage(cause)}`, }), ); }; +// openAsBlob only stats the path: a directory or an unreadable file succeeds here +// and fails only once the body is read, after the request has gone out. A missing +// path throws, but as a bare "Unable to open file as blob" with no errno. +// stat goes first because it never blocks; open on a FIFO with no writer would. +const readFileInput = async (input: string): Promise> => { + const unreadable = (cause: unknown): RequestArgsError => ({ + kind: "unreadable-input", + message: `Failed to read "${input}": ${errorMessage(cause)}`, + }); + + const stats = await tryAsync(async () => await stat(input), unreadable); + if (isErr(stats)) { + return stats; + } + + if (!stats.value.isFile()) { + return err({ + kind: "unreadable-input", + message: `Failed to read "${input}": not a file.`, + }); + } + + const readable = await tryAsync(async () => { + await (await open(input, "r")).close(); + }, unreadable); + if (isErr(readable)) { + return readable; + } + + return await tryAsync(async () => await openAsBlob(input), unreadable); +}; + const readStdin = async (): Promise => { const chunks: Buffer[] = []; for await (const chunk of process.stdin) { @@ -257,6 +285,3 @@ const formatFailure = (response: MapiResponse, source: AuthSource): string => { return `${summary}\n${hint}`; }; - -const describeCause = (cause: unknown): string => - cause instanceof Error ? cause.message : String(cause); diff --git a/src/lib/auth/formatAuthError.ts b/src/lib/auth/formatAuthError.ts index 6437ee2..4526fca 100644 --- a/src/lib/auth/formatAuthError.ts +++ b/src/lib/auth/formatAuthError.ts @@ -1,5 +1,6 @@ import { match } from "ts-pattern"; +import { errorMessage } from "../error.js"; import type { AuthError } from "./types.js"; export const formatAuthError = (error: AuthError): string => @@ -9,43 +10,33 @@ export const formatAuthError = (error: AuthError): string => .with({ kind: "expired-token" }, () => "device flow expired") .with( { kind: "discovery-failed" }, - ({ cause }) => `failed to discover Auth0 issuer: ${describeCause(cause)}`, + ({ cause }) => `failed to discover Auth0 issuer: ${errorMessage(cause)}`, ) .with( { kind: "device-auth-failed" }, - ({ cause }) => `failed to start device authorization: ${describeCause(cause)}`, + ({ cause }) => `failed to start device authorization: ${errorMessage(cause)}`, ) .with( { kind: "poll-failed" }, ({ code, description }) => `device flow failed (${code})${description ? `: ${description}` : ""}`, ) - .with( - { kind: "refresh-failed" }, - ({ cause }) => `token refresh failed: ${describeCause(cause)}`, - ) + .with({ kind: "refresh-failed" }, ({ cause }) => `token refresh failed: ${errorMessage(cause)}`) .with( { kind: "refresh-rejected" }, - ({ cause }) => `refresh token rejected: ${describeCause(cause)}`, + ({ cause }) => `refresh token rejected: ${errorMessage(cause)}`, ) .with( { kind: "storage-read-failed" }, - ({ cause }) => `failed to read stored tokens: ${describeCause(cause)}`, + ({ cause }) => `failed to read stored tokens: ${errorMessage(cause)}`, ) .with( { kind: "storage-write-failed" }, - ({ cause }) => `failed to write stored tokens: ${describeCause(cause)}`, + ({ cause }) => `failed to write stored tokens: ${errorMessage(cause)}`, ) .with( { kind: "storage-clear-failed" }, - ({ cause }) => `failed to clear stored tokens: ${describeCause(cause)}`, + ({ cause }) => `failed to clear stored tokens: ${errorMessage(cause)}`, ) - .with({ kind: "unknown" }, ({ cause }) => `unexpected error: ${describeCause(cause)}`) + .with({ kind: "unknown" }, ({ cause }) => `unexpected error: ${errorMessage(cause)}`) .exhaustive(); - -const describeCause = (cause: unknown): string => { - if (cause instanceof Error) { - return cause.message; - } - return String(cause); -}; diff --git a/src/lib/error.ts b/src/lib/error.ts index 7e6cb17..5a47f65 100644 --- a/src/lib/error.ts +++ b/src/lib/error.ts @@ -1,7 +1,6 @@ import { SharedModels } from "@kontent-ai/management-sdk"; -export const errorMessage = (cause: unknown): string => - cause instanceof Error ? cause.message : String(cause); +export const errorMessage = (cause: unknown): string => describeChain(cause, new Set()); export const mapiErrorMessage = (cause: unknown): string => { if (!(cause instanceof SharedModels.ContentManagementBaseKontentError)) { @@ -21,6 +20,41 @@ export const mapiErrorMessage = (cause: unknown): string => { ); }; +// undici reports every transport failure as a bare "fetch failed" and puts the +// reason (ENOTFOUND, ECONNREFUSED, a TLS failure) in `cause`, so the message the +// user can act on is always one or more links down the chain. +const describeChain = (cause: unknown, seen: ReadonlySet): string => { + if (!(cause instanceof Error)) { + return String(cause); + } + + // A cause chain may loop back on itself; report the message and stop. + if (seen.has(cause)) { + return cause.message; + } + + return joinParts([cause.message, reasonOf(cause, new Set([...seen, cause]))]); +}; + +// A refused connection arrives as an AggregateError with an empty message and its +// reasons - one per address tried - in `errors` rather than in `cause`. +const reasonOf = (error: Error, seen: ReadonlySet): string => { + if (error instanceof AggregateError) { + return unique(error.errors.map((nested: unknown) => describeChain(nested, seen))).join(", "); + } + + if (error.cause === undefined || error.cause === null) { + return ""; + } + + return describeChain(error.cause, seen); +}; + +const unique = (parts: ReadonlyArray): ReadonlyArray => [...new Set(parts)]; + +const joinParts = (parts: ReadonlyArray): string => + parts.filter((part) => part !== "").join(": "); + const requestInfo = ( originalError: unknown, ): { method?: string; url?: string; status?: number } => { diff --git a/src/lib/mapi/raw/client.ts b/src/lib/mapi/raw/client.ts index 580038b..3aaa46e 100644 --- a/src/lib/mapi/raw/client.ts +++ b/src/lib/mapi/raw/client.ts @@ -17,6 +17,7 @@ import { match, P } from "ts-pattern"; import pkg from "../../../../package.json" with { type: "json" }; import type { Logger } from "../../../log.js"; import { kontentManagementUrl } from "../../config/kontentUrl.js"; +import { errorMessage } from "../../error.js"; import { err, ok, type Result } from "../../result.js"; const MAX_RETRY_ATTEMPTS = 3; @@ -141,12 +142,9 @@ const fromSdkError = (error: KontentSdkError): Result => .with({ reason: "aborted" }, () => err("The request was aborted.")) .with({ reason: "parseError" }, () => err("The response could not be parsed as JSON.")) // core-sdk's own message only points at the wrapped error; the cause is what the user can act on. - .with({ reason: "adapterError" }, ({ originalError }) => err(describeCause(originalError))) + .with({ reason: "adapterError" }, ({ originalError }) => err(errorMessage(originalError))) .otherwise(() => err(error.message)); -const describeCause = (cause: unknown): string => - cause instanceof Error ? cause.message : String(cause); - // A Blob payload is unreachable here - only `downloadFile` produces one, and it // widens the shared response type - but narrowing beats asserting it away. const toRawResponse = (response: AdapterResponse): RawResponse => ({ diff --git a/src/lib/mapi/raw/endpoint.ts b/src/lib/mapi/raw/endpoint.ts index 7afcc17..edbf251 100644 --- a/src/lib/mapi/raw/endpoint.ts +++ b/src/lib/mapi/raw/endpoint.ts @@ -38,6 +38,13 @@ export const resolveEndpoint = ( }); } + if (hasTraversal(params.envId)) { + return err({ + kind: "traversal", + message: `The environment id "${params.envId}" must not contain ".." path segments.`, + }); + } + const encodedEnvId = encodeURIComponent(params.envId); const withEnvId = relative.replaceAll("{environment_id}", encodedEnvId); const path = withEnvId.startsWith("projects/") diff --git a/test/integration/mapiCommand.test.ts b/test/integration/mapiCommand.test.ts index 9f8dec5..23fdc0e 100644 --- a/test/integration/mapiCommand.test.ts +++ b/test/integration/mapiCommand.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -170,6 +170,44 @@ describe("kontent mapi argument handling", () => { captured.restore(); expect(captured.text()).toContain(path); + // The reason, not just the path: openAsBlob alone reports every unopenable + // file as "Unable to open file as blob", losing the errno. + expect(captured.text()).toContain("ENOENT"); + expect(process.exitCode).toBe(1); + expect(performRawMapiRequest).not.toHaveBeenCalled(); + }); + + // openAsBlob accepts an unreadable file and fails only once the body is read, so + // the probe has to open it. Root ignores the mode bits and would pass either way. + it.skipIf(process.getuid?.() === 0)( + "rejects an unreadable --input file before the request goes out", + async () => { + const path = join(tempDir, "noperm.json"); + await writeFile(path, "{}"); + await chmod(path, 0o000); + const captured = captureStream("stderr"); + + try { + await runCommand(["types", "--input", path, "--envId", ENV_ID]); + } finally { + captured.restore(); + // Back to readable so the suite's rm of the temp dir cannot trip on it. + await chmod(path, 0o644); + } + + expect(captured.text()).toContain("EACCES"); + expect(process.exitCode).toBe(1); + expect(performRawMapiRequest).not.toHaveBeenCalled(); + }, + ); + + it("rejects a directory at --input before the request goes out", async () => { + const captured = captureStream("stderr"); + + await runCommand(["types", "--input", tempDir, "--envId", ENV_ID]); + captured.restore(); + + expect(captured.text()).toContain("not a file"); expect(process.exitCode).toBe(1); expect(performRawMapiRequest).not.toHaveBeenCalled(); }); diff --git a/test/unit/endpoint.test.ts b/test/unit/endpoint.test.ts index 2db10a2..a4f2c06 100644 --- a/test/unit/endpoint.test.ts +++ b/test/unit/endpoint.test.ts @@ -84,4 +84,16 @@ describe("resolveEndpoint", () => { } expect(result.error.kind).toBe(kind); }); + + // The id is substituted after the path is checked, so it needs its own guard: + // "projects/../types" normalizes to "/v2/types", out of the environment scope. + it("rejects a traversal in the environment id", () => { + const result = resolveEndpoint("types", { ...params, envId: ".." }); + + expect(result.kind).toBe("err"); + if (result.kind !== "err") { + return; + } + expect(result.error.kind).toBe("traversal"); + }); }); diff --git a/test/unit/error.test.ts b/test/unit/error.test.ts new file mode 100644 index 0000000..08cc918 --- /dev/null +++ b/test/unit/error.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { errorMessage } from "../../src/lib/error.js"; + +describe("errorMessage", () => { + it("returns the message of a lone error", () => { + expect(errorMessage(new Error("fetch failed"))).toBe("fetch failed"); + }); + + it("stringifies a non-error", () => { + expect(errorMessage("boom")).toBe("boom"); + expect(errorMessage(undefined)).toBe("undefined"); + }); + + it("omits the separator for an error that wraps nothing", () => { + expect(errorMessage(new Error("fetch failed", { cause: undefined }))).toBe("fetch failed"); + }); + + // undici hides the actionable reason one level down: "fetch failed" alone + // tells the user nothing about DNS, a refused connection or a TLS failure. + it("appends the causes a transport error wraps", () => { + const cause = new Error("fetch failed", { + cause: new Error("getaddrinfo ENOTFOUND manage.kontent.ai"), + }); + + expect(errorMessage(cause)).toBe("fetch failed: getaddrinfo ENOTFOUND manage.kontent.ai"); + }); + + // A refused connection arrives as an AggregateError with an empty message, so + // following `cause` alone leaves the user with a bare "fetch failed: ". + it("appends the reasons an AggregateError cause collects", () => { + const cause = new Error("fetch failed", { + cause: new AggregateError([ + new Error("connect ECONNREFUSED ::1:45999"), + new Error("connect ECONNREFUSED 127.0.0.1:45999"), + ]), + }); + + expect(errorMessage(cause)).toBe( + "fetch failed: connect ECONNREFUSED ::1:45999, connect ECONNREFUSED 127.0.0.1:45999", + ); + }); + + it("stops on a cause chain that loops back on itself", () => { + const inner = new Error("inner"); + const outer = new Error("outer", { cause: inner }); + inner.cause = outer; + + expect(errorMessage(outer)).toBe("outer: inner: outer"); + }); +}); From ce4c3580e2e44b25669c568e023831cf1df1c26e Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Fri, 4 Sep 2026 10:15:42 +0200 Subject: [PATCH 25/25] fix: accept a pipe or /dev/stdin at --input, re-raise signals so a blocked open can die --input now opens the path once and branches on the handle: a regular file still goes to openAsBlob so it streams at send time, anything else is drained into memory, a directory or a terminal is refused up front. The open is what turns a missing or unreadable path into a real errno. The SIGINT/SIGTERM handler used to call process.exit, which joins the libuv threadpool and never returns while an fs call is blocked there (a FIFO waiting for a writer). It now drops its listeners and re-raises the signal after the telemetry flush, so the kernel terminates the process and the parent sees a real signal death. Claude-Session: https://claude.ai/code/session_01EFdF6byTgMidaELCkpcRLA --- src/commands/mapi/README.md | 2 +- src/commands/mapi/request.ts | 83 +++++++++++++++++----------- src/core/mapi/request.ts | 2 +- src/lib/telemetry/tracking.ts | 17 ++++-- test/integration/mapiCommand.test.ts | 33 +++++++++-- 5 files changed, 91 insertions(+), 46 deletions(-) diff --git a/src/commands/mapi/README.md b/src/commands/mapi/README.md index fa5b6e5..80d504d 100644 --- a/src/commands/mapi/README.md +++ b/src/commands/mapi/README.md @@ -25,7 +25,7 @@ kontent mapi [options] | `--mapiKey` | string | Management API key. Falls back to the KONTENT_MAPI_KEY environment variable, then to the logged-in user's token | | `--method`, `-X` | string | HTTP method. (default: GET, or POST with --input) | | `--header`, `-H` | string[] | Request header in the "Name: value" format. Repeatable. An Authorization header takes precedence over --mapiKey and the stored login token | -| `--input` | string | File with the request body, or "-" to read stdin. Sent as application/json unless a Content-Type header says otherwise - set one when uploading a binary file, since the Management API stores it as the asset's MIME type | +| `--input` | string | Path to the request body, or "-" to read stdin. A pipe works too - /dev/stdin or <(cmd). Sent as application/json unless a Content-Type header says otherwise - set one when uploading a binary file, since the Management API stores it as the asset's MIME type | | `--include`, `-i` | boolean | Print the status line and response headers before the body | ## Examples diff --git a/src/commands/mapi/request.ts b/src/commands/mapi/request.ts index 167bfa0..4cc238e 100644 --- a/src/commands/mapi/request.ts +++ b/src/commands/mapi/request.ts @@ -1,5 +1,8 @@ import { openAsBlob } from "node:fs"; -import { open, stat } from "node:fs/promises"; +import { type FileHandle, open } from "node:fs/promises"; +import type { Readable } from "node:stream"; +import { blob } from "node:stream/consumers"; +import { isatty } from "node:tty"; import type { Header, HttpMethod } from "@kontent-ai/core-sdk"; import { match } from "ts-pattern"; import { type MapiResponse, performRawMapiRequest } from "../../core/mapi/request.js"; @@ -66,7 +69,7 @@ export const register: RegisterCommand = (sub, deps) => .option("input", { type: "string", describe: - 'File with the request body, or "-" to read stdin. Sent as application/json unless a Content-Type header says otherwise - set one when uploading a binary file, since the Management API stores it as the asset\'s MIME type', + 'Path to the request body, or "-" to read stdin. A pipe works too - /dev/stdin or <(cmd). Sent as application/json unless a Content-Type header says otherwise - set one when uploading a binary file, since the Management API stores it as the asset\'s MIME type', }) // Without nargs, yargs-parser reads the lone "-" of `--input -` as a // positional and .strict() then rejects it as an unknown argument. @@ -221,54 +224,68 @@ const readInput = async (input: string): Promise> }); } - return await tryAsync( - async () => new Blob([await readStdin()]), - (cause) => ({ - kind: "unreadable-input" as const, - message: `Failed to read stdin: ${errorMessage(cause)}`, - }), - ); + return await drain(process.stdin, "stdin"); }; -// openAsBlob only stats the path: a directory or an unreadable file succeeds here -// and fails only once the body is read, after the request has gone out. A missing -// path throws, but as a bare "Unable to open file as blob" with no errno. -// stat goes first because it never blocks; open on a FIFO with no writer would. +// A Blob has to know its length up front, so a regular file is the only source that can +// be handed to openAsBlob unread and streamed from disk at send time; everything else is +// drained into memory first. openAsBlob only stats the path it takes, reporting a missing +// one as a bare "Unable to open file as blob" and an unreadable one only once the body is +// read, with the request already out - opening the path first is what turns either into a +// real errno. openAsBlob takes a path, not a descriptor, so the probe handle is closed and +// the path is opened a second time; a swap or chmod between the two opens is accepted. const readFileInput = async (input: string): Promise> => { - const unreadable = (cause: unknown): RequestArgsError => ({ - kind: "unreadable-input", - message: `Failed to read "${input}": ${errorMessage(cause)}`, - }); + const label = `"${input}"`; + const toError = unreadable(label); - const stats = await tryAsync(async () => await stat(input), unreadable); + const opened = await tryAsync(async () => await open(input, "r"), toError); + if (isErr(opened)) { + return opened; + } + const handle = opened.value; + + const stats = await tryAsync(async () => await handle.stat(), toError); if (isErr(stats)) { + await closeQuietly(handle); return stats; } - if (!stats.value.isFile()) { + // A directory opens fine read-only on POSIX, so only the stat rules it out. + if (stats.value.isDirectory()) { + await closeQuietly(handle); + return err({ kind: "unreadable-input", message: `Failed to read ${label}: is a directory.` }); + } + + if (isatty(handle.fd)) { + await closeQuietly(handle); return err({ kind: "unreadable-input", - message: `Failed to read "${input}": not a file.`, + message: `Failed to read ${label}: it is a terminal, nothing will arrive.`, }); } - const readable = await tryAsync(async () => { - await (await open(input, "r")).close(); - }, unreadable); - if (isErr(readable)) { - return readable; + if (stats.value.isFile()) { + await closeQuietly(handle); + return await tryAsync(async () => await openAsBlob(input), toError); } - return await tryAsync(async () => await openAsBlob(input), unreadable); + // createReadStream defaults to autoClose, closing the handle on the stream's end or error. + return await drain(handle.createReadStream(), label); }; -const readStdin = async (): Promise => { - const chunks: Buffer[] = []; - for await (const chunk of process.stdin) { - chunks.push(chunk as Buffer); - } - return Buffer.concat(chunks); -}; +// A failed close cannot make the input any less readable, so it is not a result. +const closeQuietly = async (handle: FileHandle): Promise => + await handle.close().catch(() => undefined); + +const drain = async (source: Readable, label: string): Promise> => + await tryAsync(async () => await blob(source), unreadable(label)); + +const unreadable = + (label: string) => + (cause: unknown): RequestArgsError => ({ + kind: "unreadable-input", + message: `Failed to read ${label}: ${errorMessage(cause)}`, + }); const formatFailure = (response: MapiResponse, source: AuthSource): string => { const summary = `HTTP ${response.statusCode} ${response.statusText}`; diff --git a/src/core/mapi/request.ts b/src/core/mapi/request.ts index 37d5262..a183ec7 100644 --- a/src/core/mapi/request.ts +++ b/src/core/mapi/request.ts @@ -10,7 +10,7 @@ export type MapiRequestParams = Readonly<{ method: HttpMethod; headers: ReadonlyArray
; body: Blob | null; - // No production caller: the telemetry SIGINT handler exits before an abort could + // No production caller: the telemetry SIGINT handler terminates before an abort could // land. Kept for the abort test and a handler that cooperates. abortSignal?: AbortSignal; }>; diff --git a/src/lib/telemetry/tracking.ts b/src/lib/telemetry/tracking.ts index de27112..721ba25 100644 --- a/src/lib/telemetry/tracking.ts +++ b/src/lib/telemetry/tracking.ts @@ -179,13 +179,18 @@ const formatTrackOutcome = (outcome: Exclude) .exhaustive(); export const registerTelemetrySignalFlush = (telemetry: Telemetry): void => { - const signalExitCodes: ReadonlyArray = [ - ["SIGINT", 130], - ["SIGTERM", 143], - ]; - for (const [signal, exitCode] of signalExitCodes) { + const signals: ReadonlyArray = ["SIGINT", "SIGTERM"]; + for (const signal of signals) { process.on(signal, () => { - void telemetry.flush().finally(() => process.exit(exitCode)); + // process.exit joins the libuv threadpool and never returns while an fs call is blocked + // there (a FIFO open waiting for a writer), so the signal is re-raised for the kernel to + // terminate us. Every listener has to go: @clack/prompts holds its own while a spinner + // runs, and with any left Node keeps catching the signal and the re-raise only re-emits. + // Dropped before the flush so a second signal kills at once. + process.removeAllListeners(signal); + void telemetry.flush().finally(() => { + process.kill(process.pid, signal); + }); }); } }; diff --git a/test/integration/mapiCommand.test.ts b/test/integration/mapiCommand.test.ts index 23fdc0e..d2bb9ad 100644 --- a/test/integration/mapiCommand.test.ts +++ b/test/integration/mapiCommand.test.ts @@ -1,6 +1,8 @@ +import { execFile } from "node:child_process"; import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { promisify } from "node:util"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import yargs from "yargs"; import { register } from "../../src/commands/mapi/request.js"; @@ -155,6 +157,30 @@ describe("kontent mapi argument handling", () => { expect(await params.body?.text()).toBe('{"name":"Article"}'); }); + it.skipIf(process.platform === "win32")("drains a pipe at --input", async () => { + const path = join(tempDir, "body.pipe"); + await promisify(execFile)("mkfifo", [path]); + + // The writer's open blocks until the command opens the read end, so both run at once. + await Promise.all([ + writeFile(path, '{"name":"Article"}'), + runCommand(["types", "--input", path, "--envId", ENV_ID]), + ]); + + expect(lastParams().method).toBe("POST"); + expect(await lastParams().body?.text()).toBe('{"name":"Article"}'); + }); + + it.skipIf(process.platform === "win32")( + "sends an empty body for a character device with nothing in it", + async () => { + await runCommand(["types", "--input", "/dev/null", "--envId", ENV_ID]); + + expect(await lastParams().body?.text()).toBe(""); + expect(process.exitCode).toBeUndefined(); + }, + ); + it("wires no abort of its own, leaving SIGINT to the telemetry handler", async () => { await runCommand(["types", "--envId", ENV_ID]); @@ -170,15 +196,12 @@ describe("kontent mapi argument handling", () => { captured.restore(); expect(captured.text()).toContain(path); - // The reason, not just the path: openAsBlob alone reports every unopenable - // file as "Unable to open file as blob", losing the errno. expect(captured.text()).toContain("ENOENT"); expect(process.exitCode).toBe(1); expect(performRawMapiRequest).not.toHaveBeenCalled(); }); - // openAsBlob accepts an unreadable file and fails only once the body is read, so - // the probe has to open it. Root ignores the mode bits and would pass either way. + // Root ignores the mode bits, so the file would open either way. it.skipIf(process.getuid?.() === 0)( "rejects an unreadable --input file before the request goes out", async () => { @@ -207,7 +230,7 @@ describe("kontent mapi argument handling", () => { await runCommand(["types", "--input", tempDir, "--envId", ENV_ID]); captured.restore(); - expect(captured.text()).toContain("not a file"); + expect(captured.text()).toContain("is a directory"); expect(process.exitCode).toBe(1); expect(performRawMapiRequest).not.toHaveBeenCalled(); });