Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ In a shell with no terminal to paste into, such as an agent's, `telemetry login
telemetry login --finish 'http://127.0.0.1:53124/callback?code=…'
```

A script or agent can run `telemetry login --json` instead. It never opens a browser or waits. It prints the link to pass on to a person, then exits:

```json
{
"status": "pending",
"authorization_url": "https://telemetry.dev/api/auth/oauth2/authorize?…",
"next": "telemetry login --finish '<address>'"
}
```

`telemetry login --finish '<address>' --json` then prints the sign-in, with the same fields as `telemetry status --json` and `"status": "signed_in"`. When the workspace has exactly one project, it becomes the default. `project` is `null` otherwise, and also when the projects couldn't be loaded.

A sign-in covers one workspace. To read another workspace, run `telemetry login` again and choose it. The new sign-in replaces the old one, and the CLI asks telemetry.dev to revoke the old one.

## Use
Expand All @@ -57,7 +69,7 @@ List commands return 20 results by default. Use `--limit` to return from 1 to 50

## Scripts and agents

Add `--json` to any command except `login` and `logout` for machine-readable output on stdout. Messages and progress go to stderr, so `telemetry traces --json > traces.json` writes only data. List commands print the MCP tool's result as returned, with the fields that the [MCP server documentation](https://telemetry.dev/docs/platform/mcp-server) describes.
Add `--json` to any command for machine-readable output on stdout. Messages and progress go to stderr, so `telemetry traces --json > traces.json` writes only data. List commands print the MCP tool's result as returned, with the fields that the [MCP server documentation](https://telemetry.dev/docs/platform/mcp-server) describes.

`telemetry trace` and `telemetry payload` read every page of a trace or a captured text, up to a limit: 2,000 spans for a trace, and 1,000,000 characters for each payload field. Their JSON output has `complete: false` when the limit cut it short, and human output says so on stderr.

Expand All @@ -77,7 +89,7 @@ With `--json`, an error is one line of JSON on stderr, and stdout stays empty. C
{"error":{"code":"not_signed_in","message":"You're not signed in. Run `telemetry login` first.","hint":"telemetry login","retryable":false,"exit_code":4,"cli_version":"0.1.0"}}
```

Branch on `code`, not on `message`, whose wording can change. `hint` is a command to run next, when there is one; `telemetry login` needs a person at a browser, so an agent should ask for it rather than run it. `retryable` is true when running the same command again may succeed. New codes may be added, and existing codes keep their meaning.
Branch on `code`, not on `message`, whose wording can change. `hint` is a command to run next, when there is one. `telemetry login` needs a person at a browser, so an agent should run `telemetry login --json` and pass the link on rather than run `telemetry login`. `retryable` is true when running the same command again may succeed. New codes may be added, and existing codes keep their meaning.

| Code | Exit | Meaning |
| ---------------------- | ---- | -------------------------------------------------------------------------------- |
Expand All @@ -90,6 +102,8 @@ Branch on `code`, not on `message`, whose wording can change. `hint` is a comman
| `not_signed_in` | 4 | No sign-in is saved for this deployment |
| `sign_in_expired` | 4 | The sign-in expired or was disconnected |
| `payloads_not_allowed` | 4 | The sign-in doesn't include reading captured prompts and outputs |
| `sign_in_failed` | 4 | The sign-in was cancelled, refused, or used an address from another sign-in |
| `no_pending_sign_in` | 4 | `login --finish` found no sign-in waiting to finish |
| `unreachable` | 1 | telemetry.dev could not be reached |
| `timeout` | 1 | telemetry.dev took too long to answer |
| `tool_error` | 1 | telemetry.dev returned an error; `message` says what it was |
Expand All @@ -107,4 +121,4 @@ Branch on `code`, not on `message`, whose wording can change. `hint` is a comman
| `TELEMETRY_DEV_CONFIG_DIR` | `~/.config/telemetry-dev` | Where the sign-in is saved. The default follows `XDG_CONFIG_HOME` when set |
| `NO_COLOR` | none | Turns off color. `--no-color` does the same |

The sign-in is saved in `session.json`, which only your user can read. A sign-in to another deployment through `TELEMETRY_DEV_URL` gets its own file next to it. The file holds a refresh token, so keep it private. `telemetry logout` revokes the token at telemetry.dev and removes it from the file, and says so when the server doesn't confirm the revocation. To disconnect the CLI from your account entirely, open account settings at telemetry.dev.
The sign-in is saved in `session.json`, which only your user can read. A sign-in to another deployment through `TELEMETRY_DEV_URL` gets its own file next to it. The file holds a refresh token, so keep it private. `telemetry logout` revokes the token at telemetry.dev and removes it from the file, and says so when the server doesn't confirm the revocation. `telemetry logout --json` prints `{"was_signed_in": true, "revoked": false}` in that case; `revoked` is `null` when there was no sign-in. To disconnect the CLI from your account entirely, open account settings at telemetry.dev.
74 changes: 1 addition & 73 deletions packages/cli/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Client } from "@modelcontextprotocol/client";

import { SessionAuthProvider } from "./auth.ts";
import { CliError, EXIT, notSignedIn } from "./errors.ts";
import { CliError, notSignedIn, translateError } from "./errors.ts";
import { mcpUrl, type Session } from "./session.ts";
import { withSpinner } from "./terminal.ts";

Expand Down Expand Up @@ -95,75 +95,3 @@ export class Api {
await client?.close().catch(() => {});
}
}

// Each is a refresh or authorization the server refused for good.
type AuthErrors = readonly (abstract new (...args: never[]) => Error)[];

const NETWORK_CODES = new Set([
"ECONNREFUSED",
"ECONNRESET",
"EAI_AGAIN",
"EHOSTUNREACH",
"ENETUNREACH",
"ENOTFOUND",
"ETIMEDOUT",
"UND_ERR_SOCKET",
]);

// undici's own timeouts, raised by fetch before any of the CLI's timers fire.
const TIMEOUT_CODES = new Set([
"UND_ERR_CONNECT_TIMEOUT",
"UND_ERR_HEADERS_TIMEOUT",
"UND_ERR_BODY_TIMEOUT",
]);

const translateError = (error: unknown, authErrors: AuthErrors): never => {
if (error instanceof CliError) throw error;

if (authErrors.some((type) => error instanceof type)) {
throw new CliError(
"Your sign-in has expired or was disconnected. Run `telemetry login` again.",
{ exitCode: EXIT.auth, code: "sign_in_expired", hint: "telemetry login" },
);
}

// The network error sits at the bottom of a chain: the SDK wraps fetch's
// "fetch failed", which wraps the socket error that has the code.
const chain: unknown[] = [error];

for (let link = error; link instanceof Error && link.cause !== undefined && chain.length < 8; ) {
link = link.cause;
chain.push(link);
}

const codes = chain
.map((link) => (link as { code?: unknown } | null)?.code)
.filter((code): code is string => typeof code === "string");
const network = codes.find((code) => NETWORK_CODES.has(code));
// Node's AggregateError for a failed connection has an empty message.
const message =
chain.findLast((link): link is Error => link instanceof Error && link.message !== "")
?.message ?? String(error);

if (network) {
throw new CliError(
`Couldn't reach telemetry.dev (${network}). Check your connection and try again.`,
{ code: "unreachable", retryable: true },
);
}

if (
codes.some((code) => code === "REQUEST_TIMEOUT" || TIMEOUT_CODES.has(code)) ||
chain.some(
(link) =>
link instanceof Error && (link.name === "TimeoutError" || link.name === "AbortError"),
)
) {
throw new CliError("telemetry.dev took too long to answer. Try again shortly.", {
code: "timeout",
retryable: true,
});
}

throw new CliError(`telemetry.dev request failed: ${message}`, { code: "request_failed" });
};
29 changes: 23 additions & 6 deletions packages/cli/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type {
StoredOAuthTokens,
} from "@modelcontextprotocol/client";

import { CliError, EXIT } from "./errors.ts";
import { CliError, EXIT, translateError } from "./errors.ts";
import { loadSession, mcpUrl, type Session, updateSession } from "./session.ts";
import { oneLine } from "./terminal.ts";

Expand Down Expand Up @@ -426,6 +426,8 @@ const readPastedCallback = async (state: string, signal: AbortSignal): Promise<C
export type LoginOptions = {
readonly session: Session;
readonly browser: boolean;
/** Return as soon as the link exists, leaving a pending sign-in for `finish`. */
readonly defer?: boolean;
/** The address the browser ended on, to finish a sign-in started without a terminal. */
readonly finish?: string | undefined;
readonly onAuthorizationUrl: (url: URL, opened: boolean) => void;
Expand Down Expand Up @@ -458,8 +460,13 @@ const authorize = async (
provider: SessionAuthProvider,
options: { authorizationCode?: string; iss?: string },
) => {
const { auth, AuthorizationServerMismatchError, IssuerMismatchError, OAuthError } =
await import("@modelcontextprotocol/client");
const {
auth,
AuthorizationServerMismatchError,
IssuerMismatchError,
OAuthClientFlowError,
OAuthError,
} = await import("@modelcontextprotocol/client");

try {
return await auth(provider, {
Expand Down Expand Up @@ -487,7 +494,16 @@ const authorize = async (
);
}

throw error;
// Such as a refused client registration or a token endpoint that isn't https.
if (error instanceof OAuthClientFlowError) {
throw new CliError(`Sign-in failed: ${oneLine(error.message).slice(0, 300)}`, {
exitCode: EXIT.auth,
code: "sign_in_failed",
hint: "telemetry login",
});
}

return translateError(error, []);
}
};

Expand Down Expand Up @@ -549,6 +565,7 @@ const redeem = async (provider: SessionAuthProvider, params: URLSearchParams) =>
export const login = async ({
session,
browser,
defer = false,
finish,
onAuthorizationUrl,
onWaiting,
Expand Down Expand Up @@ -612,8 +629,8 @@ export const login = async ({
const state = randomBytes(16).toString("base64url");
const callback = await listenForCallback(state);
const abort = new AbortController();
const useBrowser = browser && canOpenBrowser();
const deferred = !useBrowser && !process.stdin.isTTY;
const useBrowser = browser && !defer && canOpenBrowser();
const deferred = defer || (!useBrowser && !process.stdin.isTTY);

const provider = new SessionAuthProvider(signedIn, {
redirectUrl: callback.redirectUrl,
Expand Down
11 changes: 8 additions & 3 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ const cursor: Option = {
const COMMANDS: Readonly<Record<string, Command>> = {
login: {
summary: "Sign in with your browser",
usage: "telemetry login [--no-browser | --finish <address>]",
usage: "telemetry login [--no-browser | --finish <address>] [--json]",
options: {
"no-browser": {
type: "boolean",
Expand All @@ -73,18 +73,23 @@ const COMMANDS: Readonly<Record<string, Command>> = {
description:
"Finish a sign-in started without a terminal, with the browser's final address",
},
json: {
type: "boolean",
description: "Print JSON: the sign-in link without waiting, or the sign-in after --finish",
},
},
examples: [
"telemetry login",
"telemetry login --no-browser # over SSH, in a container, or from an agent's shell",
"telemetry login --finish 'http://127.0.0.1:53124/callback?code=…'",
"telemetry login --json # a script passes the link on, then runs --finish",
],
run: loginCommand,
},
logout: {
summary: "Sign out on this machine",
usage: "telemetry logout",
options: {},
usage: "telemetry logout [--json]",
options: { json },
examples: ["telemetry logout"],
run: logoutCommand,
},
Expand Down
Loading
Loading