diff --git a/README.md b/README.md index 82be42a..3456dac 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,8 @@ npx @amplitude/developer-cli help ## Authentication Run `amp auth login` to authenticate via the OAuth device flow. Omit -`--profile` and the CLI targets `default` — the implicit profile used when you -don't name one. Picking a region stays explicit: +`--profile` and the CLI targets the active profile, or `default` when none is +active. Picking a region stays explicit: ```bash amp auth login --region us # device flow → "default" profile @@ -65,6 +65,51 @@ amp logout --all # remove every profile A bare `amp auth login` re-authenticates the active profile in place. Profiles are saved to `~/.amplitude/amp/credentials.json` (0600). +### Signup + +Create an account with explicit inputs and consent: + +```bash +amp auth signup --non-interactive \ + --email alex@example.com \ + --full-name "Alex Morgan" \ + --region us \ + --accept-tos +``` + +`--non-interactive` is required. Signup returns outstanding requirements without +prompting, opening a browser, or polling. `--json` controls presentation; piped +output is also compact JSON. + +`--accept-tos` agrees to the [Terms of Service](https://amplitude.com/terms) and +acknowledges the [Privacy Policy](https://amplitude.com/privacy). Every accepted +request sends both document references for server validation. Missing consent +returns the documents as required information before a signup request. Outdated +references return the server's error on stderr; update the client and retry. + +| Status | Meaning | Exit | +| ------------------- | -------------------------------------------------------------------------------------- | ---- | +| `authorized` | Credentials saved; profile active. Accepted document URLs returned; tokens stay local. | `0` | +| `requires_auth` | Browser authorization required. Challenge saved; use the returned polling command. | `0` | +| `needs_information` | Required fields and their complete schema returned, with supported flag mappings. | `2` | + +Inspect `status` before issuing authenticated commands: exit `0` can mean +authorization is still required. The continuation includes a readable command +and an argument array: + +```bash +amp auth login poll --profile default --json +``` + +See `amp auth login poll --help` for polling options. + +Profiles follow login's rules: select `--profile`, otherwise the active profile, +otherwise `default`. Signup requires an explicit `--region`. Successful signup at the same +endpoint updates its identity. Changing its endpoint requires `--force`. +Existing credentials and activation +remain until authentication succeeds and the replacement is saved, including +when signup returns a pending challenge. + Prefer a Personal Access Token? `amp auth pat --with-token --region ` reads a PAT from stdin (or a masked prompt at a terminal) and saves it as a profile — `--profile` optional (defaults to `default`), same force-explicit create rule, diff --git a/bin/amp.cjs b/bin/amp.cjs new file mode 100755 index 0000000..cf08fa3 --- /dev/null +++ b/bin/amp.cjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node + +// Keep the executable available before the package is built. +let entrypoint; +try { + entrypoint = require.resolve('../dist/cli.js'); +} catch (error) { + if (error.code !== 'MODULE_NOT_FOUND') throw error; + console.error('amp has not been built. Run pnpm build.'); + process.exit(1); +} + +require(entrypoint) + .main() + .catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); diff --git a/openapi/bundled/openapi.bundled.json b/openapi/bundled/openapi.bundled.json index a3e976c..d5783a5 100644 --- a/openapi/bundled/openapi.bundled.json +++ b/openapi/bundled/openapi.bundled.json @@ -31,6 +31,10 @@ "name": "Agent Analytics", "description": "Saved findings about agent cost, efficiency, and quality." }, + { + "name": "MCP Analytics", + "description": "Read the active MCP taxonomy, its topics, and its episodes." + }, { "name": "Projects", "description": "Project discovery and project-scoped navigation." @@ -49,7 +53,7 @@ }, { "name": "Analytics", - "description": "Saved chart discovery and query operations." + "description": "Saved chart discovery, query, and get-metric read operations." }, { "name": "Heatmaps", @@ -208,6 +212,436 @@ } } }, + "/v1/projects/{project_id}/mcp-analytics/servers": { + "get": { + "tags": ["MCP Analytics"], + "operationId": "listMcpAnalyticsServers", + "summary": "List MCP Analytics servers", + "description": "Returns the MCP servers configured for the project. A project with no MCP\nservers returns 200 and an empty `data` array.\n", + "x-required-scopes": ["analytics:read"], + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "responses": { + "200": { + "description": "MCP servers for the project. The list is complete, so `has_more` is false.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "pagination"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/McpAnalyticsServer" + } + }, + "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" + }, + "429": { + "$ref": "#/components/responses/RateLimitProblem" + }, + "502": { + "$ref": "#/components/responses/Problem" + } + } + } + }, + "/v1/projects/{project_id}/mcp-analytics/servers/{server_id}/taxonomy": { + "get": { + "tags": ["MCP Analytics"], + "operationId": "getMcpAnalyticsTaxonomy", + "summary": "Get the active MCP taxonomy", + "description": "Returns the server's active topic tree. The resource id is the server id.\nA visible server with no pinned taxonomy returns 200, null run fields, and\n`nodes: []`. An unknown server returns 404.\n", + "x-required-scopes": ["analytics:read"], + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "name": "server_id", + "in": "path", + "required": true, + "description": "Server id.", + "schema": { + "type": "string", + "pattern": "^[0-9]+$" + } + }, + { + "name": "parent_id", + "in": "query", + "required": false, + "description": "Node selector. Omit it for every node in the active tree. `root`\nreturns root nodes. Any other value is a stable topic id and returns\nthat topic's direct children. An unknown parent returns 404 when a\ntaxonomy is pinned.\n", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + } + ], + "responses": { + "200": { + "description": "Active taxonomy for the server.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/McpAnalyticsTaxonomy" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "429": { + "$ref": "#/components/responses/RateLimitProblem" + }, + "502": { + "$ref": "#/components/responses/Problem" + } + } + } + }, + "/v1/projects/{project_id}/mcp-analytics/servers/{server_id}/topics/{topic_id}": { + "get": { + "tags": ["MCP Analytics"], + "operationId": "getMcpAnalyticsTopic", + "summary": "Get an MCP taxonomy topic", + "description": "Returns one topic and up to eight example episodes. Examples are not a\npage. Use the episode list to page through episodes.\n", + "x-required-scopes": ["analytics:read"], + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "name": "server_id", + "in": "path", + "required": true, + "description": "Server id.", + "schema": { + "type": "string", + "pattern": "^[0-9]+$" + } + }, + { + "name": "topic_id", + "in": "path", + "required": true, + "description": "Stable topic id. `root` is not a topic id.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + } + ], + "responses": { + "200": { + "description": "Topic and its example episodes.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/McpAnalyticsTopicDetail" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "429": { + "$ref": "#/components/responses/RateLimitProblem" + }, + "502": { + "$ref": "#/components/responses/Problem" + } + } + } + }, + "/v1/projects/{project_id}/mcp-analytics/servers/{server_id}/topics/{topic_id}/episodes": { + "get": { + "tags": ["MCP Analytics"], + "operationId": "listMcpAnalyticsEpisodes", + "summary": "List episodes for an MCP topic", + "description": "Returns a page of episode summaries for one topic. The default page size\nis 25 and the maximum is 100. Keep the same filters\nwhen following `next_cursor`.\n", + "x-required-scopes": ["analytics:read"], + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "name": "server_id", + "in": "path", + "required": true, + "description": "Server id.", + "schema": { + "type": "string", + "pattern": "^[0-9]+$" + } + }, + { + "name": "topic_id", + "in": "path", + "required": true, + "description": "Stable topic id.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + { + "name": "started_after", + "in": "query", + "required": false, + "description": "Include episodes that started at or after this RFC 3339 timestamp.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "started_before", + "in": "query", + "required": false, + "description": "Include episodes that started before this RFC 3339 timestamp.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "lane", + "in": "query", + "required": false, + "description": "Exact stored lane.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + { + "name": "client_name", + "in": "query", + "required": false, + "description": "Exact stored client name.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + { + "name": "auth_type", + "in": "query", + "required": false, + "description": "Exact stored auth type.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + { + "name": "has_error", + "in": "query", + "required": false, + "description": "Whether the episode recorded an error.", + "schema": { + "type": "boolean" + } + }, + { + "name": "is_thrash", + "in": "query", + "required": false, + "description": "Whether the episode was classified as thrash.", + "schema": { + "type": "boolean" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from `pagination.next_cursor` on the previous response.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Number of episodes to return. Defaults to 25, maximum 100.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25 + } + } + ], + "responses": { + "200": { + "description": "Episode page. Keep the same filters when requesting the next cursor.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "pagination"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/McpAnalyticsEpisodeSummary" + } + }, + "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" + }, + "429": { + "$ref": "#/components/responses/RateLimitProblem" + }, + "502": { + "$ref": "#/components/responses/Problem" + } + } + } + }, + "/v1/projects/{project_id}/mcp-analytics/episodes/{episode_id}": { + "get": { + "tags": ["MCP Analytics"], + "operationId": "getMcpAnalyticsEpisode", + "summary": "Get an MCP episode", + "description": "Returns one episode, including its project, server, auth type, and topic\nbreadcrumb. A missing or cross-project episode returns 404.\n", + "x-required-scopes": ["analytics:read"], + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "name": "episode_id", + "in": "path", + "required": true, + "description": "Episode id returned by the topic or episode list.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + } + ], + "responses": { + "200": { + "description": "Episode detail.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/McpAnalyticsEpisode" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "429": { + "$ref": "#/components/responses/RateLimitProblem" + }, + "502": { + "$ref": "#/components/responses/Problem" + } + } + } + }, "/v1/auth/device-authorization": { "post": { "tags": ["Auth"], @@ -1782,7 +2216,7 @@ "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", + "description": "Creates a user property and adds it to the project's tracking plan.\nSame idempotency semantics as creating an event property.\n", "x-required-scopes": ["taxonomy:write"], "parameters": [ { @@ -1932,7 +2366,7 @@ "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", + "description": "Updates metadata on a user property. Returns `422 not_in_schema` if the\nproperty is ingested-only. Same patch semantics as updating an event property\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 in this API version.\n", "x-required-scopes": ["taxonomy:write"], "parameters": [ { @@ -2178,34 +2612,184 @@ } } }, - "/v1/projects/{project_id}/charts/{chart_id}/query": { + "/v1/projects/{project_id}/charts/{chart_id}/query": { + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/ChartId" + } + ], + "post": { + "tags": ["Analytics"], + "operationId": "queryChart", + "summary": "Query chart", + "description": "Computes and returns normalized results for a saved chart. v1 supports\n`event_segmentation`, `sessions`, `funnels`, and `retention` chart types;\nother chart types return `422` with `error_code: unsupported_chart_type`.\nResults reflect the authenticated caller's project access and chart\npermissions.\n\nThis POST computes a result and does not mutate state. It is therefore a\nread operation and does not require an `Idempotency-Key`.\n\nOmitting `time_range` uses the chart's saved range; if the chart has no\nsaved range, the server defaults to the last 30 days.\n\nQuery is synchronous with bounded defaults. Expensive queries may return\n`504` when exceeding the server timeout; async query jobs are planned for\na follow-up slice. Retryable responses may include retry advice where\ntheir endpoint-specific error contract provides it.\n", + "x-required-scopes": ["analytics:read"], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChartQueryRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Query completed successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/AnalyticsResult" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "422": { + "description": "Chart type is not supported for public query in this version. The\nrequest was understood but cannot be processed. Returned with\n`error_code: unsupported_chart_type`; `detail` names the chart type\nand the currently supported set.\n", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimitProblem" + }, + "500": { + "$ref": "#/components/responses/Problem" + }, + "502": { + "$ref": "#/components/responses/Problem" + }, + "504": { + "description": "Query exceeded the synchronous timeout.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/projects/{project_id}/metrics": { + "get": { + "tags": ["Analytics"], + "operationId": "listMetrics", + "summary": "List metrics", + "description": "Returns saved metrics in the project. Results are paginated with cursor-based\npagination. Full metric definitions are included in list responses.\n\nSortable fields are `updated_at` and `name`; prefix with `-` for descending.\nDefaults to `-updated_at` (most recently updated first).\n", + "x-required-scopes": ["analytics:read"], + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/Cursor" + }, + { + "$ref": "#/components/parameters/Limit" + }, + { + "$ref": "#/components/parameters/Query" + }, + { + "$ref": "#/components/parameters/MetricTypeFilter" + }, + { + "$ref": "#/components/parameters/Sort" + } + ], + "responses": { + "200": { + "description": "Successful response.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "pagination"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Metric" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/MetricForbiddenProblem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "429": { + "$ref": "#/components/responses/RateLimitProblem" + }, + "500": { + "$ref": "#/components/responses/Problem" + }, + "503": { + "$ref": "#/components/responses/AvailabilityUnavailableProblem" + } + } + } + }, + "/v1/projects/{project_id}/metrics/{metric_id}": { "parameters": [ { "$ref": "#/components/parameters/ProjectId" }, { - "$ref": "#/components/parameters/ChartId" + "$ref": "#/components/parameters/MetricId" } ], - "post": { + "get": { "tags": ["Analytics"], - "operationId": "queryChart", - "summary": "Query chart", - "description": "Computes and returns normalized results for a saved chart. v1 supports\n`event_segmentation`, `sessions`, `funnels`, and `retention` chart types;\nother chart types return `422` with `error_code: unsupported_chart_type`.\nResults reflect the authenticated caller's project access and chart\npermissions.\n\nThis POST computes a result and does not mutate state. It is therefore a\nread operation and does not require an `Idempotency-Key`.\n\nOmitting `time_range` uses the chart's saved range; if the chart has no\nsaved range, the server defaults to the last 30 days.\n\nQuery is synchronous with bounded defaults. Expensive queries may return\n`504` when exceeding the server timeout; async query jobs are planned for\na follow-up slice. Retryable responses may include retry advice where\ntheir endpoint-specific error contract provides it.\n", + "operationId": "getMetric", + "summary": "Get metric", + "description": "Returns the full saved metric definition including type-specific params,\nversion, and metadata.\n", "x-required-scopes": ["analytics:read"], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChartQueryRequest" - } - } - } - }, "responses": { "200": { - "description": "Query completed successfully.", + "description": "Successful response.", "content": { "application/json": { "schema": { @@ -2213,7 +2797,7 @@ "required": ["data"], "properties": { "data": { - "$ref": "#/components/schemas/AnalyticsResult" + "$ref": "#/components/schemas/Metric" } } } @@ -2227,39 +2811,19 @@ "$ref": "#/components/responses/Problem" }, "403": { - "$ref": "#/components/responses/Problem" + "$ref": "#/components/responses/MetricForbiddenProblem" }, "404": { "$ref": "#/components/responses/Problem" }, - "422": { - "description": "Chart type is not supported for public query in this version. The\nrequest was understood but cannot be processed. Returned with\n`error_code: unsupported_chart_type`; `detail` names the chart type\nand the currently supported set.\n", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, "429": { "$ref": "#/components/responses/RateLimitProblem" }, "500": { "$ref": "#/components/responses/Problem" }, - "502": { - "$ref": "#/components/responses/Problem" - }, - "504": { - "description": "Query exceeded the synchronous timeout.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } + "503": { + "$ref": "#/components/responses/AvailabilityUnavailableProblem" } } } @@ -3204,6 +3768,24 @@ "operationId": "testDestination", "summary": "Test an event streaming destination connection", "x-required-scopes": ["destinations:write"], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "test_event_payload": { + "type": "string", + "minLength": 1, + "description": "JSON event to send to the destination. Omit the field to use\nthe partner's example event. An empty string is rejected.\n" + } + }, + "additionalProperties": false + } + } + } + }, "responses": { "200": { "description": "Test result.", @@ -3391,7 +3973,7 @@ "name": "event_id", "in": "path", "required": true, - "description": "Public event identifier. For the current TMS-backed API, this value is the event type.", + "description": "Public event identifier. This value is the event type name (the string used when sending the event to Amplitude).", "schema": { "type": "string", "minLength": 1 @@ -3446,6 +4028,16 @@ "$ref": "#/components/schemas/ChartType" } }, + "MetricId": { + "name": "metric_id", + "in": "path", + "required": true, + "description": "Saved metric identifier.", + "schema": { + "type": "string", + "minLength": 1 + } + }, "IncludeDefinition": { "name": "include_definition", "in": "query", @@ -3515,6 +4107,15 @@ "minimum": 0 } }, + "MetricTypeFilter": { + "name": "metric_type", + "in": "query", + "required": false, + "description": "Filter list results to a specific metric type.", + "schema": { + "$ref": "#/components/schemas/MetricType" + } + }, "KindFilter": { "name": "kind", "in": "query", @@ -3564,6 +4165,58 @@ } } }, + "MetricForbiddenProblem": { + "description": "The caller cannot use this operation. `operation_not_enabled` means the\noperation is not enabled for the caller's organization;\n`insufficient_scope` means the token lacks `analytics:read`.\n", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + }, + "examples": { + "operationNotEnabled": { + "summary": "Endpoint unavailable", + "value": { + "type": "https://developer-api.amplitude.com/problems/operation-not-enabled", + "title": "Endpoint unavailable", + "status": 403, + "detail": "This endpoint is unavailable for your organization.", + "error_code": "operation_not_enabled", + "retryable": false + } + }, + "insufficientScope": { + "summary": "Missing required scope", + "value": { + "type": "https://developer-api.amplitude.com/problems/forbidden", + "title": "Insufficient scope", + "status": 403, + "detail": "Token missing required scopes.", + "error_code": "insufficient_scope", + "retryable": false + } + } + } + } + } + }, + "AvailabilityUnavailableProblem": { + "description": "Endpoint availability could not be determined. Retry the request.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + }, + "example": { + "type": "https://developer-api.amplitude.com/problems/operation-availability-unavailable", + "title": "Operation availability unavailable", + "status": 503, + "detail": "Operation availability could not be determined. Retry the request.", + "error_code": "operation_availability_unavailable", + "retryable": true + } + } + } + }, "HeatmapForbiddenProblem": { "description": "The caller cannot use this operation. `operation_not_enabled` means the\noperation is not enabled for the caller's organization;\n`insufficient_scope` means the token lacks `analytics:read`; `forbidden`\nmeans the caller lacks permission to view heatmaps in the project;\n`data_access_restricted` means data access controls exclude the requested\ndata.\n", "content": { @@ -3638,24 +4291,6 @@ } } }, - "AvailabilityUnavailableProblem": { - "description": "Endpoint availability could not be determined. Retry the request.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - }, - "example": { - "type": "https://developer-api.amplitude.com/problems/operation-availability-unavailable", - "title": "Operation availability unavailable", - "status": 503, - "detail": "Operation availability could not be determined. Retry the request.", - "error_code": "operation_availability_unavailable", - "retryable": true - } - } - } - }, "UpstreamTimeoutProblem": { "description": "The query exceeded the synchronous timeout. Narrow the date range and retry.", "content": { @@ -4260,73 +4895,213 @@ "type": "string", "description": "Identifier for this ingestion check." }, - "object": { - "type": "string", - "const": "recent_event_ingestion_check", - "description": "Resource-type discriminator. Always `recent_event_ingestion_check`." + "object": { + "type": "string", + "const": "recent_event_ingestion_check", + "description": "Resource-type discriminator. Always `recent_event_ingestion_check`." + }, + "project_id": { + "type": "string", + "pattern": "^[0-9]+$" + }, + "event_type": { + "type": "string", + "description": "Event type evaluated. Omitted when the request checked for any event." + }, + "status": { + "type": "string", + "enum": ["observed", "not_observed", "inconclusive"], + "description": "`observed` when at least one matching event was found; `not_observed`\nwhen none was found; `inconclusive` when none was found but complete\nrecent data was unavailable.\n" + }, + "count": { + "type": "number", + "minimum": 0, + "description": "Number of matching events found in the evaluated window." + }, + "poll_after_seconds": { + "$ref": "#/components/schemas/RecentEventIngestionPollAfterSeconds" + }, + "window": { + "type": "object", + "required": ["start", "end", "basis"], + "properties": { + "start": { + "type": "string", + "format": "date-time", + "description": "Start of the evaluated window." + }, + "end": { + "type": "string", + "format": "date-time", + "description": "End of the evaluated window." + }, + "basis": { + "type": "string", + "const": "server_upload_time", + "description": "Time basis for the window. `server_upload_time` measures when an\nevent reached Amplitude.\n" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "TimeRange": { + "type": "object", + "required": ["start", "end"], + "properties": { + "start": { + "type": "string", + "format": "date", + "description": "Inclusive start date (project timezone unless `timezone` is set on query)." + }, + "end": { + "type": "string", + "format": "date", + "description": "Inclusive end date." + } + }, + "additionalProperties": false + }, + "MetricType": { + "type": "string", + "description": "Public metric type discriminator (`snake_case`). See the `enum` for supported\nvalues.\n\nUnsupported or unrecognized metric types are surfaced as `unknown`. Clients\nshould treat `unknown` as \"a metric type this API version does not model yet\".\n", + "enum": [ + "uniques", + "totals", + "formula", + "prop_sum", + "prop_avg", + "retention", + "conversion", + "unknown" + ] + }, + "Metric": { + "type": "object", + "required": [ + "id", + "object", + "project_id", + "name", + "metric_type", + "definition", + "version", + "created_at", + "updated_at", + "url", + "archived" + ], + "properties": { + "id": { + "type": "string", + "description": "Stable metric identifier." + }, + "object": { + "type": "string", + "const": "metric", + "description": "Resource-type discriminator. Always `metric`." + }, + "project_id": { + "type": "string", + "pattern": "^[0-9]+$" + }, + "name": { + "type": "string" + }, + "description": { + "type": ["string", "null"] + }, + "metric_type": { + "$ref": "#/components/schemas/MetricType" + }, + "definition": { + "$ref": "#/components/schemas/MetricDefinition" + }, + "version": { + "type": "integer", + "minimum": 1, + "description": "Optimistic concurrency version. Required on PATCH; incremented on each\nsuccessful update.\n" + }, + "created_at": { + "type": ["string", "null"], + "format": "date-time" + }, + "updated_at": { + "type": ["string", "null"], + "format": "date-time" }, - "project_id": { - "type": "string", - "pattern": "^[0-9]+$" + "created_by": { + "type": ["string", "null"], + "description": "Creator email or login identifier when available." }, - "event_type": { + "url": { "type": "string", - "description": "Event type evaluated. Omitted when the request checked for any event." + "format": "uri", + "description": "Deep link to the metric in the Amplitude UI." }, - "status": { - "type": "string", - "enum": ["observed", "not_observed", "inconclusive"], - "description": "`observed` when at least one matching event was found; `not_observed`\nwhen none was found; `inconclusive` when none was found but complete\nrecent data was unavailable.\n" + "archived": { + "type": "boolean", + "description": "Whether the metric is archived (soft-deleted)." }, - "count": { - "type": "number", - "minimum": 0, - "description": "Number of matching events found in the evaluated window." + "is_official": { + "type": "boolean", + "description": "Whether the metric is marked official." }, - "poll_after_seconds": { - "$ref": "#/components/schemas/RecentEventIngestionPollAfterSeconds" + "is_restricted": { + "type": "boolean", + "description": "Whether the metric is restricted." }, - "window": { - "type": "object", - "required": ["start", "end", "basis"], - "properties": { - "start": { - "type": "string", - "format": "date-time", - "description": "Start of the evaluated window." - }, - "end": { - "type": "string", - "format": "date-time", - "description": "End of the evaluated window." - }, - "basis": { - "type": "string", - "const": "server_upload_time", - "description": "Time basis for the window. `server_upload_time` measures when an\nevent reached Amplitude.\n" - } + "key_properties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetricKeyProperty" }, - "additionalProperties": false + "description": "Key properties associated with the metric definition." } - }, - "additionalProperties": false + } }, - "TimeRange": { - "type": "object", - "required": ["start", "end"], - "properties": { - "start": { - "type": "string", - "format": "date", - "description": "Inclusive start date (project timezone unless `timezone` is set on query)." + "MetricDefinition": { + "description": "Type-specific metric definition. The `metric_type` discriminator selects\nthe required fields. Aligns with MCP `shared-schema` capabilities.\n", + "oneOf": [ + { + "$ref": "#/components/schemas/MetricDefinitionUniques" }, - "end": { - "type": "string", - "format": "date", - "description": "Inclusive end date." + { + "$ref": "#/components/schemas/MetricDefinitionTotals" + }, + { + "$ref": "#/components/schemas/MetricDefinitionFormula" + }, + { + "$ref": "#/components/schemas/MetricDefinitionPropSum" + }, + { + "$ref": "#/components/schemas/MetricDefinitionPropAvg" + }, + { + "$ref": "#/components/schemas/MetricDefinitionRetention" + }, + { + "$ref": "#/components/schemas/MetricDefinitionConversion" + }, + { + "$ref": "#/components/schemas/MetricDefinitionUnknown" } - }, - "additionalProperties": false + ], + "discriminator": { + "propertyName": "metric_type", + "mapping": { + "uniques": "#/components/schemas/MetricDefinitionUniques", + "totals": "#/components/schemas/MetricDefinitionTotals", + "formula": "#/components/schemas/MetricDefinitionFormula", + "prop_sum": "#/components/schemas/MetricDefinitionPropSum", + "prop_avg": "#/components/schemas/MetricDefinitionPropAvg", + "retention": "#/components/schemas/MetricDefinitionRetention", + "conversion": "#/components/schemas/MetricDefinitionConversion", + "unknown": "#/components/schemas/MetricDefinitionUnknown" + } + } }, "ClickMapQueryRequest": { "type": "object", @@ -5243,57 +6018,301 @@ } } }, - "evidence": { + "evidence": { + "type": "object", + "required": ["session_ids", "chart_definition"], + "properties": { + "session_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "chart_definition": { + "type": ["object", "null"], + "additionalProperties": true, + "description": "Opaque, read-only chart definition. Its nested keys are preserved; the shape is not intended for authoring." + } + } + }, + "magnitude": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "score": { + "type": "number", + "description": "100 times magnitude times confidence; higher ranks first.", + "minimum": 0, + "maximum": 100 + }, + "status": { + "type": "string", + "enum": ["opened", "in_progress", "closed"] + }, + "window_start": { + "type": "string", + "format": "date-time" + }, + "window_end": { + "type": "string", + "format": "date-time" + }, + "evaluated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "McpAnalyticsServer": { + "type": "object", + "required": ["id", "object", "project_id", "name"], + "properties": { + "id": { + "type": "string", + "description": "Server id, returned as a string." + }, + "object": { + "type": "string", + "const": "mcp_analytics_server" + }, + "project_id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "McpAnalyticsTopic": { + "type": "object", + "required": [ + "id", + "object", + "parent_id", + "depth", + "name", + "description", + "episode_count", + "share", + "hard_error_rate", + "thrashing_rate", + "mean_calls", + "neighbor_ids" + ], + "properties": { + "id": { + "type": "string", + "description": "Stable topic id. `root` is reserved and is never a topic id." + }, + "object": { + "type": "string", + "const": "mcp_analytics_topic" + }, + "parent_id": { + "type": ["string", "null"] + }, + "depth": { + "type": "integer", + "minimum": 0 + }, + "name": { + "type": "string" + }, + "description": { + "type": ["string", "null"] + }, + "episode_count": { + "type": ["number", "null"] + }, + "share": { + "type": ["number", "null"] + }, + "hard_error_rate": { + "type": ["number", "null"] + }, + "thrashing_rate": { + "type": ["number", "null"] + }, + "mean_calls": { + "type": ["number", "null"] + }, + "neighbor_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "McpAnalyticsTaxonomy": { + "type": "object", + "required": [ + "id", + "object", + "project_id", + "server_id", + "run_id", + "taxonomy_version", + "activated_at", + "nodes" + ], + "properties": { + "id": { + "type": "string", + "description": "Server id. It stays stable when a new run is pinned and is present when nothing is pinned." + }, + "object": { + "type": "string", + "const": "mcp_analytics_taxonomy" + }, + "project_id": { + "type": "string" + }, + "server_id": { + "type": "string" + }, + "run_id": { + "type": ["string", "null"] + }, + "taxonomy_version": { + "type": ["string", "null"] + }, + "activated_at": { + "type": ["string", "null"], + "format": "date-time" + }, + "nodes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/McpAnalyticsTopic" + } + } + } + }, + "McpAnalyticsEpisodeSummary": { + "type": "object", + "required": [ + "id", + "object", + "started_at", + "ended_at", + "call_count", + "error_count", + "is_thrash", + "client_name", + "lane", + "confidence" + ], + "properties": { + "id": { + "type": "string" + }, + "object": { + "type": "string", + "const": "mcp_analytics_episode" + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "ended_at": { + "type": "string", + "format": "date-time" + }, + "call_count": { + "type": "integer", + "minimum": 0 + }, + "error_count": { + "type": "integer", + "minimum": 0 + }, + "is_thrash": { + "type": "boolean" + }, + "client_name": { + "type": "string" + }, + "lane": { + "type": "string" + }, + "confidence": { + "type": ["number", "null"] + } + } + }, + "McpAnalyticsTopicDetail": { + "allOf": [ + { + "$ref": "#/components/schemas/McpAnalyticsTopic" + }, + { "type": "object", - "required": ["session_ids", "chart_definition"], + "required": ["examples"], "properties": { - "session_ids": { + "examples": { "type": "array", + "maxItems": 8, + "description": "At most eight episode summaries. This is not a page and has no cursor.", "items": { - "type": "string" + "$ref": "#/components/schemas/McpAnalyticsEpisodeSummary" } - }, - "chart_definition": { - "type": ["object", "null"], - "additionalProperties": true, - "description": "Opaque, read-only chart definition. Its nested keys are preserved; the shape is not intended for authoring." } } - }, - "magnitude": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "confidence": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "score": { - "type": "number", - "description": "100 times magnitude times confidence; higher ranks first.", - "minimum": 0, - "maximum": 100 - }, - "status": { - "type": "string", - "enum": ["opened", "in_progress", "closed"] - }, - "window_start": { + } + ] + }, + "McpAnalyticsBreadcrumb": { + "type": "object", + "required": ["id", "depth", "name"], + "properties": { + "id": { "type": "string", - "format": "date-time" + "description": "Stable topic id, ordered from the root." }, - "window_end": { - "type": "string", - "format": "date-time" + "depth": { + "type": "integer", + "minimum": 0 }, - "evaluated_at": { - "type": "string", - "format": "date-time" + "name": { + "type": "string" } } }, + "McpAnalyticsEpisode": { + "allOf": [ + { + "$ref": "#/components/schemas/McpAnalyticsEpisodeSummary" + }, + { + "type": "object", + "required": ["project_id", "server_id", "auth_type", "breadcrumb"], + "properties": { + "project_id": { + "type": "string" + }, + "server_id": { + "type": "string" + }, + "auth_type": { + "type": "string" + }, + "breadcrumb": { + "type": "array", + "items": { + "$ref": "#/components/schemas/McpAnalyticsBreadcrumb" + } + } + } + } + ] + }, "SignupDocumentAcceptance": { "type": "object", "required": ["url", "accepted"], @@ -5617,6 +6636,324 @@ } } }, + "PropertyType": { + "type": "string", + "description": "Property namespace for filters, group-bys, and aggregations.", + "enum": ["event", "user", "group"] + }, + "MetricPropertyFilter": { + "type": "object", + "required": ["property_key", "operator"], + "properties": { + "property_key": { + "type": "string", + "description": "Property key to filter on." + }, + "operator": { + "type": "string", + "description": "Filter operator (matches Amplitude property operators)." + }, + "values": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "Values to match against." + }, + "property_type": { + "$ref": "#/components/schemas/PropertyType" + }, + "group_type": { + "type": "string", + "default": "", + "description": "Group type for group properties. Empty string for non-group." + } + }, + "additionalProperties": false + }, + "MetricGroupBy": { + "type": "object", + "required": ["property_type", "value"], + "properties": { + "property_type": { + "$ref": "#/components/schemas/PropertyType" + }, + "value": { + "type": "string", + "description": "Property name to group by." + }, + "group_type": { + "type": "string", + "default": "", + "description": "Group type for group properties. Empty string for non-group." + } + }, + "additionalProperties": false + }, + "MetricEvent": { + "type": "object", + "required": ["event_type"], + "properties": { + "event_type": { + "type": "string", + "description": "Event name (e.g. `Page View`, `_active`)." + }, + "filters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetricPropertyFilter" + }, + "default": [] + }, + "group_by": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetricGroupBy" + }, + "description": "Properties to group by. Required for formula metrics that use property\naggregation functions like `PROPSUM(A)` or `PROPAVG(A)`.\n" + } + }, + "additionalProperties": false + }, + "MetricDefinitionUniques": { + "type": "object", + "required": ["metric_type", "event"], + "properties": { + "metric_type": { + "type": "string", + "const": "uniques" + }, + "event": { + "$ref": "#/components/schemas/MetricEvent" + }, + "count_group": { + "type": "string", + "default": "User", + "description": "What to count. `User` for unique users, `Event` for event totals." + } + }, + "additionalProperties": false + }, + "MetricDefinitionTotals": { + "type": "object", + "required": ["metric_type", "event"], + "properties": { + "metric_type": { + "type": "string", + "const": "totals" + }, + "event": { + "$ref": "#/components/schemas/MetricEvent" + }, + "count_group": { + "type": "string", + "default": "User" + } + }, + "additionalProperties": false + }, + "MetricDefinitionFormula": { + "type": "object", + "required": ["metric_type", "events", "formula"], + "properties": { + "metric_type": { + "type": "string", + "const": "formula" + }, + "events": { + "type": "array", + "minItems": 1, + "maxItems": 6, + "items": { + "$ref": "#/components/schemas/MetricEvent" + }, + "description": "Event definitions referenced by letter (A, B, C...) in the formula string.\n" + }, + "formula": { + "type": "string", + "description": "Formula string. Reference events by letter. Functions include\n`UNIQUES(A)`, `TOTALS(A)`, `PROPSUM(A)`, `PROPAVG(A)`, `PROPMIN(A)`,\n`PROPMAX(A)`, `PROPCOUNT(A)`, `PROPCOUNTAVG(A)`, `REVENUETOTAL(A)`.\n" + } + }, + "additionalProperties": false + }, + "MetricAggregationProperty": { + "type": "object", + "required": ["property_type", "value"], + "properties": { + "property_type": { + "$ref": "#/components/schemas/PropertyType" + }, + "value": { + "type": "string", + "description": "Numeric property name to aggregate." + }, + "group_type": { + "type": "string", + "default": "" + } + }, + "additionalProperties": false + }, + "MetricDefinitionPropSum": { + "type": "object", + "required": ["metric_type", "event", "aggregation_property"], + "properties": { + "metric_type": { + "type": "string", + "const": "prop_sum" + }, + "event": { + "$ref": "#/components/schemas/MetricEvent" + }, + "aggregation_property": { + "$ref": "#/components/schemas/MetricAggregationProperty" + } + }, + "additionalProperties": false + }, + "MetricDefinitionPropAvg": { + "type": "object", + "required": ["metric_type", "event", "aggregation_property"], + "properties": { + "metric_type": { + "type": "string", + "const": "prop_avg" + }, + "event": { + "$ref": "#/components/schemas/MetricEvent" + }, + "aggregation_property": { + "$ref": "#/components/schemas/MetricAggregationProperty" + } + }, + "additionalProperties": false + }, + "MetricDefinitionRetention": { + "type": "object", + "required": [ + "metric_type", + "start_event", + "return_event", + "return_on", + "return_on_interval" + ], + "properties": { + "metric_type": { + "type": "string", + "const": "retention" + }, + "start_event": { + "$ref": "#/components/schemas/MetricEvent" + }, + "return_event": { + "$ref": "#/components/schemas/MetricEvent" + }, + "return_on": { + "type": "integer", + "minimum": 1, + "maximum": 30, + "description": "Number of intervals to return on." + }, + "return_on_interval": { + "type": "integer", + "minimum": 1, + "maximum": 30, + "description": "Interval length in days. `1` for daily, `7` for weekly, `30` for monthly.\n" + } + }, + "additionalProperties": false + }, + "FunnelMode": { + "type": "string", + "enum": ["ordered", "unordered", "sequential"] + }, + "FunnelPathsToCollect": { + "type": "string", + "enum": ["uniques", "totals"], + "description": "Whether the funnel measures unique users completing the path or total\nconversion path counts.\n" + }, + "FunnelComputePropertyFunction": { + "type": "string", + "enum": ["sum", "avg"] + }, + "MetricDefinitionConversion": { + "type": "object", + "required": [ + "metric_type", + "funnel_events", + "funnel_mode", + "conversion_seconds", + "funnel_paths_to_collect" + ], + "properties": { + "metric_type": { + "type": "string", + "const": "conversion" + }, + "funnel_events": { + "type": "array", + "minItems": 2, + "maxItems": 12, + "items": { + "$ref": "#/components/schemas/MetricEvent" + }, + "description": "Ordered funnel steps (minimum 2 events)." + }, + "funnel_mode": { + "$ref": "#/components/schemas/FunnelMode" + }, + "conversion_seconds": { + "type": "integer", + "minimum": 1, + "description": "Maximum time window to complete the funnel, in seconds\n(e.g. `86400` = 1 day, `604800` = 7 days).\n" + }, + "funnel_paths_to_collect": { + "$ref": "#/components/schemas/FunnelPathsToCollect" + }, + "funnel_constant_properties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetricAggregationProperty" + }, + "description": "Properties held constant across funnel steps." + }, + "funnel_compute_property": { + "$ref": "#/components/schemas/MetricAggregationProperty" + }, + "funnel_compute_property_function": { + "$ref": "#/components/schemas/FunnelComputePropertyFunction" + } + }, + "additionalProperties": false + }, + "MetricDefinitionUnknown": { + "type": "object", + "required": ["metric_type"], + "properties": { + "metric_type": { + "type": "string", + "const": "unknown" + } + }, + "additionalProperties": false, + "description": "Placeholder definition for metric types this API version does not model.\nNo type-specific fields are returned.\n" + }, + "MetricKeyProperty": { + "type": "object", + "required": ["property_type", "value"], + "properties": { + "property_type": { + "type": "string" + }, + "value": { + "type": "string" + }, + "group_type": { + "type": "string" + } + } + }, "PageUrl": { "type": "string", "minLength": 1, diff --git a/openapi/bundled/openapi.bundled.yaml b/openapi/bundled/openapi.bundled.yaml index 45a946c..10d9c1c 100644 --- a/openapi/bundled/openapi.bundled.yaml +++ b/openapi/bundled/openapi.bundled.yaml @@ -21,6 +21,8 @@ security: tags: - name: Agent Analytics description: Saved findings about agent cost, efficiency, and quality. + - name: MCP Analytics + description: Read the active MCP taxonomy, its topics, and its episodes. - name: Projects description: Project discovery and project-scoped navigation. - name: Taxonomy Events @@ -30,7 +32,7 @@ tags: - name: Taxonomy User Properties description: User-property taxonomy operations (scoped to a project). - name: Analytics - description: Saved chart discovery and query operations. + description: Saved chart discovery, query, and get-metric read operations. - name: Heatmaps description: Click map and scroll map queries for a page URL. - name: Zoning @@ -138,6 +140,327 @@ paths: $ref: '#/components/responses/RateLimitProblem' '502': $ref: '#/components/responses/Problem' + /v1/projects/{project_id}/mcp-analytics/servers: + get: + tags: + - MCP Analytics + operationId: listMcpAnalyticsServers + summary: List MCP Analytics servers + description: | + Returns the MCP servers configured for the project. A project with no MCP + servers returns 200 and an empty `data` array. + x-required-scopes: + - analytics:read + parameters: + - $ref: '#/components/parameters/ProjectId' + responses: + '200': + description: MCP servers for the project. The list is complete, so `has_more` is false. + content: + application/json: + schema: + type: object + required: + - data + - pagination + properties: + data: + type: array + items: + $ref: '#/components/schemas/McpAnalyticsServer' + 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' + '429': + $ref: '#/components/responses/RateLimitProblem' + '502': + $ref: '#/components/responses/Problem' + /v1/projects/{project_id}/mcp-analytics/servers/{server_id}/taxonomy: + get: + tags: + - MCP Analytics + operationId: getMcpAnalyticsTaxonomy + summary: Get the active MCP taxonomy + description: | + Returns the server's active topic tree. The resource id is the server id. + A visible server with no pinned taxonomy returns 200, null run fields, and + `nodes: []`. An unknown server returns 404. + x-required-scopes: + - analytics:read + parameters: + - $ref: '#/components/parameters/ProjectId' + - name: server_id + in: path + required: true + description: Server id. + schema: + type: string + pattern: ^[0-9]+$ + - name: parent_id + in: query + required: false + description: | + Node selector. Omit it for every node in the active tree. `root` + returns root nodes. Any other value is a stable topic id and returns + that topic's direct children. An unknown parent returns 404 when a + taxonomy is pinned. + schema: + type: string + minLength: 1 + maxLength: 256 + responses: + '200': + description: Active taxonomy for the server. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/McpAnalyticsTaxonomy' + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '404': + $ref: '#/components/responses/Problem' + '429': + $ref: '#/components/responses/RateLimitProblem' + '502': + $ref: '#/components/responses/Problem' + /v1/projects/{project_id}/mcp-analytics/servers/{server_id}/topics/{topic_id}: + get: + tags: + - MCP Analytics + operationId: getMcpAnalyticsTopic + summary: Get an MCP taxonomy topic + description: | + Returns one topic and up to eight example episodes. Examples are not a + page. Use the episode list to page through episodes. + x-required-scopes: + - analytics:read + parameters: + - $ref: '#/components/parameters/ProjectId' + - name: server_id + in: path + required: true + description: Server id. + schema: + type: string + pattern: ^[0-9]+$ + - name: topic_id + in: path + required: true + description: Stable topic id. `root` is not a topic id. + schema: + type: string + minLength: 1 + maxLength: 256 + responses: + '200': + description: Topic and its example episodes. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/McpAnalyticsTopicDetail' + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '404': + $ref: '#/components/responses/Problem' + '429': + $ref: '#/components/responses/RateLimitProblem' + '502': + $ref: '#/components/responses/Problem' + /v1/projects/{project_id}/mcp-analytics/servers/{server_id}/topics/{topic_id}/episodes: + get: + tags: + - MCP Analytics + operationId: listMcpAnalyticsEpisodes + summary: List episodes for an MCP topic + description: | + Returns a page of episode summaries for one topic. The default page size + is 25 and the maximum is 100. Keep the same filters + when following `next_cursor`. + x-required-scopes: + - analytics:read + parameters: + - $ref: '#/components/parameters/ProjectId' + - name: server_id + in: path + required: true + description: Server id. + schema: + type: string + pattern: ^[0-9]+$ + - name: topic_id + in: path + required: true + description: Stable topic id. + schema: + type: string + minLength: 1 + maxLength: 256 + - name: started_after + in: query + required: false + description: Include episodes that started at or after this RFC 3339 timestamp. + schema: + type: string + format: date-time + - name: started_before + in: query + required: false + description: Include episodes that started before this RFC 3339 timestamp. + schema: + type: string + format: date-time + - name: lane + in: query + required: false + description: Exact stored lane. + schema: + type: string + minLength: 1 + maxLength: 128 + - name: client_name + in: query + required: false + description: Exact stored client name. + schema: + type: string + minLength: 1 + maxLength: 256 + - name: auth_type + in: query + required: false + description: Exact stored auth type. + schema: + type: string + minLength: 1 + maxLength: 128 + - name: has_error + in: query + required: false + description: Whether the episode recorded an error. + schema: + type: boolean + - name: is_thrash + in: query + required: false + description: Whether the episode was classified as thrash. + schema: + type: boolean + - name: cursor + in: query + required: false + description: Opaque cursor from `pagination.next_cursor` on the previous response. + schema: + type: string + minLength: 1 + maxLength: 2048 + - name: limit + in: query + required: false + description: Number of episodes to return. Defaults to 25, maximum 100. + schema: + type: integer + minimum: 1 + maximum: 100 + default: 25 + responses: + '200': + description: Episode page. Keep the same filters when requesting the next cursor. + content: + application/json: + schema: + type: object + required: + - data + - pagination + properties: + data: + type: array + items: + $ref: '#/components/schemas/McpAnalyticsEpisodeSummary' + 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' + '429': + $ref: '#/components/responses/RateLimitProblem' + '502': + $ref: '#/components/responses/Problem' + /v1/projects/{project_id}/mcp-analytics/episodes/{episode_id}: + get: + tags: + - MCP Analytics + operationId: getMcpAnalyticsEpisode + summary: Get an MCP episode + description: | + Returns one episode, including its project, server, auth type, and topic + breadcrumb. A missing or cross-project episode returns 404. + x-required-scopes: + - analytics:read + parameters: + - $ref: '#/components/parameters/ProjectId' + - name: episode_id + in: path + required: true + description: Episode id returned by the topic or episode list. + schema: + type: string + minLength: 1 + maxLength: 512 + responses: + '200': + description: Episode detail. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/McpAnalyticsEpisode' + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '404': + $ref: '#/components/responses/Problem' + '429': + $ref: '#/components/responses/RateLimitProblem' + '502': + $ref: '#/components/responses/Problem' /v1/auth/device-authorization: post: tags: @@ -1310,7 +1633,7 @@ paths: summary: Create user property description: | Creates a user property and adds it to the project's tracking plan. - Same idempotency semantics as `createEventProperty`. + Same idempotency semantics as creating an event property. x-required-scopes: - taxonomy:write parameters: @@ -1422,13 +1745,13 @@ paths: 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` + property is ingested-only. Same patch semantics as updating an event property (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. + `is_official` is read-only in this API version. x-required-scopes: - taxonomy:write parameters: @@ -1685,54 +2008,146 @@ paths: application/problem+json: schema: $ref: '#/components/schemas/ProblemDetails' - /v1/projects/{project_id}/heatmaps/click-map: - parameters: - - $ref: '#/components/parameters/ProjectId' - post: + /v1/projects/{project_id}/metrics: + get: tags: - - Heatmaps - operationId: queryClickMap - summary: Query click map + - Analytics + operationId: listMetrics + summary: List metrics description: | - Returns click counts for one page URL, grouped by the CSS selector of the - clicked element or by page coordinate, ranked by count. Filter by device - viewport band and date range. Results reflect the authenticated caller's - project access and data access controls. + Returns saved metrics in the project. Results are paginated with cursor-based + pagination. Full metric definitions are included in list responses. - This POST computes a result and does not mutate state. It is therefore a - read operation and does not require an `Idempotency-Key`. - - Omitting `time_range` uses the last 30 days in the project timezone. The - span of `time_range` cannot exceed 90 days. + Sortable fields are `updated_at` and `name`; prefix with `-` for descending. + Defaults to `-updated_at` (most recently updated first). x-required-scopes: - analytics:read - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ClickMapQueryRequest' + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Query' + - $ref: '#/components/parameters/MetricTypeFilter' + - $ref: '#/components/parameters/Sort' responses: '200': - description: Click map computed successfully. + description: Successful response. content: application/json: schema: type: object required: - data + - pagination properties: data: - $ref: '#/components/schemas/ClickMap' + type: array + items: + $ref: '#/components/schemas/Metric' + pagination: + $ref: '#/components/schemas/Pagination' '400': $ref: '#/components/responses/ValidationProblem' '401': $ref: '#/components/responses/Problem' '403': - $ref: '#/components/responses/HeatmapForbiddenProblem' + $ref: '#/components/responses/MetricForbiddenProblem' '404': $ref: '#/components/responses/Problem' - '422': + '429': + $ref: '#/components/responses/RateLimitProblem' + '500': + $ref: '#/components/responses/Problem' + '503': + $ref: '#/components/responses/AvailabilityUnavailableProblem' + /v1/projects/{project_id}/metrics/{metric_id}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/MetricId' + get: + tags: + - Analytics + operationId: getMetric + summary: Get metric + description: | + Returns the full saved metric definition including type-specific params, + version, and metadata. + x-required-scopes: + - analytics:read + responses: + '200': + description: Successful response. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Metric' + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/MetricForbiddenProblem' + '404': + $ref: '#/components/responses/Problem' + '429': + $ref: '#/components/responses/RateLimitProblem' + '500': + $ref: '#/components/responses/Problem' + '503': + $ref: '#/components/responses/AvailabilityUnavailableProblem' + /v1/projects/{project_id}/heatmaps/click-map: + parameters: + - $ref: '#/components/parameters/ProjectId' + post: + tags: + - Heatmaps + operationId: queryClickMap + summary: Query click map + description: | + Returns click counts for one page URL, grouped by the CSS selector of the + clicked element or by page coordinate, ranked by count. Filter by device + viewport band and date range. Results reflect the authenticated caller's + project access and data access controls. + + This POST computes a result and does not mutate state. It is therefore a + read operation and does not require an `Idempotency-Key`. + + Omitting `time_range` uses the last 30 days in the project timezone. The + span of `time_range` cannot exceed 90 days. + x-required-scopes: + - analytics:read + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ClickMapQueryRequest' + responses: + '200': + description: Click map computed successfully. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/ClickMap' + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/HeatmapForbiddenProblem' + '404': + $ref: '#/components/responses/Problem' + '422': $ref: '#/components/responses/NotConfiguredProblem' '429': $ref: '#/components/responses/RateLimitProblem' @@ -2371,6 +2786,20 @@ paths: summary: Test an event streaming destination connection x-required-scopes: - destinations:write + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + test_event_payload: + type: string + minLength: 1 + description: | + JSON event to send to the destination. Omit the field to use + the partner's example event. An empty string is rejected. + additionalProperties: false responses: '200': description: Test result. @@ -2525,7 +2954,7 @@ components: name: event_id in: path required: true - description: Public event identifier. For the current TMS-backed API, this value is the event type. + description: Public event identifier. This value is the event type name (the string used when sending the event to Amplitude). schema: type: string minLength: 1 @@ -2574,6 +3003,14 @@ components: whose type is not represented by a public API value. schema: $ref: '#/components/schemas/ChartType' + MetricId: + name: metric_id + in: path + required: true + description: Saved metric identifier. + schema: + type: string + minLength: 1 IncludeDefinition: name: include_definition in: query @@ -2633,6 +3070,13 @@ components: schema: type: integer minimum: 0 + MetricTypeFilter: + name: metric_type + in: query + required: false + description: Filter list results to a specific metric type. + schema: + $ref: '#/components/schemas/MetricType' KindFilter: name: kind in: query @@ -2668,6 +3112,47 @@ components: detail: Too many requests. error_code: rate_limit_exceeded retryable: true + MetricForbiddenProblem: + description: | + The caller cannot use this operation. `operation_not_enabled` means the + operation is not enabled for the caller's organization; + `insufficient_scope` means the token lacks `analytics:read`. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + examples: + operationNotEnabled: + summary: Endpoint unavailable + value: + type: https://developer-api.amplitude.com/problems/operation-not-enabled + title: Endpoint unavailable + status: 403 + detail: This endpoint is unavailable for your organization. + error_code: operation_not_enabled + retryable: false + insufficientScope: + summary: Missing required scope + value: + type: https://developer-api.amplitude.com/problems/forbidden + title: Insufficient scope + status: 403 + detail: Token missing required scopes. + error_code: insufficient_scope + retryable: false + AvailabilityUnavailableProblem: + description: Endpoint availability could not be determined. Retry the request. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + example: + type: https://developer-api.amplitude.com/problems/operation-availability-unavailable + title: Operation availability unavailable + status: 503 + detail: Operation availability could not be determined. Retry the request. + error_code: operation_availability_unavailable + retryable: true HeatmapForbiddenProblem: description: | The caller cannot use this operation. `operation_not_enabled` means the @@ -2733,19 +3218,6 @@ components: detail: 'The project is missing required events: [Amplitude] Element Clicked.' error_code: session_replay_not_configured retryable: false - AvailabilityUnavailableProblem: - description: Endpoint availability could not be determined. Retry the request. - content: - application/problem+json: - schema: - $ref: '#/components/schemas/ProblemDetails' - example: - type: https://developer-api.amplitude.com/problems/operation-availability-unavailable - title: Operation availability unavailable - status: 503 - detail: Operation availability could not be determined. Retry the request. - error_code: operation_availability_unavailable - retryable: true UpstreamTimeoutProblem: description: The query exceeded the synchronous timeout. Narrow the date range and retry. content: @@ -3369,6 +3841,121 @@ components: format: date description: Inclusive end date. additionalProperties: false + MetricType: + type: string + description: | + Public metric type discriminator (`snake_case`). See the `enum` for supported + values. + + Unsupported or unrecognized metric types are surfaced as `unknown`. Clients + should treat `unknown` as "a metric type this API version does not model yet". + enum: + - uniques + - totals + - formula + - prop_sum + - prop_avg + - retention + - conversion + - unknown + Metric: + type: object + required: + - id + - object + - project_id + - name + - metric_type + - definition + - version + - created_at + - updated_at + - url + - archived + properties: + id: + type: string + description: Stable metric identifier. + object: + type: string + const: metric + description: Resource-type discriminator. Always `metric`. + project_id: + type: string + pattern: ^[0-9]+$ + name: + type: string + description: + type: + - string + - 'null' + metric_type: + $ref: '#/components/schemas/MetricType' + definition: + $ref: '#/components/schemas/MetricDefinition' + version: + type: integer + minimum: 1 + description: | + Optimistic concurrency version. Required on PATCH; incremented on each + successful update. + created_at: + type: + - string + - 'null' + format: date-time + updated_at: + type: + - string + - 'null' + format: date-time + created_by: + type: + - string + - 'null' + description: Creator email or login identifier when available. + url: + type: string + format: uri + description: Deep link to the metric in the Amplitude UI. + archived: + type: boolean + description: Whether the metric is archived (soft-deleted). + is_official: + type: boolean + description: Whether the metric is marked official. + is_restricted: + type: boolean + description: Whether the metric is restricted. + key_properties: + type: array + items: + $ref: '#/components/schemas/MetricKeyProperty' + description: Key properties associated with the metric definition. + MetricDefinition: + description: | + Type-specific metric definition. The `metric_type` discriminator selects + the required fields. Aligns with MCP `shared-schema` capabilities. + oneOf: + - $ref: '#/components/schemas/MetricDefinitionUniques' + - $ref: '#/components/schemas/MetricDefinitionTotals' + - $ref: '#/components/schemas/MetricDefinitionFormula' + - $ref: '#/components/schemas/MetricDefinitionPropSum' + - $ref: '#/components/schemas/MetricDefinitionPropAvg' + - $ref: '#/components/schemas/MetricDefinitionRetention' + - $ref: '#/components/schemas/MetricDefinitionConversion' + - $ref: '#/components/schemas/MetricDefinitionUnknown' + discriminator: + propertyName: metric_type + mapping: + uniques: '#/components/schemas/MetricDefinitionUniques' + totals: '#/components/schemas/MetricDefinitionTotals' + formula: '#/components/schemas/MetricDefinitionFormula' + prop_sum: '#/components/schemas/MetricDefinitionPropSum' + prop_avg: '#/components/schemas/MetricDefinitionPropAvg' + retention: '#/components/schemas/MetricDefinitionRetention' + conversion: '#/components/schemas/MetricDefinitionConversion' + unknown: '#/components/schemas/MetricDefinitionUnknown' ClickMapQueryRequest: type: object description: | @@ -4177,6 +4764,211 @@ components: evaluated_at: type: string format: date-time + McpAnalyticsServer: + type: object + required: + - id + - object + - project_id + - name + properties: + id: + type: string + description: Server id, returned as a string. + object: + type: string + const: mcp_analytics_server + project_id: + type: string + name: + type: string + McpAnalyticsTopic: + type: object + required: + - id + - object + - parent_id + - depth + - name + - description + - episode_count + - share + - hard_error_rate + - thrashing_rate + - mean_calls + - neighbor_ids + properties: + id: + type: string + description: Stable topic id. `root` is reserved and is never a topic id. + object: + type: string + const: mcp_analytics_topic + parent_id: + type: + - string + - 'null' + depth: + type: integer + minimum: 0 + name: + type: string + description: + type: + - string + - 'null' + episode_count: + type: + - number + - 'null' + share: + type: + - number + - 'null' + hard_error_rate: + type: + - number + - 'null' + thrashing_rate: + type: + - number + - 'null' + mean_calls: + type: + - number + - 'null' + neighbor_ids: + type: array + items: + type: string + McpAnalyticsTaxonomy: + type: object + required: + - id + - object + - project_id + - server_id + - run_id + - taxonomy_version + - activated_at + - nodes + properties: + id: + type: string + description: Server id. It stays stable when a new run is pinned and is present when nothing is pinned. + object: + type: string + const: mcp_analytics_taxonomy + project_id: + type: string + server_id: + type: string + run_id: + type: + - string + - 'null' + taxonomy_version: + type: + - string + - 'null' + activated_at: + type: + - string + - 'null' + format: date-time + nodes: + type: array + items: + $ref: '#/components/schemas/McpAnalyticsTopic' + McpAnalyticsEpisodeSummary: + type: object + required: + - id + - object + - started_at + - ended_at + - call_count + - error_count + - is_thrash + - client_name + - lane + - confidence + properties: + id: + type: string + object: + type: string + const: mcp_analytics_episode + started_at: + type: string + format: date-time + ended_at: + type: string + format: date-time + call_count: + type: integer + minimum: 0 + error_count: + type: integer + minimum: 0 + is_thrash: + type: boolean + client_name: + type: string + lane: + type: string + confidence: + type: + - number + - 'null' + McpAnalyticsTopicDetail: + allOf: + - $ref: '#/components/schemas/McpAnalyticsTopic' + - type: object + required: + - examples + properties: + examples: + type: array + maxItems: 8 + description: At most eight episode summaries. This is not a page and has no cursor. + items: + $ref: '#/components/schemas/McpAnalyticsEpisodeSummary' + McpAnalyticsBreadcrumb: + type: object + required: + - id + - depth + - name + properties: + id: + type: string + description: Stable topic id, ordered from the root. + depth: + type: integer + minimum: 0 + name: + type: string + McpAnalyticsEpisode: + allOf: + - $ref: '#/components/schemas/McpAnalyticsEpisodeSummary' + - type: object + required: + - project_id + - server_id + - auth_type + - breadcrumb + properties: + project_id: + type: string + server_id: + type: string + auth_type: + type: string + breadcrumb: + type: array + items: + $ref: '#/components/schemas/McpAnalyticsBreadcrumb' SignupDocumentAcceptance: type: object required: @@ -4467,6 +5259,286 @@ components: reason: type: string description: Human-readable explanation of truncation. + PropertyType: + type: string + description: Property namespace for filters, group-bys, and aggregations. + enum: + - event + - user + - group + MetricPropertyFilter: + type: object + required: + - property_key + - operator + properties: + property_key: + type: string + description: Property key to filter on. + operator: + type: string + description: Filter operator (matches Amplitude property operators). + values: + type: array + items: + type: string + default: [] + description: Values to match against. + property_type: + $ref: '#/components/schemas/PropertyType' + group_type: + type: string + default: '' + description: Group type for group properties. Empty string for non-group. + additionalProperties: false + MetricGroupBy: + type: object + required: + - property_type + - value + properties: + property_type: + $ref: '#/components/schemas/PropertyType' + value: + type: string + description: Property name to group by. + group_type: + type: string + default: '' + description: Group type for group properties. Empty string for non-group. + additionalProperties: false + MetricEvent: + type: object + required: + - event_type + properties: + event_type: + type: string + description: Event name (e.g. `Page View`, `_active`). + filters: + type: array + items: + $ref: '#/components/schemas/MetricPropertyFilter' + default: [] + group_by: + type: array + items: + $ref: '#/components/schemas/MetricGroupBy' + description: | + Properties to group by. Required for formula metrics that use property + aggregation functions like `PROPSUM(A)` or `PROPAVG(A)`. + additionalProperties: false + MetricDefinitionUniques: + type: object + required: + - metric_type + - event + properties: + metric_type: + type: string + const: uniques + event: + $ref: '#/components/schemas/MetricEvent' + count_group: + type: string + default: User + description: What to count. `User` for unique users, `Event` for event totals. + additionalProperties: false + MetricDefinitionTotals: + type: object + required: + - metric_type + - event + properties: + metric_type: + type: string + const: totals + event: + $ref: '#/components/schemas/MetricEvent' + count_group: + type: string + default: User + additionalProperties: false + MetricDefinitionFormula: + type: object + required: + - metric_type + - events + - formula + properties: + metric_type: + type: string + const: formula + events: + type: array + minItems: 1 + maxItems: 6 + items: + $ref: '#/components/schemas/MetricEvent' + description: | + Event definitions referenced by letter (A, B, C...) in the formula string. + formula: + type: string + description: | + Formula string. Reference events by letter. Functions include + `UNIQUES(A)`, `TOTALS(A)`, `PROPSUM(A)`, `PROPAVG(A)`, `PROPMIN(A)`, + `PROPMAX(A)`, `PROPCOUNT(A)`, `PROPCOUNTAVG(A)`, `REVENUETOTAL(A)`. + additionalProperties: false + MetricAggregationProperty: + type: object + required: + - property_type + - value + properties: + property_type: + $ref: '#/components/schemas/PropertyType' + value: + type: string + description: Numeric property name to aggregate. + group_type: + type: string + default: '' + additionalProperties: false + MetricDefinitionPropSum: + type: object + required: + - metric_type + - event + - aggregation_property + properties: + metric_type: + type: string + const: prop_sum + event: + $ref: '#/components/schemas/MetricEvent' + aggregation_property: + $ref: '#/components/schemas/MetricAggregationProperty' + additionalProperties: false + MetricDefinitionPropAvg: + type: object + required: + - metric_type + - event + - aggregation_property + properties: + metric_type: + type: string + const: prop_avg + event: + $ref: '#/components/schemas/MetricEvent' + aggregation_property: + $ref: '#/components/schemas/MetricAggregationProperty' + additionalProperties: false + MetricDefinitionRetention: + type: object + required: + - metric_type + - start_event + - return_event + - return_on + - return_on_interval + properties: + metric_type: + type: string + const: retention + start_event: + $ref: '#/components/schemas/MetricEvent' + return_event: + $ref: '#/components/schemas/MetricEvent' + return_on: + type: integer + minimum: 1 + maximum: 30 + description: Number of intervals to return on. + return_on_interval: + type: integer + minimum: 1 + maximum: 30 + description: | + Interval length in days. `1` for daily, `7` for weekly, `30` for monthly. + additionalProperties: false + FunnelMode: + type: string + enum: + - ordered + - unordered + - sequential + FunnelPathsToCollect: + type: string + enum: + - uniques + - totals + description: | + Whether the funnel measures unique users completing the path or total + conversion path counts. + FunnelComputePropertyFunction: + type: string + enum: + - sum + - avg + MetricDefinitionConversion: + type: object + required: + - metric_type + - funnel_events + - funnel_mode + - conversion_seconds + - funnel_paths_to_collect + properties: + metric_type: + type: string + const: conversion + funnel_events: + type: array + minItems: 2 + maxItems: 12 + items: + $ref: '#/components/schemas/MetricEvent' + description: Ordered funnel steps (minimum 2 events). + funnel_mode: + $ref: '#/components/schemas/FunnelMode' + conversion_seconds: + type: integer + minimum: 1 + description: | + Maximum time window to complete the funnel, in seconds + (e.g. `86400` = 1 day, `604800` = 7 days). + funnel_paths_to_collect: + $ref: '#/components/schemas/FunnelPathsToCollect' + funnel_constant_properties: + type: array + items: + $ref: '#/components/schemas/MetricAggregationProperty' + description: Properties held constant across funnel steps. + funnel_compute_property: + $ref: '#/components/schemas/MetricAggregationProperty' + funnel_compute_property_function: + $ref: '#/components/schemas/FunnelComputePropertyFunction' + additionalProperties: false + MetricDefinitionUnknown: + type: object + required: + - metric_type + properties: + metric_type: + type: string + const: unknown + additionalProperties: false + description: | + Placeholder definition for metric types this API version does not model. + No type-specific fields are returned. + MetricKeyProperty: + type: object + required: + - property_type + - value + properties: + property_type: + type: string + value: + type: string + group_type: + type: string PageUrl: type: string minLength: 1 diff --git a/src/args.test.ts b/src/args.test.ts index d88dba9..cbc2e9c 100644 --- a/src/args.test.ts +++ b/src/args.test.ts @@ -1,8 +1,45 @@ import { describe, expect, it } from 'vitest'; -import { authGlobalOptionAliases, isFlagEnabled, parseArgs } from './args'; +import { + apiGlobalOptionAliases, + authGlobalOptionAliases, + isFlagEnabled, + parseArgs, +} from './args'; describe('parseArgs', () => { + it.each([ + ['skills', 'list', '--loginId', 'Person+cli@example.com'], + ['skills', 'get', '--loginId=Account-123', 'integrating-amplitude'], + ])('preserves the loginId value and command positionals: %s', (...argv) => { + const parsed = parseArgs(argv); + expect(parsed.flags.loginId).toBe( + argv[1] === 'list' ? 'Person+cli@example.com' : 'Account-123', + ); + expect(parsed.command).toEqual( + argv[1] === 'list' + ? ['skills', 'list'] + : ['skills', 'get', 'integrating-amplitude'], + ); + }); + + it('requires a value for --loginId', () => { + expect(() => parseArgs(['skills', 'list', '--loginId'])).toThrow( + 'argument missing', + ); + }); + + it('keeps --loginId out of API and auth command globals', () => { + expect(apiGlobalOptionAliases()).not.toContain('loginId'); + expect(authGlobalOptionAliases()).not.toContain('loginId'); + }); + + it('does not suggest --loginId for a typo', () => { + expect(() => parseArgs(['skills', 'list', '--loginID', 'x'])).toThrow( + /^unknown option '--loginID'$/, + ); + }); + it('separates command tokens from flags', () => { const { command, flags } = parseArgs(['flags', 'list', '--project', '123']); expect(command).toEqual(['flags', 'list']); @@ -173,3 +210,49 @@ describe('isFlagEnabled', () => { expect(isFlagEnabled(undefined)).toBe(false); }); }); + +describe('signup argument parsing', () => { + it('parses signup value options and explicit consent/mode switches', () => { + expect( + parseArgs([ + 'auth', + 'signup', + '--non-interactive', + '--email', + 'new@example.com', + '--full-name', + 'Alex Morgan', + '--accept-tos', + '--region', + 'us', + '--json', + ]), + ).toMatchObject({ + command: ['auth', 'signup'], + flags: { + 'non-interactive': true, + email: 'new@example.com', + 'full-name': 'Alex Morgan', + 'accept-tos': true, + region: 'us', + json: true, + }, + }); + }); + it.each(['email', 'full-name'])('requires a value for --%s', (name) => { + expect(() => parseArgs(['auth', 'signup', `--${name}`])).toThrow( + 'argument missing', + ); + }); + it('preserves explicit false mode and consent values', () => { + expect( + parseArgs([ + 'auth', + 'signup', + '--non-interactive=false', + '--accept-tos', + 'false', + ]).flags, + ).toMatchObject({ 'non-interactive': 'false', 'accept-tos': 'false' }); + }); +}); diff --git a/src/args.ts b/src/args.ts index 5445158..41f33e5 100644 --- a/src/args.ts +++ b/src/args.ts @@ -3,6 +3,7 @@ import { distance } from 'fastest-levenshtein'; import { usageError } from './cli-error'; import { CLI_OPERATIONS, type CliOperation } from './generated/cli-manifest'; +import { LOGIN_ID_FLAG } from './login-id'; export type FlagValue = boolean | string; @@ -72,6 +73,13 @@ const GLOBAL_OPTIONS = defineGlobalOptions([ onAuthCommands: false, visible: false, }, + { + aliases: [LOGIN_ID_FLAG], + valueRequirement: 'required', + onApiCommands: false, + onAuthCommands: false, + visible: false, + }, { aliases: ['yes'], valueRequirement: 'optional', @@ -159,6 +167,14 @@ const GLOBAL_OPTIONS = defineGlobalOptions([ }, ]); +// Parse bespoke signup inputs without making them global command options. +const AUTH_SIGNUP_OPTIONS: ParseableOption[] = [ + { aliases: ['email'], valueRequirement: 'required' }, + { aliases: ['full-name'], valueRequirement: 'required' }, + { aliases: ['accept-tos'], valueRequirement: 'optional' }, + { aliases: ['non-interactive'], valueRequirement: 'optional' }, +]; + /** Every alias any global flag answers to. */ export type GlobalOptionAlias = (typeof GLOBAL_OPTIONS)[number]['aliases'][number]; @@ -174,6 +190,14 @@ export function globalOptionAliases(): GlobalOptionAlias[] { return [...new Set(GLOBAL_OPTIONS.flatMap((option) => option.aliases))]; } +export function hiddenGlobalOptionAliases(): Set { + return new Set( + GLOBAL_OPTIONS.filter((option) => !option.visible).flatMap( + (option) => option.aliases, + ), + ); +} + /** * Global aliases meaningful on generated API commands. Excludes the * auth-flow-only globals, so `amp projects list --timeout 5` is rejected as an @@ -229,7 +253,7 @@ function defineOption( function optionDefinitions(): ParseableOption[] { const byAlias = new Map(); - for (const option of GLOBAL_OPTIONS) { + for (const option of [...GLOBAL_OPTIONS, ...AUTH_SIGNUP_OPTIONS]) { for (const alias of option.aliases) { byAlias.set(alias, option.valueRequirement); } @@ -385,11 +409,7 @@ function withUnknownOptionSuggestion( return message; } - const hiddenAliases = new Set( - GLOBAL_OPTIONS.filter((option) => !option.visible).flatMap( - (option) => option.aliases, - ), - ); + const hiddenAliases = hiddenGlobalOptionAliases(); const knownAliases = suggestionAliases(commandArgs).filter( (alias) => !hiddenAliases.has(alias), ); diff --git a/src/auth-flag-coverage.test.ts b/src/auth-flag-coverage.test.ts index 08ce6fa..553a755 100644 --- a/src/auth-flag-coverage.test.ts +++ b/src/auth-flag-coverage.test.ts @@ -25,7 +25,11 @@ import { buildCatalog } from './catalog'; * guard. */ -const SCANNED_FILES = ['auth-commands.ts', 'credential-resolver.ts']; +const SCANNED_FILES = [ + 'auth-commands.ts', + 'auth-signup.ts', + 'credential-resolver.ts', +]; const SCANNED_SOURCE = SCANNED_FILES.map((file) => readFileSync(join(__dirname, file), 'utf8'), diff --git a/src/auth-signup.test.ts b/src/auth-signup.test.ts new file mode 100644 index 0000000..6163985 --- /dev/null +++ b/src/auth-signup.test.ts @@ -0,0 +1,557 @@ +import { mkdtempSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type FlagValue } from './args'; +import { runAuthLoginPoll } from './auth-commands'; +import { type AuthSignupDeps, runAuthSignup } from './auth-signup'; +import { type AnonymousRequest } from './authToken'; +import { ENV_BASE_URLS } from './config'; +import { + emptyStore, + loadStore, + type Profile, + saveStore, + setDefault, + setProfile, + updateStore, +} from './credential-store'; +import { + emptyPending, + getPending, + loadPending, + savePending, + setPending, +} from './pending-store'; +import { SIGNUP_DOCUMENT_URLS } from './signup-response'; + +const NOW = Date.parse('2026-09-30T12:00:00.000Z'); +const token = { + access_token: 'secret-access', + refresh_token: 'secret-refresh', + id_token: 'secret-id', + token_type: 'Bearer', + expires_in: 3600, +}; +const flags: Record = { + 'non-interactive': true, + email: 'new@example.com', + 'full-name': 'Alex Morgan', + region: 'us', + 'accept-tos': true, +}; +function deviceResponse(interval?: number) { + return { + type: 'requires_auth', + requires_auth: { + type: 'device_authorization', + device_authorization: { + device_code: 'secret-device', + user_code: 'ABCD-EFGH', + verification_uri: 'https://auth.example.com/device', + verification_uri_complete: + 'https://auth.example.com/device?user_code=ABCD-EFGH', + expires_in: 600, + ...(interval === undefined ? {} : { interval }), + access_token: 'secret-extra', + }, + }, + }; +} + +describe('auth signup', () => { + let dir: string; + let path: string; + let pendingPath: string; + let request: ReturnType>; + const originalExit = process.exitCode; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'amp-signup-')); + path = join(dir, 'credentials.json'); + pendingPath = join(dir, 'pending.json'); + request = vi.fn(async () => ({ + status: 200, + body: { type: 'credentials', credentials: token }, + })); + process.exitCode = 0; + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + process.exitCode = originalExit; + }); + const deps = (extra: AuthSignupDeps = {}): AuthSignupDeps => ({ + path, + pendingPath, + request, + now: () => NOW, + ...extra, + }); + function seedProfile() { + const profile: Profile = { + base_url: ENV_BASE_URLS.prod, + credential: { type: 'pat', pat: 'previous-secret' }, + saved_at: new Date(NOW - 1000).toISOString(), + store: 'file', + }; + const store = setDefault( + setProfile(setProfile(emptyStore(), 'active', profile), 'other', profile), + 'active', + ); + saveStore(store, path); + return store; + } + function seedPending() { + const pending = setPending(emptyPending(), 'active', { + device_code: 'previous-device', + base_url: ENV_BASE_URLS.prod, + expires_at: new Date(NOW + 1000).toISOString(), + interval: 5, + started_at: new Date(NOW - 1000).toISOString(), + }); + savePending(pending, pendingPath); + return pending; + } + + it('saves and activates immediate credentials with an acceptance receipt and no secrets', async () => { + const result = await runAuthSignup(flags, deps()); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.exitCode).toBe(0); + expect(result.output).toMatchObject({ + status: 'authorized', + message: + 'Authentication complete. You can now run authenticated CLI commands', + data: { + profile: 'default', + base_url: ENV_BASE_URLS.prod, + terms_acceptance: SIGNUP_DOCUMENT_URLS, + }, + }); + expect(loadStore(path).default).toBe('default'); + expect(loadStore(path).profiles.default.credential).toMatchObject({ + access_token: token.access_token, + refresh_token: token.refresh_token, + }); + expect(statSync(path).mode % 0o1000).toBe(0o600); + for (const secret of ['secret-access', 'secret-refresh', 'secret-id']) + expect(JSON.stringify(result)).not.toContain(secret); + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith('POST', '/v1/auth/signup', { + email: flags.email, + full_name: flags['full-name'], + terms_acceptance: { + terms_of_service: { + url: SIGNUP_DOCUMENT_URLS.terms_of_service, + accepted: true, + }, + privacy_policy: { + url: SIGNUP_DOCUMENT_URLS.privacy_policy, + accepted: true, + }, + }, + }); + }); + + it.each([undefined, false, 'false'])( + 'requires explicit noninteractive mode: %s', + async (mode) => { + const input: Record = { ...flags, json: true }; + if (mode === undefined) delete input['non-interactive']; + else input['non-interactive'] = mode; + const result = await runAuthSignup(input, deps()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.exitCode).toBe(2); + expect(request).not.toHaveBeenCalled(); + expect(loadStore(path)).toEqual(emptyStore()); + }, + ); + + it('collects missing email and consent locally with both document notices', async () => { + const result = await runAuthSignup( + { 'non-interactive': true, region: 'us', yes: true }, + deps(), + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.exitCode).toBe(2); + expect(result.output).toMatchObject({ + status: 'needs_information', + data: { + schema: { required: ['email', 'terms_acceptance'] }, + supported_flags: { email: '--email', terms_acceptance: '--accept-tos' }, + }, + }); + expect(JSON.stringify(result)).toContain( + SIGNUP_DOCUMENT_URLS.terms_of_service, + ); + expect(JSON.stringify(result)).toContain( + SIGNUP_DOCUMENT_URLS.privacy_policy, + ); + expect(request).not.toHaveBeenCalled(); + }); + + it('includes a complete signup example when consent is missing', async () => { + const result = await runAuthSignup( + { ...flags, 'accept-tos': false }, + deps(), + ); + expect(result.ok).toBe(true); + if (!result.ok || result.output.status !== 'needs_information') return; + expect(result.output.data.schema).toMatchObject({ + properties: { + terms_acceptance: { + description: expect.stringContaining( + 'amp auth signup --non-interactive --email alex@example.com --full-name "Alex Morgan" --region us --accept-tos', + ), + }, + }, + }); + expect(request).not.toHaveBeenCalled(); + }); + + it.each([false, 'false'])( + 'does not infer acceptance with --accept-tos=%s', + async (consent) => { + const result = await runAuthSignup( + { ...flags, 'accept-tos': consent, yes: true }, + deps(), + ); + expect(result.ok).toBe(true); + if (result.ok) expect(result.output.status).toBe('needs_information'); + expect(request).not.toHaveBeenCalled(); + }, + ); + + const invalidInputs: Record[] = [ + { email: 'bad' }, + { email: true }, + { 'full-name': true }, + { 'full-name': ' ' }, + ]; + it.each(invalidInputs)( + 'rejects invalid request inputs before the network: %j', + async (input) => { + const result = await runAuthSignup({ ...flags, ...input }, deps()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.exitCode).toBe(2); + expect(request).not.toHaveBeenCalled(); + }, + ); + + it('sends an omitted full name and preserves future server requirements', async () => { + const before = seedProfile(); + const input = { ...flags }; + delete input['full-name']; + const schema = { + type: 'object', + required: ['company_domain', 'full_name'], + properties: { + company_domain: { description: 'Work domain', type: 'string' }, + }, + $defs: { nested: { enum: ['one', 'two'] } }, + 'x-extra': { opaque: true }, + }; + request.mockResolvedValue({ + status: 200, + body: { type: 'needs_information', needs_information: { schema } }, + }); + const result = await runAuthSignup(input, deps()); + expect(result.ok).toBe(true); + if (result.ok) + expect(result.output).toMatchObject({ + status: 'needs_information', + data: { schema, supported_flags: { full_name: '--full-name' } }, + }); + expect(request).toHaveBeenCalledTimes(1); + expect(request.mock.calls[0][2]).not.toHaveProperty('full_name'); + expect(loadStore(path)).toEqual(before); + expect(result).not.toHaveProperty('output.data.terms_acceptance'); + if (result.ok) + expect(result.output.message).not.toMatch(/newer client|upgrade/i); + }); + + it.each(['cold', 'active', 'named'])( + 'requires an explicit target with a %s profile', + async (selection) => { + if (selection !== 'cold') { + seedProfile(); + await updateStore(path, (store) => + setProfile(store, 'active', { + ...store.profiles.active, + base_url: ENV_BASE_URLS['prod-eu'], + }), + ); + } + const before = loadStore(path); + const input: Record = { ...flags }; + delete input.region; + if (selection === 'named') input.profile = 'active'; + const result = await runAuthSignup(input, deps()); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.exitCode).toBe(2); + expect(result.error.message).toContain('--region'); + } + expect(request).not.toHaveBeenCalled(); + expect(loadStore(path)).toEqual(before); + expect(loadPending(pendingPath)).toEqual(emptyPending()); + }, + ); + + it.each([ + { + flag: 'env', + value: 'dev', + target: 'https://developer-api.dev.amplitude.com', + }, + { + flag: 'base-url', + value: 'http://localhost:3036', + target: 'http://localhost:3036', + }, + ])( + 'accepts an explicit development target --$flag', + async ({ flag, value, target }) => { + const input: Record = { ...flags }; + input[flag] = value; + delete input.region; + const result = await runAuthSignup(input, deps()); + expect(result.ok).toBe(true); + if (!result.ok || result.output.status !== 'authorized') return; + expect(result.output.data.base_url).toBe(target); + expect(loadStore(path).profiles.default.base_url).toBe(target); + expect(request).toHaveBeenCalledTimes(1); + }, + ); + + it('replaces same-endpoint credentials without force with an explicit region', async () => { + const before = seedProfile(); + seedPending(); + const result = await runAuthSignup(flags, deps()); + expect(result.ok).toBe(true); + const saved = loadStore(path); + expect(saved.default).toBe('active'); + expect(saved.profiles.active.credential).toMatchObject({ + access_token: token.access_token, + }); + expect(saved.profiles.other).toEqual(before.profiles.other); + expect(getPending(loadPending(pendingPath), 'active')).toBeUndefined(); + }); + + it('creates a requested named profile only on immediate success', async () => { + const before = seedProfile(); + const result = await runAuthSignup( + { ...flags, profile: 'new-name' }, + deps(), + ); + expect(result.ok).toBe(true); + expect(loadStore(path).default).toBe('new-name'); + expect(loadStore(path).profiles.active).toEqual(before.profiles.active); + }); + + it('refuses a retarget without force before any signup request', async () => { + const before = seedProfile(); + const pending = seedPending(); + const result = await runAuthSignup({ ...flags, region: 'eu' }, deps()); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.exitCode).toBe(2); + expect(result.error.message).toContain('--force'); + } + expect(request).not.toHaveBeenCalled(); + expect(loadStore(path)).toEqual(before); + expect(loadPending(pendingPath)).toEqual(pending); + }); + + it.each([undefined, 7])( + 'persists the supplied challenge with interval %s without replacing credentials', + async (interval) => { + const before = seedProfile(); + seedPending(); + request.mockResolvedValue({ + status: 200, + body: deviceResponse(interval), + }); + const result = await runAuthSignup( + { ...flags, region: 'eu', force: true }, + deps(), + ); + expect(result.ok).toBe(true); + if (!result.ok || result.output.status !== 'requires_auth') return; + expect(result.exitCode).toBe(0); + expect(result.output.data).toMatchObject({ + profile: 'active', + base_url: ENV_BASE_URLS['prod-eu'], + replaced_pending: true, + continuation: { + argv: [ + 'amp', + 'auth', + 'login', + 'poll', + '--profile', + 'active', + '--json', + ], + }, + }); + expect(result.output.message).not.toMatch(/replac/i); + expect(result.output.message).not.toMatch(/--timeout|poll waits/i); + expect(JSON.stringify(result)).not.toContain('secret-device'); + expect(JSON.stringify(result)).not.toContain('secret-extra'); + expect(result.output.data).not.toHaveProperty('terms_acceptance'); + expect(result.output.data).not.toHaveProperty('interval'); + expect(loadStore(path)).toEqual(before); + expect(getPending(loadPending(pendingPath), 'active')).toEqual({ + device_code: 'secret-device', + base_url: ENV_BASE_URLS['prod-eu'], + interval: interval ?? 5, + started_at: new Date(NOW).toISOString(), + expires_at: new Date(NOW + 600000).toISOString(), + }); + expect(statSync(pendingPath).mode % 0o1000).toBe(0o600); + expect(request).toHaveBeenCalledTimes(1); + }, + ); + + it('keeps a new profile inactive until the returned challenge is authorized', async () => { + const before = seedProfile(); + request.mockResolvedValue({ status: 200, body: deviceResponse(5) }); + await runAuthSignup({ ...flags, profile: 'new-name' }, deps()); + expect(loadStore(path)).toEqual(before); + expect(getPending(loadPending(pendingPath), 'new-name')).toBeDefined(); + }); + + it('continues signup using the existing poll command and original device code', async () => { + const before = seedProfile(); + request.mockResolvedValue({ status: 200, body: deviceResponse(5) }); + await runAuthSignup({ ...flags, region: 'eu', force: true }, deps()); + expect(loadStore(path)).toEqual(before); + const pollRequest = vi.fn(async () => ({ + status: 200, + body: token, + })); + const stdout = vi.fn<(line: string) => void>(); + await runAuthLoginPoll( + { profile: 'active', timeout: '0' }, + { path, pendingPath, request: pollRequest, stdout, now: () => NOW }, + ); + expect(pollRequest).toHaveBeenCalledWith( + 'POST', + '/v1/auth/token', + expect.objectContaining({ device_code: 'secret-device' }), + ); + expect(loadStore(path).profiles.active).toMatchObject({ + base_url: ENV_BASE_URLS['prod-eu'], + credential: { access_token: token.access_token }, + }); + expect(loadStore(path).default).toBe('active'); + expect(stdout).toHaveBeenCalledWith(expect.stringContaining('authorized')); + }); + + it('preserves concurrent unrelated profile writes', async () => { + seedProfile(); + request.mockImplementation(async () => { + await updateStore(path, (latest) => + setProfile(latest, 'concurrent', latest.profiles.other), + ); + return { status: 200, body: { type: 'credentials', credentials: token } }; + }); + await runAuthSignup(flags, deps()); + expect(loadStore(path).profiles.concurrent).toBeDefined(); + }); + + it('returns recovery after real credential persistence fails, preserving prior state', async () => { + const before = seedProfile(); + const pending = seedPending(); + const save = vi.fn().mockImplementation(() => { + throw new Error('secret-access'); + }); + const persist: typeof updateStore = (file, mutate) => + updateStore(file, mutate, { save }); + const result = await runAuthSignup(flags, deps({ updateStore: persist })); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.message).toContain('amp auth login'); + expect(JSON.stringify(result)).not.toContain('secret-access'); + expect(loadStore(path)).toEqual(before); + expect(loadPending(pendingPath)).toEqual(pending); + }); + + it('reports challenge storage failure without replacing stored credentials', async () => { + const before = seedProfile(); + const pending = seedPending(); + request.mockResolvedValue({ status: 200, body: deviceResponse() }); + const persist = vi.fn().mockImplementation(() => { + throw new Error('secret-device'); + }); + const result = await runAuthSignup(flags, deps({ savePending: persist })); + expect(result.ok).toBe(false); + expect(JSON.stringify(result)).not.toContain('secret-device'); + expect(loadStore(path)).toEqual(before); + expect(loadPending(pendingPath)).toEqual(pending); + }); + + it('reports cleanup failure accurately after credentials have already been saved', async () => { + seedProfile(); + seedPending(); + const persist = vi.fn().mockImplementation(() => { + throw new Error('cleanup'); + }); + const result = await runAuthSignup(flags, deps({ savePending: persist })); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.message).toContain('saved'); + expect(loadStore(path).profiles.active.credential).toMatchObject({ + access_token: token.access_token, + }); + }); + + it.each([ + { + status: 200, + body: { + type: 'credentials', + credentials: { access_token: 'secret-access' }, + }, + }, + { status: 502, body: 'secret-access' }, + ])('safely rejects malformed responses: %j', async (response) => { + const before = seedProfile(); + request.mockResolvedValue(response); + const result = await runAuthSignup(flags, deps()); + expect(result.ok).toBe(false); + expect(JSON.stringify(result)).not.toContain('secret-access'); + expect(request).toHaveBeenCalledTimes(1); + expect(loadStore(path)).toEqual(before); + }); + + it('does not retry or echo details after transport failure', async () => { + request.mockRejectedValue(new Error('secret-access')); + const result = await runAuthSignup(flags, deps()); + expect(result.ok).toBe(false); + expect(request).toHaveBeenCalledTimes(1); + expect(JSON.stringify(result)).not.toContain('secret-access'); + }); + + it('returns the exact server-owned outdated-document error', async () => { + request.mockResolvedValue({ + status: 400, + body: { + type: 'error', + error: { + code: 'terms_documents_outdated', + message: 'Update this client.', + }, + }, + }); + const result = await runAuthSignup(flags, deps()); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe('Update this client.'); + expect(result.error.errorCode).toBe('terms_documents_outdated'); + expect(result.error.exitCode).toBe(2); + expect(result.error.hint).toBeUndefined(); + } + }); +}); diff --git a/src/auth-signup.ts b/src/auth-signup.ts new file mode 100644 index 0000000..2a554be --- /dev/null +++ b/src/auth-signup.ts @@ -0,0 +1,350 @@ +import { type FlagValue, isFlagEnabled, stringFlag } from './args'; +import { oauthCredentialFromToken, targetProfileName } from './auth-commands'; +import { type AnonymousRequest, createAnonymousRequest } from './authToken'; +import { + CliError, + exitCodeForErrorCode, + transportError, + usageError, +} from './cli-error'; +import { resolveExplicitBaseUrl } from './config'; +import { + getProfile, + loadStore, + type Profile, + setDefault, + setProfile, + updateStore, +} from './credential-store'; +import { + gcExpiredPending, + getPending, + loadPending, + type PendingEntry, + pendingPath as defaultPendingPath, + removePending, + savePending, + setPending, +} from './pending-store'; +import { + SIGNUP_DOCUMENT_URLS, + type SignupOutput, + signupErrorFromResponse, + signupPollContinuation, + signupRequestSchema, + signupResponseSchema, + signupSupportedFlags, +} from './signup-response'; + +export interface AuthSignupDeps { + path?: string; + pendingPath?: string; + now?: () => number; + request?: AnonymousRequest; + updateStore?: typeof updateStore; + savePending?: typeof savePending; +} +export type AuthSignupResult = + | { ok: true; exitCode: 0 | 2; output: SignupOutput } + | { ok: false; error: CliError }; + +const suppliedInputsSchema = signupRequestSchema + .omit({ terms_acceptance: true }) + .partial(); + +function needsInformation(schema: Record): AuthSignupResult { + return { + ok: true, + exitCode: 2, + output: { + status: 'needs_information', + message: + 'Signup requires additional information. Supply the required information and run signup again.', + data: { schema, supported_flags: signupSupportedFlags(schema) }, + }, + }; +} + +function persistenceError(code: string, message: string): AuthSignupResult { + return { + ok: false, + error: new CliError({ + errorCode: code, + message, + exitCode: exitCodeForErrorCode(code), + }), + }; +} + +/** Sign up once, persist the resulting authentication state, and return a safe outcome. */ +export async function runAuthSignup( + flags: Record, + deps: AuthSignupDeps = {}, +): Promise { + if (!isFlagEnabled(flags['non-interactive'])) { + return { + ok: false, + error: usageError( + 'Signup requires --non-interactive. Run `amp auth signup --help` for supported inputs.', + ), + }; + } + + let profileName: string; + let baseUrl: string; + try { + const target = resolveExplicitBaseUrl({ + baseUrlFlag: stringFlag(flags, ['base-url']), + envFlag: stringFlag(flags, ['env']), + regionFlag: stringFlag(flags, ['region']), + }); + if (!target) { + return { + ok: false, + error: usageError('Signup requires --region .'), + }; + } + baseUrl = target; + const store = loadStore(deps.path); + profileName = targetProfileName(store, stringFlag(flags, ['profile'])); + const existing = getProfile(store, profileName); + if ( + existing && + existing.base_url !== baseUrl && + !isFlagEnabled(flags.force) + ) { + return { + ok: false, + error: usageError( + `Profile "${profileName}" targets ${existing.base_url}; refusing to silently retarget it to ${baseUrl}. Use a different --profile, or pass --force to overwrite.`, + ), + }; + } + } catch (error) { + return { + ok: false, + error: + error instanceof CliError + ? error + : new CliError({ + message: 'Could not read signup profile configuration.', + errorCode: 'profile_read_failed', + exitCode: 1, + }), + }; + } + + const inputs = suppliedInputsSchema.safeParse({ + email: flags.email, + full_name: flags['full-name'], + }); + if (!inputs.success) { + const fields = [ + ...new Set(inputs.error.issues.map((issue) => issue.path.join('.'))), + ]; + return { + ok: false, + error: usageError( + `Invalid signup input: ${fields.join(', ')}. Run \`amp auth signup --help\`.`, + ), + }; + } + const accepted = isFlagEnabled(flags['accept-tos']); + if (!inputs.data.email || !accepted) { + const required: string[] = []; + const properties: Record = {}; + if (!inputs.data.email) { + required.push('email'); + properties.email = { + type: 'string', + format: 'email', + description: 'Email address', + }; + } + if (!accepted) { + required.push('terms_acceptance'); + properties.terms_acceptance = { + type: 'object', + description: + 'Agree to the Terms of Service and acknowledge the Privacy Policy with --accept-tos. Example: `amp auth signup --non-interactive --email alex@example.com --full-name "Alex Morgan" --region us --accept-tos`.', + documents: [ + { + kind: 'terms_of_service', + title: 'Terms of Service', + url: SIGNUP_DOCUMENT_URLS.terms_of_service, + }, + { + kind: 'privacy_policy', + title: 'Privacy Policy', + url: SIGNUP_DOCUMENT_URLS.privacy_policy, + }, + ], + }; + } + return needsInformation({ type: 'object', properties, required }); + } + + const body = signupRequestSchema.safeParse({ + email: inputs.data.email, + ...(inputs.data.full_name === undefined + ? {} + : { full_name: inputs.data.full_name }), + terms_acceptance: { + terms_of_service: { + url: SIGNUP_DOCUMENT_URLS.terms_of_service, + accepted: true, + }, + privacy_policy: { + url: SIGNUP_DOCUMENT_URLS.privacy_policy, + accepted: true, + }, + }, + }); + if (!body.success) + return { + ok: false, + error: usageError( + 'Invalid signup request. Run `amp auth signup --help`.', + ), + }; + + const request = deps.request ?? createAnonymousRequest(baseUrl); + let result: Awaited>; + try { + result = await request('POST', '/v1/auth/signup', body.data); + } catch { + return { + ok: false, + error: transportError( + `Could not complete the signup request at ${baseUrl}.`, + ), + }; + } + if (result.status < 200 || result.status >= 300) { + return { + ok: false, + error: signupErrorFromResponse(result.status, '', result.body), + }; + } + const parsed = signupResponseSchema.safeParse(result.body); + if (!parsed.success) + return { + ok: false, + error: new CliError({ + message: 'The signup service returned an unexpected response.', + errorCode: 'unexpected_response', + exitCode: 1, + }), + }; + + const response = parsed.data; + const now = deps.now ?? Date.now; + const pPath = deps.pendingPath ?? defaultPendingPath(); + const persistPending = deps.savePending ?? savePending; + switch (response.type) { + case 'needs_information': + return needsInformation(response.needs_information.schema); + case 'credentials': { + const savedAt = now(); + const profile: Profile = { + base_url: baseUrl, + credential: oauthCredentialFromToken(response.credentials, savedAt), + saved_at: new Date(savedAt).toISOString(), + store: 'file', + }; + try { + await (deps.updateStore ?? updateStore)(deps.path, (latest) => + setDefault(setProfile(latest, profileName, profile), profileName), + ); + } catch { + return persistenceError( + 'credential_save_failed', + 'Signup completed, but credentials could not be saved. Authenticate with `amp auth login start` using the same --profile and target, then run `amp auth login poll`.', + ); + } + try { + const pending = loadPending(pPath); + if (getPending(pending, profileName)) + persistPending(removePending(pending, profileName), pPath); + } catch { + return persistenceError( + 'pending_cleanup_failed', + 'Signup credentials were saved and the profile is active, but obsolete pending authorization could not be cleared. Check local file permissions before starting another login.', + ); + } + return { + ok: true, + exitCode: 0, + output: { + status: 'authorized', + message: + 'Authentication complete. You can now run authenticated CLI commands', + data: { + profile: profileName, + base_url: baseUrl, + terms_acceptance: { + terms_of_service: body.data.terms_acceptance.terms_of_service.url, + privacy_policy: body.data.terms_acceptance.privacy_policy.url, + }, + }, + }, + }; + } + + case 'requires_auth': { + const device = response.requires_auth.device_authorization; + const startedAt = now(); + const pending = gcExpiredPending(loadPending(pPath), startedAt); + const replacedPending = getPending(pending, profileName) !== undefined; + const entry: PendingEntry = { + device_code: device.device_code, + base_url: baseUrl, + expires_at: new Date( + startedAt + device.expires_in * 1000, + ).toISOString(), + interval: device.interval ?? 5, + started_at: new Date(startedAt).toISOString(), + }; + try { + persistPending(setPending(pending, profileName, entry), pPath); + } catch { + return persistenceError( + 'pending_save_failed', + 'Could not save the signup authorization challenge. Check local file permissions and start signup or login again.', + ); + } + const continuation = signupPollContinuation(profileName); + const verificationUrl = + device.verification_uri_complete ?? device.verification_uri; + return { + ok: true, + exitCode: 0, + output: { + status: 'requires_auth', + message: `Open ${verificationUrl} and confirm code ${device.user_code}, then run \`${continuation.command}\`.`, + data: { + profile: profileName, + base_url: baseUrl, + user_code: device.user_code, + verification_uri: device.verification_uri, + verification_uri_complete: device.verification_uri_complete, + expires_at: entry.expires_at, + expires_in_seconds: device.expires_in, + replaced_pending: replacedPending, + continuation, + }, + }, + }; + } + default: + response satisfies never; + return { + ok: false, + error: new CliError({ + message: 'The signup service returned an unexpected response.', + errorCode: 'unexpected_response', + exitCode: 1, + }), + }; + } +} diff --git a/src/catalog.test.ts b/src/catalog.test.ts index 6a8ae1a..dd1ab2e 100644 --- a/src/catalog.test.ts +++ b/src/catalog.test.ts @@ -143,6 +143,7 @@ describe('buildCatalog — auth/meta commands', () => { // so this parity list is deliberately auth/meta-command-scoped. const commands = new Set(buildCatalog().map((c) => c.command.join(' '))); for (const routed of [ + 'auth signup', 'auth login', 'auth login start', 'auth login poll', @@ -196,3 +197,24 @@ describe('buildCatalog — auth/meta commands', () => { expect(get?.description).not.toContain('--save'); }); }); + +describe('signup catalog', () => { + it('declares signup inputs and links both consent documents', () => { + const signup = buildCatalog().find( + (c) => c.command.join(' ') === 'auth signup', + ); + expect(signup).toBeDefined(); + expect(signup?.description).toContain('https://amplitude.com/terms'); + expect(signup?.description).toContain('https://amplitude.com/privacy'); + expect(signup?.flags).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'non-interactive', required: true }), + expect.objectContaining({ name: 'email', required: true }), + expect.objectContaining({ name: 'accept-tos', required: false }), + ]), + ); + expect(signup?.globalFlags).not.toContain('open'); + expect(signup?.globalFlags).not.toContain('scope'); + expect(signup?.globalFlags).not.toContain('token'); + }); +}); diff --git a/src/catalog.ts b/src/catalog.ts index af3e902..3aae294 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -7,6 +7,7 @@ import { import { DEFAULT_POLL_TIMEOUT_SECONDS } from './config'; import type { CliOperation } from './generated/cli-manifest'; import { CLI_OPERATIONS } from './generated/cli-manifest'; +import { SIGNUP_DOCUMENT_URLS } from './signup-response'; export interface CatalogFlag { name: string; @@ -53,13 +54,18 @@ export interface CatalogCommand { } /** A group's commands before their shared `globalFlags` set is attached. */ -type BespokeCommand = Omit; +type BespokeCommand = Omit & { + globalFlags?: GlobalOptionAlias[]; +}; function withGlobalFlags( commands: BespokeCommand[], globalFlags: GlobalOptionAlias[], ): CatalogCommand[] { - return commands.map((command) => ({ ...command, globalFlags })); + return commands.map((command) => ({ + ...command, + globalFlags: command.globalFlags ?? globalFlags, + })); } export function findCatalogCommand( @@ -93,11 +99,12 @@ const GROUP_ORDER: Partial> = { 'user-properties': 4, flags: 5, charts: 6, - heatmaps: 7, - zoning: 8, - 'destination-types': 9, - destinations: 10, - skills: 11, + metrics: 7, + heatmaps: 8, + zoning: 9, + 'destination-types': 10, + destinations: 11, + skills: 12, auth: 20, }; @@ -109,6 +116,7 @@ const GROUP_DESCRIPTIONS: Partial> = { 'user-properties': 'User properties', flags: 'Feature flags', charts: 'Saved and ad-hoc charts', + metrics: 'Saved metric definitions', heatmaps: 'Click map and scroll map data for a page URL', zoning: 'Zone metrics for a page URL', 'destination-types': 'Available destination partners and their schemas', @@ -116,6 +124,7 @@ const GROUP_DESCRIPTIONS: Partial> = { skills: 'Skill documents for implementing Amplitude, intended for AI agents. Run amp skills list to discover them.', auth: 'Authentication and credential profiles', + 'mcp-analytics': 'MCP taxonomy, topics, and episodes', }; const EXAMPLES: Partial> = { @@ -208,6 +217,84 @@ function apiCommands(): CatalogCommand[] { // auth-flag-coverage.test.ts enforces that handlers only read declared/global // flags, so the misplaced-flag reject can't false-positive. const AUTH_COMMANDS: BespokeCommand[] = [ + { + command: ['auth', 'signup'], + summary: 'Sign up for an Amplitude account', + description: `Requires --non-interactive. Saves and activates credentials on immediate authorization, or records the supplied device challenge for auth login poll. Outstanding inputs are returned as needs_information. Signup does not prompt, open a browser, or poll automatically. Exit 0 can indicate requires_auth; inspect status to distinguish it from authorized. --accept-tos agrees to the Terms of Service (${SIGNUP_DOCUMENT_URLS.terms_of_service}) and acknowledges the Privacy Policy (${SIGNUP_DOCUMENT_URLS.privacy_policy}).`, + group: 'auth', + order: GROUP_ORDER.auth, + globalFlags: [ + 'json', + 'profile', + 'region', + 'force', + 'env', + 'base-url', + 'help', + 'h', + 'version', + 'v', + ], + flags: [ + { + name: 'non-interactive', + aliases: ['non-interactive'], + type: 'boolean', + required: true, + description: + 'Return outstanding inputs or authorization without prompts or polling.', + }, + { + name: 'email', + aliases: ['email'], + type: 'string', + required: true, + description: 'Email address for signup.', + }, + { + name: 'full-name', + aliases: ['full-name'], + type: 'string', + required: false, + description: + 'Full name; supply it to complete eligible immediate signup.', + }, + { + name: 'accept-tos', + aliases: ['accept-tos'], + type: 'boolean', + required: false, + description: `Agree to Terms of Service (${SIGNUP_DOCUMENT_URLS.terms_of_service}) and acknowledge Privacy Policy (${SIGNUP_DOCUMENT_URLS.privacy_policy}).`, + }, + { + name: 'region', + aliases: ['region'], + type: 'string', + required: true, + enum: ['us', 'eu'], + description: 'Target region, required for signup.', + }, + { + name: 'profile', + aliases: ['profile'], + type: 'string', + required: false, + description: + 'Profile name; omit to use the active profile, or default when none is active.', + }, + { + name: 'force', + aliases: ['force'], + type: 'boolean', + required: false, + description: + 'Permit changing an existing profile endpoint on successful authentication. Credentials remain until saved.', + }, + ], + example: + 'amp auth signup --non-interactive --email alex@example.com --full-name "Alex Morgan" --region us --accept-tos --json', + requiredScopes: [], + }, { command: ['auth', 'login'], summary: 'Authenticate and save a profile (interactive device flow)', @@ -282,7 +369,7 @@ const AUTH_COMMANDS: BespokeCommand[] = [ 'Complete the device flow started by `login start` (emits a JSON envelope)', group: 'auth', order: GROUP_ORDER.auth, - description: `Completes the device authorization started by \`amp auth login start\`: returns the credential once the code is confirmed, or reports that authorization is still pending or the code has expired. Each call blocks up to ~${DEFAULT_POLL_TIMEOUT_SECONDS}s (\`--timeout \`, or \`--timeout 0\` for a single check).`, + description: `Completes the device authorization returned by \`amp auth login start\` or \`amp auth signup\`: returns the credential once the code is confirmed, or reports that authorization is still pending or the code has expired. Each call blocks up to ~${DEFAULT_POLL_TIMEOUT_SECONDS}s (\`--timeout \`, or \`--timeout 0\` for a single check).`, flags: [ { name: 'profile', diff --git a/src/cli.test.ts b/src/cli.test.ts index 8136cd0..b1c3f19 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -2,10 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { parseArgs } from './args'; import * as authCommands from './auth-commands'; +import * as signupCommands from './auth-signup'; import { isHelpRequested, isVersionRequested, main } from './cli'; +import { CliError, usageError } from './cli-error'; import * as credentialStore from './credential-store'; import { formatVersion } from './help'; +vi.mock('./auth-signup', () => ({ runAuthSignup: vi.fn() })); + vi.mock('./auth-commands', async () => { const actual = await vi.importActual('./auth-commands'); @@ -71,6 +75,11 @@ describe('main routing', () => { } beforeEach(() => { + vi.mocked(signupCommands.runAuthSignup).mockReset(); + vi.mocked(signupCommands.runAuthSignup).mockResolvedValue({ + ok: false, + error: usageError('Signup requires --non-interactive.'), + }); logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); stdoutIsTTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); @@ -104,6 +113,175 @@ describe('main routing', () => { vi.unstubAllGlobals(); }); + it('requires explicit signup mode through the handler', async () => { + await runWith(['auth', 'signup']); + expect(process.exitCode).toBe(2); + expect(errSpy).toHaveBeenCalledWith( + expect.stringContaining('--non-interactive'), + ); + expect(logSpy).not.toHaveBeenCalled(); + expect(signupCommands.runAuthSignup).toHaveBeenCalledWith({}); + }); + + it.each([ + ['email', 'new@example.com'], + ['full-name', 'Alex'], + ['accept-tos', 'true'], + ['non-interactive', 'true'], + ])('rejects signup-only --%s on login', async (name, value) => { + await runWith(['auth', 'login', `--${name}`, value]); + expect(process.exitCode).toBe(2); + expect(authCommands.runAuthLoginStart).not.toHaveBeenCalled(); + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining(`--${name}`)); + }); + + it.each([ + 'scope', + 'open', + 'token', + 'timeout', + 'flow', + 'with-token', + 'dry-run', + 'body-json', + ])( + 'rejects unsupported --%s on signup instead of ignoring it', + async (name) => { + await runWith(['auth', 'signup', `--${name}=unused`]); + expect(process.exitCode).toBe(2); + expect(signupCommands.runAuthSignup).not.toHaveBeenCalled(); + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining(`--${name}`)); + }, + ); + + it('rejects signup-only flags on a generated API command', async () => { + await runWith(['projects', 'list', '--accept-tos']); + expect(process.exitCode).toBe(2); + expect(errSpy).toHaveBeenCalledWith( + expect.stringContaining('--accept-tos'), + ); + }); + + it('routes signup and renders missing information on stdout with exit 2', async () => { + vi.mocked(signupCommands.runAuthSignup).mockResolvedValue({ + ok: true, + exitCode: 2, + output: { + status: 'needs_information', + message: 'More information required.', + data: { + schema: { required: ['future_field'], 'x-extra': { nested: true } }, + supported_flags: {}, + }, + }, + }); + await runWith(['auth', 'signup', '--non-interactive', '--region', 'us']); + expect(process.exitCode).toBe(2); + expect(errSpy).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledTimes(1); + const output = JSON.parse(String(logSpy.mock.calls[0][0])); + expect(output.status).toBe('needs_information'); + expect(output.data.schema['x-extra']).toEqual({ nested: true }); + expect(String(logSpy.mock.calls[0][0])).not.toContain('\n'); + }); + + it('returns requires_auth on stdout with exit zero', async () => { + vi.mocked(signupCommands.runAuthSignup).mockResolvedValue({ + ok: true, + exitCode: 0, + output: { + status: 'requires_auth', + message: 'Authorization required.', + data: { + profile: 'default', + base_url: 'https://developer-api.amplitude.com', + user_code: 'ABCD', + verification_uri: 'https://auth.example.com/device', + expires_at: '2026-09-30T12:10:00.000Z', + expires_in_seconds: 600, + replaced_pending: false, + continuation: { + command: "amp auth login poll --profile 'default' --json", + argv: [ + 'amp', + 'auth', + 'login', + 'poll', + '--profile', + 'default', + '--json', + ], + }, + }, + }, + }); + await runWith(['auth', 'signup', '--non-interactive']); + expect(process.exitCode).toBe(0); + expect(errSpy).not.toHaveBeenCalled(); + expect(JSON.parse(String(logSpy.mock.calls[0][0])).status).toBe( + 'requires_auth', + ); + }); + + it('prints human signup data at a terminal and honors --json', async () => { + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + }); + vi.mocked(signupCommands.runAuthSignup).mockResolvedValue({ + ok: true, + exitCode: 0, + output: { + status: 'authorized', + message: 'Authenticated.', + data: { + profile: 'default', + base_url: 'https://developer-api.amplitude.com', + terms_acceptance: { + terms_of_service: 'https://amplitude.com/terms', + privacy_policy: 'https://amplitude.com/privacy', + }, + }, + }, + }); + await runWith(['auth', 'signup', '--non-interactive']); + expect(logSpy.mock.calls[0][0]).toMatch(/^Authenticated\./); + await runWith(['auth', 'signup', '--non-interactive', '--json']); + expect(JSON.parse(String(logSpy.mock.calls[1][0])).status).toBe( + 'authorized', + ); + expect(process.exitCode).toBe(0); + }); + + it('renders public signup errors only on stderr through the shared formatter', async () => { + vi.mocked(signupCommands.runAuthSignup).mockResolvedValue({ + ok: false, + error: new CliError({ + message: 'Update this client.', + errorCode: 'terms_documents_outdated', + httpStatus: 400, + exitCode: 2, + }), + }); + await runWith(['auth', 'signup', '--non-interactive', '--json']); + expect(process.exitCode).toBe(2); + expect(logSpy).not.toHaveBeenCalled(); + const output = JSON.parse(String(errSpy.mock.calls[0][0])); + expect(output.message).toBe('Update this client.'); + expect(output.error.error_code).toBe('terms_documents_outdated'); + expect(output.error.hint).toBeUndefined(); + }); + + it('renders signup help without mode selection or invoking signup', async () => { + await runWith(['auth', 'signup', '--help']); + const output = JSON.parse(String(logSpy.mock.calls[0][0])); + expect(output.command).toBe('auth signup'); + expect(JSON.stringify(output)).toContain('https://amplitude.com/terms'); + expect(JSON.stringify(output)).toContain('https://amplitude.com/privacy'); + expect(signupCommands.runAuthSignup).not.toHaveBeenCalled(); + expect(errSpy).not.toHaveBeenCalled(); + }); + it('prints the version for both `version` and --version', async () => { await runWith(['version']); await runWith(['--version']); diff --git a/src/cli.ts b/src/cli.ts index f5030b7..f958c8d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,6 +17,7 @@ import { runAuthUse, runLogout, } from './auth-commands'; +import { runAuthSignup } from './auth-signup'; import { buildCatalog, isSkillsGetCommand } from './catalog'; import { CliError, @@ -30,9 +31,10 @@ import { printCommandHelp, printGlobalHelp, } from './help'; -import { shouldUseJsonOutput } from './output'; +import { formatJsonOutput, shouldUseJsonOutput } from './output'; import { assertFlagsAllowed } from './request'; import { runOperation } from './run'; +import { formatSignupText } from './signup-response'; import { assertSkillsFlags, runSkillsGet, @@ -62,7 +64,10 @@ function assertKnownAuthFlags( candidate.command.every((part, index) => part === command[index]), ); const catalogAliases = entry?.flags.flatMap((flag) => flag.aliases) ?? []; - const allowed = new Set([...authGlobalOptionAliases(), ...catalogAliases]); + const allowed = new Set([ + ...(entry?.globalFlags ?? authGlobalOptionAliases()), + ...catalogAliases, + ]); assertFlagsAllowed(allowed, `amp ${command.join(' ')}`, flags); } @@ -150,6 +155,26 @@ export async function main(): Promise { } if (command[0] === 'auth') { + if (command.length === 2 && command[1] === 'signup') { + assertKnownAuthFlags(['auth', 'signup'], flags); + const result = await runAuthSignup(flags); + if (!result.ok) { + console.error( + wantsJson + ? formatErrorEnvelope(result.error, isTTY) + : formatErrorText(result.error), + ); + process.exitCode = result.error.exitCode; + return; + } + console.log( + wantsJson + ? formatJsonOutput(result.output, isTTY) + : formatSignupText(result.output), + ); + process.exitCode = result.exitCode; + return; + } if ( command.length === 3 && command[1] === 'login' && diff --git a/src/generated/cli-manifest.ts b/src/generated/cli-manifest.ts index b6d2d33..3be99af 100644 --- a/src/generated/cli-manifest.ts +++ b/src/generated/cli-manifest.ts @@ -105,6 +105,213 @@ export const CLI_OPERATIONS = [ ], body: [], }, + { + command: ['mcp-analytics', 'servers', 'list'], + method: 'GET', + operationId: 'listMcpAnalyticsServers', + path: '/v1/projects/{project_id}/mcp-analytics/servers', + requiredScopes: ['analytics:read'], + summary: 'List MCP Analytics servers', + successStatus: 200, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + ], + body: [], + }, + { + command: ['mcp-analytics', 'taxonomy', 'get'], + method: 'GET', + operationId: 'getMcpAnalyticsTaxonomy', + path: '/v1/projects/{project_id}/mcp-analytics/servers/{server_id}/taxonomy', + requiredScopes: ['analytics:read'], + summary: 'Get the active MCP taxonomy', + successStatus: 200, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'server_id', + in: 'path', + required: true, + aliases: ['server', 'server-id'], + type: 'string', + }, + { + name: 'parent_id', + in: 'query', + required: false, + aliases: ['parent', 'parent-id'], + type: 'string', + }, + ], + body: [], + }, + { + command: ['mcp-analytics', 'topics', 'get'], + method: 'GET', + operationId: 'getMcpAnalyticsTopic', + path: '/v1/projects/{project_id}/mcp-analytics/servers/{server_id}/topics/{topic_id}', + requiredScopes: ['analytics:read'], + summary: 'Get an MCP taxonomy topic', + successStatus: 200, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'server_id', + in: 'path', + required: true, + aliases: ['server', 'server-id'], + type: 'string', + }, + { + name: 'topic_id', + in: 'path', + required: true, + aliases: ['topic', 'topic-id'], + type: 'string', + }, + ], + body: [], + }, + { + command: ['mcp-analytics', 'episodes', 'list'], + method: 'GET', + operationId: 'listMcpAnalyticsEpisodes', + path: '/v1/projects/{project_id}/mcp-analytics/servers/{server_id}/topics/{topic_id}/episodes', + requiredScopes: ['analytics:read'], + summary: 'List episodes for an MCP topic', + successStatus: 200, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'server_id', + in: 'path', + required: true, + aliases: ['server', 'server-id'], + type: 'string', + }, + { + name: 'topic_id', + in: 'path', + required: true, + aliases: ['topic', 'topic-id'], + type: 'string', + }, + { + name: 'started_after', + in: 'query', + required: false, + aliases: ['started-after'], + type: 'string', + }, + { + name: 'started_before', + in: 'query', + required: false, + aliases: ['started-before'], + type: 'string', + }, + { + name: 'lane', + in: 'query', + required: false, + aliases: ['lane'], + type: 'string', + }, + { + name: 'client_name', + in: 'query', + required: false, + aliases: ['client-name'], + type: 'string', + }, + { + name: 'auth_type', + in: 'query', + required: false, + aliases: ['auth-type'], + type: 'string', + }, + { + name: 'has_error', + in: 'query', + required: false, + aliases: ['has-error'], + type: 'boolean', + }, + { + name: 'is_thrash', + in: 'query', + required: false, + aliases: ['is-thrash'], + type: 'boolean', + }, + { + name: 'cursor', + in: 'query', + required: false, + aliases: ['cursor'], + type: 'string', + }, + { + name: 'limit', + in: 'query', + required: false, + aliases: ['limit'], + type: 'integer', + }, + ], + body: [], + }, + { + command: ['mcp-analytics', 'episodes', 'get'], + method: 'GET', + operationId: 'getMcpAnalyticsEpisode', + path: '/v1/projects/{project_id}/mcp-analytics/episodes/{episode_id}', + requiredScopes: ['analytics:read'], + summary: 'Get an MCP episode', + successStatus: 200, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'episode_id', + in: 'path', + required: true, + aliases: ['episode', 'episode-id'], + type: 'string', + }, + ], + body: [], + }, { command: ['context'], method: 'GET', @@ -1181,6 +1388,86 @@ export const CLI_OPERATIONS = [ }, ], }, + { + command: ['metrics', 'list'], + method: 'GET', + operationId: 'listMetrics', + path: '/v1/projects/{project_id}/metrics', + requiredScopes: ['analytics:read'], + summary: 'List metrics', + 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', + }, + { + name: 'metric_type', + in: 'query', + required: false, + aliases: ['metric-type'], + type: 'string', + }, + { + name: 'sort', + in: 'query', + required: false, + aliases: ['sort'], + type: 'string', + }, + ], + body: [], + }, + { + command: ['metrics', 'get'], + method: 'GET', + operationId: 'getMetric', + path: '/v1/projects/{project_id}/metrics/{metric_id}', + requiredScopes: ['analytics:read'], + summary: 'Get metric', + successStatus: 200, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'metric_id', + in: 'path', + required: true, + aliases: ['metric', 'metric-id'], + type: 'string', + }, + ], + body: [], + }, { command: ['heatmaps', 'click-map'], method: 'POST', @@ -1979,7 +2266,15 @@ export const CLI_OPERATIONS = [ type: 'string', }, ], - body: [], + body: [ + { + name: 'test_event_payload', + required: false, + aliases: ['test-event-payload'], + type: 'string', + nullable: false, + }, + ], }, { command: ['destinations', 'generate-snowflake-rsa-key'], diff --git a/src/help-drift.test.ts b/src/help-drift.test.ts index cad01b9..b7f5595 100644 --- a/src/help-drift.test.ts +++ b/src/help-drift.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { buildCatalog } from './catalog'; -import { listProductSurfaces, printCommandHelp } from './help'; +import { authHelpText, listProductSurfaces, printCommandHelp } from './help'; function capture(fn: () => void): string { const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); @@ -67,3 +67,15 @@ describe('help drift guard', () => { } }); }); + +describe('signup help discovery', () => { + it('renders boolean signup arguments as switches in usage', () => { + const text = capture(() => printCommandHelp(['auth', 'signup'])); + expect(text).toContain('--non-interactive'); + expect(text).not.toContain('--non-interactive '); + expect(text).not.toContain('--accept-tos '); + }); + it('appears in the human auth overview', () => { + expect(authHelpText()).toContain('amp auth signup'); + }); +}); diff --git a/src/help.test.ts b/src/help.test.ts index 5cd6491..c84aa31 100644 --- a/src/help.test.ts +++ b/src/help.test.ts @@ -14,6 +14,23 @@ import { import { normalizeWhitespace } from './output'; describe('help', () => { + it.each([false, true])( + 'hides --loginId throughout help (JSON: %s)', + (json) => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + try { + printGlobalHelp(); + printCommandHelp(['skills'], { json, isTTY: false }); + printCommandHelp(['skills', 'list'], { json, isTTY: false }); + printCommandHelp(['skills', 'get'], { json, isTTY: false }); + expect(log.mock.calls.flat().join('\n')).not.toContain('loginId'); + expect(JSON.stringify(serializeCatalog())).not.toContain('loginId'); + } finally { + log.mockRestore(); + } + }, + ); + it('discovers both ingestion checks with their canonical usage', () => { const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); try { @@ -250,12 +267,14 @@ describe('help', () => { 'user-properties', 'flags', 'charts', + 'metrics', 'heatmaps', 'zoning', 'destination-types', 'destinations', 'skills', 'agent-analytics', + 'mcp-analytics', ]); expect(groups.indexOf('auth')).toBeGreaterThan(groups.indexOf('charts')); }); diff --git a/src/help.ts b/src/help.ts index cec5fa0..373ad0d 100644 --- a/src/help.ts +++ b/src/help.ts @@ -186,6 +186,7 @@ export function printGlobalHelp(): void { console.log(`amp ${CLI_VERSION} — Amplitude Developer API CLI Usage: + amp auth signup --non-interactive Sign up for an Amplitude account amp auth login --region Authenticate and save a profile amp auth Inspect, switch, or print credentials amp logout [--profile |--all] Remove a profile (or all) @@ -227,6 +228,7 @@ export function authHelpText(): string { 'Authenticate and manage saved credential profiles.', '', 'Usage:', + ' amp auth signup --non-interactive Sign up for an Amplitude account', ' amp auth login --region Device flow → save + activate a profile', ' amp auth login Re-authenticate the active profile', ' amp auth pat --with-token --region Save a supplied PAT (stdin/prompt)', @@ -243,8 +245,8 @@ export function authHelpText(): string { ' TOKEN=$(amp auth token)', '', 'Creating a profile is force-explicit: --region is required;', - '--profile is optional — omit it and the CLI targets the', - 'implicit `default` profile, created on first use.', + '--profile is optional — omit it and the CLI targets the active', + 'profile, or the implicit `default` profile when none is active.', 'Re-authenticating an existing profile reuses the target 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', @@ -326,11 +328,13 @@ function printCatalogCommandHelp(entry: CatalogCommand): void { lines.push('', normalizeWhitespace(entry.description)); } lines.push('', 'Usage:'); - const usageFlags = entry.flags.map((f) => - f.required - ? `--${f.aliases[0]} <${f.name}>` - : `[--${f.aliases[0]} <${f.name}>]`, - ); + const usageFlags = entry.flags.map((flag) => { + const argument = + flag.type === 'boolean' + ? `--${flag.aliases[0]}` + : `--${flag.aliases[0]} <${flag.name}>`; + return flag.required ? argument : `[${argument}]`; + }); const requiredUsageFlags = (entry.requiredUsageGlobalFlags ?? []).map( (flag) => `--${flag.alias} <${flag.valueName}>`, ); diff --git a/src/launcher.test.ts b/src/launcher.test.ts new file mode 100644 index 0000000..3d7241b --- /dev/null +++ b/src/launcher.test.ts @@ -0,0 +1,70 @@ +import { spawnSync } from 'node:child_process'; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +describe('amp launcher', () => { + let directory: string; + let launcher: string; + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'amp-launcher-')); + mkdirSync(join(directory, 'bin')); + launcher = join(directory, 'bin/amp.cjs'); + copyFileSync(resolve('bin/amp.cjs'), launcher); + }); + + afterEach(() => rmSync(directory, { recursive: true, force: true })); + + it('explains how to build when installed before compilation', () => { + const result = spawnSync(process.execPath, [launcher, '--version'], { + encoding: 'utf8', + }); + expect(result.status).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('Run pnpm build'); + }); + + it('preserves arguments and exit status when invoking the built CLI', () => { + mkdirSync(join(directory, 'dist')); + writeFileSync( + join(directory, 'dist/cli.js'), + ` + exports.main = async () => { + console.log(JSON.stringify(process.argv.slice(2))); + process.exitCode = 2; + }; + `, + ); + const result = spawnSync( + process.execPath, + [launcher, 'projects', '--help'], + { encoding: 'utf8' }, + ); + expect(result.status).toBe(2); + expect(JSON.parse(result.stdout)).toEqual(['projects', '--help']); + expect(result.stderr).toBe(''); + }); + + it('does not misreport a missing runtime dependency as an unbuilt CLI', () => { + mkdirSync(join(directory, 'dist')); + writeFileSync( + join(directory, 'dist/cli.js'), + "require('./missing-dependency');", + ); + const result = spawnSync(process.execPath, [launcher], { + encoding: 'utf8', + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain('missing-dependency'); + expect(result.stderr).not.toContain('has not been built'); + }); +}); diff --git a/src/login-id.test.ts b/src/login-id.test.ts new file mode 100644 index 0000000..2cfd0e4 --- /dev/null +++ b/src/login-id.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { loginIdHeader } from './login-id'; + +describe('loginIdHeader', () => { + it('trims surrounding whitespace without changing case', () => { + expect(loginIdHeader(' Person+CLI@example.com ')).toEqual({ + 'Amp-Login-Id': 'Person+CLI@example.com', + }); + }); + + it('passes a non-email identifier unchanged', () => { + expect(loginIdHeader('Account-123')).toEqual({ + 'Amp-Login-Id': 'Account-123', + }); + }); + + it.each([undefined, '', ' ', 'x\r\ny', 'x\u0000y', '用户', 'x'.repeat(1025)])( + 'omits missing or unsafe values: %j', + (loginId) => { + expect(loginIdHeader(loginId)).toEqual({}); + }, + ); +}); diff --git a/src/login-id.ts b/src/login-id.ts new file mode 100644 index 0000000..6eb0edd --- /dev/null +++ b/src/login-id.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +export const LOGIN_ID_FLAG = 'loginId'; +export const AMP_LOGIN_ID_HEADER = 'Amp-Login-Id'; + +const loginIdHeaderSchema = z + .string() + .trim() + .min(1) + .max(1024) + .regex(/^[\x20-\x7e]+$/); + +export function loginIdHeader( + loginId: string | undefined, +): Record { + const parsed = loginIdHeaderSchema.safeParse(loginId); + return parsed.success ? { [AMP_LOGIN_ID_HEADER]: parsed.data } : {}; +} diff --git a/src/request.test.ts b/src/request.test.ts index 3673a88..53f6c11 100644 --- a/src/request.test.ts +++ b/src/request.test.ts @@ -26,6 +26,12 @@ function request(command: string[], argv: string[]) { } describe('manifest invariants', () => { + it('rejects the skills-only --loginId on API commands', () => { + expect(() => + request(['projects', 'list'], ['--loginId', 'Account-123']), + ).toThrow('Unknown flag --loginId'); + }); + it('builds saved insight list/get requests and preserves project-level filtering', () => { const list = ['agent-analytics', 'insights', 'list']; expect(operation(list).requiredScopes).toEqual(['analytics:read']); diff --git a/src/request.ts b/src/request.ts index 50a489e..38d8f96 100644 --- a/src/request.ts +++ b/src/request.ts @@ -5,6 +5,7 @@ import { apiGlobalOptionAliases, flagValue, hasFlag, + hiddenGlobalOptionAliases, isMissingRequiredValue, nearestAlias, stringFlag, @@ -211,7 +212,11 @@ export function assertFlagsAllowed( continue; } - const suggestion = nearestAlias(key, allowed); + const hiddenAliases = hiddenGlobalOptionAliases(); + const suggestion = nearestAlias( + key, + [...allowed].filter((alias) => !hiddenAliases.has(alias)), + ); const hint = suggestion ? ` Did you mean --${suggestion}?` : ''; throw usageError(`Unknown flag --${key} for \`${commandLabel}\`.${hint}`); } diff --git a/src/signup-response.test.ts b/src/signup-response.test.ts new file mode 100644 index 0000000..e7fa870 --- /dev/null +++ b/src/signup-response.test.ts @@ -0,0 +1,157 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + formatSignupText, + SIGNUP_DOCUMENT_URLS, + signupErrorFromResponse, + signupPollContinuation, + signupRequestSchema, + signupResponseSchema, + signupSupportedFlags, +} from './signup-response'; + +const requiredSchema = { + type: 'object', + required: ['company_domain', 'full_name'], + properties: { + company_domain: { type: 'string', description: 'Work domain' }, + }, + $defs: { address: { properties: { country: { enum: ['US', 'DE'] } } } }, + 'x-example': { nested: ['value'] }, +}; + +describe('signup response contract', () => { + it.each([ + { label: 'Terms of Service', url: SIGNUP_DOCUMENT_URLS.terms_of_service }, + { label: 'Privacy Policy', url: SIGNUP_DOCUMENT_URLS.privacy_policy }, + ])( + 'keeps the README $label link aligned with submitted consent', + ({ label, url }) => { + const readme = readFileSync(join(__dirname, '..', 'README.md'), 'utf8'); + const link = readme.match(new RegExp(`\\[${label}\\]\\(([^)]+)\\)`)); + expect(link?.[1]).toBe(url); + }, + ); + + it('retains unfamiliar required fields and nested metadata', () => { + const body = { + type: 'needs_information', + needs_information: { schema: requiredSchema }, + }; + expect(signupResponseSchema.parse(body)).toEqual(body); + expect(signupSupportedFlags(requiredSchema)).toEqual({ + full_name: '--full-name', + }); + }); + + it('keeps the same full schema in human output', () => { + const text = formatSignupText({ + status: 'needs_information', + message: 'More information required.', + data: { + schema: requiredSchema, + supported_flags: { full_name: '--full-name' }, + }, + }); + expect(text).toContain( + JSON.stringify(requiredSchema, null, 2).split('\n')[1].trim(), + ); + expect(text).toContain('Work domain'); + expect(text).toContain('x-example'); + expect(text).toContain('$defs'); + }); + + it.each([ + { type: 'credentials', credentials: { access_token: 'sensitive' } }, + { + type: 'requires_auth', + requires_auth: { type: 'device_authorization', device_authorization: {} }, + }, + { type: 'needs_information', needs_information: { schema: [] } }, + { type: 'new_outcome' }, + ])('rejects malformed success %j', (input) => { + expect(signupResponseSchema.safeParse(input).success).toBe(false); + }); + + it('preserves server error code/message without a locally generated hint', () => { + const error = signupErrorFromResponse(400, 'Bad Request', { + type: 'error', + error: { + code: 'terms_documents_outdated', + message: 'Update this client.', + }, + }); + expect(error.message).toBe('Update this client.'); + expect(error.errorCode).toBe('terms_documents_outdated'); + expect(error.exitCode).toBe(2); + expect(error.hint).toBeUndefined(); + }); + + it.each([ + 'secret-access', + { access_token: 'secret-access' }, + { type: 'error', error: { code: 'bad' } }, + ])('does not echo a malformed error body: %j', (body) => { + const error = signupErrorFromResponse(502, 'Bad Gateway', body); + expect(error.exitCode).toBe(5); + expect(error.message).not.toContain('secret-access'); + expect(error.detail).toBeUndefined(); + }); + + it('retains common API problem details', () => { + const error = signupErrorFromResponse(400, 'Bad Request', { + type: 'about:blank', + title: 'Invalid Request', + status: 400, + error_code: 'validation_error', + detail: 'Supply a valid email address.', + }); + expect(error.errorCode).toBe('validation_error'); + expect(error.message).toContain('Supply a valid email address.'); + }); + + it('returns exact argv and shell-quotes the profile', () => { + const profile = "team $(command) 'quoted'"; + const continuation = signupPollContinuation(profile); + expect(continuation.argv).toEqual([ + 'amp', + 'auth', + 'login', + 'poll', + '--profile', + profile, + '--json', + ]); + expect(continuation.command).toBe( + "amp auth login poll --profile 'team $(command) '\\''quoted'\\''' --json", + ); + }); + + it('requires both explicit canonical acceptance documents in the request', () => { + const request = signupRequestSchema.safeParse({ + email: ' Alex@Example.com ', + terms_acceptance: { + terms_of_service: { + url: SIGNUP_DOCUMENT_URLS.terms_of_service, + accepted: true, + }, + privacy_policy: { + url: SIGNUP_DOCUMENT_URLS.privacy_policy, + accepted: true, + }, + }, + }); + expect(request.success).toBe(true); + if (request.success) expect(request.data.email).toBe('alex@example.com'); + expect( + signupRequestSchema.safeParse({ email: 'alex@example.com' }).success, + ).toBe(false); + expect( + signupRequestSchema.safeParse({ email: 'alex@example.com', answers: {} }) + .success, + ).toBe(false); + }); +}); diff --git a/src/signup-response.ts b/src/signup-response.ts new file mode 100644 index 0000000..ea902c4 --- /dev/null +++ b/src/signup-response.ts @@ -0,0 +1,175 @@ +import { z } from 'zod'; + +import { + CliError, + cliErrorFromResponse, + exitCodeForErrorCode, +} from './cli-error'; +import { + type DeviceAuthorizationResponse, + deviceAuthorizationResponseSchema, + tokenResponseSchema, +} from './oauthResponseSchemas'; +import { jsonRecordSchema } from './schemas'; + +// Stable references shipped with the client; the server validates them. +export const SIGNUP_DOCUMENT_URLS = { + terms_of_service: 'https://amplitude.com/terms', + privacy_policy: 'https://amplitude.com/privacy', +}; + +export const signupRequestSchema = z.strictObject({ + email: z.string().trim().toLowerCase().email(), + full_name: z.string().trim().min(1).optional(), + terms_acceptance: z.object({ + terms_of_service: z.object({ + url: z.literal(SIGNUP_DOCUMENT_URLS.terms_of_service), + accepted: z.literal(true), + }), + privacy_policy: z.object({ + url: z.literal(SIGNUP_DOCUMENT_URLS.privacy_policy), + accepted: z.literal(true), + }), + }), +}); +export type SignupRequest = z.infer; + +export const signupResponseSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('credentials'), + credentials: tokenResponseSchema, + }), + z.object({ + type: z.literal('requires_auth'), + requires_auth: z.object({ + type: z.literal('device_authorization'), + device_authorization: deviceAuthorizationResponseSchema, + }), + }), + z.object({ + type: z.literal('needs_information'), + needs_information: z.object({ schema: jsonRecordSchema }), + }), +]); +export type SignupResponse = z.infer; + +const signupErrorSchema = z.object({ + type: z.literal('error'), + error: z.object({ code: z.string().min(1), message: z.string().min(1) }), +}); + +// Signup has its own error envelope; other failures can use the standard API +// problem format. +const signupProblemSchema = z.object({ + title: z.string(), + error_code: z.string(), + status: z.number().int().optional(), + detail: z.string().nullable().optional(), + validation_errors: z + .array( + z.object({ + field: z.string(), + message: z.string(), + code: z.string(), + }), + ) + .nullable() + .optional(), +}); + +export function signupErrorFromResponse( + status: number, + statusText: string, + body: unknown, +): CliError { + const parsed = signupErrorSchema.safeParse(body); + if (parsed.success) { + return new CliError({ + message: parsed.data.error.message, + errorCode: parsed.data.error.code, + httpStatus: status, + exitCode: exitCodeForErrorCode(parsed.data.error.code, status), + }); + } + const problem = signupProblemSchema.safeParse(body); + if (problem.success) + return cliErrorFromResponse(status, statusText, problem.data); + return new CliError({ + message: `Signup failed (HTTP ${status}).`, + errorCode: 'request_failed', + httpStatus: status, + exitCode: exitCodeForErrorCode('request_failed', status), + }); +} + +interface SignupTarget { + profile: string; + base_url: string; +} + +/** Locally constructed outcomes; API input remains schema-validated above. */ +export type SignupOutput = + | { + status: 'authorized'; + message: string; + data: SignupTarget & { terms_acceptance: typeof SIGNUP_DOCUMENT_URLS }; + } + | { + status: 'requires_auth'; + message: string; + data: SignupTarget & + Pick< + DeviceAuthorizationResponse, + 'user_code' | 'verification_uri' | 'verification_uri_complete' + > & { + expires_at: string; + expires_in_seconds: number; + replaced_pending: boolean; + continuation: ReturnType; + }; + } + | { + status: 'needs_information'; + message: string; + data: { + schema: Record; + supported_flags: Record; + }; + }; + +const requiredFieldsSchema = z.array(z.string()); +const supportedFlags: Record = { + email: '--email', + full_name: '--full-name', + terms_acceptance: '--accept-tos', +}; + +export function signupSupportedFlags( + schema: Record, +): Record { + const required = requiredFieldsSchema.safeParse(schema.required); + if (!required.success) return {}; + return Object.fromEntries( + required.data.flatMap((field) => { + const flag = Object.hasOwn(supportedFlags, field) + ? supportedFlags[field] + : undefined; + return flag ? [[field, flag]] : []; + }), + ); +} + +export function signupPollContinuation(profile: string): { + command: string; + argv: string[]; +} { + const quoted = `'${profile.replaceAll("'", "'\\''")}'`; + return { + command: `amp auth login poll --profile ${quoted} --json`, + argv: ['amp', 'auth', 'login', 'poll', '--profile', profile, '--json'], + }; +} + +export function formatSignupText(output: SignupOutput): string { + return `${output.message}\n\n${JSON.stringify(output.data, null, 2)}`; +} diff --git a/src/skills-commands.test.ts b/src/skills-commands.test.ts index f8f7c45..fa961a2 100644 --- a/src/skills-commands.test.ts +++ b/src/skills-commands.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { parseArgs } from './args'; import { CliError, transportError } from './cli-error'; import { DEFAULT_API_BASE_URL } from './config'; import { @@ -15,6 +16,19 @@ const INDEX = [ ]; describe('assertSkillsFlags', () => { + it.each(['list', 'get'])('accepts --loginId on %s', (verb) => { + const parsed = parseArgs(['skills', verb, '--loginId', 'Account-123']); + expect(() => + assertSkillsFlags(verb === 'list' ? 'list' : 'get', parsed.flags), + ).not.toThrow(); + }); + + it('does not suggest --loginId for a misplaced parsed flag', () => { + expect(() => assertSkillsFlags('list', { limit: '1' })).toThrow( + /^Unknown flag --limit for `amp skills list`\.$/, + ); + }); + it('accepts only output and standard flags on list', () => { expect(() => assertSkillsFlags('list', { @@ -82,6 +96,58 @@ describe('assertSkillsFlags', () => { }); }); +describe('skills loginId header', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each(['list', 'get'])( + 'sends --loginId on %s without changing its output', + async (verb) => { + const document = '# Skill\n'; + const fetchMock = vi.fn(async () => + verb === 'list' + ? new Response(JSON.stringify({ data: INDEX })) + : new Response(document, { + headers: { 'Content-Type': 'text/markdown' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + const chunks: string[] = []; + const flags = parseArgs([ + 'skills', + verb, + '--region', + 'us', + '--loginId', + ' Person+CLI@example.com ', + ]).flags; + assertSkillsFlags(verb === 'list' ? 'list' : 'get', flags); + const deps = { + path: EMPTY_STORE_PATH, + isTTY: false, + write: (chunk: string) => chunks.push(chunk), + }; + + if (verb === 'list') await runSkillsList(flags, deps); + else await runSkillsGet('integrating-amplitude', flags, deps); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe( + verb === 'list' + ? 'https://developer-api.amplitude.com/v1/skills' + : 'https://developer-api.amplitude.com/v1/skills/integrating-amplitude', + ); + const headers = new Headers(init?.headers); + expect(headers.get('amp-login-id')).toBe('Person+CLI@example.com'); + expect(headers.has('authorization')).toBe(false); + expect(chunks.join('')).toBe( + verb === 'list' ? `${JSON.stringify({ data: INDEX })}\n` : document, + ); + }, + ); +}); + describe('runSkillsList', () => { it('fetches the US catalog when there is no default profile', async () => { const fetchIndex = vi.fn(async () => INDEX); @@ -96,7 +162,7 @@ describe('runSkillsList', () => { }, ); - expect(fetchIndex).toHaveBeenCalledWith(DEFAULT_API_BASE_URL); + expect(fetchIndex).toHaveBeenCalledWith(DEFAULT_API_BASE_URL, undefined); }); it('routes --region to its catalog endpoint', async () => { @@ -114,6 +180,7 @@ describe('runSkillsList', () => { expect(fetchIndex).toHaveBeenCalledWith( 'https://developer-api.eu.amplitude.com', + undefined, ); }); @@ -556,6 +623,7 @@ describe('runSkillsGet', () => { expect(fetchDocument).toHaveBeenCalledWith( 'https://developer-api.eu.amplitude.com', 'integrating-amplitude', + undefined, ); }); diff --git a/src/skills-commands.ts b/src/skills-commands.ts index 5ef6678..e21f41f 100644 --- a/src/skills-commands.ts +++ b/src/skills-commands.ts @@ -1,6 +1,7 @@ -import { type FlagValue, isFlagEnabled } from './args'; +import { type FlagValue, isFlagEnabled, stringFlag } from './args'; import { SKILLS_GLOBAL_FLAGS, type SkillsVerb } from './catalog'; import { CliError, transportError, usageError } from './cli-error'; +import { LOGIN_ID_FLAG } from './login-id'; import { formatJsonOutput, normalizeWhitespace, @@ -34,6 +35,7 @@ export function assertSkillsFlags( const label = `amp skills ${verb}`; const allowed = new Set(SKILLS_GLOBAL_FLAGS[verb]); if (verb === 'list') allowed.add('region'); + allowed.add(LOGIN_ID_FLAG); assertFlagsAllowed(allowed, label, flags); } @@ -42,8 +44,8 @@ export interface SkillsCommandDeps { write?: (chunk: string) => void; isTTY?: boolean; path?: string; - fetchIndex?: (baseUrl: string) => Promise; - fetchDocument?: (baseUrl: string, name: string) => Promise; + fetchIndex?: typeof fetchSkillIndex; + fetchDocument?: typeof fetchSkillDocument; materialize?: (name: string, document: string) => MaterializedSkill; } @@ -88,6 +90,7 @@ export async function runSkillsList( const fetchIndex = deps.fetchIndex ?? fetchSkillIndex; const index = await fetchIndex( resolveSkillsListBaseUrl(flags, { path: deps.path }), + stringFlag(flags, [LOGIN_ID_FLAG]), ); if (shouldUseJsonOutput({ jsonFlag: isFlagEnabled(flags.json), isTTY })) { @@ -114,6 +117,7 @@ export async function runSkillsGet( const document = await fetchDocument( resolveSkillsGetBaseUrl(flags, { path: deps.path }), name, + stringFlag(flags, [LOGIN_ID_FLAG]), ); const isTTY = deps.isTTY ?? Boolean(process.stdout.isTTY); diff --git a/src/skills-registry.test.ts b/src/skills-registry.test.ts index d31f3b7..1935972 100644 --- a/src/skills-registry.test.ts +++ b/src/skills-registry.test.ts @@ -285,6 +285,24 @@ describe('fetchSkillIndex', () => { vi.unstubAllGlobals(); }); + it.each(['', ' ', 'x\r\ny', 'x\u0000y', '用户', 'x'.repeat(1025)])( + 'omits an invalid loginId without failing the index request: %j', + async (loginId) => { + const fetchMock = vi.fn( + async () => new Response(JSON.stringify({ data: [] })), + ); + vi.stubGlobal('fetch', fetchMock); + + await expect( + fetchSkillIndex('https://example.test', loginId), + ).resolves.toEqual([]); + + const headers = new Headers(fetchMock.mock.calls[0][1]?.headers); + expect(headers.has('amp-login-id')).toBe(false); + expect(headers.get('amp-device-id')).toBe('device-123'); + }, + ); + it('classifies a malformed 200 body as upstream_error rather than throwing a raw parse error', async () => { vi.stubGlobal( 'fetch', diff --git a/src/skills-registry.ts b/src/skills-registry.ts index f0a64ff..67c52d0 100644 --- a/src/skills-registry.ts +++ b/src/skills-registry.ts @@ -12,6 +12,7 @@ import { DEFAULT_API_BASE_URL, resolveNamedBaseUrl } from './config'; import { loadStore } from './credential-store'; import { asProblem } from './errors'; import { CLI_VERSION } from './help'; +import { loginIdHeader } from './login-id'; export interface SkillIndexEntry { name: string; @@ -26,6 +27,7 @@ const skillIndexResponseSchema = z.object({ }), ), }); + const SKILLS_LIST_TRANSPORT_HINT = 'Check network connectivity and retry. Inspect the active profile endpoint with `amp auth status` or switch profiles with `amp auth use `.'; const SKILLS_GET_TRANSPORT_HINT = @@ -80,16 +82,18 @@ export function resolveSkillsGetBaseUrl( return stripTrailingSlash(resolved); } -async function get(url: string, hint: string): Promise { +async function get( + url: string, + hint: string, + loginId: string | undefined, +): Promise { try { - // `parseApiClient` on the server reads the leading `product/version` token - // into analytics dimensions, so this is what makes retrievals attributable - // to a CLI version. Matches the header authToken.ts already sends. return await fetch(url, { headers: { Accept: '*/*', 'User-Agent': `amp-cli/${CLI_VERSION}`, ...deviceIdHeader(), + ...loginIdHeader(loginId), }, }); } catch { @@ -155,9 +159,10 @@ function tryParseJson(body: string): unknown { export async function fetchSkillIndex( baseUrl: string, + loginId?: string, ): Promise { const url = `${baseUrl}/v1/skills`; - const response = await get(url, SKILLS_LIST_TRANSPORT_HINT); + const response = await get(url, SKILLS_LIST_TRANSPORT_HINT, loginId); const body = await response.text(); if (!response.ok) { @@ -183,9 +188,10 @@ function isMarkdown(contentType: string | null): boolean { export async function fetchSkillDocument( baseUrl: string, name: string, + loginId?: string, ): Promise { const url = `${baseUrl}/v1/skills/${encodeURIComponent(name)}`; - const response = await get(url, SKILLS_GET_TRANSPORT_HINT); + const response = await get(url, SKILLS_GET_TRANSPORT_HINT, loginId); const body = await response.text(); if (!response.ok) {