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

Unified · Split

5 files
  1. .changeset/cli-login-hints.md +5 −0
  2. apps/cli/src/main.ts +61 −24
  3. apps/cli/src/server-connection.test.ts +84 −0
  4. apps/cli/src/server-connection.ts +49 −0
  5. e2e/local/cli-call-logged-out.test.ts +160 −0
.changeset/cli-login-hints.md+5−0
@@ -0,0 +1,5 @@
1---
2"executor": patch
3---
4
5Explain 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.
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"
e2e/local/cli-call-logged-out.test.ts+160−0
@@ -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.
16import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
17import { createServer } from "node:http";
18import type { AddressInfo } from "node:net";
19import { tmpdir } from "node:os";
20import { delimiter, join } from "node:path";
21import { fileURLToPath } from "node:url";
22
23import { expect } from "@effect/vitest";
24import { Effect } from "effect";
25
26import { scenario } from "../src/scenario";
27import { Cli, RunDir } from "../src/services";
28
29const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
30
31/** Every route answers like a hosted server rejecting an expired session. */
32const 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. */
54const 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. */
82const 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
88scenario(
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);