From 99ab094aae775f500a23bf061ce81432ee1b3e9b Mon Sep 17 00:00:00 2001 From: Eric Kim Date: Thu, 25 Jun 2026 18:39:22 -0400 Subject: [PATCH 1/7] feat: import amp CLI distribution Initial import of the `amp` CLI source, bundled Developer API OpenAPI spec, TypeScript build config, tests, packaging smoke, and the CI + release-please release automation. Source is mirrored from Amplitude's Developer API. Co-authored-by: Cursor --- .github/workflows/build.yml | 52 + .github/workflows/release-please.yml | 76 + .release-please-manifest.json | 3 + AGENTS.md | 97 + docs/cli.md | 148 ++ openapi/bundled/openapi.bundled.json | 2545 ++++++++++++++++++++++++++ openapi/bundled/openapi.bundled.yaml | 1973 ++++++++++++++++++++ package.json | 63 + pnpm-lock.yaml | 1688 +++++++++++++++++ release-please-config.json | 8 + scripts/smoke-cli.sh | 42 + scripts/smoke.sh | 34 + src/args.test.ts | 83 + src/args.ts | 173 ++ src/auth-commands.ts | 570 ++++++ src/auth-guidance.test.ts | 107 ++ src/auth-guidance.ts | 89 + src/auth-login.test.ts | 229 +++ src/auth-pat.test.ts | 144 ++ src/auth-profiles.test.ts | 434 +++++ src/authToken.test.ts | 300 +++ src/authToken.ts | 271 +++ src/cli.test.ts | 84 + src/cli.ts | 114 ++ src/config.test.ts | 39 + src/config.ts | 38 + src/credential-resolver.test.ts | 190 ++ src/credential-resolver.ts | 263 +++ src/credential-store.test.ts | 313 ++++ src/credential-store.ts | 257 +++ src/env.ts | 12 + src/errors.test.ts | 50 + src/errors.ts | 93 + src/generated/cli-manifest.ts | 1141 ++++++++++++ src/help.test.ts | 57 + src/help.ts | 281 +++ src/oauthError.ts | 36 + src/oauthResponseSchemas.ts | 31 + src/output.test.ts | 150 ++ src/output.ts | 227 +++ src/prompt.ts | 14 + src/request.test.ts | 200 ++ src/request.ts | 225 +++ src/run.test.ts | 227 +++ src/run.ts | 126 ++ src/schemas.ts | 13 + src/scopes.test.ts | 17 + src/scopes.ts | 22 + src/terminal.ts | 10 + tsconfig.build.json | 9 + tsconfig.json | 22 + tsconfig.tsc.json | 3 + vitest.config.ts | 9 + 53 files changed, 13402 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/release-please.yml create mode 100644 .release-please-manifest.json create mode 100644 AGENTS.md create mode 100644 docs/cli.md create mode 100644 openapi/bundled/openapi.bundled.json create mode 100644 openapi/bundled/openapi.bundled.yaml create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 release-please-config.json create mode 100755 scripts/smoke-cli.sh create mode 100755 scripts/smoke.sh create mode 100644 src/args.test.ts create mode 100644 src/args.ts create mode 100644 src/auth-commands.ts create mode 100644 src/auth-guidance.test.ts create mode 100644 src/auth-guidance.ts create mode 100644 src/auth-login.test.ts create mode 100644 src/auth-pat.test.ts create mode 100644 src/auth-profiles.test.ts create mode 100644 src/authToken.test.ts create mode 100644 src/authToken.ts create mode 100644 src/cli.test.ts create mode 100644 src/cli.ts create mode 100644 src/config.test.ts create mode 100644 src/config.ts create mode 100644 src/credential-resolver.test.ts create mode 100644 src/credential-resolver.ts create mode 100644 src/credential-store.test.ts create mode 100644 src/credential-store.ts create mode 100644 src/env.ts create mode 100644 src/errors.test.ts create mode 100644 src/errors.ts create mode 100644 src/generated/cli-manifest.ts create mode 100644 src/help.test.ts create mode 100644 src/help.ts create mode 100644 src/oauthError.ts create mode 100644 src/oauthResponseSchemas.ts create mode 100644 src/output.test.ts create mode 100644 src/output.ts create mode 100644 src/prompt.ts create mode 100644 src/request.test.ts create mode 100644 src/request.ts create mode 100644 src/run.test.ts create mode 100644 src/run.ts create mode 100644 src/schemas.ts create mode 100644 src/scopes.test.ts create mode 100644 src/scopes.ts create mode 100644 src/terminal.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json create mode 100644 tsconfig.tsc.json create mode 100644 vitest.config.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ab36232 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,52 @@ +name: Build & Test + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + build: + name: Build, test, and package smoke + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + version: 10 + run_install: false + + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm test:typescript + + - name: Unit tests + run: pnpm test + + - name: Build + run: pnpm build + + # publint catches packaging mistakes (missing files entries, broken bin, + # bad exports) before they ship to npm. + - name: publint + run: pnpm dlx publint + + # No-network smoke: pack -> install the tarball into a throwaway global + # prefix -> assert the `amp` bin loads. Validates the packaged artifact, + # not just the source tree. Needs no PAT and does not hit the Amplitude API. + - name: Packaging smoke + run: bash scripts/smoke-cli.sh diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..a104675 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,76 @@ +name: release-please + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + issues: write + id-token: write + +jobs: + release-please: + runs-on: ubuntu-24.04 + outputs: + releases_created: ${{ steps.release.outputs.releases_created }} + steps: + # Mint a GitHub App installation token so release-please's PRs trigger + # downstream workflows (GITHUB_TOKEN-authored events do not, by design, + # which would leave required checks unrun and the Release PR BLOCKED). + - uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 + id: app-token + with: + app-id: ${{ secrets.RELEASE_PLEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_PLEASE_APP_PRIVATE_KEY }} + + - uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0 + id: release + with: + token: ${{ steps.app-token.outputs.token }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + publish: + name: Publish to npm + runs-on: ubuntu-24.04 + needs: release-please + if: needs.release-please.outputs.releases_created == 'true' + environment: npm-publish + steps: + - name: Checkout the repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + version: 10 + run_install: false + + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build the package + run: pnpm build + + # Authenticates to npm via OIDC Trusted Publishing (id-token: write at the + # workflow level). The Trusted Publisher for @amplitude/developer-api must + # be configured at + # https://www.npmjs.com/package/@amplitude/developer-api/access to trust + # repo=amplitude/developer-api, workflow=release-please.yml, + # environment=npm-publish. No NPM_TOKEN required. + # + # Pre-1.0 we publish to the `beta` dist-tag so `latest` stays unset until + # we promote at 1.0. Drop `--tag beta` when cutting 1.0.0. + - name: Publish the package to npm registry + run: pnpm publish --access public --no-git-checks --provenance --tag beta diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..466df71 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..dfd6eae --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,97 @@ +# developer-api CLI — Agent Instructions + +This package is the `amp` CLI: the human- and agent-facing surface over the +Amplitude Developer API. It is published as a standalone package, so treat +everything here as public and hold it to a high bar. + +The Amplitude Developer API OpenAPI spec is the contract; this CLI is a thin, +generated client over it. The CLI manifest and bundled spec under +`src/generated/` and `openapi/bundled/` are generated artifacts — never edit +them by hand. + +## Tenets + +These break ties when a decision is ambiguous. They are deliberately aligned +with the Developer API golden standards. + +### 1. Obvious by default + +The best surface needs no explanation. A user should be able to guess the next +command and never get surprised by what one does. + +**What good looks like** + +- Discoverable help: `amp help` → surfaces → `amp ` → `amp --help`. +- Consistent verbs across surfaces (`list`, `get`, `create`, `update`, `archive`). +- Safe defaults: destructive actions confirm (interactive `y/N`, or `--yes` in + scripts); reads are free. +- Errors are actionable — they say what to do next, not just what failed. +- Conventions users already expect work: `--version`/`-v`, `--help`/`-h`. + +**Anti-patterns** + +- A flag whose effect you can't infer from its name (e.g. a `--dry-run` that + still performs the write). +- Requiring the README to use a command safely. + +### 2. Code is marketing + +This source is public. Readers judge Amplitude by it, so optimize for clarity, +not just function. + +**What good looks like** + +- Small, single-purpose modules with names that read like a table of contents. +- Public-facing code and docs use familiar, widely understood names instead of + coined internal jargon. +- Generated code is quarantined under `src/generated/` and never hand-edited. +- Validate untrusted input (API responses, saved credentials, user JSON) with + schemas, not ad-hoc coercion. + +**Anti-patterns** + +- A grab-bag module that mixes parsing, auth, transport, and routing. +- TODOs, dead code, or aspirational claims in shipped files or the README. + +### 3. Test the seams that matter + +Cover pure logic exhaustively and protect the safety-critical paths. Skip tests +whose only purpose is coverage of trivial glue. + +**What good looks like** + +- Pure functions (arg parsing, request building, output formatting) are unit + tested. +- Security-relevant behavior has explicit tests: token precedence, the + destructive-action gate, and credential file permissions (`0600`). +- Test files mirror their source module. + +**Anti-patterns** + +- Mocking away the exact branch you mean to verify. +- No coverage on the code that can delete data or leak a secret. + +### 4. Two readers: humans and agents + +Every command is read by a person at a terminal and by an agent over a pipe. +Optimize each for what it needs: humans want maximal visibility and +readability; agents want minimal characters. + +**What good looks like** + +- Interactive (TTY) output is formatted for scanning: aligned tables, short + summaries, collapsed redundant columns, and color used only as emphasis. +- Piped / non-interactive output is compact and lossless — the full data with + no decoration, no indentation, and nothing the caller must strip. +- The two formats carry the same information; only the presentation differs. +- `--json` always yields machine-parseable output; piping never changes the + meaning of a command, only its verbosity. + +**Anti-patterns** + +- Spending agent tokens on pretty-printing, banners, color codes, or + repeated values that carry no extra information. +- Truncating or dropping data on the piped path to save space (terseness must + never cost correctness). +- Human-only adornments (spinners, prompts, ANSI codes) leaking into + non-interactive output. diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..ef95c89 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,148 @@ +# Developer API CLI + +`amp` is a manifest-driven CLI generated from the Amplitude Developer API +OpenAPI spec. A handwritten runtime in `src/cli.ts` maps flags to HTTP requests. + +## Authentication + +Authenticate via the OAuth device flow and save the result as a named profile. +Creating a profile is force-explicit — name it and pick its environment: + +```bash +amp auth login --profile prod --env prod +``` + +That runs the device flow against the chosen environment's Developer API, saves +the token to `~/.amplitude/amp/credentials.json` (0600), and activates the +profile. A +profile binds a credential to an environment (`base_url`), so a staging token +can never be sent to prod. + +```bash +amp auth login --profile staging --env staging # activates staging +amp auth use prod # switch (no re-auth) +amp auth list # * marks the active profile +amp auth status # type, base URL, expiry +amp logout --profile staging # remove one +amp logout --all # remove every profile +``` + +A bare `amp auth login` re-authenticates the active profile in place (env from +its record). `--env` maps `local|dev|staging|prod|prod-eu` to a base URL; pass +`--base-url ` instead to target an arbitrary host. + +To use a Personal Access Token instead of the device flow: + +```bash +echo "$PAT" | amp auth pat --with-token --profile ci --env prod # stdin +amp auth pat --with-token --profile ci --env prod # masked prompt at a TTY +``` + +`--with-token` reads the PAT from stdin when piped (the agent / CI path) or a +masked prompt at a terminal (the human path). Same force-explicit create rule +and store as `login`; the credential is saved as `{ "type": "pat" }`. + +`--with-token` is mandatory: it makes the supply-an-existing-PAT path explicit +and keeps the bare `amp auth pat` verb reserved. + +### Credential selection + +Precedence (highest first): `--token` > `AMP_TOKEN` > `--profile` > +`AMP_PROFILE` > the active (default) profile. `AMP_TOKEN` is **king** over +profile selection so a CI-injected token can't be shadowed by a stray stored +profile; `amp auth status` announces when it is in effect. + +```bash +export AMP_TOKEN=amp_... # one-off / CI; overrides stored profiles +export AMP_PROFILE=staging # select a stored profile by name +``` + +Accepted forms for `--token` / `AMP_TOKEN`: + +- Raw PAT: `amp_...` (normalized to `Bearer PAT=amp_...`) +- Prefixed: `PAT=amp_...` +- Full bearer: `Bearer PAT=amp_...` (or any `Bearer `) + +`amp auth token` prints the active access token to stdout +(`TOKEN=$(amp auth token)`). + +### Required scopes (examples) + +`amp auth login` requests **every** scope the CLI can use by default (the +granular `read:`/`write:` scopes below plus the legacy `mcp:read`/`mcp:write` +org scopes), so all commands work immediately after login. The per-command +scopes below document what each command needs. + +| Command family | Scopes | +| -------------------------- | --------------------------------- | +| `context`, `projects list` | `read:projects` | +| `events *` | `read:taxonomy`, `write:taxonomy` | +| `flags *` | `read:flags`, `write:flags` | + +Route-level scopes are defined on each OpenAPI operation (`x-required-scopes`). + +## Global flags + +| Flag / env | Purpose | +| --------------------------------------- | ------------------------------------------ | +| `--base-url` / `AMP_API_BASE_URL` | API host (default: prod) | +| `--env ` | Friendly env name → base URL | +| `--profile ` / `AMP_PROFILE` | Select a stored profile | +| `--token` / `AMP_TOKEN` / saved profile | Auth token | +| `--json` | Force raw JSON output (default when piped) | +| `--yes` | Confirm DELETE operations | +| `--dry-run` | Preview destructive/query side effects | +| `--body-json '{...}'` | Merge extra JSON into request bodies | + +## Help and output + +```bash +amp help # product surfaces (context, projects, events, flags, …) +amp help flags # commands within a surface +amp flags list --help # flags and examples for one command +amp version +amp auth status +``` + +In an interactive terminal, list and context commands print human-friendly tables +and summaries. Pipe to a file or another command, or pass `--json`, for raw API +JSON. + +## Prod smoke checklist + +Run against a disposable project when possible. Requires a credential with the +scopes listed above — either an active profile (`amp auth login`) or `AMP_TOKEN`. + +```bash +export AMP_TOKEN=amp_... +pnpm smoke +``` + +Manual checklist: + +1. **Health** — `GET /health` returns `200` with `{"status":"ok"}` +2. **Context** — `amp context` resolves auth and org/project context +3. **Projects** — `amp projects list` +4. **Flags read** — `amp flags list --project --limit 5` +5. **Flags write** — create → get → update description → archive dry-run → archive +6. **Events** — list → create → update → (optional delete with `--yes`) + +Known issue: `flags update --enabled false` fails for deployment-less flags. +Avoid that path in smoke tests until it is fixed. + +## Local server + +Point at a running local server: + +```bash +amp --base-url http://localhost:3036 context +``` + +## Generated files + +Do not edit by hand: + +- `src/generated/cli-manifest.ts` — generated from the Developer API OpenAPI spec +- `openapi/bundled/*` — the bundled Developer API OpenAPI spec + +These artifacts are generated and kept in sync upstream. diff --git a/openapi/bundled/openapi.bundled.json b/openapi/bundled/openapi.bundled.json new file mode 100644 index 0000000..9de5081 --- /dev/null +++ b/openapi/bundled/openapi.bundled.json @@ -0,0 +1,2545 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "Amplitude Developer API", + "version": "0.1.0", + "license": { + "name": "Proprietary", + "url": "https://amplitude.com/terms" + }, + "summary": "OpenAPI-first contract for Amplitude CLI, MCP, SDK, and docs.", + "description": "Public developer-facing API contract for Amplitude resources.\nThis spec is intended to be the canonical source for generated CLI, MCP tools,\nSDKs, and documentation.\n" + }, + "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", + "servers": [ + { + "url": "https://developer-api.amplitude.com", + "description": "Production" + }, + { + "url": "https://developer-api.stag2.amplitude.com", + "description": "Staging" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "tags": [ + { + "name": "Projects", + "description": "Project discovery and project-scoped navigation." + }, + { + "name": "Taxonomy Events", + "description": "Event taxonomy operations." + }, + { + "name": "Taxonomy Event Properties", + "description": "Event-property taxonomy operations (scoped to an event)." + }, + { + "name": "Taxonomy User Properties", + "description": "User-property taxonomy operations (scoped to a project)." + }, + { + "name": "Feature Flags", + "description": "Feature flag configuration and rollout operations." + }, + { + "name": "Auth", + "description": "OAuth device-flow and token endpoints (RFC 8628 / RFC 6749)." + } + ], + "paths": { + "/v1/auth/device-authorization": { + "post": { + "tags": ["Auth"], + "operationId": "deviceAuthorization", + "summary": "Start a device authorization request", + "description": "RFC 8628 device authorization endpoint. Initiates the device flow and returns a device_code, user_code, and verification URIs for the user to approve out of band, then poll the token endpoint.\n", + "x-required-scopes": [], + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceAuthorizationRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/DeviceAuthorizationRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Device authorization started.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceAuthorizationResponse" + } + } + } + }, + "400": { + "description": "Invalid request (RFC 6749 §5.2 OAuth error).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthError" + } + } + } + }, + "401": { + "description": "Client authentication failed (RFC 6749 §5.2 OAuth error).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthError" + } + } + } + }, + "500": { + "description": "Server error (RFC 6749 §5.2 OAuth error).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthError" + } + } + } + } + } + } + }, + "/v1/auth/token": { + "post": { + "tags": ["Auth"], + "operationId": "token", + "summary": "Exchange a grant for tokens", + "description": "RFC 6749 §3.2 token endpoint. Supports the `device_code` grant (RFC 8628). Authenticated by the fixed confidential device-flow client (injected server-side). Does not require a Bearer token.\n", + "x-required-scopes": [], + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenRequest" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/TokenRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Tokens issued.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponse" + } + } + } + }, + "400": { + "description": "Invalid or unsupported grant (RFC 6749 §5.2 OAuth error).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthError" + } + } + } + }, + "401": { + "description": "Client authentication failed (RFC 6749 §5.2 OAuth error).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthError" + } + } + } + }, + "500": { + "description": "Server error (RFC 6749 §5.2 OAuth error).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthError" + } + } + } + } + } + } + }, + "/v1/context": { + "get": { + "tags": ["Projects"], + "operationId": "getContext", + "summary": "Get authenticated context", + "description": "Returns the authenticated principal and organization context.", + "x-required-scopes": ["read:projects"], + "responses": { + "200": { + "description": "Successful response.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["object", "principal", "org"], + "properties": { + "object": { + "type": "string", + "const": "context" + }, + "principal": { + "type": "object", + "required": [ + "object", + "login_id", + "org_id", + "auth_type", + "scopes" + ], + "properties": { + "object": { + "type": "string", + "const": "principal" + }, + "login_id": { + "type": "string" + }, + "org_id": { + "type": "string" + }, + "org_name": { + "type": ["string", "null"] + }, + "auth_type": { + "type": "string", + "enum": [ + "oauth", + "pat", + "service_account", + "api_key" + ] + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "org": { + "type": "object", + "required": ["id", "object", "name"], + "properties": { + "id": { + "type": "string" + }, + "object": { + "type": "string", + "const": "org" + }, + "name": { + "type": ["string", "null"] + } + } + } + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "500": { + "$ref": "#/components/responses/Problem" + } + } + } + }, + "/v1/projects": { + "get": { + "tags": ["Projects"], + "operationId": "listProjects", + "summary": "List projects", + "description": "Returns projects visible to the authenticated principal.", + "x-required-scopes": ["read:projects"], + "parameters": [ + { + "$ref": "#/components/parameters/Cursor" + }, + { + "$ref": "#/components/parameters/Limit" + }, + { + "$ref": "#/components/parameters/Sort" + }, + { + "$ref": "#/components/parameters/Query" + } + ], + "responses": { + "200": { + "description": "Successful response.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "pagination"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "500": { + "$ref": "#/components/responses/Problem" + } + } + } + }, + "/v1/projects/{project_id}/events": { + "get": { + "tags": ["Taxonomy Events"], + "operationId": "listEvents", + "summary": "List event taxonomy entries", + "x-required-scopes": ["read:taxonomy"], + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/Cursor" + }, + { + "$ref": "#/components/parameters/Limit" + }, + { + "$ref": "#/components/parameters/Query" + } + ], + "responses": { + "200": { + "description": "Successful response.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "pagination"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Event" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "500": { + "$ref": "#/components/responses/Problem" + } + } + }, + "post": { + "tags": ["Taxonomy Events"], + "operationId": "createEvent", + "summary": "Create event taxonomy entry", + "x-required-scopes": ["write:taxonomy"], + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["event_type"], + "properties": { + "event_type": { + "type": "string" + }, + "description": { + "type": ["string", "null"] + } + }, + "additionalProperties": false + } + } + } + }, + "responses": { + "201": { + "description": "Event created.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/Event" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "409": { + "$ref": "#/components/responses/Problem" + }, + "500": { + "$ref": "#/components/responses/Problem" + } + } + } + }, + "/v1/projects/{project_id}/events/{event_id}": { + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/EventId" + } + ], + "get": { + "tags": ["Taxonomy Events"], + "operationId": "getEvent", + "summary": "Get event taxonomy entry", + "x-required-scopes": ["read:taxonomy"], + "responses": { + "200": { + "description": "Successful response.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/Event" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "500": { + "$ref": "#/components/responses/Problem" + } + } + }, + "patch": { + "tags": ["Taxonomy Events"], + "operationId": "updateEvent", + "summary": "Update event taxonomy entry", + "x-required-scopes": ["write:taxonomy"], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "description": { + "type": ["string", "null"] + }, + "is_active": { + "type": "boolean" + } + }, + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "Event updated.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/Event" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "500": { + "$ref": "#/components/responses/Problem" + } + } + }, + "delete": { + "tags": ["Taxonomy Events"], + "operationId": "deleteEvent", + "summary": "Soft-delete event taxonomy entry", + "x-required-scopes": ["write:taxonomy"], + "parameters": [ + { + "$ref": "#/components/parameters/DryRun" + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Event soft-deleted." + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "500": { + "$ref": "#/components/responses/Problem" + } + } + } + }, + "/v1/projects/{project_id}/events/{event_id}/event-properties": { + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/EventId" + } + ], + "get": { + "tags": ["Taxonomy Event Properties"], + "operationId": "listEventProperties", + "summary": "List event properties for an event", + "description": "Returns properties bound to a specific event, including ingested-only\nproperties (those with `is_in_schema: false`). Use the `is_in_schema`\nflag to detect whether a `PATCH` will succeed before issuing it.\n", + "x-required-scopes": ["read:taxonomy"], + "parameters": [ + { + "$ref": "#/components/parameters/Cursor" + }, + { + "$ref": "#/components/parameters/Limit" + }, + { + "$ref": "#/components/parameters/Query" + } + ], + "responses": { + "200": { + "description": "Successful response.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "pagination"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventProperty" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "500": { + "$ref": "#/components/responses/Problem" + } + } + }, + "post": { + "tags": ["Taxonomy Event Properties"], + "operationId": "createEventProperty", + "summary": "Create event property", + "description": "Creates an event property and adds it to the project's tracking plan\n(`is_in_schema: true` after success). If the property already exists in\nthe schema, the existing resource is returned without modification.\nIf the property exists ingested-only (`is_in_schema: false`), this call\npromotes it into the schema with the metadata supplied here.\n", + "x-required-scopes": ["write:taxonomy"], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["property_name"], + "properties": { + "property_name": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": ["string", "null"] + }, + "data_type": { + "$ref": "#/components/schemas/PropertyDataType" + }, + "is_array_type": { + "type": "boolean", + "default": false + }, + "enum_values": { + "type": "array", + "items": { + "type": "string" + } + }, + "regex": { + "type": ["string", "null"] + }, + "is_hidden": { + "type": "boolean", + "default": false + }, + "classifications": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + } + } + }, + "responses": { + "201": { + "description": "Event property created.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/EventProperty" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "409": { + "$ref": "#/components/responses/Problem" + }, + "500": { + "$ref": "#/components/responses/Problem" + } + } + } + }, + "/v1/projects/{project_id}/events/{event_id}/event-properties/{event_property_id}": { + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/EventId" + }, + { + "$ref": "#/components/parameters/EventPropertyId" + } + ], + "get": { + "tags": ["Taxonomy Event Properties"], + "operationId": "getEventProperty", + "summary": "Get event property", + "x-required-scopes": ["read:taxonomy"], + "responses": { + "200": { + "description": "Successful response.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/EventProperty" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "500": { + "$ref": "#/components/responses/Problem" + } + } + }, + "patch": { + "tags": ["Taxonomy Event Properties"], + "operationId": "updateEventProperty", + "summary": "Update event property metadata", + "description": "Updates metadata on an event property. Returns `422 not_in_schema` if\nthe property is ingested-only (`is_in_schema: false`); promote it via\n`POST` first. Field absent means leave unchanged; field with `null`\nclears (where allowed). `enum_values` and `classifications` replace the\narray entirely — there is no per-item add/remove.\n", + "x-required-scopes": ["write:taxonomy"], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "description": { + "type": ["string", "null"] + }, + "data_type": { + "$ref": "#/components/schemas/PropertyDataType" + }, + "is_array_type": { + "type": "boolean" + }, + "enum_values": { + "type": "array", + "items": { + "type": "string" + } + }, + "regex": { + "type": ["string", "null"] + }, + "is_hidden": { + "type": "boolean" + }, + "classifications": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "Event property updated.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/EventProperty" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "422": { + "description": "Property is not part of the project's tracking plan. Returned when\nattempting to PATCH a property with `is_in_schema: false`.\n", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "$ref": "#/components/responses/Problem" + } + } + }, + "delete": { + "tags": ["Taxonomy Event Properties"], + "operationId": "deleteEventProperty", + "summary": "Delete event property", + "x-required-scopes": ["write:taxonomy"], + "parameters": [ + { + "$ref": "#/components/parameters/DryRun" + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Event property deleted." + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "500": { + "$ref": "#/components/responses/Problem" + } + } + } + }, + "/v1/projects/{project_id}/user-properties": { + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "get": { + "tags": ["Taxonomy User Properties"], + "operationId": "listUserProperties", + "summary": "List user properties for a project", + "x-required-scopes": ["read:taxonomy"], + "parameters": [ + { + "$ref": "#/components/parameters/Cursor" + }, + { + "$ref": "#/components/parameters/Limit" + }, + { + "$ref": "#/components/parameters/Query" + } + ], + "responses": { + "200": { + "description": "Successful response.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "pagination"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserProperty" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "500": { + "$ref": "#/components/responses/Problem" + } + } + }, + "post": { + "tags": ["Taxonomy User Properties"], + "operationId": "createUserProperty", + "summary": "Create user property", + "description": "Creates a user property and adds it to the project's tracking plan.\nSame idempotency semantics as `createEventProperty`.\n", + "x-required-scopes": ["write:taxonomy"], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["property_name"], + "properties": { + "property_name": { + "type": "string", + "minLength": 1 + }, + "display_name": { + "type": ["string", "null"] + }, + "description": { + "type": ["string", "null"] + }, + "data_type": { + "$ref": "#/components/schemas/PropertyDataType" + }, + "is_array_type": { + "type": "boolean", + "default": false + }, + "enum_values": { + "type": "array", + "items": { + "type": "string" + } + }, + "regex": { + "type": ["string", "null"] + }, + "is_hidden": { + "type": "boolean", + "default": false + }, + "classifications": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + } + } + }, + "responses": { + "201": { + "description": "User property created.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/UserProperty" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "409": { + "$ref": "#/components/responses/Problem" + }, + "500": { + "$ref": "#/components/responses/Problem" + } + } + } + }, + "/v1/projects/{project_id}/user-properties/{user_property_id}": { + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/UserPropertyId" + } + ], + "get": { + "tags": ["Taxonomy User Properties"], + "operationId": "getUserProperty", + "summary": "Get user property", + "x-required-scopes": ["read:taxonomy"], + "responses": { + "200": { + "description": "Successful response.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/UserProperty" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "500": { + "$ref": "#/components/responses/Problem" + } + } + }, + "patch": { + "tags": ["Taxonomy User Properties"], + "operationId": "updateUserProperty", + "summary": "Update user property metadata", + "description": "Updates metadata on a user property. Returns `422 not_in_schema` if the\nproperty is ingested-only. Same patch semantics as `updateEventProperty`\n(field absent = unchanged, `null` = clear, arrays replace).\n\nOnly `display_name`, `description`, `is_hidden`, and `classifications`\nare mutable via PATCH today. Type/shape fields (`data_type`,\n`is_array_type`, `enum_values`, `regex`) are settable only at creation.\n`is_official` is read-only until TMS exposes a write path for it.\n", + "x-required-scopes": ["write:taxonomy"], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "display_name": { + "type": ["string", "null"] + }, + "description": { + "type": ["string", "null"] + }, + "is_hidden": { + "type": "boolean" + }, + "classifications": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "User property updated.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/UserProperty" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "422": { + "description": "Property is not part of the project's tracking plan. Returned when\nattempting to PATCH a property with `is_in_schema: false`.\n", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "$ref": "#/components/responses/Problem" + } + } + }, + "delete": { + "tags": ["Taxonomy User Properties"], + "operationId": "deleteUserProperty", + "summary": "Delete user property", + "x-required-scopes": ["write:taxonomy"], + "parameters": [ + { + "$ref": "#/components/parameters/DryRun" + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "User property deleted." + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "500": { + "$ref": "#/components/responses/Problem" + } + } + } + }, + "/v1/projects/{project_id}/flags": { + "get": { + "tags": ["Feature Flags"], + "operationId": "listFeatureFlags", + "summary": "List feature flags", + "x-required-scopes": ["read:flags"], + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/Cursor" + }, + { + "$ref": "#/components/parameters/Limit" + } + ], + "responses": { + "200": { + "description": "Successful response.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "pagination"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FeatureFlag" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "500": { + "$ref": "#/components/responses/Problem" + } + } + }, + "post": { + "tags": ["Feature Flags"], + "operationId": "createFeatureFlag", + "summary": "Create feature flag", + "x-required-scopes": ["write:flags"], + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["key", "name"], + "properties": { + "key": { + "type": "string", + "pattern": "^[a-z0-9_-]+$", + "description": "Lowercase letters, numbers, hyphens, and underscores only." + }, + "name": { + "type": "string" + }, + "description": { + "type": ["string", "null"] + }, + "evaluation_mode": { + "type": "string", + "enum": ["local", "remote"], + "default": "remote" + }, + "bucketing_key": { + "type": "string" + }, + "variants": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Variant" + } + }, + "deployment_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "rollout_percentage": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "rollout_weights": { + "$ref": "#/components/schemas/RolloutWeights" + }, + "testers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Tester" + } + }, + "target_segments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TargetSegmentInput" + } + } + }, + "additionalProperties": false + } + } + } + }, + "responses": { + "201": { + "description": "Feature flag created.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/FeatureFlag" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "409": { + "$ref": "#/components/responses/Problem" + }, + "500": { + "$ref": "#/components/responses/Problem" + } + } + } + }, + "/v1/projects/{project_id}/flags/{flag_id}": { + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/FlagId" + } + ], + "get": { + "tags": ["Feature Flags"], + "operationId": "getFeatureFlag", + "summary": "Get feature flag", + "x-required-scopes": ["read:flags"], + "responses": { + "200": { + "description": "Successful response.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/FeatureFlag" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "500": { + "$ref": "#/components/responses/Problem" + } + } + }, + "patch": { + "tags": ["Feature Flags"], + "operationId": "updateFeatureFlag", + "summary": "Update feature flag", + "description": "Updates feature flag metadata and rollout settings. Variant, tester, deployment, and link mutations will be exposed through dedicated subresources in a later slice.", + "x-required-scopes": ["write:flags"], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": ["string", "null"] + }, + "enabled": { + "type": "boolean" + }, + "evaluation_mode": { + "type": "string", + "enum": ["local", "remote"] + }, + "bucketing_key": { + "type": "string" + }, + "rollout_percentage": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "Feature flag updated.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/FeatureFlag" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "409": { + "$ref": "#/components/responses/Problem" + }, + "500": { + "$ref": "#/components/responses/Problem" + } + } + }, + "delete": { + "tags": ["Feature Flags"], + "operationId": "archiveFeatureFlag", + "summary": "Archive feature flag", + "x-required-scopes": ["write:flags"], + "parameters": [ + { + "$ref": "#/components/parameters/DryRun" + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "204": { + "description": "Feature flag archived." + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "500": { + "$ref": "#/components/responses/Problem" + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "parameters": { + "ProjectId": { + "name": "project_id", + "in": "path", + "required": true, + "description": "Amplitude project identifier, backed by the canonical app ID.", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "example": "12345" + } + }, + "Cursor": { + "name": "cursor", + "in": "query", + "required": false, + "description": "Cursor token from previous list response.", + "schema": { + "type": ["string", "null"] + } + }, + "Limit": { + "name": "limit", + "in": "query", + "required": false, + "description": "Number of items to return. Defaults to 50, max 200.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + } + }, + "Sort": { + "name": "sort", + "in": "query", + "required": false, + "description": "Comma-separated sort fields. Prefix with `-` for descending.", + "schema": { + "type": "string", + "example": "-created_at,name" + } + }, + "Query": { + "name": "q", + "in": "query", + "required": false, + "description": "Full-text search query.", + "schema": { + "type": "string" + } + }, + "DryRun": { + "name": "dry_run", + "in": "query", + "required": false, + "description": "Validate request without performing mutation.", + "schema": { + "type": "boolean", + "default": false + } + }, + "IdempotencyKey": { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "description": "Unique idempotency key used to safely retry write operations.", + "schema": { + "type": "string", + "minLength": 8, + "maxLength": 255 + } + }, + "EventId": { + "name": "event_id", + "in": "path", + "required": true, + "description": "Public event identifier. For the current TMS-backed API, this value is the event type.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "EventPropertyId": { + "name": "event_property_id", + "in": "path", + "required": true, + "description": "Public event property identifier. Equal to `property_name`. URL-encode\nwhen the property name contains spaces or special characters.\n", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "UserPropertyId": { + "name": "user_property_id", + "in": "path", + "required": true, + "description": "Public user property identifier. Equal to `property_name`. URL-encode\nwhen the property name contains spaces or special characters.\n", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "FlagId": { + "name": "flag_id", + "in": "path", + "required": true, + "description": "Feature flag identifier.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "ExperimentId": { + "name": "experiment_id", + "in": "path", + "required": true, + "description": "Experiment identifier. Experiments are backed by flag configurations and use the same identifier namespace.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "responses": { + "Problem": { + "description": "Error response encoded as RFC 9457 Problem Details.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "ValidationProblem": { + "description": "Validation failure response encoded as RFC 9457 Problem Details.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "schemas": { + "ObjectMeta": { + "type": "object", + "required": ["id", "object", "created_at", "updated_at"], + "properties": { + "id": { + "type": "string" + }, + "object": { + "type": "string", + "description": "Stable resource-type discriminator. Each resource sets a fixed value\n(declared via `const` on the concrete schema, e.g. `project`, `event`).\nClients can use this field to safely narrow polymorphic responses.\n" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "Pagination": { + "type": "object", + "required": ["next_cursor", "has_more"], + "properties": { + "next_cursor": { + "type": ["string", "null"] + }, + "has_more": { + "type": "boolean" + } + } + }, + "ProblemDetails": { + "type": "object", + "required": ["type", "title", "status", "error_code", "retryable"], + "properties": { + "type": { + "type": "string", + "format": "uri" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer", + "minimum": 400, + "maximum": 599 + }, + "detail": { + "type": ["string", "null"] + }, + "instance": { + "type": ["string", "null"], + "format": "uri" + }, + "error_code": { + "type": "string" + }, + "retryable": { + "type": "boolean" + }, + "retry_after_seconds": { + "type": ["integer", "null"], + "minimum": 0 + }, + "validation_errors": { + "type": ["array", "null"], + "items": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "ValidationError": { + "type": "object", + "required": ["field", "message", "code"], + "properties": { + "field": { + "type": "string" + }, + "message": { + "type": "string" + }, + "code": { + "type": "string" + } + } + }, + "Project": { + "allOf": [ + { + "$ref": "#/components/schemas/ObjectMeta" + }, + { + "type": "object", + "required": ["name"], + "properties": { + "id": { + "type": "string", + "pattern": "^[0-9]+$" + }, + "object": { + "type": "string", + "const": "project", + "description": "Resource-type discriminator. Always `project`." + }, + "name": { + "type": "string" + }, + "description": { + "type": ["string", "null"] + } + } + } + ] + }, + "Event": { + "type": "object", + "required": [ + "id", + "object", + "project_id", + "event_type", + "display_name", + "is_active" + ], + "properties": { + "id": { + "type": "string", + "description": "Canonical event identifier. Equal to `event_type`." + }, + "object": { + "type": "string", + "const": "event", + "description": "Resource-type discriminator. Always `event`." + }, + "project_id": { + "type": "string", + "pattern": "^[0-9]+$" + }, + "event_type": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "description": { + "type": ["string", "null"] + }, + "is_active": { + "type": "boolean" + } + } + }, + "EventProperty": { + "type": "object", + "required": [ + "id", + "object", + "project_id", + "event_type", + "property_name", + "is_hidden", + "is_in_schema", + "is_array_type", + "enum_values", + "classifications" + ], + "properties": { + "id": { + "type": "string", + "description": "Stable resource identifier. Equal to `property_name`.\n" + }, + "object": { + "type": "string", + "const": "event_property", + "description": "Resource-type discriminator. Always `event_property`." + }, + "project_id": { + "type": "string", + "pattern": "^[0-9]+$" + }, + "event_type": { + "type": "string", + "description": "Event this property is bound to." + }, + "property_name": { + "type": "string" + }, + "description": { + "type": ["string", "null"] + }, + "is_hidden": { + "type": "boolean", + "description": "Hidden from default UI surfaces and API listings." + }, + "is_in_schema": { + "type": "boolean", + "description": "Whether this property is part of the project's curated tracking\nplan. `false` means ingested-only (seen via SDK calls but never\nformally added). Properties with `is_in_schema: false` cannot be\nmodified — `PATCH` returns `422 not_in_schema` and metadata writes\nare rejected. `POST` implicitly transitions a property to\n`is_in_schema: true`.\n" + }, + "data_type": { + "$ref": "#/components/schemas/PropertyDataType" + }, + "is_array_type": { + "type": "boolean" + }, + "enum_values": { + "type": "array", + "items": { + "type": "string" + } + }, + "regex": { + "type": ["string", "null"] + }, + "classifications": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Data Access Controls (DAC) tags. Examples include `PII`,\n`SENSITIVE`, `INTERNAL`.\n" + } + } + }, + "UserProperty": { + "type": "object", + "required": [ + "id", + "object", + "project_id", + "property_name", + "is_hidden", + "is_in_schema", + "is_array_type", + "enum_values", + "classifications" + ], + "properties": { + "id": { + "type": "string", + "description": "Stable resource identifier. Equal to `property_name`.\n" + }, + "object": { + "type": "string", + "const": "user_property", + "description": "Resource-type discriminator. Always `user_property`." + }, + "project_id": { + "type": "string", + "pattern": "^[0-9]+$" + }, + "property_name": { + "type": "string" + }, + "display_name": { + "type": ["string", "null"] + }, + "description": { + "type": ["string", "null"] + }, + "is_official": { + "type": ["boolean", "null"], + "description": "Curated/official flag. `null` ⇒ inherited / unset upstream.\n" + }, + "is_hidden": { + "type": "boolean" + }, + "is_in_schema": { + "type": "boolean", + "description": "Whether this property is part of the project's curated tracking\nplan. Same semantics as `EventProperty.is_in_schema`.\n" + }, + "data_type": { + "$ref": "#/components/schemas/PropertyDataType" + }, + "is_array_type": { + "type": "boolean" + }, + "enum_values": { + "type": "array", + "items": { + "type": "string" + } + }, + "regex": { + "type": ["string", "null"] + }, + "classifications": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PropertyDataType": { + "description": "Logical data type of a property. `null` means untyped at the source;\nclients should treat it as `string` for ingestion.\n", + "type": ["string", "null"], + "enum": ["string", "number", "boolean", "object", "enum", "any", null] + }, + "FeatureFlag": { + "allOf": [ + { + "$ref": "#/components/schemas/FeatureFlagBase" + }, + { + "type": "object", + "required": ["object"], + "properties": { + "object": { + "type": "string", + "const": "feature_flag", + "description": "Resource-type discriminator. Always `feature_flag`." + } + } + } + ] + }, + "Variant": { + "type": "object", + "required": ["key"], + "properties": { + "key": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]+$", + "description": "Stable variant key. The value `off` is reserved." + }, + "name": { + "type": ["string", "null"] + }, + "description": { + "type": ["string", "null"] + }, + "payload": { + "description": "Optional JSON payload returned by evaluation." + } + } + }, + "TargetSegment": { + "type": "object", + "required": ["conditions"], + "properties": { + "name": { + "type": ["string", "null"] + }, + "conditions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TargetCondition" + } + }, + "percentage": { + "type": ["integer", "null"], + "minimum": 0, + "maximum": 100, + "description": "Segment rollout percentage." + }, + "rollout_weights": { + "$ref": "#/components/schemas/RolloutWeights" + }, + "bucketing_key": { + "type": ["string", "null"] + } + } + }, + "TargetSegmentInput": { + "type": "object", + "required": ["percentage", "rollout_weights"], + "properties": { + "name": { + "type": ["string", "null"] + }, + "conditions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TargetCondition" + }, + "description": "Omitted (or empty) for the catch-all \"all other users\" segment." + }, + "percentage": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Segment rollout percentage." + }, + "rollout_weights": { + "$ref": "#/components/schemas/RolloutWeights" + }, + "bucketing_key": { + "type": ["string", "null"] + } + } + }, + "DeviceAuthorizationRequest": { + "type": "object", + "required": ["code_challenge", "code_challenge_method"], + "properties": { + "scope": { + "type": "string", + "description": "Space-delimited OAuth scopes to request." + }, + "code_challenge": { + "type": "string", + "description": "PKCE code challenge (RFC 7636): the base64url-encoded SHA-256 of your code_verifier.\n" + }, + "code_challenge_method": { + "type": "string", + "enum": ["S256"], + "description": "PKCE challenge method. Must be S256." + } + } + }, + "DeviceAuthorizationResponse": { + "type": "object", + "required": [ + "device_code", + "user_code", + "verification_uri", + "expires_in" + ], + "properties": { + "device_code": { + "type": "string" + }, + "user_code": { + "type": "string" + }, + "verification_uri": { + "type": "string" + }, + "verification_uri_complete": { + "type": "string" + }, + "expires_in": { + "type": "integer" + }, + "interval": { + "type": "integer" + } + } + }, + "OAuthError": { + "type": "object", + "description": "RFC 6749 §5.2 OAuth error response.", + "required": ["error"], + "properties": { + "error": { + "type": "string" + }, + "error_description": { + "type": "string" + }, + "error_hint": { + "type": "string" + }, + "error_uri": { + "type": "string" + } + } + }, + "TokenRequest": { + "type": "object", + "required": ["grant_type", "device_code", "code_verifier"], + "properties": { + "grant_type": { + "type": "string", + "enum": ["urn:ietf:params:oauth:grant-type:device_code"], + "description": "The OAuth grant type. Must be `urn:ietf:params:oauth:grant-type:device_code`." + }, + "device_code": { + "type": "string", + "description": "The device_code from the device-authorization response." + }, + "code_verifier": { + "type": "string", + "description": "PKCE code verifier matching the code_challenge sent to the device-authorization endpoint.\n" + } + } + }, + "TokenResponse": { + "type": "object", + "required": ["access_token", "token_type", "expires_in"], + "properties": { + "access_token": { + "type": "string" + }, + "token_type": { + "type": "string" + }, + "expires_in": { + "type": "integer" + }, + "refresh_token": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "id_token": { + "type": "string" + } + } + }, + "RolloutWeights": { + "type": "object", + "additionalProperties": { + "type": "number", + "minimum": 0 + }, + "description": "Variant-keyed rollout weights. Values may be normalized to sum to 100 (percentages) or expressed as unnormalized relative weights (for example `{ \"control\": 1, \"treatment\": 1 }` for an even split). Weights are non-negative and may be fractional." + }, + "TargetCondition": { + "type": "object", + "required": ["prop", "op", "type", "values"], + "properties": { + "group_type": { + "type": ["string", "null"] + }, + "prop": { + "type": "string", + "minLength": 1, + "description": "Property name or cohort identifier being targeted." + }, + "prop_type": { + "type": ["string", "null"], + "description": "Namespace of the targeted property, for example `user`, `event`, or `group`. Omitted for conditions that target a cohort rather than a property." + }, + "op": { + "type": "string", + "description": "Targeting operator applied to the property values.", + "enum": [ + "is", + "is not", + "contains", + "does not contain", + "less", + "less or equal", + "greater", + "greater or equal", + "set is", + "set is not", + "set contains", + "set does not contain", + "glob match", + "glob does not match" + ] + }, + "type": { + "type": "string", + "description": "Condition type, for example `property` or `cohort`." + }, + "values": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + }, + "description": "At least one value to match against. Conditions with no values are rejected." + } + } + }, + "Tester": { + "type": "object", + "required": ["variant_key", "values"], + "properties": { + "variant_key": { + "type": "string" + }, + "values": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 2000, + "description": "User IDs, device IDs, or cohort identifiers included in this variant. At most 2000 entries per variant (the limit is 10 when the entries are cohort identifiers)." + } + } + }, + "ParentDependency": { + "type": "object", + "required": ["flag_id"], + "properties": { + "flag_id": { + "type": "string" + }, + "allowed_variants": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "ParentDependencies": { + "type": "object", + "required": ["operator", "flags"], + "properties": { + "operator": { + "type": "string", + "enum": ["all", "any"] + }, + "flags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ParentDependency" + } + } + } + }, + "Link": { + "type": "object", + "required": ["url", "title"], + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "title": { + "type": "string" + } + } + }, + "FeatureFlagBase": { + "type": "object", + "required": [ + "id", + "project_id", + "key", + "name", + "enabled", + "evaluation_mode", + "variants", + "bucketing_key", + "rollout_percentage", + "rollout_weights", + "target_segments", + "version", + "archived", + "tags" + ], + "properties": { + "id": { + "type": "string", + "readOnly": true + }, + "project_id": { + "type": "string", + "pattern": "^[0-9]+$", + "readOnly": true + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": ["string", "null"] + }, + "deployments": { + "type": "array", + "items": { + "type": "string" + }, + "readOnly": true, + "description": "Deployment IDs associated with this flag. Managed via the create/update deployment fields, not set directly on this field." + }, + "enabled": { + "type": "boolean" + }, + "evaluation_mode": { + "type": "string", + "enum": ["local", "remote"] + }, + "variants": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Variant" + } + }, + "bucketing_key": { + "type": "string", + "description": "Bucketing key used for assignment. Never null in responses; defaults to `amplitude_id` when not explicitly set." + }, + "bucketing_salt": { + "type": ["string", "null"], + "readOnly": true + }, + "bucketing_unit": { + "type": ["string", "null"] + }, + "rollout_percentage": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "rollout_weights": { + "$ref": "#/components/schemas/RolloutWeights" + }, + "target_segments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TargetSegment" + } + }, + "testers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Tester" + } + }, + "owners": { + "type": "array", + "items": { + "type": "string" + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "created_by": { + "type": ["string", "null"], + "readOnly": true + }, + "last_modified_by": { + "type": ["string", "null"], + "readOnly": true + }, + "created_at": { + "type": ["string", "null"], + "format": "date-time", + "readOnly": true + }, + "updated_at": { + "type": ["string", "null"], + "format": "date-time", + "readOnly": true + }, + "parent_dependencies": { + "allOf": [ + { + "$ref": "#/components/schemas/ParentDependencies" + } + ], + "readOnly": true + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Link" + } + }, + "version": { + "type": "integer", + "readOnly": true, + "description": "Server-managed config version used for optimistic concurrency." + }, + "archived": { + "type": "boolean", + "readOnly": true, + "description": "Whether the resource is archived. Set via the archive (DELETE) operation." + }, + "url": { + "type": ["string", "null"], + "format": "uri", + "readOnly": true + } + } + } + } + } +} diff --git a/openapi/bundled/openapi.bundled.yaml b/openapi/bundled/openapi.bundled.yaml new file mode 100644 index 0000000..f54e382 --- /dev/null +++ b/openapi/bundled/openapi.bundled.yaml @@ -0,0 +1,1973 @@ +openapi: 3.1.1 +info: + title: Amplitude Developer API + version: 0.1.0 + license: + name: Proprietary + url: https://amplitude.com/terms + summary: OpenAPI-first contract for Amplitude CLI, MCP, SDK, and docs. + description: | + Public developer-facing API contract for Amplitude resources. + This spec is intended to be the canonical source for generated CLI, MCP tools, + SDKs, and documentation. +jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema +servers: + - url: https://developer-api.amplitude.com + description: Production + - url: https://developer-api.stag2.amplitude.com + description: Staging +security: + - bearerAuth: [] +tags: + - name: Projects + description: Project discovery and project-scoped navigation. + - name: Taxonomy Events + description: Event taxonomy operations. + - name: Taxonomy Event Properties + description: Event-property taxonomy operations (scoped to an event). + - name: Taxonomy User Properties + description: User-property taxonomy operations (scoped to a project). + - name: Feature Flags + description: Feature flag configuration and rollout operations. + - name: Auth + description: OAuth device-flow and token endpoints (RFC 8628 / RFC 6749). +paths: + /v1/auth/device-authorization: + post: + tags: + - Auth + operationId: deviceAuthorization + summary: Start a device authorization request + description: | + RFC 8628 device authorization endpoint. Initiates the device flow and returns a device_code, user_code, and verification URIs for the user to approve out of band, then poll the token endpoint. + x-required-scopes: [] + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DeviceAuthorizationRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/DeviceAuthorizationRequest' + responses: + '200': + description: Device authorization started. + content: + application/json: + schema: + $ref: '#/components/schemas/DeviceAuthorizationResponse' + '400': + description: Invalid request (RFC 6749 §5.2 OAuth error). + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '401': + description: Client authentication failed (RFC 6749 §5.2 OAuth error). + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '500': + description: Server error (RFC 6749 §5.2 OAuth error). + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + /v1/auth/token: + post: + tags: + - Auth + operationId: token + summary: Exchange a grant for tokens + description: | + RFC 6749 §3.2 token endpoint. Supports the `device_code` grant (RFC 8628). Authenticated by the fixed confidential device-flow client (injected server-side). Does not require a Bearer token. + x-required-scopes: [] + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TokenRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/TokenRequest' + responses: + '200': + description: Tokens issued. + content: + application/json: + schema: + $ref: '#/components/schemas/TokenResponse' + '400': + description: Invalid or unsupported grant (RFC 6749 §5.2 OAuth error). + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '401': + description: Client authentication failed (RFC 6749 §5.2 OAuth error). + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '500': + description: Server error (RFC 6749 §5.2 OAuth error). + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + /v1/context: + get: + tags: + - Projects + operationId: getContext + summary: Get authenticated context + description: Returns the authenticated principal and organization context. + x-required-scopes: + - read:projects + responses: + '200': + description: Successful response. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + type: object + required: + - object + - principal + - org + properties: + object: + type: string + const: context + principal: + type: object + required: + - object + - login_id + - org_id + - auth_type + - scopes + properties: + object: + type: string + const: principal + login_id: + type: string + org_id: + type: string + org_name: + type: + - string + - 'null' + auth_type: + type: string + enum: + - oauth + - pat + - service_account + - api_key + scopes: + type: array + items: + type: string + org: + type: object + required: + - id + - object + - name + properties: + id: + type: string + object: + type: string + const: org + name: + type: + - string + - 'null' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' + /v1/projects: + get: + tags: + - Projects + operationId: listProjects + summary: List projects + description: Returns projects visible to the authenticated principal. + x-required-scopes: + - read:projects + parameters: + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Sort' + - $ref: '#/components/parameters/Query' + responses: + '200': + description: Successful response. + content: + application/json: + schema: + type: object + required: + - data + - pagination + properties: + data: + type: array + items: + $ref: '#/components/schemas/Project' + pagination: + $ref: '#/components/schemas/Pagination' + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' + /v1/projects/{project_id}/events: + get: + tags: + - Taxonomy Events + operationId: listEvents + summary: List event taxonomy entries + x-required-scopes: + - read:taxonomy + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Query' + responses: + '200': + description: Successful response. + content: + application/json: + schema: + type: object + required: + - data + - pagination + properties: + data: + type: array + items: + $ref: '#/components/schemas/Event' + pagination: + $ref: '#/components/schemas/Pagination' + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' + post: + tags: + - Taxonomy Events + operationId: createEvent + summary: Create event taxonomy entry + x-required-scopes: + - write:taxonomy + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - event_type + properties: + event_type: + type: string + description: + type: + - string + - 'null' + additionalProperties: false + responses: + '201': + description: Event created. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Event' + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '409': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' + /v1/projects/{project_id}/events/{event_id}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/EventId' + get: + tags: + - Taxonomy Events + operationId: getEvent + summary: Get event taxonomy entry + x-required-scopes: + - read:taxonomy + responses: + '200': + description: Successful response. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Event' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '404': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' + patch: + tags: + - Taxonomy Events + operationId: updateEvent + summary: Update event taxonomy entry + x-required-scopes: + - write:taxonomy + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + description: + type: + - string + - 'null' + is_active: + type: boolean + additionalProperties: false + responses: + '200': + description: Event updated. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Event' + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '404': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' + delete: + tags: + - Taxonomy Events + operationId: deleteEvent + summary: Soft-delete event taxonomy entry + x-required-scopes: + - write:taxonomy + parameters: + - $ref: '#/components/parameters/DryRun' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': + description: Event soft-deleted. + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '404': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' + /v1/projects/{project_id}/events/{event_id}/event-properties: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/EventId' + get: + tags: + - Taxonomy Event Properties + operationId: listEventProperties + summary: List event properties for an event + description: | + Returns properties bound to a specific event, including ingested-only + properties (those with `is_in_schema: false`). Use the `is_in_schema` + flag to detect whether a `PATCH` will succeed before issuing it. + x-required-scopes: + - read:taxonomy + parameters: + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Query' + responses: + '200': + description: Successful response. + content: + application/json: + schema: + type: object + required: + - data + - pagination + properties: + data: + type: array + items: + $ref: '#/components/schemas/EventProperty' + pagination: + $ref: '#/components/schemas/Pagination' + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '404': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' + post: + tags: + - Taxonomy Event Properties + operationId: createEventProperty + summary: Create event property + description: | + Creates an event property and adds it to the project's tracking plan + (`is_in_schema: true` after success). If the property already exists in + the schema, the existing resource is returned without modification. + If the property exists ingested-only (`is_in_schema: false`), this call + promotes it into the schema with the metadata supplied here. + x-required-scopes: + - write:taxonomy + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - property_name + properties: + property_name: + type: string + minLength: 1 + description: + type: + - string + - 'null' + data_type: + $ref: '#/components/schemas/PropertyDataType' + is_array_type: + type: boolean + default: false + enum_values: + type: array + items: + type: string + regex: + type: + - string + - 'null' + is_hidden: + type: boolean + default: false + classifications: + type: array + items: + type: string + additionalProperties: false + responses: + '201': + description: Event property created. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/EventProperty' + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '404': + $ref: '#/components/responses/Problem' + '409': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' + /v1/projects/{project_id}/events/{event_id}/event-properties/{event_property_id}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/EventId' + - $ref: '#/components/parameters/EventPropertyId' + get: + tags: + - Taxonomy Event Properties + operationId: getEventProperty + summary: Get event property + x-required-scopes: + - read:taxonomy + responses: + '200': + description: Successful response. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/EventProperty' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '404': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' + patch: + tags: + - Taxonomy Event Properties + operationId: updateEventProperty + summary: Update event property metadata + description: | + Updates metadata on an event property. Returns `422 not_in_schema` if + the property is ingested-only (`is_in_schema: false`); promote it via + `POST` first. Field absent means leave unchanged; field with `null` + clears (where allowed). `enum_values` and `classifications` replace the + array entirely — there is no per-item add/remove. + x-required-scopes: + - write:taxonomy + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + description: + type: + - string + - 'null' + data_type: + $ref: '#/components/schemas/PropertyDataType' + is_array_type: + type: boolean + enum_values: + type: array + items: + type: string + regex: + type: + - string + - 'null' + is_hidden: + type: boolean + classifications: + type: array + items: + type: string + additionalProperties: false + responses: + '200': + description: Event property updated. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/EventProperty' + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '404': + $ref: '#/components/responses/Problem' + '422': + description: | + Property is not part of the project's tracking plan. Returned when + attempting to PATCH a property with `is_in_schema: false`. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + $ref: '#/components/responses/Problem' + delete: + tags: + - Taxonomy Event Properties + operationId: deleteEventProperty + summary: Delete event property + x-required-scopes: + - write:taxonomy + parameters: + - $ref: '#/components/parameters/DryRun' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': + description: Event property deleted. + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '404': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' + /v1/projects/{project_id}/user-properties: + parameters: + - $ref: '#/components/parameters/ProjectId' + get: + tags: + - Taxonomy User Properties + operationId: listUserProperties + summary: List user properties for a project + x-required-scopes: + - read:taxonomy + parameters: + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Query' + responses: + '200': + description: Successful response. + content: + application/json: + schema: + type: object + required: + - data + - pagination + properties: + data: + type: array + items: + $ref: '#/components/schemas/UserProperty' + pagination: + $ref: '#/components/schemas/Pagination' + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '404': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' + post: + tags: + - Taxonomy User Properties + operationId: createUserProperty + summary: Create user property + description: | + Creates a user property and adds it to the project's tracking plan. + Same idempotency semantics as `createEventProperty`. + x-required-scopes: + - write:taxonomy + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - property_name + properties: + property_name: + type: string + minLength: 1 + display_name: + type: + - string + - 'null' + description: + type: + - string + - 'null' + data_type: + $ref: '#/components/schemas/PropertyDataType' + is_array_type: + type: boolean + default: false + enum_values: + type: array + items: + type: string + regex: + type: + - string + - 'null' + is_hidden: + type: boolean + default: false + classifications: + type: array + items: + type: string + additionalProperties: false + responses: + '201': + description: User property created. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/UserProperty' + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '404': + $ref: '#/components/responses/Problem' + '409': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' + /v1/projects/{project_id}/user-properties/{user_property_id}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/UserPropertyId' + get: + tags: + - Taxonomy User Properties + operationId: getUserProperty + summary: Get user property + x-required-scopes: + - read:taxonomy + responses: + '200': + description: Successful response. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/UserProperty' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '404': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' + patch: + tags: + - Taxonomy User Properties + operationId: updateUserProperty + summary: Update user property metadata + description: | + Updates metadata on a user property. Returns `422 not_in_schema` if the + property is ingested-only. Same patch semantics as `updateEventProperty` + (field absent = unchanged, `null` = clear, arrays replace). + + Only `display_name`, `description`, `is_hidden`, and `classifications` + are mutable via PATCH today. Type/shape fields (`data_type`, + `is_array_type`, `enum_values`, `regex`) are settable only at creation. + `is_official` is read-only until TMS exposes a write path for it. + x-required-scopes: + - write:taxonomy + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + display_name: + type: + - string + - 'null' + description: + type: + - string + - 'null' + is_hidden: + type: boolean + classifications: + type: array + items: + type: string + additionalProperties: false + responses: + '200': + description: User property updated. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/UserProperty' + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '404': + $ref: '#/components/responses/Problem' + '422': + description: | + Property is not part of the project's tracking plan. Returned when + attempting to PATCH a property with `is_in_schema: false`. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + $ref: '#/components/responses/Problem' + delete: + tags: + - Taxonomy User Properties + operationId: deleteUserProperty + summary: Delete user property + x-required-scopes: + - write:taxonomy + parameters: + - $ref: '#/components/parameters/DryRun' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': + description: User property deleted. + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '404': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' + /v1/projects/{project_id}/flags: + get: + tags: + - Feature Flags + operationId: listFeatureFlags + summary: List feature flags + x-required-scopes: + - read:flags + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: Successful response. + content: + application/json: + schema: + type: object + required: + - data + - pagination + properties: + data: + type: array + items: + $ref: '#/components/schemas/FeatureFlag' + pagination: + $ref: '#/components/schemas/Pagination' + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' + post: + tags: + - Feature Flags + operationId: createFeatureFlag + summary: Create feature flag + x-required-scopes: + - write:flags + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - key + - name + properties: + key: + type: string + pattern: ^[a-z0-9_-]+$ + description: Lowercase letters, numbers, hyphens, and underscores only. + name: + type: string + description: + type: + - string + - 'null' + evaluation_mode: + type: string + enum: + - local + - remote + default: remote + bucketing_key: + type: string + variants: + type: array + items: + $ref: '#/components/schemas/Variant' + deployment_ids: + type: array + items: + type: string + rollout_percentage: + type: integer + minimum: 0 + maximum: 100 + rollout_weights: + $ref: '#/components/schemas/RolloutWeights' + testers: + type: array + items: + $ref: '#/components/schemas/Tester' + target_segments: + type: array + items: + $ref: '#/components/schemas/TargetSegmentInput' + additionalProperties: false + responses: + '201': + description: Feature flag created. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/FeatureFlag' + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '409': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' + /v1/projects/{project_id}/flags/{flag_id}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/FlagId' + get: + tags: + - Feature Flags + operationId: getFeatureFlag + summary: Get feature flag + x-required-scopes: + - read:flags + responses: + '200': + description: Successful response. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/FeatureFlag' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '404': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' + patch: + tags: + - Feature Flags + operationId: updateFeatureFlag + summary: Update feature flag + description: Updates feature flag metadata and rollout settings. Variant, tester, deployment, and link mutations will be exposed through dedicated subresources in a later slice. + x-required-scopes: + - write:flags + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: + type: + - string + - 'null' + enabled: + type: boolean + evaluation_mode: + type: string + enum: + - local + - remote + bucketing_key: + type: string + rollout_percentage: + type: integer + minimum: 0 + maximum: 100 + tags: + type: array + items: + type: string + additionalProperties: false + responses: + '200': + description: Feature flag updated. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/FeatureFlag' + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '404': + $ref: '#/components/responses/Problem' + '409': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' + delete: + tags: + - Feature Flags + operationId: archiveFeatureFlag + summary: Archive feature flag + x-required-scopes: + - write:flags + parameters: + - $ref: '#/components/parameters/DryRun' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '204': + description: Feature flag archived. + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '404': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + parameters: + ProjectId: + name: project_id + in: path + required: true + description: Amplitude project identifier, backed by the canonical app ID. + schema: + type: string + pattern: ^[0-9]+$ + example: '12345' + Cursor: + name: cursor + in: query + required: false + description: Cursor token from previous list response. + schema: + type: + - string + - 'null' + Limit: + name: limit + in: query + required: false + description: Number of items to return. Defaults to 50, max 200. + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + Sort: + name: sort + in: query + required: false + description: Comma-separated sort fields. Prefix with `-` for descending. + schema: + type: string + example: '-created_at,name' + Query: + name: q + in: query + required: false + description: Full-text search query. + schema: + type: string + DryRun: + name: dry_run + in: query + required: false + description: Validate request without performing mutation. + schema: + type: boolean + default: false + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + description: Unique idempotency key used to safely retry write operations. + schema: + type: string + minLength: 8 + maxLength: 255 + EventId: + name: event_id + in: path + required: true + description: Public event identifier. For the current TMS-backed API, this value is the event type. + schema: + type: string + minLength: 1 + EventPropertyId: + name: event_property_id + in: path + required: true + description: | + Public event property identifier. Equal to `property_name`. URL-encode + when the property name contains spaces or special characters. + schema: + type: string + minLength: 1 + UserPropertyId: + name: user_property_id + in: path + required: true + description: | + Public user property identifier. Equal to `property_name`. URL-encode + when the property name contains spaces or special characters. + schema: + type: string + minLength: 1 + FlagId: + name: flag_id + in: path + required: true + description: Feature flag identifier. + schema: + type: string + minLength: 1 + ExperimentId: + name: experiment_id + in: path + required: true + description: Experiment identifier. Experiments are backed by flag configurations and use the same identifier namespace. + schema: + type: string + minLength: 1 + responses: + Problem: + description: Error response encoded as RFC 9457 Problem Details. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + ValidationProblem: + description: Validation failure response encoded as RFC 9457 Problem Details. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + schemas: + ObjectMeta: + type: object + required: + - id + - object + - created_at + - updated_at + properties: + id: + type: string + object: + type: string + description: | + Stable resource-type discriminator. Each resource sets a fixed value + (declared via `const` on the concrete schema, e.g. `project`, `event`). + Clients can use this field to safely narrow polymorphic responses. + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + Pagination: + type: object + required: + - next_cursor + - has_more + properties: + next_cursor: + type: + - string + - 'null' + has_more: + type: boolean + ProblemDetails: + type: object + required: + - type + - title + - status + - error_code + - retryable + properties: + type: + type: string + format: uri + title: + type: string + status: + type: integer + minimum: 400 + maximum: 599 + detail: + type: + - string + - 'null' + instance: + type: + - string + - 'null' + format: uri + error_code: + type: string + retryable: + type: boolean + retry_after_seconds: + type: + - integer + - 'null' + minimum: 0 + validation_errors: + type: + - array + - 'null' + items: + $ref: '#/components/schemas/ValidationError' + ValidationError: + type: object + required: + - field + - message + - code + properties: + field: + type: string + message: + type: string + code: + type: string + Project: + allOf: + - $ref: '#/components/schemas/ObjectMeta' + - type: object + required: + - name + properties: + id: + type: string + pattern: ^[0-9]+$ + object: + type: string + const: project + description: Resource-type discriminator. Always `project`. + name: + type: string + description: + type: + - string + - 'null' + Event: + type: object + required: + - id + - object + - project_id + - event_type + - display_name + - is_active + properties: + id: + type: string + description: Canonical event identifier. Equal to `event_type`. + object: + type: string + const: event + description: Resource-type discriminator. Always `event`. + project_id: + type: string + pattern: ^[0-9]+$ + event_type: + type: string + display_name: + type: string + description: + type: + - string + - 'null' + is_active: + type: boolean + EventProperty: + type: object + required: + - id + - object + - project_id + - event_type + - property_name + - is_hidden + - is_in_schema + - is_array_type + - enum_values + - classifications + properties: + id: + type: string + description: | + Stable resource identifier. Equal to `property_name`. + object: + type: string + const: event_property + description: Resource-type discriminator. Always `event_property`. + project_id: + type: string + pattern: ^[0-9]+$ + event_type: + type: string + description: Event this property is bound to. + property_name: + type: string + description: + type: + - string + - 'null' + is_hidden: + type: boolean + description: Hidden from default UI surfaces and API listings. + is_in_schema: + type: boolean + description: | + Whether this property is part of the project's curated tracking + plan. `false` means ingested-only (seen via SDK calls but never + formally added). Properties with `is_in_schema: false` cannot be + modified — `PATCH` returns `422 not_in_schema` and metadata writes + are rejected. `POST` implicitly transitions a property to + `is_in_schema: true`. + data_type: + $ref: '#/components/schemas/PropertyDataType' + is_array_type: + type: boolean + enum_values: + type: array + items: + type: string + regex: + type: + - string + - 'null' + classifications: + type: array + items: + type: string + description: | + Data Access Controls (DAC) tags. Examples include `PII`, + `SENSITIVE`, `INTERNAL`. + UserProperty: + type: object + required: + - id + - object + - project_id + - property_name + - is_hidden + - is_in_schema + - is_array_type + - enum_values + - classifications + properties: + id: + type: string + description: | + Stable resource identifier. Equal to `property_name`. + object: + type: string + const: user_property + description: Resource-type discriminator. Always `user_property`. + project_id: + type: string + pattern: ^[0-9]+$ + property_name: + type: string + display_name: + type: + - string + - 'null' + description: + type: + - string + - 'null' + is_official: + type: + - boolean + - 'null' + description: | + Curated/official flag. `null` ⇒ inherited / unset upstream. + is_hidden: + type: boolean + is_in_schema: + type: boolean + description: | + Whether this property is part of the project's curated tracking + plan. Same semantics as `EventProperty.is_in_schema`. + data_type: + $ref: '#/components/schemas/PropertyDataType' + is_array_type: + type: boolean + enum_values: + type: array + items: + type: string + regex: + type: + - string + - 'null' + classifications: + type: array + items: + type: string + PropertyDataType: + description: | + Logical data type of a property. `null` means untyped at the source; + clients should treat it as `string` for ingestion. + type: + - string + - 'null' + enum: + - string + - number + - boolean + - object + - enum + - any + - null + FeatureFlag: + allOf: + - $ref: '#/components/schemas/FeatureFlagBase' + - type: object + required: + - object + properties: + object: + type: string + const: feature_flag + description: Resource-type discriminator. Always `feature_flag`. + Variant: + type: object + required: + - key + properties: + key: + type: string + pattern: ^[A-Za-z0-9_-]+$ + description: Stable variant key. The value `off` is reserved. + name: + type: + - string + - 'null' + description: + type: + - string + - 'null' + payload: + description: Optional JSON payload returned by evaluation. + TargetSegment: + type: object + required: + - conditions + properties: + name: + type: + - string + - 'null' + conditions: + type: array + items: + $ref: '#/components/schemas/TargetCondition' + percentage: + type: + - integer + - 'null' + minimum: 0 + maximum: 100 + description: Segment rollout percentage. + rollout_weights: + $ref: '#/components/schemas/RolloutWeights' + bucketing_key: + type: + - string + - 'null' + TargetSegmentInput: + type: object + required: + - percentage + - rollout_weights + properties: + name: + type: + - string + - 'null' + conditions: + type: array + items: + $ref: '#/components/schemas/TargetCondition' + description: Omitted (or empty) for the catch-all "all other users" segment. + percentage: + type: integer + minimum: 0 + maximum: 100 + description: Segment rollout percentage. + rollout_weights: + $ref: '#/components/schemas/RolloutWeights' + bucketing_key: + type: + - string + - 'null' + DeviceAuthorizationRequest: + type: object + required: + - code_challenge + - code_challenge_method + properties: + scope: + type: string + description: Space-delimited OAuth scopes to request. + code_challenge: + type: string + description: | + PKCE code challenge (RFC 7636): the base64url-encoded SHA-256 of your code_verifier. + code_challenge_method: + type: string + enum: + - S256 + description: PKCE challenge method. Must be S256. + DeviceAuthorizationResponse: + type: object + required: + - device_code + - user_code + - verification_uri + - expires_in + properties: + device_code: + type: string + user_code: + type: string + verification_uri: + type: string + verification_uri_complete: + type: string + expires_in: + type: integer + interval: + type: integer + OAuthError: + type: object + description: RFC 6749 §5.2 OAuth error response. + required: + - error + properties: + error: + type: string + error_description: + type: string + error_hint: + type: string + error_uri: + type: string + TokenRequest: + type: object + required: + - grant_type + - device_code + - code_verifier + properties: + grant_type: + type: string + enum: + - urn:ietf:params:oauth:grant-type:device_code + description: The OAuth grant type. Must be `urn:ietf:params:oauth:grant-type:device_code`. + device_code: + type: string + description: The device_code from the device-authorization response. + code_verifier: + type: string + description: | + PKCE code verifier matching the code_challenge sent to the device-authorization endpoint. + TokenResponse: + type: object + required: + - access_token + - token_type + - expires_in + properties: + access_token: + type: string + token_type: + type: string + expires_in: + type: integer + refresh_token: + type: string + scope: + type: string + id_token: + type: string + RolloutWeights: + type: object + additionalProperties: + type: number + minimum: 0 + description: 'Variant-keyed rollout weights. Values may be normalized to sum to 100 (percentages) or expressed as unnormalized relative weights (for example `{ "control": 1, "treatment": 1 }` for an even split). Weights are non-negative and may be fractional.' + TargetCondition: + type: object + required: + - prop + - op + - type + - values + properties: + group_type: + type: + - string + - 'null' + prop: + type: string + minLength: 1 + description: Property name or cohort identifier being targeted. + prop_type: + type: + - string + - 'null' + description: Namespace of the targeted property, for example `user`, `event`, or `group`. Omitted for conditions that target a cohort rather than a property. + op: + type: string + description: Targeting operator applied to the property values. + enum: + - is + - is not + - contains + - does not contain + - less + - less or equal + - greater + - greater or equal + - set is + - set is not + - set contains + - set does not contain + - glob match + - glob does not match + type: + type: string + description: Condition type, for example `property` or `cohort`. + values: + type: array + minItems: 1 + items: + type: string + description: At least one value to match against. Conditions with no values are rejected. + Tester: + type: object + required: + - variant_key + - values + properties: + variant_key: + type: string + values: + type: array + items: + type: string + maxItems: 2000 + description: User IDs, device IDs, or cohort identifiers included in this variant. At most 2000 entries per variant (the limit is 10 when the entries are cohort identifiers). + ParentDependency: + type: object + required: + - flag_id + properties: + flag_id: + type: string + allowed_variants: + type: array + items: + type: string + ParentDependencies: + type: object + required: + - operator + - flags + properties: + operator: + type: string + enum: + - all + - any + flags: + type: array + items: + $ref: '#/components/schemas/ParentDependency' + Link: + type: object + required: + - url + - title + properties: + url: + type: string + format: uri + title: + type: string + FeatureFlagBase: + type: object + required: + - id + - project_id + - key + - name + - enabled + - evaluation_mode + - variants + - bucketing_key + - rollout_percentage + - rollout_weights + - target_segments + - version + - archived + - tags + properties: + id: + type: string + readOnly: true + project_id: + type: string + pattern: ^[0-9]+$ + readOnly: true + key: + type: string + name: + type: string + description: + type: + - string + - 'null' + deployments: + type: array + items: + type: string + readOnly: true + description: Deployment IDs associated with this flag. Managed via the create/update deployment fields, not set directly on this field. + enabled: + type: boolean + evaluation_mode: + type: string + enum: + - local + - remote + variants: + type: array + items: + $ref: '#/components/schemas/Variant' + bucketing_key: + type: string + description: Bucketing key used for assignment. Never null in responses; defaults to `amplitude_id` when not explicitly set. + bucketing_salt: + type: + - string + - 'null' + readOnly: true + bucketing_unit: + type: + - string + - 'null' + rollout_percentage: + type: integer + minimum: 0 + maximum: 100 + rollout_weights: + $ref: '#/components/schemas/RolloutWeights' + target_segments: + type: array + items: + $ref: '#/components/schemas/TargetSegment' + testers: + type: array + items: + $ref: '#/components/schemas/Tester' + owners: + type: array + items: + type: string + tags: + type: array + items: + type: string + created_by: + type: + - string + - 'null' + readOnly: true + last_modified_by: + type: + - string + - 'null' + readOnly: true + created_at: + type: + - string + - 'null' + format: date-time + readOnly: true + updated_at: + type: + - string + - 'null' + format: date-time + readOnly: true + parent_dependencies: + allOf: + - $ref: '#/components/schemas/ParentDependencies' + readOnly: true + links: + type: array + items: + $ref: '#/components/schemas/Link' + version: + type: integer + readOnly: true + description: Server-managed config version used for optimistic concurrency. + archived: + type: boolean + readOnly: true + description: Whether the resource is archived. Set via the archive (DELETE) operation. + url: + type: + - string + - 'null' + format: uri + readOnly: true diff --git a/package.json b/package.json new file mode 100644 index 0000000..96d8629 --- /dev/null +++ b/package.json @@ -0,0 +1,63 @@ +{ + "name": "@amplitude/developer-api", + "version": "0.1.0", + "description": "amp CLI and developer artifacts for managing Amplitude platform primitives via the Developer API", + "type": "commonjs", + "license": "MIT", + "homepage": "https://github.com/amplitude/developer-api#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/amplitude/developer-api.git" + }, + "bugs": { + "url": "https://github.com/amplitude/developer-api/issues" + }, + "keywords": [ + "amplitude", + "cli", + "amp", + "developer-api" + ], + "bin": { + "amp": "dist/cli.js" + }, + "files": [ + "dist/**/*", + "openapi/bundled/**/*", + "README.md", + "docs/**/*" + ], + "main": "dist/cli.js", + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "pnpm clean && tsgo --build tsconfig.build.json && pnpm bundle:cli", + "bundle:cli": "chmod +x dist/cli.js", + "clean": "rimraf ./dist tsconfig.build.tsbuildinfo", + "cli": "tsx src/cli.ts", + "smoke": "bash scripts/smoke.sh", + "smoke:cli": "bash scripts/smoke-cli.sh", + "test": "vitest run", + "test:typescript": "tsgo --project tsconfig.tsc.json --noEmit" + }, + "dependencies": { + "@inquirer/prompts": "^8.5.2", + "chalk": "^4.1.2", + "commander": "^15.0.0", + "open": "^11.0.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "22.19.17", + "@typescript/native-preview": "7.0.0-dev.20260601.1", + "rimraf": "^3.0.2", + "tsx": "4.7.2", + "typescript": "6.0.3", + "vitest": "^4.1.4" + }, + "engines": { + "node": "^22.22.0" + }, + "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..1d47951 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1688 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@inquirer/prompts': + specifier: ^8.5.2 + version: 8.5.2(@types/node@22.19.17) + chalk: + specifier: ^4.1.2 + version: 4.1.2 + commander: + specifier: ^15.0.0 + version: 15.0.0 + open: + specifier: ^11.0.0 + version: 11.0.0 + zod: + specifier: ^4.3.6 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: 22.19.17 + version: 22.19.17 + '@typescript/native-preview': + specifier: 7.0.0-dev.20260601.1 + version: 7.0.0-dev.20260601.1 + rimraf: + specifier: ^3.0.2 + version: 3.0.2 + tsx: + specifier: 4.7.2 + version: 4.7.2 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.4 + version: 4.1.9(@types/node@22.19.17)(vite@8.1.0(@types/node@22.19.17)(tsx@4.7.2)) + +packages: + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@esbuild/aix-ppc64@0.19.12': + resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.19.12': + resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.19.12': + resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.19.12': + resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.19.12': + resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.19.12': + resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.19.12': + resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.19.12': + resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.19.12': + resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.19.12': + resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.19.12': + resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.19.12': + resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.19.12': + resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.19.12': + resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.19.12': + resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.19.12': + resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.19.12': + resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.19.12': + resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.19.12': + resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.19.12': + resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.19.12': + resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.19.12': + resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.19.12': + resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@inquirer/ansi@2.0.7': + resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/checkbox@5.2.1': + resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@6.1.1': + resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@11.2.1': + resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@5.2.2': + resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@5.1.1': + resolution: {integrity: sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@3.0.3': + resolution: {integrity: sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.7': + resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/input@5.1.2': + resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@4.1.1': + resolution: {integrity: sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@5.1.1': + resolution: {integrity: sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@8.5.2': + resolution: {integrity: sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@5.3.1': + resolution: {integrity: sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@4.2.1': + resolution: {integrity: sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@5.2.1': + resolution: {integrity: sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@4.0.7': + resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.137.0': + resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} + + '@rolldown/binding-android-arm64@1.1.3': + resolution: {integrity: sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.3': + resolution: {integrity: sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.3': + resolution: {integrity: sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.3': + resolution: {integrity: sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + resolution: {integrity: sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.3': + resolution: {integrity: sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.3': + resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.3': + resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.3': + resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.3': + resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.3': + resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.3': + resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.3': + resolution: {integrity: sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.3': + resolution: {integrity: sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.3': + resolution: {integrity: sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260601.1': + resolution: {integrity: sha512-d4PVG8gcotwb5eJiMK14GdFOXRCH3tx4nAJxBcwJ3ZxPJ8Se3eQ54NZMeuuulU9isMJgtVxusAgjI8Qkhg4xDg==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260601.1': + resolution: {integrity: sha512-JJ34gLHbemqkRRuWshdWYL+WFXVYMmivRKD6qyHkhLGWmq3AmPaAsjNoscdaDRWU3GrQm1gI2hx8N+BpaJow7Q==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260601.1': + resolution: {integrity: sha512-Pr3/dVouvJ1So1buF6OZ5sUJ2Ij8BOX2vZXugJeoyeSgCjkhsZO4IJ3hW87aY3KpFrgJMLimKy2Ud1S3o9wPxQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260601.1': + resolution: {integrity: sha512-8jMjFrryB4hosxgKY6R1RJA9imUvxwk99AoRcfmy4xHZ7pLQ/QgAxElZaISOT8/gXQfVWppfI2Sj5YakgiAqyQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260601.1': + resolution: {integrity: sha512-FJUXnaSz8OWZSQiX02Sd51pXQSpZEHvkChuM/UxTsJ1kfRCzx59jB5fmN3JLiRoUizJIa0rhxu/+YA+/c/euqw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260601.1': + resolution: {integrity: sha512-tzc4eMCpdu+rHKxNUzWclm107WoCIbbcYrDg5DU5/07Wk5QlXG6bXsaKILpXxA60tyseVLbRe4wv6Ww79X7EgA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260601.1': + resolution: {integrity: sha512-H0YctU7f48BpxTaGTdK7f1UT5+1EY6pg7vMK9zBQXVhc8wSrCpQg4O/rf7kM8vyLUsC/pJpTE5OLUlxk+addCg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@typescript/native-preview@7.0.0-dev.20260601.1': + resolution: {integrity: sha512-IAoqEMiW2aw3m74TLQbNI1V235p9Sk8XhvvKTw2U5YAOfkPEFZaoYFvmoqWA/d2OLap1D3atyNbtD/dub7Jn9g==} + engines: {node: '>=16.20.0'} + hasBin: true + + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@1.1.15: + resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + esbuild@0.19.12: + resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==} + engines: {node: '>=12'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rolldown@1.1.3: + resolution: {integrity: sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.7.2: + resolution: {integrity: sha512-BCNd4kz6fz12fyrgCTEdZHGJ9fWTGeUzXmQysh0RVocDY3h4frk05ZNCXSy4kIenF7y/QnrdiVpTsyNRn6vlAw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite@8.1.0: + resolution: {integrity: sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.19.12': + optional: true + + '@esbuild/android-arm64@0.19.12': + optional: true + + '@esbuild/android-arm@0.19.12': + optional: true + + '@esbuild/android-x64@0.19.12': + optional: true + + '@esbuild/darwin-arm64@0.19.12': + optional: true + + '@esbuild/darwin-x64@0.19.12': + optional: true + + '@esbuild/freebsd-arm64@0.19.12': + optional: true + + '@esbuild/freebsd-x64@0.19.12': + optional: true + + '@esbuild/linux-arm64@0.19.12': + optional: true + + '@esbuild/linux-arm@0.19.12': + optional: true + + '@esbuild/linux-ia32@0.19.12': + optional: true + + '@esbuild/linux-loong64@0.19.12': + optional: true + + '@esbuild/linux-mips64el@0.19.12': + optional: true + + '@esbuild/linux-ppc64@0.19.12': + optional: true + + '@esbuild/linux-riscv64@0.19.12': + optional: true + + '@esbuild/linux-s390x@0.19.12': + optional: true + + '@esbuild/linux-x64@0.19.12': + optional: true + + '@esbuild/netbsd-x64@0.19.12': + optional: true + + '@esbuild/openbsd-x64@0.19.12': + optional: true + + '@esbuild/sunos-x64@0.19.12': + optional: true + + '@esbuild/win32-arm64@0.19.12': + optional: true + + '@esbuild/win32-ia32@0.19.12': + optional: true + + '@esbuild/win32-x64@0.19.12': + optional: true + + '@inquirer/ansi@2.0.7': {} + + '@inquirer/checkbox@5.2.1(@types/node@22.19.17)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@22.19.17) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@22.19.17) + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/confirm@6.1.1(@types/node@22.19.17)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.19.17) + '@inquirer/type': 4.0.7(@types/node@22.19.17) + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/core@11.2.1(@types/node@22.19.17)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@22.19.17) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/editor@5.2.2(@types/node@22.19.17)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.19.17) + '@inquirer/external-editor': 3.0.3(@types/node@22.19.17) + '@inquirer/type': 4.0.7(@types/node@22.19.17) + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/expand@5.1.1(@types/node@22.19.17)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.19.17) + '@inquirer/type': 4.0.7(@types/node@22.19.17) + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/external-editor@3.0.3(@types/node@22.19.17)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/figures@2.0.7': {} + + '@inquirer/input@5.1.2(@types/node@22.19.17)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.19.17) + '@inquirer/type': 4.0.7(@types/node@22.19.17) + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/number@4.1.1(@types/node@22.19.17)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.19.17) + '@inquirer/type': 4.0.7(@types/node@22.19.17) + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/password@5.1.1(@types/node@22.19.17)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@22.19.17) + '@inquirer/type': 4.0.7(@types/node@22.19.17) + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/prompts@8.5.2(@types/node@22.19.17)': + dependencies: + '@inquirer/checkbox': 5.2.1(@types/node@22.19.17) + '@inquirer/confirm': 6.1.1(@types/node@22.19.17) + '@inquirer/editor': 5.2.2(@types/node@22.19.17) + '@inquirer/expand': 5.1.1(@types/node@22.19.17) + '@inquirer/input': 5.1.2(@types/node@22.19.17) + '@inquirer/number': 4.1.1(@types/node@22.19.17) + '@inquirer/password': 5.1.1(@types/node@22.19.17) + '@inquirer/rawlist': 5.3.1(@types/node@22.19.17) + '@inquirer/search': 4.2.1(@types/node@22.19.17) + '@inquirer/select': 5.2.1(@types/node@22.19.17) + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/rawlist@5.3.1(@types/node@22.19.17)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.19.17) + '@inquirer/type': 4.0.7(@types/node@22.19.17) + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/search@4.2.1(@types/node@22.19.17)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.19.17) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@22.19.17) + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/select@5.2.1(@types/node@22.19.17)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@22.19.17) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@22.19.17) + optionalDependencies: + '@types/node': 22.19.17 + + '@inquirer/type@4.0.7(@types/node@22.19.17)': + optionalDependencies: + '@types/node': 22.19.17 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.137.0': {} + + '@rolldown/binding-android-arm64@1.1.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.3': + optional: true + + '@rolldown/binding-darwin-x64@1.1.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.3': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@22.19.17': + dependencies: + undici-types: 6.21.0 + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260601.1': + optional: true + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260601.1': + optional: true + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260601.1': + optional: true + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260601.1': + optional: true + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260601.1': + optional: true + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260601.1': + optional: true + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260601.1': + optional: true + + '@typescript/native-preview@7.0.0-dev.20260601.1': + optionalDependencies: + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260601.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260601.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260601.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260601.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260601.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260601.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260601.1 + + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@22.19.17)(tsx@4.7.2))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.0(@types/node@22.19.17)(tsx@4.7.2) + + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.9': {} + + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + assertion-error@2.0.1: {} + + balanced-match@1.0.2: {} + + brace-expansion@1.1.15: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + chai@6.2.2: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chardet@2.2.0: {} + + cli-width@4.1.0: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@15.0.0: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + + detect-libc@2.1.2: {} + + es-module-lexer@2.1.0: {} + + esbuild@0.19.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.19.12 + '@esbuild/android-arm': 0.19.12 + '@esbuild/android-arm64': 0.19.12 + '@esbuild/android-x64': 0.19.12 + '@esbuild/darwin-arm64': 0.19.12 + '@esbuild/darwin-x64': 0.19.12 + '@esbuild/freebsd-arm64': 0.19.12 + '@esbuild/freebsd-x64': 0.19.12 + '@esbuild/linux-arm': 0.19.12 + '@esbuild/linux-arm64': 0.19.12 + '@esbuild/linux-ia32': 0.19.12 + '@esbuild/linux-loong64': 0.19.12 + '@esbuild/linux-mips64el': 0.19.12 + '@esbuild/linux-ppc64': 0.19.12 + '@esbuild/linux-riscv64': 0.19.12 + '@esbuild/linux-s390x': 0.19.12 + '@esbuild/linux-x64': 0.19.12 + '@esbuild/netbsd-x64': 0.19.12 + '@esbuild/openbsd-x64': 0.19.12 + '@esbuild/sunos-x64': 0.19.12 + '@esbuild/win32-arm64': 0.19.12 + '@esbuild/win32-ia32': 0.19.12 + '@esbuild/win32-x64': 0.19.12 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + has-flag@4.0.0: {} + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + is-docker@3.0.0: {} + + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.15 + + mute-stream@3.0.0: {} + + nanoid@3.3.15: {} + + obug@2.1.3: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + + path-is-absolute@1.0.1: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + powershell-utils@0.1.0: {} + + resolve-pkg-maps@1.0.0: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rolldown@1.1.3: + dependencies: + '@oxc-project/types': 0.137.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.3 + '@rolldown/binding-darwin-arm64': 1.1.3 + '@rolldown/binding-darwin-x64': 1.1.3 + '@rolldown/binding-freebsd-x64': 1.1.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.3 + '@rolldown/binding-linux-arm64-gnu': 1.1.3 + '@rolldown/binding-linux-arm64-musl': 1.1.3 + '@rolldown/binding-linux-ppc64-gnu': 1.1.3 + '@rolldown/binding-linux-s390x-gnu': 1.1.3 + '@rolldown/binding-linux-x64-gnu': 1.1.3 + '@rolldown/binding-linux-x64-musl': 1.1.3 + '@rolldown/binding-openharmony-arm64': 1.1.3 + '@rolldown/binding-wasm32-wasi': 1.1.3 + '@rolldown/binding-win32-arm64-msvc': 1.1.3 + '@rolldown/binding-win32-x64-msvc': 1.1.3 + + run-applescript@7.1.0: {} + + safer-buffer@2.1.2: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tslib@2.8.1: + optional: true + + tsx@4.7.2: + dependencies: + esbuild: 0.19.12 + get-tsconfig: 4.14.0 + optionalDependencies: + fsevents: 2.3.3 + + typescript@6.0.3: {} + + undici-types@6.21.0: {} + + vite@8.1.0(@types/node@22.19.17)(tsx@4.7.2): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.1.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.19.17 + fsevents: 2.3.3 + tsx: 4.7.2 + + vitest@4.1.9(@types/node@22.19.17)(vite@8.1.0(@types/node@22.19.17)(tsx@4.7.2)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@22.19.17)(tsx@4.7.2)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.1.0 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.0(@types/node@22.19.17)(tsx@4.7.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.17 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrappy@1.0.2: {} + + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + + zod@4.4.3: {} diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..ee4d805 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "node" + } + } +} diff --git a/scripts/smoke-cli.sh b/scripts/smoke-cli.sh new file mode 100755 index 0000000..6a966f8 --- /dev/null +++ b/scripts/smoke-cli.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# +# Packaging smoke for the amp CLI. +# +# Builds, packs, installs the resulting tarball into a throwaway global prefix, +# and asserts the `amp` bin loads (`amp --version`, `amp --help` exit 0). This +# catches packaging mistakes that unit tests cannot — missing `files` entries, +# a broken `bin`, or a lost executable bit — before they ship to npm. +# +# Unlike scripts/smoke.sh this does NOT hit the Amplitude API and needs no PAT. +# It is meant for PR CI (it only talks to the npm registry to resolve deps). +# +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +PACK_DIR="$(mktemp -d)" +PREFIX="$(mktemp -d)" +cleanup() { rm -rf "$PACK_DIR" "$PREFIX"; } +trap cleanup EXIT + +echo "==> Building" +pnpm build + +echo "==> Packing" +pnpm pack --pack-destination "$PACK_DIR" >/dev/null +TARBALL="$(ls "$PACK_DIR"/*.tgz | head -n 1)" +echo " $TARBALL" + +echo "==> Installing tarball into throwaway global prefix" +export npm_config_prefix="$PREFIX" +export PATH="$PREFIX/bin:$PATH" +npm install -g "$TARBALL" + +echo "==> amp --version" +amp --version + +echo "==> amp --help" +amp --help >/dev/null + +echo "==> Packaging smoke passed" diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 0000000..bbdc9bc --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CLI=(pnpm --dir "$ROOT" cli --) + +BASE_URL="${AMP_API_BASE_URL:-https://developer-api.amplitude.com}" +BASE_URL="${BASE_URL%/}" + +# A credential must resolve: an active profile (amp auth login), AMP_TOKEN, or +# AMP_PROFILE. Probe via `amp auth token`, which exits non-zero when nothing +# resolves. +if ! "${CLI[@]}" --base-url "$BASE_URL" auth token >/dev/null 2>&1; then + echo "ERROR: No credential resolves. Run \`amp auth login\` or set AMP_TOKEN before running smoke tests." + exit 1 +fi + +echo "==> Smoke: health ($BASE_URL)" +health_status="$(curl -s -o /tmp/amp-health.json -w '%{http_code}' "$BASE_URL/health")" +if [[ "$health_status" != "200" ]]; then + echo "FAIL: /health returned $health_status" + cat /tmp/amp-health.json + exit 1 +fi +echo "OK: /health" + +echo "==> Smoke: context" +"${CLI[@]}" --base-url "$BASE_URL" context + +echo "==> Smoke: projects list" +"${CLI[@]}" --base-url "$BASE_URL" projects list --limit 3 + +echo "==> Smoke passed (read-only checks). For write checks, run manually with a disposable project." +echo "See docs/cli.md for the full checklist." diff --git a/src/args.test.ts b/src/args.test.ts new file mode 100644 index 0000000..9ebded0 --- /dev/null +++ b/src/args.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; + +import { isFlagEnabled, parseArgs } from './args'; + +describe('parseArgs', () => { + it('separates command tokens from flags', () => { + const { command, flags } = parseArgs(['flags', 'list', '--project', '123']); + expect(command).toEqual(['flags', 'list']); + expect(flags).toEqual({ project: '123' }); + }); + + it('parses --name=value equals form', () => { + expect(parseArgs(['--yes=true']).flags).toEqual({ yes: 'true' }); + }); + + it('treats a flag with no value as a boolean switch', () => { + expect(parseArgs(['--dry-run']).flags).toEqual({ 'dry-run': true }); + }); + + it('parses short switches -h and -v as booleans', () => { + expect(parseArgs(['-h']).flags).toEqual({ h: true }); + expect(parseArgs(['-v']).flags).toEqual({ v: true }); + }); + + it('stops flag parsing at -- and keeps it out of the command', () => { + const { command } = parseArgs(['context', '--']); + expect(command).toEqual(['context']); + }); + + it('ignores pnpm run-script leading -- separator', () => { + const { command, flags } = parseArgs(['--', 'auth', '--open']); + expect(command).toEqual(['auth']); + expect(flags).toEqual({ open: true }); + }); + + it('throws when a string option is passed without a value', () => { + expect(() => parseArgs(['context', '--token'])).toThrowError( + "option '--token ' argument missing", + ); + }); + + it('parses generated command options after command tokens', () => { + const { command, flags } = parseArgs([ + 'events', + 'list', + '--project', + '187520', + '--limit', + '10', + ]); + + expect(command).toEqual(['events', 'list']); + expect(flags).toMatchObject({ project: '187520', limit: '10' }); + }); + + it('parses auth token device-flow options', () => { + const { command, flags } = parseArgs([ + 'auth', + 'token', + '--flow', + 'device', + '--scope', + 'openid email', + ]); + + expect(command).toEqual(['auth', 'token']); + expect(flags).toMatchObject({ flow: 'device', scope: 'openid email' }); + }); +}); + +describe('isFlagEnabled', () => { + it('treats boolean true and string "true" as enabled', () => { + expect(isFlagEnabled(true)).toBe(true); + expect(isFlagEnabled('true')).toBe(true); + }); + + it('treats everything else as disabled', () => { + expect(isFlagEnabled(false)).toBe(false); + expect(isFlagEnabled('false')).toBe(false); + expect(isFlagEnabled('')).toBe(false); + expect(isFlagEnabled(undefined)).toBe(false); + }); +}); diff --git a/src/args.ts b/src/args.ts new file mode 100644 index 0000000..3fd56b9 --- /dev/null +++ b/src/args.ts @@ -0,0 +1,173 @@ +import { Command, CommanderError, Option } from 'commander'; + +import { CLI_OPERATIONS } from './generated/cli-manifest'; + +export type FlagValue = boolean | string; + +export interface ParsedArgs { + command: string[]; + flags: Record; +} + +type ValueRequirement = 'optional' | 'required'; + +interface CliOptionDefinition { + aliases: string[]; + valueRequirement: ValueRequirement; +} + +const GLOBAL_OPTIONS: CliOptionDefinition[] = [ + { aliases: ['token'], valueRequirement: 'required' }, + { aliases: ['base-url'], valueRequirement: 'required' }, + { aliases: ['body-json'], valueRequirement: 'required' }, + { aliases: ['json'], valueRequirement: 'optional' }, + { aliases: ['yes'], valueRequirement: 'optional' }, + { aliases: ['open'], valueRequirement: 'optional' }, + { aliases: ['flow'], valueRequirement: 'required' }, + { aliases: ['scope'], valueRequirement: 'required' }, + { aliases: ['profile'], valueRequirement: 'required' }, + { aliases: ['env'], valueRequirement: 'required' }, + { aliases: ['with-token'], valueRequirement: 'optional' }, + { aliases: ['all'], valueRequirement: 'optional' }, + { aliases: ['help', 'h'], valueRequirement: 'optional' }, + { aliases: ['version', 'v'], valueRequirement: 'optional' }, +]; + +function attributeName(alias: string): string { + return alias.replace(/-([a-z])/g, (_, letter: string) => + letter.toUpperCase(), + ); +} + +function defineOption( + program: Command, + alias: string, + valueRequirement: ValueRequirement, +): void { + const valuePlaceholder = + valueRequirement === 'optional' ? '[value]' : ''; + const flags = + alias.length === 1 + ? `-${alias}, --${alias} ${valuePlaceholder}` + : `--${alias} ${valuePlaceholder}`; + + program.addOption(new Option(flags).hideHelp()); +} + +function optionDefinitions(): CliOptionDefinition[] { + const byAlias = new Map(); + + for (const option of GLOBAL_OPTIONS) { + for (const alias of option.aliases) { + byAlias.set(alias, option.valueRequirement); + } + } + + for (const operation of CLI_OPERATIONS) { + for (const option of [...operation.parameters, ...operation.body]) { + const valueRequirement: ValueRequirement = + option.type === 'boolean' ? 'optional' : 'required'; + for (const alias of option.aliases) { + const previous = byAlias.get(alias); + // If an alias is ever shared across commands with different shapes, + // optional boolean parsing is the safer universal form: bare flags + // still work, and explicit string values keep flowing to request + // validation for command-specific errors. + byAlias.set( + alias, + previous === 'optional' || valueRequirement === 'optional' + ? 'optional' + : 'required', + ); + } + } + } + + return [...byAlias.entries()].map(([alias, valueRequirement]) => ({ + aliases: [alias], + valueRequirement, + })); +} + +function createParser(): Command { + const program = new Command('amp'); + program + .exitOverride() + .configureOutput({ writeErr: () => undefined }) + .helpOption(false) + .allowExcessArguments(true) + .argument('[command...]'); + + for (const option of optionDefinitions()) { + defineOption(program, option.aliases[0], option.valueRequirement); + } + + return program; +} + +export function parseArgs(argv: string[]): ParsedArgs { + const flags: Record = {}; + const program = createParser(); + const normalizedArgv = argv[0] === '--' ? argv.slice(1) : argv; + + try { + program.parse(normalizedArgv, { from: 'user' }); + } catch (error) { + if (error instanceof CommanderError) { + throw new Error(error.message.replace(/^error: /, '')); + } + throw error; + } + + const opts = program.opts>(); + for (const option of optionDefinitions()) { + const alias = option.aliases[0]; + const value = opts[attributeName(alias)]; + if (value !== undefined) { + flags[alias] = value; + } + } + + const command = program.args.filter((arg) => arg !== '--'); + return { command, flags }; +} + +export function flagValue( + flags: Record, + aliases: string[], +): FlagValue | undefined { + for (const name of aliases) { + const value = flags[name]; + if (value !== undefined) { + return value; + } + } + + return undefined; +} + +export function stringFlag( + flags: Record, + aliases: string[], +): string | undefined { + const value = flagValue(flags, aliases); + if (value === true) { + throw new Error(`Expected --${aliases[0]} to have a value.`); + } + return typeof value === 'string' ? value : undefined; +} + +export function hasFlag( + flags: Record, + aliases: string[], +): boolean { + return aliases.some((name) => flags[name] !== undefined); +} + +export function isFlagEnabled(value: FlagValue | undefined): boolean { + return value === true || value === 'true'; +} + +export function isMissingRequiredValue(value: FlagValue | undefined): boolean { + return value === undefined || value === ''; +} diff --git a/src/auth-commands.ts b/src/auth-commands.ts new file mode 100644 index 0000000..9f36c6f --- /dev/null +++ b/src/auth-commands.ts @@ -0,0 +1,570 @@ +/* eslint-disable no-console */ +import { text } from 'node:stream/consumers'; + +import { type FlagValue, isFlagEnabled, stringFlag } from './args'; +import { personalAccessTokenSetupUrl, resolveOrgUrl } from './auth-guidance'; +import { + type DeviceFlowOptions, + createAnonymousRequest, + requestDeviceToken, +} from './authToken'; +import { ENV_BASE_URLS, resolveEnvBaseUrl } from './config'; +import { + resolveAuthFromFlags, + selectProfileFromFlags, +} from './credential-resolver'; +import { + type Credential, + type CredentialStore, + type OAuthCredential, + type PatCredential, + type Profile, + assertValidProfileName, + emptyStore, + getProfile, + loadStore, + oauthCredentialSchema, + removeProfile, + saveStore, + setDefault, + setProfile, +} from './credential-store'; +import type { TokenResponse } from './oauthResponseSchemas'; +import { askSecret, confirm } from './prompt'; +import { DEFAULT_SCOPES } from './scopes'; +import { terminal } from './terminal'; + +/** Builds an OAuth credential record from a freshly minted device-flow token. */ +export function oauthCredentialFromToken( + token: TokenResponse, + now: number, +): OAuthCredential { + return { + type: 'oauth', + access_token: token.access_token, + token_type: token.token_type, + expires_at: new Date(now + token.expires_in * 1000).toISOString(), + refresh_token: token.refresh_token ?? undefined, + scope: token.scope ?? undefined, + }; +} + +/** + * The base URL a login targets. Force-explicit: creating a profile needs an + * explicit `--base-url` or `--env`; re-authing an existing profile reuses the + * env recorded on it (so a bare re-login keeps working). + */ +export function loginBaseUrl(args: { + baseUrlFlag?: string; + envFlag?: string; + existing?: Profile; +}): string { + if (args.baseUrlFlag) { + return args.baseUrlFlag.replace(/\/$/, ''); + } + if (args.envFlag) { + return resolveEnvBaseUrl(args.envFlag); + } + if (args.existing) { + return args.existing.base_url; + } + throw new Error( + 'Creating a profile requires --env or --base-url .', + ); +} + +export interface AuthLoginDeps { + requestToken?: (options: DeviceFlowOptions) => Promise; + now?: () => number; + path?: string; + stdout?: (line: string) => void; + stderr?: (line: string) => void; + confirm?: (message: string) => Promise; +} + +/** + * `amp auth login` — runs the device flow, saves the token as an OAuth profile, + * and activates it (announcing the switch). Requires an explicit `--profile`; + * creating a profile also requires `--env`/`--base-url`. + */ +export async function runAuthLogin( + flags: Record, + deps: AuthLoginDeps = {}, +): Promise { + const now = deps.now ?? (() => Date.now()); + const emitStdout = deps.stdout ?? ((line) => console.log(line)); + const emitStderr = deps.stderr ?? ((line) => console.error(line)); + const confirmOverwrite = deps.confirm ?? confirm; + const requestToken = deps.requestToken ?? requestDeviceToken; + + const store = loadStore(deps.path); + + // No --profile re-auths the active profile in place (force-explicit applies to + // *creating* a profile, not refreshing one — the default's env+name are already + // recorded). With no default there's nothing to refresh, so require both flags. + // Validate the user-supplied name only; a stored default was already validated + // when it was created. + const requestedName = stringFlag(flags, ['profile']); + if (requestedName !== undefined) { + assertValidProfileName(requestedName); + } + const profileName = requestedName ?? store.default; + if (!profileName) { + throw new Error( + 'No default profile to re-authenticate. Run `amp auth login --profile --env `.', + ); + } + + const existing = getProfile(store, profileName); + + // Orphan default: the name came from the store's `default` but that profile is + // gone (e.g. a hand-edited file). The user meant to refresh, not create, so + // match the resolver's "No such profile" instead of falling into + // loginBaseUrl's misleading "creating a profile requires --env" error. A + // user-supplied --profile with no match is the legitimate create path. + if (requestedName === undefined && !existing) { + throw new Error(`No such profile: ${profileName}. Run \`amp auth list\`.`); + } + + const baseUrl = loginBaseUrl({ + baseUrlFlag: stringFlag(flags, ['base-url']), + envFlag: stringFlag(flags, ['env']), + existing, + }); + + // Reusing a name for a different target is almost always a mistake — confirm + // before clobbering. Same target is a silent refresh. + if (existing && existing.base_url !== baseUrl) { + const approved = await confirmOverwrite( + `Profile "${profileName}" currently targets ${existing.base_url}. Overwrite to ${baseUrl}?`, + ); + if (!approved) { + throw new Error('Aborted.'); + } + } + + const token = await requestToken({ + flow: stringFlag(flags, ['flow']) ?? 'device', + scope: stringFlag(flags, ['scope']) ?? DEFAULT_SCOPES, + request: createAnonymousRequest(baseUrl), + stderr: emitStderr, + }); + + const profile: Profile = { + base_url: baseUrl, + credential: oauthCredentialFromToken(token, now()), + saved_at: new Date(now()).toISOString(), + store: 'file', + }; + + const previousDefault = store.default; + saveStore( + setDefault(setProfile(store, profileName, profile), profileName), + deps.path, + ); + + const verb = existing ? 'updated' : 'created'; + const wasNote = + previousDefault && previousDefault !== profileName + ? ` (was "${previousDefault}")` + : ''; + emitStdout( + terminal.success( + `Profile "${profileName}" ${verb} and set as default${wasNote}.`, + ), + ); +} + +/** Strips Bearer / PAT= prefixes from a pasted token, leaving the bare PAT. */ +export function normalizePat(raw: string): string { + let value = raw.trim(); + if (value.startsWith('Bearer ')) { + value = value.slice('Bearer '.length).trim(); + } + if (value.startsWith('PAT=')) { + value = value.slice('PAT='.length).trim(); + } + return value; +} + +/** + * Reads a PAT for `--with-token`: from stdin when piped (the agent / CI path), + * or a masked prompt at a TTY (the human path). Honors the "two readers" + * tenet — one flag, both input modes. + */ +async function readWithToken( + baseUrl: string, + emitStdout: (line: string) => void, +): Promise { + if (process.stdin.isTTY) { + emitStdout( + `Create a PAT with the scopes you need at:\n ${personalAccessTokenSetupUrl( + { apiBaseUrl: baseUrl, orgUrl: resolveOrgUrl() }, + )}`, + ); + return askSecret('Paste your Personal Access Token: '); + } + return text(process.stdin); +} + +export interface AuthPatDeps { + path?: string; + now?: () => number; + stdout?: (line: string) => void; + confirm?: (message: string) => Promise; + // Test seam: supplies the raw token in place of stdin / the masked prompt. + readToken?: () => Promise; +} + +/** + * `amp auth pat --with-token` — save a supplied Personal Access Token as a + * profile and activate it. The token is read from stdin when piped, or a masked + * prompt at a TTY. Force-explicit like login: a new profile needs --profile and + * --env/--base-url; re-auth of an existing profile reuses its recorded env. + * + * `--with-token` is mandatory: it makes the supply-an-existing-PAT path + * explicit and keeps the bare `amp auth pat` verb reserved. + */ +export async function runAuthPat( + flags: Record, + deps: AuthPatDeps = {}, +): Promise { + if (!isFlagEnabled(flags['with-token'])) { + throw new Error( + '`amp auth pat` requires --with-token to supply an existing PAT (piped on stdin, or pasted at a prompt).', + ); + } + + const profileName = stringFlag(flags, ['profile']); + if (!profileName) { + throw new Error('`amp auth pat` requires --profile .'); + } + assertValidProfileName(profileName); + + const now = deps.now ?? (() => Date.now()); + const emitStdout = deps.stdout ?? ((line) => console.log(line)); + const confirmOverwrite = deps.confirm ?? confirm; + + const store = loadStore(deps.path); + const existing = getProfile(store, profileName); + const baseUrl = loginBaseUrl({ + baseUrlFlag: stringFlag(flags, ['base-url']), + envFlag: stringFlag(flags, ['env']), + existing, + }); + + // Reusing a name for a different target is almost always a mistake — confirm + // before clobbering. Same target is a silent refresh. + if (existing && existing.base_url !== baseUrl) { + const approved = await confirmOverwrite( + `Profile "${profileName}" currently targets ${existing.base_url}. Overwrite to ${baseUrl}?`, + ); + if (!approved) { + throw new Error('Aborted.'); + } + } + + const readToken = + deps.readToken ?? (() => readWithToken(baseUrl, emitStdout)); + const pat = normalizePat(await readToken()); + if (!pat) { + throw new Error('PAT cannot be empty.'); + } + + const credential: PatCredential = { type: 'pat', pat }; + const profile: Profile = { + base_url: baseUrl, + credential, + saved_at: new Date(now()).toISOString(), + store: 'file', + }; + + const previousDefault = store.default; + saveStore( + setDefault(setProfile(store, profileName, profile), profileName), + deps.path, + ); + + const verb = existing ? 'updated' : 'created'; + const wasNote = + previousDefault && previousDefault !== profileName + ? ` (was "${previousDefault}")` + : ''; + emitStdout( + terminal.success( + `Profile "${profileName}" ${verb} and set as default${wasNote}.`, + ), + ); +} + +interface ProfileCommandDeps { + path?: string; + now?: () => number; + stdout?: (line: string) => void; +} + +/** Reverses a base_url back to its friendly `--env` name when one is known. */ +export function envLabel(baseUrl: string): string { + for (const [name, url] of Object.entries(ENV_BASE_URLS)) { + if (url === baseUrl) { + return name; + } + } + return baseUrl; +} + +function humanizeDuration(ms: number): string { + const totalMinutes = Math.round(ms / 60_000); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; +} + +/** Time-to-expiry label for `auth list` — only OAuth tokens expire. */ +export function expiryLabel(credential: Credential, now: number): string { + const oauth = oauthCredentialSchema.safeParse(credential); + if (!oauth.success) { + return '—'; + } + const at = Date.parse(oauth.data.expires_at); + if (Number.isNaN(at)) { + return '—'; + } + return at <= now ? 'expired' : `in ${humanizeDuration(at - now)}`; +} + +export function formatProfileList(store: CredentialStore, now: number): string { + const names = Object.keys(store.profiles); + if (names.length === 0) { + return 'No profiles. Run `amp auth login`.'; + } + + const rows = names.map((name) => { + const profile = store.profiles[name]; + return { + marker: store.default === name ? '*' : ' ', + profile: name, + type: profile.credential.type, + env: envLabel(profile.base_url), + expires: expiryLabel(profile.credential, now), + }; + }); + + // Pad each column to the widest cell (header included) so the table aligns + // regardless of profile-name / env length. Trailing column isn't padded. + const header = { + profile: 'PROFILE', + type: 'TYPE', + env: 'ENV', + expires: 'EXPIRES', + }; + const width = (key: 'profile' | 'type' | 'env') => + Math.max(header[key].length, ...rows.map((row) => row[key].length)); + const wProfile = width('profile'); + const wType = width('type'); + const wEnv = width('env'); + + const line = ( + marker: string, + cells: { profile: string; type: string; env: string; expires: string }, + ) => + `${marker} ${cells.profile.padEnd(wProfile)} ${cells.type.padEnd(wType)} ${cells.env.padEnd(wEnv)} ${cells.expires}`.trimEnd(); + + return [line(' ', header), ...rows.map((row) => line(row.marker, row))].join( + '\n', + ); +} + +/** `amp auth list` — show profiles, marking the default with `*`. */ +export function runAuthList(deps: ProfileCommandDeps = {}): void { + const emitStdout = deps.stdout ?? ((line) => console.log(line)); + const now = deps.now?.() ?? Date.now(); + emitStdout(formatProfileList(loadStore(deps.path), now)); +} + +/** `amp auth use ` — repoint the default with no re-auth. */ +export function runAuthUse( + name: string | undefined, + deps: ProfileCommandDeps = {}, +): void { + if (!name) { + throw new Error( + '`amp auth use` requires a profile name: amp auth use .', + ); + } + const emitStdout = deps.stdout ?? ((line) => console.log(line)); + const store = loadStore(deps.path); + if (!getProfile(store, name)) { + const known = Object.keys(store.profiles); + throw new Error( + `No such profile: ${name}.${known.length ? ` Known: ${known.join(', ')}.` : ' Run `amp auth login`.'}`, + ); + } + const previousDefault = store.default; + saveStore(setDefault(store, name), deps.path); + const wasNote = + previousDefault && previousDefault !== name + ? ` (was "${previousDefault}")` + : ''; + emitStdout(terminal.success(`Default profile is now "${name}"${wasNote}.`)); +} + +/** + * `amp logout [--profile | --all]` — remove a profile, or wipe the whole + * store with `--all`. Target resolution is `--profile` > default > error. Clears + * `default` if it pointed at the removed profile and never auto-promotes a + * survivor (the active identity only changes on an explicit command), so + * logging out the default leaves no default set. + */ +export function runLogout( + flags: Record, + deps: ProfileCommandDeps = {}, +): void { + const emitStdout = deps.stdout ?? ((line) => console.log(line)); + const store = loadStore(deps.path); + + if (isFlagEnabled(flags.all)) { + if (stringFlag(flags, ['profile'])) { + throw new Error('Pass either --profile or --all, not both.'); + } + const count = Object.keys(store.profiles).length; + if (count === 0) { + emitStdout('No profiles to remove.'); + return; + } + saveStore(emptyStore(), deps.path); + emitStdout( + terminal.success( + `Removed all ${count} profile${count === 1 ? '' : 's'}. No credentials remain — run \`amp auth login\`.`, + ), + ); + return; + } + + const target = stringFlag(flags, ['profile']) ?? store.default; + + if (!target) { + throw new Error( + 'No profile to log out of. Pass --profile or set a default with `amp auth use`.', + ); + } + if (!getProfile(store, target)) { + const known = Object.keys(store.profiles); + throw new Error( + `No such profile: ${target}.${known.length ? ` Known: ${known.join(', ')}.` : ''}`, + ); + } + + const wasDefault = store.default === target; + const next = removeProfile(store, target); + saveStore(next, deps.path); + + if (wasDefault) { + const remaining = Object.keys(next.profiles); + const pick = remaining.length + ? `select another (${remaining.join(', ')}) with \`amp auth use \`` + : 'create one with `amp auth login`'; + emitStdout( + terminal.success( + `Logged out of "${target}". No default set — commands won't authenticate until you ${pick}.`, + ), + ); + return; + } + emitStdout(terminal.success(`Logged out of "${target}".`)); +} + +/** Masks a secret for display: first and last few chars, middle elided. */ +export function maskToken(token: string): string { + if (token.length <= 8) { + return '••••'; + } + return `${token.slice(0, 4)}…${token.slice(-4)}`; +} + +interface AuthStatusDeps { + store?: CredentialStore; + now?: () => number; + env?: NodeJS.ProcessEnv; + stdout?: (line: string) => void; +} + +/** + * `amp auth status` — local inspection of the active credential. Resolves via + * the shared precedence ladder (so it agrees with every API command), then + * prints source, profile/type/expiry, base URL, and a masked token. Exits 0 + * when a usable credential resolves, non-zero otherwise — scriptable. When + * `AMP_TOKEN` is in effect it is announced (the gh "exported token silently + * shadows my login" mitigation). No network call. + */ +export function runAuthStatus( + flags: Record, + deps: AuthStatusDeps = {}, +): void { + const emitStdout = deps.stdout ?? ((line) => console.log(line)); + const now = deps.now?.() ?? Date.now(); + const store = deps.store ?? loadStore(); + + emitStdout(`${terminal.heading('Auth status')}\n`); + + const emitProfileRows = (name: string, profile: Profile): void => { + const marker = store.default === name ? ' (default)' : ''; + emitStdout(`Profile: ${name}${marker}`); + emitStdout(`Type: ${profile.credential.type}`); + emitStdout(`Expires: ${expiryLabel(profile.credential, now)}`); + }; + + let auth; + try { + auth = resolveAuthFromFlags(flags, { store, now, env: deps.env }); + } catch (error) { + // Resolution failed — most often an expired (or otherwise unusable) stored + // credential. Still surface the selected profile's metadata, including + // `Expires: expired`, so status stays a useful diagnostic; then exit non-zero. + const selected = selectProfileFromFlags(flags, { store, env: deps.env }); + if (selected) { + emitProfileRows(selected.name, selected.profile); + } + emitStdout( + terminal.warning(error instanceof Error ? error.message : String(error)), + ); + process.exitCode = 1; + return; + } + + if (auth.source.startsWith('AMP_TOKEN')) { + emitStdout( + terminal.warning('AMP_TOKEN is set and overrides any stored profile.'), + ); + } + + emitStdout(`Source: ${terminal.dim(auth.source)}`); + + const profile = auth.profile ? getProfile(store, auth.profile) : undefined; + if (auth.profile && profile) { + emitProfileRows(auth.profile, profile); + } + + emitStdout(`Base URL: ${terminal.dim(auth.baseUrl)}`); + emitStdout(`Token: ${maskToken(auth.token)}`); +} + +/** + * `amp auth token` — print the resolved access token to stdout, nothing else, + * so it pipes cleanly (`TOKEN=$(amp auth token)`). Reads the store via the + * shared resolver instead of running a flow; when no credential resolves (or + * the selected one has expired) the resolver throws and the CLI exits non-zero + * with no stdout. + */ +export function runAuthToken( + flags: Record, + deps: AuthStatusDeps = {}, +): void { + const emitStdout = deps.stdout ?? ((line) => console.log(line)); + const auth = resolveAuthFromFlags(flags, { + store: deps.store, + now: deps.now?.(), + env: deps.env, + }); + emitStdout(auth.token); +} diff --git a/src/auth-guidance.test.ts b/src/auth-guidance.test.ts new file mode 100644 index 0000000..02aee8d --- /dev/null +++ b/src/auth-guidance.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + authSetupInstructions, + EU_APP_ORIGIN, + missingTokenMessage, + personalAccessTokenSetupUrl, + resolveAppOrigin, +} from './auth-guidance'; + +describe('auth guidance', () => { + it('builds the default PAT settings URL', () => { + expect( + personalAccessTokenSetupUrl({ + appOrigin: 'https://app.amplitude.com', + }), + ).toBe( + 'https://app.amplitude.com/analytics/amplitude/settings/profile/personal-access-tokens', + ); + }); + + it('includes org slug when provided', () => { + expect( + personalAccessTokenSetupUrl({ + appOrigin: 'https://app.amplitude.com', + orgUrl: 'acme-corp', + }), + ).toBe( + 'https://app.amplitude.com/analytics/acme-corp/settings/profile/personal-access-tokens', + ); + }); + + it('detects EU app origin from an explicit API base URL', () => { + expect( + personalAccessTokenSetupUrl({ + apiBaseUrl: 'https://developer-api.eu.amplitude.com', + }), + ).toBe( + 'https://eu.amplitude.com/analytics/amplitude/settings/profile/personal-access-tokens', + ); + }); + + it('lets AMP_APP_URL override an explicit API base URL', () => { + const original = process.env.AMP_APP_URL; + process.env.AMP_APP_URL = 'https://custom-app.example.com/'; + try { + expect( + personalAccessTokenSetupUrl({ + apiBaseUrl: 'https://developer-api.eu.amplitude.com', + }), + ).toBe( + 'https://custom-app.example.com/analytics/amplitude/settings/profile/personal-access-tokens', + ); + } finally { + restoreEnvValue('AMP_APP_URL', original); + } + }); + + it('mentions the PAT setup URL when token is missing', () => { + expect(missingTokenMessage()).toContain( + 'https://app.amplitude.com/analytics/amplitude/settings/profile/personal-access-tokens', + ); + expect(missingTokenMessage()).toContain('amp auth pat'); + }); + + it('uses the explicit API base URL in missing-token guidance', () => { + expect( + missingTokenMessage({ + apiBaseUrl: 'https://developer-api.eu.amplitude.com', + }), + ).toContain( + 'https://eu.amplitude.com/analytics/amplitude/settings/profile/personal-access-tokens', + ); + }); + + it('documents setup steps in auth instructions', () => { + expect(authSetupInstructions()).toContain('amp context'); + expect(authSetupInstructions()).toContain('personal-access-tokens'); + }); + + describe('app origin from shared base URL env helper', () => { + const original = { + appUrl: process.env.AMP_APP_URL, + base: process.env.AMP_API_BASE_URL, + }; + + afterEach(() => { + restoreEnvValue('AMP_APP_URL', original.appUrl); + restoreEnvValue('AMP_API_BASE_URL', original.base); + }); + + it('detects EU from AMP_API_BASE_URL', () => { + delete process.env.AMP_APP_URL; + process.env.AMP_API_BASE_URL = 'https://api.eu.amplitude.com'; + + expect(resolveAppOrigin()).toBe(EU_APP_ORIGIN); + }); + }); +}); + +function restoreEnvValue(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} diff --git a/src/auth-guidance.ts b/src/auth-guidance.ts new file mode 100644 index 0000000..ae88f05 --- /dev/null +++ b/src/auth-guidance.ts @@ -0,0 +1,89 @@ +import { apiBaseUrlFromEnv } from './env'; + +export const DEFAULT_APP_ORIGIN = 'https://app.amplitude.com'; +export const EU_APP_ORIGIN = 'https://eu.amplitude.com'; +export const DEFAULT_ORG_URL = 'amplitude'; + +const PAT_SETTINGS_SUFFIX = '/settings/profile/personal-access-tokens'; + +export function resolveAppOrigin(options?: { apiBaseUrl?: string }): string { + const explicit = process.env.AMP_APP_URL?.trim(); + if (explicit) { + return explicit.replace(/\/$/, ''); + } + + const apiBase = options?.apiBaseUrl ?? apiBaseUrlFromEnv() ?? ''; + if (apiBase.includes('eu.amplitude.com')) { + return EU_APP_ORIGIN; + } + + return DEFAULT_APP_ORIGIN; +} + +export function resolveOrgUrl(): string { + const orgUrl = process.env.AMP_ORG_URL?.trim(); + return orgUrl || DEFAULT_ORG_URL; +} + +export function personalAccessTokenSetupUrl(options?: { + apiBaseUrl?: string; + appOrigin?: string; + orgUrl?: string; +}): string { + const appOrigin = ( + options?.appOrigin ?? resolveAppOrigin({ apiBaseUrl: options?.apiBaseUrl }) + ).replace(/\/$/, ''); + const orgUrl = options?.orgUrl ?? resolveOrgUrl(); + + return `${appOrigin}/analytics/${encodeURIComponent(orgUrl)}${PAT_SETTINGS_SUFFIX}`; +} + +export function credentialsFileHint(path: string): string { + return `Saved credentials to ${path}`; +} + +export function missingTokenMessage(options?: { apiBaseUrl?: string }): string { + const setupUrl = personalAccessTokenSetupUrl({ + apiBaseUrl: options?.apiBaseUrl, + }); + + return [ + 'Missing token.', + '', + `Run: amp auth pat --with-token`, + `Or create a token manually: ${setupUrl}`, + '', + 'You can also set AMP_TOKEN or pass --token for a single command.', + ].join('\n'); +} + +export function authSetupInstructions(options?: { + apiBaseUrl?: string; +}): string { + const setupUrl = personalAccessTokenSetupUrl({ + apiBaseUrl: options?.apiBaseUrl, + }); + + return [ + 'Authenticate amp with a Personal Access Token (PAT).', + '', + 'Recommended:', + ' amp auth pat --with-token --profile --env ', + '', + 'That prints the PAT settings page and reads the token from stdin (or a', + 'masked prompt at a terminal), saving it as a profile for future commands.', + '', + 'Manual setup:', + ` ${setupUrl}`, + '', + 'Create a token with read access (and write access if you need', + 'create/update/delete commands), then run `amp auth pat --with-token`.', + '', + 'Verify:', + ' amp context', + '', + 'Optional:', + ' AMP_ORG_URL= PAT settings org slug (default: amplitude)', + ' AMP_APP_URL= Override app host (default: app.amplitude.com)', + ].join('\n'); +} diff --git a/src/auth-login.test.ts b/src/auth-login.test.ts new file mode 100644 index 0000000..39ba876 --- /dev/null +++ b/src/auth-login.test.ts @@ -0,0 +1,229 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + loginBaseUrl, + oauthCredentialFromToken, + runAuthLogin, +} from './auth-commands'; +import { + CURRENT_VERSION, + getProfile, + loadStore, + saveStore, +} from './credential-store'; +import type { TokenResponse } from './oauthResponseSchemas'; + +const NOW = Date.parse('2026-06-23T12:00:00.000Z'); + +const TOKEN: TokenResponse = { + access_token: 'eyJ.jwt', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'refresh_x', + scope: 'read:projects', +}; + +describe('oauthCredentialFromToken', () => { + it('computes expires_at from expires_in and carries optional fields', () => { + expect(oauthCredentialFromToken(TOKEN, NOW)).toEqual({ + type: 'oauth', + access_token: 'eyJ.jwt', + token_type: 'Bearer', + expires_at: '2026-06-23T13:00:00.000Z', + refresh_token: 'refresh_x', + scope: 'read:projects', + }); + }); +}); + +describe('loginBaseUrl', () => { + it('prefers --base-url (trailing slash trimmed)', () => { + expect(loginBaseUrl({ baseUrlFlag: 'http://localhost:3036/' })).toBe( + 'http://localhost:3036', + ); + }); + + it('maps --env', () => { + expect(loginBaseUrl({ envFlag: 'staging' })).toBe( + 'https://developer-api.stag2.amplitude.com', + ); + }); + + it('reuses an existing profile base_url on re-auth', () => { + expect( + loginBaseUrl({ + existing: { + base_url: 'https://prod', + credential: { type: 'pat', pat: 'amp_x' }, + saved_at: 'x', + }, + }), + ).toBe('https://prod'); + }); + + it('errors creating a profile without --env/--base-url (force-explicit)', () => { + expect(() => loginBaseUrl({})).toThrow(/requires --env|--base-url/); + }); + + it('errors on an unknown --env', () => { + expect(() => loginBaseUrl({ envFlag: 'nope' })).toThrow(/Unknown --env/); + }); +}); + +describe('runAuthLogin', () => { + const dirs: string[] = []; + + afterEach(() => { + for (const dir of dirs) { + rmSync(dir, { force: true, recursive: true }); + } + dirs.length = 0; + }); + + function tempPath(): string { + const dir = mkdtempSync(join(tmpdir(), 'amp-login-')); + dirs.push(dir); + return join(dir, 'credentials.json'); + } + + function deps(path: string, out: string[]) { + return { + path, + now: () => NOW, + stdout: (line: string) => out.push(line), + stderr: () => {}, + requestToken: () => Promise.resolve(TOKEN), + confirm: () => Promise.resolve(true), + }; + } + + it('creates a profile, activates it, and saves the oauth credential', async () => { + const path = tempPath(); + const out: string[] = []; + await runAuthLogin({ profile: 'amplitude', env: 'prod' }, deps(path, out)); + + const store = loadStore(path); + expect(store.default).toBe('amplitude'); + expect(getProfile(store, 'amplitude')?.base_url).toBe( + 'https://developer-api.amplitude.com', + ); + expect(getProfile(store, 'amplitude')?.credential.type).toBe('oauth'); + expect(out.join('\n')).toMatch(/created and set as default/); + }); + + it('repoints the default and announces the previous one', async () => { + const path = tempPath(); + const out: string[] = []; + await runAuthLogin({ profile: 'amplitude', env: 'prod' }, deps(path, out)); + out.length = 0; + await runAuthLogin({ profile: 'staging', env: 'staging' }, deps(path, out)); + + expect(loadStore(path).default).toBe('staging'); + expect(out.join('\n')).toMatch(/set as default \(was "amplitude"\)/); + }); + + it('re-auths the default in place when --profile is omitted', async () => { + const path = tempPath(); + const out: string[] = []; + await runAuthLogin({ profile: 'amplitude', env: 'prod' }, deps(path, out)); + out.length = 0; + + // Bare login: no --profile, no --env — refreshes the active profile. + await runAuthLogin({}, deps(path, out)); + + const store = loadStore(path); + expect(store.default).toBe('amplitude'); + expect(getProfile(store, 'amplitude')?.base_url).toBe( + 'https://developer-api.amplitude.com', + ); + expect(out.join('\n')).toMatch(/updated and set as default\./); + }); + + it('errors when neither --profile nor a default profile exists', async () => { + await expect( + runAuthLogin({ env: 'prod' }, deps(tempPath(), [])), + ).rejects.toThrow(/No default profile/); + }); + + it('errors with "No such profile" when the stored default is orphaned', async () => { + // A hand-edited file whose `default` names a profile that isn't present. + const path = tempPath(); + saveStore( + { version: CURRENT_VERSION, default: 'ghost', profiles: {} }, + path, + ); + + // Bare login (no --profile) should match the resolver's wording, not fall + // into loginBaseUrl's "creating a profile requires --env". + await expect(runAuthLogin({}, deps(path, []))).rejects.toThrow( + /No such profile: ghost/, + ); + }); + + it('rejects the reserved name "default"', async () => { + await expect( + runAuthLogin({ profile: 'default', env: 'prod' }, deps(tempPath(), [])), + ).rejects.toThrow(/reserved/); + }); + + it('rejects an invalid profile name', async () => { + await expect( + runAuthLogin({ profile: 'bad name', env: 'prod' }, deps(tempPath(), [])), + ).rejects.toThrow(/Invalid profile name/); + }); + + it('aborts a re-login to a different target when not confirmed', async () => { + const path = tempPath(); + await runAuthLogin({ profile: 'p', env: 'prod' }, deps(path, [])); + + await expect( + runAuthLogin( + { profile: 'p', env: 'staging' }, + { ...deps(path, []), confirm: () => Promise.resolve(false) }, + ), + ).rejects.toThrow(/Aborted/); + // unchanged + expect(loadStore(path).profiles.p?.base_url).toBe( + 'https://developer-api.amplitude.com', + ); + }); + + it('requests the full default scope set when --scope is absent', async () => { + const path = tempPath(); + const scopes: Array = []; + const capturing = { + ...deps(path, []), + requestToken: (options: { scope?: string }) => { + scopes.push(options.scope); + return Promise.resolve(TOKEN); + }, + }; + + await runAuthLogin({ profile: 'amplitude', env: 'prod' }, capturing); + expect(scopes[0]).toBe( + 'mcp:read mcp:write read:flags read:projects read:taxonomy write:flags write:taxonomy', + ); + }); + + it('forwards an explicit --scope verbatim', async () => { + const path = tempPath(); + const scopes: Array = []; + const capturing = { + ...deps(path, []), + requestToken: (options: { scope?: string }) => { + scopes.push(options.scope); + return Promise.resolve(TOKEN); + }, + }; + + await runAuthLogin( + { profile: 'amplitude', env: 'prod', scope: 'read:projects' }, + capturing, + ); + expect(scopes[0]).toBe('read:projects'); + }); +}); diff --git a/src/auth-pat.test.ts b/src/auth-pat.test.ts new file mode 100644 index 0000000..bff46de --- /dev/null +++ b/src/auth-pat.test.ts @@ -0,0 +1,144 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { type AuthPatDeps, normalizePat, runAuthPat } from './auth-commands'; +import { getProfile, loadStore } from './credential-store'; + +const NOW = Date.parse('2026-06-23T12:00:00.000Z'); + +describe('normalizePat', () => { + it('strips Bearer and PAT= prefixes', () => { + expect(normalizePat('Bearer PAT=amp_x')).toBe('amp_x'); + expect(normalizePat('PAT=amp_x')).toBe('amp_x'); + expect(normalizePat(' amp_x ')).toBe('amp_x'); + }); +}); + +describe('runAuthPat', () => { + const dirs: string[] = []; + + afterEach(() => { + for (const dir of dirs) { + rmSync(dir, { force: true, recursive: true }); + } + dirs.length = 0; + }); + + function tempPath(): string { + const dir = mkdtempSync(join(tmpdir(), 'amp-pat-')); + dirs.push(dir); + return join(dir, 'credentials.json'); + } + + function deps(path: string, out: string[], over: Partial = {}) { + return { + path, + now: () => NOW, + stdout: (line: string) => out.push(line), + confirm: () => Promise.resolve(true), + readToken: () => Promise.resolve('amp_pasted'), + ...over, + }; + } + + it('saves a pat credential and activates the profile', async () => { + const path = tempPath(); + const out: string[] = []; + + await runAuthPat( + { profile: 'ci', env: 'prod', 'with-token': true }, + deps(path, out), + ); + + const store = loadStore(path); + expect(store.default).toBe('ci'); + expect(getProfile(store, 'ci')?.credential).toEqual({ + type: 'pat', + pat: 'amp_pasted', + }); + expect(getProfile(store, 'ci')?.base_url).toBe( + 'https://developer-api.amplitude.com', + ); + expect(out.join('\n')).toMatch(/created and set as default/); + }); + + it('normalizes a prefixed token before saving', async () => { + const path = tempPath(); + await runAuthPat( + { profile: 'ci', env: 'prod', 'with-token': true }, + deps(path, [], { readToken: () => Promise.resolve('Bearer PAT=amp_x') }), + ); + expect(getProfile(loadStore(path), 'ci')?.credential).toEqual({ + type: 'pat', + pat: 'amp_x', + }); + }); + + it('errors and saves nothing without --with-token (bare verb reserved)', async () => { + const path = tempPath(); + await expect( + runAuthPat({ profile: 'ci', env: 'prod' }, deps(path, [])), + ).rejects.toThrow(/requires --with-token/); + expect(loadStore(path).profiles.ci).toBeUndefined(); + }); + + it('requires --profile', async () => { + await expect( + runAuthPat({ env: 'prod', 'with-token': true }, deps(tempPath(), [])), + ).rejects.toThrow(/--profile/); + }); + + it('rejects the reserved name "default"', async () => { + await expect( + runAuthPat( + { profile: 'default', env: 'prod', 'with-token': true }, + deps(tempPath(), []), + ), + ).rejects.toThrow(/reserved/); + }); + + it('rejects an invalid profile name', async () => { + await expect( + runAuthPat( + { profile: 'bad/name', env: 'prod', 'with-token': true }, + deps(tempPath(), []), + ), + ).rejects.toThrow(/Invalid profile name/); + }); + + it('is force-explicit: creating without --env/--base-url errors', async () => { + await expect( + runAuthPat({ profile: 'ci', 'with-token': true }, deps(tempPath(), [])), + ).rejects.toThrow(/requires --env|--base-url/); + }); + + it('rejects an empty supplied PAT', async () => { + await expect( + runAuthPat( + { profile: 'ci', env: 'prod', 'with-token': true }, + deps(tempPath(), [], { readToken: () => Promise.resolve(' ') }), + ), + ).rejects.toThrow(/cannot be empty/); + }); + + it('aborts a re-pat to a different target when not confirmed', async () => { + const path = tempPath(); + await runAuthPat( + { profile: 'ci', env: 'prod', 'with-token': true }, + deps(path, []), + ); + + await expect( + runAuthPat( + { profile: 'ci', env: 'staging', 'with-token': true }, + deps(path, [], { confirm: () => Promise.resolve(false) }), + ), + ).rejects.toThrow(/Aborted/); + expect(loadStore(path).profiles.ci?.base_url).toBe( + 'https://developer-api.amplitude.com', + ); + }); +}); diff --git a/src/auth-profiles.test.ts b/src/auth-profiles.test.ts new file mode 100644 index 0000000..6471a1c --- /dev/null +++ b/src/auth-profiles.test.ts @@ -0,0 +1,434 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + envLabel, + expiryLabel, + formatProfileList, + maskToken, + runAuthStatus, + runAuthToken, + runAuthUse, + runLogout, +} from './auth-commands'; +import { + type CredentialStore, + CURRENT_VERSION, + emptyStore, + loadStore, + saveStore, + setDefault, + setProfile, +} from './credential-store'; + +const NOW = Date.parse('2026-06-23T12:00:00.000Z'); + +function oauthProfile(baseUrl: string, expiresAt: string) { + return { + base_url: baseUrl, + credential: { + type: 'oauth' as const, + access_token: 'eyJ.jwt', + token_type: 'Bearer', + expires_at: expiresAt, + }, + saved_at: '2026-06-23T00:00:00.000Z', + store: 'file', + }; +} + +describe('envLabel', () => { + it('reverses a known base_url to its env name', () => { + expect(envLabel('https://developer-api.amplitude.com')).toBe('prod'); + expect(envLabel('http://localhost:3036')).toBe('local'); + }); + + it('falls back to the raw base_url when unknown', () => { + expect(envLabel('https://custom.example.com')).toBe( + 'https://custom.example.com', + ); + }); +}); + +describe('expiryLabel', () => { + it('reports a relative time for a valid oauth token', () => { + expect( + expiryLabel( + oauthProfile('x', '2026-06-23T14:05:00.000Z').credential, + NOW, + ), + ).toBe('in 2h 5m'); + }); + + it('reports expired for a past oauth token', () => { + expect( + expiryLabel( + oauthProfile('x', '2026-06-23T11:00:00.000Z').credential, + NOW, + ), + ).toBe('expired'); + }); + + it('returns — for a non-oauth credential', () => { + expect(expiryLabel({ type: 'pat', pat: 'amp_x' }, NOW)).toBe('—'); + }); +}); + +describe('formatProfileList', () => { + it('marks the default with * and masks secrets', () => { + let store = setProfile( + emptyStore(), + 'amplitude', + oauthProfile( + 'https://developer-api.amplitude.com', + '2026-06-23T21:14:00.000Z', + ), + ); + store = setProfile( + store, + 'staging', + oauthProfile( + 'https://developer-api.stag2.amplitude.com', + '2026-06-23T22:02:00.000Z', + ), + ); + store = setDefault(store, 'staging'); + + const out = formatProfileList(store, NOW); + const lines = out.split('\n'); + + // Header row, then a * on the default and a space on the rest. + expect(lines[0]).toMatch(/PROFILE\s+TYPE\s+ENV\s+EXPIRES/); + expect(out).toMatch(/\* staging\s+oauth\s+staging/); + expect(out).toMatch(/ {2}amplitude\s+oauth\s+prod/); + expect(out).not.toContain('eyJ.jwt'); + + // Columns align: the `oauth` type cell starts at the same index on every + // data row (proves padding, not just single-space joins). + const typeAt = lines.map((l) => l.indexOf('oauth')).filter((i) => i >= 0); + expect(new Set(typeAt).size).toBe(1); + }); + + it('guides login when empty', () => { + expect(formatProfileList(emptyStore(), NOW)).toMatch(/amp auth login/); + }); +}); + +describe('runAuthUse', () => { + const dirs: string[] = []; + afterEach(() => { + for (const dir of dirs) { + rmSync(dir, { force: true, recursive: true }); + } + dirs.length = 0; + }); + + function seeded(): { path: string; store: CredentialStore } { + const dir = mkdtempSync(join(tmpdir(), 'amp-use-')); + dirs.push(dir); + const path = join(dir, 'credentials.json'); + let store: CredentialStore = setProfile( + emptyStore(), + 'amplitude', + oauthProfile( + 'https://developer-api.amplitude.com', + '2026-06-24T00:00:00.000Z', + ), + ); + store = setProfile( + store, + 'staging', + oauthProfile( + 'https://developer-api.stag2.amplitude.com', + '2026-06-24T00:00:00.000Z', + ), + ); + store = setDefault(store, 'amplitude'); + saveStore(store, path); + return { path, store }; + } + + it('repoints the default and announces the previous one', () => { + const { path } = seeded(); + const out: string[] = []; + runAuthUse('staging', { path, stdout: (l) => out.push(l) }); + + expect(loadStore(path).default).toBe('staging'); + expect(out.join('\n')).toMatch(/now "staging" \(was "amplitude"\)/); + }); + + it('throws listing known profiles on an unknown name', () => { + const { path } = seeded(); + expect(() => runAuthUse('nope', { path })).toThrow( + /Known: amplitude, staging/, + ); + }); + + it('requires a name', () => { + expect(() => runAuthUse(undefined, { path: seeded().path })).toThrow( + /requires a profile name/, + ); + }); +}); + +describe('runLogout', () => { + const dirs: string[] = []; + afterEach(() => { + for (const dir of dirs) { + rmSync(dir, { force: true, recursive: true }); + } + dirs.length = 0; + }); + + function seeded(): string { + const dir = mkdtempSync(join(tmpdir(), 'amp-logout-')); + dirs.push(dir); + const path = join(dir, 'credentials.json'); + let store: CredentialStore = setProfile( + emptyStore(), + 'amplitude', + oauthProfile( + 'https://developer-api.amplitude.com', + '2026-06-24T00:00:00.000Z', + ), + ); + store = setProfile( + store, + 'staging', + oauthProfile( + 'https://developer-api.stag2.amplitude.com', + '2026-06-24T00:00:00.000Z', + ), + ); + store = setDefault(store, 'amplitude'); + saveStore(store, path); + return path; + } + + it('removes a non-default profile and leaves the default untouched', () => { + const path = seeded(); + const out: string[] = []; + runLogout({ profile: 'staging' }, { path, stdout: (l) => out.push(l) }); + + const after = loadStore(path); + expect(after.profiles.staging).toBeUndefined(); + expect(after.default).toBe('amplitude'); + expect(out.join('\n')).toMatch(/Logged out of "staging"\./); + }); + + it('clears the default (never auto-promotes) when logging out the default', () => { + const path = seeded(); + const out: string[] = []; + runLogout({}, { path, stdout: (l) => out.push(l) }); + + const after = loadStore(path); + expect(after.profiles.amplitude).toBeUndefined(); + expect(after.default).toBeUndefined(); + expect(after.profiles.staging).toBeDefined(); + expect(out.join('\n')).toMatch(/No default set/); + }); + + it('errors with known names on an unknown target', () => { + const path = seeded(); + expect(() => runLogout({ profile: 'nope' }, { path })).toThrow( + /Known: amplitude, staging/, + ); + }); + + it('errors when there is nothing to log out of', () => { + const dir = mkdtempSync(join(tmpdir(), 'amp-logout-empty-')); + dirs.push(dir); + const path = join(dir, 'credentials.json'); + saveStore(emptyStore(), path); + expect(() => runLogout({}, { path })).toThrow(/No profile to log out of/); + }); + + it('--all wipes every profile and clears the default', () => { + const path = seeded(); + const out: string[] = []; + runLogout({ all: true }, { path, stdout: (l) => out.push(l) }); + + const after = loadStore(path); + expect(Object.keys(after.profiles)).toEqual([]); + expect(after.default).toBeUndefined(); + expect(out.join('\n')).toMatch(/Removed all 2 profiles/); + }); + + it('--all on an empty store is a no-op message, not an error', () => { + const dir = mkdtempSync(join(tmpdir(), 'amp-logout-all-empty-')); + dirs.push(dir); + const path = join(dir, 'credentials.json'); + saveStore(emptyStore(), path); + const out: string[] = []; + runLogout({ all: true }, { path, stdout: (l) => out.push(l) }); + expect(out.join('\n')).toMatch(/No profiles to remove/); + }); + + it('rejects --all combined with --profile', () => { + expect(() => + runLogout({ all: true, profile: 'staging' }, { path: seeded() }), + ).toThrow(/not both/); + }); +}); + +describe('maskToken', () => { + it('shows the ends and elides the middle', () => { + expect(maskToken('eyJabcdefghAB12')).toBe('eyJa…AB12'); + }); + + it('fully masks short tokens', () => { + expect(maskToken('amp_x')).toBe('••••'); + }); +}); + +describe('runAuthStatus', () => { + const original = process.exitCode; + afterEach(() => { + process.exitCode = original; + }); + + function statusStore(): CredentialStore { + const store = setProfile( + emptyStore(), + 'amplitude', + oauthProfile( + 'https://developer-api.amplitude.com', + '2026-06-23T14:00:00.000Z', + ), + ); + return setDefault(store, 'amplitude'); + } + + it('reports the default profile with type, expiry, and a masked token', () => { + const out: string[] = []; + runAuthStatus( + {}, + { + store: statusStore(), + now: () => NOW, + env: {}, + stdout: (l) => out.push(l), + }, + ); + const text = out.join('\n'); + + expect(text).toMatch(/Source: {3}default profile amplitude/); + expect(text).toMatch(/Profile: {2}amplitude \(default\)/); + expect(text).toMatch(/Type: {5}oauth/); + expect(text).toMatch(/Expires: {2}in 2h 0m/); + expect(text).not.toContain('eyJ.jwt'); + expect(process.exitCode).not.toBe(1); + }); + + it('exits non-zero with guidance when nothing resolves', () => { + const out: string[] = []; + runAuthStatus( + {}, + { + store: emptyStore(), + now: () => NOW, + env: {}, + stdout: (l) => out.push(l), + }, + ); + + expect(out.join('\n')).toMatch(/amp auth login/); + expect(process.exitCode).toBe(1); + }); + + it('announces AMP_TOKEN when it is in effect', () => { + const out: string[] = []; + runAuthStatus( + {}, + { + store: statusStore(), + now: () => NOW, + env: { AMP_TOKEN: 'env-token' }, + stdout: (l) => out.push(l), + }, + ); + const text = out.join('\n'); + + expect(text).toMatch(/AMP_TOKEN is set/); + expect(text).toMatch(/Source: {3}AMP_TOKEN env var/); + expect(process.exitCode).not.toBe(1); + }); + + it('still shows the profile row (Expires: expired) and exits non-zero for an expired credential', () => { + const store = setDefault( + setProfile( + emptyStore(), + 'amplitude', + oauthProfile( + 'https://developer-api.amplitude.com', + '2026-06-23T10:00:00.000Z', + ), + ), + 'amplitude', + ); + const out: string[] = []; + runAuthStatus( + {}, + { store, now: () => NOW, env: {}, stdout: (l) => out.push(l) }, + ); + const text = out.join('\n'); + + expect(text).toMatch(/Profile: {2}amplitude \(default\)/); + expect(text).toMatch(/Type: {5}oauth/); + expect(text).toMatch(/Expires: {2}expired/); + expect(text).toMatch(/expired/i); + expect(text).not.toContain('eyJ.jwt'); + expect(process.exitCode).toBe(1); + }); +}); + +describe('runAuthToken', () => { + function tokenStore(expiresAt: string): CredentialStore { + const store = setProfile( + emptyStore(), + 'amplitude', + oauthProfile('https://developer-api.amplitude.com', expiresAt), + ); + return setDefault(store, 'amplitude'); + } + + it('prints only the resolved access token (pipeable)', () => { + const out: string[] = []; + runAuthToken( + {}, + { + store: tokenStore('2026-06-23T14:00:00.000Z'), + now: () => NOW, + env: {}, + stdout: (l) => out.push(l), + }, + ); + expect(out).toEqual(['eyJ.jwt']); + }); + + it('throws (no stdout) when no credential resolves', () => { + expect(() => + runAuthToken({}, { store: emptyStore(), now: () => NOW, env: {} }), + ).toThrow(/amp auth login/); + }); + + it('throws when the selected token has expired', () => { + expect(() => + runAuthToken( + {}, + { + store: tokenStore('2026-06-23T11:00:00.000Z'), + now: () => NOW, + env: {}, + }, + ), + ).toThrow(/expired/i); + }); +}); + +it('store version constant is stable', () => { + expect(CURRENT_VERSION).toBe(1); +}); diff --git a/src/authToken.test.ts b/src/authToken.test.ts new file mode 100644 index 0000000..69350f1 --- /dev/null +++ b/src/authToken.test.ts @@ -0,0 +1,300 @@ +import { createHash } from 'node:crypto'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createAnonymousRequest, + formatVerificationPrompt, + generatePkcePair, + pollForToken, + runAuthTokenCommand, +} from './authToken'; + +describe('generatePkcePair', () => { + it('derives an S256 challenge from a base64url verifier', () => { + const { codeVerifier, codeChallenge } = generatePkcePair(); + + // 32 random bytes, base64url, no padding => 43 chars from the URL-safe set. + expect(codeVerifier).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(codeChallenge).toBe( + createHash('sha256').update(codeVerifier).digest('base64url'), + ); + }); +}); + +describe('formatVerificationPrompt', () => { + it('shows the user_code and verification URL but never the device_code', () => { + const message = formatVerificationPrompt({ + device_code: 'SECRET-DEVICE-CODE', + user_code: 'WDJB-MJHT', + verification_uri: 'https://amp.example/device', + verification_uri_complete: + 'https://amp.example/device?user_code=WDJB-MJHT', + expires_in: 600, + }); + + expect(message).toContain('WDJB-MJHT'); + expect(message).toContain('https://amp.example/device'); + expect(message).not.toContain('SECRET-DEVICE-CODE'); + }); +}); + +describe('runAuthTokenCommand', () => { + const unusedRequest = () => Promise.resolve({ status: 200, body: {} }); + + it('requires --flow', async () => { + await expect( + runAuthTokenCommand({ flow: undefined, request: unusedRequest }), + ).rejects.toThrow(/--flow/); + }); + + it('rejects an unsupported flow', async () => { + await expect( + runAuthTokenCommand({ flow: 'web', request: unusedRequest }), + ).rejects.toThrow(/flow/i); + }); + + it('prompts on stderr, polls, and prints the token on stdout', async () => { + const calls: Array<{ path: string; body: unknown }> = []; + let tokenAttempts = 0; + + const request = (method: string, path: string, body?: unknown) => { + calls.push({ path, body }); + + if (path.endsWith('/v1/auth/device-authorization')) { + return Promise.resolve({ + status: 200, + body: { + device_code: 'DEVICE-CODE-SECRET', + user_code: 'WDJB-MJHT', + verification_uri: 'https://amp.example/device', + verification_uri_complete: + 'https://amp.example/device?user_code=WDJB-MJHT', + expires_in: 600, + interval: 5, + }, + }); + } + + tokenAttempts += 1; + if (tokenAttempts < 2) { + return Promise.resolve({ + status: 400, + body: { error: 'authorization_pending' }, + }); + } + return Promise.resolve({ + status: 200, + body: { + access_token: 'ACCESS-TOKEN', + token_type: 'bearer', + expires_in: 3600, + scope: 'openid', + }, + }); + }; + + const out: string[] = []; + const err: string[] = []; + + await runAuthTokenCommand({ + flow: 'device', + request, + sleep: () => Promise.resolve(), + now: () => 0, + stdout: (line) => out.push(line), + stderr: (line) => err.push(line), + }); + + // Token (with its metadata) printed to stdout. + const stdout = out.join('\n'); + expect(stdout).toContain('ACCESS-TOKEN'); + expect(stdout).toContain('openid'); + + // device_code was sent in the token request body... + const tokenCall = calls.find((c) => c.path.endsWith('/v1/auth/token')); + expect(tokenCall?.body).toMatchObject({ + device_code: 'DEVICE-CODE-SECRET', + }); + + // ...but never surfaced to the user. + expect(err.join('\n')).toContain('WDJB-MJHT'); + expect(err.join('\n')).not.toContain('DEVICE-CODE-SECRET'); + expect(stdout).not.toContain('DEVICE-CODE-SECRET'); + }); +}); + +describe('pollForToken', () => { + it('polls through authorization_pending and slow_down until a token is issued', async () => { + const exchanges = [ + { status: 400, body: { error: 'authorization_pending' } }, + { status: 400, body: { error: 'slow_down' } }, + { + status: 200, + body: { access_token: 'tok', token_type: 'bearer', expires_in: 3600 }, + }, + ]; + let attempt = 0; + const sleeps: number[] = []; + + const token = await pollForToken({ + exchange: () => Promise.resolve(exchanges[attempt++]), + sleep: (seconds) => { + sleeps.push(seconds); + return Promise.resolve(); + }, + intervalSeconds: 5, + }); + + expect(token.access_token).toBe('tok'); + // pending slept the base interval; slow_down bumped it by 5 before sleeping. + expect(sleeps).toEqual([5, 10]); + }); + + it('throws a readable error when the device code expires', async () => { + await expect( + pollForToken({ + exchange: () => + Promise.resolve({ + status: 400, + body: { + error: 'expired_token', + error_description: 'The device code has expired.', + }, + }), + sleep: () => Promise.resolve(), + intervalSeconds: 5, + }), + ).rejects.toThrow('The device code has expired.'); + }); + + it( + 'stops polling once the device code lifetime is exceeded', + { timeout: 500 }, + async () => { + let clock = 0; + + await expect( + pollForToken({ + exchange: () => + Promise.resolve({ + status: 400, + body: { error: 'authorization_pending' }, + }), + // Advance the clock and yield a macrotask so an unbounded loop times + // out cleanly instead of starving the runner on microtasks. + sleep: () => { + clock += 5000; + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); + }, + intervalSeconds: 5, + deadline: { now: () => clock, expiresInSeconds: 8 }, + }), + ).rejects.toThrow(/expired/i); + }, + ); + + it('makes the exchange that lands on the deadline instead of giving up first', async () => { + let clock = 0; + const exchanges = [ + { status: 400, body: { error: 'authorization_pending' } }, + { + status: 200, + body: { + access_token: 'approved-at-the-buzzer', + token_type: 'bearer', + expires_in: 3600, + }, + }, + ]; + let attempt = 0; + + // interval and lifetime are both 5s, so the second exchange happens exactly + // when the deadline is reached. It must still run (the user approved during + // the sleep) rather than the loop throwing "expired" without polling again. + const token = await pollForToken({ + exchange: () => Promise.resolve(exchanges[attempt++]), + sleep: () => { + clock += 5000; + return Promise.resolve(); + }, + intervalSeconds: 5, + deadline: { now: () => clock, expiresInSeconds: 5 }, + }); + + expect(token.access_token).toBe('approved-at-the-buzzer'); + }); + + it('degrades an unrecognized error body to a generic message without echoing it', async () => { + const promise = pollForToken({ + exchange: () => + Promise.resolve({ + status: 400, + body: { internal_detail: 'SENSITIVE-UPSTREAM-STATE' }, + }), + sleep: () => Promise.resolve(), + intervalSeconds: 5, + }); + + await expect(promise).rejects.toThrow( + 'The authorization server returned an unexpected response.', + ); + await expect(promise).rejects.not.toThrow(/SENSITIVE-UPSTREAM-STATE/); + }); +}); + +describe('createAnonymousRequest', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('sends no Authorization header and returns the status and parsed body', async () => { + const fetchMock = vi.fn(() => + Promise.resolve(new Response('{"ok":true}', { status: 200 })), + ); + vi.stubGlobal('fetch', fetchMock); + + const request = createAnonymousRequest('https://api.test'); + const result = await request('POST', '/v1/auth/token', { grant_type: 'x' }); + + expect(result).toEqual({ status: 200, body: { ok: true } }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.test/v1/auth/token'); + expect(init?.headers).not.toHaveProperty('Authorization'); + }); + + it('throws a readable error when the API is unreachable', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.reject(new Error('ECONNREFUSED'))), + ); + + const request = createAnonymousRequest('https://api.test'); + + await expect(request('POST', '/v1/auth/token')).rejects.toThrow( + 'Could not reach the API at https://api.test.', + ); + }); + + it('falls back to the raw text when the body is not JSON', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve( + new Response('502 Bad Gateway', { status: 502 }), + ), + ), + ); + + const request = createAnonymousRequest('https://api.test'); + const result = await request('POST', '/v1/auth/token'); + + expect(result).toEqual({ + status: 502, + body: '502 Bad Gateway', + }); + }); +}); diff --git a/src/authToken.ts b/src/authToken.ts new file mode 100644 index 0000000..705478f --- /dev/null +++ b/src/authToken.ts @@ -0,0 +1,271 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; + +import { z } from 'zod'; + +import { toOAuthError } from './oauthError'; +import { + type DeviceAuthorizationResponse, + deviceAuthorizationResponseSchema, + type TokenResponse, + tokenResponseSchema, +} from './oauthResponseSchemas'; +import { DEVICE_CODE_GRANT_TYPE } from './schemas'; + +// Best readable message from an OAuth error body: prefer the human description, +// then the server's hint, then the bare error code. Reuses toOAuthError so the +// parse-and-fallback logic lives in one place. +function oauthErrorMessage(body: unknown): string { + const error = toOAuthError(body); + + return error.error_description ?? error.error_hint ?? error.error; +} + +// The token and device-authorization bodies are validated at the CLI boundary. +// A malformed success body is a server bug, not user error — surface a plain +// sentence rather than letting a raw ZodError dump reach the terminal. +function parseResponse( + schema: Schema, + body: unknown, + description: string, +): z.infer { + const result = schema.safeParse(body); + if (result.success) { + return result.data; + } + + throw new Error( + `The authorization server returned an unexpected ${description} response.`, + ); +} + +export interface PkcePair { + codeVerifier: string; + codeChallenge: string; +} + +// RFC 7636: a 32-byte base64url verifier (43 chars, no padding) and its S256 +// challenge. Kept entirely in memory — never written to disk. +export function generatePkcePair(): PkcePair { + const codeVerifier = randomBytes(32).toString('base64url'); + const codeChallenge = createHash('sha256') + .update(codeVerifier) + .digest('base64url'); + + return { codeVerifier, codeChallenge }; +} + +// The human-facing prompt (goes to stderr). Shows the verification URL with the +// user_code already embedded — the /device route reads it from the param, so +// the user never types it; deliberately omits the secret `device_code`. +export function formatVerificationPrompt( + device: DeviceAuthorizationResponse, +): string { + const url = device.verification_uri_complete ?? device.verification_uri; + + return [ + 'To authorize, open this URL in a browser and sign in:', + '', + url, + '', + 'Waiting for approval…', + ].join('\n'); +} + +interface ExchangeResult { + status: number; + body: unknown; +} + +// Mirror fetch's Response.ok (a 2xx status). createAnonymousRequest flattens +// the fetch Response to {status, body}, so the .ok convenience is reproduced +// here for the callers that work with ExchangeResult. +function isOk(status: number): boolean { + return status >= 200 && status < 300; +} + +// Optional lifetime bound: stop polling once the device code's `expires_in` +// elapses, so a server stuck on authorization_pending can't make the CLI poll +// forever. `now` returns milliseconds. Paired as one object so the type system +// enforces all-or-nothing — supplying one half without the other is impossible. +interface PollDeadline { + now: () => number; + expiresInSeconds: number; +} + +interface PollForTokenOptions { + // A single token-exchange attempt against POST /v1/auth/token. + exchange: () => Promise; + // Injected so tests don't wait on real time. + sleep: (seconds: number) => Promise; + intervalSeconds: number; + deadline?: PollDeadline; +} + +export async function pollForToken({ + exchange, + sleep, + intervalSeconds, + deadline, +}: PollForTokenOptions): Promise { + let interval = intervalSeconds; + const deadlineMs = deadline + ? deadline.now() + deadline.expiresInSeconds * 1000 + : undefined; + + for (;;) { + const { status, body } = await exchange(); + + if (isOk(status)) { + return parseResponse(tokenResponseSchema, body, 'token'); + } + + const code = toOAuthError(body).error; + + if (code === 'slow_down') { + // RFC 8628 §3.5: slow_down permanently raises the interval by 5s. + interval += 5; + } else if (code !== 'authorization_pending') { + throw new Error(oauthErrorMessage(body)); + } + + // Give up only after a poll has just come back pending/slow_down and the + // code's lifetime is spent — checked here, not before exchange(), so the + // attempt that lands on the deadline still runs and can return a token the + // user approved during the preceding sleep. + if (deadline && deadlineMs !== undefined && deadline.now() >= deadlineMs) { + throw new Error('The device code has expired before approval.'); + } + + await sleep(interval); + } +} + +export type AnonymousRequest = ( + method: string, + path: string, + body?: unknown, +) => Promise; + +// The OAuth endpoints are anonymous (the service injects the confidential +// client) and signal flow state via non-2xx OAuth errors the caller must +// inspect. So this sends no Authorization header and, unlike the CLI's +// getJson/requestJson, never throws on a non-2xx — it returns the status and +// parsed body for the poll loop to interpret. A non-JSON body (e.g. an HTML +// error page) degrades to the raw text rather than throwing a parse error. +export function createAnonymousRequest(baseUrl: string): AnonymousRequest { + return async (method, path, body) => { + const headers: Record = { Accept: 'application/json' }; + if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + } + + const init: RequestInit = { method, headers }; + if (body !== undefined) { + init.body = JSON.stringify(body); + } + + let response: Response; + try { + response = await fetch(`${baseUrl}${path}`, init); + } catch { + throw new Error(`Could not reach the API at ${baseUrl}.`); + } + + const text = await response.text(); + let parsed: unknown; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + parsed = text; + } + + return { status: response.status, body: parsed }; + }; +} + +export interface DeviceFlowOptions { + flow: string | undefined; + scope?: string; + // Token-optional, non-throwing HTTP call against the Developer API endpoints. + request: AnonymousRequest; + sleep?: (seconds: number) => Promise; + now?: () => number; + // The human prompt (verification URL) goes to stderr. + stderr?: (line: string) => void; +} + +export interface RunAuthTokenOptions extends DeviceFlowOptions { + stdout?: (line: string) => void; +} + +/** + * Runs the OAuth device-authorization flow and returns the token. Shared by + * `auth token` (which prints it) and `auth login` (which saves it to a + * profile) so the flow lives in one place. + */ +export async function requestDeviceToken( + options: DeviceFlowOptions, +): Promise { + if (!options.flow) { + throw new Error('Missing --flow. The only supported value is "device".'); + } + + if (options.flow !== 'device') { + throw new Error( + `Unsupported --flow "${options.flow}". The only supported value is "device".`, + ); + } + + const { request, scope } = options; + const sleep = options.sleep ?? ((seconds) => delay(seconds * 1000)); + const now = options.now ?? (() => Date.now()); + const emitStderr = + options.stderr ?? ((line) => process.stderr.write(`${line}\n`)); + + const { codeVerifier, codeChallenge } = generatePkcePair(); + + const deviceResponse = await request( + 'POST', + '/v1/auth/device-authorization', + { + code_challenge: codeChallenge, + code_challenge_method: 'S256', + scope: scope ?? undefined, + }, + ); + + if (!isOk(deviceResponse.status)) { + throw new Error(oauthErrorMessage(deviceResponse.body)); + } + + const deviceAuthorization = parseResponse( + deviceAuthorizationResponseSchema, + deviceResponse.body, + 'device-authorization', + ); + + emitStderr(formatVerificationPrompt(deviceAuthorization)); + + return pollForToken({ + exchange: () => + request('POST', '/v1/auth/token', { + grant_type: DEVICE_CODE_GRANT_TYPE, + device_code: deviceAuthorization.device_code, + code_verifier: codeVerifier, + }), + sleep, + intervalSeconds: deviceAuthorization.interval ?? 5, + deadline: { now, expiresInSeconds: deviceAuthorization.expires_in }, + }); +} + +export async function runAuthTokenCommand( + options: RunAuthTokenOptions, +): Promise { + const token = await requestDeviceToken(options); + // Token JSON goes to stdout (so `… | jq -r .access_token` works). + const emitStdout = + options.stdout ?? ((line) => process.stdout.write(`${line}\n`)); + emitStdout(JSON.stringify(token, null, 2)); +} diff --git a/src/cli.test.ts b/src/cli.test.ts new file mode 100644 index 0000000..dabace1 --- /dev/null +++ b/src/cli.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { isHelpRequested, isVersionRequested, main } from './cli'; +import { formatVersion } from './help'; + +describe('isHelpRequested', () => { + it('recognizes the help command and --help/-h in every form', () => { + expect(isHelpRequested(['help', 'flags'], {})).toBe(true); + expect(isHelpRequested(['flags', 'list'], { help: true })).toBe(true); + expect(isHelpRequested(['flags', 'list'], { help: 'true' })).toBe(true); + expect(isHelpRequested(['flags', 'list'], { h: true })).toBe(true); + }); + + it('does not treat --help=false as a help request', () => { + expect(isHelpRequested(['flags', 'list'], { help: 'false' })).toBe(false); + expect(isHelpRequested(['flags', 'list'], {})).toBe(false); + }); +}); + +describe('isVersionRequested', () => { + it('recognizes the version command and --version/-v', () => { + expect(isVersionRequested(['version'], {})).toBe(true); + expect(isVersionRequested([], { version: true })).toBe(true); + expect(isVersionRequested([], { v: true })).toBe(true); + }); + + it('does not treat --version=false as a version request', () => { + expect(isVersionRequested([], { version: 'false' })).toBe(false); + expect(isVersionRequested([], {})).toBe(false); + }); +}); + +describe('main routing', () => { + const originalArgv = process.argv; + let logSpy: ReturnType; + + function runWith(argv: string[]): Promise { + process.argv = ['node', 'amp', ...argv]; + return main(); + } + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.stubGlobal( + 'fetch', + vi.fn(() => { + throw new Error('fetch should not be called for these routes'); + }), + ); + }); + + afterEach(() => { + process.argv = originalArgv; + logSpy.mockRestore(); + vi.unstubAllGlobals(); + }); + + it('prints the version for both `version` and --version', async () => { + await runWith(['version']); + await runWith(['--version']); + expect(logSpy).toHaveBeenCalledWith(formatVersion()); + expect(logSpy).toHaveBeenCalledTimes(2); + }); + + it('prints global help when no command is given', async () => { + await runWith([]); + const output = logSpy.mock.calls + .map((call: unknown[]) => String(call[0])) + .join('\n'); + expect(output).toContain('amp'); + }); + + it('throws a helpful error for an unknown command', async () => { + await expect(runWith(['frobnicate'])).rejects.toThrow( + /Unknown command: frobnicate/, + ); + }); + + it('rejects `auth use` with an extra argument instead of ignoring it', async () => { + await expect(runWith(['auth', 'use', 'foo', 'bar'])).rejects.toThrow( + /Unknown auth command: auth use foo bar/, + ); + }); +}); diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..fb82817 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,114 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ +import { type FlagValue, isFlagEnabled, parseArgs } from './args'; +import { + runAuthList, + runAuthLogin, + runAuthPat, + runAuthStatus, + runAuthToken, + runAuthUse, + runLogout, +} from './auth-commands'; +import { DEFAULT_API_BASE_URL } from './config'; +import { + findOperation, + formatVersion, + printCommandHelp, + printGlobalHelp, +} from './help'; +import { runOperation } from './run'; +import { terminal } from './terminal'; + +export function isHelpRequested( + command: string[], + flags: Record, +): boolean { + return ( + command[0] === 'help' || isFlagEnabled(flags.help) || isFlagEnabled(flags.h) + ); +} + +export function isVersionRequested( + command: string[], + flags: Record, +): boolean { + return ( + command[0] === 'version' || + isFlagEnabled(flags.version) || + isFlagEnabled(flags.v) + ); +} + +export async function main(): Promise { + const { command, flags } = parseArgs(process.argv.slice(2)); + + if (isVersionRequested(command, flags)) { + console.log(formatVersion()); + return; + } + + if (command.length === 0) { + printGlobalHelp(DEFAULT_API_BASE_URL); + return; + } + + if (isHelpRequested(command, flags)) { + const topic = command[0] === 'help' ? command.slice(1) : command; + printCommandHelp(topic, DEFAULT_API_BASE_URL); + return; + } + + if (command[0] === 'auth') { + if (command.length === 2 && command[1] === 'login') { + await runAuthLogin(flags); + return; + } + if (command.length === 2 && command[1] === 'pat') { + await runAuthPat(flags); + return; + } + if (command.length === 2 && command[1] === 'list') { + runAuthList(); + return; + } + if (command[1] === 'use' && command.length <= 3) { + runAuthUse(command[2]); + return; + } + if (command.length === 2 && command[1] === 'status') { + runAuthStatus(flags); + return; + } + if (command.length === 2 && command[1] === 'token') { + runAuthToken(flags); + return; + } + + throw new Error( + `Unknown auth command: ${command.join(' ')}. Try: amp help auth`, + ); + } + + if (command[0] === 'logout' && command.length === 1) { + runLogout(flags); + return; + } + + const operation = findOperation(command); + if (!operation) { + throw new Error( + `Unknown command: ${command.join(' ')}. Run \`amp help ${command[0]}\` for related commands.`, + ); + } + + await runOperation(operation, flags); +} + +if (require.main === module) { + main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.error(terminal.error(message)); + process.exitCode = 1; + }); +} diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..62a8bfa --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { resolveBaseUrl } from './config'; + +function restoreEnvValue(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} + +describe('resolveBaseUrl', () => { + const original = { + base: process.env.AMP_API_BASE_URL, + }; + + afterEach(() => { + restoreEnvValue('AMP_API_BASE_URL', original.base); + }); + + it('resolves flag > AMP_API_BASE_URL > default and strips trailing slash', () => { + delete process.env.AMP_API_BASE_URL; + expect(resolveBaseUrl({})).toBe('https://developer-api.amplitude.com'); + + process.env.AMP_API_BASE_URL = 'https://canonical.example.com//'; + expect(resolveBaseUrl({})).toBe('https://canonical.example.com/'); + + expect(resolveBaseUrl({ 'base-url': 'https://flag.example.com/' })).toBe( + 'https://flag.example.com', + ); + }); + + it('throws when --base-url is passed without a value', () => { + expect(() => resolveBaseUrl({ 'base-url': true })).toThrowError( + 'Expected --base-url to have a value.', + ); + }); +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..74273b8 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,38 @@ +import { type FlagValue, stringFlag } from './args'; +import { apiBaseUrlFromEnv } from './env'; + +export const DEFAULT_API_BASE_URL = 'https://developer-api.amplitude.com'; + +// Friendly `--env` names → developer-api base URLs. `auth login` requires an +// explicit env (or --base-url) when creating a profile — no implicit default — +// so this map is the ergonomic primitive, not optional sugar. +// +// DRIFT RISK: these env→host mappings mirror the Developer API's per-env host +// config. This package ships standalone and cannot import that config, so the +// duplication is intentional but must be kept in sync by hand — a service-side +// host rename won't trip a test here. +export const ENV_BASE_URLS: Record = { + local: 'http://localhost:3036', + dev: 'https://developer-api.dev.amplitude.com', + staging: 'https://developer-api.stag2.amplitude.com', + prod: 'https://developer-api.amplitude.com', + 'prod-eu': 'https://developer-api.eu.amplitude.com', +}; + +export function resolveEnvBaseUrl(name: string): string { + const url = ENV_BASE_URLS[name]; + if (!url) { + throw new Error( + `Unknown --env "${name}". Known: ${Object.keys(ENV_BASE_URLS).join(', ')}.`, + ); + } + return url; +} + +export function resolveBaseUrl(flags: Record): string { + return ( + stringFlag(flags, ['base-url']) ?? + apiBaseUrlFromEnv() ?? + DEFAULT_API_BASE_URL + ).replace(/\/$/, ''); +} diff --git a/src/credential-resolver.test.ts b/src/credential-resolver.test.ts new file mode 100644 index 0000000..8863742 --- /dev/null +++ b/src/credential-resolver.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest'; + +import { + authorizationHeaderForToken, + resolveAuth, +} from './credential-resolver'; +import { + type CredentialStore, + CURRENT_VERSION, + emptyStore, + setDefault, + setProfile, +} from './credential-store'; + +const NOW = Date.parse('2026-06-23T12:00:00.000Z'); +const FUTURE = '2026-06-24T12:00:00.000Z'; +const PAST = '2026-06-22T12:00:00.000Z'; + +function oauthStore( + name: string, + baseUrl: string, + expiresAt: string, +): CredentialStore { + let store = setProfile(emptyStore(), name, { + base_url: baseUrl, + credential: { + type: 'oauth', + access_token: 'eyJ.jwt', + token_type: 'Bearer', + expires_at: expiresAt, + }, + saved_at: '2026-06-23T00:00:00.000Z', + store: 'file', + }); + return setDefault(store, name); +} + +describe('resolveAuth', () => { + it('--token flag wins over everything', () => { + const r = resolveAuth({ + tokenFlag: 'raw-flag', + env: { AMP_TOKEN: 'env-token' }, + store: oauthStore('p', 'https://prod', FUTURE), + now: NOW, + }); + expect(r.token).toBe('raw-flag'); + expect(r.source).toBe('--token flag'); + }); + + it('AMP_TOKEN is king over profile selection', () => { + const r = resolveAuth({ + profileFlag: 'p', + env: { AMP_TOKEN: 'env-token' }, + store: oauthStore('p', 'https://prod', FUTURE), + now: NOW, + }); + expect(r.token).toBe('env-token'); + expect(r.source).toBe('AMP_TOKEN env var'); + }); + + it('--profile selects the named profile and its base_url', () => { + const r = resolveAuth({ + profileFlag: 'p', + env: {}, + store: oauthStore('p', 'https://staging', FUTURE), + now: NOW, + }); + expect(r.token).toBe('eyJ.jwt'); + expect(r.baseUrl).toBe('https://staging'); + expect(r.source).toBe('--profile p'); + }); + + it('AMP_PROFILE selects when no --profile is given', () => { + const r = resolveAuth({ + env: { AMP_PROFILE: 'p' }, + store: oauthStore('p', 'https://prod', FUTURE), + now: NOW, + }); + expect(r.source).toBe('AMP_PROFILE env (p)'); + }); + + it('falls back to the default profile', () => { + const r = resolveAuth({ + env: {}, + store: oauthStore('p', 'https://prod', FUTURE), + now: NOW, + }); + expect(r.source).toBe('default profile p'); + }); + + it('--base-url overrides the profile base_url', () => { + const r = resolveAuth({ + profileFlag: 'p', + baseUrlFlag: 'http://localhost:3036', + env: {}, + store: oauthStore('p', 'https://prod', FUTURE), + now: NOW, + }); + expect(r.baseUrl).toBe('http://localhost:3036'); + }); + + it('throws on an unknown profile', () => { + expect(() => + resolveAuth({ + profileFlag: 'nope', + env: {}, + store: emptyStore(), + now: NOW, + }), + ).toThrow(/nope/); + }); + + it('throws when the selected oauth token has expired', () => { + expect(() => + resolveAuth({ + env: {}, + store: oauthStore('p', 'https://prod', PAST), + now: NOW, + }), + ).toThrow(/expired/i); + }); + + it('returns a PAT credential as its raw token', () => { + let store = setProfile(emptyStore(), 'ci', { + base_url: 'https://prod', + credential: { type: 'pat', pat: 'amp_ci' }, + saved_at: '2026-06-23T00:00:00.000Z', + }); + store = setDefault(store, 'ci'); + expect(resolveAuth({ env: {}, store, now: NOW }).token).toBe('amp_ci'); + }); + + it('throws on an unsupported credential type', () => { + let store = setProfile(emptyStore(), 'sa', { + base_url: 'https://prod', + credential: { type: 'service_account', client_id: 'c' }, + saved_at: '2026-06-23T00:00:00.000Z', + }); + store = setDefault(store, 'sa'); + expect(() => resolveAuth({ env: {}, store, now: NOW })).toThrow( + /unsupported credential type/i, + ); + }); + + it('errors with login guidance when the store is empty', () => { + expect(() => + resolveAuth({ env: {}, store: emptyStore(), now: NOW }), + ).toThrow(/No credentials\. Run `amp auth login`/); + }); + + it('points to `auth use` when profiles exist but none is active', () => { + // A profile exists, but no default (e.g. just after logging out the default). + const store = setProfile(emptyStore(), 'p', { + base_url: 'https://prod', + credential: { type: 'pat', pat: 'amp_x' }, + saved_at: '2026-06-23T00:00:00.000Z', + }); + expect(() => resolveAuth({ env: {}, store, now: NOW })).toThrow( + /No active profile.*amp auth use/s, + ); + }); + + it('raw-token path defaults base_url and honors AMP_API_BASE_URL', () => { + expect( + resolveAuth({ tokenFlag: 't', env: {}, store: emptyStore(), now: NOW }) + .baseUrl, + ).toBe('https://developer-api.amplitude.com'); + expect( + resolveAuth({ + tokenFlag: 't', + env: { AMP_API_BASE_URL: 'https://dev.example.com/' }, + store: emptyStore(), + now: NOW, + }).baseUrl, + ).toBe('https://dev.example.com'); + }); +}); + +describe('authorizationHeaderForToken', () => { + it('formats by token shape', () => { + expect(authorizationHeaderForToken('amp_x')).toBe('Bearer PAT=amp_x'); + expect(authorizationHeaderForToken('PAT=amp_x')).toBe('Bearer PAT=amp_x'); + expect(authorizationHeaderForToken('Bearer abc')).toBe('Bearer abc'); + expect(authorizationHeaderForToken('eyJ.jwt')).toBe('Bearer eyJ.jwt'); + }); +}); + +it('exposes CURRENT_VERSION for callers', () => { + expect(CURRENT_VERSION).toBe(1); +}); diff --git a/src/credential-resolver.ts b/src/credential-resolver.ts new file mode 100644 index 0000000..474d6bd --- /dev/null +++ b/src/credential-resolver.ts @@ -0,0 +1,263 @@ +import { type FlagValue, stringFlag } from './args'; +import { DEFAULT_API_BASE_URL, resolveEnvBaseUrl } from './config'; +import { + type CredentialStore, + type Profile, + getProfile, + loadStore, + oauthCredentialSchema, + patCredentialSchema, +} from './credential-store'; + +/** + * Resolves which credential a command should use and how to reach the API. + * + * Precedence (highest first): `--token` flag, then `AMP_TOKEN` (env-token-is- + * king — a CI-injected token outranks any stored profile so it can't be + * silently shadowed), then `--profile`, then `AMP_PROFILE`, then the store's + * `default` profile. The active identity is never changed here — selection is + * read-only. + */ +export interface ResolveInput { + tokenFlag?: string; + profileFlag?: string; + baseUrlFlag?: string; + env?: NodeJS.ProcessEnv; + store?: CredentialStore; + now?: number; +} + +export interface ResolvedAuth { + token: string; + baseUrl: string; + source: string; + // Set only when a stored profile was selected (not on the --token / AMP_TOKEN + // paths), so callers like `auth status` can show the profile's type/expiry + // without re-deriving the precedence ladder. + profile?: string; +} + +function trimmed(value: string | undefined): string | undefined { + const t = value?.trim(); + return t ? t : undefined; +} + +function stripTrailingSlash(url: string): string { + return url.replace(/\/$/, ''); +} + +// Base URL for the raw-token path (`--token` / `AMP_TOKEN`): explicit flag > +// env > prod default. The prod default here is intentional and is NOT the +// "implicit prod" that force-explicit forbids — that rule guards a *persisted* +// profile's base_url (a durable default users come to depend on, a trap-door). +// This path persists nothing: it's an ephemeral, per-invocation token, so the +// prod default is a power-user/CI escape hatch (mirrors gh's GH_TOKEN, which +// likewise defaults its host). A wrong-env token here fails closed (the server +// rejects it), it doesn't silently write a bad default. +function rawTokenBaseUrl(input: ResolveInput, env: NodeJS.ProcessEnv): string { + return stripTrailingSlash( + trimmed(input.baseUrlFlag) ?? + trimmed(env.AMP_API_BASE_URL) ?? + DEFAULT_API_BASE_URL, + ); +} + +function isExpired(expiresAt: string, now: number): boolean { + const at = Date.parse(expiresAt); + // An unparseable expiry is left to the server to reject rather than locking + // the user out locally on a format quirk. + return Number.isNaN(at) ? false : at <= now; +} + +/** + * The usable bearer token for a profile's credential. OAuth tokens are checked + * for expiry (no refresh yet — the seam is here for when the server enables the + * grant); PATs pass through; a credential type this version doesn't model is a + * hard error rather than a silent skip. + */ +export function tokenFromProfile( + profile: Profile, + name: string, + now: number, +): string { + const oauth = oauthCredentialSchema.safeParse(profile.credential); + if (oauth.success) { + if (isExpired(oauth.data.expires_at, now)) { + throw new Error( + `Stored token for profile "${name}" has expired. Run \`amp auth login\`.`, + ); + } + return oauth.data.access_token; + } + + const pat = patCredentialSchema.safeParse(profile.credential); + if (pat.success) { + return pat.data.pat; + } + + throw new Error( + `Profile "${name}" uses an unsupported credential type "${profile.credential.type}". Update the CLI.`, + ); +} + +export interface SelectedProfile { + name: string; + profile: Profile; + source: string; +} + +/** + * The store-backed profile a command would select, ignoring whether its token + * is still usable. Returns undefined when a raw token (`--token` / `AMP_TOKEN`) + * shadows the store, or when no stored profile is active. Lets `auth status` + * render a profile's type/expiry even when {@link tokenFromProfile} throws on + * expiry — selection and token-extraction are separate concerns. + */ +export function selectProfile( + input: ResolveInput = {}, +): SelectedProfile | undefined { + const env = input.env ?? process.env; + if (trimmed(input.tokenFlag) || trimmed(env.AMP_TOKEN)) { + return undefined; + } + + const store = input.store ?? loadStore(); + const profileFlag = trimmed(input.profileFlag); + const ampProfile = trimmed(env.AMP_PROFILE); + const name = profileFlag ?? ampProfile ?? store.default; + if (!name) { + return undefined; + } + + const profile = getProfile(store, name); + if (!profile) { + return undefined; + } + + const source = profileFlag + ? `--profile ${name}` + : ampProfile + ? `AMP_PROFILE env (${name})` + : `default profile ${name}`; + return { name, profile, source }; +} + +export function resolveAuth(input: ResolveInput = {}): ResolvedAuth { + const env = input.env ?? process.env; + const now = input.now ?? Date.now(); + + const tokenFlag = trimmed(input.tokenFlag); + if (tokenFlag) { + return { + token: tokenFlag, + baseUrl: rawTokenBaseUrl(input, env), + source: '--token flag', + }; + } + + const ampToken = trimmed(env.AMP_TOKEN); + if (ampToken) { + return { + token: ampToken, + baseUrl: rawTokenBaseUrl(input, env), + source: 'AMP_TOKEN env var', + }; + } + + const store = input.store ?? loadStore(); + const selected = selectProfile({ ...input, store, env }); + if (selected) { + return { + token: tokenFromProfile(selected.profile, selected.name, now), + baseUrl: stripTrailingSlash( + trimmed(input.baseUrlFlag) ?? selected.profile.base_url, + ), + source: selected.source, + profile: selected.name, + }; + } + + // selectProfile returned nothing: either a name was requested but no such + // profile exists, or no profile is active at all. The guidance differs. + const name = + trimmed(input.profileFlag) ?? trimmed(env.AMP_PROFILE) ?? store.default; + if (name) { + throw new Error(`No such profile: ${name}. Run \`amp auth list\`.`); + } + // "no profiles at all" vs "profiles exist but none is active" (e.g. just + // after logging out the default) — the suggested fix differs. + if (Object.keys(store.profiles).length > 0) { + throw new Error( + 'No active profile. Run `amp auth use ` to pick one (`amp auth list`), or `amp auth login`.', + ); + } + throw new Error('No credentials. Run `amp auth login`.'); +} + +/** + * The base-url override carried by the request flags: explicit `--base-url` + * wins, else `--env` is mapped through the friendly-name table. Mirrors + * `loginBaseUrl`'s precedence so a command and a login agree on what an env + * name means. + */ +function baseUrlOverrideFromFlags( + flags: Record, +): string | undefined { + const baseUrl = stringFlag(flags, ['base-url']); + if (baseUrl) { + return baseUrl; + } + const env = stringFlag(flags, ['env']); + return env ? resolveEnvBaseUrl(env) : undefined; +} + +/** + * Adapts parsed CLI flags into a {@link resolveAuth} call — the single entry + * point the request pipeline and `auth status` share. `overrides` lets tests + * inject a store / clock / env without touching disk or globals. + */ +export function resolveAuthFromFlags( + flags: Record, + overrides: Pick = {}, +): ResolvedAuth { + return resolveAuth({ + tokenFlag: stringFlag(flags, ['token']), + profileFlag: stringFlag(flags, ['profile']), + baseUrlFlag: baseUrlOverrideFromFlags(flags), + ...overrides, + }); +} + +/** + * Flags adapter for {@link selectProfile}, mirroring {@link resolveAuthFromFlags}. + * Used by `auth status` to render the selected profile's metadata even when its + * token has expired. + */ +export function selectProfileFromFlags( + flags: Record, + overrides: Pick = {}, +): SelectedProfile | undefined { + return selectProfile({ + tokenFlag: stringFlag(flags, ['token']), + profileFlag: stringFlag(flags, ['profile']), + ...overrides, + }); +} + +/** + * Formats a resolved token into an Authorization header by its shape: an + * `amp_` value is a PAT, an already-prefixed value passes through, anything + * else is a bearer. (Carried over from the prior auth header logic.) + */ +export function authorizationHeaderForToken(token: string): string { + if (token.startsWith('Bearer ')) { + return token; + } + if (token.startsWith('PAT=')) { + return `Bearer ${token}`; + } + if (token.startsWith('amp_')) { + return `Bearer PAT=${token}`; + } + return `Bearer ${token}`; +} diff --git a/src/credential-store.test.ts b/src/credential-store.test.ts new file mode 100644 index 0000000..22f6b05 --- /dev/null +++ b/src/credential-store.test.ts @@ -0,0 +1,313 @@ +import { + existsSync, + mkdtempSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + CURRENT_VERSION, + type CredentialStore, + type OAuthCredential, + assertValidProfileName, + emptyStore, + getProfile, + loadStore, + removeProfile, + saveStore, + setDefault, + setProfile, +} from './credential-store'; + +const OAUTH: OAuthCredential = { + type: 'oauth', + access_token: 'eyJ.test.token', + token_type: 'Bearer', + expires_at: '2026-06-24T12:00:00.000Z', + refresh_token: 'refresh_test', + scope: 'read:projects', +}; + +function oauthProfile(baseUrl: string): CredentialStore['profiles'][string] { + return { + base_url: baseUrl, + credential: OAUTH, + saved_at: '2026-06-23T14:56:23.000Z', + store: 'file', + }; +} + +describe('credential-store', () => { + const dirs: string[] = []; + + afterEach(() => { + for (const dir of dirs) { + rmSync(dir, { force: true, recursive: true }); + } + dirs.length = 0; + }); + + function tempPath(): string { + const dir = mkdtempSync(join(tmpdir(), 'amp-store-')); + dirs.push(dir); + return join(dir, 'credentials.json'); + } + + describe('loadStore', () => { + it('returns an empty store when the file is missing', () => { + expect(loadStore(tempPath())).toEqual(emptyStore()); + }); + + it('returns an empty store on corrupt JSON', () => { + const path = tempPath(); + writeFileSync(path, '{ not valid json'); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + expect(loadStore(path)).toEqual(emptyStore()); + stderr.mockRestore(); + }); + + it('warns on stderr before treating corrupt JSON as empty', () => { + const path = tempPath(); + writeFileSync(path, '{ not valid json'); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + loadStore(path); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('not valid JSON'), + ); + stderr.mockRestore(); + }); + + it('does not warn when the file is simply missing', () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + loadStore(tempPath()); + expect(stderr).not.toHaveBeenCalled(); + stderr.mockRestore(); + }); + + it('ignores a legacy flat PAT file (no version/profiles) without warning', () => { + const path = tempPath(); + writeFileSync( + path, + JSON.stringify({ pat: 'amp_old', org_url: 'amplitude', saved_at: 'x' }), + ); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + expect(loadStore(path)).toEqual(emptyStore()); + expect(stderr).not.toHaveBeenCalled(); + stderr.mockRestore(); + }); + + it('warns when a versioned store fails schema validation', () => { + // Looks like our store (has version/profiles) but one profile is malformed + // — without a warning every profile would silently vanish. + const path = tempPath(); + writeFileSync( + path, + JSON.stringify({ + version: CURRENT_VERSION, + default: 'a', + profiles: { a: { credential: { type: 'oauth' } } }, + }), + ); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + expect(loadStore(path)).toEqual(emptyStore()); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('did not validate'), + ); + stderr.mockRestore(); + }); + + it('emptyStore carries the current version and no default', () => { + expect(emptyStore()).toEqual({ version: CURRENT_VERSION, profiles: {} }); + }); + }); + + describe('round-trip', () => { + it('saves and loads a profile store', () => { + const path = tempPath(); + let store = emptyStore(); + store = setProfile(store, 'amplitude', oauthProfile('https://prod')); + store = setDefault(store, 'amplitude'); + saveStore(store, path); + + expect(loadStore(path)).toEqual(store); + }); + + it('writes atomically with no temp files left behind', () => { + const path = tempPath(); + saveStore(setProfile(emptyStore(), 'p', oauthProfile('https://x')), path); + + expect(existsSync(path)).toBe(true); + expect(readdirSync(dirname(path))).toEqual(['credentials.json']); + }); + + it('writes the file 0600', () => { + if (process.platform === 'win32') { + return; + } + const path = tempPath(); + saveStore(setProfile(emptyStore(), 'p', oauthProfile('https://x')), path); + + // eslint-disable-next-line no-bitwise -- masking permission bits off st_mode + expect(statSync(path).mode & 0o777).toBe(0o600); + }); + + it('creates the parent directory 0700', () => { + if (process.platform === 'win32') { + return; + } + const base = mkdtempSync(join(tmpdir(), 'amp-store-')); + dirs.push(base); + const dir = join(base, 'nested'); + saveStore( + setProfile(emptyStore(), 'p', oauthProfile('https://x')), + join(dir, 'credentials.json'), + ); + + // eslint-disable-next-line no-bitwise -- masking permission bits off st_mode + expect(statSync(dir).mode & 0o777).toBe(0o700); + }); + }); + + describe('profile CRUD', () => { + it('setProfile adds without changing the default', () => { + const store = setProfile(emptyStore(), 'a', oauthProfile('https://a')); + expect(getProfile(store, 'a')?.base_url).toBe('https://a'); + expect(store.default).toBeUndefined(); + }); + + it('setDefault throws on an unknown profile', () => { + expect(() => setDefault(emptyStore(), 'nope')).toThrow(); + }); + + it('removeProfile clears the default when removing the active profile', () => { + let store = setProfile(emptyStore(), 'a', oauthProfile('https://a')); + store = setDefault(store, 'a'); + store = removeProfile(store, 'a'); + + expect(getProfile(store, 'a')).toBeUndefined(); + expect(store.default).toBeUndefined(); + }); + + it('removeProfile leaves the default when removing a non-default profile', () => { + let store = setProfile(emptyStore(), 'a', oauthProfile('https://a')); + store = setProfile(store, 'b', oauthProfile('https://b')); + store = setDefault(store, 'a'); + store = removeProfile(store, 'b'); + + expect(store.default).toBe('a'); + expect(getProfile(store, 'b')).toBeUndefined(); + }); + + it('removeProfile on an unknown profile is a no-op', () => { + const store = setProfile(emptyStore(), 'a', oauthProfile('https://a')); + expect(removeProfile(store, 'nope')).toEqual(store); + }); + }); + + describe('forward-compat', () => { + it('round-trips a credential type this version does not model', () => { + const path = tempPath(); + const store: CredentialStore = { + version: CURRENT_VERSION, + default: 'sa', + profiles: { + sa: { + base_url: 'https://prod', + credential: { + type: 'service_account', + client_id: 'cid', + secret_ref: 'ref', + }, + saved_at: '2026-06-23T00:00:00.000Z', + }, + }, + }; + saveStore(store, path); + + expect(loadStore(path)).toEqual(store); + }); + + it('loads a file written by a newer CLI version without downgrading it', () => { + const path = tempPath(); + const future = CURRENT_VERSION + 1; + writeFileSync( + path, + JSON.stringify({ + version: future, + default: 'a', + profiles: { a: oauthProfile('https://a') }, + }), + ); + + const loaded = loadStore(path); + expect(loaded.version).toBe(future); + + // A mutation from this older CLI must not stamp the file back to + // CURRENT_VERSION — the newer version is preserved across rewrites. + saveStore(setProfile(loaded, 'b', oauthProfile('https://b')), path); + expect(loadStore(path).version).toBe(future); + }); + + it('preserves unknown top-level and profile keys (passthrough)', () => { + const path = tempPath(); + const store: CredentialStore = { + version: CURRENT_VERSION, + profiles: { + a: { ...oauthProfile('https://a'), future_field: 'keep' }, + }, + future_top: { nested: true }, + }; + saveStore(store, path); + + expect(loadStore(path)).toEqual(store); + }); + }); + + describe('assertValidProfileName', () => { + it('accepts identifier-style names', () => { + for (const name of ['dev', 'prod-eu', 'my_org.1', 'A1', 'x'.repeat(64)]) { + expect(() => assertValidProfileName(name)).not.toThrow(); + } + }); + + it('rejects the reserved name "default"', () => { + expect(() => assertValidProfileName('default')).toThrow(/reserved/); + }); + + it('rejects whitespace, control chars, slashes, empty, and over-length', () => { + for (const name of [ + '', + ' ', + 'my profile', + 'a\nb', + 'a/b', + 'emoji😀', + 'x'.repeat(65), + ]) { + expect(() => assertValidProfileName(name)).toThrow( + /Invalid profile name/, + ); + } + }); + }); +}); diff --git a/src/credential-store.ts b/src/credential-store.ts new file mode 100644 index 0000000..60434d5 --- /dev/null +++ b/src/credential-store.ts @@ -0,0 +1,257 @@ +import { + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { z } from 'zod'; + +/** + * On-disk credential store for the `amp` CLI: a versioned file holding named + * profiles (one credential + the backend it targets) plus a pointer to the + * active one. Replaces the earlier flat single-PAT file. + * + * Forward-compat is load-bearing here (we want to evolve the shape later + * without breaking older CLIs that read a newer file): every object keeps + * unknown keys (`.catchall`) and a credential `type` this version doesn't model + * round-trips untouched rather than failing the read. + */ + +export const CURRENT_VERSION = 1; + +// Validates a user-supplied profile name at the *creation* boundary (login / +// pat). Deliberately NOT enforced on the store's `profiles` key — load stays +// lenient so a name written by a newer CLI (or before this rule existed) +// round-trips instead of dropping the profile. Strict identifier charset keeps +// names safe to interpolate into `auth list` / `status` output (no newlines or +// control chars to spoof a row) and predictable across tools. +export const profileNameSchema = z + .string() + .min(1, 'must not be empty') + .max(64, 'must be at most 64 characters') + .regex(/^[A-Za-z0-9._-]+$/, 'use letters, digits, dot, underscore, or hyphen') + .refine((name) => name !== 'default', 'the name "default" is reserved'); + +/** Throws a friendly error if `name` is not a valid profile name. */ +export function assertValidProfileName(name: string): void { + const result = profileNameSchema.safeParse(name); + if (!result.success) { + throw new Error( + `Invalid profile name "${name}": ${result.error.issues[0]?.message ?? 'invalid'}.`, + ); + } +} + +export const oauthCredentialSchema = z + .object({ + type: z.literal('oauth'), + access_token: z.string().min(1), + token_type: z.string().min(1), + expires_at: z.string().min(1), + refresh_token: z.string().min(1).optional(), + scope: z.string().optional(), + }) + .catchall(z.unknown()); + +export const patCredentialSchema = z + .object({ + type: z.literal('pat'), + pat: z.string().min(1), + }) + .catchall(z.unknown()); + +// A credential type this CLI version does not model (e.g. a future +// service_account). Matched last so known types win; preserved on rewrite. +const unknownCredentialSchema = z + .object({ type: z.string().min(1) }) + .catchall(z.unknown()); + +const credentialSchema = z.union([ + oauthCredentialSchema, + patCredentialSchema, + unknownCredentialSchema, +]); + +const profileSchema = z + .object({ + base_url: z.string().min(1), + credential: credentialSchema, + saved_at: z.string().min(1), + // Storage backend for this profile's secret. "file" today; recorded rather + // than assumed so an OS-keychain backend can be added (and later defaulted) + // without breaking existing files. + store: z.string().optional(), + }) + .catchall(z.unknown()); + +const storeSchema = z + .object({ + version: z.number().int(), + default: z.string().optional(), + profiles: z.record(z.string(), profileSchema), + }) + .catchall(z.unknown()); + +export type OAuthCredential = z.infer; +export type PatCredential = z.infer; +export type Credential = z.infer; +export type Profile = z.infer; +export type CredentialStore = z.infer; + +export function credentialsPath(override?: string): string { + return override ?? join(homedir(), '.amplitude', 'amp', 'credentials.json'); +} + +export function emptyStore(): CredentialStore { + return { version: CURRENT_VERSION, profiles: {} }; +} + +/** + * Reads the store. A missing or legacy-flat file is treated as "no store yet" + * (empty) — the next save overwrites it. There is no migration from the old + * flat PAT file by design. + * + * A file that exists but cannot be read or parsed (corrupt JSON, bad + * permissions) is also treated as empty so the CLI stays usable, but we warn on + * stderr first: the next save silently overwrites it, and silent credential + * loss is worse than a noisy one. + */ +export function loadStore(path: string = credentialsPath()): CredentialStore { + let raw: string; + try { + raw = readFileSync(path, 'utf8'); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return emptyStore(); + } + process.stderr.write( + `amp: could not read credentials at ${path} (${ + error instanceof Error ? error.message : String(error) + }); treating as empty. The next login will overwrite it.\n`, + ); + return emptyStore(); + } + + let json: unknown; + try { + json = JSON.parse(raw); + } catch (error) { + process.stderr.write( + `amp: credentials at ${path} are not valid JSON (${ + error instanceof Error ? error.message : String(error) + }); treating as empty. The next login will overwrite it.\n`, + ); + return emptyStore(); + } + + const parsed = storeSchema.safeParse(json); + if (parsed.success) { + return parsed.data; + } + + // Schema mismatch falls into two cases. A legacy flat PAT file (or any + // pre-versioned/foreign shape) is the expected "no store yet" — silent, since + // warning on every read of an old file is noise. But a file that already + // *looks like* our versioned store yet fails validation (e.g. one malformed + // profile fails the whole `z.record`) would otherwise silently hide every + // profile until the next save overwrites it — so warn loudly there, the same + // as corrupt JSON. + if (looksLikeStore(json)) { + const issue = parsed.error.issues[0]; + const where = issue + ? `${issue.path.join('.') || 'root'}: ${issue.message}` + : 'schema mismatch'; + process.stderr.write( + `amp: credentials at ${path} did not validate (${where}); treating as empty. The next login will overwrite it.\n`, + ); + } + return emptyStore(); +} + +// Whether the parsed JSON is an attempt at *our* versioned store (vs a legacy +// flat PAT file or unrelated shape). Used to decide whether a schema-validation +// failure is worth warning about. +function looksLikeStore(json: unknown): boolean { + return ( + typeof json === 'object' && + json !== null && + ('version' in json || 'profiles' in json) + ); +} + +export function saveStore( + store: CredentialStore, + path: string = credentialsPath(), +): void { + const dir = dirname(path); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + + const tempPath = join( + dir, + `.credentials.${process.pid}.${Date.now()}.${Math.random() + .toString(36) + .slice(2)}.tmp`, + ); + + try { + writeFileSync(tempPath, `${JSON.stringify(store, null, 2)}\n`, { + mode: 0o600, + }); + renameSync(tempPath, path); + } catch (error) { + rmSync(tempPath, { force: true }); + throw error; + } +} + +export function getProfile( + store: CredentialStore, + name: string, +): Profile | undefined { + return store.profiles[name]; +} + +/** Adds or replaces a profile. Does not change the active (`default`) profile. */ +export function setProfile( + store: CredentialStore, + name: string, + profile: Profile, +): CredentialStore { + return { ...store, profiles: { ...store.profiles, [name]: profile } }; +} + +/** Points `default` at an existing profile. Throws if the profile is unknown. */ +export function setDefault( + store: CredentialStore, + name: string, +): CredentialStore { + if (!store.profiles[name]) { + throw new Error(`No such profile: ${name}`); + } + return { ...store, default: name }; +} + +/** + * Removes a profile. Clears `default` if it pointed at the removed profile — + * never auto-promotes a survivor (the active identity only changes on an + * explicit command). Unknown profile name is a no-op. + */ +export function removeProfile( + store: CredentialStore, + name: string, +): CredentialStore { + if (!store.profiles[name]) { + return store; + } + const profiles = { ...store.profiles }; + delete profiles[name]; + const next: CredentialStore = { ...store, profiles }; + if (store.default === name) { + delete next.default; + } + return next; +} diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..a9c4cdd --- /dev/null +++ b/src/env.ts @@ -0,0 +1,12 @@ +/** + * Reads the API base URL from the environment via the canonical + * AMP_API_BASE_URL. Returns undefined when it is unset (or blank) so callers + * can apply their own default. + * + * Centralized here so the CLI request host (cli.ts) and the PAT settings app + * origin (auth-guidance.ts) cannot drift apart. AMP_API_BASE_URL is the only + * supported name. + */ +export function apiBaseUrlFromEnv(): string | undefined { + return process.env.AMP_API_BASE_URL?.trim() || undefined; +} diff --git a/src/errors.test.ts b/src/errors.test.ts new file mode 100644 index 0000000..d8c3bb8 --- /dev/null +++ b/src/errors.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { formatApiError } from './errors'; + +describe('formatApiError', () => { + it('formats RFC 7807 problem responses with hints', () => { + const message = formatApiError(403, 'Forbidden', { + title: 'Insufficient scope', + detail: 'Token missing required scopes.', + error_code: 'insufficient_scope', + }); + + expect(message).toContain('Insufficient scope (403 Forbidden)'); + expect(message).toContain('Token missing required scopes.'); + expect(message).toContain('amp context'); + }); + + it('includes validation errors when present', () => { + const message = formatApiError(400, 'Bad Request', { + title: 'Validation failed', + error_code: 'validation_error', + validation_errors: [ + { field: 'event_type', message: 'Required', code: 'required' }, + ], + }); + + expect(message).toContain('event_type: Required'); + expect(message).toContain('amp help'); + }); + + it('falls back to JSON for unknown error bodies', () => { + const message = formatApiError(500, 'Internal Server Error', { + message: 'boom', + }); + + expect(message).toContain('500 Internal Server Error'); + expect(message).toContain('"boom"'); + }); + + it('prints non-JSON error bodies directly', () => { + const message = formatApiError( + 502, + 'Bad Gateway', + 'gateway unavailable', + ); + + expect(message).toContain('502 Bad Gateway'); + expect(message).toContain('gateway unavailable'); + }); +}); diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..312a581 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,93 @@ +interface ProblemDetails { + title?: string; + status?: number; + detail?: string | null; + error_code?: string; + validation_errors?: Array<{ + field: string; + message: string; + code: string; + }> | null; +} + +function asProblem(body: unknown): ProblemDetails | undefined { + if (!body || typeof body !== 'object') { + return undefined; + } + + const candidate = body as ProblemDetails; + if ( + typeof candidate.error_code === 'string' || + typeof candidate.title === 'string' + ) { + return candidate; + } + + return undefined; +} + +function hintForErrorCode(errorCode: string | undefined): string | undefined { + switch (errorCode) { + case 'authentication_required': + case 'invalid_token': + return 'Run `amp auth login`, or set AMP_TOKEN for a single shell.'; + case 'insufficient_scope': + return 'Run `amp context` to inspect your token, then re-authenticate with the required access (`amp auth login`).'; + case 'validation_error': + return 'Check required flags with `amp help `.'; + case 'not_found': + return 'Verify identifiers such as --project, --flag, and --event.'; + case 'auth_unavailable': + case 'upstream_error': + return 'Retry the request. If it persists, check API status or use --base-url for a different host.'; + default: + return undefined; + } +} + +export function formatApiError( + status: number, + statusText: string, + body: unknown, +): string { + const problem = asProblem(body); + const lines: string[] = []; + + if (problem) { + const title = problem.title ?? `HTTP ${status}`; + lines.push(`${title} (${status} ${statusText})`); + + if (problem.detail) { + lines.push(problem.detail); + } + + if (problem.validation_errors && problem.validation_errors.length > 0) { + lines.push(''); + lines.push('Validation errors:'); + for (const error of problem.validation_errors) { + lines.push(` - ${error.field}: ${error.message}`); + } + } + + const hint = hintForErrorCode(problem.error_code); + if (hint) { + lines.push(''); + lines.push(hint); + } + + return lines.join('\n'); + } + + if (typeof body === 'string') { + const trimmed = body.trim(); + if (trimmed) { + return `Request failed (${status} ${statusText}):\n${trimmed}`; + } + } + + if (body !== null && body !== undefined) { + return `Request failed (${status} ${statusText}):\n${JSON.stringify(body, null, 2)}`; + } + + return `Request failed (${status} ${statusText}).`; +} diff --git a/src/generated/cli-manifest.ts b/src/generated/cli-manifest.ts new file mode 100644 index 0000000..fcb0df1 --- /dev/null +++ b/src/generated/cli-manifest.ts @@ -0,0 +1,1141 @@ +/* eslint-disable */ +// This file is generated by scripts/generate-cli-manifest.ts. Do not edit by hand. + +// OpenAPI `info.version` of the bundled Developer API spec this CLI was built +// against. Surfaced by `amp version` so support can correlate CLI builds with +// the API contract snapshot they ship. +export const API_SPEC_VERSION = '0.1.0'; + +export interface CliParameter { + name: string; + in: 'header' | 'path' | 'query'; + required: boolean; + aliases: string[]; + type: string; +} + +export interface CliBodyProperty { + name: string; + required: boolean; + aliases: string[]; + type: string; + nullable: boolean; + enum?: string[]; +} + +export interface CliOperation { + command: string[]; + method: string; + operationId: string; + path: string; + requiredScopes: string[]; + summary?: string; + successStatus?: number; + parameters: CliParameter[]; + body: CliBodyProperty[]; +} + +export const CLI_OPERATIONS = [ + { + command: ['context'], + method: 'GET', + operationId: 'getContext', + path: '/v1/context', + requiredScopes: ['read:projects'], + summary: 'Get authenticated context', + successStatus: 200, + parameters: [], + body: [], + }, + { + command: ['projects', 'list'], + method: 'GET', + operationId: 'listProjects', + path: '/v1/projects', + requiredScopes: ['read:projects'], + summary: 'List projects', + successStatus: 200, + parameters: [ + { + name: 'cursor', + in: 'query', + required: false, + aliases: ['cursor'], + type: 'string', + }, + { + name: 'limit', + in: 'query', + required: false, + aliases: ['limit'], + type: 'integer', + }, + { + name: 'sort', + in: 'query', + required: false, + aliases: ['sort'], + type: 'string', + }, + { + name: 'q', + in: 'query', + required: false, + aliases: ['q', 'query'], + type: 'string', + }, + ], + body: [], + }, + { + command: ['events', 'list'], + method: 'GET', + operationId: 'listEvents', + path: '/v1/projects/{project_id}/events', + requiredScopes: ['read:taxonomy'], + summary: 'List event taxonomy entries', + successStatus: 200, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'cursor', + in: 'query', + required: false, + aliases: ['cursor'], + type: 'string', + }, + { + name: 'limit', + in: 'query', + required: false, + aliases: ['limit'], + type: 'integer', + }, + { + name: 'q', + in: 'query', + required: false, + aliases: ['q', 'query'], + type: 'string', + }, + ], + body: [], + }, + { + command: ['events', 'create'], + method: 'POST', + operationId: 'createEvent', + path: '/v1/projects/{project_id}/events', + requiredScopes: ['write:taxonomy'], + summary: 'Create event taxonomy entry', + successStatus: 201, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'Idempotency-Key', + in: 'header', + required: true, + aliases: ['idempotency-key'], + type: 'string', + }, + ], + body: [ + { + name: 'event_type', + required: true, + aliases: ['event-type', 'type'], + type: 'string', + nullable: false, + }, + { + name: 'description', + required: false, + aliases: ['description'], + type: 'string', + nullable: true, + }, + ], + }, + { + command: ['events', 'delete'], + method: 'DELETE', + operationId: 'deleteEvent', + path: '/v1/projects/{project_id}/events/{event_id}', + requiredScopes: ['write:taxonomy'], + summary: 'Soft-delete event taxonomy entry', + successStatus: 204, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'event_id', + in: 'path', + required: true, + aliases: ['event', 'event-id', 'event-type'], + type: 'string', + }, + { + name: 'dry_run', + in: 'query', + required: false, + aliases: ['dry-run'], + type: 'boolean', + }, + { + name: 'Idempotency-Key', + in: 'header', + required: true, + aliases: ['idempotency-key'], + type: 'string', + }, + ], + body: [], + }, + { + command: ['events', 'get'], + method: 'GET', + operationId: 'getEvent', + path: '/v1/projects/{project_id}/events/{event_id}', + requiredScopes: ['read:taxonomy'], + summary: 'Get event taxonomy entry', + successStatus: 200, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'event_id', + in: 'path', + required: true, + aliases: ['event', 'event-id', 'event-type'], + type: 'string', + }, + ], + body: [], + }, + { + command: ['events', 'update'], + method: 'PATCH', + operationId: 'updateEvent', + path: '/v1/projects/{project_id}/events/{event_id}', + requiredScopes: ['write:taxonomy'], + summary: 'Update event taxonomy entry', + successStatus: 200, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'event_id', + in: 'path', + required: true, + aliases: ['event', 'event-id', 'event-type'], + type: 'string', + }, + { + name: 'Idempotency-Key', + in: 'header', + required: true, + aliases: ['idempotency-key'], + type: 'string', + }, + ], + body: [ + { + name: 'description', + required: false, + aliases: ['description'], + type: 'string', + nullable: true, + }, + { + name: 'is_active', + required: false, + aliases: ['is-active'], + type: 'boolean', + nullable: false, + }, + ], + }, + { + command: ['event-properties', 'list'], + method: 'GET', + operationId: 'listEventProperties', + path: '/v1/projects/{project_id}/events/{event_id}/event-properties', + requiredScopes: ['read:taxonomy'], + summary: 'List event properties for an event', + successStatus: 200, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'event_id', + in: 'path', + required: true, + aliases: ['event', 'event-id', 'event-type'], + type: 'string', + }, + { + name: 'cursor', + in: 'query', + required: false, + aliases: ['cursor'], + type: 'string', + }, + { + name: 'limit', + in: 'query', + required: false, + aliases: ['limit'], + type: 'integer', + }, + { + name: 'q', + in: 'query', + required: false, + aliases: ['q', 'query'], + type: 'string', + }, + ], + body: [], + }, + { + command: ['event-properties', 'create'], + method: 'POST', + operationId: 'createEventProperty', + path: '/v1/projects/{project_id}/events/{event_id}/event-properties', + requiredScopes: ['write:taxonomy'], + summary: 'Create event property', + successStatus: 201, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'event_id', + in: 'path', + required: true, + aliases: ['event', 'event-id', 'event-type'], + type: 'string', + }, + { + name: 'Idempotency-Key', + in: 'header', + required: true, + aliases: ['idempotency-key'], + type: 'string', + }, + ], + body: [ + { + name: 'property_name', + required: true, + aliases: ['property', 'property-name', 'name'], + type: 'string', + nullable: false, + }, + { + name: 'description', + required: false, + aliases: ['description'], + type: 'string', + nullable: true, + }, + { + name: 'data_type', + required: false, + aliases: ['data-type', 'type'], + type: 'string', + nullable: true, + enum: ['string', 'number', 'boolean', 'object', 'enum', 'any'], + }, + { + name: 'is_array_type', + required: false, + aliases: ['is-array'], + type: 'boolean', + nullable: false, + }, + { + name: 'enum_values', + required: false, + aliases: ['enum-values'], + type: 'array', + nullable: false, + }, + { + name: 'regex', + required: false, + aliases: ['regex'], + type: 'string', + nullable: true, + }, + { + name: 'is_hidden', + required: false, + aliases: ['is-hidden'], + type: 'boolean', + nullable: false, + }, + { + name: 'classifications', + required: false, + aliases: ['classifications'], + type: 'array', + nullable: false, + }, + ], + }, + { + command: ['event-properties', 'delete'], + method: 'DELETE', + operationId: 'deleteEventProperty', + path: '/v1/projects/{project_id}/events/{event_id}/event-properties/{event_property_id}', + requiredScopes: ['write:taxonomy'], + summary: 'Delete event property', + successStatus: 204, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'event_id', + in: 'path', + required: true, + aliases: ['event', 'event-id', 'event-type'], + type: 'string', + }, + { + name: 'event_property_id', + in: 'path', + required: true, + aliases: ['property', 'property-name', 'name'], + type: 'string', + }, + { + name: 'dry_run', + in: 'query', + required: false, + aliases: ['dry-run'], + type: 'boolean', + }, + { + name: 'Idempotency-Key', + in: 'header', + required: true, + aliases: ['idempotency-key'], + type: 'string', + }, + ], + body: [], + }, + { + command: ['event-properties', 'get'], + method: 'GET', + operationId: 'getEventProperty', + path: '/v1/projects/{project_id}/events/{event_id}/event-properties/{event_property_id}', + requiredScopes: ['read:taxonomy'], + summary: 'Get event property', + successStatus: 200, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'event_id', + in: 'path', + required: true, + aliases: ['event', 'event-id', 'event-type'], + type: 'string', + }, + { + name: 'event_property_id', + in: 'path', + required: true, + aliases: ['property', 'property-name', 'name'], + type: 'string', + }, + ], + body: [], + }, + { + command: ['event-properties', 'update'], + method: 'PATCH', + operationId: 'updateEventProperty', + path: '/v1/projects/{project_id}/events/{event_id}/event-properties/{event_property_id}', + requiredScopes: ['write:taxonomy'], + summary: 'Update event property metadata', + successStatus: 200, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'event_id', + in: 'path', + required: true, + aliases: ['event', 'event-id', 'event-type'], + type: 'string', + }, + { + name: 'event_property_id', + in: 'path', + required: true, + aliases: ['property', 'property-name', 'name'], + type: 'string', + }, + { + name: 'Idempotency-Key', + in: 'header', + required: true, + aliases: ['idempotency-key'], + type: 'string', + }, + ], + body: [ + { + name: 'description', + required: false, + aliases: ['description'], + type: 'string', + nullable: true, + }, + { + name: 'data_type', + required: false, + aliases: ['data-type', 'type'], + type: 'string', + nullable: true, + enum: ['string', 'number', 'boolean', 'object', 'enum', 'any'], + }, + { + name: 'is_array_type', + required: false, + aliases: ['is-array'], + type: 'boolean', + nullable: false, + }, + { + name: 'enum_values', + required: false, + aliases: ['enum-values'], + type: 'array', + nullable: false, + }, + { + name: 'regex', + required: false, + aliases: ['regex'], + type: 'string', + nullable: true, + }, + { + name: 'is_hidden', + required: false, + aliases: ['is-hidden'], + type: 'boolean', + nullable: false, + }, + { + name: 'classifications', + required: false, + aliases: ['classifications'], + type: 'array', + nullable: false, + }, + ], + }, + { + command: ['user-properties', 'list'], + method: 'GET', + operationId: 'listUserProperties', + path: '/v1/projects/{project_id}/user-properties', + requiredScopes: ['read:taxonomy'], + summary: 'List user properties for a project', + successStatus: 200, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'cursor', + in: 'query', + required: false, + aliases: ['cursor'], + type: 'string', + }, + { + name: 'limit', + in: 'query', + required: false, + aliases: ['limit'], + type: 'integer', + }, + { + name: 'q', + in: 'query', + required: false, + aliases: ['q', 'query'], + type: 'string', + }, + ], + body: [], + }, + { + command: ['user-properties', 'create'], + method: 'POST', + operationId: 'createUserProperty', + path: '/v1/projects/{project_id}/user-properties', + requiredScopes: ['write:taxonomy'], + summary: 'Create user property', + successStatus: 201, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'Idempotency-Key', + in: 'header', + required: true, + aliases: ['idempotency-key'], + type: 'string', + }, + ], + body: [ + { + name: 'property_name', + required: true, + aliases: ['property', 'property-name', 'name'], + type: 'string', + nullable: false, + }, + { + name: 'display_name', + required: false, + aliases: ['display-name'], + type: 'string', + nullable: true, + }, + { + name: 'description', + required: false, + aliases: ['description'], + type: 'string', + nullable: true, + }, + { + name: 'data_type', + required: false, + aliases: ['data-type', 'type'], + type: 'string', + nullable: true, + enum: ['string', 'number', 'boolean', 'object', 'enum', 'any'], + }, + { + name: 'is_array_type', + required: false, + aliases: ['is-array'], + type: 'boolean', + nullable: false, + }, + { + name: 'enum_values', + required: false, + aliases: ['enum-values'], + type: 'array', + nullable: false, + }, + { + name: 'regex', + required: false, + aliases: ['regex'], + type: 'string', + nullable: true, + }, + { + name: 'is_hidden', + required: false, + aliases: ['is-hidden'], + type: 'boolean', + nullable: false, + }, + { + name: 'classifications', + required: false, + aliases: ['classifications'], + type: 'array', + nullable: false, + }, + ], + }, + { + command: ['user-properties', 'delete'], + method: 'DELETE', + operationId: 'deleteUserProperty', + path: '/v1/projects/{project_id}/user-properties/{user_property_id}', + requiredScopes: ['write:taxonomy'], + summary: 'Delete user property', + successStatus: 204, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'user_property_id', + in: 'path', + required: true, + aliases: ['property', 'property-name', 'name'], + type: 'string', + }, + { + name: 'dry_run', + in: 'query', + required: false, + aliases: ['dry-run'], + type: 'boolean', + }, + { + name: 'Idempotency-Key', + in: 'header', + required: true, + aliases: ['idempotency-key'], + type: 'string', + }, + ], + body: [], + }, + { + command: ['user-properties', 'get'], + method: 'GET', + operationId: 'getUserProperty', + path: '/v1/projects/{project_id}/user-properties/{user_property_id}', + requiredScopes: ['read:taxonomy'], + summary: 'Get user property', + successStatus: 200, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'user_property_id', + in: 'path', + required: true, + aliases: ['property', 'property-name', 'name'], + type: 'string', + }, + ], + body: [], + }, + { + command: ['user-properties', 'update'], + method: 'PATCH', + operationId: 'updateUserProperty', + path: '/v1/projects/{project_id}/user-properties/{user_property_id}', + requiredScopes: ['write:taxonomy'], + summary: 'Update user property metadata', + successStatus: 200, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'user_property_id', + in: 'path', + required: true, + aliases: ['property', 'property-name', 'name'], + type: 'string', + }, + { + name: 'Idempotency-Key', + in: 'header', + required: true, + aliases: ['idempotency-key'], + type: 'string', + }, + ], + body: [ + { + name: 'display_name', + required: false, + aliases: ['display-name'], + type: 'string', + nullable: true, + }, + { + name: 'description', + required: false, + aliases: ['description'], + type: 'string', + nullable: true, + }, + { + name: 'is_hidden', + required: false, + aliases: ['is-hidden'], + type: 'boolean', + nullable: false, + }, + { + name: 'classifications', + required: false, + aliases: ['classifications'], + type: 'array', + nullable: false, + }, + ], + }, + { + command: ['flags', 'list'], + method: 'GET', + operationId: 'listFeatureFlags', + path: '/v1/projects/{project_id}/flags', + requiredScopes: ['read:flags'], + summary: 'List feature flags', + successStatus: 200, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'cursor', + in: 'query', + required: false, + aliases: ['cursor'], + type: 'string', + }, + { + name: 'limit', + in: 'query', + required: false, + aliases: ['limit'], + type: 'integer', + }, + ], + body: [], + }, + { + command: ['flags', 'create'], + method: 'POST', + operationId: 'createFeatureFlag', + path: '/v1/projects/{project_id}/flags', + requiredScopes: ['write:flags'], + summary: 'Create feature flag', + successStatus: 201, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'Idempotency-Key', + in: 'header', + required: true, + aliases: ['idempotency-key'], + type: 'string', + }, + ], + body: [ + { + name: 'key', + required: true, + aliases: ['key'], + type: 'string', + nullable: false, + }, + { + name: 'name', + required: true, + aliases: ['name'], + type: 'string', + nullable: false, + }, + { + name: 'description', + required: false, + aliases: ['description'], + type: 'string', + nullable: true, + }, + { + name: 'evaluation_mode', + required: false, + aliases: ['evaluation-mode'], + type: 'string', + nullable: false, + enum: ['local', 'remote'], + }, + { + name: 'bucketing_key', + required: false, + aliases: ['bucketing-key'], + type: 'string', + nullable: false, + }, + { + name: 'variants', + required: false, + aliases: ['variants'], + type: 'array', + nullable: false, + }, + { + name: 'deployment_ids', + required: false, + aliases: ['deployment-ids'], + type: 'array', + nullable: false, + }, + { + name: 'rollout_percentage', + required: false, + aliases: ['rollout-percentage'], + type: 'integer', + nullable: false, + }, + { + name: 'rollout_weights', + required: false, + aliases: ['rollout-weights'], + type: 'object', + nullable: false, + }, + { + name: 'testers', + required: false, + aliases: ['testers'], + type: 'array', + nullable: false, + }, + { + name: 'target_segments', + required: false, + aliases: ['target-segments'], + type: 'array', + nullable: false, + }, + ], + }, + { + command: ['flags', 'archive'], + method: 'DELETE', + operationId: 'archiveFeatureFlag', + path: '/v1/projects/{project_id}/flags/{flag_id}', + requiredScopes: ['write:flags'], + summary: 'Archive feature flag', + successStatus: 204, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'flag_id', + in: 'path', + required: true, + aliases: ['flag', 'flag-id', 'key'], + type: 'string', + }, + { + name: 'dry_run', + in: 'query', + required: false, + aliases: ['dry-run'], + type: 'boolean', + }, + { + name: 'Idempotency-Key', + in: 'header', + required: true, + aliases: ['idempotency-key'], + type: 'string', + }, + ], + body: [], + }, + { + command: ['flags', 'get'], + method: 'GET', + operationId: 'getFeatureFlag', + path: '/v1/projects/{project_id}/flags/{flag_id}', + requiredScopes: ['read:flags'], + summary: 'Get feature flag', + successStatus: 200, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'flag_id', + in: 'path', + required: true, + aliases: ['flag', 'flag-id', 'key'], + type: 'string', + }, + ], + body: [], + }, + { + command: ['flags', 'update'], + method: 'PATCH', + operationId: 'updateFeatureFlag', + path: '/v1/projects/{project_id}/flags/{flag_id}', + requiredScopes: ['write:flags'], + summary: 'Update feature flag', + successStatus: 200, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'flag_id', + in: 'path', + required: true, + aliases: ['flag', 'flag-id', 'key'], + type: 'string', + }, + { + name: 'Idempotency-Key', + in: 'header', + required: true, + aliases: ['idempotency-key'], + type: 'string', + }, + ], + body: [ + { + name: 'name', + required: false, + aliases: ['name'], + type: 'string', + nullable: false, + }, + { + name: 'description', + required: false, + aliases: ['description'], + type: 'string', + nullable: true, + }, + { + name: 'enabled', + required: false, + aliases: ['enabled'], + type: 'boolean', + nullable: false, + }, + { + name: 'evaluation_mode', + required: false, + aliases: ['evaluation-mode'], + type: 'string', + nullable: false, + enum: ['local', 'remote'], + }, + { + name: 'bucketing_key', + required: false, + aliases: ['bucketing-key'], + type: 'string', + nullable: false, + }, + { + name: 'rollout_percentage', + required: false, + aliases: ['rollout-percentage'], + type: 'integer', + nullable: false, + }, + { + name: 'tags', + required: false, + aliases: ['tags'], + type: 'array', + nullable: false, + }, + ], + }, +] satisfies CliOperation[]; diff --git a/src/help.test.ts b/src/help.test.ts new file mode 100644 index 0000000..e339b66 --- /dev/null +++ b/src/help.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + authHelpText, + findOperation, + listProductSurfaces, + operationsMatchingPrefix, + printGlobalHelp, +} from './help'; + +describe('help', () => { + it('lists product surfaces for top-level help', () => { + const surfaces = listProductSurfaces().map((surface) => surface.label); + expect(surfaces).toEqual([ + 'context', + 'projects', + 'events', + 'event-properties', + 'user-properties', + 'flags', + ]); + }); + + it('finds an exact operation', () => { + expect(findOperation(['flags', 'list'])?.operationId).toBe( + 'listFeatureFlags', + ); + }); + + it('finds operations under a command prefix', () => { + const matches = operationsMatchingPrefix(['flags']); + expect(matches.length).toBeGreaterThan(1); + expect(matches.every((operation) => operation.command[0] === 'flags')).toBe( + true, + ); + }); + + it('has auth-specific help outside the generated manifest', () => { + const help = authHelpText(); + expect(help).toContain('amp auth login'); + expect(help).toContain('amp auth status'); + expect(help).toContain('--profile'); + }); + + it('describes DELETE safety without overstating --dry-run support', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + try { + printGlobalHelp('https://developer-api.amplitude.com'); + const output = log.mock.calls.map((call) => String(call[0])).join('\n'); + expect(output).toContain('Skip interactive confirmation for DELETE'); + expect(output).toContain('Preview supported DELETE commands'); + expect(output).not.toContain('unless --dry-run is set'); + } finally { + log.mockRestore(); + } + }); +}); diff --git a/src/help.ts b/src/help.ts new file mode 100644 index 0000000..8b17e67 --- /dev/null +++ b/src/help.ts @@ -0,0 +1,281 @@ +import packageJson from '../package.json'; +import type { CliOperation } from './generated/cli-manifest'; +import { API_SPEC_VERSION, CLI_OPERATIONS } from './generated/cli-manifest'; + +export const CLI_VERSION = packageJson.version; + +export { API_SPEC_VERSION }; + +/** + * One-line version string for `amp version` / `--version`. Pairs the CLI's npm + * version with the bundled Developer API spec version so a single line is both + * human-readable and greppable by agents. + */ +export function formatVersion(): string { + return `amp/${CLI_VERSION} (Developer API spec ${API_SPEC_VERSION})`; +} + +const COMMAND_EXAMPLES: Record = { + context: 'amp context', + 'projects list': 'amp projects list --limit 10', + 'events list': 'amp events list --project --limit 5', + 'events create': + 'amp events create --project --event-type my_event', + 'events get': 'amp events get --project --event ', + 'flags list': 'amp flags list --project --limit 5', + 'flags create': + 'amp flags create --project --key my-flag --name "My Flag"', + 'flags get': 'amp flags get --project --flag ', + 'flags archive': + 'amp flags archive --project --flag --dry-run', +}; + +function commandKey(command: string[]): string { + return command.join(' '); +} + +export function operationsMatchingPrefix(command: string[]): CliOperation[] { + return CLI_OPERATIONS.filter( + (operation) => + operation.command.length >= command.length && + command.every((part, index) => operation.command[index] === part), + ); +} + +export function findOperation(command: string[]): CliOperation | undefined { + return CLI_OPERATIONS.find( + (operation) => + operation.command.length === command.length && + operation.command.every((part, index) => part === command[index]), + ); +} + +function operationUsage(operation: CliOperation): string { + const flags = [ + ...operation.parameters + .filter((parameter) => parameter.in !== 'header') + .map((parameter) => + parameter.required + ? `--${parameter.aliases[0]} <${parameter.name}>` + : `[--${parameter.aliases[0]} <${parameter.name}>]`, + ), + ...operation.body.map((property) => + property.required + ? `--${property.aliases[0]} <${property.name}>` + : `[--${property.aliases[0]} <${property.name}>]`, + ), + ]; + + return ` amp ${operation.command.join(' ')} ${flags.join(' ')}`.trimEnd(); +} + +function exampleFor(operation: CliOperation): string | undefined { + return COMMAND_EXAMPLES[commandKey(operation.command)]; +} + +const SURFACE_DESCRIPTIONS: Record = { + context: 'Authenticated user and org context', + projects: 'Projects in your organization', + events: 'Event taxonomy', + 'event-properties': 'Properties on events', + 'user-properties': 'User properties', + flags: 'Feature flags', +}; + +const SURFACE_ORDER = [ + 'context', + 'projects', + 'events', + 'event-properties', + 'user-properties', + 'flags', +] as const; + +export interface ProductSurface { + command: string[]; + description: string; + label: string; +} + +export function listProductSurfaces(): ProductSurface[] { + const byLabel = new Map(); + + for (const operation of CLI_OPERATIONS) { + const label = operation.command[0]; + if (byLabel.has(label)) { + continue; + } + + byLabel.set(label, { + label, + command: operation.command.length === 1 ? operation.command : [label], + description: + SURFACE_DESCRIPTIONS[label] ?? + operation.summary ?? + 'Developer API commands', + }); + } + + // A SURFACE_ORDER label may legitimately be absent when the manifest does + // not include that surface, so skip missing entries instead of asserting. + return SURFACE_ORDER.flatMap((label) => { + const surface = byLabel.get(label); + return surface ? [surface] : []; + }); +} + +function printSurfaceOverview(): string { + const lines = ['Product surfaces:']; + + for (const surface of listProductSurfaces()) { + const padding = ' '.repeat(Math.max(1, 22 - surface.label.length)); + lines.push(` ${surface.label}${padding}${surface.description}`); + } + + return lines.join('\n'); +} + +export function printGlobalHelp(defaultApiBaseUrl: string): void { + console.log(`amp ${CLI_VERSION} — Amplitude Developer API CLI + +Usage: + amp auth login --profile --env Authenticate and save a profile + amp auth Inspect, switch, or print credentials + amp logout [--profile |--all] Remove a profile (or all) + amp version Print CLI version + amp help [surface...] Explore commands for a product surface + +${printSurfaceOverview()} + +Explore commands: + amp help List commands for a surface (e.g. amp help flags) + amp --help Flags and examples for one command + +Global flags: + --base-url API base URL, defaults to ${defaultApiBaseUrl} + --env Target env (local|dev|staging|prod|prod-eu); sets the base URL + --profile Use a stored profile for this command + --token Raw PAT, PAT=, or bearer-compatible token + --json Print raw JSON (default when piped) + --yes Skip interactive confirmation for DELETE commands + --dry-run Preview supported DELETE commands without applying changes + --body-json '{...}' Merge raw JSON into request bodies for fields not yet modeled as flags + +Environment: + AMP_TOKEN Raw token (amp_... → PAT, else bearer); overrides stored profiles + AMP_PROFILE Stored profile to select by name + AMP_API_BASE_URL API base URL + ~/.amplitude/amp/credentials.json Saved profiles from \`amp auth login\``); +} + +export function authHelpText(): string { + return [ + 'amp auth', + '', + 'Authenticate and manage saved credential profiles.', + '', + 'Usage:', + ' amp auth login --profile --env Device flow → save + activate a profile', + ' amp auth login Re-authenticate the active profile', + ' amp auth pat --with-token --profile --env Save a supplied PAT (stdin/prompt)', + ' amp auth use Switch the active profile (no re-auth)', + ' amp auth list List profiles (* marks the active one)', + ' amp auth status Show the active credential and expiry', + ' amp auth token Print the active access token to stdout', + ' amp logout [--profile |--all] Remove a profile (or all)', + '', + 'Examples:', + ' amp auth login --profile prod --env prod', + ' amp auth login --profile staging --env staging', + ' amp auth use prod', + ' TOKEN=$(amp auth token)', + '', + 'Creating a profile is force-explicit: a new profile needs both --profile', + ' and --env (or --base-url ). Re-authenticating an existing', + 'profile reuses the env recorded on it, so a bare `amp auth login` refreshes', + 'the active profile in place. Login activates the profile it mints and prints', + 'the switch; the active identity never changes without a command.', + '', + 'A login requests every scope the CLI can use by default, so all commands', + 'work immediately.', + '', + 'Environment:', + ' AMP_TOKEN Raw token (amp_... → PAT, else bearer); overrides stored profiles', + ' AMP_PROFILE Stored profile to select by name', + ' AMP_API_BASE_URL API base URL', + ].join('\n'); +} + +export function printCommandHelp( + command: string[], + defaultApiBaseUrl: string, +): void { + if (command.length === 1 && command[0] === 'auth') { + console.log(authHelpText()); + return; + } + + const exact = findOperation(command); + if (exact) { + printOperationHelp(exact); + return; + } + + const matches = operationsMatchingPrefix(command); + if (matches.length > 0 && matches.length < CLI_OPERATIONS.length) { + printGroupHelp(command, matches); + return; + } + + printGlobalHelp(defaultApiBaseUrl); +} + +function printOperationHelp(operation: CliOperation): void { + const lines = [ + `amp ${operation.command.join(' ')}`, + '', + operation.summary ?? 'Call the Developer API.', + '', + 'Usage:', + operationUsage(operation), + ]; + + const example = exampleFor(operation); + if (example) { + lines.push('', 'Example:', ` ${example}`); + } + + if (operation.requiredScopes.length > 0) { + lines.push( + '', + 'Required scopes:', + ` ${operation.requiredScopes.join(', ')}`, + ); + } + + lines.push( + '', + 'Global flags: --base-url, --token, --json, --yes, --body-json', + 'Run `amp help` for product surfaces.', + ); + + console.log(lines.join('\n')); +} + +function printGroupHelp(command: string[], operations: CliOperation[]): void { + const label = command.join(' '); + console.log(`amp ${label} — available commands:\n`); + for (const operation of operations) { + const summary = operation.summary ?? ''; + console.log(` ${operation.command.join(' ')}`); + if (summary) { + console.log(` ${summary}`); + } + const example = exampleFor(operation); + if (example) { + console.log(` e.g. ${example}`); + } + console.log(''); + } + console.log('Run `amp help ` to explore another product surface.'); +} diff --git a/src/oauthError.ts b/src/oauthError.ts new file mode 100644 index 0000000..71e835e --- /dev/null +++ b/src/oauthError.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +/** + * RFC 6749 §5.2 OAuth error, plus the OAuth server's `error_hint` extension. + * The CLI parses upstream error bodies through this strict schema so debug-only + * fields (`error_debug`, `status_code`) are dropped and only the actionable + * message survives. Duplicated here so this package stays standalone for + * distribution. + */ +export const oauthErrorSchema = z.object({ + error: z.string(), + error_description: z.string().optional(), + error_hint: z.string().optional(), + error_uri: z.string().optional(), +}); + +export type OAuthErrorBody = z.infer; + +/** Fallback when an upstream response is not RFC-shaped (no `error` field). */ +export function serverError(): OAuthErrorBody { + return { + error: 'server_error', + error_description: + 'The authorization server returned an unexpected response.', + }; +} + +/** + * Convert an arbitrary upstream error body into the client-facing OAuth error + * shape. A body that isn't RFC-shaped degrades to a generic `server_error`. + */ +export function toOAuthError(body: unknown): OAuthErrorBody { + const parsed = oauthErrorSchema.safeParse(body); + + return parsed.success ? parsed.data : serverError(); +} diff --git a/src/oauthResponseSchemas.ts b/src/oauthResponseSchemas.ts new file mode 100644 index 0000000..0323f40 --- /dev/null +++ b/src/oauthResponseSchemas.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +// The OAuth device-flow response shapes the CLI validates off the wire. These +// mirror the Developer API's response contract; the duplication is deliberate — +// this package ships standalone and must not import from the service. Both sides +// encode the same RFC shapes, so they stay in lockstep by definition rather than +// by a shared import. + +// RFC 8628 §3.2 — the device-authorization response. +export const deviceAuthorizationResponseSchema = z.object({ + device_code: z.string().min(1), + user_code: z.string().min(1), + verification_uri: z.url(), + verification_uri_complete: z.url().optional(), + expires_in: z.number().int().positive(), + interval: z.number().int().positive().optional(), +}); +export type DeviceAuthorizationResponse = z.infer< + typeof deviceAuthorizationResponseSchema +>; + +// RFC 6749 §5.1 — the token response on a successful grant. +export const tokenResponseSchema = z.object({ + access_token: z.string().min(1), + token_type: z.string().min(1), + expires_in: z.number().int().positive(), + refresh_token: z.string().min(1).optional(), + scope: z.string().optional(), + id_token: z.string().min(1).optional(), +}); +export type TokenResponse = z.infer; diff --git a/src/output.test.ts b/src/output.test.ts new file mode 100644 index 0000000..2a01307 --- /dev/null +++ b/src/output.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest'; + +import type { CliOperation } from './generated/cli-manifest'; +import { formatSuccessOutput, shouldUseJsonOutput } from './output'; + +const listProjects: CliOperation = { + command: ['projects', 'list'], + method: 'GET', + operationId: 'listProjects', + path: '/v1/projects', + requiredScopes: ['read:projects'], + parameters: [], + body: [], +}; + +const listEvents: CliOperation = { + command: ['events', 'list'], + method: 'GET', + operationId: 'listEvents', + path: '/v1/projects/{project_id}/events', + requiredScopes: ['read:taxonomy'], + parameters: [], + body: [], +}; + +const getContext: CliOperation = { + command: ['context'], + method: 'GET', + operationId: 'getContext', + path: '/v1/context', + requiredScopes: ['read:projects'], + parameters: [], + body: [], +}; + +describe('output formatting', () => { + it('uses JSON when stdout is not a TTY unless --json is forced', () => { + expect(shouldUseJsonOutput({ isTTY: false })).toBe(true); + expect(shouldUseJsonOutput({ isTTY: true })).toBe(false); + expect(shouldUseJsonOutput({ isTTY: true, jsonFlag: true })).toBe(true); + }); + + it('formats list responses as a table', () => { + const output = formatSuccessOutput( + { + data: [ + { id: '1', name: 'Alpha' }, + { id: '2', name: 'Beta' }, + ], + pagination: { has_more: true, next_cursor: '2' }, + }, + listProjects, + ); + + expect(output).toContain('ID'); + expect(output).toContain('Alpha'); + expect(output).toContain('--cursor 2'); + }); + + it('truncates and normalizes long table cells', () => { + const longDescription = + 'First line with a lot of detail.\nSecond line with even more detail that should not make the table unreadably wide.'; + const output = formatSuccessOutput( + { + data: [ + { + id: '1', + name: 'A project with an intentionally long name that needs truncation', + description: longDescription, + }, + ], + }, + listProjects, + ); + + expect(output).toContain('…'); + expect(output).not.toContain('\nSecond line'); + expect(output).not.toContain(longDescription); + }); + + it('collapses columns that repeat the same value across rows', () => { + const output = formatSuccessOutput( + { + data: [ + { + id: 'delete-roles', + object: 'event', + event_type: 'delete-roles', + display_name: 'delete-roles', + description: null, + is_active: false, + }, + { + id: 'Cookie Preferences Updated', + object: 'event', + event_type: 'Cookie Preferences Updated', + display_name: 'Cookie Preferences Updated', + description: 'Fired when a visitor saves cookie settings.', + is_active: true, + }, + ], + }, + listEvents, + ); + + const header = output.split('\n')[0]; + expect(header).toContain('ID'); + expect(header).toContain('IS_ACTIVE'); + expect(header).toContain('DESCRIPTION'); + // event_type and display_name duplicate id on every row, so they collapse. + expect(header).not.toContain('EVENT_TYPE'); + expect(header).not.toContain('DISPLAY_NAME'); + }); + + it('emits no trailing whitespace on any table row', () => { + const output = formatSuccessOutput( + { + data: [ + { id: '1', name: 'Alpha' }, + { id: '2', name: 'Beta' }, + ], + }, + listProjects, + ); + + for (const line of output.split('\n')) { + expect(line).toBe(line.trimEnd()); + } + }); + + it('formats context as a short summary', () => { + const output = formatSuccessOutput( + { + data: { + principal: { + login_id: 'user@example.com', + auth_type: 'pat', + scopes: ['read:projects'], + }, + org: { id: '1', name: 'Acme' }, + }, + }, + getContext, + ); + + expect(output).toContain('user@example.com'); + expect(output).toContain('Acme'); + expect(output).toContain('read:projects'); + }); +}); diff --git a/src/output.ts b/src/output.ts new file mode 100644 index 0000000..957246f --- /dev/null +++ b/src/output.ts @@ -0,0 +1,227 @@ +import type { CliOperation } from './generated/cli-manifest'; +import { jsonRecordSchema } from './schemas'; + +type Row = Record; + +const DEFAULT_CELL_WIDTH = 40; +const COLUMN_WIDTHS: Record = { + description: 80, + display_name: 40, + event_type: 40, + name: 40, +}; + +function isListOperation(operation: CliOperation): boolean { + return operation.operationId.startsWith('list'); +} + +function asRecord(value: unknown): Record | undefined { + const result = jsonRecordSchema.safeParse(value); + return result.success ? result.data : undefined; +} + +function cellValue(value: unknown): string { + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + if (typeof value === 'object') { + return JSON.stringify(value); + } + + return String(value); +} + +function normalizeCell(value: string): string { + return value.replace(/\s+/g, ' ').trim(); +} + +function maxWidth(column: string): number { + return COLUMN_WIDTHS[column] ?? DEFAULT_CELL_WIDTH; +} + +function truncateCell(value: string, column: string): string { + const normalized = normalizeCell(value); + const width = maxWidth(column); + if (normalized.length <= width) { + return normalized; + } + + return `${normalized.slice(0, width - 1)}…`; +} + +function dropDuplicateColumns(rows: Row[], columns: string[]): string[] { + const kept: string[] = []; + for (const column of columns) { + const duplicatesKept = kept.some((keptColumn) => + rows.every((row) => (row[keptColumn] ?? '') === (row[column] ?? '')), + ); + if (!duplicatesKept) { + kept.push(column); + } + } + + return kept; +} + +function pickColumns(rows: Row[]): string[] { + const preferred = [ + 'id', + 'key', + 'name', + 'display_name', + 'event_type', + 'enabled', + 'archived', + 'is_active', + 'description', + ]; + + const available = new Set(); + for (const row of rows) { + for (const key of Object.keys(row)) { + available.add(key); + } + } + + // Taxonomy rows routinely repeat one value across id / event_type / + // display_name (and key / name); collapse those so neither humans nor agents + // pay to read the same string two or three times. + const columns = preferred.filter((column) => available.has(column)); + if (columns.length > 0) { + return dropDuplicateColumns(rows, columns).slice(0, 5); + } + + return dropDuplicateColumns(rows, Array.from(available)).slice(0, 4); +} + +function formatTable(rows: Row[], columns: string[]): string { + const displayRows = rows.map((row) => + Object.fromEntries( + columns.map((column) => [ + column, + truncateCell(row[column] ?? '', column), + ]), + ), + ); + const widths = columns.map((column) => + Math.max( + column.length, + ...displayRows.map((row) => (row[column] ?? '').length), + ), + ); + + const header = columns + .map((column, index) => column.toUpperCase().padEnd(widths[index] ?? 0)) + .join(' ') + .trimEnd(); + + const body = displayRows + .map((row) => + columns + .map((column, index) => (row[column] ?? '').padEnd(widths[index] ?? 0)) + .join(' ') + .trimEnd(), + ) + .join('\n'); + + return `${header}\n${body}`; +} + +function formatContextSummary(body: Record): string { + const data = asRecord(body.data); + const principal = data ? asRecord(data.principal) : undefined; + const org = data ? asRecord(data.org) : undefined; + const scopes = Array.isArray(principal?.scopes) + ? principal.scopes.map(String) + : []; + + const lines = ['Authenticated context:']; + if (principal?.login_id) { + lines.push(` User: ${principal.login_id}`); + } + if (org?.name || org?.id) { + const orgLabel = org.name ?? org.id; + const orgId = org.id ? ` (${org.id})` : ''; + lines.push(` Org: ${orgLabel}${orgId}`); + } + if (principal?.auth_type) { + lines.push(` Auth: ${principal.auth_type}`); + } + if (scopes.length > 0) { + lines.push(` Scopes: ${scopes.join(', ')}`); + } + + return lines.join('\n'); +} + +function formatListTable( + body: Record, + pagination?: Record, +): string { + const items = Array.isArray(body.data) ? body.data : []; + const rows = items + .map((item) => asRecord(item)) + .filter((item): item is Record => item !== undefined) + .map((item) => + Object.fromEntries( + Object.entries(item).map(([key, value]) => [key, cellValue(value)]), + ), + ); + + if (rows.length === 0) { + return 'No results.'; + } + + const columns = pickColumns(rows); + const lines = [formatTable(rows, columns)]; + + if (pagination?.has_more === true) { + const cursor = + typeof pagination.next_cursor === 'string' + ? pagination.next_cursor + : undefined; + lines.push(''); + lines.push( + cursor + ? `More results available. Use --cursor ${cursor}` + : 'More results available. Use --cursor to continue.', + ); + } + + return lines.join('\n'); +} + +export function shouldUseJsonOutput(options: { + jsonFlag?: boolean; + isTTY: boolean; +}): boolean { + if (options.jsonFlag === true) { + return true; + } + + return !options.isTTY; +} + +export function formatSuccessOutput( + body: unknown, + operation: CliOperation, +): string { + const record = asRecord(body); + if (!record) { + return JSON.stringify(body, null, 2); + } + + if (operation.command.length === 1 && operation.command[0] === 'context') { + return formatContextSummary(record); + } + + if (isListOperation(operation) && Array.isArray(record.data)) { + const pagination = asRecord(record.pagination); + return formatListTable(record, pagination); + } + + return JSON.stringify(body, null, 2); +} diff --git a/src/prompt.ts b/src/prompt.ts new file mode 100644 index 0000000..5e2f86d --- /dev/null +++ b/src/prompt.ts @@ -0,0 +1,14 @@ +import { confirm as confirmPrompt, input, password } from '@inquirer/prompts'; + +export async function confirm(message: string): Promise { + return confirmPrompt({ default: false, message }); +} + +export async function askQuestion(message: string): Promise { + return input({ message }); +} + +/** Prompts for a secret, masking the typed characters. */ +export async function askSecret(message: string): Promise { + return password({ mask: true, message }); +} diff --git a/src/request.test.ts b/src/request.test.ts new file mode 100644 index 0000000..6703c1b --- /dev/null +++ b/src/request.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest'; + +import { parseArgs } from './args'; +import { CLI_OPERATIONS, type CliOperation } from './generated/cli-manifest'; +import { + buildRequest, + operationSupportsDryRun, + parseResponseBody, +} from './request'; + +const AUTH = 'Bearer test'; + +function operation(command: string[]): CliOperation { + const found = CLI_OPERATIONS.find( + (candidate) => + candidate.command.length === command.length && + candidate.command.every((part, index) => part === command[index]), + ); + + if (!found) { + throw new Error(`Missing CLI operation ${command.join(' ')}.`); + } + + return found; +} + +function request(command: string[], argv: string[]) { + const parsed = parseArgs([...command, ...argv]); + return buildRequest(operation(command), parsed.flags, AUTH); +} + +describe('manifest invariants', () => { + it('does not generate duplicate aliases within a command', () => { + for (const cliOperation of CLI_OPERATIONS) { + const aliases = new Map(); + for (const item of [...cliOperation.parameters, ...cliOperation.body]) { + for (const alias of item.aliases) { + expect( + aliases.get(alias), + `${cliOperation.command.join(' ')} duplicates --${alias}`, + ).toBeUndefined(); + aliases.set(alias, item.name); + } + } + } + }); + + it('excludes Auth-tagged operations from the generated manifest', () => { + const authOps = CLI_OPERATIONS.filter((cliOperation) => + cliOperation.path.startsWith('/v1/auth/'), + ); + expect(authOps).toEqual([]); + }); +}); + +describe('buildRequest', () => { + it('sends bare dry-run flags as query parameters', () => { + const built = request( + ['events', 'delete'], + ['--project', '187520', '--event', 'signup', '--dry-run'], + ); + + expect(built.path).toBe('/v1/projects/187520/events/signup?dry_run=true'); + }); + + it('sends bare boolean body flags', () => { + const built = request( + ['event-properties', 'create'], + [ + '--project', + '187520', + '--event', + 'signup', + '--property', + 'plan', + '--data-type', + 'string', + '--is-hidden', + '--is-array', + ], + ); + + expect(built.body).toMatchObject({ + data_type: 'string', + is_array_type: true, + is_hidden: true, + }); + }); + + it('parses referenced object and array body schemas as JSON', () => { + const built = request( + ['flags', 'create'], + [ + '--project', + '187520', + '--key', + 'rollout-test', + '--name', + 'Rollout Test', + '--rollout-weights', + '{"on":1,"off":0}', + '--target-segments', + '[{"conditions":[],"percentage":100,"rollout_weights":{"on":1}}]', + ], + ); + + expect(built.body).toMatchObject({ + rollout_weights: { on: 1, off: 0 }, + target_segments: [ + { + conditions: [], + percentage: 100, + rollout_weights: { on: 1 }, + }, + ], + }); + }); + + it('rejects string-valued flags passed without a value', () => { + expect(() => + buildRequest( + operation(['events', 'create']), + { + project: '187520', + 'body-json': true, + }, + AUTH, + ), + ).toThrowError('Expected --body-json to have a value.'); + }); + + it('accepts a JSON object for --body-json and merges it', () => { + const built = request( + ['events', 'create'], + ['--project', '187520', '--body-json', '{"event_type":"signup"}'], + ); + + expect(built.body).toMatchObject({ event_type: 'signup' }); + }); + + it('rejects --body-json for operations without request bodies', () => { + expect(() => + request(['context'], ['--body-json', '{"unexpected":true}']), + ).toThrowError('`amp context` does not accept a request body.'); + }); + + it('rejects non-object --body-json values', () => { + expect(() => + request( + ['events', 'create'], + ['--project', '187520', '--body-json=[1,2]'], + ), + ).toThrowError('--body-json must be a JSON object.'); + + expect(() => + request( + ['events', 'create'], + ['--project', '187520', '--body-json=null'], + ), + ).toThrowError('--body-json must be a JSON object.'); + + expect(() => + request(['events', 'create'], ['--project', '187520', '--body-json=42']), + ).toThrowError('--body-json must be a JSON object.'); + + expect(() => + request(['events', 'create'], ['--project', '187520', '--body-json=']), + ).toThrowError('--body-json must be a JSON object.'); + }); + + it('rejects empty required path and body flags', () => { + expect(() => request(['events', 'list'], ['--project='])).toThrowError( + 'Missing --project .', + ); + + expect(() => + request( + ['events', 'create'], + ['--project', '187520', '--event-type', ''], + ), + ).toThrowError('Missing --event-type .'); + }); +}); + +describe('operationSupportsDryRun', () => { + it('detects the dry_run parameter', () => { + expect(operationSupportsDryRun(operation(['events', 'delete']))).toBe(true); + expect(operationSupportsDryRun(operation(['events', 'get']))).toBe(false); + }); +}); + +describe('parseResponseBody', () => { + it('parses JSON, keeps non-JSON text, and maps empty to null', () => { + expect(parseResponseBody('{"ok":true}')).toEqual({ ok: true }); + expect(parseResponseBody('bad gateway')).toBe( + 'bad gateway', + ); + expect(parseResponseBody('')).toBeNull(); + }); +}); diff --git a/src/request.ts b/src/request.ts new file mode 100644 index 0000000..b766849 --- /dev/null +++ b/src/request.ts @@ -0,0 +1,225 @@ +import { randomUUID } from 'node:crypto'; + +import { + type FlagValue, + flagValue, + hasFlag, + isMissingRequiredValue, + stringFlag, +} from './args'; +import type { CliBodyProperty, CliOperation } from './generated/cli-manifest'; +import { jsonRecordSchema } from './schemas'; + +export type QueryValue = boolean | number | string | null | undefined; + +export interface BuiltRequest { + body?: Record; + headers: Record; + path: string; +} + +export function operationSupportsDryRun(operation: CliOperation): boolean { + return operation.parameters.some((parameter) => parameter.name === 'dry_run'); +} + +export function parseResponseBody(responseBody: string): unknown { + if (!responseBody) { + return null; + } + + try { + return JSON.parse(responseBody) as unknown; + } catch { + return responseBody; + } +} + +function parseScalar( + value: FlagValue, + type: string, + nullable: boolean, +): QueryValue { + if (nullable && value === 'null') { + return null; + } + + if (type === 'boolean') { + if (typeof value === 'boolean') { + return value; + } + if (value === 'true') { + return true; + } + if (value === 'false') { + return false; + } + throw new Error(`Expected boolean value, got ${value}.`); + } + + if (typeof value === 'boolean') { + throw new Error(`Expected ${type} value, got ${value}.`); + } + + if (type === 'integer' || type === 'number') { + const parsed = Number(value); + if ( + !Number.isFinite(parsed) || + (type === 'integer' && !Number.isInteger(parsed)) + ) { + throw new Error(`Expected ${type} value, got ${value}.`); + } + return parsed; + } + + return value; +} + +function parseBodyValue( + property: CliBodyProperty, + value: FlagValue, +): QueryValue | QueryValue[] | Record { + if (property.nullable && value === 'null') { + return null; + } + + if (typeof value === 'boolean') { + return parseScalar(value, property.type, property.nullable); + } + + if (property.enum && !property.enum.includes(value)) { + throw new Error( + `Invalid --${property.aliases[0]} ${value}. Allowed: ${property.enum.join(', ')}.`, + ); + } + + if (property.type === 'array') { + if (value.startsWith('[')) { + return JSON.parse(value) as QueryValue[]; + } + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + } + + if (property.type === 'object') { + return JSON.parse(value) as Record; + } + + return parseScalar(value, property.type, property.nullable); +} + +function parseBodyJson( + flags: Record, +): Record { + const raw = stringFlag(flags, ['body-json']); + if (raw === undefined) { + return {}; + } + if (raw.trim() === '') { + throw new Error('--body-json must be a JSON object.'); + } + + const result = jsonRecordSchema.safeParse(JSON.parse(raw)); + if (!result.success) { + throw new Error('--body-json must be a JSON object.'); + } + + return result.data; +} + +function withQuery(path: string, query: Record): string { + const params = new URLSearchParams(); + + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null && value !== '') { + params.set(key, String(value)); + } + } + + const queryString = params.toString(); + + return queryString ? `${path}?${queryString}` : path; +} + +export function buildRequest( + operation: CliOperation, + flags: Record, + authorizationHeader: string, +): BuiltRequest { + const headers: Record = { + Accept: 'application/json', + Authorization: authorizationHeader, + }; + const query: Record = {}; + let path = operation.path; + + for (const parameter of operation.parameters) { + const raw = flagValue(flags, parameter.aliases); + if ( + parameter.required && + isMissingRequiredValue(raw) && + parameter.in !== 'header' + ) { + throw new Error(`Missing --${parameter.aliases[0]} <${parameter.name}>.`); + } + + if (parameter.in === 'path') { + if (typeof raw === 'boolean') { + throw new Error(`Expected --${parameter.aliases[0]} to have a value.`); + } + path = path.replace(`{${parameter.name}}`, encodeURIComponent(raw ?? '')); + } else if (parameter.in === 'query' && raw !== undefined) { + query[parameter.name] = parseScalar(raw, parameter.type, true); + } else if (parameter.in === 'header') { + if (parameter.name === 'Idempotency-Key') { + headers[parameter.name] = + typeof raw === 'string' + ? raw + : `api-cli-${operation.operationId}-${randomUUID()}`; + } else if (typeof raw === 'string') { + headers[parameter.name] = raw; + } else if (raw !== undefined) { + throw new Error(`Expected --${parameter.aliases[0]} to have a value.`); + } + } + } + + const body = parseBodyJson(flags); + if (operation.body.length === 0 && Object.keys(body).length > 0) { + throw new Error( + `\`amp ${operation.command.join(' ')}\` does not accept a request body.`, + ); + } + + for (const property of operation.body) { + const raw = flagValue(flags, property.aliases); + if (property.required && raw === '') { + throw new Error(`Missing --${property.aliases[0]} <${property.name}>.`); + } + if ( + property.required && + raw === undefined && + !hasFlag(flags, ['body-json']) + ) { + throw new Error(`Missing --${property.aliases[0]} <${property.name}>.`); + } + + if (raw !== undefined) { + body[property.name] = parseBodyValue(property, raw); + } + } + + if (operation.body.length > 0) { + headers['Content-Type'] = 'application/json'; + } + + return { + body: + operation.body.length > 0 || Object.keys(body).length > 0 + ? body + : undefined, + headers, + path: withQuery(path, query), + }; +} diff --git a/src/run.test.ts b/src/run.test.ts new file mode 100644 index 0000000..022a3d2 --- /dev/null +++ b/src/run.test.ts @@ -0,0 +1,227 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CLI_OPERATIONS, type CliOperation } from './generated/cli-manifest'; +import { deleteGateDecision, runOperation } from './run'; + +function operation(command: string[]): CliOperation { + const found = CLI_OPERATIONS.find( + (candidate) => + candidate.command.length === command.length && + candidate.command.every((part, index) => part === command[index]), + ); + + if (!found) { + throw new Error(`Missing CLI operation ${command.join(' ')}.`); + } + + return found; +} + +const deleteWithoutDryRun: CliOperation = { + command: ['widgets', 'delete'], + method: 'DELETE', + operationId: 'deleteWidget', + path: '/v1/widgets/{id}', + requiredScopes: [], + summary: 'Delete a widget', + successStatus: 204, + parameters: [ + { name: 'id', in: 'path', required: true, aliases: ['id'], type: 'string' }, + ], + body: [], +}; + +function jsonResponse(status: number, body: unknown) { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), + } as Response; +} + +describe('deleteGateDecision', () => { + const base = { + dryRunRequested: false, + dryRunSupported: false, + isDelete: true, + isTTY: false, + yes: false, + }; + + it('always proceeds for non-DELETE operations', () => { + expect(deleteGateDecision({ ...base, isDelete: false })).toBe('proceed'); + }); + + it('proceeds when --dry-run is supported by the operation', () => { + expect( + deleteGateDecision({ + ...base, + dryRunRequested: true, + dryRunSupported: true, + }), + ).toBe('proceed'); + }); + + it('proceeds when --yes is set', () => { + expect(deleteGateDecision({ ...base, yes: true })).toBe('proceed'); + }); + + it('confirms interactively when no bypass is given in a TTY', () => { + expect(deleteGateDecision({ ...base, isTTY: true })).toBe('confirm'); + }); + + it('blocks non-interactively when no bypass is given', () => { + expect(deleteGateDecision(base)).toBe('block'); + }); + + it('does not let an unsupported --dry-run bypass the gate', () => { + expect( + deleteGateDecision({ + ...base, + dryRunRequested: true, + dryRunSupported: false, + }), + ).toBe('block'); + }); +}); + +describe('runOperation', () => { + let fetchMock: ReturnType; + let logSpy: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + // Force non-interactive so the destructive gate is deterministic. + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: false, + }); + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + value: false, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + logSpy.mockRestore(); + }); + + it('prints compact JSON when piped (non-interactive), the agent path', async () => { + fetchMock.mockResolvedValue(jsonResponse(200, { data: { id: 'evt_1' } })); + + await runOperation(operation(['events', 'get']), { + token: 'amp_test', + project: '187520', + event: 'signup', + json: true, + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + // No indentation: stdout is not a TTY in this suite, so output is compact. + expect(logSpy).toHaveBeenCalledWith('{"data":{"id":"evt_1"}}'); + }); + + it('pretty-prints JSON when --json is used at a real terminal', async () => { + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: true, + }); + fetchMock.mockResolvedValue(jsonResponse(200, { data: { id: 'evt_1' } })); + + await runOperation(operation(['events', 'get']), { + token: 'amp_test', + project: '187520', + event: 'signup', + json: true, + }); + + expect(logSpy).toHaveBeenCalledWith( + JSON.stringify({ data: { id: 'evt_1' } }, null, 2), + ); + }); + + it('threads the resolved base_url and Authorization header into the request', async () => { + fetchMock.mockResolvedValue(jsonResponse(200, { data: { id: 'evt_1' } })); + + await runOperation(operation(['events', 'get']), { + token: 'amp_secret', + 'base-url': 'https://developer-api.staging.amplitude.com', + project: '187520', + event: 'signup', + }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toMatch( + /^https:\/\/developer-api\.staging\.amplitude\.com\//, + ); + expect(init.headers.Authorization).toBe('Bearer PAT=amp_secret'); + }); + + it('throws a formatted error on non-2xx responses', async () => { + fetchMock.mockResolvedValue( + jsonResponse(403, { detail: 'insufficient scope' }), + ); + + await expect( + runOperation(operation(['events', 'get']), { + token: 'amp_test', + project: '187520', + event: 'signup', + }), + ).rejects.toThrow(/403/); + }); + + it('blocks a DELETE without --yes in a non-interactive shell', async () => { + await expect( + runOperation(operation(['events', 'delete']), { + token: 'amp_test', + project: '187520', + event: 'signup', + }), + ).rejects.toThrow('Pass --yes to run a DELETE command'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('runs a DELETE when --yes is set', async () => { + fetchMock.mockResolvedValue(jsonResponse(204, '')); + + await runOperation(operation(['events', 'delete']), { + token: 'amp_test', + project: '187520', + event: 'signup', + yes: true, + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0][1]).toMatchObject({ method: 'DELETE' }); + }); + + it('passes --dry-run through to the server when supported', async () => { + fetchMock.mockResolvedValue(jsonResponse(200, { data: { dry_run: true } })); + + await runOperation(operation(['events', 'delete']), { + token: 'amp_test', + project: '187520', + event: 'signup', + 'dry-run': true, + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(String(fetchMock.mock.calls[0][0])).toContain('dry_run=true'); + }); + + it('refuses an unsupported --dry-run instead of silently deleting', async () => { + await expect( + runOperation(deleteWithoutDryRun, { + token: 'amp_test', + id: 'w_1', + 'dry-run': true, + }), + ).rejects.toThrow('does not support --dry-run'); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/run.ts b/src/run.ts new file mode 100644 index 0000000..ff2d31a --- /dev/null +++ b/src/run.ts @@ -0,0 +1,126 @@ +/* eslint-disable no-console */ +import { type FlagValue, isFlagEnabled } from './args'; +import { + authorizationHeaderForToken, + resolveAuthFromFlags, +} from './credential-resolver'; +import { formatApiError } from './errors'; +import type { CliOperation } from './generated/cli-manifest'; +import { formatSuccessOutput, shouldUseJsonOutput } from './output'; +import { confirm } from './prompt'; +import { + buildRequest, + operationSupportsDryRun, + parseResponseBody, +} from './request'; + +export type DeleteGateDecision = 'block' | 'confirm' | 'proceed'; + +/** + * Decides whether a destructive (DELETE) command may run. A `--dry-run` only + * bypasses confirmation when the operation actually supports it server-side; + * otherwise we confirm (interactive) or block (non-interactive) so `--dry-run` + * can never silently perform a real delete. + */ +export function deleteGateDecision(options: { + dryRunRequested: boolean; + dryRunSupported: boolean; + isDelete: boolean; + isTTY: boolean; + yes: boolean; +}): DeleteGateDecision { + if (!options.isDelete) { + return 'proceed'; + } + if (options.dryRunRequested && options.dryRunSupported) { + return 'proceed'; + } + if (options.yes) { + return 'proceed'; + } + return options.isTTY ? 'confirm' : 'block'; +} + +const SYNTHETIC_OK = { data: { ok: true } }; + +async function ensureDeleteAllowed( + operation: CliOperation, + flags: Record, +): Promise { + const dryRunRequested = isFlagEnabled(flags['dry-run']); + const dryRunSupported = operationSupportsDryRun(operation); + const decision = deleteGateDecision({ + dryRunRequested, + dryRunSupported, + isDelete: operation.method === 'DELETE', + isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY), + yes: isFlagEnabled(flags.yes), + }); + + if (decision === 'proceed') { + return; + } + + if (decision === 'block') { + if (dryRunRequested && !dryRunSupported) { + throw new Error( + `\`amp ${operation.command.join(' ')}\` does not support --dry-run. Pass --yes to confirm, or run it in an interactive terminal.`, + ); + } + throw new Error( + 'Pass --yes to run a DELETE command, or use --dry-run if the command supports it.', + ); + } + + const approved = await confirm( + `This runs DELETE \`amp ${operation.command.join(' ')}\`. Continue?`, + ); + if (!approved) { + throw new Error('Aborted.'); + } +} + +export async function runOperation( + operation: CliOperation, + flags: Record, +): Promise { + await ensureDeleteAllowed(operation, flags); + + const auth = resolveAuthFromFlags(flags); + const request = buildRequest( + operation, + flags, + authorizationHeaderForToken(auth.token), + ); + const response = await fetch(`${auth.baseUrl}${request.path}`, { + method: operation.method, + headers: request.headers, + body: request.body === undefined ? undefined : JSON.stringify(request.body), + }); + const parsed = parseResponseBody(await response.text()); + + if (!response.ok) { + throw new Error( + formatApiError(response.status, response.statusText, parsed), + ); + } + + const payload = parsed ?? SYNTHETIC_OK; + const isTTY = Boolean(process.stdout.isTTY); + const useJson = shouldUseJsonOutput({ + jsonFlag: isFlagEnabled(flags.json), + isTTY, + }); + + // When piped or non-interactive (the path agents and scripts take), emit + // compact JSON to avoid spending tokens on indentation. Pretty-print only + // when a human asked for JSON at a real terminal. + let output: string; + if (useJson) { + output = isTTY ? JSON.stringify(payload, null, 2) : JSON.stringify(payload); + } else { + output = formatSuccessOutput(payload, operation); + } + + console.log(output); +} diff --git a/src/schemas.ts b/src/schemas.ts new file mode 100644 index 0000000..0d447f0 --- /dev/null +++ b/src/schemas.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +/** + * A plain JSON object: rejects arrays, null, and primitives. Shared by the + * output formatter (guarding untrusted API payloads) and the CLI (validating + * user-supplied --body-json). + */ +export const jsonRecordSchema = z.record(z.string(), z.unknown()); + +// RFC 8628 device-code grant type. The URN, not the bare `device_code` — the +// Developer API's token endpoint expects the URN form. +export const DEVICE_CODE_GRANT_TYPE = + 'urn:ietf:params:oauth:grant-type:device_code'; diff --git a/src/scopes.test.ts b/src/scopes.test.ts new file mode 100644 index 0000000..e591247 --- /dev/null +++ b/src/scopes.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_SCOPES } from './scopes'; + +describe('DEFAULT_SCOPES', () => { + it('is the manifest scope union plus the legacy mcp scopes, sorted', () => { + expect(DEFAULT_SCOPES).toBe( + 'mcp:read mcp:write read:flags read:projects read:taxonomy write:flags write:taxonomy', + ); + }); + + it('includes the legacy mcp org scopes', () => { + const scopes = DEFAULT_SCOPES.split(' '); + expect(scopes).toContain('mcp:read'); + expect(scopes).toContain('mcp:write'); + }); +}); diff --git a/src/scopes.ts b/src/scopes.ts new file mode 100644 index 0000000..19b9a82 --- /dev/null +++ b/src/scopes.ts @@ -0,0 +1,22 @@ +import { CLI_OPERATIONS } from './generated/cli-manifest'; + +// Legacy org-level scopes. They map to the granular API read/write scopes +// server-side, but the device-flow client can grant them directly, so include +// them in the default request for full coverage. Added explicitly because no +// CLI command declares them as required. +const MCP_SCOPES = ['mcp:read', 'mcp:write']; + +/** + * The scope set a login requests when no `--scope` is given. The granular + * `read:`/`write:` scopes come from the generated manifest, so the default + * tracks the command surface automatically; the MCP scopes are appended. + * Sorted and de-duped for deterministic output. + */ +export const DEFAULT_SCOPES: string = [ + ...new Set([ + ...CLI_OPERATIONS.flatMap((operation) => operation.requiredScopes), + ...MCP_SCOPES, + ]), +] + .sort() + .join(' '); diff --git a/src/terminal.ts b/src/terminal.ts new file mode 100644 index 0000000..726170c --- /dev/null +++ b/src/terminal.ts @@ -0,0 +1,10 @@ +import chalk from 'chalk'; + +export const terminal = { + command: chalk.cyan, + dim: chalk.dim, + error: chalk.red, + heading: chalk.bold, + success: chalk.green, + warning: chalk.yellow, +}; diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..bb97caf --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "include": ["src"], + "exclude": ["**/*.test.ts", "**/*.spec.ts"], + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9749ffc --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["src"], + "compilerOptions": { + "strict": true, + "allowJs": false, + "esModuleInterop": true, + "incremental": true, + "lib": ["ES2021", "DOM"], + "module": "commonjs", + "target": "es2020", + "sourceMap": true, + "preserveConstEnums": true, + "skipLibCheck": true, + "rootDir": ".", + "outDir": "dist", + "types": ["node"], + "noUnusedLocals": false, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "useUnknownInCatchVariables": false + } +} diff --git a/tsconfig.tsc.json b/tsconfig.tsc.json new file mode 100644 index 0000000..fc8520e --- /dev/null +++ b/tsconfig.tsc.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.json" +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..ec30a2b --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + testTimeout: 10000, + }, +}); From 3e21cd2a8bd186701e8ef793d7f14b22fdf7d845 Mon Sep 17 00:00:00 2001 From: Eric Kim Date: Thu, 25 Jun 2026 21:19:27 -0400 Subject: [PATCH 2/7] ci: drop pnpm version from action-setup (use packageManager) pnpm/action-setup errors when both `version:` and package.json's `packageManager` field specify pnpm. Keep the pinned `packageManager` version as the single source of truth and remove the duplicate `version: 10`. Co-authored-by: Cursor --- .github/workflows/build.yml | 3 ++- .github/workflows/release-please.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ab36232..809f950 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,9 +17,10 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Install pnpm + # Version comes from the "packageManager" field in package.json — do not + # also set `version:` here or action-setup fails on the conflict. uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 with: - version: 10 run_install: false - name: Set up Node diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index a104675..2ce4ba9 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -46,9 +46,10 @@ jobs: fetch-depth: 0 - name: Install pnpm + # Version comes from the "packageManager" field in package.json — do not + # also set `version:` here or action-setup fails on the conflict. uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 with: - version: 10 run_install: false - name: Set up Node From 34fcf3c4422c613f5ed7c56f8676851e99ea9ac4 Mon Sep 17 00:00:00 2001 From: Eric Kim Date: Thu, 25 Jun 2026 21:20:46 -0400 Subject: [PATCH 3/7] chore: add Bugbot review guide Add .cursor/BUGBOT.md with repo-specific review priorities: public hygiene (no internal references), credential/token safety (0600, no token logging, precedence), destructive-action gating, generated-artifact protection, the human/agent output contract, and input validation / standalone portability. Co-authored-by: Cursor --- .cursor/BUGBOT.md | 48 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .cursor/BUGBOT.md diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md new file mode 100644 index 0000000..43a4615 --- /dev/null +++ b/.cursor/BUGBOT.md @@ -0,0 +1,48 @@ +# Bugbot review guide — @amplitude/developer-api + +This repo is the published `amp` CLI: a thin, generated client over the +Amplitude Developer API. It is a **public distribution**, so review for clarity +and safety, not just correctness. Prioritize the areas below. + +## Public hygiene + +- This source is public and read by external users. Flag anything that leaks + internal context: private repo names or paths, internal infrastructure + hostnames, internal issue-tracker IDs, internal-only tooling, or employee + names — in code, comments, docs, or commit-touched fixtures. +- README/docs/help must make sense to someone who only has the published + package (no upstream/monorepo context). + +## Credential & token safety + +- Credential files must be written with `0600` permissions. Flag any write that + loosens this. +- Never log, print, or echo tokens, PATs, or `Authorization` headers (including + in error messages or debug output). +- Don't weaken the documented credential precedence + (`--token` > `AMP_TOKEN` > `--profile` > `AMP_PROFILE` > active profile). + +## Destructive actions + +- DELETE / destructive commands must confirm (interactive `y/N`) or require + `--yes`. Flag any destructive path that skips the gate. +- `--dry-run` must never perform a write — flag a dry-run path that mutates. + +## Generated & bundled artifacts + +- `src/generated/**` and `openapi/bundled/**` are generated. Flag hand-edits; + changes belong upstream and are regenerated. + +## Human/agent output contract + +- Non-interactive / piped output must stay compact, lossless, and + machine-parseable. Flag ANSI colors, spinners, prompts, banners, or truncation + leaking onto the non-TTY path. +- `--json` output must remain valid, parseable JSON. + +## Input validation & portability + +- Validate untrusted input (API responses, saved credentials, user-supplied + `--body-json`) with schemas, not ad-hoc coercion. +- This package ships standalone: flag imports from any server/monorepo source or + new heavyweight dependencies. From 6a9c41c8eb2b32fda2235f29c72655f972194845 Mon Sep 17 00:00:00 2001 From: Eric Kim Date: Thu, 25 Jun 2026 23:46:59 -0400 Subject: [PATCH 4/7] fix: sync upstream MCP-452 and sanitization from master Mirror latest developer-api from javascript master: - MCP-452: faithful empty-body handling for 204 DELETE/archive (no synthetic {"data":{"ok":true}} on stdout) - Public hygiene: sanitized AGENTS.md, docs/cli.md, and src comments - Updated tests for run/output empty-success paths Co-authored-by: Cursor --- AGENTS.md | 12 ++++---- docs/cli.md | 18 +++++------ src/authToken.ts | 6 ++-- src/config.ts | 11 ++++--- src/env.ts | 4 +-- src/oauthError.ts | 10 +++--- src/oauthResponseSchemas.ts | 8 ++--- src/output.test.ts | 39 +++++++++++++++++++++++- src/output.ts | 17 +++++++++++ src/run.test.ts | 61 +++++++++++++++++++++++++++++++++++++ src/run.ts | 28 ++++++++++++----- src/schemas.ts | 5 +-- src/scopes.ts | 8 ++--- 13 files changed, 179 insertions(+), 48 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dfd6eae..818fbe2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,13 +1,13 @@ # developer-api CLI — Agent Instructions This package is the `amp` CLI: the human- and agent-facing surface over the -Amplitude Developer API. It is published as a standalone package, so treat -everything here as public and hold it to a high bar. +Amplitude Developer API. It is vended publicly as a standalone repo, so it is +held to a higher bar than internal code. -The Amplitude Developer API OpenAPI spec is the contract; this CLI is a thin, -generated client over it. The CLI manifest and bundled spec under -`src/generated/` and `openapi/bundled/` are generated artifacts — never edit -them by hand. +The OpenAPI spec in the parent `api-server` package is the contract; this CLI is +a thin, generated client over it. Before changing command behavior, auth, or the +generated manifest, read the parent `../AGENTS.md` and +`../docs/golden-standards.md`. ## Tenets diff --git a/docs/cli.md b/docs/cli.md index ef95c89..535f834 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -12,9 +12,8 @@ Creating a profile is force-explicit — name it and pick its environment: amp auth login --profile prod --env prod ``` -That runs the device flow against the chosen environment's Developer API, saves -the token to `~/.amplitude/amp/credentials.json` (0600), and activates the -profile. A +That runs the device flow against the chosen env's api-server, saves the token +to `~/.amplitude/amp/credentials.json` (0600), and activates the profile. A profile binds a credential to an environment (`base_url`), so a staging token can never be sent to prod. @@ -127,10 +126,11 @@ Manual checklist: 5. **Flags write** — create → get → update description → archive dry-run → archive 6. **Events** — list → create → update → (optional delete with `--yes`) -Known issue: `flags update --enabled false` fails for deployment-less flags. -Avoid that path in smoke tests until it is fixed. +Known upstream issue: `flags update --enabled false` fails for deployment-less +flags ([MCP-414](https://linear.app/amplitude/issue/MCP-414)). Avoid that path +in smoke tests until fixed. -## Local server +## Local api-server Point at a running local server: @@ -142,7 +142,7 @@ amp --base-url http://localhost:3036 context Do not edit by hand: -- `src/generated/cli-manifest.ts` — generated from the Developer API OpenAPI spec -- `openapi/bundled/*` — the bundled Developer API OpenAPI spec +- `src/generated/cli-manifest.ts` — from `api-server/scripts/generate-cli-manifest.ts` +- `openapi/bundled/*` — copied from `api-server/openapi/bundled/` -These artifacts are generated and kept in sync upstream. +Regenerate via `pnpm build:openapi` in the parent `api-server` package. diff --git a/src/authToken.ts b/src/authToken.ts index 705478f..115217d 100644 --- a/src/authToken.ts +++ b/src/authToken.ts @@ -13,7 +13,7 @@ import { import { DEVICE_CODE_GRANT_TYPE } from './schemas'; // Best readable message from an OAuth error body: prefer the human description, -// then the server's hint, then the bare error code. Reuses toOAuthError so the +// then fosite's hint, then the bare error code. Reuses toOAuthError so the // parse-and-fallback logic lives in one place. function oauthErrorMessage(body: unknown): string { const error = toOAuthError(body); @@ -147,7 +147,7 @@ export type AnonymousRequest = ( body?: unknown, ) => Promise; -// The OAuth endpoints are anonymous (the service injects the confidential +// The OAuth endpoints are anonymous (api-server injects the confidential // client) and signal flow state via non-2xx OAuth errors the caller must // inspect. So this sends no Authorization header and, unlike the CLI's // getJson/requestJson, never throws on a non-2xx — it returns the status and @@ -187,7 +187,7 @@ export function createAnonymousRequest(baseUrl: string): AnonymousRequest { export interface DeviceFlowOptions { flow: string | undefined; scope?: string; - // Token-optional, non-throwing HTTP call against the Developer API endpoints. + // Token-optional, non-throwing HTTP call against api-server's own endpoints. request: AnonymousRequest; sleep?: (seconds: number) => Promise; now?: () => number; diff --git a/src/config.ts b/src/config.ts index 74273b8..b5a5077 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,15 +7,18 @@ export const DEFAULT_API_BASE_URL = 'https://developer-api.amplitude.com'; // explicit env (or --base-url) when creating a profile — no implicit default — // so this map is the ergonomic primitive, not optional sugar. // -// DRIFT RISK: these env→host mappings mirror the Developer API's per-env host -// config. This package ships standalone and cannot import that config, so the -// duplication is intentional but must be kept in sync by hand — a service-side -// host rename won't trip a test here. +// DRIFT RISK: these env→host mappings mirror the server's per-env Hydra/host +// config (api-server `oauthConfig.ts`). This package is vended standalone, so it +// cannot import that config — the duplication is intentional but must be kept in +// sync by hand. (`staging` = stag2, matching the server; a server-side host +// rename won't trip a test here.) export const ENV_BASE_URLS: Record = { local: 'http://localhost:3036', + // TODO(BA-367): verify the dev developer-api hostname. dev: 'https://developer-api.dev.amplitude.com', staging: 'https://developer-api.stag2.amplitude.com', prod: 'https://developer-api.amplitude.com', + // TODO(BA-367): verify the prod-eu developer-api hostname. 'prod-eu': 'https://developer-api.eu.amplitude.com', }; diff --git a/src/env.ts b/src/env.ts index a9c4cdd..675aad7 100644 --- a/src/env.ts +++ b/src/env.ts @@ -4,8 +4,8 @@ * can apply their own default. * * Centralized here so the CLI request host (cli.ts) and the PAT settings app - * origin (auth-guidance.ts) cannot drift apart. AMP_API_BASE_URL is the only - * supported name. + * origin (auth-guidance.ts) cannot drift apart. The legacy AMP_API alias was + * dropped in M1 (BA-367) — AMP_API_BASE_URL is the only supported name. */ export function apiBaseUrlFromEnv(): string | undefined { return process.env.AMP_API_BASE_URL?.trim() || undefined; diff --git a/src/oauthError.ts b/src/oauthError.ts index 71e835e..9c20f4c 100644 --- a/src/oauthError.ts +++ b/src/oauthError.ts @@ -1,11 +1,11 @@ import { z } from 'zod'; /** - * RFC 6749 §5.2 OAuth error, plus the OAuth server's `error_hint` extension. - * The CLI parses upstream error bodies through this strict schema so debug-only - * fields (`error_debug`, `status_code`) are dropped and only the actionable - * message survives. Duplicated here so this package stays standalone for - * distribution. + * RFC 6749 §5.2 OAuth error, plus Ory fosite's `error_hint` extension. The CLI + * parses upstream error bodies through this strict schema so internal fields + * (fosite's `error_debug`, `status_code`) are dropped and only the actionable + * message survives. Mirrors api-server's oauthError; duplicated here so this + * package stays standalone for distribution. */ export const oauthErrorSchema = z.object({ error: z.string(), diff --git a/src/oauthResponseSchemas.ts b/src/oauthResponseSchemas.ts index 0323f40..3801369 100644 --- a/src/oauthResponseSchemas.ts +++ b/src/oauthResponseSchemas.ts @@ -1,10 +1,10 @@ import { z } from 'zod'; // The OAuth device-flow response shapes the CLI validates off the wire. These -// mirror the Developer API's response contract; the duplication is deliberate — -// this package ships standalone and must not import from the service. Both sides -// encode the same RFC shapes, so they stay in lockstep by definition rather than -// by a shared import. +// mirror the contract api-server projects from its own oauthResponseSchemas; +// the duplication is deliberate — developer-api ships as a standalone package +// and must not import from the server. Both sides encode the same RFC shapes, +// so they stay in lockstep by definition rather than by a shared import. // RFC 8628 §3.2 — the device-authorization response. export const deviceAuthorizationResponseSchema = z.object({ diff --git a/src/output.test.ts b/src/output.test.ts index 2a01307..25adcea 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from 'vitest'; import type { CliOperation } from './generated/cli-manifest'; -import { formatSuccessOutput, shouldUseJsonOutput } from './output'; +import { + formatNoContentSuccess, + formatSuccessOutput, + isNoContentSuccess, + shouldUseJsonOutput, +} from './output'; const listProjects: CliOperation = { command: ['projects', 'list'], @@ -33,7 +38,39 @@ const getContext: CliOperation = { body: [], }; +const deleteEvent: CliOperation = { + command: ['events', 'delete'], + method: 'DELETE', + operationId: 'deleteEvent', + path: '/v1/projects/{project_id}/events/{event_id}', + requiredScopes: ['write:taxonomy'], + successStatus: 204, + parameters: [], + body: [], +}; + +const archiveFlag: CliOperation = { + command: ['flags', 'archive'], + method: 'DELETE', + operationId: 'archiveFeatureFlag', + path: '/v1/projects/{project_id}/flags/{flag_id}', + requiredScopes: ['write:flags'], + successStatus: 204, + parameters: [], + body: [], +}; + describe('output formatting', () => { + it('detects empty 204 success responses', () => { + expect(isNoContentSuccess(204, null)).toBe(true); + expect(isNoContentSuccess(200, null)).toBe(false); + expect(isNoContentSuccess(204, { data: {} })).toBe(false); + }); + + it('formats no-content success for humans', () => { + expect(formatNoContentSuccess(deleteEvent)).toBe('Deleted.'); + expect(formatNoContentSuccess(archiveFlag)).toBe('Archived.'); + }); it('uses JSON when stdout is not a TTY unless --json is forced', () => { expect(shouldUseJsonOutput({ isTTY: false })).toBe(true); expect(shouldUseJsonOutput({ isTTY: true })).toBe(false); diff --git a/src/output.ts b/src/output.ts index 957246f..090f85c 100644 --- a/src/output.ts +++ b/src/output.ts @@ -205,6 +205,23 @@ export function shouldUseJsonOutput(options: { return !options.isTTY; } +export function isNoContentSuccess(status: number, parsed: unknown): boolean { + return parsed === null && status === 204; +} + +export function formatNoContentSuccess(operation: CliOperation): string { + const verb = operation.command.at(-1); + if (verb === 'archive') { + return 'Archived.'; + } + + return 'Deleted.'; +} + +export function formatJsonOutput(payload: unknown, isTTY: boolean): string { + return isTTY ? JSON.stringify(payload, null, 2) : JSON.stringify(payload); +} + export function formatSuccessOutput( body: unknown, operation: CliOperation, diff --git a/src/run.test.ts b/src/run.test.ts index 022a3d2..9dd6119 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -198,6 +198,67 @@ describe('runOperation', () => { expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock.mock.calls[0][1]).toMatchObject({ method: 'DELETE' }); + expect(logSpy).toHaveBeenCalledWith('null'); + }); + + it('pretty-prints null for a 204 when --json is used at a real terminal', async () => { + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: true, + }); + fetchMock.mockResolvedValue(jsonResponse(204, '')); + + await runOperation(operation(['events', 'delete']), { + token: 'amp_test', + project: '187520', + event: 'signup', + yes: true, + json: true, + }); + + expect(logSpy).toHaveBeenCalledWith(JSON.stringify(null, null, 2)); + }); + + it('prints a short message for a 204 DELETE at an interactive terminal', async () => { + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: true, + }); + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + value: true, + }); + fetchMock.mockResolvedValue(jsonResponse(204, '')); + + await runOperation(operation(['events', 'delete']), { + token: 'amp_test', + project: '187520', + event: 'signup', + yes: true, + }); + + expect(logSpy).toHaveBeenCalledWith('Deleted.'); + }); + + it('prints a short message for a 204 archive at an interactive terminal', async () => { + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value: true, + }); + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + value: true, + }); + fetchMock.mockResolvedValue(jsonResponse(204, '')); + + await runOperation(operation(['flags', 'archive']), { + token: 'amp_test', + project: '187520', + flag: 'my-flag', + yes: true, + }); + + expect(logSpy).toHaveBeenCalledWith('Archived.'); }); it('passes --dry-run through to the server when supported', async () => { diff --git a/src/run.ts b/src/run.ts index ff2d31a..b3976be 100644 --- a/src/run.ts +++ b/src/run.ts @@ -6,7 +6,13 @@ import { } from './credential-resolver'; import { formatApiError } from './errors'; import type { CliOperation } from './generated/cli-manifest'; -import { formatSuccessOutput, shouldUseJsonOutput } from './output'; +import { + formatJsonOutput, + formatNoContentSuccess, + formatSuccessOutput, + isNoContentSuccess, + shouldUseJsonOutput, +} from './output'; import { confirm } from './prompt'; import { buildRequest, @@ -105,22 +111,28 @@ export async function runOperation( ); } - const payload = parsed ?? SYNTHETIC_OK; const isTTY = Boolean(process.stdout.isTTY); const useJson = shouldUseJsonOutput({ jsonFlag: isFlagEnabled(flags.json), isTTY, }); + if (isNoContentSuccess(response.status, parsed)) { + const output = useJson + ? formatJsonOutput(null, isTTY) + : formatNoContentSuccess(operation); + console.log(output); + return; + } + + const payload = parsed ?? SYNTHETIC_OK; + // When piped or non-interactive (the path agents and scripts take), emit // compact JSON to avoid spending tokens on indentation. Pretty-print only // when a human asked for JSON at a real terminal. - let output: string; - if (useJson) { - output = isTTY ? JSON.stringify(payload, null, 2) : JSON.stringify(payload); - } else { - output = formatSuccessOutput(payload, operation); - } + const output = useJson + ? formatJsonOutput(payload, isTTY) + : formatSuccessOutput(payload, operation); console.log(output); } diff --git a/src/schemas.ts b/src/schemas.ts index 0d447f0..fd48b0b 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -7,7 +7,8 @@ import { z } from 'zod'; */ export const jsonRecordSchema = z.record(z.string(), z.unknown()); -// RFC 8628 device-code grant type. The URN, not the bare `device_code` — the -// Developer API's token endpoint expects the URN form. +// RFC 8628 device-code grant type. The URN, not the bare `device_code` — +// api-server's public token endpoint accepts the URN and forwards it to Hydra +// unchanged. export const DEVICE_CODE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'; diff --git a/src/scopes.ts b/src/scopes.ts index 19b9a82..2e282f2 100644 --- a/src/scopes.ts +++ b/src/scopes.ts @@ -1,9 +1,9 @@ import { CLI_OPERATIONS } from './generated/cli-manifest'; -// Legacy org-level scopes. They map to the granular API read/write scopes -// server-side, but the device-flow client can grant them directly, so include -// them in the default request for full coverage. Added explicitly because no -// CLI command declares them as required. +// Legacy org-level scopes (api-server principalResolver MCP_SCOPE). They map to +// the granular API read/write scopes server-side, but the device-flow client +// can grant them directly, so include them in the default request for full +// coverage. Added explicitly because no CLI command declares them as required. const MCP_SCOPES = ['mcp:read', 'mcp:write']; /** From c1aa4acef6f8b94903b35d4aa9f9eaffd9afd6ce Mon Sep 17 00:00:00 2001 From: Eric Kim Date: Fri, 26 Jun 2026 00:53:02 -0400 Subject: [PATCH 5/7] fix: sync upstream hygiene and MCP-453 from master Mirror latest developer-api from javascript master: - Sanitize AGENTS.md, docs/cli.md, and src comments for public mirror - MCP-453: block --dry-run --yes on unsupported DELETE commands - MCP-452 already included in prior sync (204 empty-body handling) Co-authored-by: Cursor --- AGENTS.md | 12 ++++++------ docs/cli.md | 18 +++++++++--------- src/authToken.ts | 6 +++--- src/config.ts | 11 ++++------- src/env.ts | 4 ++-- src/oauthError.ts | 10 +++++----- src/oauthResponseSchemas.ts | 8 ++++---- src/run.test.ts | 23 +++++++++++++++++++++++ src/run.ts | 5 ++++- src/schemas.ts | 5 ++--- src/scopes.ts | 8 ++++---- 11 files changed, 66 insertions(+), 44 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 818fbe2..dfd6eae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,13 +1,13 @@ # developer-api CLI — Agent Instructions This package is the `amp` CLI: the human- and agent-facing surface over the -Amplitude Developer API. It is vended publicly as a standalone repo, so it is -held to a higher bar than internal code. +Amplitude Developer API. It is published as a standalone package, so treat +everything here as public and hold it to a high bar. -The OpenAPI spec in the parent `api-server` package is the contract; this CLI is -a thin, generated client over it. Before changing command behavior, auth, or the -generated manifest, read the parent `../AGENTS.md` and -`../docs/golden-standards.md`. +The Amplitude Developer API OpenAPI spec is the contract; this CLI is a thin, +generated client over it. The CLI manifest and bundled spec under +`src/generated/` and `openapi/bundled/` are generated artifacts — never edit +them by hand. ## Tenets diff --git a/docs/cli.md b/docs/cli.md index 535f834..ef95c89 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -12,8 +12,9 @@ Creating a profile is force-explicit — name it and pick its environment: amp auth login --profile prod --env prod ``` -That runs the device flow against the chosen env's api-server, saves the token -to `~/.amplitude/amp/credentials.json` (0600), and activates the profile. A +That runs the device flow against the chosen environment's Developer API, saves +the token to `~/.amplitude/amp/credentials.json` (0600), and activates the +profile. A profile binds a credential to an environment (`base_url`), so a staging token can never be sent to prod. @@ -126,11 +127,10 @@ Manual checklist: 5. **Flags write** — create → get → update description → archive dry-run → archive 6. **Events** — list → create → update → (optional delete with `--yes`) -Known upstream issue: `flags update --enabled false` fails for deployment-less -flags ([MCP-414](https://linear.app/amplitude/issue/MCP-414)). Avoid that path -in smoke tests until fixed. +Known issue: `flags update --enabled false` fails for deployment-less flags. +Avoid that path in smoke tests until it is fixed. -## Local api-server +## Local server Point at a running local server: @@ -142,7 +142,7 @@ amp --base-url http://localhost:3036 context Do not edit by hand: -- `src/generated/cli-manifest.ts` — from `api-server/scripts/generate-cli-manifest.ts` -- `openapi/bundled/*` — copied from `api-server/openapi/bundled/` +- `src/generated/cli-manifest.ts` — generated from the Developer API OpenAPI spec +- `openapi/bundled/*` — the bundled Developer API OpenAPI spec -Regenerate via `pnpm build:openapi` in the parent `api-server` package. +These artifacts are generated and kept in sync upstream. diff --git a/src/authToken.ts b/src/authToken.ts index 115217d..705478f 100644 --- a/src/authToken.ts +++ b/src/authToken.ts @@ -13,7 +13,7 @@ import { import { DEVICE_CODE_GRANT_TYPE } from './schemas'; // Best readable message from an OAuth error body: prefer the human description, -// then fosite's hint, then the bare error code. Reuses toOAuthError so the +// then the server's hint, then the bare error code. Reuses toOAuthError so the // parse-and-fallback logic lives in one place. function oauthErrorMessage(body: unknown): string { const error = toOAuthError(body); @@ -147,7 +147,7 @@ export type AnonymousRequest = ( body?: unknown, ) => Promise; -// The OAuth endpoints are anonymous (api-server injects the confidential +// The OAuth endpoints are anonymous (the service injects the confidential // client) and signal flow state via non-2xx OAuth errors the caller must // inspect. So this sends no Authorization header and, unlike the CLI's // getJson/requestJson, never throws on a non-2xx — it returns the status and @@ -187,7 +187,7 @@ export function createAnonymousRequest(baseUrl: string): AnonymousRequest { export interface DeviceFlowOptions { flow: string | undefined; scope?: string; - // Token-optional, non-throwing HTTP call against api-server's own endpoints. + // Token-optional, non-throwing HTTP call against the Developer API endpoints. request: AnonymousRequest; sleep?: (seconds: number) => Promise; now?: () => number; diff --git a/src/config.ts b/src/config.ts index b5a5077..74273b8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,18 +7,15 @@ export const DEFAULT_API_BASE_URL = 'https://developer-api.amplitude.com'; // explicit env (or --base-url) when creating a profile — no implicit default — // so this map is the ergonomic primitive, not optional sugar. // -// DRIFT RISK: these env→host mappings mirror the server's per-env Hydra/host -// config (api-server `oauthConfig.ts`). This package is vended standalone, so it -// cannot import that config — the duplication is intentional but must be kept in -// sync by hand. (`staging` = stag2, matching the server; a server-side host -// rename won't trip a test here.) +// DRIFT RISK: these env→host mappings mirror the Developer API's per-env host +// config. This package ships standalone and cannot import that config, so the +// duplication is intentional but must be kept in sync by hand — a service-side +// host rename won't trip a test here. export const ENV_BASE_URLS: Record = { local: 'http://localhost:3036', - // TODO(BA-367): verify the dev developer-api hostname. dev: 'https://developer-api.dev.amplitude.com', staging: 'https://developer-api.stag2.amplitude.com', prod: 'https://developer-api.amplitude.com', - // TODO(BA-367): verify the prod-eu developer-api hostname. 'prod-eu': 'https://developer-api.eu.amplitude.com', }; diff --git a/src/env.ts b/src/env.ts index 675aad7..a9c4cdd 100644 --- a/src/env.ts +++ b/src/env.ts @@ -4,8 +4,8 @@ * can apply their own default. * * Centralized here so the CLI request host (cli.ts) and the PAT settings app - * origin (auth-guidance.ts) cannot drift apart. The legacy AMP_API alias was - * dropped in M1 (BA-367) — AMP_API_BASE_URL is the only supported name. + * origin (auth-guidance.ts) cannot drift apart. AMP_API_BASE_URL is the only + * supported name. */ export function apiBaseUrlFromEnv(): string | undefined { return process.env.AMP_API_BASE_URL?.trim() || undefined; diff --git a/src/oauthError.ts b/src/oauthError.ts index 9c20f4c..71e835e 100644 --- a/src/oauthError.ts +++ b/src/oauthError.ts @@ -1,11 +1,11 @@ import { z } from 'zod'; /** - * RFC 6749 §5.2 OAuth error, plus Ory fosite's `error_hint` extension. The CLI - * parses upstream error bodies through this strict schema so internal fields - * (fosite's `error_debug`, `status_code`) are dropped and only the actionable - * message survives. Mirrors api-server's oauthError; duplicated here so this - * package stays standalone for distribution. + * RFC 6749 §5.2 OAuth error, plus the OAuth server's `error_hint` extension. + * The CLI parses upstream error bodies through this strict schema so debug-only + * fields (`error_debug`, `status_code`) are dropped and only the actionable + * message survives. Duplicated here so this package stays standalone for + * distribution. */ export const oauthErrorSchema = z.object({ error: z.string(), diff --git a/src/oauthResponseSchemas.ts b/src/oauthResponseSchemas.ts index 3801369..0323f40 100644 --- a/src/oauthResponseSchemas.ts +++ b/src/oauthResponseSchemas.ts @@ -1,10 +1,10 @@ import { z } from 'zod'; // The OAuth device-flow response shapes the CLI validates off the wire. These -// mirror the contract api-server projects from its own oauthResponseSchemas; -// the duplication is deliberate — developer-api ships as a standalone package -// and must not import from the server. Both sides encode the same RFC shapes, -// so they stay in lockstep by definition rather than by a shared import. +// mirror the Developer API's response contract; the duplication is deliberate — +// this package ships standalone and must not import from the service. Both sides +// encode the same RFC shapes, so they stay in lockstep by definition rather than +// by a shared import. // RFC 8628 §3.2 — the device-authorization response. export const deviceAuthorizationResponseSchema = z.object({ diff --git a/src/run.test.ts b/src/run.test.ts index 9dd6119..c4e539e 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -84,6 +84,17 @@ describe('deleteGateDecision', () => { }), ).toBe('block'); }); + + it('does not let --yes bypass the gate when --dry-run is unsupported', () => { + expect( + deleteGateDecision({ + ...base, + dryRunRequested: true, + dryRunSupported: false, + yes: true, + }), + ).toBe('block'); + }); }); describe('runOperation', () => { @@ -285,4 +296,16 @@ describe('runOperation', () => { ).rejects.toThrow('does not support --dry-run'); expect(fetchMock).not.toHaveBeenCalled(); }); + + it('refuses --dry-run --yes on unsupported DELETE instead of silently deleting', async () => { + await expect( + runOperation(deleteWithoutDryRun, { + token: 'amp_test', + id: 'w_1', + 'dry-run': true, + yes: true, + }), + ).rejects.toThrow('does not support --dry-run'); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/run.ts b/src/run.ts index b3976be..a7e560d 100644 --- a/src/run.ts +++ b/src/run.ts @@ -41,6 +41,9 @@ export function deleteGateDecision(options: { if (options.dryRunRequested && options.dryRunSupported) { return 'proceed'; } + if (options.dryRunRequested && !options.dryRunSupported) { + return 'block'; + } if (options.yes) { return 'proceed'; } @@ -70,7 +73,7 @@ async function ensureDeleteAllowed( if (decision === 'block') { if (dryRunRequested && !dryRunSupported) { throw new Error( - `\`amp ${operation.command.join(' ')}\` does not support --dry-run. Pass --yes to confirm, or run it in an interactive terminal.`, + `\`amp ${operation.command.join(' ')}\` does not support --dry-run. Drop --dry-run and pass --yes to confirm a real delete, or run it in an interactive terminal.`, ); } throw new Error( diff --git a/src/schemas.ts b/src/schemas.ts index fd48b0b..0d447f0 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -7,8 +7,7 @@ import { z } from 'zod'; */ export const jsonRecordSchema = z.record(z.string(), z.unknown()); -// RFC 8628 device-code grant type. The URN, not the bare `device_code` — -// api-server's public token endpoint accepts the URN and forwards it to Hydra -// unchanged. +// RFC 8628 device-code grant type. The URN, not the bare `device_code` — the +// Developer API's token endpoint expects the URN form. export const DEVICE_CODE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'; diff --git a/src/scopes.ts b/src/scopes.ts index 2e282f2..19b9a82 100644 --- a/src/scopes.ts +++ b/src/scopes.ts @@ -1,9 +1,9 @@ import { CLI_OPERATIONS } from './generated/cli-manifest'; -// Legacy org-level scopes (api-server principalResolver MCP_SCOPE). They map to -// the granular API read/write scopes server-side, but the device-flow client -// can grant them directly, so include them in the default request for full -// coverage. Added explicitly because no CLI command declares them as required. +// Legacy org-level scopes. They map to the granular API read/write scopes +// server-side, but the device-flow client can grant them directly, so include +// them in the default request for full coverage. Added explicitly because no +// CLI command declares them as required. const MCP_SCOPES = ['mcp:read', 'mcp:write']; /** From 40b08e3e81273ac04b127d09936d870f90b8dc27 Mon Sep 17 00:00:00 2001 From: Eric Kim Date: Fri, 26 Jun 2026 09:06:05 -0400 Subject: [PATCH 6/7] fix: sync BA-389 auth CLI changes and align publish Node to 22 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror latest developer-api from javascript master: - BA-389: improved device-flow login prompt and open-in-browser support (authToken.ts + tests) External-only: - release-please publish job: Node 24 → 22 to match build.yml and package.json engines (^22.22.0) Co-authored-by: Cursor --- .github/workflows/release-please.yml | 3 +- src/authToken.test.ts | 149 ++++++++++++++++++++ src/authToken.ts | 199 ++++++++++++++++++++++++--- 3 files changed, 331 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 2ce4ba9..ba3e953 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -55,7 +55,8 @@ jobs: - name: Set up Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 24 + # Match build.yml and package.json engines (^22.22.0). + node-version: 22 registry-url: https://registry.npmjs.org - name: Install dependencies diff --git a/src/authToken.test.ts b/src/authToken.test.ts index 69350f1..3a33bdd 100644 --- a/src/authToken.test.ts +++ b/src/authToken.test.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto'; +import { PassThrough } from 'node:stream'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -7,7 +8,9 @@ import { formatVerificationPrompt, generatePkcePair, pollForToken, + requestDeviceToken, runAuthTokenCommand, + startWaitingIndicator, } from './authToken'; describe('generatePkcePair', () => { @@ -39,6 +42,152 @@ describe('formatVerificationPrompt', () => { }); }); +describe('requestDeviceToken open affordance', () => { + const deviceAuthorization = { + status: 200, + body: { + device_code: 'DEVICE-CODE-SECRET', + user_code: 'WDJB-MJHT', + verification_uri: 'https://amp.example/device', + verification_uri_complete: + 'https://amp.example/device?user_code=WDJB-MJHT', + expires_in: 600, + interval: 1, + }, + }; + + it('opens the verification URL when Enter is pressed at a TTY', async () => { + const stdin = Object.assign(new PassThrough(), { isTTY: true }); + // The affordance needs stderr to be a TTY too (that's where the "Press + // Enter" hint shows); restore it so the stub can't leak to other tests. + const originalStderrIsTTY = process.stderr.isTTY; + process.stderr.isTTY = true; + const opened: string[] = []; + + let tokenAttempts = 0; + const request = (method: string, path: string) => { + if (path.endsWith('/v1/auth/device-authorization')) { + return Promise.resolve(deviceAuthorization); + } + tokenAttempts += 1; + if (tokenAttempts < 2) { + return Promise.resolve({ + status: 400, + body: { error: 'authorization_pending' }, + }); + } + return Promise.resolve({ + status: 200, + body: { + access_token: 'ACCESS-TOKEN', + token_type: 'bearer', + expires_in: 3600, + }, + }); + }; + + // Press Enter while the first poll is sleeping, then resolve a tick later so + // the stdin data listener (and the open it triggers) has run. + const sleep = () => { + stdin.write('\n'); + return new Promise((resolve) => setImmediate(resolve)); + }; + + try { + await requestDeviceToken({ + flow: 'device', + request, + sleep, + now: () => 0, + stderr: () => {}, + stdin, + openUrl: (url) => { + opened.push(url); + return Promise.resolve(); + }, + }); + } finally { + process.stderr.isTTY = originalStderrIsTTY; + } + + expect(opened).toEqual(['https://amp.example/device?user_code=WDJB-MJHT']); + }); + + it('skips the affordance (no hint, no open) when stdin is not a TTY', async () => { + const stdin = Object.assign(new PassThrough(), { isTTY: false }); + const opened: string[] = []; + const err: string[] = []; + + const request = (method: string, path: string) => + path.endsWith('/v1/auth/device-authorization') + ? Promise.resolve(deviceAuthorization) + : Promise.resolve({ + status: 200, + body: { + access_token: 'ACCESS-TOKEN', + token_type: 'bearer', + expires_in: 3600, + }, + }); + + await requestDeviceToken({ + flow: 'device', + request, + sleep: () => Promise.resolve(), + now: () => 0, + stderr: (line) => err.push(line), + stdin, + openUrl: (url) => { + opened.push(url); + return Promise.resolve(); + }, + }); + + expect(opened).toEqual([]); + expect(err.join('\n')).not.toContain('Press Enter'); + }); +}); + +describe('startWaitingIndicator', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('animates frames in place at a TTY and clears the line on stop', () => { + vi.useFakeTimers(); + const writes: string[] = []; + const stop = startWaitingIndicator('Waiting…', () => {}, { + isInteractive: true, + write: (chunk) => writes.push(chunk), + }); + + expect(writes[0]).toBe('\r⠋ Waiting…'); + + vi.advanceTimersByTime(80); + expect(writes[1]).toBe('\r⠙ Waiting…'); + + stop(); + expect(writes.at(-1)).toBe('\r\u001b[K'); + + // No further frames render once stopped. + vi.advanceTimersByTime(240); + expect(writes.filter((chunk) => chunk.includes('Waiting…')).length).toBe(2); + }); + + it('prints a single static line when stderr is not a TTY', () => { + const lines: string[] = []; + const writes: string[] = []; + const stop = startWaitingIndicator('Waiting…', (line) => lines.push(line), { + isInteractive: false, + write: (chunk) => writes.push(chunk), + }); + stop(); + + expect(lines).toEqual(['Waiting…']); + expect(writes).toEqual([]); + }); +}); + describe('runAuthTokenCommand', () => { const unusedRequest = () => Promise.resolve({ status: 200, body: {} }); diff --git a/src/authToken.ts b/src/authToken.ts index 705478f..bed4279 100644 --- a/src/authToken.ts +++ b/src/authToken.ts @@ -1,6 +1,8 @@ import { createHash, randomBytes } from 'node:crypto'; +import type { Readable } from 'node:stream'; import { setTimeout as delay } from 'node:timers/promises'; +import open from 'open'; import { z } from 'zod'; import { toOAuthError } from './oauthError'; @@ -55,23 +57,160 @@ export function generatePkcePair(): PkcePair { return { codeVerifier, codeChallenge }; } -// The human-facing prompt (goes to stderr). Shows the verification URL with the -// user_code already embedded — the /device route reads it from the param, so -// the user never types it; deliberately omits the secret `device_code`. +// Prefer the code-embedded URL (the /device route reads user_code from the +// param, so the user never types it) and fall back to the bare verification_uri. +function verificationUrl(device: DeviceAuthorizationResponse): string { + return device.verification_uri_complete ?? device.verification_uri; +} + +// The human-facing prompt (goes to stderr). Surfaces the user_code as the thing +// to verify — the browser confirmation page shows the same code, and matching +// the two is what proves this CLI (not an attacker) initiated the request. We +// deliberately omit the secret `device_code`. The "Press Enter…" hint and the +// trailing "Waiting…" line are emitted by the caller, since the hint only +// applies at a TTY. export function formatVerificationPrompt( device: DeviceAuthorizationResponse, ): string { - const url = device.verification_uri_complete ?? device.verification_uri; - return [ - 'To authorize, open this URL in a browser and sign in:', + 'To authorize, confirm this code in your browser:', '', - url, + ` ${device.user_code}`, '', - 'Waiting for approval…', + 'At the following url:', + '', + ` ${verificationUrl(device)}`, ].join('\n'); } +type InteractiveStdin = Readable & { + isTTY?: boolean; + setRawMode?: (mode: boolean) => void; +}; + +// Opens the verification URL when the user presses Enter, without blocking the +// poll loop (the listener runs on the event loop; the poll keeps awaiting its +// sleeps). Returns a teardown that restores the terminal and lets the process +// exit (a resumed stdin keeps Node alive). `open` is fire-and-forget; a spawn +// failure (sync throw or rejected promise) degrades to a hint, not a dead login. +// +// Raw mode is what makes this usable: in cooked mode the TTY echoes every +// keystroke and turns Enter into a visible newline. Raw mode suppresses both — +// stray typing is swallowed and only Enter acts — but it also stops Ctrl+C from +// reaching the default SIGINT handler, so we restore the terminal and re-raise +// SIGINT ourselves to keep abort working. +function listenForOpen( + url: string, + openUrl: (url: string) => Promise, + stdin: InteractiveStdin, + emitStderr: (line: string) => void, +): () => void { + const useRawMode = + stdin.isTTY === true && typeof stdin.setRawMode === 'function'; + + function restore(): void { + stdin.off('data', onData); + if (useRawMode) { + stdin.setRawMode?.(false); + } + stdin.pause(); + } + + function onData(chunk: Buffer | string): void { + const input = chunk.toString(); + if (input.includes('\u0003')) { + restore(); + process.kill(process.pid, 'SIGINT'); + return; + } + if (input.includes('\r') || input.includes('\n')) { + Promise.resolve() + .then(() => openUrl(url)) + .catch(() => + emitStderr('Could not open a browser. Open the URL above manually.'), + ); + } + } + + if (useRawMode) { + stdin.setRawMode?.(true); + } + stdin.on('data', onData); + stdin.resume(); + + return restore; +} + +// Wires the "Press Enter to open" affordance and returns its teardown. Needs +// both stdin and stderr to be a TTY: stdin to read the keypress, stderr (where +// every prompt goes) so the hint is actually visible. Returns undefined when +// either isn't interactive — piped stdin (agent/CI) has no keypress to listen +// for, and a redirected stderr would otherwise enable raw mode (the terminal +// stops echoing) with no on-screen prompt explaining why. +function startOpenAffordance( + device: DeviceAuthorizationResponse, + options: DeviceFlowOptions, + emitStderr: (line: string) => void, +): (() => void) | undefined { + const stdin = options.stdin ?? process.stdin; + if (!stdin.isTTY || process.stderr.isTTY !== true) { + return undefined; + } + emitStderr('\nPress Enter to open it in your browser.'); + return listenForOpen( + verificationUrl(device), + options.openUrl ?? open, + stdin, + emitStderr, + ); +} + +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; +const SPINNER_INTERVAL_MS = 80; + +interface WaitingIndicatorOptions { + // Defaults to whether stderr is a real terminal. Piped output gets a single + // static line instead of an animation (no \r tricks, no ANSI escapes). + isInteractive?: boolean; + // Raw write seam (no trailing newline), for the in-place animation. Defaults + // to process.stderr; injected in tests. + write?: (chunk: string) => void; +} + +// A braille spinner for the "Waiting…" line. At a TTY it animates in place via +// carriage return; otherwise it prints the label once. Returns a stop() that +// halts the timer and clears the spinner line so the next output starts clean. +// The interval is unref'd so it can never, by itself, keep the process alive. +export function startWaitingIndicator( + label: string, + emitStderr: (line: string) => void, + options: WaitingIndicatorOptions = {}, +): () => void { + const isInteractive = options.isInteractive ?? process.stderr.isTTY === true; + if (!isInteractive) { + emitStderr(label); + return () => {}; + } + + const write = options.write ?? ((chunk) => void process.stderr.write(chunk)); + let frame = 0; + const render = (): void => { + write(`\r${SPINNER_FRAMES[frame]} ${label}`); + frame = (frame + 1) % SPINNER_FRAMES.length; + }; + + render(); + const timer = setInterval(render, SPINNER_INTERVAL_MS); + timer.unref?.(); + + return () => { + clearInterval(timer); + // Carriage return + clear-to-end-of-line wipes the spinner before the next + // line (a success message, or the SIGINT-killed prompt) is written. + write('\r\u001b[K'); + }; +} + interface ExchangeResult { status: number; body: unknown; @@ -193,6 +332,11 @@ export interface DeviceFlowOptions { now?: () => number; // The human prompt (verification URL) goes to stderr. stderr?: (line: string) => void; + // Interactive input for the "Press Enter to open" affordance. Defaults to the + // real process.stdin; the affordance only engages when it's a TTY. + stdin?: InteractiveStdin; + // Test seam for the browser-open side effect; defaults to the `open` package. + openUrl?: (url: string) => Promise; } export interface RunAuthTokenOptions extends DeviceFlowOptions { @@ -247,17 +391,34 @@ export async function requestDeviceToken( emitStderr(formatVerificationPrompt(deviceAuthorization)); - return pollForToken({ - exchange: () => - request('POST', '/v1/auth/token', { - grant_type: DEVICE_CODE_GRANT_TYPE, - device_code: deviceAuthorization.device_code, - code_verifier: codeVerifier, - }), - sleep, - intervalSeconds: deviceAuthorization.interval ?? 5, - deadline: { now, expiresInSeconds: deviceAuthorization.expires_in }, - }); + const stopListening = startOpenAffordance( + deviceAuthorization, + options, + emitStderr, + ); + + emitStderr(''); + const stopWaiting = startWaitingIndicator( + 'Waiting for confirmation…', + emitStderr, + ); + + try { + return await pollForToken({ + exchange: () => + request('POST', '/v1/auth/token', { + grant_type: DEVICE_CODE_GRANT_TYPE, + device_code: deviceAuthorization.device_code, + code_verifier: codeVerifier, + }), + sleep, + intervalSeconds: deviceAuthorization.interval ?? 5, + deadline: { now, expiresInSeconds: deviceAuthorization.expires_in }, + }); + } finally { + stopWaiting(); + stopListening?.(); + } } export async function runAuthTokenCommand( From 23df68d956830c07e03a547891de5654f3c44805 Mon Sep 17 00:00:00 2001 From: Eric Kim Date: Fri, 26 Jun 2026 09:12:10 -0400 Subject: [PATCH 7/7] fix: sync MCP-454 auth output and logout --all gates from master Mirror latest developer-api from javascript master: - Gate auth status ANSI styling on TTY (plain output when piped) - Require confirmation or --yes for logout --all - terminal.ts helpers for TTY-aware formatting Co-authored-by: Cursor --- src/auth-commands.ts | 56 +++++++++++++++--- src/auth-profiles.test.ts | 119 +++++++++++++++++++++++++++++++++----- src/cli.ts | 2 +- src/terminal.ts | 16 +++++ 4 files changed, 169 insertions(+), 24 deletions(-) diff --git a/src/auth-commands.ts b/src/auth-commands.ts index 9f36c6f..225141b 100644 --- a/src/auth-commands.ts +++ b/src/auth-commands.ts @@ -32,7 +32,23 @@ import { import type { TokenResponse } from './oauthResponseSchemas'; import { askSecret, confirm } from './prompt'; import { DEFAULT_SCOPES } from './scopes'; -import { terminal } from './terminal'; +import { terminal, terminalForStdout } from './terminal'; + +export type LogoutAllGateDecision = 'block' | 'confirm' | 'proceed'; + +/** + * Decides whether `amp logout --all` may wipe every stored profile. Mirrors the + * DELETE gate: interactive confirm at a TTY, `--yes` in scripts, block otherwise. + */ +export function logoutAllGateDecision(options: { + isTTY: boolean; + yes: boolean; +}): LogoutAllGateDecision { + if (options.yes) { + return 'proceed'; + } + return options.isTTY ? 'confirm' : 'block'; +} /** Builds an OAuth credential record from a freshly minted device-flow token. */ export function oauthCredentialFromToken( @@ -301,6 +317,8 @@ interface ProfileCommandDeps { path?: string; now?: () => number; stdout?: (line: string) => void; + confirm?: (message: string) => Promise; + isTTY?: boolean; } /** Reverses a base_url back to its friendly `--env` name when one is known. */ @@ -416,11 +434,12 @@ export function runAuthUse( * survivor (the active identity only changes on an explicit command), so * logging out the default leaves no default set. */ -export function runLogout( +export async function runLogout( flags: Record, deps: ProfileCommandDeps = {}, -): void { +): Promise { const emitStdout = deps.stdout ?? ((line) => console.log(line)); + const confirmLogout = deps.confirm ?? confirm; const store = loadStore(deps.path); if (isFlagEnabled(flags.all)) { @@ -432,6 +451,25 @@ export function runLogout( emitStdout('No profiles to remove.'); return; } + + const decision = logoutAllGateDecision({ + isTTY: deps.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY), + yes: isFlagEnabled(flags.yes), + }); + if (decision === 'block') { + throw new Error( + 'Pass --yes to remove every stored profile with `amp logout --all`.', + ); + } + if (decision === 'confirm') { + const approved = await confirmLogout( + `Remove all ${count} profile${count === 1 ? '' : 's'}? Stored credentials cannot be recovered.`, + ); + if (!approved) { + throw new Error('Aborted.'); + } + } + saveStore(emptyStore(), deps.path); emitStdout( terminal.success( @@ -487,6 +525,7 @@ interface AuthStatusDeps { now?: () => number; env?: NodeJS.ProcessEnv; stdout?: (line: string) => void; + isTTY?: boolean; } /** @@ -502,10 +541,11 @@ export function runAuthStatus( deps: AuthStatusDeps = {}, ): void { const emitStdout = deps.stdout ?? ((line) => console.log(line)); + const styled = terminalForStdout(deps.isTTY); const now = deps.now?.() ?? Date.now(); const store = deps.store ?? loadStore(); - emitStdout(`${terminal.heading('Auth status')}\n`); + emitStdout(`${styled.heading('Auth status')}\n`); const emitProfileRows = (name: string, profile: Profile): void => { const marker = store.default === name ? ' (default)' : ''; @@ -526,7 +566,7 @@ export function runAuthStatus( emitProfileRows(selected.name, selected.profile); } emitStdout( - terminal.warning(error instanceof Error ? error.message : String(error)), + styled.warning(error instanceof Error ? error.message : String(error)), ); process.exitCode = 1; return; @@ -534,18 +574,18 @@ export function runAuthStatus( if (auth.source.startsWith('AMP_TOKEN')) { emitStdout( - terminal.warning('AMP_TOKEN is set and overrides any stored profile.'), + styled.warning('AMP_TOKEN is set and overrides any stored profile.'), ); } - emitStdout(`Source: ${terminal.dim(auth.source)}`); + emitStdout(`Source: ${styled.dim(auth.source)}`); const profile = auth.profile ? getProfile(store, auth.profile) : undefined; if (auth.profile && profile) { emitProfileRows(auth.profile, profile); } - emitStdout(`Base URL: ${terminal.dim(auth.baseUrl)}`); + emitStdout(`Base URL: ${styled.dim(auth.baseUrl)}`); emitStdout(`Token: ${maskToken(auth.token)}`); } diff --git a/src/auth-profiles.test.ts b/src/auth-profiles.test.ts index 6471a1c..14cf553 100644 --- a/src/auth-profiles.test.ts +++ b/src/auth-profiles.test.ts @@ -8,6 +8,7 @@ import { envLabel, expiryLabel, formatProfileList, + logoutAllGateDecision, maskToken, runAuthStatus, runAuthToken, @@ -174,6 +175,20 @@ describe('runAuthUse', () => { }); }); +describe('logoutAllGateDecision', () => { + it('proceeds when --yes is set', () => { + expect(logoutAllGateDecision({ isTTY: false, yes: true })).toBe('proceed'); + }); + + it('confirms interactively when no bypass is given in a TTY', () => { + expect(logoutAllGateDecision({ isTTY: true, yes: false })).toBe('confirm'); + }); + + it('blocks in a non-interactive shell without --yes', () => { + expect(logoutAllGateDecision({ isTTY: false, yes: false })).toBe('block'); + }); +}); + describe('runLogout', () => { const dirs: string[] = []; afterEach(() => { @@ -208,10 +223,13 @@ describe('runLogout', () => { return path; } - it('removes a non-default profile and leaves the default untouched', () => { + it('removes a non-default profile and leaves the default untouched', async () => { const path = seeded(); const out: string[] = []; - runLogout({ profile: 'staging' }, { path, stdout: (l) => out.push(l) }); + await runLogout( + { profile: 'staging' }, + { path, stdout: (l) => out.push(l) }, + ); const after = loadStore(path); expect(after.profiles.staging).toBeUndefined(); @@ -219,10 +237,10 @@ describe('runLogout', () => { expect(out.join('\n')).toMatch(/Logged out of "staging"\./); }); - it('clears the default (never auto-promotes) when logging out the default', () => { + it('clears the default (never auto-promotes) when logging out the default', async () => { const path = seeded(); const out: string[] = []; - runLogout({}, { path, stdout: (l) => out.push(l) }); + await runLogout({}, { path, stdout: (l) => out.push(l) }); const after = loadStore(path); expect(after.profiles.amplitude).toBeUndefined(); @@ -231,25 +249,30 @@ describe('runLogout', () => { expect(out.join('\n')).toMatch(/No default set/); }); - it('errors with known names on an unknown target', () => { + it('errors with known names on an unknown target', async () => { const path = seeded(); - expect(() => runLogout({ profile: 'nope' }, { path })).toThrow( + await expect(runLogout({ profile: 'nope' }, { path })).rejects.toThrow( /Known: amplitude, staging/, ); }); - it('errors when there is nothing to log out of', () => { + it('errors when there is nothing to log out of', async () => { const dir = mkdtempSync(join(tmpdir(), 'amp-logout-empty-')); dirs.push(dir); const path = join(dir, 'credentials.json'); saveStore(emptyStore(), path); - expect(() => runLogout({}, { path })).toThrow(/No profile to log out of/); + await expect(runLogout({}, { path })).rejects.toThrow( + /No profile to log out of/, + ); }); - it('--all wipes every profile and clears the default', () => { + it('--all --yes wipes every profile and clears the default', async () => { const path = seeded(); const out: string[] = []; - runLogout({ all: true }, { path, stdout: (l) => out.push(l) }); + await runLogout( + { all: true, yes: true }, + { path, stdout: (l) => out.push(l), isTTY: false }, + ); const after = loadStore(path); expect(Object.keys(after.profiles)).toEqual([]); @@ -257,20 +280,67 @@ describe('runLogout', () => { expect(out.join('\n')).toMatch(/Removed all 2 profiles/); }); - it('--all on an empty store is a no-op message, not an error', () => { + it('--all on an empty store is a no-op message, not an error', async () => { const dir = mkdtempSync(join(tmpdir(), 'amp-logout-all-empty-')); dirs.push(dir); const path = join(dir, 'credentials.json'); saveStore(emptyStore(), path); const out: string[] = []; - runLogout({ all: true }, { path, stdout: (l) => out.push(l) }); + await runLogout({ all: true }, { path, stdout: (l) => out.push(l) }); expect(out.join('\n')).toMatch(/No profiles to remove/); }); - it('rejects --all combined with --profile', () => { - expect(() => + it('blocks --all without --yes in a non-interactive shell', async () => { + const path = seeded(); + await expect( + runLogout({ all: true }, { path, isTTY: false }), + ).rejects.toThrow(/Pass --yes to remove every stored profile/); + expect(Object.keys(loadStore(path).profiles)).toEqual([ + 'amplitude', + 'staging', + ]); + }); + + it('aborts --all when confirmation is declined', async () => { + const path = seeded(); + await expect( + runLogout( + { all: true }, + { + path, + isTTY: true, + confirm: () => Promise.resolve(false), + }, + ), + ).rejects.toThrow(/Aborted/); + expect(Object.keys(loadStore(path).profiles)).toEqual([ + 'amplitude', + 'staging', + ]); + }); + + it('wipes every profile when confirmation is approved', async () => { + const path = seeded(); + const out: string[] = []; + await runLogout( + { all: true }, + { + path, + isTTY: true, + confirm: () => Promise.resolve(true), + stdout: (l) => out.push(l), + }, + ); + + const after = loadStore(path); + expect(Object.keys(after.profiles)).toEqual([]); + expect(out.join('\n')).toMatch(/Removed all 2 profiles/); + }); + + it('rejects --all combined with --profile', async () => { + await expect( runLogout({ all: true, profile: 'staging' }, { path: seeded() }), - ).toThrow(/not both/); + ).rejects.toThrow(/not both/); }); }); @@ -323,6 +393,25 @@ describe('runAuthStatus', () => { expect(process.exitCode).not.toBe(1); }); + it('emits plain text without ANSI when stdout is not a TTY', () => { + const out: string[] = []; + runAuthStatus( + {}, + { + store: statusStore(), + now: () => NOW, + env: {}, + isTTY: false, + stdout: (l) => out.push(l), + }, + ); + const text = out.join('\n'); + + expect(text).toMatch(/Auth status/); + expect(text).toMatch(/Source: {3}default profile amplitude/); + expect(text).not.toContain(`${String.fromCharCode(0x1b)}[`); + }); + it('exits non-zero with guidance when nothing resolves', () => { const out: string[] = []; runAuthStatus( diff --git a/src/cli.ts b/src/cli.ts index fb82817..76dfc1e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -91,7 +91,7 @@ export async function main(): Promise { } if (command[0] === 'logout' && command.length === 1) { - runLogout(flags); + await runLogout(flags); return; } diff --git a/src/terminal.ts b/src/terminal.ts index 726170c..b6421aa 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -8,3 +8,19 @@ export const terminal = { success: chalk.green, warning: chalk.yellow, }; + +const plain = (value: string): string => value; + +/** Terminal styling when stdout is not a TTY (piped / scripted output). */ +export const plainTerminal = { + command: plain, + dim: plain, + error: plain, + heading: plain, + success: plain, + warning: plain, +}; + +export function terminalForStdout(isTTY = Boolean(process.stdout.isTTY)) { + return isTTY ? terminal : plainTerminal; +}