History

CLI: sign-in hint on 401 and a default login server (#1371)

* CLI: explain 401s with a login hint, default login to executor.sh A 401 from a stored profile now renders as a sign-in message naming the profile and the exact login command, instead of the transport-level `Decode error (401 GET .../api/tools)`. `executor login` with no flags and no profiles now targets https://executor.sh instead of erroring, and prints the chosen server before starting the device flow. * Keep profile plumbing out of CLI auth messages The sign-in hint now mirrors how the user addressed the server: plain `executor login` when no flags were typed, echoing --server/--base-url only when they were. Login and logout output mention profiles only when the user works with named profiles. * Add changeset for CLI login hints

Rhys Sullivan committed 4 months ago

Commit 262fc3e1 parent 56b60bf5 files · +359 −24Browse files

Unified · Split

5 files
  1. .changeset/cli-login-hints.md +5 −0
  2. apps/cli/src/main.ts +61 −24
  3. apps/cli/src/server-connection.test.ts +84 −0
  4. apps/cli/src/server-connection.ts +49 −0
  5. e2e/local/cli-call-logged-out.test.ts +160 −0
.changeset/cli-login-hints.md+5−0
@@ -0,0 +1,5 @@
1+---
2+"executor": patch
3+---
4+
5+Explain 401s from a hosted server as a sign-in problem with the exact `executor login` command to run, instead of surfacing a raw decode error. `executor login` now defaults to https://executor.sh when no server is specified, and profile plumbing stays out of messages unless you address servers by name.
apps/cli/src/main.ts+61−24
@@ -64,7 +64,12 @@ if (sentryDsn) { · 63 unmodified lines
6464 import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli";
6565 import { BunRuntime, BunServices } from "@effect/platform-bun";
6666 import { HttpApiClient } from "effect/unstable/httpapi";
67−import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
67+import {
68+ FetchHttpClient,
69+ HttpClient,
70+ HttpClientError,
71+ HttpClientRequest,
72+} from "effect/unstable/http";
6873 import { FileSystem, Path as PlatformPath } from "effect";
6974 import type { PlatformError } from "effect/PlatformError";
7075 import * as Effect from "effect/Effect";
@@ -131,7 +136,9 @@ import { · 60 unmodified lines
131136 import {
132137 canAutoStartCliServerConnection,
133138 chooseCliServerConnectionWithActiveLocal,
139+ describeUnauthorizedCliServer,
134140 parseCliExecutorServerConnection,
141+ profileNameFromConnectionKey,
135142 type CliServerConnectionSource,
136143 withCliServerAuthFallback,
137144 } from "./server-connection";
@@ -743,9 +750,6 @@ const resolveRequestedExecutorServerConnection = ( · 605 unmodified lines
743750 // login. The refreshed tokens are written back to the originating profile.
744751 const OAUTH_REFRESH_SKEW_SECONDS = 60;
745752
746−const profileNameFromKey = (key: string): string | null =>
747− key.startsWith("profile:") ? key.slice("profile:".length) : null;
748−
749753 const refreshOAuthConnection = (
750754 connection: ExecutorServerConnection,
751755 ): Effect.Effect<ExecutorServerConnection, never, FileSystem.FileSystem | PlatformPath.Path> =>
@@ -780,7 +784,7 @@ const refreshOAuthConnection = ( · 28 unmodified lines
780784 },
781785 });
782786
783− const profileName = profileNameFromKey(connection.key);
787+ const profileName = profileNameFromConnectionKey(connection.key);
784788 if (profileName) {
785789 yield* upsertCliServerConnectionProfile({
786790 name: profileName,
@@ -961,7 +965,7 @@ const executeCode = (input: { · 174 unmodified lines
961965 }): Effect.Effect<ExecuteCodeResult, Error, FileSystem.FileSystem | PlatformPath.Path> =>
962966 Effect.gen(function* () {
963967 const connection = yield* resolveExecutorServerConnection(input.target);
964− const client = yield* makeApiClient(connection);
968+ const client = yield* makeApiClient(connection, input.target);
965969 const response = yield* client.executions.execute({
966970 payload: {
967971 code: input.code,
@@ -1050,7 +1054,7 @@ const printExecutionOutcome = (input: { · 82 unmodified lines
10501054 // Typed API client
10511055 // ---------------------------------------------------------------------------
10521056
1053−const makeApiClient = (connection: ExecutorServerConnection) => {
1057+const makeApiClient = (connection: ExecutorServerConnection, target: ServerTarget = {}) => {
10541058 const authorization = getExecutorServerAuthorizationHeader(connection);
10551059 return HttpApiClient.make(ExecutorApi, {
10561060 baseUrl: connection.apiBaseUrl,
@@ -1061,6 +1065,18 @@ const makeApiClient = (connection: ExecutorServerConnection) => { · 4 unmodified lines
10611065 ),
10621066 }
10631067 : {}),
1068+ // A 401 on an endpoint that doesn't model it is a sign-in problem: rewrite
1069+ // the transport-level error into the login hint. Without this the client
1070+ // fails decoding the unexpected status and prints the opaque
1071+ // `Decode error (401 GET .../api/tools)`. Declared 401s (typed API errors)
1072+ // decode before this catch and pass through untouched.
1073+ transformResponse: (effect) =>
1074+ Effect.catchIf(
1075+ effect,
1076+ (cause) => HttpClientError.isHttpClientError(cause) && cause.response?.status === 401,
1077+ () =>
1078+ Effect.fail(new Error(describeUnauthorizedCliServer({ connection, cliPrefix, target }))),
1079+ ),
10641080 }).pipe(Effect.provide(FetchHttpClient.layer));
10651081 };
10661082
@@ -1802,11 +1818,9 @@ const runCallHelp = ( · 735 unmodified lines
18021818 Effect.gen(function* () {
18031819 if (args.scopeDir) process.env.EXECUTOR_SCOPE_DIR = resolve(args.scopeDir);
18041820
1805− const connection = yield* resolveExecutorServerConnection({
1806− baseUrl: args.baseUrl,
1807− serverName: args.serverName,
1808− });
1809− const client = yield* makeApiClient(connection);
1821+ const target: ServerTarget = { baseUrl: args.baseUrl, serverName: args.serverName };
1822+ const connection = yield* resolveExecutorServerConnection(target);
1823+ const client = yield* makeApiClient(connection, target);
18101824 const tools = yield* client.tools.list({ query: {} });
18111825 const toolPaths = tools.map((tool) => tool.address);
18121826
@@ -1987,7 +2001,7 @@ const resumeCommand = Command.make( · 174 unmodified lines
19872001
19882002 const contentObj = yield* parseOptionalJsonObject(Option.getOrUndefined(content));
19892003
1990− const client = yield* makeApiClient(connection);
2004+ const client = yield* makeApiClient(connection, target);
19912005 const result = yield* client.executions.resume({
19922006 params: { executionId },
19932007 payload: { action, content: contentObj },
@@ -2311,9 +2325,13 @@ const chooseLoginProfileName = ( · 317 unmodified lines
23112325 }
23122326 };
23132327
2328+/** Where `executor login` lands with no flags and no stored profiles. */
2329+const DEFAULT_LOGIN_ORIGIN = "https://executor.sh";
2330+
23142331 // Resolve which server a login/logout targets: an existing profile (--server
2315−// or the default) or a bare origin (--base-url). The profile name is decided
2316−// later, from the authenticated account.
2332+// or the default), a bare origin (--base-url), or hosted Executor when
2333+// nothing is configured yet. The profile name is decided later, from the
2334+// authenticated account.
23172335 const resolveLoginOrigin = (input: {
23182336 readonly baseUrl: Option.Option<string>;
23192337 readonly server: Option.Option<string>;
@@ -2341,11 +2359,7 @@ const resolveLoginOrigin = (input: { · 21 unmodified lines
23412359 const store = yield* readCliServerConnectionStore();
23422360 const profile = defaultCliServerConnectionProfile(store);
23432361 if (profile) return { origin: profile.connection.origin, profile };
2344− return yield* Effect.fail(
2345− new Error(
2346− "No server to log in to. Pass --base-url <https://your-host> or --server <profile>.",
2347− ),
2348− );
2362+ return { origin: DEFAULT_LOGIN_ORIGIN, profile: null };
23492363 });
23502364
23512365 const loginNameOption = Options.string("name").pipe(
@@ -2370,6 +2384,9 @@ const loginCommand = Command.make( · 18 unmodified lines
23702384 Effect.gen(function* () {
23712385 const target = yield* resolveLoginOrigin({ baseUrl, server });
23722386 const explicitName = Option.getOrUndefined(name);
2387+ // The target may have been picked implicitly (default profile, or the
2388+ // hosted fallback): say where the login is going before the device flow.
2389+ console.log(`Signing in to ${target.origin}`);
23732390 const discovery = yield* Effect.tryPromise({
23742391 try: () => discoverCliLogin(target.origin),
23752392 catch: toError,
@@ -2424,12 +2441,24 @@ const loginCommand = Command.make( · 48 unmodified lines
24242441 });
24252442
24262443 console.log("");
2427− console.log(`Logged in to ${target.origin} (profile "${profileName}", now the default).`);
2444+ // Profile bookkeeping stays backstage unless the user works with named
2445+ // profiles (--name, or a store that already has several).
2446+ const mentionProfile =
2447+ explicitName !== undefined || (yield* readCliServerConnectionStore()).profiles.length > 1;
2448+ console.log(
2449+ mentionProfile
2450+ ? `Logged in to ${target.origin} (profile "${profileName}", now the default).`
2451+ : `Logged in to ${target.origin}.`,
2452+ );
24282453 if (email) console.log(`Account: ${email}`);
24292454 if (org) console.log(`Organization: ${org}`);
24302455 else if (sub) console.log(`User: ${sub}`);
24312456 }),
2432−).pipe(Command.withDescription("Sign in to a hosted Executor server in the browser (device flow)"));
2457+).pipe(
2458+ Command.withDescription(
2459+ "Sign in to a hosted Executor server in the browser (device flow). Defaults to https://executor.sh.",
2460+ ),
2461+);
24332462
24342463 const logoutCommand = Command.make(
24352464 "logout",
@@ -2445,8 +2474,14 @@ const logoutCommand = Command.make( · 9 unmodified lines
24452474 console.log(`No stored login for ${target.origin}.`);
24462475 return;
24472476 }
2477+ // Profile bookkeeping stays backstage unless the user addressed one.
2478+ const mentionProfile = Option.isSome(server) || store.profiles.length > 1;
24482479 if (!profile.connection.auth) {
2449− console.log(`Profile "${profile.name}" has no stored credentials.`);
2480+ console.log(
2481+ mentionProfile
2482+ ? `Profile "${profile.name}" has no stored credentials.`
2483+ : `Not signed in to ${target.origin}.`,
2484+ );
24502485 return;
24512486 }
24522487 yield* upsertCliServerConnectionProfile({
@@ -2459,7 +2494,9 @@ const logoutCommand = Command.make( · 6 unmodified lines
24592494 makeDefault: store.defaultProfile === profile.name,
24602495 });
24612496 console.log(
2462− `Logged out of ${profile.connection.origin} (cleared credentials for "${profile.name}").`,
2497+ mentionProfile
2498+ ? `Logged out of ${profile.connection.origin} (cleared credentials for "${profile.name}").`
2499+ : `Logged out of ${profile.connection.origin}.`,
24632500 );
24642501 }),
24652502 ).pipe(Command.withDescription("Clear stored credentials for a server profile"));
apps/cli/src/server-connection.test.ts+84−0
@@ -1,8 +1,11 @@
11 import { describe, expect, it } from "@effect/vitest";
22
3+import { normalizeExecutorServerConnection } from "@executor-js/sdk/shared";
4+
35 import {
46 canAutoStartCliServerConnection,
57 chooseCliServerConnectionWithActiveLocal,
8+ describeUnauthorizedCliServer,
69 parseCliExecutorServerConnection,
710 withCliServerAuthFallback,
811 } from "./server-connection";
@@ -135,3 +138,84 @@ describe("CLI server connection", () => { · 126 unmodified lines
135138 ).toBe("conflict");
136139 });
137140 });
141+
142+describe("describeUnauthorizedCliServer", () => {
143+ it("hints plain `executor login` when the server was picked implicitly", () => {
144+ const connection = normalizeExecutorServerConnection({
145+ key: "profile:rhys-executor.sh",
146+ origin: "https://executor.sh",
147+ auth: { kind: "oauth", accessToken: "stale", expiresAt: 1 },
148+ });
149+
150+ const message = describeUnauthorizedCliServer({
151+ connection,
152+ cliPrefix: "executor",
153+ target: {},
154+ });
155+ expect(message).toContain("You're signed out of https://executor.sh");
156+ expect(message).toContain("Run `executor login` to sign in again.");
157+ // Profile names are plumbing: never surfaced when the user didn't type one.
158+ expect(message).not.toContain("--server");
159+ expect(message).not.toContain("profile");
160+ });
161+
162+ it("echoes --server back when the user targeted a named profile", () => {
163+ const connection = normalizeExecutorServerConnection({
164+ key: "profile:work",
165+ origin: "https://executor.example",
166+ auth: { kind: "oauth", accessToken: "stale", expiresAt: 1 },
167+ });
168+
169+ const message = describeUnauthorizedCliServer({
170+ connection,
171+ cliPrefix: "executor",
172+ target: { serverName: "work" },
173+ });
174+ expect(message).toContain("You're signed out of https://executor.example");
175+ expect(message).toContain("executor login --server work");
176+ });
177+
178+ it("echoes --base-url back when the user targeted an origin", () => {
179+ const connection = normalizeExecutorServerConnection({
180+ origin: "https://executor.example",
181+ });
182+
183+ const message = describeUnauthorizedCliServer({
184+ connection,
185+ cliPrefix: "executor",
186+ target: { baseUrl: "https://executor.example" },
187+ });
188+ expect(message).toContain("no credentials are stored");
189+ expect(message).toContain("executor login --base-url https://executor.example");
190+ });
191+
192+ it("mentions the env vars when a profile-less bearer key is rejected", () => {
193+ const connection = normalizeExecutorServerConnection({
194+ origin: "https://executor.example",
195+ auth: { kind: "bearer", token: "key_bad" },
196+ });
197+
198+ const message = describeUnauthorizedCliServer({
199+ connection,
200+ cliPrefix: "executor",
201+ target: {},
202+ });
203+ expect(message).toContain("rejected the stored bearer credentials");
204+ expect(message).toContain("EXECUTOR_API_KEY");
205+ });
206+
207+ it("uses the dev entrypoint prefix verbatim", () => {
208+ const connection = normalizeExecutorServerConnection({
209+ key: "profile:hosted",
210+ origin: "https://executor.example",
211+ auth: { kind: "oauth", accessToken: "stale" },
212+ });
213+
214+ const message = describeUnauthorizedCliServer({
215+ connection,
216+ cliPrefix: "bun run apps/cli/src/main.ts",
217+ target: {},
218+ });
219+ expect(message).toContain("bun run apps/cli/src/main.ts login");
220+ });
221+});
apps/cli/src/server-connection.ts+49−0
@@ -52,6 +52,55 @@ export const canAutoStartCliServerConnection = (connection: ExecutorServerConnec · 51 unmodified lines
5252 return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname);
5353 };
5454
55+export const profileNameFromConnectionKey = (key: string): string | null =>
56+ key.startsWith("profile:") ? key.slice("profile:".length) : null;
57+
58+// A 401 from the server means the stored credential is stale, revoked, or
59+// missing: a sign-in problem, not a transport problem. Rendered instead of
60+// the raw HTTP client error so the user learns the recovery command, not the
61+// failing endpoint.
62+//
63+// The hint mirrors how the user addressed the server. The common case types
64+// no flags (the default profile, normally executor.sh), and login re-targets
65+// that same default, so the fix is plain `executor login`: profile names are
66+// plumbing and stay invisible. Only an explicit --server / --base-url comes
67+// back in the hint.
68+export const describeUnauthorizedCliServer = (input: {
69+ readonly connection: ExecutorServerConnection;
70+ readonly cliPrefix: string;
71+ readonly target: { readonly baseUrl?: string; readonly serverName?: string };
72+}): string => {
73+ const { connection, cliPrefix, target } = input;
74+ const loginCommand = target.serverName
75+ ? `${cliPrefix} login --server ${target.serverName}`
76+ : target.baseUrl
77+ ? `${cliPrefix} login --base-url ${connection.origin}`
78+ : `${cliPrefix} login`;
79+
80+ if (!connection.auth) {
81+ return [
82+ `${connection.origin} requires authentication, and no credentials are stored for it.`,
83+ `Run \`${loginCommand}\` to sign in.`,
84+ ].join("\n");
85+ }
86+
87+ if (connection.auth.kind === "oauth") {
88+ return [
89+ `You're signed out of ${connection.origin}.`,
90+ `Run \`${loginCommand}\` to sign in again.`,
91+ ].join("\n");
92+ }
93+
94+ const envHint =
95+ connection.auth.kind === "bearer" && !profileNameFromConnectionKey(connection.key)
96+ ? " If the key came from EXECUTOR_API_KEY or EXECUTOR_AUTH_TOKEN, check that value."
97+ : "";
98+ return [
99+ `${connection.origin} rejected the stored ${connection.auth.kind} credentials (401).${envHint}`,
100+ `Run \`${loginCommand}\` to sign in again.`,
101+ ].join("\n");
102+};
103+
55104 export type CliServerConnectionSource =
56105 | "explicit"
57106 | "default-profile"
e2e/local/cli-call-logged-out.test.ts+160−0
@@ -0,0 +1,160 @@
1+// Local CLI: what `executor call` tells a user whose stored hosted-server
2+// login no longer works. A stub standing in for the hosted server answers
3+// every request 401 (the wire shape of an expired or revoked session), and
4+// the default profile carries an expired OAuth token with no way to refresh.
5+//
6+// The CLI must treat that 401 as a sign-in problem, not a transport problem:
7+// say which server the user is signed out of and that `executor login` fixes
8+// it. The user typed no flags, so the hint is plain `executor login`; profile
9+// names are internal plumbing and must not leak. And the raw
10+// `Decode error (401 GET .../api/tools)` from before the 401 mapping must
11+// never resurface.
12+//
13+// The session runs in an interactive shell with an `executor` shim on PATH so
14+// the terminal.cast reads like the real moment: the user types the command,
15+// the error is the whole answer.
16+import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
17+import { createServer } from "node:http";
18+import type { AddressInfo } from "node:net";
19+import { tmpdir } from "node:os";
20+import { delimiter, join } from "node:path";
21+import { fileURLToPath } from "node:url";
22+
23+import { expect } from "@effect/vitest";
24+import { Effect } from "effect";
25+
26+import { scenario } from "../src/scenario";
27+import { Cli, RunDir } from "../src/services";
28+
29+const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
30+
31+/** Every route answers like a hosted server rejecting an expired session. */
32+const listenAsUnauthorizedServer = () =>
33+ Effect.promise(
34+ () =>
35+ new Promise<{ origin: string; close: () => void }>((resolve) => {
36+ const server = createServer((_request, response) => {
37+ response.writeHead(401, { "content-type": "application/json" });
38+ response.end(JSON.stringify({ error: "unauthorized" }));
39+ });
40+ server.listen(0, "127.0.0.1", () => {
41+ const { port } = server.address() as AddressInfo;
42+ resolve({
43+ origin: `http://127.0.0.1:${port}`,
44+ close: () => server.close(),
45+ });
46+ });
47+ }),
48+ );
49+
50+/** A data dir whose default profile is a hosted server with an expired,
51+ * unrefreshable OAuth token: the state `executor server list` shows as
52+ * `stored-auth` after a login has gone stale. The `profile:` key matches
53+ * what `executor login` persists via upsertCliServerConnectionProfile. */
54+const writeLoggedOutProfile = (dataDir: string, origin: string) => {
55+ writeFileSync(
56+ join(dataDir, "server-connections.json"),
57+ JSON.stringify(
58+ {
59+ version: 1,
60+ defaultProfile: "hosted",
61+ profiles: [
62+ {
63+ name: "hosted",
64+ connection: {
65+ kind: "http",
66+ key: "profile:hosted",
67+ origin,
68+ displayName: "hosted",
69+ auth: { kind: "oauth", accessToken: "expired-access-token", expiresAt: 1 },
70+ },
71+ },
72+ ],
73+ },
74+ null,
75+ 2,
76+ ),
77+ );
78+};
79+
80+/** An `executor` on PATH that runs this checkout's CLI, so the recording
81+ * shows the command a user actually types. */
82+const writeExecutorShim = (binDir: string) => {
83+ const shim = join(binDir, "executor");
84+ writeFileSync(shim, `#!/bin/sh\nexec bun run "${join(repoRoot, "apps/cli/src/main.ts")}" "$@"\n`);
85+ chmodSync(shim, 0o755);
86+};
87+
88+scenario(
89+ "CLI call · a signed-out hosted profile explains how to log back in",
90+ { timeout: 120_000 },
91+ Effect.gen(function* () {
92+ const cli = yield* Cli;
93+ const runDir = yield* RunDir;
94+
95+ const hosted = yield* listenAsUnauthorizedServer();
96+ const dataDir = mkdtempSync(join(tmpdir(), "executor-cli-logged-out-"));
97+ writeLoggedOutProfile(dataDir, hosted.origin);
98+ writeExecutorShim(dataDir);
99+
100+ yield* cli
101+ .session(
102+ ["bash", "--noprofile", "--norc"],
103+ async (term) => {
104+ await term.screen.waitUntil((current) => current.text.includes("$"), {
105+ timeoutMs: 30_000,
106+ });
107+
108+ // The user browses the catalog the way the docs teach: call --help.
109+ await term.keyboard.type("executor call github issues --help", { paceMs: 40 });
110+ await term.keyboard.press("Enter");
111+
112+ // The command exits after printing its sign-in error.
113+ const snapshot = await term.screen.waitUntil(
114+ (current) => current.text.includes("sign in"),
115+ { timeoutMs: 60_000 },
116+ );
117+ const screen = snapshot.text;
118+
119+ // The moment reads as a sign-in problem naming the server...
120+ expect(screen, "the error says the user is signed out").toContain("You're signed out");
121+ // ...fixed by plain `executor login` (dev mode prefixes the entry
122+ // script, so assert the stable tail of the sentence).
123+ expect(screen, "it hands over the bare login command").toContain(
124+ "login` to sign in again.",
125+ );
126+
127+ // No flags were typed, so no plumbing comes back.
128+ expect(screen, "profile names stay internal").not.toContain("--server");
129+ expect(screen, "profiles are not mentioned at all").not.toContain("profile");
130+
131+ // The pre-fix rendering must not resurface.
132+ expect(screen, "the raw transport error stays internal").not.toContain("Decode error");
133+ expect(screen, "the API endpoint does not leak into the message").not.toContain(
134+ "/api/tools",
135+ );
136+ },
137+ {
138+ cwd: repoRoot,
139+ env: {
140+ EXECUTOR_DATA_DIR: dataDir,
141+ EXECUTOR_SCOPE_DIR: dataDir,
142+ PATH: `${dataDir}${delimiter}${process.env.PATH ?? ""}`,
143+ PS1: "$ ",
144+ // Keep macOS's "default shell is now zsh" banner out of the cast.
145+ BASH_SILENCE_DEPRECATION_WARNING: "1",
146+ },
147+ record: join(runDir, "terminal.cast"),
148+ viewport: { cols: 100, rows: 24 },
149+ },
150+ )
151+ .pipe(
152+ Effect.ensuring(
153+ Effect.sync(() => {
154+ hosted.close();
155+ rmSync(dataDir, { recursive: true, force: true });
156+ }),
157+ ),
158+ );
159+ }),
160+);