Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
63728b0
refactor: send all logging to stderr through an injectable Logger (#66)
IvanKiral Aug 17, 2026
6e55fa0
feat: add `kontent mapi` raw Management API passthrough command (#67)
IvanKiral Aug 17, 2026
7b45491
test: add e2e suite exercising `kontent mapi` against a cloned Konten…
IvanKiral Aug 18, 2026
c2310b6
feat: generate command reference docs from yargs definitions (#68)
IvanKiral Aug 18, 2026
4b88237
fix: move E2E_ENV_ID_FILE to step-level env, runner context is invali…
IvanKiral Aug 19, 2026
2f2661c
test: fail e2e run with an error when E2E_* credentials are unset
IvanKiral Aug 19, 2026
f5aee29
fix: resolve KONTENT_* env vars explicitly instead of through yargs
IvanKiral Aug 20, 2026
eba7550
fix: bound, abort, and correctly parse the 429 retry backoff
IvanKiral Aug 20, 2026
33cfb7f
fix: treat backslashes as separators in the endpoint traversal guard
IvanKiral Aug 20, 2026
fd9585a
fix: correct argument handling in kontent mapi
IvanKiral Aug 20, 2026
bcd5763
fix: send command payloads to stdout, unconditionally
IvanKiral Aug 20, 2026
7dcce04
fix: return raw response bytes from `kontent mapi`
IvanKiral Aug 20, 2026
45f746f
fix: read the response body with arrayBuffer, not Response.bytes
IvanKiral Aug 20, 2026
2a25ae7
fix: read MAPI responses through core-sdk's HttpAdapter again
IvanKiral Aug 24, 2026
b29990f
fix: match core-sdk's JSON rule, copy no request body, bind prompts t…
IvanKiral Aug 24, 2026
6dc209e
fix: report a dropped response body from its content type, not its le…
IvanKiral Aug 24, 2026
f0d655b
docs: say what --logLevel none does and does not silence
IvanKiral Aug 24, 2026
c09503e
refactor: declare mapi request errors in the layer that raises them
IvanKiral Aug 24, 2026
0c1644d
refactor: format the mapi response before writing it
IvanKiral Aug 24, 2026
c73c808
refactor: hand the raw mapi retry loop to core-sdk
IvanKiral Aug 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,17 @@ DO_NOT_TRACK=

# Verbose telemetry logging for debugging.
KONTENT_TELEMETRY_DEBUG=

# --- 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
# 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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
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: ${{ 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 }}
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
# 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")" \
-H "Authorization: Bearer $E2E_MAPI_KEY" || true
fi
24 changes: 17 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,28 @@ 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 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`.
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 `<!-- reference:start/end -->` 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

- `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: 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, { 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

- **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 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

Expand All @@ -49,10 +57,12 @@ Adding a command: export a `register: RegisterCommand` (see `src/commands/login/

## 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).

## 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`.
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,35 @@ kontent logout
Run `kontent --help` for the full, always-current list.
Each command supports `--help` for its own options.

<!-- commands:start -->
| 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 <endpoint>`](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 |
<!-- commands:end -->

## Global options

- `--logLevel`, `-ll` — detail level: `none`, `standard` (default), `verbose`
- `--verbose` — shortcut for `--logLevel verbose`
- `--configFile` — path to a JSON file with CLI parameters
- `--help`, `-h` / `--version`, `-v`

Options can also be supplied via `KONTENT_*` environment variables.
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
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

Expand Down
10 changes: 10 additions & 0 deletions TELEMETRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@
"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",
"test:watch": "vitest",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
Expand Down Expand Up @@ -58,13 +60,14 @@
"eslint": "^9.39.4",
"rimraf": "^6.1.3",
"tsdown": "^0.21.10",
"tsx": "^4.20.6",
"typescript": "^5.9.3",
"vitest": "^4.1.9"
},
"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",
Expand Down
Loading