// Local CLI: what `executor call` tells a user whose stored hosted-server // login no longer works. A stub standing in for the hosted server answers // every request 401 (the wire shape of an expired or revoked session), and // the default profile carries an expired OAuth token with no way to refresh. // // The CLI must treat that 401 as a sign-in problem, not a transport problem: // say which server the user is signed out of and that `executor login` fixes // it. The user typed no flags, so the hint is plain `executor login`; profile // names are internal plumbing and must not leak. And the raw // `Decode error (401 GET .../api/tools)` from before the 401 mapping must // never resurface. // // The session runs in an interactive shell with an `executor` shim on PATH so // the terminal.cast reads like the real moment: the user types the command, // the error is the whole answer. import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import { fileURLToPath } from "node:url"; import { expect } from "@effect/vitest"; import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Cli, RunDir } from "../src/services"; const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); /** Every route answers like a hosted server rejecting an expired session. */ const listenAsUnauthorizedServer = () => Effect.promise( () => new Promise<{ origin: string; close: () => void }>((resolve) => { const server = createServer((_request, response) => { response.writeHead(401, { "content-type": "application/json" }); response.end(JSON.stringify({ error: "unauthorized" })); }); server.listen(0, "127.0.0.1", () => { const { port } = server.address() as AddressInfo; resolve({ origin: `http://127.0.0.1:${port}`, close: () => server.close(), }); }); }), ); /** A data dir whose default profile is a hosted server with an expired, * unrefreshable OAuth token: the state `executor server list` shows as * `stored-auth` after a login has gone stale. The `profile:` key matches * what `executor login` persists via upsertCliServerConnectionProfile. */ const writeLoggedOutProfile = (dataDir: string, origin: string) => { writeFileSync( join(dataDir, "server-connections.json"), JSON.stringify( { version: 1, defaultProfile: "hosted", profiles: [ { name: "hosted", connection: { kind: "http", key: "profile:hosted", origin, displayName: "hosted", auth: { kind: "oauth", accessToken: "expired-access-token", expiresAt: 1 }, }, }, ], }, null, 2, ), ); }; /** An `executor` on PATH that runs this checkout's CLI, so the recording * shows the command a user actually types. */ const writeExecutorShim = (binDir: string) => { const shim = join(binDir, "executor"); writeFileSync(shim, `#!/bin/sh\nexec bun run "${join(repoRoot, "apps/cli/src/main.ts")}" "$@"\n`); chmodSync(shim, 0o755); }; scenario( "CLI call ยท a signed-out hosted profile explains how to log back in", { timeout: 120_000 }, Effect.gen(function* () { const cli = yield* Cli; const runDir = yield* RunDir; const hosted = yield* listenAsUnauthorizedServer(); const dataDir = mkdtempSync(join(tmpdir(), "executor-cli-logged-out-")); writeLoggedOutProfile(dataDir, hosted.origin); writeExecutorShim(dataDir); yield* cli .session( ["bash", "--noprofile", "--norc"], async (term) => { await term.screen.waitUntil((current) => current.text.includes("$"), { timeoutMs: 30_000, }); // The user browses the catalog the way the docs teach: call --help. await term.keyboard.type("executor call github issues --help", { paceMs: 40 }); await term.keyboard.press("Enter"); // The command exits after printing its sign-in error. const snapshot = await term.screen.waitUntil( (current) => current.text.includes("sign in"), { timeoutMs: 60_000 }, ); const screen = snapshot.text; // The moment reads as a sign-in problem naming the server... expect(screen, "the error says the user is signed out").toContain("You're signed out"); // ...fixed by plain `executor login` (dev mode prefixes the entry // script, so assert the stable tail of the sentence). expect(screen, "it hands over the bare login command").toContain( "login` to sign in again.", ); // No flags were typed, so no plumbing comes back. expect(screen, "profile names stay internal").not.toContain("--server"); expect(screen, "profiles are not mentioned at all").not.toContain("profile"); // The pre-fix rendering must not resurface. expect(screen, "the raw transport error stays internal").not.toContain("Decode error"); expect(screen, "the API endpoint does not leak into the message").not.toContain( "/api/tools", ); }, { cwd: repoRoot, env: { EXECUTOR_DATA_DIR: dataDir, EXECUTOR_SCOPE_DIR: dataDir, PATH: `${dataDir}${delimiter}${process.env.PATH ?? ""}`, PS1: "$ ", // Keep macOS's "default shell is now zsh" banner out of the cast. BASH_SILENCE_DEPRECATION_WARNING: "1", }, record: join(runDir, "terminal.cast"), viewport: { cols: 100, rows: 24 }, }, ) .pipe( Effect.ensuring( Effect.sync(() => { hosted.close(); rmSync(dataDir, { recursive: true, force: true }); }), ), ); }), );