* 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 | 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" |
| @@ -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 | +); | |