diff --git a/.env.template b/.env.template index 2044a32..2890ca1 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; 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 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/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..984468b --- /dev/null +++ b/.github/workflows/e2e.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 63142bc..68b6118 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `` 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 @@ -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`. diff --git a/README.md b/README.md index b59bb2e..3713d16 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` @@ -60,7 +69,19 @@ 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. +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 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/package.json b/package.json index 64be098..165d91a 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67cd5c7..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' @@ -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} @@ -281,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 @@ -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'} @@ -1037,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: '*' @@ -1911,6 +2076,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 +2386,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 @@ -2303,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 @@ -2639,13 +2887,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 +3289,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 +4291,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 +4358,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 +4367,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 +4391,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..887489a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1 +1,5 @@ +allowBuilds: + esbuild: true +minimumReleaseAgeExclude: + - '@kontent-ai/*' 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/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/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/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/mapi/README.md b/src/commands/mapi/README.md new file mode 100644 index 0000000..fa5b6e5 --- /dev/null +++ b/src/commands/mapi/README.md @@ -0,0 +1,66 @@ +# `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. 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 | +| `--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 - +``` + + +## 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/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/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 new file mode 100644 index 0000000..3257c7a --- /dev/null +++ b/src/commands/mapi/request.ts @@ -0,0 +1,268 @@ +import { readFile } 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 { createMapiRawClient } from "../../lib/mapi/raw/client.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<{ + 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. Falls back to the KONTENT_MAPI_KEY environment variable, then 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', + }) + // 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. 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. + .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 resolveMapiCredential(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, logger }) }, + ); + process.off("SIGINT", abortRequest); + + if (isErr(result)) { + tracker.fail(result.error.kind, { "auth-source": source }); + logger.error(result.error.message); + process.exitCode = 1; + return; + } + + 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}`, { + "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: Blob | null; +}>; + +type RequestArgsError = Readonly<{ + kind: "invalid-method" | "invalid-header" | "unreadable-input"; + message: string; +}>; + +const prepareRequest = async ( + args: RequestArgs, +): Promise> => { + const method = parseMethod(args.method, args.input !== undefined); + if (isErr(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") { + 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 }); + } + + 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, + }); +}; + +const readInput = async (input: string): Promise> => { + if (input !== "-") { + return await tryAsync( + async () => new Blob([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 Buffer.concat(chunks); +}; + +const formatFailure = (response: MapiResponse, source: AuthSource): string => { + const summary = `HTTP ${response.statusCode} ${response.statusText}`; + + 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/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/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/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/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..eaaaea8 100644 --- a/src/commands/telemetry/status.ts +++ b/src/commands/telemetry/status.ts @@ -1,4 +1,4 @@ -import { showTelemetryStatus } from "../../core/telemetry/settings.js"; +import { buildTelemetryStatusReport } from "../../core/telemetry/settings.js"; import type { RegisterCommand } from "../../types/yargs.js"; export const register: RegisterCommand = (sub) => @@ -6,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(args), + handler: async () => { + process.stdout.write(`${await buildTelemetryStatusReport()}\n`); + }, }); 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; + body: Blob | null; + abortSignal?: AbortSignal; +}>; + +export type MapiResponse = Readonly<{ + statusCode: number; + statusText: string; + headers: ReadonlyArray
; + // 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; +}>; + +/** + * 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: "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, + body: response.value.payload, + }); +}; diff --git a/src/core/project/bootstrap.ts b/src/core/project/bootstrap.ts index f09f9b7..6d6ee56 100644 --- a/src/core/project/bootstrap.ts +++ b/src/core/project/bootstrap.ts @@ -1,6 +1,6 @@ import { readdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; -import { confirm, isCancel, note, select, spinner } from "@clack/prompts"; +import { isCancel } from "@clack/prompts"; import type { KontentSdkError } from "@kontent-ai/core-sdk"; import { downloadTemplate } from "giget"; import { applyEnvOverrides } from "../../lib/envFile.js"; @@ -13,17 +13,18 @@ import { type ApiKeyListingItem, listApiKeys } from "../../lib/iapi/endpoints/li import { listProjectProperties } from "../../lib/iapi/endpoints/listProjectProperties.js"; import type { MapiClient } from "../../lib/mapi/client.js"; import { err, isErr, ok, type Result } from "../../lib/result.js"; -import { type LogOptions, logError, logWarning } from "../../log.js"; +import { confirm, note, select, spinner } from "../../lib/ui/prompts.js"; +import type { Logger } from "../../log.js"; import { buildEnvValues, findSample, type PreviewSpaceConfig, type SampleApp } from "./samples.js"; import { ensureLocalhostSpace, PREVIEW_PORT, type SpaceSetupError } from "./space.js"; -export type BootstrapParams = LogOptions & - Readonly<{ - envId: string; - path: string; - }>; +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..fd82edb 100644 --- a/src/core/telemetry/settings.ts +++ b/src/core/telemetry/settings.ts @@ -4,11 +4,13 @@ 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 => { +/** + * 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); @@ -18,49 +20,37 @@ export const showTelemetryStatus = async (params: TelemetryCommandParams): Promi : "Reason: default (no opt-out detected)" : `Reason: ${formatTelemetryOffReason(consent.reason)}`; - logInfo( - params, - "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 ( - 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..1b50a9a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,9 @@ #!/usr/bin/env node -import chalk from "chalk"; +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 { @@ -10,21 +11,18 @@ import { formatTelemetryMode, registerTelemetrySignalFlush, } from "./lib/telemetry/tracking.js"; -import { addLogLevelOptions, logInfo } 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/project/project.js")).register, - (await import("./commands/telemetry/telemetry.js")).register, -]; +import { addLogLevelOptions, createLoggerFromArgs } from "./log.js"; +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.")) @@ -36,21 +34,9 @@ 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(`${chalk.red("Error:")} ${kontentDomainResult.error}`); + console.error(`${chalkStderr.red("Error:")} ${kontentDomainResult.error}`); process.exit(1); } @@ -59,8 +45,8 @@ const deps: CommandDeps = { telemetry }; registerTelemetrySignalFlush(telemetry); // Runs after parsing (so --verbose is known) and before the command handler. -const withTelemetryModeLog = withHiddenEnvOptions.middleware((args) => { - logInfo(args, "verbose", formatTelemetryMode(mode)); +const withTelemetryModeLog = withLogLevel.middleware((args) => { + createLoggerFromArgs(args).info("verbose", formatTelemetryMode(mode)); }); await commandsToRegister diff --git a/src/lib/auth/mapiCredential.ts b/src/lib/auth/mapiCredential.ts new file mode 100644 index 0000000..5b99460 --- /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 MapiCredential = 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/iapi/formatIapiError.ts b/src/lib/iapi/formatIapiError.ts index 5849f3a..8c6c66b 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; @@ -30,8 +28,9 @@ const formatGenericIapiError = (error: KontentSdkError, options: LogOptions): st "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}`, - isVerbose(options) + // 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/client.ts b/src/lib/mapi/raw/client.ts new file mode 100644 index 0000000..580038b --- /dev/null +++ b/src/lib/mapi/raw/client.ts @@ -0,0 +1,165 @@ +import { + type AdapterPayload, + type AdapterResponse, + createSdkIdHeader, + 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, ok, type Result } from "../../result.js"; + +const MAX_RETRY_ATTEMPTS = 3; +// 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 mapiSdkInfo: SdkInfo = { + name: pkg.name, + version: pkg.version, + host: "npmjs.com", +}; + +/** + * 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 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; + // 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: 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<{ + 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. + */ +export const executeRawRequest = async ( + client: MapiRawClient, + request: RawRequest, + logger: Logger, +): 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)); + } + return fromSdkError(response.error); +}; + +/** + * 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. + */ +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( + (header) => + ` ${header.name}: ${header.name.toLowerCase() === "authorization" ? "" : header.value}`, + ); + return [`${request.method} ${request.url.toString()}`, ...headerLines].join("\n"); +}; 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/mapi/raw/endpoint.ts b/src/lib/mapi/raw/endpoint.ts new file mode 100644 index 0000000..7afcc17 --- /dev/null +++ b/src/lib/mapi/raw/endpoint.ts @@ -0,0 +1,69 @@ +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("//"); + +// 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) === ".."); + +// 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/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/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..218a0c8 --- /dev/null +++ b/src/lib/ui/prompts.ts @@ -0,0 +1,37 @@ +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. 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({ ...options, output: process.stderr }); + +export const confirm: typeof clackConfirm = async (options) => + clackConfirm({ ...options, output: process.stderr }); + +export const select: typeof clackSelect = async (options) => + clackSelect({ ...options, output: process.stderr }); + +export const note: typeof clackNote = (message, title, options = {}) => + clackNote(message, title, { ...options, output: process.stderr }); + +export const intro: typeof clackIntro = (title, options = {}) => + clackIntro(title, { ...options, output: process.stderr }); + +export const outro: typeof clackOutro = (message, options = {}) => + clackOutro(message, { ...options, output: process.stderr }); 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/e2e/fixtures/kailogo.png b/test/e2e/fixtures/kailogo.png new file mode 100644 index 0000000..78ac4ed Binary files /dev/null and b/test/e2e/fixtures/kailogo.png differ 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 new file mode 100644 index 0000000..d8d464d --- /dev/null +++ b/test/e2e/helpers/config.ts @@ -0,0 +1,29 @@ +export type E2eConfig = Readonly<{ + mapiKey: string; + sourceEnvId: string; +}>; + +// 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"); + 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): string | undefined => { + const value = process.env[name]; + return value === "" ? undefined : 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..1d19a6b --- /dev/null +++ b/test/e2e/helpers/runCli.ts @@ -0,0 +1,69 @@ +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: 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 = {}, +): 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(), + }); + }); + + // 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); + } + 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..c9c8c5e --- /dev/null +++ b/test/e2e/mapi.test.ts @@ -0,0 +1,200 @@ +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 { requireE2eConfig } 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 = requireE2eConfig(); + +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("kontent mapi e2e", () => { + let env: TestEnvironment | undefined; + let itemId: string; + let assetId: string; + + 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", config.mapiKey, ...extraArgs], + options, + ); + + beforeAll(async () => { + env = await cloneTestEnvironment(config); + await recordEnvironmentId(env.envId); + }); + + afterAll(async () => { + if (env !== undefined) { + await deleteTestEnvironment(config, 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/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..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/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/integration/mapi.test.ts b/test/integration/mapi.test.ts new file mode 100644 index 0000000..7e72d4f --- /dev/null +++ b/test/integration/mapi.test.ts @@ -0,0 +1,282 @@ +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"; +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"; +// Mirrors the cap the client configures on core-sdk's retry strategy. +const MAX_RETRY_DELAY_MS = 60_000; + +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, + logger, + }); + 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]); + + assertOk(result); + expect(result.value.statusCode).toBe(200); + expect(result.value.statusText).toBe("OK"); + 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({ + 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.toLowerCase() === "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.toLowerCase() === "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: new Blob(['{"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 () => { + 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.body).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("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 }); + + // 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 () => { + 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( + [ + { + method: "POST", + path: /\/types$/, + replies: [{ status: 503, statusText: "Service Unavailable" }, { status: 201 }], + }, + ], + { params: { method: "POST", body: new Blob(["{}"]) } }, + ); + + 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/integration/mapiCommand.test.ts b/test/integration/mapiCommand.test.ts new file mode 100644 index 0000000..ddfcaa9 --- /dev/null +++ b/test/integration/mapiCommand.test.ts @@ -0,0 +1,144 @@ +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: [], body: 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 captureStream = (stream: "stdout" | "stderr") => { + const chunks: string[] = []; + 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 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 () => { + 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" }, + ]); + }); + + // 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, + 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(["types", "--envId", ENV_ID]); + stdout.restore(); + stderr.restore(); + + expect(stdout.text()).toBe(""); + expect(stderr.text()).toContain("137 bytes of text/html"); + expect(process.exitCode).toBe(1); + }); + + it("prints a 4xx body on stdout and its diagnosis on stderr", async () => { + vi.mocked(performRawMapiRequest).mockResolvedValueOnce( + ok({ + statusCode: 404, + statusText: "Not Found", + headers: [{ name: "content-type", value: "application/json" }], + body: { message: "The requested content type was not found." }, + }), + ); + const stdout = captureStream("stdout"); + const stderr = captureStream("stderr"); + + await runCommand(["types/missing", "--envId", ENV_ID]); + stdout.restore(); + stderr.restore(); + + 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 () => { + const captured = captureStream("stderr"); + + await runCommand(["types", "-X", "GET", "--input", "body.json", "--envId", ENV_ID]); + captured.restore(); + + 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"); + }); +}); diff --git a/test/unit/endpoint.test.ts b/test/unit/endpoint.test.ts new file mode 100644 index 0000000..2db10a2 --- /dev/null +++ b/test/unit/endpoint.test.ts @@ -0,0 +1,87 @@ +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"], + ["types\\..\\..\\secret", "traversal"], + ["..\\..\\admin", "traversal"], + ["types/..\\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/formatIapiError.test.ts b/test/unit/formatIapiError.test.ts index fac869b..33cce55 100644 --- a/test/unit/formatIapiError.test.ts +++ b/test/unit/formatIapiError.test.ts @@ -24,10 +24,11 @@ const httpError = ( statusText, responseHeaders: [{ name: NOISE_HEADER, value: "cache-vie6340-VIE" }], kontentErrorResponse, + adapterResponse: undefined, }, }); -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 +64,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/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/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/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" })); diff --git a/test/unit/mapiCredential.test.ts b/test/unit/mapiCredential.test.ts new file mode 100644 index 0000000..1b8782b --- /dev/null +++ b/test/unit/mapiCredential.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveMapiCredential } from "../../src/lib/auth/mapiCredential.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("resolveMapiCredential", () => { + it("prefers an Authorization header and adds no token of its own", async () => { + const result = await resolveMapiCredential(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 resolveMapiCredential( + [{ 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 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 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 resolveMapiCredential([], 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 resolveMapiCredential([], undefined, { KONTENT_MAPI_KEY: "" }); + + assertOk(result); + expect(result.value).toEqual({ token: "stored-login-token", source: "login" }); + }); +}); 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."); + }); +}); 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"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 7cf6b97..6b57450 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "ESNext", + "lib": ["ESNext"], "module": "NodeNext", "moduleResolution": "NodeNext", "esModuleInterop": true, @@ -11,6 +12,6 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["src/**/*", "test/**/*", "vitest.config.ts"], + "include": ["src/**/*", "test/**/*", "scripts/**/*", "vitest.config.ts", "vitest.e2e.config.ts"], "exclude": ["node_modules", "dist"] } 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..0a2fa68 --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,31 @@ +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 when the shell provides the +// variables; test/e2e/globalSetup.ts fails the run when they are missing +// everywhere. +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"], + globalSetup: ["test/e2e/globalSetup.ts"], + fileParallelism: false, + testTimeout: 30_000, + // Covers environment cloning, which the API performs asynchronously. + hookTimeout: 300_000, + }, +});