Skip to content

Add mapi command - #69

Draft
IvanKiral wants to merge 20 commits into
masterfrom
add_mapi_command
Draft

Add mapi command#69
IvanKiral wants to merge 20 commits into
masterfrom
add_mapi_command

Conversation

@IvanKiral

@IvanKiral IvanKiral commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

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.
  • Auth resolution, each source suppressing the ones below it: an Authorization header → --mapiKeyKONTENT_MAPI_KEY → the stored login token. Reading the env var directly (not as a yargs option) keeps the key off argv, out of ps and shell history.
  • A 4xx/5xx is a result, not an error: the body still goes to stdout and the exit code is 1, so a failing request stays scriptable.

Logging refactor (src/log.ts) — all logging goes to stderr through an injectable Logger; --logLevel none|standard|verbose and --verbose.

Generated docs (scripts/generateCommandDocs.ts)pnpm docs:generate replays 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 on E2E_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 none must still print a response body — a payload is not a log. This also fixed kontent telemetry status, which logged its report at info and so vanished under --logLevel none and never piped to grep; 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's HttpAdapter with a bespoke raw-bytes transport so a CSV or binary body wouldn't come back as empty stdout — then reverted it: MAPI answers application/json on 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 yields null for absent, skipped, and literal-JSON-null bodies 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/%5c stay encoded and can't traverse; only %2e%2e decodes 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/promises with 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.5 fell through to Date.parse as years → a past date → immediate retry); parsing now requires 1*DIGIT, and the HTTP-date branch requires a letter.

Dropped .env("KONTENT"). It turned every KONTENT_* var in the shell into a CLI flag, and .strict() then rejected the ones the running command didn't declare — an unrelated KONTENT_PROJECT_ID broke every command, and a stray KONTENT_INPUT silently turned a plain listing into a POST of that file. Nothing depended on the mapping; every supported variable is read from process.env where it applies.

-X GET --input is rejected up front. curl allows a GET with a body, but undici refuses one, so this used to die on a raw Request with GET/HEAD method cannot have body. This is where curl parity stops.

--input sends application/json unless a header overrides it — documented explicitly, because MAPI stores that header as the asset's MIME type, so a PNG uploaded without -H was served as JSON with exit code 0 and no warning.

Checklist

  • Code follows coding conventions held in this repo
  • Automated tests have been added
  • Tests are passing
  • Docs have been updated (if applicable)
  • Temporary settings (e.g. variables used during development and testing) have been reverted to defaults

How to test

test/unit covers 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/integration covers command-level behavior by folding register over a real yargs instance. Several fixes were also verified against the built binary (noted in their commits).

Manually, against a real environment:

pnpm build
kontent mapi 'types?limit=10' --envId <id> --mapiKey <key> | jq        # payload on stdout, pipes
kontent mapi types --envId <id> --logLevel none                        # still prints the body
kontent mapi 'items/does-not-exist' --envId <id>; echo $?              # 4xx body on stdout, exit 1
kontent mapi 'types\..\..\secret' --envId <id>                         # rejected by the traversal guard
KONTENT_FOO=1 kontent --help                                           # stray KONTENT_* no longer breaks commands

End-to-end: pnpm test:e2e with E2E_MAPI_KEY and E2E_SOURCE_ENV_ID set (clones an environment per run).

IvanKiral and others added 5 commits August 17, 2026 08:01
* 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
* feat: generate command reference docs from yargs definitions into colocated READMEs

* ci: fail when generated command docs are stale
`.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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant