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
64import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli";64import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli";
65import { BunRuntime, BunServices } from "@effect/platform-bun";65import { BunRuntime, BunServices } from "@effect/platform-bun";
66import { HttpApiClient } from "effect/unstable/httpapi";66import { HttpApiClient } from "effect/unstable/httpapi";
67import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";67import {
68 FetchHttpClient,
69 HttpClient,
70 HttpClientError,
71 HttpClientRequest,
72} from "effect/unstable/http";
68import { FileSystem, Path as PlatformPath } from "effect";73import { FileSystem, Path as PlatformPath } from "effect";
69import type { PlatformError } from "effect/PlatformError";74import type { PlatformError } from "effect/PlatformError";
70import * as Effect from "effect/Effect";75import * as Effect from "effect/Effect";
@@ -131,7 +136,9 @@ import { · 60 unmodified lines
131import {136import {
132 canAutoStartCliServerConnection,137 canAutoStartCliServerConnection,
133 chooseCliServerConnectionWithActiveLocal,138 chooseCliServerConnectionWithActiveLocal,
139 describeUnauthorizedCliServer,
134 parseCliExecutorServerConnection,140 parseCliExecutorServerConnection,
141 profileNameFromConnectionKey,
135 type CliServerConnectionSource,142 type CliServerConnectionSource,
136 withCliServerAuthFallback,143 withCliServerAuthFallback,
137} from "./server-connection";144} from "./server-connection";
@@ -743,9 +750,6 @@ const resolveRequestedExecutorServerConnection = ( · 605 unmodified lines
743// login. The refreshed tokens are written back to the originating profile.750// login. The refreshed tokens are written back to the originating profile.
744const OAUTH_REFRESH_SKEW_SECONDS = 60;751const OAUTH_REFRESH_SKEW_SECONDS = 60;
745 752
746const profileNameFromKey = (key: string): string | null =>
747 key.startsWith("profile:") ? key.slice("profile:".length) : null;
748
749const refreshOAuthConnection = (753const refreshOAuthConnection = (
750 connection: ExecutorServerConnection,754 connection: ExecutorServerConnection,
751): Effect.Effect<ExecutorServerConnection, never, FileSystem.FileSystem | PlatformPath.Path> =>755): Effect.Effect<ExecutorServerConnection, never, FileSystem.FileSystem | PlatformPath.Path> =>
@@ -780,7 +784,7 @@ const refreshOAuthConnection = ( · 28 unmodified lines
780 },784 },
781 });785 });
782 786
783 const profileName = profileNameFromKey(connection.key);787 const profileName = profileNameFromConnectionKey(connection.key);
784 if (profileName) {788 if (profileName) {
785 yield* upsertCliServerConnectionProfile({789 yield* upsertCliServerConnectionProfile({
786 name: profileName,790 name: profileName,
@@ -961,7 +965,7 @@ const executeCode = (input: { · 174 unmodified lines
961}): Effect.Effect<ExecuteCodeResult, Error, FileSystem.FileSystem | PlatformPath.Path> =>965}): Effect.Effect<ExecuteCodeResult, Error, FileSystem.FileSystem | PlatformPath.Path> =>
962 Effect.gen(function* () {966 Effect.gen(function* () {
963 const connection = yield* resolveExecutorServerConnection(input.target);967 const connection = yield* resolveExecutorServerConnection(input.target);
964 const client = yield* makeApiClient(connection);968 const client = yield* makeApiClient(connection, input.target);
965 const response = yield* client.executions.execute({969 const response = yield* client.executions.execute({
966 payload: {970 payload: {
967 code: input.code,971 code: input.code,
@@ -1050,7 +1054,7 @@ const printExecutionOutcome = (input: { · 82 unmodified lines
1050// Typed API client1054// Typed API client
1051// ---------------------------------------------------------------------------1055// ---------------------------------------------------------------------------
1052 1056
1053const makeApiClient = (connection: ExecutorServerConnection) => {1057const makeApiClient = (connection: ExecutorServerConnection, target: ServerTarget = {}) => {
1054 const authorization = getExecutorServerAuthorizationHeader(connection);1058 const authorization = getExecutorServerAuthorizationHeader(connection);
1055 return HttpApiClient.make(ExecutorApi, {1059 return HttpApiClient.make(ExecutorApi, {
1056 baseUrl: connection.apiBaseUrl,1060 baseUrl: connection.apiBaseUrl,
@@ -1061,6 +1065,18 @@ const makeApiClient = (connection: ExecutorServerConnection) => { · 4 unmodified lines
1061 ),1065 ),
1062 }1066 }
1063 : {}),1067 : {}),
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 ),
1064 }).pipe(Effect.provide(FetchHttpClient.layer));1080 }).pipe(Effect.provide(FetchHttpClient.layer));
1065};1081};
1066 1082
@@ -1802,11 +1818,9 @@ const runCallHelp = ( · 735 unmodified lines
1802 Effect.gen(function* () {1818 Effect.gen(function* () {
1803 if (args.scopeDir) process.env.EXECUTOR_SCOPE_DIR = resolve(args.scopeDir);1819 if (args.scopeDir) process.env.EXECUTOR_SCOPE_DIR = resolve(args.scopeDir);
1804 1820
1805 const connection = yield* resolveExecutorServerConnection({1821 const target: ServerTarget = { baseUrl: args.baseUrl, serverName: args.serverName };
1806 baseUrl: args.baseUrl,1822 const connection = yield* resolveExecutorServerConnection(target);
1807 serverName: args.serverName,1823 const client = yield* makeApiClient(connection, target);
1808 });
1809 const client = yield* makeApiClient(connection);
1810 const tools = yield* client.tools.list({ query: {} });1824 const tools = yield* client.tools.list({ query: {} });
1811 const toolPaths = tools.map((tool) => tool.address);1825 const toolPaths = tools.map((tool) => tool.address);
1812 1826
@@ -1987,7 +2001,7 @@ const resumeCommand = Command.make( · 174 unmodified lines
1987 2001
1988 const contentObj = yield* parseOptionalJsonObject(Option.getOrUndefined(content));2002 const contentObj = yield* parseOptionalJsonObject(Option.getOrUndefined(content));
1989 2003
1990 const client = yield* makeApiClient(connection);2004 const client = yield* makeApiClient(connection, target);
1991 const result = yield* client.executions.resume({2005 const result = yield* client.executions.resume({
1992 params: { executionId },2006 params: { executionId },
1993 payload: { action, content: contentObj },2007 payload: { action, content: contentObj },
@@ -2311,9 +2325,13 @@ const chooseLoginProfileName = ( · 317 unmodified lines
2311 }2325 }
2312};2326};
2313 2327
2328/** Where `executor login` lands with no flags and no stored profiles. */
2329const DEFAULT_LOGIN_ORIGIN = "https://executor.sh";
2330
2314// Resolve which server a login/logout targets: an existing profile (--server2331// 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 decided2332// or the default), a bare origin (--base-url), or hosted Executor when
2316// later, from the authenticated account.2333// nothing is configured yet. The profile name is decided later, from the
2334// authenticated account.
2317const resolveLoginOrigin = (input: {2335const resolveLoginOrigin = (input: {
2318 readonly baseUrl: Option.Option<string>;2336 readonly baseUrl: Option.Option<string>;
2319 readonly server: Option.Option<string>;2337 readonly server: Option.Option<string>;
@@ -2341,11 +2359,7 @@ const resolveLoginOrigin = (input: { · 21 unmodified lines
2341 const store = yield* readCliServerConnectionStore();2359 const store = yield* readCliServerConnectionStore();
2342 const profile = defaultCliServerConnectionProfile(store);2360 const profile = defaultCliServerConnectionProfile(store);
2343 if (profile) return { origin: profile.connection.origin, profile };2361 if (profile) return { origin: profile.connection.origin, profile };
2344 return yield* Effect.fail(2362 return { origin: DEFAULT_LOGIN_ORIGIN, profile: null };
2345 new Error(
2346 "No server to log in to. Pass --base-url <https://your-host> or --server <profile>.",
2347 ),
2348 );
2349 });2363 });
2350 2364
2351const loginNameOption = Options.string("name").pipe(2365const loginNameOption = Options.string("name").pipe(
@@ -2370,6 +2384,9 @@ const loginCommand = Command.make( · 18 unmodified lines
2370 Effect.gen(function* () {2384 Effect.gen(function* () {
2371 const target = yield* resolveLoginOrigin({ baseUrl, server });2385 const target = yield* resolveLoginOrigin({ baseUrl, server });
2372 const explicitName = Option.getOrUndefined(name);2386 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}`);
2373 const discovery = yield* Effect.tryPromise({2390 const discovery = yield* Effect.tryPromise({
2374 try: () => discoverCliLogin(target.origin),2391 try: () => discoverCliLogin(target.origin),
2375 catch: toError,2392 catch: toError,
@@ -2424,12 +2441,24 @@ const loginCommand = Command.make( · 48 unmodified lines
2424 });2441 });
2425 2442
2426 console.log("");2443 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 );
2428 if (email) console.log(`Account: ${email}`);2453 if (email) console.log(`Account: ${email}`);
2429 if (org) console.log(`Organization: ${org}`);2454 if (org) console.log(`Organization: ${org}`);
2430 else if (sub) console.log(`User: ${sub}`);2455 else if (sub) console.log(`User: ${sub}`);
2431 }),2456 }),
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);
2433 2462
2434const logoutCommand = Command.make(2463const logoutCommand = Command.make(
2435 "logout",2464 "logout",
@@ -2445,8 +2474,14 @@ const logoutCommand = Command.make( · 9 unmodified lines
2445 console.log(`No stored login for ${target.origin}.`);2474 console.log(`No stored login for ${target.origin}.`);
2446 return;2475 return;
2447 }2476 }
2477 // Profile bookkeeping stays backstage unless the user addressed one.
2478 const mentionProfile = Option.isSome(server) || store.profiles.length > 1;
2448 if (!profile.connection.auth) {2479 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 );
2450 return;2485 return;
2451 }2486 }
2452 yield* upsertCliServerConnectionProfile({2487 yield* upsertCliServerConnectionProfile({
@@ -2459,7 +2494,9 @@ const logoutCommand = Command.make( · 6 unmodified lines
2459 makeDefault: store.defaultProfile === profile.name,2494 makeDefault: store.defaultProfile === profile.name,
2460 });2495 });
2461 console.log(2496 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}.`,
2463 );2500 );
2464 }),2501 }),
2465).pipe(Command.withDescription("Clear stored credentials for a server profile"));2502).pipe(Command.withDescription("Clear stored credentials for a server profile"));
apps/cli/src/server-connection.test.ts+84−0
@@ -1,8 +1,11 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2 2
3import { normalizeExecutorServerConnection } from "@executor-js/sdk/shared";
4
3import {5import {
4 canAutoStartCliServerConnection,6 canAutoStartCliServerConnection,
5 chooseCliServerConnectionWithActiveLocal,7 chooseCliServerConnectionWithActiveLocal,
8 describeUnauthorizedCliServer,
6 parseCliExecutorServerConnection,9 parseCliExecutorServerConnection,
7 withCliServerAuthFallback,10 withCliServerAuthFallback,
8} from "./server-connection";11} from "./server-connection";
@@ -135,3 +138,84 @@ describe("CLI server connection", () => { · 126 unmodified lines
135 ).toBe("conflict");138 ).toBe("conflict");
136 });139 });
137});140});
141
142describe("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
52 return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname);52 return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname);
53};53};
54 54
55export 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.
68export 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
55export type CliServerConnectionSource =104export type CliServerConnectionSource =
56 | "explicit"105 | "explicit"
57 | "default-profile"106 | "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