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

Showing the changes to apps/cli/src (3 of 5 files). Show all

Unified · Split

3 files
  1. apps/cli/src/main.ts +61 −24
  2. apps/cli/src/server-connection.test.ts +84 −0
  3. apps/cli/src/server-connection.ts +49 −0
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"

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t