Skip to content

Commit 49f69f4

Browse files
authored
feat(cli): support browser-based auth login (#4887)
1 parent 5950542 commit 49f69f4

File tree

6 files changed

+374
-13
lines changed

6 files changed

+374
-13
lines changed

apps/cli/skill.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ Use this skill when a user asks to:
1616

1717
1. Folo CLI is installed and executable as `folo`.
1818
2. Authentication is configured:
19-
- `folo auth login --token <session-token>`
19+
- `folo auth login` (recommended, opens browser and auto-logins)
20+
- or `folo auth login --token <session-token>`
2021
- or set `FOLO_TOKEN=<token>`
2122

2223
## Output Contract
@@ -113,7 +114,7 @@ Loop until `hasNext` is `false`:
113114

114115
## Command Reference
115116

116-
- `folo auth login --token <token>`
117+
- `folo auth login [--timeout <seconds>] [--token <token>]`
117118
- `folo auth logout`
118119
- `folo auth whoami`
119120

@@ -163,7 +164,8 @@ Loop until `hasNext` is `false`:
163164
## Error Recovery
164165

165166
- `UNAUTHORIZED`
166-
- Re-login: `folo auth login --token <token>`
167+
- Re-login: `folo auth login`
168+
- or `folo auth login --token <token>`
167169
- Or set `FOLO_TOKEN`
168170
- `HTTP_4xx` / `HTTP_5xx`
169171
- Retry with `--verbose` for request details

apps/cli/src/browser-login.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, expect, it } from "vitest"
2+
3+
import { DEFAULT_VALUES } from "../../../packages/internal/shared/src/env.common"
4+
import { resolveCLILoginUrl } from "./browser-login"
5+
6+
describe("browser login helpers", () => {
7+
it("maps production API URL using env.common", () => {
8+
const url = resolveCLILoginUrl(DEFAULT_VALUES.PROD.API_URL, "http://127.0.0.1:12345/callback")
9+
const parsed = new URL(url)
10+
11+
expect(parsed.origin).toBe(new URL(DEFAULT_VALUES.PROD.WEB_URL).origin)
12+
expect(parsed.pathname).toBe("/login")
13+
expect(parsed.searchParams.get("cli_callback")).toBe("http://127.0.0.1:12345/callback")
14+
})
15+
16+
it("maps dev API URL using env.common", () => {
17+
const url = resolveCLILoginUrl(DEFAULT_VALUES.DEV.API_URL, "http://127.0.0.1:12345/callback")
18+
const parsed = new URL(url)
19+
20+
expect(parsed.origin).toBe(new URL(DEFAULT_VALUES.DEV.WEB_URL).origin)
21+
expect(parsed.pathname).toBe("/login")
22+
expect(parsed.searchParams.get("cli_callback")).toBe("http://127.0.0.1:12345/callback")
23+
})
24+
25+
it("maps local API URL using env.common", () => {
26+
const url = resolveCLILoginUrl(DEFAULT_VALUES.LOCAL.API_URL, "http://127.0.0.1:12345/callback")
27+
const parsed = new URL(url)
28+
29+
expect(parsed.origin).toBe(new URL(DEFAULT_VALUES.LOCAL.WEB_URL).origin)
30+
expect(parsed.pathname).toBe("/login")
31+
expect(parsed.searchParams.get("cli_callback")).toBe("http://127.0.0.1:12345/callback")
32+
})
33+
34+
it("falls back to API origin when no mapping exists", () => {
35+
const url = resolveCLILoginUrl("https://api.follow.is", "http://localhost:3456/callback")
36+
const parsed = new URL(url)
37+
38+
expect(parsed.origin).toBe("https://api.follow.is")
39+
expect(parsed.pathname).toBe("/login")
40+
expect(parsed.searchParams.get("cli_callback")).toBe("http://localhost:3456/callback")
41+
})
42+
43+
it("throws for invalid api url", () => {
44+
expect(() => resolveCLILoginUrl("not-a-url", "http://127.0.0.1:3333/callback")).toThrowError(
45+
/Invalid API URL/,
46+
)
47+
})
48+
})

apps/cli/src/browser-login.ts

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { spawnSync } from "node:child_process"
2+
import { createServer } from "node:http"
3+
import type { AddressInfo } from "node:net"
4+
5+
import { DEFAULT_VALUES } from "../../../packages/internal/shared/src/env.common"
6+
import { CLIError } from "./output"
7+
8+
const LOCAL_CALLBACK_HOST = "127.0.0.1"
9+
const LOCAL_CALLBACK_PATH = "/callback"
10+
const DEFAULT_TIMEOUT_MS = 3 * 60 * 1000
11+
12+
const mappedWebOrigins: Array<{ apiOrigin: string; webOrigin: string }> = [
13+
{
14+
apiOrigin: new URL(DEFAULT_VALUES.PROD.API_URL).origin,
15+
webOrigin: new URL(DEFAULT_VALUES.PROD.WEB_URL).origin,
16+
},
17+
{
18+
apiOrigin: new URL(DEFAULT_VALUES.DEV.API_URL).origin,
19+
webOrigin: new URL(DEFAULT_VALUES.DEV.WEB_URL).origin,
20+
},
21+
{
22+
apiOrigin: new URL(DEFAULT_VALUES.LOCAL.API_URL).origin,
23+
webOrigin: new URL(DEFAULT_VALUES.LOCAL.WEB_URL).origin,
24+
},
25+
]
26+
27+
const successPageHtml = `<!doctype html>
28+
<html lang="en">
29+
<head>
30+
<meta charset="utf-8" />
31+
<meta name="viewport" content="width=device-width, initial-scale=1" />
32+
<title>Folo CLI Login</title>
33+
</head>
34+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; line-height: 1.5;">
35+
<h1>Folo CLI login complete</h1>
36+
<p>You can close this window and return to your terminal.</p>
37+
</body>
38+
</html>
39+
`
40+
41+
const failurePageHtml = `<!doctype html>
42+
<html lang="en">
43+
<head>
44+
<meta charset="utf-8" />
45+
<meta name="viewport" content="width=device-width, initial-scale=1" />
46+
<title>Folo CLI Login</title>
47+
</head>
48+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; line-height: 1.5;">
49+
<h1>Folo CLI login failed</h1>
50+
<p>Missing token in callback URL. Please retry in terminal.</p>
51+
</body>
52+
</html>
53+
`
54+
55+
const getOpenBrowserCommand = (url: string): { command: string; args: string[] } => {
56+
if (process.platform === "darwin") {
57+
return { command: "open", args: [url] }
58+
}
59+
60+
if (process.platform === "win32") {
61+
return { command: "cmd", args: ["/c", "start", "", url] }
62+
}
63+
64+
return { command: "xdg-open", args: [url] }
65+
}
66+
67+
const openBrowser = (url: string) => {
68+
const { command, args } = getOpenBrowserCommand(url)
69+
const result = spawnSync(command, args, {
70+
stdio: "ignore",
71+
})
72+
73+
if (result.error || result.status !== 0) {
74+
throw new CLIError(
75+
"BROWSER_OPEN_FAILED",
76+
`Failed to open browser automatically. Open this URL manually: ${url}`,
77+
)
78+
}
79+
}
80+
81+
export const resolveCLILoginUrl = (apiUrl: string, callbackUrl: string): string => {
82+
let api: URL
83+
try {
84+
api = new URL(apiUrl)
85+
} catch {
86+
throw new CLIError("INVALID_ARGUMENT", `Invalid API URL: ${apiUrl}`)
87+
}
88+
89+
const mappedWebOrigin = mappedWebOrigins.find((item) => item.apiOrigin === api.origin)?.webOrigin
90+
const webUrl = new URL(mappedWebOrigin ?? api.origin)
91+
92+
webUrl.pathname = "/login"
93+
webUrl.search = ""
94+
webUrl.hash = ""
95+
webUrl.searchParams.set("cli_callback", callbackUrl)
96+
97+
return webUrl.toString()
98+
}
99+
100+
export interface BrowserLoginOptions {
101+
apiUrl: string
102+
timeoutMs?: number
103+
onStatus?: (message: string) => void
104+
}
105+
106+
export interface BrowserLoginResult {
107+
token: string
108+
callbackUrl: string
109+
loginUrl: string
110+
}
111+
112+
export const loginWithBrowser = async (
113+
options: BrowserLoginOptions,
114+
): Promise<BrowserLoginResult> => {
115+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
116+
const onStatus = options.onStatus ?? (() => {})
117+
118+
if (timeoutMs <= 0) {
119+
throw new CLIError("INVALID_ARGUMENT", "Browser login timeout must be greater than 0.")
120+
}
121+
122+
const result = await new Promise<BrowserLoginResult>((resolve, reject) => {
123+
let settled = false
124+
let timer: NodeJS.Timeout | undefined
125+
126+
const settle = (handler: () => void) => {
127+
if (settled) {
128+
return
129+
}
130+
settled = true
131+
if (timer) {
132+
clearTimeout(timer)
133+
}
134+
server.close(() => {
135+
handler()
136+
})
137+
}
138+
139+
const server = createServer((req, res) => {
140+
const requestUrl = new URL(
141+
req.url ?? "/",
142+
`http://${req.headers.host ?? LOCAL_CALLBACK_HOST}`,
143+
)
144+
145+
if (requestUrl.pathname !== LOCAL_CALLBACK_PATH) {
146+
res.statusCode = 404
147+
res.end("Not Found")
148+
return
149+
}
150+
151+
const token = requestUrl.searchParams.get("token")
152+
if (!token) {
153+
res.statusCode = 400
154+
res.setHeader("content-type", "text/html; charset=utf-8")
155+
res.end(failurePageHtml)
156+
return
157+
}
158+
159+
res.statusCode = 200
160+
res.setHeader("content-type", "text/html; charset=utf-8")
161+
res.end(successPageHtml)
162+
163+
const callbackAddress = server.address() as AddressInfo | null
164+
const callbackUrl = callbackAddress
165+
? `http://${LOCAL_CALLBACK_HOST}:${callbackAddress.port}${LOCAL_CALLBACK_PATH}`
166+
: ""
167+
const loginUrl = resolveCLILoginUrl(options.apiUrl, callbackUrl)
168+
169+
settle(() => {
170+
resolve({
171+
token,
172+
callbackUrl,
173+
loginUrl,
174+
})
175+
})
176+
})
177+
178+
server.once("error", (error) => {
179+
settle(() => {
180+
reject(
181+
new CLIError("NETWORK_ERROR", `Failed to start local callback server: ${error.message}`),
182+
)
183+
})
184+
})
185+
186+
server.listen(0, LOCAL_CALLBACK_HOST, () => {
187+
const address = server.address() as AddressInfo | null
188+
if (!address) {
189+
settle(() => {
190+
reject(new CLIError("NETWORK_ERROR", "Failed to bind local callback server."))
191+
})
192+
return
193+
}
194+
195+
const callbackUrl = `http://${LOCAL_CALLBACK_HOST}:${address.port}${LOCAL_CALLBACK_PATH}`
196+
const loginUrl = resolveCLILoginUrl(options.apiUrl, callbackUrl)
197+
198+
onStatus(`Open this URL to sign in: ${loginUrl}`)
199+
200+
try {
201+
openBrowser(loginUrl)
202+
onStatus("Browser opened. Waiting for login confirmation...")
203+
} catch (error) {
204+
onStatus((error as Error).message)
205+
onStatus("Waiting for login confirmation...")
206+
}
207+
208+
timer = setTimeout(() => {
209+
settle(() => {
210+
reject(
211+
new CLIError(
212+
"TIMEOUT",
213+
"Timed out waiting for browser login. Please run `folo auth login` again.",
214+
),
215+
)
216+
})
217+
}, timeoutMs)
218+
})
219+
})
220+
221+
return result
222+
}

apps/cli/src/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export const createCommandContext = async (
8282
if (requireAuth && !token) {
8383
throw new CLIError(
8484
"UNAUTHORIZED",
85-
"Missing token. Run `folo auth login --token <token>` or set FOLO_TOKEN.",
85+
"Missing token. Run `folo auth login` (browser sign-in) or set FOLO_TOKEN.",
8686
)
8787
}
8888

apps/cli/src/commands/auth.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,44 @@
11
import type { Command } from "commander"
22

3+
import { parsePositiveInt } from "../args"
4+
import { loginWithBrowser } from "../browser-login"
35
import { getGlobalOptions } from "../client"
46
import { runCommand } from "../command"
57
import { clearToken, getConfigPath, updateConfig } from "../config"
68
import { CLIError } from "../output"
79

810
interface AuthLoginOptions {
9-
token: string
11+
token?: string
12+
timeout?: number
1013
}
1114

1215
export const registerAuthCommand = (program: Command) => {
1316
const authCommand = program.command("auth").description("Authentication commands")
1417

1518
authCommand
1619
.command("login")
17-
.description("Save session token and verify authentication")
20+
.description("Sign in via browser (or save a provided token) and verify authentication")
1821
.option("--token <token>", "Session token from Folo")
22+
.option(
23+
"--timeout <seconds>",
24+
"Browser login timeout in seconds (default: 180)",
25+
parsePositiveInt,
26+
)
1927
.action(async function (this: Command, options: AuthLoginOptions) {
2028
await runCommand(
2129
this,
2230
async ({ client, options: globalOptions }) => {
23-
const token = options.token ?? getGlobalOptions(this).token
31+
let token = options.token ?? getGlobalOptions(this).token
2432
if (!token) {
25-
throw new CLIError("INVALID_ARGUMENT", "Missing token. Use --token <token>.")
33+
const timeoutMs = (options.timeout ?? 180) * 1000
34+
const browserLogin = await loginWithBrowser({
35+
apiUrl: globalOptions.apiUrl,
36+
timeoutMs,
37+
onStatus: (message) => {
38+
console.error(`[auth] ${message}`)
39+
},
40+
})
41+
token = browserLogin.token
2642
}
2743

2844
client.setAuthToken(token)

0 commit comments

Comments
 (0)