diff --git a/README.md b/README.md index 44ec4fe..675b6e2 100644 --- a/README.md +++ b/README.md @@ -41,36 +41,37 @@ npx @amplitude/developer-cli help ## Authentication -Run `amp auth login` to authenticate via the OAuth device flow. Creating a -profile is force-explicit — name it and pick its environment: +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: ```bash -amp auth login --profile prod --env prod # device flow → save + activate +amp auth login --region us # device flow → "default" profile amp auth status # active credential, type, expiry amp context ``` -Profiles bind a credential to an environment (its `base_url`), so a staging -token can never be sent to prod. Add more and switch between them: +Profiles bind a credential to a region (its `base_url`), so an EU token can +never be sent to prod. Name one explicitly when you want more than one: ```bash -amp auth login --profile staging --env staging # activates staging +amp auth login --profile eu --region eu # activates the EU profile amp auth use prod # switch back (no re-auth) amp auth list # * marks the active profile -amp logout --profile staging # remove one +amp logout --profile eu # remove one 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). -Prefer a Personal Access Token? `amp auth pat --with-token --profile --env ` -reads a PAT from stdin (or a masked prompt at a terminal) and saves it as a -profile — same force-explicit create rule, same store, just a different -credential type. `--with-token` is required. +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, +same store. `--with-token` is required. ```bash -echo "$PAT" | amp auth pat --with-token --profile ci --env prod +echo "$PAT" | amp auth pat --with-token --region us ``` For CI or a one-off shell, set a raw token — it overrides stored profiles: @@ -82,22 +83,6 @@ export AMP_TOKEN=amp_... # amp_… is treated as a PAT, anything else a b `AMP_PROFILE` selects a stored profile by name; `amp auth token` prints the active access token to stdout (`TOKEN=$(amp auth token)`). -## Base URL - -Defaults to production: - -```text -https://developer-api.amplitude.com -``` - -Override for staging or a local server: - -```bash -export AMP_API_BASE_URL=http://localhost:3036 -# or -amp --base-url http://localhost:3036 context -``` - ## Examples ```bash diff --git a/docs/cli.md b/docs/cli.md index ef95c89..71ce231 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -5,42 +5,42 @@ OpenAPI spec. A handwritten runtime in `src/cli.ts` maps flags to HTTP requests. ## Authentication -Authenticate via the OAuth device flow and save the result as a named profile. -Creating a profile is force-explicit — name it and pick its environment: +Authenticate via the OAuth device flow and save the result as a profile. Omit +`--profile` and the CLI targets `default` — the implicit profile used when you +don't name one. Picking a region stays explicit: ```bash -amp auth login --profile prod --env prod +amp auth login --region us ``` -That runs the device flow against the chosen environment's Developer API, saves +That runs the device flow against the chosen region's Developer API, saves the token to `~/.amplitude/amp/credentials.json` (0600), and activates the -profile. A -profile binds a credential to an environment (`base_url`), so a staging token -can never be sent to prod. +profile. A profile binds a credential to a region (`base_url`), so an EU +token can never be sent to prod. ```bash -amp auth login --profile staging --env staging # activates staging +amp auth login --profile eu --region eu # activates the EU profile amp auth use prod # switch (no re-auth) amp auth list # * marks the active profile amp auth status # type, base URL, expiry -amp logout --profile staging # remove one +amp logout --profile eu # remove one amp logout --all # remove every profile ``` -A bare `amp auth login` re-authenticates the active profile in place (env from -its record). `--env` maps `local|dev|staging|prod|prod-eu` to a base URL; pass -`--base-url ` instead to target an arbitrary host. +A bare `amp auth login` re-authenticates the active profile in place (region +from its record). `--region` maps `us|eu` to a base URL. To use a Personal Access Token instead of the device flow: ```bash -echo "$PAT" | amp auth pat --with-token --profile ci --env prod # stdin -amp auth pat --with-token --profile ci --env prod # masked prompt at a TTY +echo "$PAT" | amp auth pat --with-token --region us # stdin → "default" profile +amp auth pat --with-token --profile ci --region us # name it; masked prompt at a TTY ``` `--with-token` reads the PAT from stdin when piped (the agent / CI path) or a -masked prompt at a terminal (the human path). Same force-explicit create rule -and store as `login`; the credential is saved as `{ "type": "pat" }`. +masked prompt at a terminal (the human path). Same rules as `login`: `--profile` +is optional (defaults to `default`) and `--region` is required to create a +profile; the credential is saved as `{ "type": "pat" }`. `--with-token` is mandatory: it makes the supply-an-existing-PAT path explicit and keeps the bare `amp auth pat` verb reserved. @@ -54,7 +54,7 @@ profile; `amp auth status` announces when it is in effect. ```bash export AMP_TOKEN=amp_... # one-off / CI; overrides stored profiles -export AMP_PROFILE=staging # select a stored profile by name +export AMP_PROFILE=eu # select a stored profile by name ``` Accepted forms for `--token` / `AMP_TOKEN`: @@ -85,8 +85,7 @@ Route-level scopes are defined on each OpenAPI operation (`x-required-scopes`). | Flag / env | Purpose | | --------------------------------------- | ------------------------------------------ | -| `--base-url` / `AMP_API_BASE_URL` | API host (default: prod) | -| `--env ` | Friendly env name → base URL | +| `--region ` | Data region name → base URL | | `--profile ` / `AMP_PROFILE` | Select a stored profile | | `--token` / `AMP_TOKEN` / saved profile | Auth token | | `--json` | Force raw JSON output (default when piped) | @@ -130,14 +129,6 @@ Manual checklist: Known issue: `flags update --enabled false` fails for deployment-less flags. Avoid that path in smoke tests until it is fixed. -## Local server - -Point at a running local server: - -```bash -amp --base-url http://localhost:3036 context -``` - ## Generated files Do not edit by hand: diff --git a/openapi/bundled/openapi.bundled.json b/openapi/bundled/openapi.bundled.json index 9de5081..82b6412 100644 --- a/openapi/bundled/openapi.bundled.json +++ b/openapi/bundled/openapi.bundled.json @@ -43,6 +43,10 @@ "name": "Taxonomy User Properties", "description": "User-property taxonomy operations (scoped to a project)." }, + { + "name": "Analytics", + "description": "Saved chart discovery and query operations." + }, { "name": "Feature Flags", "description": "Feature flag configuration and rollout operations." @@ -1277,6 +1281,212 @@ } } }, + "/v1/projects/{project_id}/charts": { + "get": { + "tags": ["Analytics"], + "operationId": "listCharts", + "summary": "List charts", + "description": "Returns saved charts in the project. Results are paginated with cursor-based\npagination. Chart definitions are omitted; use GET chart with\n`include_definition=true` to retrieve a read-only definition.\n\nPagination uses an opaque numeric cursor (offset into the current sorted\nsearch result set). Each list query can access at most **10,000** matching\ncharts from the search index; pages beyond that window return fewer items\nand `next_cursor: null` even when more charts may exist in the project.\nUse filters (`q`, `chart_type`) to narrow large projects.\n\nSortable fields are `updated_at` and `name`; prefix with `-` for descending.\nDefaults to `-updated_at` (most recently updated first). `created_at` is not\nsupported in this API version.\n", + "x-required-scopes": ["read:analytics"], + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/Cursor" + }, + { + "$ref": "#/components/parameters/Limit" + }, + { + "$ref": "#/components/parameters/Query" + }, + { + "$ref": "#/components/parameters/ChartType" + }, + { + "$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/Chart" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "500": { + "$ref": "#/components/responses/Problem" + } + } + } + }, + "/v1/projects/{project_id}/charts/{chart_id}": { + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/ChartId" + } + ], + "get": { + "tags": ["Analytics"], + "operationId": "getChart", + "summary": "Get chart", + "description": "Returns chart metadata. The chart definition is omitted by default. Set\n`include_definition=true` to include a read-only definition object. The\ndefinition shape is unstable and not intended for authoring.\n", + "x-required-scopes": ["read:analytics"], + "parameters": [ + { + "$ref": "#/components/parameters/IncludeDefinition" + } + ], + "responses": { + "200": { + "description": "Successful response.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/Chart" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/ValidationProblem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "500": { + "$ref": "#/components/responses/Problem" + } + } + } + }, + "/v1/projects/{project_id}/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 are DAC-filtered for the authenticated caller.\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 (`429`, `502`, `504`) populate\n`retry_after_seconds` in the problem body when a delay is advised.\n", + "x-required-scopes": ["read:analytics"], + "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/Problem" + }, + "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}/flags": { "get": { "tags": ["Feature Flags"], @@ -1637,7 +1847,7 @@ "name": "cursor", "in": "query", "required": false, - "description": "Cursor token from previous list response.", + "description": "Opaque cursor from `pagination.next_cursor` on the previous response.\nNumeric offsets are capped by the underlying search index (10,000 results\nper query). Well-formed pagination stops before that window; requests whose\ncursor would exceed it return 400 `pagination_not_supported`.\n", "schema": { "type": ["string", "null"] } @@ -1658,10 +1868,10 @@ "name": "sort", "in": "query", "required": false, - "description": "Comma-separated sort fields. Prefix with `-` for descending.", + "description": "Sort field. Prefix with `-` for descending. Supported values are `updated_at` and `name`.", "schema": { "type": "string", - "example": "-created_at,name" + "example": "-updated_at" } }, "Query": { @@ -1734,6 +1944,35 @@ "minLength": 1 } }, + "ChartId": { + "name": "chart_id", + "in": "path", + "required": true, + "description": "Saved chart identifier.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "ChartType": { + "name": "chart_type", + "in": "query", + "required": false, + "description": "Filter list results to a specific chart type. Use `unknown` to return charts\nwhose internal type is not mapped to a public API value.\n", + "schema": { + "$ref": "#/components/schemas/ChartType" + } + }, + "IncludeDefinition": { + "name": "include_definition", + "in": "query", + "required": false, + "description": "When true, include the read-only chart definition on GET chart. The definition\nshape is unstable and not intended for authoring.\n", + "schema": { + "type": "boolean", + "default": false + } + }, "ExperimentId": { "name": "experiment_id", "in": "path", @@ -1794,7 +2033,8 @@ "required": ["next_cursor", "has_more"], "properties": { "next_cursor": { - "type": ["string", "null"] + "type": ["string", "null"], + "description": "Cursor for the next page, or `null` on the last page. Chart list\nresponses may stop before all project charts are enumerated because\nthe search backend caps each query at 10,000 sorted hits.\n" }, "has_more": { "type": "boolean" @@ -2068,6 +2308,204 @@ "type": ["string", "null"], "enum": ["string", "number", "boolean", "object", "enum", "any", null] }, + "Chart": { + "type": "object", + "required": [ + "id", + "object", + "project_id", + "name", + "chart_type", + "created_at", + "updated_at", + "url", + "archived" + ], + "properties": { + "id": { + "type": "string", + "description": "Stable chart identifier." + }, + "object": { + "type": "string", + "const": "chart", + "description": "Resource-type discriminator. Always `chart`." + }, + "project_id": { + "type": "string", + "pattern": "^[0-9]+$" + }, + "name": { + "type": "string" + }, + "description": { + "type": ["string", "null"] + }, + "chart_type": { + "$ref": "#/components/schemas/ChartType" + }, + "created_at": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO-8601 creation time, or `null` when unavailable." + }, + "updated_at": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO-8601 last-modified time, or `null` when unavailable." + }, + "created_by": { + "type": ["string", "null"], + "description": "Chart creator. The creator's email when available, otherwise a login\nidentifier; `null` when the creator cannot be resolved.\n" + }, + "url": { + "type": "string", + "format": "uri", + "description": "Deep link to the chart in the Amplitude UI." + }, + "archived": { + "type": "boolean", + "description": "Whether the chart is archived." + }, + "definition": { + "type": "object", + "additionalProperties": true, + "description": "Read-only chart definition. Returned only when `include_definition=true`\non GET chart. Shape is unstable and may change with Nova/Dash versions;\nnot intended for authoring.\n" + } + } + }, + "ChartType": { + "type": "string", + "description": "Public chart type discriminator (snake_case). Maps from internal Dash types\n(e.g. `eventsSegmentation` → `event_segmentation`). All values may appear on\nlist/get; query returns `422` (`unsupported_chart_type`) for types outside\nthe v1 supported matrix.\n\nInternal-only or deprecated Dash types (e.g. `asql`, `eventsLog`) are not\nexposed on the public wire; those charts surface as `unknown`.\n\nNew chart types may be added over time. An internal type the adapter cannot\nmap is surfaced as `unknown` rather than failing the response, so the wire\nvalue is always one of the members below. Clients should treat `unknown` as\n\"a chart type this API version does not model yet\".\n", + "enum": [ + "event_segmentation", + "sessions", + "funnels", + "retention", + "composition", + "revenue_ltv", + "stickiness", + "data_table", + "engagement_matrix", + "metric_explorer", + "growth_accounting", + "impact", + "users", + "unknown" + ] + }, + "ChartQueryRequest": { + "type": "object", + "description": "v1 query parameters. Filter and group-by overrides are intentionally omitted\nuntil Tier 2 allowlist validation is defined.\n", + "properties": { + "time_range": { + "$ref": "#/components/schemas/TimeRange" + }, + "timezone": { + "type": "string", + "description": "IANA timezone identifier (e.g. `America/New_York`).", + "example": "America/New_York" + }, + "exclude_incomplete_datapoints": { + "type": "boolean", + "default": false, + "description": "When true, excludes the current incomplete interval from results.\n" + }, + "group_by_limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 10, + "description": "Maximum number of breakdown groups returned per series." + }, + "time_series_limit": { + "type": "integer", + "minimum": 0, + "maximum": 1000, + "default": 100, + "description": "Maximum number of time buckets per series. `0` collapses each series to\na single scalar aggregate.\n" + } + }, + "additionalProperties": false + }, + "AnalyticsResult": { + "type": "object", + "required": [ + "id", + "object", + "source_type", + "source_id", + "project_id", + "computed_at", + "timezone", + "result_kind", + "metric_semantics", + "data", + "metadata", + "warnings" + ], + "properties": { + "id": { + "type": "string", + "description": "Identifier for this query execution." + }, + "object": { + "type": "string", + "const": "analytics_result", + "description": "Resource-type discriminator. Always `analytics_result`." + }, + "source_type": { + "type": "string", + "enum": ["chart"], + "description": "Source artifact type. v1 supports `chart` only. `dashboard` and `query`\nare reserved for future slices.\n" + }, + "source_id": { + "type": "string", + "description": "Identifier of the source chart." + }, + "project_id": { + "type": "string", + "pattern": "^[0-9]+$" + }, + "computed_at": { + "type": "string", + "format": "date-time" + }, + "timezone": { + "type": "string", + "description": "IANA timezone used for computation." + }, + "result_kind": { + "$ref": "#/components/schemas/ResultKind" + }, + "metric_semantics": { + "$ref": "#/components/schemas/MetricSemantics" + }, + "data": { + "$ref": "#/components/schemas/AnalyticsResultData" + }, + "metadata": { + "$ref": "#/components/schemas/AnalyticsResultMetadata" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal issues encountered during query execution." + }, + "truncated": { + "oneOf": [ + { + "$ref": "#/components/schemas/TruncationInfo" + }, + { + "type": "null" + } + ] + } + } + }, "FeatureFlag": { "allOf": [ { @@ -2271,6 +2709,219 @@ } } }, + "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 + }, + "ResultKind": { + "type": "string", + "description": "High-level shape of the normalized result payload. Adapters set this\nexplicitly per supported chart type; `unknown` means the server could not\nclassify the result shape safely.\n", + "enum": [ + "timeseries", + "scalar", + "funnel", + "retention", + "table", + "unknown" + ] + }, + "RecommendedAggregate": { + "type": "string", + "description": "Recommended aggregation method for downstream consumers. Non-additive metrics\n(e.g. unique users) must not be summed across intervals. `unknown` is used\nwhen the adapter cannot determine a safe aggregate; treat values as\nnon-additive in that case.\n", + "enum": ["sum", "last", "mean", "deduped_total", "none", "unknown"] + }, + "MetricSemantics": { + "type": "object", + "required": ["additive", "recommended_aggregate"], + "properties": { + "additive": { + "type": "boolean", + "description": "When false, values must not be summed across time intervals or groups\nwithout understanding deduplication semantics.\n" + }, + "recommended_aggregate": { + "$ref": "#/components/schemas/RecommendedAggregate" + }, + "notes": { + "type": ["string", "null"], + "description": "Human-readable guidance for interpreting values." + } + } + }, + "ResultDimension": { + "type": "object", + "required": ["id", "label", "role"], + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "role": { + "type": "string", + "enum": ["time", "segment", "breakdown", "step"], + "description": "Role of this dimension in the result." + } + } + }, + "ResultPoint": { + "type": "object", + "required": ["x", "y"], + "properties": { + "x": { + "description": "Dimension value (typically an ISO date or category label).", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "y": { + "type": ["number", "null"], + "description": "Metric value at this point." + }, + "complete": { + "type": "boolean", + "description": "When false, the interval is incomplete (current bucket). Only present\nwhen `exclude_incomplete_datapoints` is false.\n" + } + } + }, + "ResultSeries": { + "type": "object", + "required": ["id", "label", "points"], + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "points": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResultPoint" + } + }, + "aggregate": { + "type": "object", + "description": "Optional pre-computed aggregate for the series.", + "properties": { + "value": { + "type": ["number", "null"] + }, + "method": { + "type": "string" + } + }, + "required": ["value", "method"] + } + } + }, + "AnalyticsResultData": { + "type": "object", + "description": "Normalized result payload. Populated fields depend on `result_kind`.\nTimeseries and funnel results use `dimensions` and `series`. Table results\nuse `columns` and `rows`.\n", + "properties": { + "dimensions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResultDimension" + } + }, + "series": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResultSeries" + } + }, + "columns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Column headers for table-shaped results." + }, + "rows": { + "type": "array", + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + } + } + }, + "AnalyticsResultMetadata": { + "type": "object", + "description": "Echo of effective query parameters and chart context.", + "properties": { + "chart_type": { + "$ref": "#/components/schemas/ChartType" + }, + "chart_name": { + "type": "string" + }, + "time_range": { + "$ref": "#/components/schemas/TimeRange" + }, + "exclude_incomplete_datapoints": { + "type": "boolean" + }, + "group_by_limit": { + "type": "integer" + }, + "time_series_limit": { + "type": "integer" + } + }, + "additionalProperties": true + }, + "TruncationInfo": { + "type": "object", + "properties": { + "group_by_limit": { + "type": "integer", + "description": "Applied group-by limit when breakdown was truncated." + }, + "time_series_limit": { + "type": "integer", + "description": "Applied time-series limit when buckets were truncated." + }, + "reason": { + "type": "string", + "description": "Human-readable explanation of truncation." + } + } + }, "RolloutWeights": { "type": "object", "additionalProperties": { diff --git a/openapi/bundled/openapi.bundled.yaml b/openapi/bundled/openapi.bundled.yaml index f54e382..7f5e4d6 100644 --- a/openapi/bundled/openapi.bundled.yaml +++ b/openapi/bundled/openapi.bundled.yaml @@ -27,6 +27,8 @@ tags: description: Event-property taxonomy operations (scoped to an event). - name: Taxonomy User Properties description: User-property taxonomy operations (scoped to a project). + - name: Analytics + description: Saved chart discovery and query operations. - name: Feature Flags description: Feature flag configuration and rollout operations. - name: Auth @@ -920,6 +922,174 @@ paths: $ref: '#/components/responses/Problem' '500': $ref: '#/components/responses/Problem' + /v1/projects/{project_id}/charts: + get: + tags: + - Analytics + operationId: listCharts + summary: List charts + description: | + Returns saved charts in the project. Results are paginated with cursor-based + pagination. Chart definitions are omitted; use GET chart with + `include_definition=true` to retrieve a read-only definition. + + Pagination uses an opaque numeric cursor (offset into the current sorted + search result set). Each list query can access at most **10,000** matching + charts from the search index; pages beyond that window return fewer items + and `next_cursor: null` even when more charts may exist in the project. + Use filters (`q`, `chart_type`) to narrow large projects. + + Sortable fields are `updated_at` and `name`; prefix with `-` for descending. + Defaults to `-updated_at` (most recently updated first). `created_at` is not + supported in this API version. + x-required-scopes: + - read:analytics + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Query' + - $ref: '#/components/parameters/ChartType' + - $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/Chart' + pagination: + $ref: '#/components/schemas/Pagination' + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' + /v1/projects/{project_id}/charts/{chart_id}: + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/ChartId' + get: + tags: + - Analytics + operationId: getChart + summary: Get chart + description: | + Returns chart metadata. The chart definition is omitted by default. Set + `include_definition=true` to include a read-only definition object. The + definition shape is unstable and not intended for authoring. + x-required-scopes: + - read:analytics + parameters: + - $ref: '#/components/parameters/IncludeDefinition' + responses: + '200': + description: Successful response. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Chart' + '400': + $ref: '#/components/responses/ValidationProblem' + '401': + $ref: '#/components/responses/Problem' + '403': + $ref: '#/components/responses/Problem' + '404': + $ref: '#/components/responses/Problem' + '500': + $ref: '#/components/responses/Problem' + /v1/projects/{project_id}/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 + `event_segmentation`, `sessions`, `funnels`, and `retention` chart types; + other chart types return `422` with `error_code: unsupported_chart_type`. + Results are DAC-filtered for the authenticated caller. + + 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 chart's saved range; if the chart has no + saved range, the server defaults to the last 30 days. + + Query is synchronous with bounded defaults. Expensive queries may return + `504` when exceeding the server timeout; async query jobs are planned for + a follow-up slice. Retryable responses (`429`, `502`, `504`) populate + `retry_after_seconds` in the problem body when a delay is advised. + x-required-scopes: + - read:analytics + 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 + request was understood but cannot be processed. Returned with + `error_code: unsupported_chart_type`; `detail` names the chart type + and the currently supported set. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '429': + $ref: '#/components/responses/Problem' + '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}/flags: get: tags: @@ -1179,7 +1349,11 @@ components: name: cursor in: query required: false - description: Cursor token from previous list response. + description: | + Opaque cursor from `pagination.next_cursor` on the previous response. + Numeric offsets are capped by the underlying search index (10,000 results + per query). Well-formed pagination stops before that window; requests whose + cursor would exceed it return 400 `pagination_not_supported`. schema: type: - string @@ -1198,10 +1372,10 @@ components: name: sort in: query required: false - description: Comma-separated sort fields. Prefix with `-` for descending. + description: Sort field. Prefix with `-` for descending. Supported values are `updated_at` and `name`. schema: type: string - example: '-created_at,name' + example: '-updated_at' Query: name: q in: query @@ -1262,6 +1436,33 @@ components: schema: type: string minLength: 1 + ChartId: + name: chart_id + in: path + required: true + description: Saved chart identifier. + schema: + type: string + minLength: 1 + ChartType: + name: chart_type + in: query + required: false + description: | + Filter list results to a specific chart type. Use `unknown` to return charts + whose internal type is not mapped to a public API value. + schema: + $ref: '#/components/schemas/ChartType' + IncludeDefinition: + name: include_definition + in: query + required: false + description: | + When true, include the read-only chart definition on GET chart. The definition + shape is unstable and not intended for authoring. + schema: + type: boolean + default: false ExperimentId: name: experiment_id in: path @@ -1316,6 +1517,10 @@ components: type: - string - 'null' + description: | + Cursor for the next page, or `null` on the last page. Chart list + responses may stop before all project charts are enumerated because + the search backend caps each query at 10,000 sorted hits. has_more: type: boolean ProblemDetails: @@ -1566,6 +1771,191 @@ components: - enum - any - null + Chart: + type: object + required: + - id + - object + - project_id + - name + - chart_type + - created_at + - updated_at + - url + - archived + properties: + id: + type: string + description: Stable chart identifier. + object: + type: string + const: chart + description: Resource-type discriminator. Always `chart`. + project_id: + type: string + pattern: ^[0-9]+$ + name: + type: string + description: + type: + - string + - 'null' + chart_type: + $ref: '#/components/schemas/ChartType' + created_at: + type: + - string + - 'null' + format: date-time + description: ISO-8601 creation time, or `null` when unavailable. + updated_at: + type: + - string + - 'null' + format: date-time + description: ISO-8601 last-modified time, or `null` when unavailable. + created_by: + type: + - string + - 'null' + description: | + Chart creator. The creator's email when available, otherwise a login + identifier; `null` when the creator cannot be resolved. + url: + type: string + format: uri + description: Deep link to the chart in the Amplitude UI. + archived: + type: boolean + description: Whether the chart is archived. + definition: + type: object + additionalProperties: true + description: | + Read-only chart definition. Returned only when `include_definition=true` + on GET chart. Shape is unstable and may change with Nova/Dash versions; + not intended for authoring. + ChartType: + type: string + description: | + Public chart type discriminator (snake_case). Maps from internal Dash types + (e.g. `eventsSegmentation` → `event_segmentation`). All values may appear on + list/get; query returns `422` (`unsupported_chart_type`) for types outside + the v1 supported matrix. + + Internal-only or deprecated Dash types (e.g. `asql`, `eventsLog`) are not + exposed on the public wire; those charts surface as `unknown`. + + New chart types may be added over time. An internal type the adapter cannot + map is surfaced as `unknown` rather than failing the response, so the wire + value is always one of the members below. Clients should treat `unknown` as + "a chart type this API version does not model yet". + enum: + - event_segmentation + - sessions + - funnels + - retention + - composition + - revenue_ltv + - stickiness + - data_table + - engagement_matrix + - metric_explorer + - growth_accounting + - impact + - users + - unknown + ChartQueryRequest: + type: object + description: | + v1 query parameters. Filter and group-by overrides are intentionally omitted + until Tier 2 allowlist validation is defined. + properties: + time_range: + $ref: '#/components/schemas/TimeRange' + timezone: + type: string + description: IANA timezone identifier (e.g. `America/New_York`). + example: America/New_York + exclude_incomplete_datapoints: + type: boolean + default: false + description: | + When true, excludes the current incomplete interval from results. + group_by_limit: + type: integer + minimum: 1 + maximum: 1000 + default: 10 + description: Maximum number of breakdown groups returned per series. + time_series_limit: + type: integer + minimum: 0 + maximum: 1000 + default: 100 + description: | + Maximum number of time buckets per series. `0` collapses each series to + a single scalar aggregate. + additionalProperties: false + AnalyticsResult: + type: object + required: + - id + - object + - source_type + - source_id + - project_id + - computed_at + - timezone + - result_kind + - metric_semantics + - data + - metadata + - warnings + properties: + id: + type: string + description: Identifier for this query execution. + object: + type: string + const: analytics_result + description: Resource-type discriminator. Always `analytics_result`. + source_type: + type: string + enum: + - chart + description: | + Source artifact type. v1 supports `chart` only. `dashboard` and `query` + are reserved for future slices. + source_id: + type: string + description: Identifier of the source chart. + project_id: + type: string + pattern: ^[0-9]+$ + computed_at: + type: string + format: date-time + timezone: + type: string + description: IANA timezone used for computation. + result_kind: + $ref: '#/components/schemas/ResultKind' + metric_semantics: + $ref: '#/components/schemas/MetricSemantics' + data: + $ref: '#/components/schemas/AnalyticsResultData' + metadata: + $ref: '#/components/schemas/AnalyticsResultMetadata' + warnings: + type: array + items: + type: string + description: Non-fatal issues encountered during query execution. + truncated: + oneOf: + - $ref: '#/components/schemas/TruncationInfo' + - type: 'null' FeatureFlag: allOf: - $ref: '#/components/schemas/FeatureFlagBase' @@ -1738,6 +2128,193 @@ components: type: string id_token: type: string + 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 + ResultKind: + type: string + description: | + High-level shape of the normalized result payload. Adapters set this + explicitly per supported chart type; `unknown` means the server could not + classify the result shape safely. + enum: + - timeseries + - scalar + - funnel + - retention + - table + - unknown + RecommendedAggregate: + type: string + description: | + Recommended aggregation method for downstream consumers. Non-additive metrics + (e.g. unique users) must not be summed across intervals. `unknown` is used + when the adapter cannot determine a safe aggregate; treat values as + non-additive in that case. + enum: + - sum + - last + - mean + - deduped_total + - none + - unknown + MetricSemantics: + type: object + required: + - additive + - recommended_aggregate + properties: + additive: + type: boolean + description: | + When false, values must not be summed across time intervals or groups + without understanding deduplication semantics. + recommended_aggregate: + $ref: '#/components/schemas/RecommendedAggregate' + notes: + type: + - string + - 'null' + description: Human-readable guidance for interpreting values. + ResultDimension: + type: object + required: + - id + - label + - role + properties: + id: + type: string + label: + type: string + role: + type: string + enum: + - time + - segment + - breakdown + - step + description: Role of this dimension in the result. + ResultPoint: + type: object + required: + - x + - 'y' + properties: + x: + description: Dimension value (typically an ISO date or category label). + oneOf: + - type: string + - type: number + 'y': + type: + - number + - 'null' + description: Metric value at this point. + complete: + type: boolean + description: | + When false, the interval is incomplete (current bucket). Only present + when `exclude_incomplete_datapoints` is false. + ResultSeries: + type: object + required: + - id + - label + - points + properties: + id: + type: string + label: + type: string + points: + type: array + items: + $ref: '#/components/schemas/ResultPoint' + aggregate: + type: object + description: Optional pre-computed aggregate for the series. + properties: + value: + type: + - number + - 'null' + method: + type: string + required: + - value + - method + AnalyticsResultData: + type: object + description: | + Normalized result payload. Populated fields depend on `result_kind`. + Timeseries and funnel results use `dimensions` and `series`. Table results + use `columns` and `rows`. + properties: + dimensions: + type: array + items: + $ref: '#/components/schemas/ResultDimension' + series: + type: array + items: + $ref: '#/components/schemas/ResultSeries' + columns: + type: array + items: + type: string + description: Column headers for table-shaped results. + rows: + type: array + items: + type: array + items: + oneOf: + - type: string + - type: number + - type: boolean + - type: 'null' + AnalyticsResultMetadata: + type: object + description: Echo of effective query parameters and chart context. + properties: + chart_type: + $ref: '#/components/schemas/ChartType' + chart_name: + type: string + time_range: + $ref: '#/components/schemas/TimeRange' + exclude_incomplete_datapoints: + type: boolean + group_by_limit: + type: integer + time_series_limit: + type: integer + additionalProperties: true + TruncationInfo: + type: object + properties: + group_by_limit: + type: integer + description: Applied group-by limit when breakdown was truncated. + time_series_limit: + type: integer + description: Applied time-series limit when buckets were truncated. + reason: + type: string + description: Human-readable explanation of truncation. RolloutWeights: type: object additionalProperties: diff --git a/src/args.ts b/src/args.ts index 3fd56b9..9c1a3cf 100644 --- a/src/args.ts +++ b/src/args.ts @@ -27,8 +27,11 @@ const GLOBAL_OPTIONS: CliOptionDefinition[] = [ { aliases: ['scope'], valueRequirement: 'required' }, { aliases: ['profile'], valueRequirement: 'required' }, { aliases: ['env'], valueRequirement: 'required' }, + { aliases: ['region'], valueRequirement: 'required' }, + { aliases: ['timeout'], valueRequirement: 'required' }, { aliases: ['with-token'], valueRequirement: 'optional' }, { aliases: ['all'], valueRequirement: 'optional' }, + { aliases: ['force'], valueRequirement: 'optional' }, { aliases: ['help', 'h'], valueRequirement: 'optional' }, { aliases: ['version', 'v'], valueRequirement: 'optional' }, ]; diff --git a/src/auth-commands.test.ts b/src/auth-commands.test.ts new file mode 100644 index 0000000..527ccc1 --- /dev/null +++ b/src/auth-commands.test.ts @@ -0,0 +1,949 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + resolvePollProfile, + runAuthLoginPoll, + runAuthLoginStart, + targetProfileName, +} from './auth-commands'; +import { + CURRENT_VERSION, + type CredentialStore, + getProfile, + loadStore, + saveStore, + setProfile, +} from './credential-store'; +import { + emptyPending, + getPending, + loadPending, + savePending, + setPending, +} from './pending-store'; + +const profile = { + base_url: 'https://developer-api.amplitude.com', + credential: { type: 'pat' as const, pat: 'amp_x' }, + saved_at: '2026-06-23T12:00:00.000Z', +}; + +function storeWith(over: Partial = {}): CredentialStore { + return { version: CURRENT_VERSION, profiles: {}, ...over }; +} + +describe('targetProfileName', () => { + it('returns an explicit --profile after validating it', () => { + expect(targetProfileName(storeWith(), 'prod')).toBe('prod'); + }); + + it('rejects an invalid explicit --profile', () => { + expect(() => targetProfileName(storeWith(), 'bad/name')).toThrow( + /Invalid profile name/, + ); + }); + + it('falls back to the active pointer when --profile is omitted', () => { + const store = storeWith({ default: 'prod', profiles: { prod: profile } }); + expect(targetProfileName(store, undefined)).toBe('prod'); + }); + + it('falls back to "default" on a cold store (unset pointer)', () => { + expect(targetProfileName(storeWith(), undefined)).toBe('default'); + }); + + it('throws for an orphaned pointer (set but missing profile)', () => { + const store = storeWith({ default: 'ghost' }); + expect(() => targetProfileName(store, undefined)).toThrow( + /No such profile: ghost/, + ); + }); +}); + +function paths() { + const dir = mkdtempSync(join(tmpdir(), 'amp-start-')); + return { + path: join(dir, 'credentials.json'), + pendingPath: join(dir, 'pending.json'), + }; +} + +describe('runAuthLoginStart', () => { + it('persists a pending entry and emits verification_required without secrets', async () => { + const { path, pendingPath } = paths(); + const lines: string[] = []; + await runAuthLoginStart( + { region: 'us' }, + { + path, + pendingPath, + now: () => 0, + deviceId: () => 'dev', + stdout: (l) => lines.push(l), + request: async (method, p) => { + expect(method).toBe('POST'); + expect(p).toBe('/v1/auth/device-authorization'); + return { + status: 200, + body: { + device_code: 'DC', + user_code: 'HZNK-QLIB', + verification_uri: 'https://app.amplitude.com/device', + verification_uri_complete: + 'https://app.amplitude.com/device?user_code=HZNK-QLIB', + expires_in: 600, + interval: 5, + }, + }; + }, + }, + ); + const out = JSON.parse(lines.join('\n')); + expect(out.status).toBe('verification_required'); + expect(out.data.user_code).toBe('HZNK-QLIB'); + expect(out.data.profile).toBe('default'); + expect(out.data.region).toBe('us'); + // Proactively tells the agent the poll blocks (default) and is tunable. + expect(out.message).toMatch(/blocks up to ~25s by default/); + expect(out.message).toMatch(/--timeout/); + expect(lines.join('\n')).not.toContain('DC'); // device_code withheld + expect(getPending(loadPending(pendingPath), 'default')?.device_code).toBe( + 'DC', + ); + }); + + it('surfaces the server OAuth error on a non-2xx device-authorization response', async () => { + const { path, pendingPath } = paths(); + const lines: string[] = []; + await runAuthLoginStart( + { region: 'us' }, + { + path, + pendingPath, + now: () => 0, + deviceId: () => 'dev', + stdout: (l) => lines.push(l), + request: async () => ({ + status: 400, + body: { + error: 'invalid_request', + error_description: 'Unknown scope requested.', + }, + }), + }, + ); + const out = JSON.parse(lines.join('\n')); + expect(out.status).toBe('error'); + expect(out.error.error_code).toBe('invalid_request'); + expect(out.error.detail).toBe('Unknown scope requested.'); + expect(out.message).toContain('Unknown scope requested.'); + expect(process.exitCode).toBe(1); + process.exitCode = 0; + }); + + it('garbage-collects expired pending entries on start', async () => { + const { path, pendingPath } = paths(); + savePending( + setPending(emptyPending(), 'old', pendingEntry('2000-01-01T00:00:00Z')), + pendingPath, + ); + await runAuthLoginStart( + { region: 'us' }, + { + path, + pendingPath, + now: () => Date.parse('2026-07-15T00:00:00.000Z'), + deviceId: () => 'dev', + stdout: () => {}, + request: async () => ({ + status: 200, + body: { + device_code: 'DC', + user_code: 'UC', + verification_uri: 'https://app.amplitude.com/device', + verification_uri_complete: + 'https://app.amplitude.com/device?user_code=UC', + expires_in: 600, + interval: 5, + }, + }), + }, + ); + expect(Object.keys(loadPending(pendingPath).pending)).toEqual(['default']); + }); + + it('mints a fresh code and notes it supersedes a still-live prior one', async () => { + const { path, pendingPath } = paths(); + savePending( + setPending( + emptyPending(), + 'default', + pendingEntry('2999-01-01T00:00:00Z'), + ), + pendingPath, + ); + const lines: string[] = []; + await runAuthLoginStart( + { region: 'us' }, + { + path, + pendingPath, + now: () => 0, + deviceId: () => 'dev', + stdout: (l) => lines.push(l), + request: async () => ({ + status: 200, + body: { + device_code: 'DC2', + user_code: 'NEW-CODE', + verification_uri: 'https://app.amplitude.com/device', + verification_uri_complete: + 'https://app.amplitude.com/device?user_code=NEW-CODE', + expires_in: 600, + interval: 5, + }, + }), + }, + ); + const out = JSON.parse(lines.join('\n')); + expect(out.status).toBe('verification_required'); + expect(out.message).toMatch(/replaces an earlier in-progress code/); + expect(getPending(loadPending(pendingPath), 'default')?.device_code).toBe( + 'DC2', + ); + }); + + it('refuses to silently retarget an existing profile to a different region', async () => { + const { path, pendingPath } = paths(); + const store: CredentialStore = storeWith({ + default: 'default', + profiles: { + default: { + base_url: 'https://developer-api.eu.amplitude.com', + credential: { type: 'pat', pat: 'amp_x' }, + saved_at: '2026-06-23T12:00:00.000Z', + }, + }, + }); + saveStore(store, path); + const lines: string[] = []; + await runAuthLoginStart( + { region: 'us' }, + { + path, + pendingPath, + now: () => 0, + deviceId: () => 'dev', + stdout: (l) => lines.push(l), + request: async () => { + throw new Error('request should not be called'); + }, + }, + ); + const out = JSON.parse(lines.join('\n')); + expect(out.status).toBe('error'); + expect(out.error.error_code).toBe('profile_target_conflict'); + expect(out.message).toContain('https://developer-api.eu.amplitude.com'); + expect(process.exitCode).toBe(1); + process.exitCode = 0; + expect(Object.keys(loadPending(pendingPath).pending)).toEqual([]); + }); + + it('proceeds with a cross-region retarget when --force is set', async () => { + const { path, pendingPath } = paths(); + const store: CredentialStore = storeWith({ + default: 'default', + profiles: { + default: { + base_url: 'https://developer-api.eu.amplitude.com', + credential: { type: 'pat', pat: 'amp_x' }, + saved_at: '2026-06-23T12:00:00.000Z', + }, + }, + }); + saveStore(store, path); + const lines: string[] = []; + await runAuthLoginStart( + { region: 'us', force: true }, + { + path, + pendingPath, + now: () => 0, + deviceId: () => 'dev', + stdout: (l) => lines.push(l), + request: async () => ({ + status: 200, + body: { + device_code: 'DC', + user_code: 'UC', + verification_uri: 'https://app.amplitude.com/device', + verification_uri_complete: + 'https://app.amplitude.com/device?user_code=UC', + expires_in: 600, + interval: 5, + }, + }), + }, + ); + const out = JSON.parse(lines.join('\n')); + expect(out.status).toBe('verification_required'); + }); + + it('pretty-prints the JSON envelope at a TTY', async () => { + const { path, pendingPath } = paths(); + const lines: string[] = []; + await runAuthLoginStart( + { region: 'us' }, + { + path, + pendingPath, + now: () => 0, + deviceId: () => 'dev', + isTTY: true, + stdout: (l) => lines.push(l), + request: async () => ({ + status: 200, + body: { + device_code: 'DC', + user_code: 'UC', + verification_uri: 'https://app.amplitude.com/device', + verification_uri_complete: + 'https://app.amplitude.com/device?user_code=UC', + expires_in: 600, + interval: 5, + }, + }), + }, + ); + const out = lines.join('\n'); + expect(out).toContain('\n "status"'); + }); + + it('emits a clean unexpected_response envelope on a malformed 2xx device-authorization body', async () => { + const { path, pendingPath } = paths(); + const lines: string[] = []; + await runAuthLoginStart( + { region: 'us' }, + { + path, + pendingPath, + now: () => 0, + deviceId: () => 'dev', + stdout: (l) => lines.push(l), + request: async () => ({ + status: 200, + body: {}, // missing device_code/user_code/verification_uri/expires_in + }), + }, + ); + const out = JSON.parse(lines.join('\n')); + expect(out.status).toBe('error'); + expect(out.error.error_code).toBe('unexpected_response'); + expect(out.message).toBe( + 'The authorization server returned an unexpected device-authorization response.', + ); + expect(process.exitCode).toBe(1); + process.exitCode = 0; + expect(getPending(loadPending(pendingPath), 'default')).toBeUndefined(); + }); + + it('emits a JSON error envelope (not a throw) on invalid input', async () => { + const { path, pendingPath } = paths(); + const lines: string[] = []; + // No --region on a cold store → loginBaseUrl throws; must surface as JSON. + await runAuthLoginStart( + {}, + { + path, + pendingPath, + now: () => 0, + deviceId: () => 'dev', + stdout: (l) => lines.push(l), + request: async () => ({ status: 200, body: {} }), + }, + ); + const out = JSON.parse(lines.join('\n')); + expect(out.status).toBe('error'); + expect(out.error.error_code).toBe('start_failed'); + expect(process.exitCode).toBe(1); + process.exitCode = 0; + }); +}); + +const pendingEntry = (expiresAt: string) => ({ + device_code: 'DC', + code_verifier: 'CV', + base_url: 'https://developer-api.amplitude.com', + expires_at: expiresAt, + interval: 5, + started_at: '2026-07-15T00:00:00.000Z', +}); + +describe('resolvePollProfile', () => { + it('auto-selects the sole pending login when --profile is omitted', () => { + const pending = setPending( + emptyPending(), + 'work', + pendingEntry('2999-01-01T00:00:00Z'), + ); + expect( + resolvePollProfile(pending, undefined, undefined, Date.now()), + ).toEqual({ + name: 'work', + }); + }); + + it('falls back to the store default when multiple logins are pending', () => { + const pending = setPending( + setPending(emptyPending(), 'work', pendingEntry('2999-01-01T00:00:00Z')), + 'personal', + pendingEntry('2999-01-01T00:00:00Z'), + ); + expect( + resolvePollProfile(pending, undefined, 'personal', Date.now()), + ).toEqual({ + name: 'personal', + }); + }); + + it('errors listing profiles when multiple logins are pending and none disambiguates', () => { + const pending = setPending( + setPending(emptyPending(), 'work', pendingEntry('2999-01-01T00:00:00Z')), + 'personal', + pendingEntry('2999-01-01T00:00:00Z'), + ); + const result = resolvePollProfile( + pending, + undefined, + undefined, + Date.now(), + ); + expect('error' in result && result.error).toMatch(/work/); + expect('error' in result && result.error).toMatch(/personal/); + expect('error' in result && result.code).toBe('ambiguous_pending_login'); + }); + + it('does not guess "default" over another live login when the active pointer is elsewhere', () => { + const pending = setPending( + setPending( + emptyPending(), + 'default', + pendingEntry('2999-01-01T00:00:00Z'), + ), + 'work', + pendingEntry('2999-01-01T00:00:00Z'), + ); + const result = resolvePollProfile(pending, undefined, 'prod', Date.now()); + expect('error' in result && result.code).toBe('ambiguous_pending_login'); + }); + + it('ignores expired entries so a stale sibling does not shadow the live login', () => { + const pending = setPending( + setPending(emptyPending(), 'stale', pendingEntry('2000-01-01T00:00:00Z')), + 'live', + pendingEntry('2999-01-01T00:00:00Z'), + ); + expect( + resolvePollProfile(pending, undefined, undefined, Date.now()), + ).toEqual({ + name: 'live', + }); + }); + + it('reports no login in progress when every pending entry is expired', () => { + const pending = setPending( + setPending(emptyPending(), 'a', pendingEntry('2000-01-01T00:00:00Z')), + 'b', + pendingEntry('2000-01-01T00:00:00Z'), + ); + const result = resolvePollProfile( + pending, + undefined, + undefined, + Date.now(), + ); + expect('error' in result && result.error).toMatch(/No login in progress/); + expect('error' in result && result.code).toBe('no_pending_login'); + }); +}); + +describe('runAuthLoginPoll', () => { + it('authorizes: writes+activates the profile and clears pending', async () => { + const { path, pendingPath } = paths(); + savePending( + setPending( + emptyPending(), + 'default', + pendingEntry('2999-01-01T00:00:00Z'), + ), + pendingPath, + ); + const lines: string[] = []; + await runAuthLoginPoll( + { profile: 'default' }, + { + path, + pendingPath, + now: () => 0, + stdout: (l) => lines.push(l), + request: async () => ({ + status: 200, + body: { access_token: 'AT', token_type: 'bearer', expires_in: 3600 }, + }), + }, + ); + const out = JSON.parse(lines.join('\n')); + expect(out.status).toBe('authorized'); + expect(out.data.profile).toBe('default'); + expect(out.data.region).toBe('us'); + const store = loadStore(path); + expect(store.default).toBe('default'); + expect(getProfile(store, 'default')?.credential.type).toBe('oauth'); + expect(getPending(loadPending(pendingPath), 'default')).toBeUndefined(); + }); + + it('authorizes with region derived from an EU pending base_url', async () => { + const { path, pendingPath } = paths(); + savePending( + setPending(emptyPending(), 'default', { + ...pendingEntry('2999-01-01T00:00:00Z'), + base_url: 'https://developer-api.eu.amplitude.com', + }), + pendingPath, + ); + const lines: string[] = []; + await runAuthLoginPoll( + { profile: 'default' }, + { + path, + pendingPath, + now: () => 0, + stdout: (l) => lines.push(l), + request: async () => ({ + status: 200, + body: { access_token: 'AT', token_type: 'bearer', expires_in: 3600 }, + }), + }, + ); + const out = JSON.parse(lines.join('\n')); + expect(out.status).toBe('authorized'); + expect(out.data.region).toBe('eu'); + }); + + it('reports pending (exit 75) when not yet confirmed', async () => { + const { path, pendingPath } = paths(); + savePending( + setPending( + emptyPending(), + 'default', + pendingEntry('2999-01-01T00:00:00Z'), + ), + pendingPath, + ); + const lines: string[] = []; + process.exitCode = 0; + await runAuthLoginPoll( + { profile: 'default', timeout: '0' }, + { + path, + pendingPath, + now: () => 0, + sleep: async () => {}, + stdout: (l) => lines.push(l), + request: async () => ({ + status: 400, + body: { error: 'authorization_pending' }, + }), + }, + ); + expect(JSON.parse(lines.join('\n')).status).toBe('pending'); + expect(process.exitCode).toBe(75); + process.exitCode = 0; + }); + + it('errors when there is no pending login', async () => { + const { path, pendingPath } = paths(); + const lines: string[] = []; + await runAuthLoginPoll( + { profile: 'default' }, + { path, pendingPath, now: () => 0, stdout: (l) => lines.push(l) }, + ); + const out = JSON.parse(lines.join('\n')); + expect(out.status).toBe('error'); + expect(out.error.error_code).toBe('no_pending_login'); + process.exitCode = 0; + }); + + it('surfaces ambiguous_pending_login when multiple logins are pending and none disambiguates', async () => { + const { path, pendingPath } = paths(); + savePending( + setPending( + setPending( + emptyPending(), + 'work', + pendingEntry('2999-01-01T00:00:00Z'), + ), + 'personal', + pendingEntry('2999-01-01T00:00:00Z'), + ), + pendingPath, + ); + const lines: string[] = []; + await runAuthLoginPoll( + {}, + { path, pendingPath, now: () => 0, stdout: (l) => lines.push(l) }, + ); + const out = JSON.parse(lines.join('\n')); + expect(out.status).toBe('error'); + expect(out.error.error_code).toBe('ambiguous_pending_login'); + process.exitCode = 0; + }); + + it('names --region eu in the restart hint for an EU pending entry', async () => { + const { path, pendingPath } = paths(); + savePending( + setPending(emptyPending(), 'default', { + ...pendingEntry('2000-01-01T00:00:00Z'), // already expired + base_url: 'https://developer-api.eu.amplitude.com', + }), + pendingPath, + ); + const lines: string[] = []; + await runAuthLoginPoll( + { profile: 'default' }, + { + path, + pendingPath, + now: () => Date.now(), + stdout: (l) => lines.push(l), + }, + ); + const out = JSON.parse(lines.join('\n')); + expect(out.status).toBe('expired'); + expect(out.message).toContain('--region eu'); + expect(out.message).not.toContain('--region us'); + process.exitCode = 0; + }); + + it('surfaces the server error_code on an OAuth-error outcome (access_denied)', async () => { + const { path, pendingPath } = paths(); + savePending( + setPending( + emptyPending(), + 'default', + pendingEntry('2999-01-01T00:00:00Z'), + ), + pendingPath, + ); + const lines: string[] = []; + await runAuthLoginPoll( + { profile: 'default' }, + { + path, + pendingPath, + now: () => 0, + stdout: (l) => lines.push(l), + request: async () => ({ + status: 400, + body: { + error: 'access_denied', + error_description: 'The user denied the authorization request.', + }, + }), + }, + ); + const out = JSON.parse(lines.join('\n')); + expect(out.status).toBe('error'); + expect(out.error.error_code).toBe('access_denied'); + expect(out.error.detail).toBe('The user denied the authorization request.'); + expect(process.exitCode).toBe(1); + process.exitCode = 0; + }); + + it('rejects a non-numeric --timeout with an invalid_timeout envelope', async () => { + const { path, pendingPath } = paths(); + savePending( + setPending( + emptyPending(), + 'default', + pendingEntry('2999-01-01T00:00:00Z'), + ), + pendingPath, + ); + const lines: string[] = []; + await runAuthLoginPoll( + { profile: 'default', timeout: 'abc' }, + { path, pendingPath, now: () => 0, stdout: (l) => lines.push(l) }, + ); + const out = JSON.parse(lines.join('\n')); + expect(out.status).toBe('error'); + expect(out.error.error_code).toBe('invalid_timeout'); + expect(process.exitCode).toBe(1); + process.exitCode = 0; + }); + + it('keeps the pending entry on a transient server_error (retryable)', async () => { + const { path, pendingPath } = paths(); + savePending( + setPending( + emptyPending(), + 'default', + pendingEntry('2999-01-01T00:00:00Z'), + ), + pendingPath, + ); + let t = 0; + const lines: string[] = []; + process.exitCode = 0; + await runAuthLoginPoll( + { profile: 'default', timeout: '2' }, + { + path, + pendingPath, + now: () => (t += 1000), + sleep: async () => {}, + stdout: (l) => lines.push(l), + request: async () => ({ status: 500, body: { error: 'server_error' } }), + }, + ); + expect(JSON.parse(lines.join('\n')).status).toBe('pending'); + expect(getPending(loadPending(pendingPath), 'default')).toBeDefined(); + expect(process.exitCode).toBe(75); + process.exitCode = 0; + }); + + it('notes the pending login is preserved and hints a retry on poll_failed', async () => { + const { path, pendingPath } = paths(); + savePending( + setPending( + emptyPending(), + 'default', + pendingEntry('2999-01-01T00:00:00Z'), + ), + pendingPath, + ); + const lines: string[] = []; + await runAuthLoginPoll( + { profile: 'default' }, + { + path, + pendingPath, + now: () => 0, + stdout: (l) => lines.push(l), + request: async () => { + throw new Error('Could not reach the API'); + }, + }, + ); + const out = JSON.parse(lines.join('\n')); + expect(out.status).toBe('error'); + expect(out.error.error_code).toBe('poll_failed'); + expect(out.message).toMatch(/re-run the poll command to retry/); + expect(process.exitCode).toBe(1); + process.exitCode = 0; + expect(getPending(loadPending(pendingPath), 'default')).toBeDefined(); + }); + + it('does not apply a slow_down interval to a concurrently-started fresh code', async () => { + const { path, pendingPath } = paths(); + savePending( + setPending( + emptyPending(), + 'default', + pendingEntry('2999-01-01T00:00:00Z'), + ), + pendingPath, + ); + let t = 0; + process.exitCode = 0; + await runAuthLoginPoll( + { profile: 'default', timeout: '5' }, + { + path, + pendingPath, + now: () => (t += 1000), + sleep: async () => {}, + stdout: () => {}, + request: async () => { + // A concurrent `start` overwrites the entry with a fresh device_code + // (interval 5) while this poll's request is in flight; the slow_down + // below was raised against the old code and must not touch the fresh row. + savePending( + setPending(loadPending(pendingPath), 'default', { + ...pendingEntry('2999-01-01T00:00:00Z'), + device_code: 'FRESH', + }), + pendingPath, + ); + return { status: 400, body: { error: 'slow_down' } }; + }, + }, + ); + const after = getPending(loadPending(pendingPath), 'default'); + expect(after?.device_code).toBe('FRESH'); + expect(after?.interval).toBe(5); + process.exitCode = 0; + }); + + it('does not clear a pending entry a concurrent start superseded when erroring', async () => { + const { path, pendingPath } = paths(); + savePending( + setPending( + emptyPending(), + 'default', + pendingEntry('2999-01-01T00:00:00Z'), + ), + pendingPath, + ); + process.exitCode = 0; + await runAuthLoginPoll( + { profile: 'default', timeout: '0' }, + { + path, + pendingPath, + now: () => 0, + sleep: async () => {}, + stdout: () => {}, + request: async () => { + // A concurrent `start` mints a fresh code mid-flight; this slower + // poll then errors and must not wipe the newer in-progress login. + savePending( + setPending(loadPending(pendingPath), 'default', { + ...pendingEntry('2999-01-01T00:00:00Z'), + device_code: 'FRESH', + }), + pendingPath, + ); + return { status: 400, body: { error: 'access_denied' } }; + }, + }, + ); + expect(getPending(loadPending(pendingPath), 'default')?.device_code).toBe( + 'FRESH', + ); + process.exitCode = 0; + }); + + it('clears the pending entry on authorize even if a concurrent start superseded it', async () => { + const { path, pendingPath } = paths(); + savePending( + setPending( + emptyPending(), + 'default', + pendingEntry('2999-01-01T00:00:00Z'), + ), + pendingPath, + ); + await runAuthLoginPoll( + { profile: 'default' }, + { + path, + pendingPath, + now: () => 0, + stdout: () => {}, + request: async () => { + // A concurrent `start` mints a fresh code mid-flight; this poll then + // authorizes. The profile is now authenticated, so the leftover code + // must be cleared, not left to make later polls report `pending`. + savePending( + setPending(loadPending(pendingPath), 'default', { + ...pendingEntry('2999-01-01T00:00:00Z'), + device_code: 'FRESH', + }), + pendingPath, + ); + return { + status: 200, + body: { + access_token: 'AT', + token_type: 'bearer', + expires_in: 3600, + }, + }; + }, + }, + ); + expect(getPending(loadPending(pendingPath), 'default')).toBeUndefined(); + }); + + it('does not clobber a profile a concurrent auth wrote to the store mid-poll', async () => { + const { path, pendingPath } = paths(); + savePending( + setPending( + emptyPending(), + 'default', + pendingEntry('2999-01-01T00:00:00Z'), + ), + pendingPath, + ); + await runAuthLoginPoll( + { profile: 'default' }, + { + path, + pendingPath, + now: () => 0, + stdout: () => {}, + request: async () => { + // A concurrent auth (e.g. another profile's poll, or an interactive + // login) writes to the SAME store file while this poll is in + // flight. The authorized save below must layer onto this, not a + // stale pre-poll snapshot. + saveStore( + setProfile(loadStore(path), 'other', { + base_url: 'https://concurrent.example.com', + credential: { type: 'pat', pat: 'amp_other' }, + saved_at: '2026-06-23T12:00:00.000Z', + store: 'file', + }), + path, + ); + return { + status: 200, + body: { + access_token: 'AT', + token_type: 'bearer', + expires_in: 3600, + }, + }; + }, + }, + ); + expect(getProfile(loadStore(path), 'default')).toBeDefined(); + const other = getProfile(loadStore(path), 'other'); + expect(other).toBeDefined(); + expect(other?.base_url).toBe('https://concurrent.example.com'); + }); + + it('persists a slow_down-raised interval for the next poll', async () => { + const { path, pendingPath } = paths(); + savePending( + setPending( + emptyPending(), + 'default', + pendingEntry('2999-01-01T00:00:00Z'), + ), + pendingPath, + ); + let t = 0; + process.exitCode = 0; + await runAuthLoginPoll( + { profile: 'default', timeout: '2' }, + { + path, + pendingPath, + now: () => (t += 1000), + sleep: async () => {}, + stdout: () => {}, + request: async () => ({ status: 400, body: { error: 'slow_down' } }), + }, + ); + expect( + getPending(loadPending(pendingPath), 'default')?.interval, + ).toBeGreaterThan(5); + process.exitCode = 0; + }); +}); diff --git a/src/auth-commands.ts b/src/auth-commands.ts index 225141b..baf0632 100644 --- a/src/auth-commands.ts +++ b/src/auth-commands.ts @@ -2,13 +2,25 @@ import { text } from 'node:stream/consumers'; import { type FlagValue, isFlagEnabled, stringFlag } from './args'; -import { personalAccessTokenSetupUrl, resolveOrgUrl } from './auth-guidance'; import { + personalAccessTokenSetupUrl, + resolveAppOrigin, + resolveOrgUrl, +} from './auth-guidance'; +import { + type AnonymousRequest, type DeviceFlowOptions, createAnonymousRequest, + generatePkcePair, + pollDeviceTokenBounded, requestDeviceToken, } from './authToken'; -import { ENV_BASE_URLS, resolveEnvBaseUrl } from './config'; +import { getOrCreateDeviceId } from './cli-state'; +import { + assertRegionAndEnvNotBothSet, + ENV_BASE_URLS, + resolveNamedBaseUrl, +} from './config'; import { resolveAuthFromFlags, selectProfileFromFlags, @@ -29,7 +41,24 @@ import { setDefault, setProfile, } from './credential-store'; -import type { TokenResponse } from './oauthResponseSchemas'; +import { toOAuthError } from './oauthError'; +import { + deviceAuthorizationResponseSchema, + type TokenResponse, +} from './oauthResponseSchemas'; +import { + emptyPending, + gcExpiredPending, + getPending, + isPendingExpired, + loadPending, + type PendingEntry, + type PendingStore, + pendingPath as defaultPendingPath, + removePending, + savePending, + setPending, +} from './pending-store'; import { askSecret, confirm } from './prompt'; import { DEFAULT_SCOPES } from './scopes'; import { terminal, terminalForStdout } from './terminal'; @@ -73,26 +102,557 @@ export function oauthCredentialFromToken( export function loginBaseUrl(args: { baseUrlFlag?: string; envFlag?: string; + regionFlag?: string; existing?: Profile; }): string { + assertRegionAndEnvNotBothSet(args); if (args.baseUrlFlag) { return args.baseUrlFlag.replace(/\/$/, ''); } - if (args.envFlag) { - return resolveEnvBaseUrl(args.envFlag); + const named = resolveNamedBaseUrl({ + envFlag: args.envFlag, + regionFlag: args.regionFlag, + }); + if (named) { + return named; } if (args.existing) { return args.existing.base_url; } - throw new Error( - 'Creating a profile requires --env or --base-url .', + throw new Error('Creating a profile requires --region .'); +} + +/** + * The profile name a create-or-reauth verb targets: an explicit `--profile`, + * else the active pointer (`store.default`), else the implicit `default` + * (materialized on first login). Throws when relying on a *set* pointer whose + * profile is gone (an orphaned hand-edited default) so the caller matches the + * resolver's "No such profile" wording rather than falling into loginBaseUrl's + * create-time "requires --env". A cold store (unset pointer) falls through to + * `default` and create-mode. + */ +export function targetProfileName( + store: CredentialStore, + requestedName: string | undefined, +): string { + if (requestedName !== undefined) { + assertValidProfileName(requestedName); + return requestedName; + } + if (store.default) { + if (!getProfile(store, store.default)) { + throw new Error( + `No such profile: ${store.default}. Run \`amp auth list\`.`, + ); + } + return store.default; + } + return 'default'; +} + +/** + * Wraps a phase response in the `{ status, message, data | error }` envelope + * every agent-driven auth verb emits. `status` is always a string enum; + * secrets (`device_code`, `code_verifier`) must never be passed in `data`. + */ +export function authFlowJson( + payload: { + status: + | 'verification_required' + | 'pending' + | 'authorized' + | 'expired' + | 'error'; + message: string; + data?: Record; + error?: Record; + }, + isTTY: boolean, +): string { + return isTTY ? JSON.stringify(payload, null, 2) : JSON.stringify(payload); +} + +export interface AuthStartDeps { + path?: string; + pendingPath?: string; + now?: () => number; + deviceId?: () => string; + stdout?: (line: string) => void; + request?: AnonymousRequest; + isTTY?: boolean; +} + +/** + * `amp auth login start` — device-flow phase 1. Requests a device + * authorization, stashes the secret `device_code`/`code_verifier` in the + * pending-logins store (never emitted), and prints the JSON envelope an agent + * relays to the human: the `user_code` to confirm and the poll command to run + * next. + */ +export async function runAuthLoginStart( + flags: Record, + deps: AuthStartDeps = {}, +): Promise { + const now = deps.now ?? (() => Date.now()); + const emit = deps.stdout ?? ((line) => console.log(line)); + const pPath = deps.pendingPath ?? defaultPendingPath(); + const isTTY = deps.isTTY ?? Boolean(process.stdout.isTTY); + const toJson = (payload: Parameters[0]): string => + authFlowJson(payload, isTTY); + + // Machine verb: any validation/parse failure must still be a JSON envelope, + // never a thrown prose error to stderr. + try { + const store = loadStore(deps.path); + const profileName = targetProfileName( + store, + stringFlag(flags, ['profile']), + ); + const existing = getProfile(store, profileName); + const baseUrl = loginBaseUrl({ + baseUrlFlag: stringFlag(flags, ['base-url']), + envFlag: stringFlag(flags, ['env']), + regionFlag: stringFlag(flags, ['region']), + existing, + }); + + if ( + existing && + existing.base_url !== baseUrl && + !isFlagEnabled(flags.force) + ) { + emit( + toJson({ + status: 'error', + message: `Profile "${profileName}" targets ${existing.base_url}; refusing to silently retarget it to ${baseUrl}. Use a different --profile, or pass --force to overwrite.`, + error: { error_code: 'profile_target_conflict' }, + }), + ); + process.exitCode = 1; + return; + } + + const request = + deps.request ?? + createAnonymousRequest(baseUrl, (deps.deviceId ?? getOrCreateDeviceId)()); + const { codeVerifier, codeChallenge } = generatePkcePair(); + const response = await request('POST', '/v1/auth/device-authorization', { + code_challenge: codeChallenge, + code_challenge_method: 'S256', + scope: DEFAULT_SCOPES, + }); + if (response.status < 200 || response.status >= 300) { + const oauthError = toOAuthError(response.body); + const detail = oauthError.error_description ?? oauthError.error_hint; + emit( + toJson({ + status: 'error', + message: `Could not start device authorization: ${detail ?? oauthError.error}.`, + error: { + error_code: oauthError.error, + detail: detail ?? undefined, + status: response.status, + }, + }), + ); + process.exitCode = 1; + return; + } + const parsed = deviceAuthorizationResponseSchema.safeParse(response.body); + if (!parsed.success) { + emit( + toJson({ + status: 'error', + message: + 'The authorization server returned an unexpected device-authorization response.', + error: { error_code: 'unexpected_response' }, + }), + ); + process.exitCode = 1; + return; + } + const device = parsed.data; + const expiresAt = new Date(now() + device.expires_in * 1000).toISOString(); + const entry: PendingEntry = { + device_code: device.device_code, + code_verifier: codeVerifier, + base_url: baseUrl, + expires_at: expiresAt, + interval: device.interval ?? 5, + started_at: new Date(now()).toISOString(), + }; + // Every start mints a fresh code; we never resume (a stale or errored code + // must never trap the user). Opportunistically GC expired entries on the way + // in, so abandoned logins don't accumulate. After GC, any remaining entry for + // this profile is a still-live code being overwritten — flag it so the agent + // steers the human to the new code, not one they may already have open. + const pending = gcExpiredPending(loadPending(pPath), now()); + const supersededPriorLogin = pending.pending[profileName] !== undefined; + savePending(setPending(pending, profileName, entry), pPath); + + const verificationUrl = + device.verification_uri_complete ?? device.verification_uri; + const pollCmd = `amp auth login poll --profile ${profileName} --json`; + const supersedeNote = supersededPriorLogin + ? ' This replaces an earlier in-progress code for this profile — have the user use this one.' + : ''; + emit( + toJson({ + status: 'verification_required', + message: `Ask the user to open ${verificationUrl} and confirm code ${device.user_code}, then run \`${pollCmd}\` until it reports authorized. Each poll blocks up to ~${DEFAULT_POLL_TIMEOUT_SECONDS}s by default (pass \`--timeout \`, or \`--timeout 0\` for a single check).${supersedeNote}`, + data: { + user_code: device.user_code, + verification_uri: device.verification_uri, + verification_uri_complete: verificationUrl, + expires_at: expiresAt, + expires_in_seconds: device.expires_in, + region: regionLabelForBaseUrl(baseUrl)?.toLowerCase(), + profile: profileName, + scopes: DEFAULT_SCOPES.split(' '), + }, + }), + ); + } catch (error) { + emit( + toJson({ + status: 'error', + message: error instanceof Error ? error.message : String(error), + error: { + error_code: 'start_failed', + detail: error instanceof Error ? error.message : undefined, + }, + }), + ); + process.exitCode = 1; + } +} + +/** + * Resolves which profile `amp auth login poll` targets: an explicit + * `--profile`, else the sole in-flight pending login, else the store's active + * default (when it has a pending entry). Returns an error when there's nothing + * to poll, or when more than one pending login exists and none of the above + * disambiguates it (we never guess a profile out of several live logins). + */ +export function resolvePollProfile( + pending: PendingStore, + explicit: string | undefined, + storeDefault: string | undefined, + now: number, +): + | { name: string } + | { + error: string; + code: 'no_pending_login' | 'ambiguous_pending_login'; + } { + if (explicit) { + return { name: explicit }; + } + // Only non-expired entries are in-flight. Expired ones are removed lazily + // (when poll targets them), so a leftover stale entry must not shadow the live + // login or manufacture false ambiguity when --profile is omitted. + const live = Object.keys(pending.pending).filter( + (name) => !isPendingExpired(pending.pending[name], now), ); + if (live.length === 1) { + return { name: live[0] }; + } + if (storeDefault && live.includes(storeDefault)) { + return { name: storeDefault }; + } + if (live.length === 0) { + return { + error: `No login in progress. Start one with \`${loginStartRestartHint()}\`.`, + code: 'no_pending_login', + }; + } + return { + error: `Ambiguous: pending logins for ${live.join(', ')}. Pass --profile .`, + code: 'ambiguous_pending_login', + }; +} + +/** + * The `amp auth login start` command to suggest in a restart hint. Derives + * `--region ` from a pending entry's `base_url` when known (via + * `regionLabelForBaseUrl`); falls back to the generic `` placeholder + * when there's no entry to derive from, or its base_url isn't a recognized + * region (e.g. an internal/dev host). + */ +function loginStartRestartHint(baseUrl?: string): string { + const region = baseUrl && regionLabelForBaseUrl(baseUrl)?.toLowerCase(); + return `amp auth login start --region ${region ?? ''} --json`; +} + +export interface AuthPollDeps extends AuthStartDeps { + sleep?: (seconds: number) => Promise; +} + +const DEFAULT_POLL_TIMEOUT_SECONDS = 25; + +/** + * Parses `--timeout` into whole seconds: omitted falls back to the default, + * an explicit value must be a finite non-negative integer (0 means single-shot). + * Returns `undefined` on invalid input rather than throwing, so the caller can + * emit an error envelope instead of crashing the process. + */ +function parseTimeoutSeconds(raw: string | undefined): number | undefined { + if (raw === undefined) { + return DEFAULT_POLL_TIMEOUT_SECONDS; + } + const parsed = Number(raw); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) { + return undefined; + } + return parsed; +} + +/** + * `amp auth login poll` — device-flow phase 2. Resolves the in-flight + * profile (see `resolvePollProfile`), then makes one bounded poll attempt + * against `/v1/auth/token`: `authorized` saves + activates the profile and + * clears the pending entry; `pending` reports back (exit 75) for the agent to + * retry; `expired`/`error` clear the pending entry and exit non-zero. Never + * emits the pending entry's secret `device_code`/`code_verifier`. + */ +export async function runAuthLoginPoll( + flags: Record, + deps: AuthPollDeps = {}, +): Promise { + const now = deps.now ?? (() => Date.now()); + const emit = deps.stdout ?? ((line) => console.log(line)); + const sleep = + deps.sleep ?? + ((seconds: number) => + new Promise((resolve) => setTimeout(resolve, seconds * 1000))); + const pPath = deps.pendingPath ?? defaultPendingPath(); + const isTTY = deps.isTTY ?? Boolean(process.stdout.isTTY); + const toJson = (payload: Parameters[0]): string => + authFlowJson(payload, isTTY); + + // Machine verb: any unexpected failure must still be a JSON envelope. + try { + const store = loadStore(deps.path); + const pending = loadPending(pPath); + const resolved = resolvePollProfile( + pending, + stringFlag(flags, ['profile']), + store.default, + now(), + ); + if ('error' in resolved) { + emit( + toJson({ + status: 'error', + message: resolved.error, + error: { error_code: resolved.code }, + }), + ); + process.exitCode = 1; + return; + } + + const name = resolved.name; + const entry = getPending(pending, name); + if (!entry) { + emit( + toJson({ + status: 'error', + message: `No pending login for "${name}". Start one with \`${loginStartRestartHint()}\`.`, + error: { error_code: 'no_pending_login' }, + }), + ); + process.exitCode = 1; + return; + } + + const clearPending = (): void => { + const current = loadPending(pPath); + // A concurrent `start` may have superseded our entry with a fresh + // device_code; only clear the row we were actually polling, never a + // newer in-progress login. + if (getPending(current, name)?.device_code !== entry.device_code) { + return; + } + savePending(removePending(current, name), pPath); + }; + + if (isPendingExpired(entry, now())) { + clearPending(); + emit( + toJson({ + status: 'expired', + message: `The device code expired before confirmation. Start over with \`${loginStartRestartHint(entry.base_url)}\`.`, + }), + ); + process.exitCode = 1; + return; + } + + const timeoutRaw = stringFlag(flags, ['timeout']); + const timeoutSeconds = parseTimeoutSeconds(timeoutRaw); + if (timeoutSeconds === undefined) { + emit( + toJson({ + status: 'error', + message: `Invalid --timeout "${timeoutRaw}". Must be a non-negative integer number of seconds.`, + error: { error_code: 'invalid_timeout' }, + }), + ); + process.exitCode = 1; + return; + } + const request = + deps.request ?? + createAnonymousRequest( + entry.base_url, + (deps.deviceId ?? getOrCreateDeviceId)(), + ); + + const result = await pollDeviceTokenBounded({ + request, + deviceCode: entry.device_code, + codeVerifier: entry.code_verifier, + intervalSeconds: entry.interval, + codeExpiresAtMs: Date.parse(entry.expires_at), + timeoutSeconds, + now, + sleep, + }); + + if (result.status === 'authorized') { + const profile: Profile = { + base_url: entry.base_url, + credential: oauthCredentialFromToken(result.token, now()), + saved_at: new Date(now()).toISOString(), + store: 'file', + }; + // Re-load right before the write: a concurrent auth may have saved to + // the store during the poll's long wait; the stale `store` would clobber it. + saveStore( + setDefault(setProfile(loadStore(deps.path), name, profile), name), + deps.path, + ); + // Profile is now authenticated, so any pending code for it is moot — + // clear unconditionally (unlike the error/expired paths, which guard on + // device_code to avoid nuking a fresh start). Otherwise a code a + // concurrent `start` minted mid-poll would linger and make later polls + // misreport `pending` for an already-authenticated profile. + savePending(removePending(loadPending(pPath), name), pPath); + emit( + toJson({ + status: 'authorized', + message: `Logged in; profile "${name}" activated. Confirm with \`amp context --json\`.`, + data: { + profile: name, + base_url: entry.base_url, + region: regionLabelForBaseUrl(entry.base_url)?.toLowerCase(), + token_expires_at: + profile.credential.type === 'oauth' + ? profile.credential.expires_at + : undefined, + scopes: result.token.scope + ? result.token.scope.split(' ') + : undefined, + }, + }), + ); + return; + } + + if (result.status === 'pending') { + // Persist a slow_down-raised interval so the next poll subprocess honors it + // (RFC 8628 §3.5 — the raised interval applies to all subsequent requests). + // Only onto the row we actually polled: a concurrent `start` may have + // superseded it, and this slow_down was raised against the old code, so it + // must not inflate the fresh code's interval (nor clobber its device_code). + const fresh = loadPending(pPath); + const current = getPending(fresh, name); + if ( + current && + current.device_code === entry.device_code && + current.interval !== result.interval + ) { + savePending( + setPending(fresh, name, { ...current, interval: result.interval }), + pPath, + ); + } + emit( + toJson({ + status: 'pending', + message: `Not confirmed yet. Re-run \`amp auth login poll --profile ${name} --json\` immediately — it blocks up to ~${timeoutSeconds}s internally; no sleep needed.`, + data: { + expires_at: entry.expires_at, + poll_waits_seconds: timeoutSeconds, + }, + }), + ); + process.exitCode = 75; + return; + } + + clearPending(); + if (result.status === 'expired') { + emit( + toJson({ + status: 'expired', + message: `The device code expired before confirmation. Start over with \`${loginStartRestartHint(entry.base_url)}\`.`, + }), + ); + } else { + const detail = result.error.description ?? result.error.hint; + emit( + toJson({ + status: 'error', + message: `Authorization failed: ${detail ?? result.error.code}. Start over with \`${loginStartRestartHint(entry.base_url)}\`.`, + error: { + error_code: result.error.code, + detail: detail ?? undefined, + }, + }), + ); + } + process.exitCode = 1; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + emit( + toJson({ + status: 'error', + message: `${detail} The pending login is preserved — re-run the poll command to retry.`, + error: { + error_code: 'poll_failed', + detail: error instanceof Error ? error.message : undefined, + }, + }), + ); + process.exitCode = 1; + } +} + +/** + * Drops any in-flight `login start` entry for `name`. Completing auth + * out-of-band (interactive `login`, `pat`) strands the device-flow code the + * agent started; without this a later `login poll` would keep hammering the + * abandoned code and reporting `pending` until it expired. No write when + * there's nothing pending for the profile. + */ +function clearPendingLogin(pendingPath: string, name: string): void { + const pending = loadPending(pendingPath); + if (!getPending(pending, name)) { + return; + } + savePending(removePending(pending, name), pendingPath); } export interface AuthLoginDeps { requestToken?: (options: DeviceFlowOptions) => Promise; now?: () => number; path?: string; + pendingPath?: string; + // Resolves the stable install device_id. Injected so tests don't touch the + // real state file; defaults to reading/minting from ~/.amplitude/amp/state.json. + deviceId?: () => string; stdout?: (line: string) => void; stderr?: (line: string) => void; confirm?: (message: string) => Promise; @@ -100,8 +660,9 @@ export interface AuthLoginDeps { /** * `amp auth login` — runs the device flow, saves the token as an OAuth profile, - * and activates it (announcing the switch). Requires an explicit `--profile`; - * creating a profile also requires `--env`/`--base-url`. + * and activates it (announcing the switch). `--profile` is optional: omitting + * it targets the active pointer, else the implicit `default` profile (see + * `targetProfileName`). Creating a profile also requires `--env`/`--base-url`. */ export async function runAuthLogin( flags: Record, @@ -112,41 +673,26 @@ export async function runAuthLogin( const emitStderr = deps.stderr ?? ((line) => console.error(line)); const confirmOverwrite = deps.confirm ?? confirm; const requestToken = deps.requestToken ?? requestDeviceToken; + const resolveDeviceId = deps.deviceId ?? getOrCreateDeviceId; const store = loadStore(deps.path); - // No --profile re-auths the active profile in place (force-explicit applies to - // *creating* a profile, not refreshing one — the default's env+name are already - // recorded). With no default there's nothing to refresh, so require both flags. - // Validate the user-supplied name only; a stored default was already validated - // when it was created. - const requestedName = stringFlag(flags, ['profile']); - if (requestedName !== undefined) { - assertValidProfileName(requestedName); - } - const profileName = requestedName ?? store.default; - if (!profileName) { - throw new Error( - 'No default profile to re-authenticate. Run `amp auth login --profile --env `.', - ); - } - + const profileName = targetProfileName(store, stringFlag(flags, ['profile'])); const existing = getProfile(store, profileName); - // Orphan default: the name came from the store's `default` but that profile is - // gone (e.g. a hand-edited file). The user meant to refresh, not create, so - // match the resolver's "No such profile" instead of falling into - // loginBaseUrl's misleading "creating a profile requires --env" error. A - // user-supplied --profile with no match is the legitimate create path. - if (requestedName === undefined && !existing) { - throw new Error(`No such profile: ${profileName}. Run \`amp auth list\`.`); - } - + const regionFlag = stringFlag(flags, ['region']); + const baseUrlFlag = stringFlag(flags, ['base-url']); const baseUrl = loginBaseUrl({ - baseUrlFlag: stringFlag(flags, ['base-url']), + baseUrlFlag, envFlag: stringFlag(flags, ['env']), + regionFlag, existing, }); + if (regionFlag && !baseUrlFlag) { + emitStdout( + `Authenticating to ${resolveAppOrigin({ apiBaseUrl: baseUrl })}/`, + ); + } // Reusing a name for a different target is almost always a mistake — confirm // before clobbering. Same target is a silent refresh. @@ -162,7 +708,7 @@ export async function runAuthLogin( const token = await requestToken({ flow: stringFlag(flags, ['flow']) ?? 'device', scope: stringFlag(flags, ['scope']) ?? DEFAULT_SCOPES, - request: createAnonymousRequest(baseUrl), + request: createAnonymousRequest(baseUrl, resolveDeviceId()), stderr: emitStderr, }); @@ -178,6 +724,7 @@ export async function runAuthLogin( setDefault(setProfile(store, profileName, profile), profileName), deps.path, ); + clearPendingLogin(deps.pendingPath ?? defaultPendingPath(), profileName); const verb = existing ? 'updated' : 'created'; const wasNote = @@ -225,6 +772,7 @@ async function readWithToken( export interface AuthPatDeps { path?: string; + pendingPath?: string; now?: () => number; stdout?: (line: string) => void; confirm?: (message: string) => Promise; @@ -235,8 +783,10 @@ export interface AuthPatDeps { /** * `amp auth pat --with-token` — save a supplied Personal Access Token as a * profile and activate it. The token is read from stdin when piped, or a masked - * prompt at a TTY. Force-explicit like login: a new profile needs --profile and - * --env/--base-url; re-auth of an existing profile reuses its recorded env. + * prompt at a TTY. `--profile` is optional: omitting it targets the active + * pointer, else the implicit `default` profile (see `targetProfileName`). + * Creating a profile still requires `--env`/`--base-url`; re-auth of an + * existing profile reuses its recorded env. * * `--with-token` is mandatory: it makes the supply-an-existing-PAT path * explicit and keeps the bare `amp auth pat` verb reserved. @@ -251,23 +801,26 @@ export async function runAuthPat( ); } - const profileName = stringFlag(flags, ['profile']); - if (!profileName) { - throw new Error('`amp auth pat` requires --profile .'); - } - assertValidProfileName(profileName); - const now = deps.now ?? (() => Date.now()); const emitStdout = deps.stdout ?? ((line) => console.log(line)); const confirmOverwrite = deps.confirm ?? confirm; const store = loadStore(deps.path); + const profileName = targetProfileName(store, stringFlag(flags, ['profile'])); const existing = getProfile(store, profileName); + const regionFlag = stringFlag(flags, ['region']); + const baseUrlFlag = stringFlag(flags, ['base-url']); const baseUrl = loginBaseUrl({ - baseUrlFlag: stringFlag(flags, ['base-url']), + baseUrlFlag, envFlag: stringFlag(flags, ['env']), + regionFlag, existing, }); + if (regionFlag && !baseUrlFlag) { + emitStdout( + `Authenticating to ${resolveAppOrigin({ apiBaseUrl: baseUrl })}/`, + ); + } // Reusing a name for a different target is almost always a mistake — confirm // before clobbering. Same target is a silent refresh. @@ -300,6 +853,7 @@ export async function runAuthPat( setDefault(setProfile(store, profileName, profile), profileName), deps.path, ); + clearPendingLogin(deps.pendingPath ?? defaultPendingPath(), profileName); const verb = existing ? 'updated' : 'created'; const wasNote = @@ -315,6 +869,7 @@ export async function runAuthPat( interface ProfileCommandDeps { path?: string; + pendingPath?: string; now?: () => number; stdout?: (line: string) => void; confirm?: (message: string) => Promise; @@ -331,6 +886,17 @@ export function envLabel(baseUrl: string): string { return baseUrl; } +/** US/EU label for `auth status`, shown only for prod/prod-eu profiles. */ +export function regionLabelForBaseUrl(baseUrl: string): string | undefined { + if (baseUrl === ENV_BASE_URLS.prod) { + return 'US'; + } + if (baseUrl === ENV_BASE_URLS['prod-eu']) { + return 'EU'; + } + return undefined; +} + function humanizeDuration(ms: number): string { const totalMinutes = Math.round(ms / 60_000); const hours = Math.floor(totalMinutes / 60); @@ -429,7 +995,8 @@ export function runAuthUse( /** * `amp logout [--profile | --all]` — remove a profile, or wipe the whole - * store with `--all`. Target resolution is `--profile` > default > error. Clears + * store with `--all`. Target resolution is `--profile` > default > a solitary + * in-progress login > error. Clears * `default` if it pointed at the removed profile and never auto-promotes a * survivor (the active identity only changes on an explicit command), so * logging out the default leaves no default set. @@ -440,6 +1007,8 @@ export async function runLogout( ): Promise { const emitStdout = deps.stdout ?? ((line) => console.log(line)); const confirmLogout = deps.confirm ?? confirm; + const now = deps.now ?? (() => Date.now()); + const pPath = deps.pendingPath ?? defaultPendingPath(); const store = loadStore(deps.path); if (isFlagEnabled(flags.all)) { @@ -447,54 +1016,99 @@ export async function runLogout( throw new Error('Pass either --profile or --all, not both.'); } const count = Object.keys(store.profiles).length; - if (count === 0) { + const pendingCount = Object.keys(loadPending(pPath).pending).length; + if (count === 0 && pendingCount === 0) { emitStdout('No profiles to remove.'); return; } - const decision = logoutAllGateDecision({ - isTTY: deps.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY), - yes: isFlagEnabled(flags.yes), - }); - if (decision === 'block') { - throw new Error( - 'Pass --yes to remove every stored profile with `amp logout --all`.', - ); - } - if (decision === 'confirm') { - const approved = await confirmLogout( - `Remove all ${count} profile${count === 1 ? '' : 's'}? Stored credentials cannot be recovered.`, - ); - if (!approved) { - throw new Error('Aborted.'); + // Gate protects stored credentials; only apply it when there are profiles + // to remove. An in-flight pending login is transient, so clearing it alone + // needs no confirmation. + if (count > 0) { + const decision = logoutAllGateDecision({ + isTTY: + deps.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY), + yes: isFlagEnabled(flags.yes), + }); + if (decision === 'block') { + throw new Error( + 'Pass --yes to remove every stored profile with `amp logout --all`.', + ); + } + if (decision === 'confirm') { + const approved = await confirmLogout( + `Remove all ${count} profile${count === 1 ? '' : 's'}? Stored credentials cannot be recovered.`, + ); + if (!approved) { + throw new Error('Aborted.'); + } } } saveStore(emptyStore(), deps.path); + savePending(emptyPending(), pPath); emitStdout( terminal.success( - `Removed all ${count} profile${count === 1 ? '' : 's'}. No credentials remain — run \`amp auth login\`.`, + count > 0 + ? `Removed all ${count} profile${count === 1 ? '' : 's'}. No credentials remain — run \`amp auth login\`.` + : `Cleared ${pendingCount} in-progress login${pendingCount === 1 ? '' : 's'}.`, ), ); return; } - const target = stringFlag(flags, ['profile']) ?? store.default; + const pending = loadPending(pPath); + // A cold `login start` leaves a pending entry but no store default yet, so + // fall back to a solitary in-progress login — otherwise bare `logout` can't + // cancel it, though `logout --profile ` can. Only live entries count + // (expired ones are GC'd lazily), matching how `login poll` disambiguates. + const livePending = Object.keys(pending.pending).filter( + (name) => !isPendingExpired(pending.pending[name], now()), + ); + const target = + stringFlag(flags, ['profile']) ?? + store.default ?? + (livePending.length === 1 ? livePending[0] : undefined); if (!target) { throw new Error( 'No profile to log out of. Pass --profile or set a default with `amp auth use`.', ); } - if (!getProfile(store, target)) { + const hasProfile = getProfile(store, target) !== undefined; + const hasPending = getPending(pending, target) !== undefined; + // A `start`ed-but-never-completed login has a pending entry with no stored + // profile yet (e.g. first-time `default`). `logout --profile ` must be + // able to cancel it, so only error when there's neither a profile nor a + // pending login to remove. + if (!hasProfile && !hasPending) { const known = Object.keys(store.profiles); throw new Error( `No such profile: ${target}.${known.length ? ` Known: ${known.join(', ')}.` : ''}`, ); } - const wasDefault = store.default === target; - const next = removeProfile(store, target); + // Drop any in-flight login for this profile — a logout shouldn't leave its + // device-flow secrets behind. Re-read immediately before the write so a + // `login start` for another profile that landed since the snapshot above + // isn't clobbered (same reload-before-mutate rule the poll paths follow). + if (hasPending) { + savePending(removePending(loadPending(pPath), target), pPath); + } + if (!hasProfile) { + emitStdout( + terminal.success(`Canceled the in-progress login for "${target}".`), + ); + return; + } + + // Re-read before the write for the same reason as the pending clear above: + // another auth for a different profile may have landed since the snapshot at + // the top, and removing `target` from a stale store would clobber it. + const current = loadStore(deps.path); + const wasDefault = current.default === target; + const next = removeProfile(current, target); saveStore(next, deps.path); if (wasDefault) { @@ -585,6 +1199,12 @@ export function runAuthStatus( emitProfileRows(auth.profile, profile); } + const region = regionLabelForBaseUrl(auth.baseUrl); + if (region) { + emitStdout( + `Region: ${region} (${resolveAppOrigin({ apiBaseUrl: auth.baseUrl })}/)`, + ); + } emitStdout(`Base URL: ${styled.dim(auth.baseUrl)}`); emitStdout(`Token: ${maskToken(auth.token)}`); } diff --git a/src/auth-guidance.test.ts b/src/auth-guidance.test.ts index 02aee8d..06c0edc 100644 --- a/src/auth-guidance.test.ts +++ b/src/auth-guidance.test.ts @@ -36,7 +36,7 @@ describe('auth guidance', () => { apiBaseUrl: 'https://developer-api.eu.amplitude.com', }), ).toBe( - 'https://eu.amplitude.com/analytics/amplitude/settings/profile/personal-access-tokens', + 'https://app.eu.amplitude.com/analytics/amplitude/settings/profile/personal-access-tokens', ); }); @@ -69,7 +69,7 @@ describe('auth guidance', () => { apiBaseUrl: 'https://developer-api.eu.amplitude.com', }), ).toContain( - 'https://eu.amplitude.com/analytics/amplitude/settings/profile/personal-access-tokens', + 'https://app.eu.amplitude.com/analytics/amplitude/settings/profile/personal-access-tokens', ); }); diff --git a/src/auth-guidance.ts b/src/auth-guidance.ts index ae88f05..31ee39c 100644 --- a/src/auth-guidance.ts +++ b/src/auth-guidance.ts @@ -1,7 +1,7 @@ import { apiBaseUrlFromEnv } from './env'; export const DEFAULT_APP_ORIGIN = 'https://app.amplitude.com'; -export const EU_APP_ORIGIN = 'https://eu.amplitude.com'; +export const EU_APP_ORIGIN = 'https://app.eu.amplitude.com'; export const DEFAULT_ORG_URL = 'amplitude'; const PAT_SETTINGS_SUFFIX = '/settings/profile/personal-access-tokens'; @@ -68,10 +68,11 @@ export function authSetupInstructions(options?: { 'Authenticate amp with a Personal Access Token (PAT).', '', 'Recommended:', - ' amp auth pat --with-token --profile --env ', + ' amp auth pat --with-token --region ', '', 'That prints the PAT settings page and reads the token from stdin (or a', - 'masked prompt at a terminal), saving it as a profile for future commands.', + 'masked prompt at a terminal), saving it as the "default" profile (pass', + '--profile to use a different one) for future commands.', '', 'Manual setup:', ` ${setupUrl}`, diff --git a/src/auth-login.test.ts b/src/auth-login.test.ts index 39ba876..966afe5 100644 --- a/src/auth-login.test.ts +++ b/src/auth-login.test.ts @@ -1,6 +1,6 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -16,6 +16,12 @@ import { saveStore, } from './credential-store'; import type { TokenResponse } from './oauthResponseSchemas'; +import { + getPending, + loadPending, + savePending, + setPending, +} from './pending-store'; const NOW = Date.parse('2026-06-23T12:00:00.000Z'); @@ -53,6 +59,28 @@ describe('loginBaseUrl', () => { ); }); + it('maps --region', () => { + expect(loginBaseUrl({ regionFlag: 'us' })).toBe( + 'https://developer-api.amplitude.com', + ); + }); + + it('throws when both --region and --env are given', () => { + expect(() => + loginBaseUrl({ regionFlag: 'us', envFlag: 'staging' }), + ).toThrow('Pass either --region or --env, not both.'); + }); + + it('throws when both --region and --env are given even though --base-url would win', () => { + expect(() => + loginBaseUrl({ + regionFlag: 'us', + envFlag: 'staging', + baseUrlFlag: 'http://localhost:3036', + }), + ).toThrow('Pass either --region or --env, not both.'); + }); + it('reuses an existing profile base_url on re-auth', () => { expect( loginBaseUrl({ @@ -65,8 +93,8 @@ describe('loginBaseUrl', () => { ).toBe('https://prod'); }); - it('errors creating a profile without --env/--base-url (force-explicit)', () => { - expect(() => loginBaseUrl({})).toThrow(/requires --env|--base-url/); + it('errors creating a profile without --region (force-explicit)', () => { + expect(() => loginBaseUrl({})).toThrow(/requires --region/); }); it('errors on an unknown --env', () => { @@ -90,10 +118,16 @@ describe('runAuthLogin', () => { return join(dir, 'credentials.json'); } + function pendingPathFor(path: string): string { + return join(dirname(path), 'pending.json'); + } + function deps(path: string, out: string[]) { return { path, + pendingPath: pendingPathFor(path), now: () => NOW, + deviceId: () => 'test-device-id', stdout: (line: string) => out.push(line), stderr: () => {}, requestToken: () => Promise.resolve(TOKEN), @@ -143,10 +177,23 @@ describe('runAuthLogin', () => { expect(out.join('\n')).toMatch(/updated and set as default\./); }); - it('errors when neither --profile nor a default profile exists', async () => { - await expect( - runAuthLogin({ env: 'prod' }, deps(tempPath(), [])), - ).rejects.toThrow(/No default profile/); + it('creates the implicit "default" profile when --profile is omitted', async () => { + const path = tempPath(); + const out: string[] = []; + await runAuthLogin({ env: 'prod' }, deps(path, out)); + + const store = loadStore(path); + expect(store.default).toBe('default'); + expect(getProfile(store, 'default')?.base_url).toBe( + 'https://developer-api.amplitude.com', + ); + expect(out.join('\n')).toMatch(/created and set as default/); + }); + + it('errors asking for --region on a cold bare login', async () => { + await expect(runAuthLogin({}, deps(tempPath(), []))).rejects.toThrow( + /requires --region/, + ); }); it('errors with "No such profile" when the stored default is orphaned', async () => { @@ -164,10 +211,12 @@ describe('runAuthLogin', () => { ); }); - it('rejects the reserved name "default"', async () => { - await expect( - runAuthLogin({ profile: 'default', env: 'prod' }, deps(tempPath(), [])), - ).rejects.toThrow(/reserved/); + it('accepts an explicit --profile default', async () => { + const path = tempPath(); + const out: string[] = []; + await runAuthLogin({ profile: 'default', env: 'prod' }, deps(path, out)); + expect(loadStore(path).default).toBe('default'); + expect(out.join('\n')).toMatch(/created and set as default/); }); it('rejects an invalid profile name', async () => { @@ -176,6 +225,39 @@ describe('runAuthLogin', () => { ).rejects.toThrow(/Invalid profile name/); }); + it('announces the region when --region is used', async () => { + const path = tempPath(); + const out: string[] = []; + await runAuthLogin({ profile: 'amplitude', region: 'us' }, deps(path, out)); + expect(out.join('\n')).toContain( + 'Authenticating to https://app.amplitude.com/', + ); + }); + + it('does not announce a region when --env is used', async () => { + const path = tempPath(); + const out: string[] = []; + await runAuthLogin({ profile: 'amplitude', env: 'prod' }, deps(path, out)); + expect(out.join('\n')).not.toContain('Authenticating to'); + }); + + it('does not announce a region when --base-url wins over --region', async () => { + const path = tempPath(); + const out: string[] = []; + await runAuthLogin( + { + profile: 'amplitude', + region: 'us', + 'base-url': 'http://localhost:3036', + }, + deps(path, out), + ); + expect(out.join('\n')).not.toContain('Authenticating to'); + expect(getProfile(loadStore(path), 'amplitude')?.base_url).toBe( + 'http://localhost:3036', + ); + }); + it('aborts a re-login to a different target when not confirmed', async () => { const path = tempPath(); await runAuthLogin({ profile: 'p', env: 'prod' }, deps(path, [])); @@ -226,4 +308,46 @@ describe('runAuthLogin', () => { ); expect(scopes[0]).toBe('read:projects'); }); + + it('clears a stranded login-start entry for the profile it authenticates', async () => { + const path = tempPath(); + const pendingPath = pendingPathFor(path); + savePending( + setPending(loadPending(pendingPath), 'amplitude', { + device_code: 'abandoned', + code_verifier: 'cv', + base_url: 'https://developer-api.amplitude.com', + expires_at: new Date(NOW + 600_000).toISOString(), + interval: 5, + started_at: new Date(NOW).toISOString(), + }), + pendingPath, + ); + + await runAuthLogin({ profile: 'amplitude', env: 'prod' }, deps(path, [])); + + expect(getPending(loadPending(pendingPath), 'amplitude')).toBeUndefined(); + }); + + it('leaves another profile’s pending login untouched', async () => { + const path = tempPath(); + const pendingPath = pendingPathFor(path); + savePending( + setPending(loadPending(pendingPath), 'other', { + device_code: 'other-code', + code_verifier: 'cv', + base_url: 'https://developer-api.amplitude.com', + expires_at: new Date(NOW + 600_000).toISOString(), + interval: 5, + started_at: new Date(NOW).toISOString(), + }), + pendingPath, + ); + + await runAuthLogin({ profile: 'amplitude', env: 'prod' }, deps(path, [])); + + expect(getPending(loadPending(pendingPath), 'other')?.device_code).toBe( + 'other-code', + ); + }); }); diff --git a/src/auth-pat.test.ts b/src/auth-pat.test.ts index bff46de..76e089f 100644 --- a/src/auth-pat.test.ts +++ b/src/auth-pat.test.ts @@ -1,11 +1,17 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { type AuthPatDeps, normalizePat, runAuthPat } from './auth-commands'; import { getProfile, loadStore } from './credential-store'; +import { + getPending, + loadPending, + savePending, + setPending, +} from './pending-store'; const NOW = Date.parse('2026-06-23T12:00:00.000Z'); @@ -33,9 +39,14 @@ describe('runAuthPat', () => { return join(dir, 'credentials.json'); } + function pendingPathFor(path: string): string { + return join(dirname(path), 'pending.json'); + } + function deps(path: string, out: string[], over: Partial = {}) { return { path, + pendingPath: pendingPathFor(path), now: () => NOW, stdout: (line: string) => out.push(line), confirm: () => Promise.resolve(true), @@ -85,19 +96,27 @@ describe('runAuthPat', () => { expect(loadStore(path).profiles.ci).toBeUndefined(); }); - it('requires --profile', async () => { - await expect( - runAuthPat({ env: 'prod', 'with-token': true }, deps(tempPath(), [])), - ).rejects.toThrow(/--profile/); + it('creates the implicit "default" profile when --profile is omitted', async () => { + const path = tempPath(); + const out: string[] = []; + await runAuthPat({ env: 'prod', 'with-token': true }, deps(path, out)); + + const store = loadStore(path); + expect(store.default).toBe('default'); + expect(getProfile(store, 'default')?.credential).toEqual({ + type: 'pat', + pat: 'amp_pasted', + }); + expect(out.join('\n')).toMatch(/created and set as default/); }); - it('rejects the reserved name "default"', async () => { - await expect( - runAuthPat( - { profile: 'default', env: 'prod', 'with-token': true }, - deps(tempPath(), []), - ), - ).rejects.toThrow(/reserved/); + it('accepts an explicit --profile default', async () => { + const path = tempPath(); + await runAuthPat( + { profile: 'default', env: 'prod', 'with-token': true }, + deps(path, []), + ); + expect(loadStore(path).default).toBe('default'); }); it('rejects an invalid profile name', async () => { @@ -109,10 +128,57 @@ describe('runAuthPat', () => { ).rejects.toThrow(/Invalid profile name/); }); - it('is force-explicit: creating without --env/--base-url errors', async () => { + it('is force-explicit: creating without --region errors', async () => { await expect( runAuthPat({ profile: 'ci', 'with-token': true }, deps(tempPath(), [])), - ).rejects.toThrow(/requires --env|--base-url/); + ).rejects.toThrow(/requires --region/); + }); + + it('maps --region for a new PAT profile', async () => { + const path = tempPath(); + await runAuthPat( + { profile: 'ci', region: 'eu', 'with-token': true }, + deps(path, []), + ); + expect(getProfile(loadStore(path), 'ci')?.base_url).toBe( + 'https://developer-api.eu.amplitude.com', + ); + }); + + it('announces the region when --region is used', async () => { + const path = tempPath(); + const out: string[] = []; + await runAuthPat( + { profile: 'ci', region: 'eu', 'with-token': true }, + deps(path, out), + ); + expect(out.join('\n')).toContain( + 'Authenticating to https://app.eu.amplitude.com/', + ); + }); + + it('does not announce a region when --base-url wins over --region', async () => { + const path = tempPath(); + const out: string[] = []; + await runAuthPat( + { + profile: 'ci', + region: 'eu', + 'base-url': 'http://localhost:3036', + 'with-token': true, + }, + deps(path, out), + ); + expect(out.join('\n')).not.toContain('Authenticating to'); + expect(getProfile(loadStore(path), 'ci')?.base_url).toBe( + 'http://localhost:3036', + ); + }); + + it('errors asking for --region on a cold bare pat', async () => { + await expect( + runAuthPat({ 'with-token': true }, deps(tempPath(), [])), + ).rejects.toThrow(/requires --region/); }); it('rejects an empty supplied PAT', async () => { @@ -141,4 +207,27 @@ describe('runAuthPat', () => { 'https://developer-api.amplitude.com', ); }); + + it('clears a stranded login-start entry for the profile it authenticates', async () => { + const path = tempPath(); + const pendingPath = pendingPathFor(path); + savePending( + setPending(loadPending(pendingPath), 'ci', { + device_code: 'abandoned', + code_verifier: 'cv', + base_url: 'https://developer-api.amplitude.com', + expires_at: new Date(NOW + 600_000).toISOString(), + interval: 5, + started_at: new Date(NOW).toISOString(), + }), + pendingPath, + ); + + await runAuthPat( + { profile: 'ci', env: 'prod', 'with-token': true }, + deps(path, []), + ); + + expect(getPending(loadPending(pendingPath), 'ci')).toBeUndefined(); + }); }); diff --git a/src/auth-profiles.test.ts b/src/auth-profiles.test.ts index 14cf553..2747f65 100644 --- a/src/auth-profiles.test.ts +++ b/src/auth-profiles.test.ts @@ -1,6 +1,6 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -10,6 +10,7 @@ import { formatProfileList, logoutAllGateDecision, maskToken, + regionLabelForBaseUrl, runAuthStatus, runAuthToken, runAuthUse, @@ -24,9 +25,25 @@ import { setDefault, setProfile, } from './credential-store'; +import { + emptyPending, + getPending, + loadPending, + savePending, + setPending, +} from './pending-store'; const NOW = Date.parse('2026-06-23T12:00:00.000Z'); +const pendingEntry = (baseUrl: string) => ({ + device_code: 'DC', + code_verifier: 'CV', + base_url: baseUrl, + expires_at: '2999-01-01T00:00:00Z', + interval: 5, + started_at: '2026-07-15T00:00:00.000Z', +}); + function oauthProfile(baseUrl: string, expiresAt: string) { return { base_url: baseUrl, @@ -54,6 +71,24 @@ describe('envLabel', () => { }); }); +describe('regionLabelForBaseUrl', () => { + it('labels prod as US and prod-eu as EU', () => { + expect(regionLabelForBaseUrl('https://developer-api.amplitude.com')).toBe( + 'US', + ); + expect( + regionLabelForBaseUrl('https://developer-api.eu.amplitude.com'), + ).toBe('EU'); + }); + + it('returns undefined for internal envs', () => { + expect(regionLabelForBaseUrl('http://localhost:3036')).toBeUndefined(); + expect( + regionLabelForBaseUrl('https://developer-api.stag2.amplitude.com'), + ).toBeUndefined(); + }); +}); + describe('expiryLabel', () => { it('reports a relative time for a valid oauth token', () => { expect( @@ -342,6 +377,149 @@ describe('runLogout', () => { runLogout({ all: true, profile: 'staging' }, { path: seeded() }), ).rejects.toThrow(/not both/); }); + + it('clears the pending login for the profile being logged out', async () => { + const path = seeded(); + const pendingPath = join(dirname(path), 'pending.json'); + savePending( + setPending( + setPending( + emptyPending(), + 'staging', + pendingEntry('https://developer-api.stag2.amplitude.com'), + ), + 'amplitude', + pendingEntry('https://developer-api.amplitude.com'), + ), + pendingPath, + ); + await runLogout( + { profile: 'staging' }, + { path, pendingPath, stdout: () => {} }, + ); + const after = loadPending(pendingPath); + expect(after.pending.staging).toBeUndefined(); + expect(after.pending.amplitude).toBeDefined(); + }); + + it('--all clears every pending login alongside the profiles', async () => { + const path = seeded(); + const pendingPath = join(dirname(path), 'pending.json'); + savePending( + setPending( + emptyPending(), + 'amplitude', + pendingEntry('https://developer-api.amplitude.com'), + ), + pendingPath, + ); + await runLogout( + { all: true, yes: true }, + { path, pendingPath, isTTY: false, stdout: () => {} }, + ); + expect(Object.keys(loadPending(pendingPath).pending)).toEqual([]); + }); + + it('cancels a pending login for a profile with no stored credential yet', async () => { + const dir = mkdtempSync(join(tmpdir(), 'amp-logout-pending-cancel-')); + dirs.push(dir); + const path = join(dir, 'credentials.json'); + const pendingPath = join(dir, 'pending.json'); + saveStore(emptyStore(), path); + savePending( + setPending( + emptyPending(), + 'default', + pendingEntry('https://developer-api.amplitude.com'), + ), + pendingPath, + ); + const out: string[] = []; + await runLogout( + { profile: 'default' }, + { path, pendingPath, stdout: (l) => out.push(l) }, + ); + expect(getPending(loadPending(pendingPath), 'default')).toBeUndefined(); + expect(out.join('\n')).toMatch(/Canceled the in-progress login/); + }); + + it('bare logout cancels a solitary in-progress login with no default set', async () => { + const dir = mkdtempSync(join(tmpdir(), 'amp-logout-bare-pending-')); + dirs.push(dir); + const path = join(dir, 'credentials.json'); + const pendingPath = join(dir, 'pending.json'); + // Cold `login start`: a pending entry exists but store.default is unset. + saveStore(emptyStore(), path); + savePending( + setPending( + emptyPending(), + 'default', + pendingEntry('https://developer-api.amplitude.com'), + ), + pendingPath, + ); + const out: string[] = []; + await runLogout({}, { path, pendingPath, stdout: (l) => out.push(l) }); + expect(getPending(loadPending(pendingPath), 'default')).toBeUndefined(); + expect(out.join('\n')).toMatch(/Canceled the in-progress login/); + }); + + it('bare logout selects the sole live login, ignoring an expired sibling', async () => { + const dir = mkdtempSync(join(tmpdir(), 'amp-logout-live-plus-expired-')); + dirs.push(dir); + const path = join(dir, 'credentials.json'); + const pendingPath = join(dir, 'pending.json'); + saveStore(emptyStore(), path); + let store = emptyPending(); + store = setPending( + store, + 'live', + pendingEntry('https://developer-api.amplitude.com'), + ); + store = setPending(store, 'stale', { + ...pendingEntry('https://developer-api.amplitude.com'), + device_code: 'STALE', + expires_at: '2000-01-01T00:00:00Z', + }); + savePending(store, pendingPath); + const out: string[] = []; + await runLogout( + {}, + { + path, + pendingPath, + now: () => Date.parse('2026-07-16T00:00:00Z'), + stdout: (l) => out.push(l), + }, + ); + const after = loadPending(pendingPath); + expect(getPending(after, 'live')).toBeUndefined(); + expect(getPending(after, 'stale')?.device_code).toBe('STALE'); + expect(out.join('\n')).toMatch(/Canceled the in-progress login/); + }); + + it('--all clears pending even when there are no profiles', async () => { + const dir = mkdtempSync(join(tmpdir(), 'amp-logout-pending-only-')); + dirs.push(dir); + const path = join(dir, 'credentials.json'); + const pendingPath = join(dir, 'pending.json'); + saveStore(emptyStore(), path); + savePending( + setPending( + emptyPending(), + 'work', + pendingEntry('https://developer-api.amplitude.com'), + ), + pendingPath, + ); + const out: string[] = []; + await runLogout( + { all: true }, + { path, pendingPath, stdout: (l) => out.push(l) }, + ); + expect(Object.keys(loadPending(pendingPath).pending)).toEqual([]); + expect(out.join('\n')).toMatch(/Cleared 1 in-progress login/); + }); }); describe('maskToken', () => { @@ -393,6 +571,42 @@ describe('runAuthStatus', () => { expect(process.exitCode).not.toBe(1); }); + it('shows the Region line for a prod profile', () => { + const out: string[] = []; + runAuthStatus( + {}, + { + store: statusStore(), + now: () => NOW, + env: {}, + stdout: (l) => out.push(l), + }, + ); + expect(out.join('\n')).toContain( + 'Region: US (https://app.amplitude.com/)', + ); + }); + + it('omits the Region line for an internal-env profile', () => { + const store = setDefault( + setProfile( + emptyStore(), + 'staging', + oauthProfile( + 'https://developer-api.stag2.amplitude.com', + '2026-06-23T14:00:00.000Z', + ), + ), + 'staging', + ); + const out: string[] = []; + runAuthStatus( + {}, + { store, now: () => NOW, env: {}, stdout: (l) => out.push(l) }, + ); + expect(out.join('\n')).not.toContain('Region:'); + }); + it('emits plain text without ANSI when stdout is not a TTY', () => { const out: string[] = []; runAuthStatus( diff --git a/src/authToken.test.ts b/src/authToken.test.ts index 3a33bdd..dfaed41 100644 --- a/src/authToken.test.ts +++ b/src/authToken.test.ts @@ -7,6 +7,7 @@ import { createAnonymousRequest, formatVerificationPrompt, generatePkcePair, + pollDeviceTokenBounded, pollForToken, requestDeviceToken, runAuthTokenCommand, @@ -413,6 +414,25 @@ describe('createAnonymousRequest', () => { const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe('https://api.test/v1/auth/token'); expect(init?.headers).not.toHaveProperty('Authorization'); + // Identifies the API client to the server for analytics. + expect(new Headers(init?.headers).get('user-agent')).toMatch( + /^amp-cli\/\S+$/, + ); + // No device id was supplied, so no header is sent. + expect(new Headers(init?.headers).get('amp-device-id')).toBeNull(); + }); + + it('sends the persistent device_id as the Amp-Device-Id header', async () => { + const fetchMock = vi.fn(() => + Promise.resolve(new Response('{}', { status: 200 })), + ); + vi.stubGlobal('fetch', fetchMock); + + const request = createAnonymousRequest('https://api.test', 'install-xyz'); + await request('POST', '/v1/auth/device-authorization', { x: 1 }); + + const [, init] = fetchMock.mock.calls[0]; + expect(new Headers(init?.headers).get('amp-device-id')).toBe('install-xyz'); }); it('throws a readable error when the API is unreachable', async () => { @@ -447,3 +467,158 @@ describe('createAnonymousRequest', () => { }); }); }); + +const noSleep = async () => {}; +const token = { access_token: 'at', token_type: 'bearer', expires_in: 3600 }; + +describe('pollDeviceTokenBounded', () => { + it('returns authorized when the exchange succeeds', async () => { + const res = await pollDeviceTokenBounded({ + request: async () => ({ status: 200, body: token }), + deviceCode: 'dc', + codeVerifier: 'cv', + intervalSeconds: 1, + codeExpiresAtMs: 10_000, + timeoutSeconds: 5, + now: () => 0, + sleep: noSleep, + }); + expect(res).toEqual({ status: 'authorized', token }); + }); + + it('returns pending when the bounded timeout elapses but the code is still valid', async () => { + let t = 0; + const res = await pollDeviceTokenBounded({ + request: async () => ({ + status: 400, + body: { error: 'authorization_pending' }, + }), + deviceCode: 'dc', + codeVerifier: 'cv', + intervalSeconds: 1, + codeExpiresAtMs: 1_000_000, + timeoutSeconds: 2, + now: () => (t += 1000), + sleep: noSleep, + }); + expect(res).toEqual({ status: 'pending', interval: 1 }); + }); + + it('stops polling once a full interval no longer fits the timeout budget', async () => { + // Clock advances only by what we sleep, so wall-clock == sum(sleeps). + const sleeps: number[] = []; + let calls = 0; + const res = await pollDeviceTokenBounded({ + request: async () => { + calls += 1; + return { status: 400, body: { error: 'authorization_pending' } }; + }, + deviceCode: 'dc', + codeVerifier: 'cv', + intervalSeconds: 5, + codeExpiresAtMs: 1_000_000, + timeoutSeconds: 12, + now: () => sleeps.reduce((sum, s) => sum + s, 0) * 1000, + sleep: (s) => { + sleeps.push(s); + return Promise.resolve(); + }, + }); + // Polls at t=0 and t=5 (each +5s still fits the 12s budget), then at t=10 a + // further 5s sleep would overshoot to 15s — so it returns pending instead of + // sleeping past --timeout. Total slept 10s, never exceeding the budget. + expect(res).toEqual({ status: 'pending', interval: 5 }); + expect(sleeps).toEqual([5, 5]); + expect(calls).toBe(3); + }); + + it('retries transient server_error within the window (pending, not error)', async () => { + let t = 0; + const res = await pollDeviceTokenBounded({ + request: async () => ({ status: 500, body: { error: 'server_error' } }), + deviceCode: 'dc', + codeVerifier: 'cv', + intervalSeconds: 1, + codeExpiresAtMs: 1_000_000, + timeoutSeconds: 2, + now: () => (t += 1000), + sleep: noSleep, + }); + expect(res).toEqual({ status: 'pending', interval: 1 }); + }); + + it('raises the interval on slow_down and reports it on pending', async () => { + let t = 0; + const res = await pollDeviceTokenBounded({ + request: async () => ({ status: 400, body: { error: 'slow_down' } }), + deviceCode: 'dc', + codeVerifier: 'cv', + intervalSeconds: 1, + codeExpiresAtMs: 1_000_000, + timeoutSeconds: 2, + now: () => (t += 1000), + sleep: noSleep, + }); + expect(res).toEqual({ status: 'pending', interval: 6 }); + }); + + it('returns expired when the device code lifetime passes', async () => { + let t = 0; + const res = await pollDeviceTokenBounded({ + request: async () => ({ + status: 400, + body: { error: 'authorization_pending' }, + }), + deviceCode: 'dc', + codeVerifier: 'cv', + intervalSeconds: 1, + codeExpiresAtMs: 1500, + timeoutSeconds: 999, + now: () => (t += 1000), + sleep: noSleep, + }); + expect(res).toEqual({ status: 'expired' }); + }); + + it('returns error on access_denied', async () => { + const res = await pollDeviceTokenBounded({ + request: async () => ({ status: 400, body: { error: 'access_denied' } }), + deviceCode: 'dc', + codeVerifier: 'cv', + intervalSeconds: 1, + codeExpiresAtMs: 10_000, + timeoutSeconds: 5, + now: () => 0, + sleep: noSleep, + }); + expect(res.status).toBe('error'); + }); + + it('preserves the structured OAuth error on a non-expired_token error', async () => { + const res = await pollDeviceTokenBounded({ + request: async () => ({ + status: 400, + body: { + error: 'access_denied', + error_description: 'The user denied the request.', + error_hint: 'Ask the user to approve the code.', + }, + }), + deviceCode: 'dc', + codeVerifier: 'cv', + intervalSeconds: 1, + codeExpiresAtMs: 10_000, + timeoutSeconds: 5, + now: () => 0, + sleep: noSleep, + }); + expect(res).toEqual({ + status: 'error', + error: { + code: 'access_denied', + description: 'The user denied the request.', + hint: 'Ask the user to approve the code.', + }, + }); + }); +}); diff --git a/src/authToken.ts b/src/authToken.ts index bed4279..1d6ce6b 100644 --- a/src/authToken.ts +++ b/src/authToken.ts @@ -5,6 +5,7 @@ import { setTimeout as delay } from 'node:timers/promises'; import open from 'open'; import { z } from 'zod'; +import { CLI_VERSION } from './help'; import { toOAuthError } from './oauthError'; import { type DeviceAuthorizationResponse, @@ -73,13 +74,13 @@ export function formatVerificationPrompt( device: DeviceAuthorizationResponse, ): string { return [ - 'To authorize, confirm this code in your browser:', + 'To authorize, confirm this code:', '', ` ${device.user_code}`, '', 'At the following url:', '', - ` ${verificationUrl(device)}`, + verificationUrl(device), ].join('\n'); } @@ -280,6 +281,96 @@ export async function pollForToken({ } } +export type PollDeviceResult = + | { status: 'authorized'; token: TokenResponse } + // `interval` is the current poll interval (raised by any `slow_down`), so the + // caller can persist it — RFC 8628 §3.5 requires the raised interval to apply + // to all subsequent requests, including later poll invocations. + | { status: 'pending'; interval: number } + | { status: 'expired' } + | { + status: 'error'; + error: { code: string; description?: string; hint?: string }; + }; + +// RFC 6749 §5.2 transient errors — retry within the bounded window rather than +// giving up. `server_error` is also the fallback for a non-RFC/non-JSON body +// (see toOAuthError), so a transient token-endpoint blip never destroys a valid +// device code. +const TRANSIENT_OAUTH_ERRORS = new Set([ + 'server_error', + 'temporarily_unavailable', +]); + +interface PollDeviceBoundedOptions { + request: AnonymousRequest; + deviceCode: string; + codeVerifier: string; + intervalSeconds: number; + codeExpiresAtMs: number; + timeoutSeconds: number; + now: () => number; + sleep: (seconds: number) => Promise; +} + +// Bounded variant of pollForToken: instead of throwing on deadline, returns a +// result union so the caller can distinguish "my bounded timeout elapsed but +// the code is still valid" (pending — safe to resume polling later) from "the +// device code itself expired" (expired — the user must restart the flow). +export async function pollDeviceTokenBounded( + options: PollDeviceBoundedOptions, +): Promise { + let interval = options.intervalSeconds; + const timeoutDeadlineMs = options.now() + options.timeoutSeconds * 1000; + + for (;;) { + const { status, body } = await options.request('POST', '/v1/auth/token', { + grant_type: DEVICE_CODE_GRANT_TYPE, + device_code: options.deviceCode, + code_verifier: options.codeVerifier, + }); + + if (isOk(status)) { + return { + status: 'authorized', + token: parseResponse(tokenResponseSchema, body, 'token'), + }; + } + + const oauthError = toOAuthError(body); + if (oauthError.error === 'slow_down') { + // RFC 8628 §3.5: slow_down permanently raises the interval by 5s. + interval += 5; + } else if ( + oauthError.error !== 'authorization_pending' && + !TRANSIENT_OAUTH_ERRORS.has(oauthError.error) + ) { + return oauthError.error === 'expired_token' + ? { status: 'expired' } + : { + status: 'error', + error: { + code: oauthError.error, + description: oauthError.error_description ?? undefined, + hint: oauthError.error_hint ?? undefined, + }, + }; + } + + if (options.now() >= options.codeExpiresAtMs) { + return { status: 'expired' }; + } + // Only sleep + re-poll when a full RFC interval still fits before the + // deadline. Otherwise return pending now: sleeping the interval would + // overshoot the caller's --timeout budget, and a shorter sleep would poll + // faster than the interval RFC 8628 §3.5 mandates. + if (options.now() + interval * 1000 >= timeoutDeadlineMs) { + return { status: 'pending', interval }; + } + await options.sleep(interval); + } +} + export type AnonymousRequest = ( method: string, path: string, @@ -292,9 +383,21 @@ export type AnonymousRequest = ( // getJson/requestJson, never throws on a non-2xx — it returns the status and // parsed body for the poll loop to interpret. A non-JSON body (e.g. an HTML // error page) degrades to the raw text rather than throwing a parse error. -export function createAnonymousRequest(baseUrl: string): AnonymousRequest { +export function createAnonymousRequest( + baseUrl: string, + deviceId?: string, +): AnonymousRequest { return async (method, path, body) => { - const headers: Record = { Accept: 'application/json' }; + // Client-context headers, stamped on analytics server-side and sent on every + // request: `User-Agent` identifies the API client + version; `Amp-Device-Id` + // (when known) carries the persistent install id so the anonymous identity + // is stable across invocations. Headers, not the body, so they can't affect + // request validation. + const headers: Record = { + Accept: 'application/json', + 'User-Agent': `amp-cli/${CLI_VERSION}`, + ...(deviceId ? { 'Amp-Device-Id': deviceId } : {}), + }; if (body !== undefined) { headers['Content-Type'] = 'application/json'; } @@ -327,6 +430,8 @@ export interface DeviceFlowOptions { flow: string | undefined; scope?: string; // Token-optional, non-throwing HTTP call against the Developer API endpoints. + // The persistent device_id (when known) is carried as a header baked into this + // request by createAnonymousRequest — not a device-flow option. request: AnonymousRequest; sleep?: (seconds: number) => Promise; now?: () => number; diff --git a/src/cli-state.test.ts b/src/cli-state.test.ts new file mode 100644 index 0000000..e134ec4 --- /dev/null +++ b/src/cli-state.test.ts @@ -0,0 +1,107 @@ +import { + chmodSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { getOrCreateDeviceId } from './cli-state'; + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +describe('getOrCreateDeviceId', () => { + const dirs: string[] = []; + + afterEach(() => { + vi.restoreAllMocks(); + for (const dir of dirs) { + rmSync(dir, { force: true, recursive: true }); + } + dirs.length = 0; + }); + + function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'amp-state-')); + dirs.push(dir); + return dir; + } + + function tempPath(): string { + return join(tempDir(), 'state.json'); + } + + it('mints and persists a device_id with a created_at on first use', () => { + const path = tempPath(); + const id = getOrCreateDeviceId(path); + + expect(id).toMatch(UUID_RE); + const written = JSON.parse(readFileSync(path, 'utf8')); + expect(written.device_id).toBe(id); + expect(Number.isNaN(Date.parse(written.created_at))).toBe(false); + }); + + it('returns the same device_id on subsequent calls (stable)', () => { + const path = tempPath(); + expect(getOrCreateDeviceId(path)).toBe(getOrCreateDeviceId(path)); + }); + + it('returns an existing device_id from a valid file', () => { + const path = tempPath(); + writeFileSync(path, JSON.stringify({ version: 1, device_id: 'kept-id' })); + expect(getOrCreateDeviceId(path)).toBe('kept-id'); + }); + + it('mints into a valid file that has no device_id, preserving other keys', () => { + const path = tempPath(); + writeFileSync(path, JSON.stringify({ version: 1, future_flag: true })); + const id = getOrCreateDeviceId(path); + const written = JSON.parse(readFileSync(path, 'utf8')); + expect(written.device_id).toBe(id); + expect(written.future_flag).toBe(true); + }); + + it('recreates a corrupt file (self-heal) without any stderr output', () => { + const path = tempPath(); + writeFileSync(path, '{ not json'); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + const id = getOrCreateDeviceId(path); + expect(id).toMatch(UUID_RE); + // Corrupt content held no recoverable id, so it's replaced with a valid file. + expect(JSON.parse(readFileSync(path, 'utf8')).device_id).toBe(id); + // Telemetry is silent — no user-facing output. + expect(stderr).not.toHaveBeenCalled(); + }); + + it('never throws when the state cannot be written', () => { + // Read-only dir: the file is absent (read → mint path), but the write fails. + // The failure must be swallowed and the (ephemeral) id still returned. + const dir = tempDir(); + chmodSync(dir, 0o500); + let id: string | undefined; + try { + id = getOrCreateDeviceId(join(dir, 'state.json')); + } finally { + chmodSync(dir, 0o700); + } + expect(id).toMatch(UUID_RE); + }); + + it('leaves no temp file behind after a write', () => { + const path = tempPath(); + getOrCreateDeviceId(path); + const leftovers = readdirSync(dirname(path)).filter((f) => + f.includes('.tmp'), + ); + expect(leftovers).toEqual([]); + }); +}); diff --git a/src/cli-state.ts b/src/cli-state.ts new file mode 100644 index 0000000..0ac0530 --- /dev/null +++ b/src/cli-state.ts @@ -0,0 +1,108 @@ +import { randomUUID } from 'node:crypto'; +import { + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { z } from 'zod'; + +/** + * On-disk install state for the `amp` CLI: a versioned, non-secret file holding + * a stable per-installation device_id. Separate from credentials.json (the + * secrets store): this is write-once and must never churn, and would have + * nowhere to live once secrets move to an OS keychain. + * + * It mirrors the credential store's write mechanics (atomic temp-file + rename, + * 0600/0700, forward-compat `.catchall`) but follows two rules the credential + * store does not, because this is telemetry: it is entirely silent (never writes + * to stderr) and it never throws — a read or write failure degrades to "no id + * this run", never an error the user sees or that could break a command. + */ + +const CURRENT_VERSION = 1; + +const stateSchema = z + .object({ + version: z.number().int(), + device_id: z.string().min(1).optional(), + created_at: z.string().min(1).optional(), + }) + .catchall(z.unknown()); + +type CliState = z.infer; + +function statePath(): string { + return join(homedir(), '.amplitude', 'amp', 'state.json'); +} + +// The parsed state, or undefined when there is nothing usable to build on — +// missing, unreadable, corrupt, or schema-invalid all collapse to undefined, +// and the caller recreates the file. An unreadable/corrupt file holds no +// recoverable id, so recreating self-heals rather than getting stuck. +function readState(path: string): CliState | undefined { + try { + const parsed = stateSchema.safeParse( + JSON.parse(readFileSync(path, 'utf8')), + ); + return parsed.success ? parsed.data : undefined; + } catch { + return undefined; + } +} + +function writeState(state: CliState, path: string): void { + const dir = dirname(path); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + + const tempPath = join( + dir, + `.state.${process.pid}.${Date.now()}.${Math.random() + .toString(36) + .slice(2)}.tmp`, + ); + + try { + writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, { + mode: 0o600, + }); + renameSync(tempPath, path); + } catch (error) { + rmSync(tempPath, { force: true }); + throw error; + } +} + +/** + * The installation's stable device_id, minting and persisting one on first use + * and returning it unchanged thereafter. A missing, corrupt, or unreadable file + * is recreated (it holds no recoverable id, so this self-heals); a valid file + * without an id is filled in, preserving its other keys. Never throws and never + * writes to stderr: a telemetry helper must not break or bother the CLI, so a + * failed persist just yields an ephemeral id for this run. + */ +export function getOrCreateDeviceId(path: string = statePath()): string { + const existing = readState(path); + if (existing?.device_id) { + return existing.device_id; + } + + const deviceId = randomUUID(); + try { + writeState( + { + ...(existing ?? { version: CURRENT_VERSION }), + device_id: deviceId, + created_at: new Date().toISOString(), + }, + path, + ); + } catch { + // Best-effort persist. + } + return deviceId; +} diff --git a/src/cli.test.ts b/src/cli.test.ts index dabace1..aff3abd 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,8 +1,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { parseArgs } from './args'; +import * as authCommands from './auth-commands'; import { isHelpRequested, isVersionRequested, main } from './cli'; import { formatVersion } from './help'; +vi.mock('./auth-commands', async () => { + const actual = + await vi.importActual('./auth-commands'); + return { + ...actual, + runAuthLoginStart: vi.fn(), + runAuthLoginPoll: vi.fn(), + runAuthLogin: vi.fn(), + }; +}); + describe('isHelpRequested', () => { it('recognizes the help command and --help/-h in every form', () => { expect(isHelpRequested(['help', 'flags'], {})).toBe(true); @@ -47,6 +60,9 @@ describe('main routing', () => { throw new Error('fetch should not be called for these routes'); }), ); + vi.mocked(authCommands.runAuthLoginStart).mockClear(); + vi.mocked(authCommands.runAuthLoginPoll).mockClear(); + vi.mocked(authCommands.runAuthLogin).mockClear(); }); afterEach(() => { @@ -81,4 +97,100 @@ describe('main routing', () => { /Unknown auth command: auth use foo bar/, ); }); + + it('dispatches `auth login start` to runAuthLoginStart, not runAuthLoginPoll', async () => { + await runWith(['auth', 'login', 'start', '--region', 'us']); + + expect(authCommands.runAuthLoginStart).toHaveBeenCalledWith( + expect.objectContaining({ region: 'us' }), + ); + expect(authCommands.runAuthLoginPoll).not.toHaveBeenCalled(); + }); + + it('dispatches `auth login poll` to runAuthLoginPoll, not runAuthLoginStart', async () => { + await runWith(['auth', 'login', 'poll', '--profile', 'default']); + + expect(authCommands.runAuthLoginPoll).toHaveBeenCalledWith( + expect.objectContaining({ profile: 'default' }), + ); + expect(authCommands.runAuthLoginStart).not.toHaveBeenCalled(); + }); + + it('rejects `auth login start` with a trailing argument instead of ignoring it', async () => { + await expect( + runWith(['auth', 'login', 'start', 'default']), + ).rejects.toThrow(/Unknown auth command: auth login start default/); + expect(authCommands.runAuthLoginStart).not.toHaveBeenCalled(); + }); + + it('rejects `auth login poll` with a trailing argument instead of ignoring it', async () => { + await expect(runWith(['auth', 'login', 'poll', 'default'])).rejects.toThrow( + /Unknown auth command: auth login poll default/, + ); + expect(authCommands.runAuthLoginPoll).not.toHaveBeenCalled(); + }); + + describe('bare `auth login` TTY dispatch', () => { + let stdinIsTTY: PropertyDescriptor | undefined; + let stdoutIsTTY: PropertyDescriptor | undefined; + + beforeEach(() => { + stdinIsTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + stdoutIsTTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + }); + + afterEach(() => { + if (stdinIsTTY) { + Object.defineProperty(process.stdin, 'isTTY', stdinIsTTY); + } + if (stdoutIsTTY) { + Object.defineProperty(process.stdout, 'isTTY', stdoutIsTTY); + } + }); + + it('dispatches to runAuthLoginStart when not an interactive terminal', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: false, + configurable: true, + }); + Object.defineProperty(process.stdout, 'isTTY', { + value: false, + configurable: true, + }); + + await runWith(['auth', 'login']); + + expect(authCommands.runAuthLoginStart).toHaveBeenCalled(); + expect(authCommands.runAuthLogin).not.toHaveBeenCalled(); + }); + + it('dispatches to runAuthLogin when at an interactive terminal', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + }); + + await runWith(['auth', 'login']); + + expect(authCommands.runAuthLogin).toHaveBeenCalled(); + expect(authCommands.runAuthLoginStart).not.toHaveBeenCalled(); + }); + }); +}); + +describe('cli routing', () => { + it('keeps --timeout as a value flag', () => { + expect( + parseArgs(['auth', 'login', 'poll', '--timeout', '25']).flags.timeout, + ).toBe('25'); + }); + it('parses the login start/poll command path', () => { + expect( + parseArgs(['auth', 'login', 'start', '--region', 'us']).command, + ).toEqual(['auth', 'login', 'start']); + }); }); diff --git a/src/cli.ts b/src/cli.ts index 76dfc1e..2bde3eb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,13 +4,14 @@ import { type FlagValue, isFlagEnabled, parseArgs } from './args'; import { runAuthList, runAuthLogin, + runAuthLoginPoll, + runAuthLoginStart, runAuthPat, runAuthStatus, runAuthToken, runAuthUse, runLogout, } from './auth-commands'; -import { DEFAULT_API_BASE_URL } from './config'; import { findOperation, formatVersion, @@ -49,19 +50,42 @@ export async function main(): Promise { } if (command.length === 0) { - printGlobalHelp(DEFAULT_API_BASE_URL); + printGlobalHelp(); return; } if (isHelpRequested(command, flags)) { const topic = command[0] === 'help' ? command.slice(1) : command; - printCommandHelp(topic, DEFAULT_API_BASE_URL); + printCommandHelp(topic); return; } if (command[0] === 'auth') { + if ( + command.length === 3 && + command[1] === 'login' && + command[2] === 'start' + ) { + await runAuthLoginStart(flags); + return; + } + if ( + command.length === 3 && + command[1] === 'login' && + command[2] === 'poll' + ) { + await runAuthLoginPoll(flags); + return; + } if (command.length === 2 && command[1] === 'login') { - await runAuthLogin(flags); + const isInteractive = Boolean( + process.stdin.isTTY && process.stdout.isTTY, + ); + if (!isInteractive) { + await runAuthLoginStart(flags); + } else { + await runAuthLogin(flags); + } return; } if (command.length === 2 && command[1] === 'pat') { diff --git a/src/config.test.ts b/src/config.test.ts index 62a8bfa..c2c830a 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { resolveBaseUrl } from './config'; +import { + resolveBaseUrl, + resolveNamedBaseUrl, + resolveRegionBaseUrl, +} from './config'; function restoreEnvValue(key: string, value: string | undefined): void { if (value === undefined) { @@ -37,3 +41,44 @@ describe('resolveBaseUrl', () => { ); }); }); + +describe('resolveRegionBaseUrl', () => { + it('maps us and eu to the prod / prod-eu hosts', () => { + expect(resolveRegionBaseUrl('us')).toBe( + 'https://developer-api.amplitude.com', + ); + expect(resolveRegionBaseUrl('eu')).toBe( + 'https://developer-api.eu.amplitude.com', + ); + }); + + it('throws on an unknown region', () => { + expect(() => resolveRegionBaseUrl('apac')).toThrow( + 'Unknown --region "apac". Known: us, eu.', + ); + }); +}); + +describe('resolveNamedBaseUrl', () => { + it('resolves --region', () => { + expect(resolveNamedBaseUrl({ regionFlag: 'us' })).toBe( + 'https://developer-api.amplitude.com', + ); + }); + + it('resolves --env', () => { + expect(resolveNamedBaseUrl({ envFlag: 'staging' })).toBe( + 'https://developer-api.stag2.amplitude.com', + ); + }); + + it('returns undefined when neither is given', () => { + expect(resolveNamedBaseUrl({})).toBeUndefined(); + }); + + it('throws when both --region and --env are given', () => { + expect(() => + resolveNamedBaseUrl({ regionFlag: 'us', envFlag: 'staging' }), + ).toThrow('Pass either --region or --env, not both.'); + }); +}); diff --git a/src/config.ts b/src/config.ts index 91000e2..8ebb93b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -29,6 +29,54 @@ export function resolveEnvBaseUrl(name: string): string { return url; } +// Friendly `--region` names → the same Developer API base URLs as the +// internal prod/prod-eu envs. References ENV_BASE_URLS rather than +// hardcoding hosts a second time, so the two maps can't drift apart. +export const REGION_BASE_URLS: Record = { + us: ENV_BASE_URLS.prod, + eu: ENV_BASE_URLS['prod-eu'], +}; + +export function resolveRegionBaseUrl(name: string): string { + const url = REGION_BASE_URLS[name]; + if (!url) { + throw new Error( + `Unknown --region "${name}". Known: ${Object.keys(REGION_BASE_URLS).join(', ')}.`, + ); + } + return url; +} + +// Callers must run this before any --base-url short-circuit, not just inside +// resolveNamedBaseUrl — otherwise a conflicting --region/--env pair silently +// passes through whenever --base-url also happens to be set. +export function assertRegionAndEnvNotBothSet(options: { + envFlag?: string; + regionFlag?: string; +}): void { + if (options.regionFlag && options.envFlag) { + throw new Error('Pass either --region or --env, not both.'); + } +} + +// Centralizes the "--region xor --env" precedence shared by loginBaseUrl +// (auth-commands.ts) and baseUrlOverrideFromFlags (credential-resolver.ts) so +// both call sites agree on what each flag means and on the same +// mutual-exclusivity error. +export function resolveNamedBaseUrl(options: { + envFlag?: string; + regionFlag?: string; +}): string | undefined { + assertRegionAndEnvNotBothSet(options); + if (options.regionFlag) { + return resolveRegionBaseUrl(options.regionFlag); + } + if (options.envFlag) { + return resolveEnvBaseUrl(options.envFlag); + } + return undefined; +} + export function resolveBaseUrl(flags: Record): string { return ( stringFlag(flags, ['base-url']) ?? diff --git a/src/credential-resolver.test.ts b/src/credential-resolver.test.ts index 8863742..892d3af 100644 --- a/src/credential-resolver.test.ts +++ b/src/credential-resolver.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { authorizationHeaderForToken, resolveAuth, + resolveAuthFromFlags, } from './credential-resolver'; import { type CredentialStore, @@ -176,6 +177,34 @@ describe('resolveAuth', () => { }); }); +describe('resolveAuthFromFlags', () => { + it('resolves --region into the base URL', () => { + const r = resolveAuthFromFlags( + { region: 'eu' }, + { store: oauthStore('p', 'https://prod', FUTURE), now: NOW }, + ); + expect(r.baseUrl).toBe('https://developer-api.eu.amplitude.com'); + }); + + it('throws when both --region and --env are given', () => { + expect(() => + resolveAuthFromFlags( + { region: 'eu', env: 'staging' }, + { store: oauthStore('p', 'https://prod', FUTURE), now: NOW }, + ), + ).toThrow('Pass either --region or --env, not both.'); + }); + + it('throws when both --region and --env are given even though --base-url would win', () => { + expect(() => + resolveAuthFromFlags( + { region: 'us', env: 'staging', 'base-url': 'http://localhost:3036' }, + { store: oauthStore('p', 'https://prod', FUTURE), now: NOW }, + ), + ).toThrow('Pass either --region or --env, not both.'); + }); +}); + describe('authorizationHeaderForToken', () => { it('formats by token shape', () => { expect(authorizationHeaderForToken('amp_x')).toBe('Bearer PAT=amp_x'); diff --git a/src/credential-resolver.ts b/src/credential-resolver.ts index 474d6bd..63f6f3d 100644 --- a/src/credential-resolver.ts +++ b/src/credential-resolver.ts @@ -1,5 +1,9 @@ import { type FlagValue, stringFlag } from './args'; -import { DEFAULT_API_BASE_URL, resolveEnvBaseUrl } from './config'; +import { + assertRegionAndEnvNotBothSet, + DEFAULT_API_BASE_URL, + resolveNamedBaseUrl, +} from './config'; import { type CredentialStore, type Profile, @@ -196,19 +200,24 @@ export function resolveAuth(input: ResolveInput = {}): ResolvedAuth { /** * The base-url override carried by the request flags: explicit `--base-url` - * wins, else `--env` is mapped through the friendly-name table. Mirrors - * `loginBaseUrl`'s precedence so a command and a login agree on what an env - * name means. + * wins, else `--region`/`--env` is mapped through resolveNamedBaseUrl. Mirrors + * `loginBaseUrl`'s precedence so a command and a login agree on what a region + * or env name means. */ function baseUrlOverrideFromFlags( flags: Record, ): string | undefined { + const envFlag = stringFlag(flags, ['env']); + const regionFlag = stringFlag(flags, ['region']); + assertRegionAndEnvNotBothSet({ envFlag, regionFlag }); const baseUrl = stringFlag(flags, ['base-url']); if (baseUrl) { return baseUrl; } - const env = stringFlag(flags, ['env']); - return env ? resolveEnvBaseUrl(env) : undefined; + return resolveNamedBaseUrl({ + envFlag, + regionFlag, + }); } /** diff --git a/src/credential-store.test.ts b/src/credential-store.test.ts index 22f6b05..c4fbf17 100644 --- a/src/credential-store.test.ts +++ b/src/credential-store.test.ts @@ -290,8 +290,8 @@ describe('credential-store', () => { } }); - it('rejects the reserved name "default"', () => { - expect(() => assertValidProfileName('default')).toThrow(/reserved/); + it('accepts the name "default" (used as the implicit profile)', () => { + expect(() => assertValidProfileName('default')).not.toThrow(); }); it('rejects whitespace, control chars, slashes, empty, and over-length', () => { diff --git a/src/credential-store.ts b/src/credential-store.ts index 60434d5..58a299b 100644 --- a/src/credential-store.ts +++ b/src/credential-store.ts @@ -28,13 +28,16 @@ export const CURRENT_VERSION = 1; // lenient so a name written by a newer CLI (or before this rule existed) // round-trips instead of dropping the profile. Strict identifier charset keeps // names safe to interpolate into `auth list` / `status` output (no newlines or -// control chars to spoof a row) and predictable across tools. +// control chars to spoof a row) and predictable across tools. `default` is a +// valid name: it is the profile the CLI targets when --profile is omitted. export const profileNameSchema = z .string() .min(1, 'must not be empty') .max(64, 'must be at most 64 characters') - .regex(/^[A-Za-z0-9._-]+$/, 'use letters, digits, dot, underscore, or hyphen') - .refine((name) => name !== 'default', 'the name "default" is reserved'); + .regex( + /^[A-Za-z0-9._-]+$/, + 'use letters, digits, dot, underscore, or hyphen', + ); /** Throws a friendly error if `name` is not a valid profile name. */ export function assertValidProfileName(name: string): void { diff --git a/src/errors.ts b/src/errors.ts index 312a581..3b2f375 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -30,7 +30,7 @@ function hintForErrorCode(errorCode: string | undefined): string | undefined { switch (errorCode) { case 'authentication_required': case 'invalid_token': - return 'Run `amp auth login`, or set AMP_TOKEN for a single shell.'; + return 'Interactive: `amp auth login`. Agents/CI: `amp auth login start` then `amp auth login poll`, or `amp auth pat --with-token`, or set AMP_TOKEN.'; case 'insufficient_scope': return 'Run `amp context` to inspect your token, then re-authenticate with the required access (`amp auth login`).'; case 'validation_error': @@ -39,7 +39,7 @@ function hintForErrorCode(errorCode: string | undefined): string | undefined { return 'Verify identifiers such as --project, --flag, and --event.'; case 'auth_unavailable': case 'upstream_error': - return 'Retry the request. If it persists, check API status or use --base-url for a different host.'; + return 'Retry the request. If it persists, check API status or try a different region with --region.'; default: return undefined; } diff --git a/src/generated/cli-manifest.ts b/src/generated/cli-manifest.ts index fcb0df1..9b3133c 100644 --- a/src/generated/cli-manifest.ts +++ b/src/generated/cli-manifest.ts @@ -850,6 +850,155 @@ export const CLI_OPERATIONS = [ }, ], }, + { + command: ['charts', 'list'], + method: 'GET', + operationId: 'listCharts', + path: '/v1/projects/{project_id}/charts', + requiredScopes: ['read:analytics'], + summary: 'List charts', + 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: 'chart_type', + in: 'query', + required: false, + aliases: ['chart-type', 'type'], + type: 'string', + }, + { + name: 'sort', + in: 'query', + required: false, + aliases: ['sort'], + type: 'string', + }, + ], + body: [], + }, + { + command: ['charts', 'get'], + method: 'GET', + operationId: 'getChart', + path: '/v1/projects/{project_id}/charts/{chart_id}', + requiredScopes: ['read:analytics'], + summary: 'Get chart', + successStatus: 200, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'chart_id', + in: 'path', + required: true, + aliases: ['chart', 'chart-id'], + type: 'string', + }, + { + name: 'include_definition', + in: 'query', + required: false, + aliases: ['include-definition'], + type: 'boolean', + }, + ], + body: [], + }, + { + command: ['charts', 'query'], + method: 'POST', + operationId: 'queryChart', + path: '/v1/projects/{project_id}/charts/{chart_id}/query', + requiredScopes: ['read:analytics'], + summary: 'Query chart', + successStatus: 200, + parameters: [ + { + name: 'project_id', + in: 'path', + required: true, + aliases: ['project', 'project-id'], + type: 'string', + }, + { + name: 'chart_id', + in: 'path', + required: true, + aliases: ['chart', 'chart-id'], + type: 'string', + }, + ], + body: [ + { + name: 'time_range', + required: false, + aliases: ['time-range'], + type: 'object', + nullable: false, + }, + { + name: 'timezone', + required: false, + aliases: ['timezone'], + type: 'string', + nullable: false, + }, + { + name: 'exclude_incomplete_datapoints', + required: false, + aliases: ['exclude-incomplete-datapoints'], + type: 'boolean', + nullable: false, + }, + { + name: 'group_by_limit', + required: false, + aliases: ['group-by-limit'], + type: 'integer', + nullable: false, + }, + { + name: 'time_series_limit', + required: false, + aliases: ['time-series-limit'], + type: 'integer', + nullable: false, + }, + ], + }, { command: ['flags', 'list'], method: 'GET', diff --git a/src/generated/scopes.ts b/src/generated/scopes.ts new file mode 100644 index 0000000..42f3a97 --- /dev/null +++ b/src/generated/scopes.ts @@ -0,0 +1,6 @@ +/* eslint-disable */ +// This file is generated by scripts/generate-cli-manifest.ts. Do not edit by hand. + +// Base MCP scopes the CLI requests at login, sourced from the centralized +// OAuth scope catalog (amplitude/auth/oauth-scopes). +export const MCP_BASE_SCOPES = ['mcp:read', 'mcp:write'] as const; diff --git a/src/help.test.ts b/src/help.test.ts index e339b66..b8f3671 100644 --- a/src/help.test.ts +++ b/src/help.test.ts @@ -40,12 +40,27 @@ describe('help', () => { expect(help).toContain('amp auth login'); expect(help).toContain('amp auth status'); expect(help).toContain('--profile'); + expect(help).toContain('--region'); + expect(help).not.toContain('--env'); + expect(help).not.toContain('--base-url'); + }); + + it('mentions the scripting-friendly login start/poll verbs', () => { + const help = authHelpText(); + expect(help).toMatch(/login start/); + expect(help).toMatch(/login poll/); + }); + + it('describes --profile as optional, defaulting to the implicit "default" profile', () => { + const help = authHelpText(); + expect(help).toMatch(/implicit `default` profile/); + expect(help).not.toMatch(/needs both --profile/); }); it('describes DELETE safety without overstating --dry-run support', () => { const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); try { - printGlobalHelp('https://developer-api.amplitude.com'); + printGlobalHelp(); const output = log.mock.calls.map((call) => String(call[0])).join('\n'); expect(output).toContain('Skip interactive confirmation for DELETE'); expect(output).toContain('Preview supported DELETE commands'); @@ -54,4 +69,17 @@ describe('help', () => { log.mockRestore(); } }); + + it('documents --region and not --env/--base-url in global help', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + try { + printGlobalHelp(); + const output = log.mock.calls.map((call) => String(call[0])).join('\n'); + expect(output).toContain('--region '); + expect(output).not.toContain('--env'); + expect(output).not.toContain('--base-url'); + } finally { + log.mockRestore(); + } + }); }); diff --git a/src/help.ts b/src/help.ts index 8b17e67..d835bf9 100644 --- a/src/help.ts +++ b/src/help.ts @@ -135,15 +135,15 @@ function printSurfaceOverview(): string { return lines.join('\n'); } -export function printGlobalHelp(defaultApiBaseUrl: string): void { +export function printGlobalHelp(): void { console.log(`amp ${CLI_VERSION} — Amplitude Developer API CLI Usage: - amp auth login --profile --env Authenticate and save a profile - amp auth Inspect, switch, or print credentials - amp logout [--profile |--all] Remove a profile (or all) - amp version Print CLI version - amp help [surface...] Explore commands for a product surface + 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) + amp version Print CLI version + amp help [surface...] Explore commands for a product surface ${printSurfaceOverview()} @@ -152,8 +152,7 @@ Explore commands: amp --help Flags and examples for one command Global flags: - --base-url API base URL, defaults to ${defaultApiBaseUrl} - --env Target env (local|dev|staging|prod|prod-eu); sets the base URL + --region Target region (us|eu); sets the base URL --profile Use a stored profile for this command --token Raw PAT, PAT=, or bearer-compatible token --json Print raw JSON (default when piped) @@ -164,7 +163,6 @@ Global flags: Environment: AMP_TOKEN Raw token (amp_... → PAT, else bearer); overrides stored profiles AMP_PROFILE Stored profile to select by name - AMP_API_BASE_URL API base URL ~/.amplitude/amp/credentials.json Saved profiles from \`amp auth login\``); } @@ -175,41 +173,44 @@ export function authHelpText(): string { 'Authenticate and manage saved credential profiles.', '', 'Usage:', - ' amp auth login --profile --env Device flow → save + activate a profile', - ' amp auth login Re-authenticate the active profile', - ' amp auth pat --with-token --profile --env Save a supplied PAT (stdin/prompt)', - ' amp auth use Switch the active profile (no re-auth)', - ' amp auth list List profiles (* marks the active one)', - ' amp auth status Show the active credential and expiry', - ' amp auth token Print the active access token to stdout', - ' amp logout [--profile |--all] Remove a profile (or all)', + ' 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)', + ' amp auth use Switch the active profile (no re-auth)', + ' amp auth list List profiles (* marks the active one)', + ' amp auth status Show the active credential and expiry', + ' amp auth token Print the active access token to stdout', + ' amp logout [--profile |--all] Remove a profile (or all)', '', 'Examples:', - ' amp auth login --profile prod --env prod', - ' amp auth login --profile staging --env staging', + ' amp auth login --region us', + ' amp auth login --profile eu --region eu', ' amp auth use prod', ' TOKEN=$(amp auth token)', '', - 'Creating a profile is force-explicit: a new profile needs both --profile', - ' and --env (or --base-url ). Re-authenticating an existing', - 'profile reuses the env recorded on it, so a bare `amp auth login` refreshes', - 'the active profile in place. Login activates the profile it mints and prints', - 'the switch; the active identity never changes without a command.', + '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.', + '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', + 'without a command.', '', 'A login requests every scope the CLI can use by default, so all commands', 'work immediately.', '', + 'Agents / scripting (JSON):', + ' amp auth login start --region --json Begin the device flow', + ' amp auth login poll --json Complete it (add --timeout )', + ' Both always emit a JSON envelope — never prose — for scripted use.', + '', 'Environment:', ' AMP_TOKEN Raw token (amp_... → PAT, else bearer); overrides stored profiles', ' AMP_PROFILE Stored profile to select by name', - ' AMP_API_BASE_URL API base URL', ].join('\n'); } -export function printCommandHelp( - command: string[], - defaultApiBaseUrl: string, -): void { +export function printCommandHelp(command: string[]): void { if (command.length === 1 && command[0] === 'auth') { console.log(authHelpText()); return; @@ -227,7 +228,7 @@ export function printCommandHelp( return; } - printGlobalHelp(defaultApiBaseUrl); + printGlobalHelp(); } function printOperationHelp(operation: CliOperation): void { @@ -255,7 +256,7 @@ function printOperationHelp(operation: CliOperation): void { lines.push( '', - 'Global flags: --base-url, --token, --json, --yes, --body-json', + 'Global flags: --token, --json, --yes, --body-json', 'Run `amp help` for product surfaces.', ); diff --git a/src/pending-store.test.ts b/src/pending-store.test.ts new file mode 100644 index 0000000..51158cc --- /dev/null +++ b/src/pending-store.test.ts @@ -0,0 +1,88 @@ +import { mkdtempSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + emptyPending, + gcExpiredPending, + getPending, + isPendingExpired, + loadPending, + removePending, + savePending, + setPending, +} from './pending-store'; + +const entry = { + device_code: 'dc', + code_verifier: 'cv', + base_url: 'https://developer-api.amplitude.com', + expires_at: '2026-07-15T00:10:00.000Z', + interval: 5, + started_at: '2026-07-15T00:00:00.000Z', +}; + +function tmpFile() { + return join( + mkdtempSync(join(tmpdir(), 'amp-pending-')), + 'pending-logins.json', + ); +} + +function writeRaw(path: string, contents: string) { + writeFileSync(path, contents); +} + +describe('pending-store', () => { + it('round-trips an entry and writes 0600', () => { + const path = tmpFile(); + savePending(setPending(emptyPending(), 'default', entry), path); + expect(getPending(loadPending(path), 'default')).toEqual(entry); + // eslint-disable-next-line no-bitwise -- masking permission bits off st_mode + expect(statSync(path).mode & 0o777).toBe(0o600); + }); + + it('removePending drops the entry', () => { + const path = tmpFile(); + savePending(setPending(emptyPending(), 'default', entry), path); + savePending(removePending(loadPending(path), 'default'), path); + expect(getPending(loadPending(path), 'default')).toBeUndefined(); + }); + + it('treats missing/corrupt/mismatched files as empty', () => { + const path = tmpFile(); + expect(loadPending(path)).toEqual(emptyPending()); // ENOENT + savePending(emptyPending(), path); + writeRaw(path, '{ not json'); + expect(loadPending(path)).toEqual(emptyPending()); + writeRaw( + path, + JSON.stringify({ version: 1, pending: { x: { device_code: 'd' } } }), + ); + expect(loadPending(path)).toEqual(emptyPending()); // bad entry schema + }); + + it('isPendingExpired compares against expires_at', () => { + const at = Date.parse(entry.expires_at); + expect(isPendingExpired(entry, at - 1)).toBe(false); + expect(isPendingExpired(entry, at)).toBe(true); + }); + + it('gcExpiredPending drops expired entries and keeps live ones', () => { + const store = setPending( + setPending(emptyPending(), 'live', { + ...entry, + expires_at: '2026-07-15T00:10:00.000Z', + }), + 'dead', + { ...entry, expires_at: '2026-07-15T00:00:00.000Z' }, + ); + const gced = gcExpiredPending( + store, + Date.parse('2026-07-15T00:05:00.000Z'), + ); + expect(Object.keys(gced.pending)).toEqual(['live']); + }); +}); diff --git a/src/pending-store.ts b/src/pending-store.ts new file mode 100644 index 0000000..77b9a6a --- /dev/null +++ b/src/pending-store.ts @@ -0,0 +1,122 @@ +import { + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { z } from 'zod'; + +export const CURRENT_PENDING_VERSION = 1; + +const pendingEntrySchema = z + .object({ + device_code: z.string().min(1), + code_verifier: z.string().min(1), + base_url: z.string().min(1), + expires_at: z.string().min(1), + interval: z.number().int().positive(), + started_at: z.string().min(1), + }) + .catchall(z.unknown()); + +const pendingStoreSchema = z + .object({ + version: z.number().int(), + pending: z.record(z.string(), pendingEntrySchema), + }) + .catchall(z.unknown()); + +export type PendingEntry = z.infer; +export type PendingStore = z.infer; + +export function pendingPath(override?: string): string { + return ( + override ?? join(homedir(), '.amplitude', 'amp', 'pending-logins.json') + ); +} + +export function emptyPending(): PendingStore { + return { version: CURRENT_PENDING_VERSION, pending: {} }; +} + +// Pending entries are disposable: any unreadable / unparseable / schema-mismatched +// file is treated as "nothing in flight" rather than surfaced as an error. +export function loadPending(path: string = pendingPath()): PendingStore { + let raw: string; + try { + raw = readFileSync(path, 'utf8'); + } catch { + return emptyPending(); + } + try { + const parsed = pendingStoreSchema.safeParse(JSON.parse(raw)); + return parsed.success ? parsed.data : emptyPending(); + } catch { + return emptyPending(); + } +} + +export function savePending( + store: PendingStore, + path: string = pendingPath(), +): void { + const dir = dirname(path); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const tempPath = join( + dir, + `.pending-logins.${process.pid}.${Date.now()}.tmp`, + ); + try { + writeFileSync(tempPath, `${JSON.stringify(store, null, 2)}\n`, { + mode: 0o600, + }); + renameSync(tempPath, path); + } catch (error) { + rmSync(tempPath, { force: true }); + throw error; + } +} + +export function getPending( + store: PendingStore, + name: string, +): PendingEntry | undefined { + return store.pending[name]; +} + +export function setPending( + store: PendingStore, + name: string, + entry: PendingEntry, +): PendingStore { + return { ...store, pending: { ...store.pending, [name]: entry } }; +} + +export function removePending(store: PendingStore, name: string): PendingStore { + const pending = { ...store.pending }; + delete pending[name]; + return { ...store, pending }; +} + +export function isPendingExpired(entry: PendingEntry, now: number): boolean { + const at = Date.parse(entry.expires_at); + return Number.isNaN(at) ? true : at <= now; +} + +/** Returns a copy of the store with expired entries dropped (opportunistic GC). */ +export function gcExpiredPending( + store: PendingStore, + now: number, +): PendingStore { + const pending: Record = {}; + for (const [name, entry] of Object.entries(store.pending)) { + if (!isPendingExpired(entry, now)) { + pending[name] = entry; + } + } + return { ...store, pending }; +} diff --git a/src/scopes.test.ts b/src/scopes.test.ts index e591247..fa12949 100644 --- a/src/scopes.test.ts +++ b/src/scopes.test.ts @@ -14,4 +14,8 @@ describe('DEFAULT_SCOPES', () => { expect(scopes).toContain('mcp:read'); expect(scopes).toContain('mcp:write'); }); + + it('excludes read:analytics until the OAuth client registers it', () => { + expect(DEFAULT_SCOPES.split(' ')).not.toContain('read:analytics'); + }); }); diff --git a/src/scopes.ts b/src/scopes.ts index 19b9a82..7d1ad59 100644 --- a/src/scopes.ts +++ b/src/scopes.ts @@ -1,10 +1,16 @@ import { CLI_OPERATIONS } from './generated/cli-manifest'; +// Legacy org-level scopes, generated from the central OAuth scope catalog. +// They map to the granular API read/write scopes server-side, but the +// device-flow client can grant them directly, so include them in the default +// request for full coverage. No CLI command declares them as required. +import { MCP_BASE_SCOPES } from './generated/scopes'; -// Legacy org-level scopes. They map to the granular API read/write scopes -// server-side, but the device-flow client can grant them directly, so include -// them in the default request for full coverage. Added explicitly because no -// CLI command declares them as required. -const MCP_SCOPES = ['mcp:read', 'mcp:write']; +/** + * Scopes declared in the OpenAPI manifest but not yet registered on the + * device-flow OAuth client. Requesting these during login makes Hydra reject + * the whole authorization. Remove entries here once MCP-396 registers them. + */ +const LOGIN_SCOPE_EXCLUSIONS = new Set(['read:analytics']); /** * The scope set a login requests when no `--scope` is given. The granular @@ -15,8 +21,9 @@ const MCP_SCOPES = ['mcp:read', 'mcp:write']; export const DEFAULT_SCOPES: string = [ ...new Set([ ...CLI_OPERATIONS.flatMap((operation) => operation.requiredScopes), - ...MCP_SCOPES, + ...MCP_BASE_SCOPES, ]), ] + .filter((scope) => !LOGIN_SCOPE_EXCLUSIONS.has(scope)) .sort() .join(' ');