Skip to content

Commit ff53e10

Browse files
PaulJPhilpclaude
andcommitted
feat: add ep login command for browser-based authentication
Opens browser to EffectTalk.dev, captures API key via local callback server, and saves credentials to ~/.config/ep-cli/config.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3fa7aa9 commit ff53e10

5 files changed

Lines changed: 332 additions & 13 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* Tests for the login command and config writer.
3+
*/
4+
5+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
6+
import { Effect } from "effect";
7+
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
8+
import path from "node:path";
9+
import { tmpdir } from "node:os";
10+
import { resolveConfigPath, writeConfig } from "../services/config/writer.js";
11+
12+
describe("resolveConfigPath", () => {
13+
const originalEnv = { ...process.env };
14+
15+
afterEach(() => {
16+
process.env = { ...originalEnv };
17+
});
18+
19+
it("uses EP_CONFIG_FILE when set", () => {
20+
process.env.EP_CONFIG_FILE = "/custom/path/config.json";
21+
expect(resolveConfigPath()).toBe("/custom/path/config.json");
22+
});
23+
24+
it("ignores empty EP_CONFIG_FILE", () => {
25+
process.env.EP_CONFIG_FILE = " ";
26+
const result = resolveConfigPath();
27+
expect(result).not.toBe(" ");
28+
expect(result).toContain("ep-cli");
29+
});
30+
31+
it("uses XDG_CONFIG_HOME when set", () => {
32+
delete process.env.EP_CONFIG_FILE;
33+
process.env.XDG_CONFIG_HOME = "/xdg/config";
34+
expect(resolveConfigPath()).toBe("/xdg/config/ep-cli/config.json");
35+
});
36+
37+
it("falls back to ~/.config", () => {
38+
delete process.env.EP_CONFIG_FILE;
39+
delete process.env.XDG_CONFIG_HOME;
40+
const result = resolveConfigPath();
41+
expect(result).toContain(".config");
42+
expect(result).toContain("ep-cli");
43+
expect(result).toContain("config.json");
44+
});
45+
});
46+
47+
describe("writeConfig", () => {
48+
let testDir: string;
49+
const originalEnv = { ...process.env };
50+
51+
beforeEach(() => {
52+
testDir = path.join(tmpdir(), `ep-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
53+
mkdirSync(testDir, { recursive: true });
54+
process.env.EP_CONFIG_FILE = path.join(testDir, "config.json");
55+
});
56+
57+
afterEach(() => {
58+
process.env = { ...originalEnv };
59+
if (existsSync(testDir)) {
60+
rmSync(testDir, { recursive: true, force: true });
61+
}
62+
});
63+
64+
it("writes config with apiKey, email, and updatedAt", async () => {
65+
const configPath = await Effect.runPromise(
66+
writeConfig({ apiKey: "test-key-123", email: "test@example.com" })
67+
);
68+
69+
expect(existsSync(configPath)).toBe(true);
70+
const content = JSON.parse(readFileSync(configPath, "utf8"));
71+
expect(content.apiKey).toBe("test-key-123");
72+
expect(content.email).toBe("test@example.com");
73+
expect(content.updatedAt).toBeDefined();
74+
expect(() => new Date(content.updatedAt)).not.toThrow();
75+
});
76+
77+
it("creates parent directories if missing", async () => {
78+
const nestedPath = path.join(testDir, "nested", "dir", "config.json");
79+
process.env.EP_CONFIG_FILE = nestedPath;
80+
81+
await Effect.runPromise(
82+
writeConfig({ apiKey: "key", email: "e@x.com" })
83+
);
84+
85+
expect(existsSync(nestedPath)).toBe(true);
86+
});
87+
88+
it("overwrites existing config", async () => {
89+
await Effect.runPromise(
90+
writeConfig({ apiKey: "old-key", email: "old@x.com" })
91+
);
92+
await Effect.runPromise(
93+
writeConfig({ apiKey: "new-key", email: "new@x.com" })
94+
);
95+
96+
const configPath = resolveConfigPath();
97+
const content = JSON.parse(readFileSync(configPath, "utf8"));
98+
expect(content.apiKey).toBe("new-key");
99+
expect(content.email).toBe("new@x.com");
100+
});
101+
});
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* Login Command
3+
*
4+
* Opens the browser to EffectTalk.dev for authentication and captures
5+
* the API key via a local callback server.
6+
*/
7+
8+
import { Command } from "@effect/cli";
9+
import { Console, Duration, Effect } from "effect";
10+
import { randomBytes } from "node:crypto";
11+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
12+
import { writeConfig } from "../services/config/writer.js";
13+
14+
const AUTH_BASE_URL = "https://effecttalk.dev/cli/auth";
15+
const PRIMARY_PORT = 4567;
16+
const FALLBACK_PORT = 4568;
17+
const TIMEOUT = Duration.minutes(5);
18+
19+
interface AuthResult {
20+
readonly apiKey: string;
21+
readonly email: string;
22+
}
23+
24+
/**
25+
* Try to start an HTTP server on the given port.
26+
* Returns an Effect that fails if the port is busy.
27+
*/
28+
const tryListen = (server: Server, port: number): Effect.Effect<number, Error> =>
29+
Effect.async<number, Error>((resume) => {
30+
server.once("error", (err: NodeJS.ErrnoException) => {
31+
resume(Effect.fail(new Error(`Port ${port} unavailable: ${err.code}`)));
32+
});
33+
server.listen(port, "127.0.0.1", () => {
34+
resume(Effect.succeed(port));
35+
});
36+
});
37+
38+
/**
39+
* Open a URL in the user's default browser.
40+
*/
41+
const openBrowser = (url: string): Effect.Effect<void, Error> =>
42+
Effect.try({
43+
try: () => {
44+
const { exec } = require("node:child_process") as typeof import("node:child_process");
45+
const cmd =
46+
process.platform === "darwin"
47+
? "open"
48+
: process.platform === "win32"
49+
? "start"
50+
: "xdg-open";
51+
exec(`${cmd} "${url}"`);
52+
},
53+
catch: (error) =>
54+
error instanceof Error ? error : new Error(`Failed to open browser: ${String(error)}`),
55+
});
56+
57+
/**
58+
* Wait for the authentication callback on the local server.
59+
*/
60+
const waitForCallback = (
61+
server: Server,
62+
port: number,
63+
expectedState: string,
64+
): Effect.Effect<AuthResult, Error> =>
65+
Effect.async<AuthResult, Error>((resume) => {
66+
server.on("request", (req: IncomingMessage, res: ServerResponse) => {
67+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
68+
69+
if (url.pathname !== "/callback") {
70+
res.writeHead(404, { "Content-Type": "text/plain" });
71+
res.end("Not Found");
72+
return;
73+
}
74+
75+
const state = url.searchParams.get("state");
76+
if (state !== expectedState) {
77+
res.writeHead(403, { "Content-Type": "text/plain" });
78+
res.end("Invalid state parameter");
79+
return;
80+
}
81+
82+
const apiKey = url.searchParams.get("apiKey") ?? "";
83+
const email = url.searchParams.get("email") ?? "";
84+
85+
if (!apiKey) {
86+
res.writeHead(400, { "Content-Type": "text/plain" });
87+
res.end("Missing apiKey parameter");
88+
resume(Effect.fail(new Error("Callback received without apiKey")));
89+
return;
90+
}
91+
92+
res.writeHead(200, { "Content-Type": "text/html" });
93+
res.end(
94+
"<html><body style=\"font-family:system-ui;text-align:center;padding:2em\">" +
95+
"<h1>Success!</h1><p>You can close this tab and return to the terminal.</p>" +
96+
"</body></html>"
97+
);
98+
99+
resume(Effect.succeed({ apiKey, email }));
100+
});
101+
});
102+
103+
/**
104+
* Shut down the HTTP server.
105+
*/
106+
const closeServer = (server: Server): Effect.Effect<void> =>
107+
Effect.async<void>((resume) => {
108+
server.close(() => {
109+
resume(Effect.succeed(void 0));
110+
});
111+
});
112+
113+
export const loginCommand = Command.make("login").pipe(
114+
Command.withDescription("Authenticate with EffectTalk.dev"),
115+
Command.withHandler(() =>
116+
Effect.gen(function* () {
117+
const state = randomBytes(32).toString("hex");
118+
const server = createServer();
119+
120+
// Try primary port, fall back to secondary
121+
const port = yield* tryListen(server, PRIMARY_PORT).pipe(
122+
Effect.orElse(() => tryListen(server, FALLBACK_PORT)),
123+
Effect.mapError(() => new Error(
124+
`Could not start local server on port ${PRIMARY_PORT} or ${FALLBACK_PORT}. ` +
125+
"Please free one of these ports and try again."
126+
)),
127+
);
128+
129+
const authUrl = `${AUTH_BASE_URL}?state=${state}&port=${port}`;
130+
131+
yield* Console.log("\nAuthenticating with EffectTalk...");
132+
yield* Console.log("Your browser should open automatically.\n");
133+
yield* Console.log(`If it doesn't, open this URL manually:`);
134+
yield* Console.log(` ${authUrl}\n`);
135+
yield* Console.log("Waiting for authentication... (timeout: 5 minutes)\n");
136+
137+
// Open browser (best-effort — don't fail if it can't open)
138+
yield* openBrowser(authUrl).pipe(Effect.catchAll(() => Effect.void));
139+
140+
// Wait for callback with timeout
141+
const result = yield* waitForCallback(server, port, state).pipe(
142+
Effect.timeout(TIMEOUT),
143+
Effect.catchTag("TimeoutException", () =>
144+
Effect.fail(new Error(
145+
"Authentication timed out after 5 minutes.\n" +
146+
"Run `ep login` again to retry."
147+
))
148+
),
149+
Effect.ensuring(closeServer(server)),
150+
);
151+
152+
// Write credentials to config file
153+
const configPath = yield* writeConfig({
154+
apiKey: result.apiKey,
155+
email: result.email,
156+
});
157+
158+
const displayEmail = result.email || "unknown";
159+
yield* Console.log(`Success! Authenticated as ${displayEmail}.`);
160+
yield* Console.log(` API key saved to ${configPath}`);
161+
})
162+
)
163+
);

packages/ep-cli/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { CLI } from "./constants.js";
1616

1717
// Commands
1818
import { installCommand } from "./commands/install-commands.js";
19+
import { loginCommand } from "./commands/login-command.js";
1920
import { listCommand, searchCommand, showCommand } from "./commands/pattern-repo-commands.js";
2021
import { skillsCommand } from "./commands/skills-commands.js";
2122

@@ -133,6 +134,7 @@ export const rootCommand = Command.make("ep").pipe(
133134
showCommand,
134135
installCommand,
135136
skillsCommand,
137+
loginCommand,
136138
])
137139
);
138140

@@ -247,7 +249,7 @@ const isDirectExecution = (() => {
247249
return false;
248250
})();
249251

250-
const ROOT_COMMANDS = ["search", "list", "show", "install", "skills"] as const;
252+
const ROOT_COMMANDS = ["search", "list", "show", "install", "skills", "login"] as const;
251253
const NESTED_COMMANDS: Record<string, readonly string[]> = {
252254
install: ["add", "list"],
253255
skills: ["list", "preview", "validate", "stats"],
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* Config file path resolution and writing utilities.
3+
*/
4+
5+
import { Effect } from "effect";
6+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
7+
import { homedir } from "node:os";
8+
import path from "node:path";
9+
10+
/**
11+
* Resolve the path to the ep-cli config file.
12+
*
13+
* Priority:
14+
* 1. EP_CONFIG_FILE environment variable
15+
* 2. $XDG_CONFIG_HOME/ep-cli/config.json
16+
* 3. ~/.config/ep-cli/config.json
17+
*/
18+
export const resolveConfigPath = (): string => {
19+
const explicit = process.env.EP_CONFIG_FILE;
20+
if (explicit && explicit.trim().length > 0) {
21+
return explicit;
22+
}
23+
24+
const configHome = process.env.XDG_CONFIG_HOME || path.join(homedir(), ".config");
25+
return path.join(configHome, "ep-cli", "config.json");
26+
};
27+
28+
/**
29+
* Write credentials to the ep-cli config file.
30+
*
31+
* Creates the parent directory if it doesn't exist and sets
32+
* file permissions to 0o600 (owner read/write only).
33+
*/
34+
export const writeConfig = (data: {
35+
readonly apiKey: string;
36+
readonly email: string;
37+
}): Effect.Effect<string, Error> =>
38+
Effect.try({
39+
try: () => {
40+
const configPath = resolveConfigPath();
41+
const dir = path.dirname(configPath);
42+
43+
if (!existsSync(dir)) {
44+
mkdirSync(dir, { recursive: true });
45+
}
46+
47+
const content = JSON.stringify(
48+
{
49+
apiKey: data.apiKey,
50+
email: data.email,
51+
updatedAt: new Date().toISOString(),
52+
},
53+
null,
54+
2
55+
);
56+
57+
writeFileSync(configPath, content, { mode: 0o600 });
58+
return configPath;
59+
},
60+
catch: (error) =>
61+
error instanceof Error
62+
? error
63+
: new Error(`Failed to write config: ${String(error)}`),
64+
});

packages/ep-cli/src/services/pattern-api/service.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,17 @@
44

55
import { Effect } from "effect";
66
import { existsSync, readFileSync } from "node:fs";
7-
import { homedir } from "node:os";
8-
import path from "node:path";
97
import type {
108
PatternApiService,
119
PatternDetail,
1210
PatternSearchParams,
1311
PatternSummary,
1412
} from "./api.js";
13+
import { resolveConfigPath } from "../config/writer.js";
1514

1615
const DEFAULT_API_BASE_URL = "https://effect-patterns-mcp-server-buddybuilder.vercel.app";
1716
const DEFAULT_TIMEOUT_MS = 10_000;
1817

19-
const resolveConfigPath = () => {
20-
const explicit = process.env.EP_CONFIG_FILE;
21-
if (explicit && explicit.trim().length > 0) {
22-
return explicit;
23-
}
24-
25-
const configHome = process.env.XDG_CONFIG_HOME || path.join(homedir(), ".config");
26-
return path.join(configHome, "ep-cli", "config.json");
27-
};
28-
2918
const readApiKeyFromFile = (filePath: string): string => {
3019
const content = readFileSync(filePath, "utf8").trim();
3120
if (!content) {

0 commit comments

Comments
 (0)