diff --git a/packages/cli/README.md b/packages/cli/README.md
index feb416b0..60884425 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -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 '
'"
+}
+```
+
+`telemetry login --finish '' --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
@@ -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.
@@ -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 |
| ---------------------- | ---- | -------------------------------------------------------------------------------- |
@@ -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 |
@@ -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.
diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts
index 890ff8d7..d448e7e2 100644
--- a/packages/cli/src/api.ts
+++ b/packages/cli/src/api.ts
@@ -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";
@@ -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" });
-};
diff --git a/packages/cli/src/auth.ts b/packages/cli/src/auth.ts
index 7fcdde0f..a486fe6b 100644
--- a/packages/cli/src/auth.ts
+++ b/packages/cli/src/auth.ts
@@ -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";
@@ -426,6 +426,8 @@ const readPastedCallback = async (state: string, signal: AbortSignal): Promise void;
@@ -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, {
@@ -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, []);
}
};
@@ -549,6 +565,7 @@ const redeem = async (provider: SessionAuthProvider, params: URLSearchParams) =>
export const login = async ({
session,
browser,
+ defer = false,
finish,
onAuthorizationUrl,
onWaiting,
@@ -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,
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index 03696604..93d877a3 100644
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -61,7 +61,7 @@ const cursor: Option = {
const COMMANDS: Readonly> = {
login: {
summary: "Sign in with your browser",
- usage: "telemetry login [--no-browser | --finish ]",
+ usage: "telemetry login [--no-browser | --finish ] [--json]",
options: {
"no-browser": {
type: "boolean",
@@ -73,18 +73,23 @@ const COMMANDS: Readonly> = {
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,
},
diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts
index b70dd7aa..b5529ae2 100644
--- a/packages/cli/src/commands.ts
+++ b/packages/cli/src/commands.ts
@@ -323,20 +323,29 @@ export async function loginCommand(context: Context) {
const server = serverUrl();
const session = await loadSession(server);
const stream = process.stderr;
+ const asJson = json(context);
const finish = typeof context.values.finish === "string" ? context.values.finish : undefined;
- if (process.stderr.isTTY && finish === undefined) {
+ if (process.stderr.isTTY && finish === undefined && !asJson) {
stream.write("\n");
await printMark(TAGLINE, { animate: true, stream });
stream.write("\n");
}
+ let authorizationUrl = "";
+
const result = await login({
session,
browser: context.values["no-browser"] !== true,
+ // A program reading JSON can't wait for a person, so it gets the link to pass on.
+ defer: asJson,
finish,
onAuthorizationUrl: (url, opened) => {
+ authorizationUrl = url.href;
+
+ if (asJson) return;
+
note(
opened
? "Opening your browser to sign in to telemetry.dev."
@@ -350,11 +359,21 @@ export async function loginCommand(context: Context) {
note(style.dim("Waiting for you to choose a workspace and select Allow…", stream));
},
}).catch((error: unknown) => {
- reportStatus("error");
+ if (!asJson) reportStatus("error");
throw error;
});
if (result.status === "pending") {
+ if (asJson) {
+ printJson({
+ status: "pending",
+ authorization_url: authorizationUrl,
+ next: "telemetry login --finish ''",
+ });
+
+ return;
+ }
+
note(
"After you select Allow, the browser opens an http://127.0.0.1 address that doesn't load. Copy the whole address and run:",
);
@@ -366,64 +385,94 @@ export async function loginCommand(context: Context) {
const signedIn = result.session;
- reportStatus("done");
+ if (!asJson) reportStatus("done");
+ // The sign-in is saved by now, so a failed lookup leaves the project unset rather than
+ // failing a sign-in that worked.
const api = new Api(signedIn);
+ let projects: ProjectList["projects"] | undefined;
+ let lookupFailure: CliError | undefined;
try {
- const { projects } = await api.call("list_projects", {}, "Loading projects");
+ ({ projects } = await api.call("list_projects", {}, "Loading projects"));
+ } catch (error) {
+ if (!(error instanceof CliError)) throw error;
- if (projects.length === 1) {
- const project = toProject(projects[0]!);
+ lookupFailure = error;
+ } finally {
+ await api.close();
+ }
- signedIn.project = project;
- await updateSession(signedIn.server, (saved) => ({ ...saved, project }));
- }
+ if (projects?.length === 1) {
+ const project = toProject(projects[0]!);
- note();
+ signedIn.project = project;
+ await updateSession(signedIn.server, (saved) => ({ ...saved, project }));
+ }
+
+ if (asJson) {
+ printJson({ status: "signed_in", ...sessionJson(signedIn) });
+
+ return;
+ }
+
+ note();
+ note(
+ `${style.green("✓", stream)} Signed in${signedIn.email ? ` as ${style.bold(signedIn.email, stream)}` : ""}`,
+ );
+
+ if (!grantedScopes(signedIn).has(PAYLOADS_SCOPE)) {
note(
- `${style.green("✓", stream)} Signed in${signedIn.email ? ` as ${style.bold(signedIn.email, stream)}` : ""}`,
+ style.dim(
+ " Captured prompts and outputs stay hidden. Sign in again and allow them to use `telemetry payload`.",
+ stream,
+ ),
);
+ }
- if (!grantedScopes(signedIn).has(PAYLOADS_SCOPE)) {
- note(
- style.dim(
- " Captured prompts and outputs stay hidden. Sign in again and allow them to use `telemetry payload`.",
- stream,
- ),
- );
- }
-
- if (signedIn.project) {
- note(` Project ${style.bold(signedIn.project.name, stream)}`);
- note();
- note(
- `Try ${style.bold("telemetry traces", stream)} or ${style.bold("telemetry usage", stream)}.`,
- );
- } else if (projects.length > 1) {
- note(` ${projects.length} projects: ${projectNames(projects)}`);
- note();
- note(
- `Choose a default: ${style.bold(`telemetry use ${shellQuote(toProject(projects[0]!).name)}`, stream)}`,
- );
- } else {
- note(" This workspace has no projects yet. Create one at telemetry.dev.");
- }
- } finally {
- await api.close();
+ if (lookupFailure) {
+ note(style.dim(` Couldn't load your projects: ${oneLine(lookupFailure.message)}`, stream));
+ note(style.dim(" Run telemetry projects to list them.", stream));
+ } else if (signedIn.project) {
+ note(` Project ${style.bold(signedIn.project.name, stream)}`);
+ note();
+ note(
+ `Try ${style.bold("telemetry traces", stream)} or ${style.bold("telemetry usage", stream)}.`,
+ );
+ } else if (projects && projects.length > 1) {
+ note(` ${projects.length} projects: ${projectNames(projects)}`);
+ note();
+ note(
+ `Choose a default: ${style.bold(`telemetry use ${shellQuote(toProject(projects[0]!).name)}`, stream)}`,
+ );
+ } else {
+ note(" This workspace has no projects yet. Create one at telemetry.dev.");
}
}
-export async function logoutCommand() {
+export async function logoutCommand(context: Context) {
const session = await loadSession(serverUrl());
if (!session.tokens) {
+ if (json(context)) {
+ printJson({ was_signed_in: false, revoked: null });
+
+ return;
+ }
+
note("You're not signed in.");
return;
}
const { revoked } = await logout(session);
+
+ if (json(context)) {
+ printJson({ was_signed_in: true, revoked });
+
+ return;
+ }
+
note(
`${style.green("✓", process.stderr)} Signed out${session.email ? ` of ${session.email}` : ""}.`,
);
@@ -437,19 +486,22 @@ export async function logoutCommand() {
);
}
+/** What `status --json` and `login --json` say about a sign-in. */
+const sessionJson = (session: Session) => ({
+ signed_in: Boolean(session.tokens),
+ server: session.server,
+ email: session.email ?? null,
+ project: session.project ?? null,
+ payloads_allowed: grantedScopes(session).has(PAYLOADS_SCOPE),
+});
+
export async function statusCommand(context: Context) {
const server = serverUrl();
const session = await loadSession(server);
const scopes = grantedScopes(session);
if (json(context)) {
- printJson({
- signed_in: Boolean(session.tokens),
- server,
- email: session.email ?? null,
- project: session.project ?? null,
- payloads_allowed: scopes.has(PAYLOADS_SCOPE),
- });
+ printJson(sessionJson(session));
} else if (session.tokens) {
out(
`${style.green("✓")} Signed in${session.email ? ` as ${style.bold(oneLine(session.email))}` : ""}`,
diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts
index 1cb0b3c8..30a9d8e0 100644
--- a/packages/cli/src/errors.ts
+++ b/packages/cli/src/errors.ts
@@ -68,3 +68,75 @@ export const notSignedIn = () =>
code: "not_signed_in",
hint: "telemetry login",
});
+
+// 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",
+]);
+
+export 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" });
+};
diff --git a/packages/cli/tests/cli.test.ts b/packages/cli/tests/cli.test.ts
index 1f4743fd..452b3fa5 100644
--- a/packages/cli/tests/cli.test.ts
+++ b/packages/cli/tests/cli.test.ts
@@ -12,6 +12,7 @@ import {
beforeRefresh,
calls,
EMAIL,
+ failProjects,
PARALLEL_TRACE_ID,
PAYLOADS,
PROJECTS,
@@ -58,6 +59,7 @@ beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), "telemetry-cli-"));
calls.length = 0;
delete beforeRefresh.run;
+ failProjects.value = false;
return () => rm(home, { recursive: true, force: true });
});
@@ -83,6 +85,15 @@ const runWith = (env: Record, ...args: string[]) =>
const run = (...args: string[]) => runWith({}, ...args);
+/** The address of a port that was just closed, which refuses connections. */
+const closedServer = () =>
+ new Promise((resolve) => {
+ const server = createServer().listen(0, "127.0.0.1", () => {
+ const { port } = server.address() as AddressInfo;
+ server.close(() => resolve(`http://127.0.0.1:${port}`));
+ });
+ });
+
const CONTROL = new RegExp(String.raw`[\u0000-\u0008\u000b-\u001f\u007f-\u009f]`);
test("human output never passes terminal escapes from captured text, and --json keeps the data", async () => {
@@ -323,13 +334,7 @@ test("with --json, an error is one JSON line on stderr with a code to branch on,
message: "Trace not found.",
});
- // A port that was just closed refuses the connection.
- const closed = await new Promise((resolve) => {
- const server = createServer().listen(0, "127.0.0.1", () => {
- const { port } = server.address() as AddressInfo;
- server.close(() => resolve(`http://127.0.0.1:${port}`));
- });
- });
+ const closed = await closedServer();
await writeFile(
join(home, sessionFile(closed)),
JSON.stringify({ server: closed, tokens: { access_token: TOKEN, token_type: "Bearer" } }),
@@ -445,6 +450,109 @@ test("a shell without a terminal signs in with --no-browser, then --finish, keep
expect(again.code).toBe(4);
});
+test("login --json prints the link without opening a browser or waiting, and --finish --json prints the sign-in", async () => {
+ // A browser that could open: an `open` and `xdg-open` that record being run.
+ const bin = await mkdtemp(join(tmpdir(), "telemetry-cli-bin-"));
+ const opened = join(bin, "opened");
+ for (const name of ["open", "xdg-open"]) {
+ await writeFile(join(bin, name), `#!/bin/sh\ntouch '${opened}'\n`, { mode: 0o755 });
+ }
+ const browser = { PATH: `${bin}:${process.env.PATH}`, DISPLAY: ":0" };
+
+ try {
+ const started = await runWith(browser, "login", "--json");
+ expect(started).toMatchObject({ code: 0, stderr: "" });
+
+ const pending = JSON.parse(started.stdout);
+ expect(pending).toEqual({
+ status: "pending",
+ authorization_url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/authorize\?/),
+ next: "telemetry login --finish ''",
+ });
+
+ const approved = await fetch(pending.authorization_url, { redirect: "manual" });
+ const finished = await run("login", "--finish", approved.headers.get("location")!, "--json");
+ expect(finished).toMatchObject({ code: 0, stderr: "" });
+
+ const status = JSON.parse((await run("status", "--json")).stdout);
+ expect(status).toMatchObject({ signed_in: true, email: EMAIL });
+ expect(JSON.parse(finished.stdout)).toEqual({ status: "signed_in", ...status });
+
+ const again = await run("login", "--finish", approved.headers.get("location")!, "--json");
+ expect(again).toMatchObject({ code: 4, stdout: "" });
+ expect(JSON.parse(again.stderr).error.code).toBe("no_pending_sign_in");
+
+ expect(JSON.parse((await run("logout", "--json")).stdout)).toEqual({
+ was_signed_in: true,
+ revoked: true,
+ });
+ expect(JSON.parse((await run("logout", "--json")).stdout)).toEqual({
+ was_signed_in: false,
+ revoked: null,
+ });
+
+ // `open` runs detached, so by now, several commands later, it would have left its mark.
+ await expect(stat(opened)).rejects.toThrow();
+ } finally {
+ await rm(bin, { recursive: true });
+ }
+});
+
+test("login --json returns the link at once even when stdin is a terminal", async () => {
+ const { login } = await import("../src/auth.ts");
+ const { loadSession } = await import("../src/session.ts");
+
+ vi.stubEnv("TELEMETRY_DEV_CONFIG_DIR", home);
+ const isTTY = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
+ Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
+
+ try {
+ const result = await login({
+ session: await loadSession(fixture.url),
+ browser: false,
+ defer: true,
+ onAuthorizationUrl: () => {},
+ onWaiting: () => {
+ throw new Error("login waited for a person");
+ },
+ });
+
+ expect(result).toEqual({ status: "pending" });
+ expect((await loadSession(fixture.url)).pending).toBeDefined();
+ } finally {
+ if (isTTY) Object.defineProperty(process.stdin, "isTTY", isTTY);
+ else delete (process.stdin as { isTTY?: boolean }).isTTY;
+ vi.unstubAllEnvs();
+ }
+});
+
+test("a sign-in whose project list fails still signs in, without a default project", async () => {
+ failProjects.value = true;
+
+ for (const format of [[], ["--json"]]) {
+ const started = await run("login", "--json");
+ const approved = await fetch(JSON.parse(started.stdout).authorization_url, {
+ redirect: "manual",
+ });
+ const finished = await run("login", "--finish", approved.headers.get("location")!, ...format);
+
+ expect(finished.code).toBe(0);
+ if (format.length) expect(JSON.parse(finished.stdout)).toMatchObject({ project: null });
+ else expect(finished.stderr).toContain("Couldn't load your projects");
+ }
+
+ expect(JSON.parse((await run("status", "--json")).stdout)).toMatchObject({
+ signed_in: true,
+ project: null,
+ });
+});
+
+test("a sign-in that can't reach the server says so, like any other command", async () => {
+ const result = await runWith({ TELEMETRY_DEV_URL: await closedServer() }, "login", "--json");
+ expect(result).toMatchObject({ code: 1, stdout: "" });
+ expect(JSON.parse(result.stderr).error).toMatchObject({ code: "unreachable", retryable: true });
+});
+
test("an expired access token is refreshed and the new one saved", async () => {
await writeFile(
sessionPath(),
diff --git a/packages/cli/tests/fixture.ts b/packages/cli/tests/fixture.ts
index 707483cf..392bd667 100644
--- a/packages/cli/tests/fixture.ts
+++ b/packages/cli/tests/fixture.ts
@@ -445,7 +445,11 @@ const server = () => {
: result(value);
});
- tool("list_projects", () => ({ organization_id: "org_fixture", projects: PROJECTS }));
+ tool("list_projects", () =>
+ failProjects.value
+ ? "telemetry.dev could not complete this request. Try again shortly."
+ : { organization_id: "org_fixture", projects: PROJECTS },
+ );
tool("search_traces", () => traces());
tool("search_sessions", () => sessions());
tool("get_trace", (args) =>
@@ -476,6 +480,9 @@ const server = () => {
export const calls: { tool: string; args: Record }[] = [];
+/** Makes list_projects answer with a tool error, as a server failure would. */
+export const failProjects = { value: false };
+
/**
* Runs before the token endpoint answers a refresh, to act as another CLI process or a
* server that rotates tokens. "accept" or "refuse" overrides the usual check.