Add mapi command - #69
Draft
IvanKiral wants to merge 20 commits into
Draft
Conversation
* refactor: send all logging to stderr through an injectable Logger * refactor: give warnings a yellow Warning: prefix and raise user-facing ones to standard
* feat: add `kontent mapi` raw Management API passthrough command * refactor: polish raw mapi command and harden its tests
…t.ai environment
* feat: generate command reference docs from yargs definitions into colocated READMEs * ci: fail when generated command docs are stale
IvanKiral
force-pushed
the
add_mapi_command
branch
from
August 19, 2026 08:47
e189222 to
4b88237
Compare
`.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
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
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
`--header` is an array option without nargs, so it kept consuming words: `kontent mapi -H 'X-Foo: 1' types --envId <id>` 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
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
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
`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.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
kontent mapi <endpoint>, a curl-like passthrough to the Kontent.ai Management API: you give it a path, it authenticates the request and prints the response. Also lands the two things that command needed to exist cleanly — a proper stdout/stderr split across the whole CLI, and generated command reference docs — plus an e2e suite that runs the built binary against a real cloned environment.What's in it
kontent mapi(src/commands/mapi,src/core/mapi,src/lib/mapi/raw)kontent mapi 'types?limit=10' --envId <id>,-X/--method, repeatable-H/--header,--input <file|->,-i/--include.Authorizationheader →--mapiKey→KONTENT_MAPI_KEY→ the stored login token. Reading the env var directly (not as a yargs option) keeps the key off argv, out ofpsand shell history.Logging refactor (
src/log.ts) — all logging goes to stderr through an injectableLogger;--logLevel none|standard|verboseand--verbose.Generated docs (
scripts/generateCommandDocs.ts) —pnpm docs:generatereplays the yargs registrations against a recording proxy and rewrites the root README table plus each command folder's<!-- reference -->block. CI fails when they're stale.e2e (
test/e2e) — clone-per-run from an empty template environment, gated onE2E_MAPI_KEY/E2E_SOURCE_ENV_ID, own vitest config, separate workflow (fork PRs skipped, no secret access).Decisions worth calling out
Output channels. stdout carries only the data the command exists to produce and is never level-gated; stderr carries everything said about producing it.
--logLevel nonemust still print a response body — a payload is not a log. This also fixedkontent telemetry status, which logged its report atinfoand so vanished under--logLevel noneand never piped togrep; core now returns the report and the command writes it out.Which HTTP layer to sit on. Went through the full loop here. 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. Briefly replaced core-sdk'sHttpAdapterwith a bespoke raw-bytes transport so a CSV or binary body wouldn't come back as empty stdout — then reverted it: MAPI answersapplication/jsonon every endpoint and every status, and binary only ever travels request-side on an asset upload. A second HTTP path with its own abort and header handling was cost for a case that does not arise. The adapter stays; the body decision is made from the response's content type, not from the payload (core-sdk yieldsnullfor absent, skipped, and literal-JSON-nullbodies alike), and a non-JSON body is reported on stderr rather than silently vanishing.Endpoint scoping is a guard, not a privilege boundary. The host is always pinned to MAPI and the caller only escapes their own scoping — but the guard split on forward slashes only, so
'types\..\..\secret'walked past it (WHATWG treats backslashes as separators in an https URL). Now splits on both. Percent-encoded separators need no handling:%2f/%5cstay encoded and can't traverse; only%2e%2edecodes into a traversing segment, which the per-segment decode already covered.429 backoff. Bounded, abortable, and RFC-correct. Past a minute the API is rationing quota rather than smoothing a burst, so the 429 goes back to the caller with a warning naming the requested delay instead of sleeping an hour three times over. The sleep runs through
node:timers/promiseswith the request's abort signal — the command installs a SIGINT handler, so the first Ctrl+C previously did nothing at all.Number()accepted values delta-seconds does not (""→ 0,-5/1.5fell through toDate.parseas years → a past date → immediate retry); parsing now requires1*DIGIT, and the HTTP-date branch requires a letter.Dropped
.env("KONTENT"). It turned everyKONTENT_*var in the shell into a CLI flag, and.strict()then rejected the ones the running command didn't declare — an unrelatedKONTENT_PROJECT_IDbroke every command, and a strayKONTENT_INPUTsilently turned a plain listing into a POST of that file. Nothing depended on the mapping; every supported variable is read fromprocess.envwhere it applies.-X GET --inputis rejected up front. curl allows a GET with a body, but undici refuses one, so this used to die on a rawRequest with GET/HEAD method cannot have body.This is where curl parity stops.--inputsendsapplication/jsonunless a header overrides it — documented explicitly, because MAPI stores that header as the asset's MIME type, so a PNG uploaded without-Hwas served as JSON with exit code 0 and no warning.Checklist
How to test
test/unitcovers endpoint resolution, header/method parsing, credential resolution,Retry-After, response presentation, and a test that drives the real core-sdk adapter to assert the duplicated JSON content-type rule still agrees with it.test/integrationcovers command-level behavior by foldingregisterover a real yargs instance. Several fixes were also verified against the built binary (noted in their commits).Manually, against a real environment:
End-to-end:
pnpm test:e2ewithE2E_MAPI_KEYandE2E_SOURCE_ENV_IDset (clones an environment per run).