* 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
Unified · Split
| @@ -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. | ||
| @@ -64,7 +64,12 @@ if (sentryDsn) { · 63 unmodified lines | |||
| 64 | import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"; | 64 | import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"; |
| 65 | import { BunRuntime, BunServices } from "@effect/platform-bun"; | 65 | import { BunRuntime, BunServices } from "@effect/platform-bun"; |
| 66 | import { HttpApiClient } from "effect/unstable/httpapi"; | 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 | import { FileSystem, Path as PlatformPath } from "effect"; | 73 | import { FileSystem, Path as PlatformPath } from "effect"; |
| 69 | import type { PlatformError } from "effect/PlatformError"; | 74 | import type { PlatformError } from "effect/PlatformError"; |
| 70 | import * as Effect from "effect/Effect"; | 75 | import * as Effect from "effect/Effect"; |
| @@ -131,7 +136,9 @@ import { · 60 unmodified lines | |||
| 131 | import { | 136 | import { |
| 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. |
| 744 | const OAUTH_REFRESH_SKEW_SECONDS = 60; | 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 | const refreshOAuthConnection = ( | 753 | const 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 client | 1054 | // Typed API client |
| 1051 | // --------------------------------------------------------------------------- | 1055 | // --------------------------------------------------------------------------- |
| 1052 | 1056 | ||
| 1053 | const makeApiClient = (connection: ExecutorServerConnection) => { | 1057 | const 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. */ | ||
| 2329 | const DEFAULT_LOGIN_ORIGIN = "https://executor.sh"; | ||
| 2330 | |||
| 2314 | // Resolve which server a login/logout targets: an existing profile (--server | 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 | 2332 | // 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. | ||
| 2317 | const resolveLoginOrigin = (input: { | 2335 | const 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 | ||
| 2351 | const loginNameOption = Options.string("name").pipe( | 2365 | const 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 | ||
| 2434 | const logoutCommand = Command.make( | 2463 | const 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")); |
| @@ -1,8 +1,11 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import { normalizeExecutorServerConnection } from "@executor-js/sdk/shared"; | ||
| 4 | |||
| 3 | import { | 5 | import { |
| 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 | |||
| 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 | return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname); | 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 | export type CliServerConnectionSource = | 104 | export type CliServerConnectionSource = |
| 56 | | "explicit" | 105 | | "explicit" |
| 57 | | "default-profile" | 106 | | "default-profile" |
| @@ -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 | ); | ||