* 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
Showing the changes to apps/cli/src (3 of 5 files). Show all
Unified · Split
| @@ -64,7 +64,12 @@ if (sentryDsn) { · 63 unmodified lines | ||
| 64 | 64 | import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"; |
| 65 | 65 | import { BunRuntime, BunServices } from "@effect/platform-bun"; |
| 66 | 66 | 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"; | |
| 68 | 73 | import { FileSystem, Path as PlatformPath } from "effect"; |
| 69 | 74 | import type { PlatformError } from "effect/PlatformError"; |
| 70 | 75 | import * as Effect from "effect/Effect"; |
| @@ -131,7 +136,9 @@ import { · 60 unmodified lines | ||
| 131 | 136 | import { |
| 132 | 137 | canAutoStartCliServerConnection, |
| 133 | 138 | chooseCliServerConnectionWithActiveLocal, |
| 139 | + describeUnauthorizedCliServer, | |
| 134 | 140 | parseCliExecutorServerConnection, |
| 141 | + profileNameFromConnectionKey, | |
| 135 | 142 | type CliServerConnectionSource, |
| 136 | 143 | withCliServerAuthFallback, |
| 137 | 144 | } from "./server-connection"; |
| @@ -743,9 +750,6 @@ const resolveRequestedExecutorServerConnection = ( · 605 unmodified lines | ||
| 743 | 750 | // login. The refreshed tokens are written back to the originating profile. |
| 744 | 751 | const OAUTH_REFRESH_SKEW_SECONDS = 60; |
| 745 | 752 | |
| 746 | −const profileNameFromKey = (key: string): string | null => | |
| 747 | − key.startsWith("profile:") ? key.slice("profile:".length) : null; | |
| 748 | − | |
| 749 | 753 | const refreshOAuthConnection = ( |
| 750 | 754 | connection: ExecutorServerConnection, |
| 751 | 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 | 788 | if (profileName) { |
| 785 | 789 | yield* upsertCliServerConnectionProfile({ |
| 786 | 790 | name: profileName, |
| @@ -961,7 +965,7 @@ const executeCode = (input: { · 174 unmodified lines | ||
| 961 | 965 | }): Effect.Effect<ExecuteCodeResult, Error, FileSystem.FileSystem | PlatformPath.Path> => |
| 962 | 966 | Effect.gen(function* () { |
| 963 | 967 | const connection = yield* resolveExecutorServerConnection(input.target); |
| 964 | − const client = yield* makeApiClient(connection); | |
| 968 | + const client = yield* makeApiClient(connection, input.target); | |
| 965 | 969 | const response = yield* client.executions.execute({ |
| 966 | 970 | payload: { |
| 967 | 971 | code: input.code, |
| @@ -1050,7 +1054,7 @@ const printExecutionOutcome = (input: { · 82 unmodified lines | ||
| 1050 | 1054 | // Typed API client |
| 1051 | 1055 | // --------------------------------------------------------------------------- |
| 1052 | 1056 | |
| 1053 | −const makeApiClient = (connection: ExecutorServerConnection) => { | |
| 1057 | +const makeApiClient = (connection: ExecutorServerConnection, target: ServerTarget = {}) => { | |
| 1054 | 1058 | const authorization = getExecutorServerAuthorizationHeader(connection); |
| 1055 | 1059 | return HttpApiClient.make(ExecutorApi, { |
| 1056 | 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 | 1080 | }).pipe(Effect.provide(FetchHttpClient.layer)); |
| 1065 | 1081 | }; |
| 1066 | 1082 | |
| @@ -1802,11 +1818,9 @@ const runCallHelp = ( · 735 unmodified lines | ||
| 1802 | 1818 | Effect.gen(function* () { |
| 1803 | 1819 | if (args.scopeDir) process.env.EXECUTOR_SCOPE_DIR = resolve(args.scopeDir); |
| 1804 | 1820 | |
| 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); | |
| 1810 | 1824 | const tools = yield* client.tools.list({ query: {} }); |
| 1811 | 1825 | const toolPaths = tools.map((tool) => tool.address); |
| 1812 | 1826 | |
| @@ -1987,7 +2001,7 @@ const resumeCommand = Command.make( · 174 unmodified lines | ||
| 1987 | 2001 | |
| 1988 | 2002 | const contentObj = yield* parseOptionalJsonObject(Option.getOrUndefined(content)); |
| 1989 | 2003 | |
| 1990 | − const client = yield* makeApiClient(connection); | |
| 2004 | + const client = yield* makeApiClient(connection, target); | |
| 1991 | 2005 | const result = yield* client.executions.resume({ |
| 1992 | 2006 | params: { executionId }, |
| 1993 | 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. */ | |
| 2329 | +const DEFAULT_LOGIN_ORIGIN = "https://executor.sh"; | |
| 2330 | + | |
| 2314 | 2331 | // 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. | |
| 2317 | 2335 | const resolveLoginOrigin = (input: { |
| 2318 | 2336 | readonly baseUrl: Option.Option<string>; |
| 2319 | 2337 | readonly server: Option.Option<string>; |
| @@ -2341,11 +2359,7 @@ const resolveLoginOrigin = (input: { · 21 unmodified lines | ||
| 2341 | 2359 | const store = yield* readCliServerConnectionStore(); |
| 2342 | 2360 | const profile = defaultCliServerConnectionProfile(store); |
| 2343 | 2361 | 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 }; | |
| 2349 | 2363 | }); |
| 2350 | 2364 | |
| 2351 | 2365 | const loginNameOption = Options.string("name").pipe( |
| @@ -2370,6 +2384,9 @@ const loginCommand = Command.make( · 18 unmodified lines | ||
| 2370 | 2384 | Effect.gen(function* () { |
| 2371 | 2385 | const target = yield* resolveLoginOrigin({ baseUrl, server }); |
| 2372 | 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 | 2390 | const discovery = yield* Effect.tryPromise({ |
| 2374 | 2391 | try: () => discoverCliLogin(target.origin), |
| 2375 | 2392 | catch: toError, |
| @@ -2424,12 +2441,24 @@ const loginCommand = Command.make( · 48 unmodified lines | ||
| 2424 | 2441 | }); |
| 2425 | 2442 | |
| 2426 | 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 | 2453 | if (email) console.log(`Account: ${email}`); |
| 2429 | 2454 | if (org) console.log(`Organization: ${org}`); |
| 2430 | 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 | |
| 2434 | 2463 | const logoutCommand = Command.make( |
| 2435 | 2464 | "logout", |
| @@ -2445,8 +2474,14 @@ const logoutCommand = Command.make( · 9 unmodified lines | ||
| 2445 | 2474 | console.log(`No stored login for ${target.origin}.`); |
| 2446 | 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 | 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 | 2485 | return; |
| 2451 | 2486 | } |
| 2452 | 2487 | yield* upsertCliServerConnectionProfile({ |
| @@ -2459,7 +2494,9 @@ const logoutCommand = Command.make( · 6 unmodified lines | ||
| 2459 | 2494 | makeDefault: store.defaultProfile === profile.name, |
| 2460 | 2495 | }); |
| 2461 | 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 | 2502 | ).pipe(Command.withDescription("Clear stored credentials for a server profile")); |
| @@ -1,8 +1,11 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | +import { normalizeExecutorServerConnection } from "@executor-js/sdk/shared"; | |
| 4 | + | |
| 3 | 5 | import { |
| 4 | 6 | canAutoStartCliServerConnection, |
| 5 | 7 | chooseCliServerConnectionWithActiveLocal, |
| 8 | + describeUnauthorizedCliServer, | |
| 6 | 9 | parseCliExecutorServerConnection, |
| 7 | 10 | withCliServerAuthFallback, |
| 8 | 11 | } from "./server-connection"; |
| @@ -135,3 +138,84 @@ describe("CLI server connection", () => { · 126 unmodified lines | ||
| 135 | 138 | ).toBe("conflict"); |
| 136 | 139 | }); |
| 137 | 140 | }); |
| 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 | +}); | |
| @@ -52,6 +52,55 @@ export const canAutoStartCliServerConnection = (connection: ExecutorServerConnec · 51 unmodified lines | ||
| 52 | 52 | return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname); |
| 53 | 53 | }; |
| 54 | 54 | |
| 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 | + | |
| 55 | 104 | export type CliServerConnectionSource = |
| 56 | 105 | | "explicit" |
| 57 | 106 | | "default-profile" |