From 0609862bb21707bfe48c71dda4b27b4f55007435 Mon Sep 17 00:00:00 2001 From: jackwener Date: Thu, 4 Jun 2026 18:29:12 +0800 Subject: [PATCH 1/2] feat(auth): add site login and whoami commands --- cli-manifest.json | 256 +++++++++++++++++++++++++++++++++ clis/_shared/site-auth.js | 104 ++++++++++++++ clis/_shared/site-auth.test.js | 97 +++++++++++++ clis/bilibili/auth.js | 35 +++++ clis/douyin/auth.js | 38 +++++ clis/github/auth.js | 42 ++++++ clis/twitter/auth.js | 40 ++++++ clis/xiaohongshu/auth.js | 51 +++++++ src/build-manifest.ts | 3 +- src/discovery.ts | 3 +- src/execution.test.ts | 27 ++++ src/execution.ts | 2 +- src/manifest-types.ts | 2 + src/registry.ts | 3 + 14 files changed, 700 insertions(+), 3 deletions(-) create mode 100644 clis/_shared/site-auth.js create mode 100644 clis/_shared/site-auth.test.js create mode 100644 clis/bilibili/auth.js create mode 100644 clis/douyin/auth.js create mode 100644 clis/github/auth.js create mode 100644 clis/twitter/auth.js create mode 100644 clis/xiaohongshu/auth.js diff --git a/cli-manifest.json b/cli-manifest.json index fd1a13229..6c45af7b8 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -3386,6 +3386,37 @@ "sourceFile": "bilibili/hot.js", "navigateBefore": "https://www.bilibili.com" }, + { + "site": "bilibili", + "name": "login", + "description": "Open bilibili login and wait until the browser session is authenticated", + "access": "write", + "domain": "www.bilibili.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "timeout", + "type": "int", + "default": 300, + "required": false, + "help": "Maximum seconds to wait for the user to finish login" + } + ], + "columns": [ + "status", + "logged_in", + "site", + "id", + "username", + "level" + ], + "type": "js", + "modulePath": "bilibili/auth.js", + "sourceFile": "bilibili/auth.js", + "navigateBefore": false, + "defaultWindowMode": "foreground" + }, { "site": "bilibili", "name": "me", @@ -3623,6 +3654,27 @@ "sourceFile": "bilibili/video.js", "navigateBefore": true }, + { + "site": "bilibili", + "name": "whoami", + "description": "Show the current logged-in bilibili account", + "access": "read", + "domain": "www.bilibili.com", + "strategy": "cookie", + "browser": true, + "args": [], + "columns": [ + "logged_in", + "site", + "id", + "username", + "level" + ], + "type": "js", + "modulePath": "bilibili/auth.js", + "sourceFile": "bilibili/auth.js", + "navigateBefore": false + }, { "site": "binance", "name": "asks", @@ -10288,6 +10340,37 @@ "sourceFile": "douyin/location.js", "navigateBefore": "https://creator.douyin.com" }, + { + "site": "douyin", + "name": "login", + "description": "Open douyin login and wait until the browser session is authenticated", + "access": "write", + "domain": "creator.douyin.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "timeout", + "type": "int", + "default": 300, + "required": false, + "help": "Maximum seconds to wait for the user to finish login" + } + ], + "columns": [ + "status", + "logged_in", + "site", + "id", + "username", + "followers" + ], + "type": "js", + "modulePath": "douyin/auth.js", + "sourceFile": "douyin/auth.js", + "navigateBefore": false, + "defaultWindowMode": "foreground" + }, { "site": "douyin", "name": "profile", @@ -10637,6 +10720,27 @@ "sourceFile": "douyin/videos.js", "navigateBefore": "https://creator.douyin.com" }, + { + "site": "douyin", + "name": "whoami", + "description": "Show the current logged-in douyin account", + "access": "read", + "domain": "creator.douyin.com", + "strategy": "cookie", + "browser": true, + "args": [], + "columns": [ + "logged_in", + "site", + "id", + "username", + "followers" + ], + "type": "js", + "modulePath": "douyin/auth.js", + "sourceFile": "douyin/auth.js", + "navigateBefore": false + }, { "site": "duckduckgo", "name": "search", @@ -12372,6 +12476,58 @@ "modulePath": "gitee/user.js", "sourceFile": "gitee/user.js" }, + { + "site": "github", + "name": "login", + "description": "Open github login and wait until the browser session is authenticated", + "access": "write", + "domain": "github.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "timeout", + "type": "int", + "default": 300, + "required": false, + "help": "Maximum seconds to wait for the user to finish login" + } + ], + "columns": [ + "status", + "logged_in", + "site", + "id", + "username", + "name" + ], + "type": "js", + "modulePath": "github/auth.js", + "sourceFile": "github/auth.js", + "navigateBefore": false, + "defaultWindowMode": "foreground" + }, + { + "site": "github", + "name": "whoami", + "description": "Show the current logged-in github account", + "access": "read", + "domain": "github.com", + "strategy": "cookie", + "browser": true, + "args": [], + "columns": [ + "logged_in", + "site", + "id", + "username", + "name" + ], + "type": "js", + "modulePath": "github/auth.js", + "sourceFile": "github/auth.js", + "navigateBefore": false + }, { "site": "google", "name": "news", @@ -30194,6 +30350,36 @@ "sourceFile": "twitter/lists.js", "navigateBefore": "https://x.com" }, + { + "site": "twitter", + "name": "login", + "description": "Open twitter login and wait until the browser session is authenticated", + "access": "write", + "domain": "x.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "timeout", + "type": "int", + "default": 300, + "required": false, + "help": "Maximum seconds to wait for the user to finish login" + } + ], + "columns": [ + "status", + "logged_in", + "site", + "username", + "url" + ], + "type": "js", + "modulePath": "twitter/auth.js", + "sourceFile": "twitter/auth.js", + "navigateBefore": false, + "defaultWindowMode": "foreground" + }, { "site": "twitter", "name": "notifications", @@ -30878,6 +31064,26 @@ "sourceFile": "twitter/unretweet.js", "navigateBefore": true }, + { + "site": "twitter", + "name": "whoami", + "description": "Show the current logged-in twitter account", + "access": "read", + "domain": "x.com", + "strategy": "cookie", + "browser": true, + "args": [], + "columns": [ + "logged_in", + "site", + "username", + "url" + ], + "type": "js", + "modulePath": "twitter/auth.js", + "sourceFile": "twitter/auth.js", + "navigateBefore": false + }, { "site": "uisdc", "name": "news", @@ -34181,6 +34387,36 @@ "sourceFile": "xiaohongshu/feed.js", "navigateBefore": false }, + { + "site": "xiaohongshu", + "name": "login", + "description": "Open xiaohongshu login and wait until the browser session is authenticated", + "access": "write", + "domain": "creator.xiaohongshu.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "timeout", + "type": "int", + "default": 300, + "required": false, + "help": "Maximum seconds to wait for the user to finish login" + } + ], + "columns": [ + "status", + "logged_in", + "site", + "username", + "followers" + ], + "type": "js", + "modulePath": "xiaohongshu/auth.js", + "sourceFile": "xiaohongshu/auth.js", + "navigateBefore": false, + "defaultWindowMode": "foreground" + }, { "site": "xiaohongshu", "name": "note", @@ -34368,6 +34604,26 @@ "sourceFile": "xiaohongshu/user.js", "navigateBefore": false }, + { + "site": "xiaohongshu", + "name": "whoami", + "description": "Show the current logged-in xiaohongshu account", + "access": "read", + "domain": "creator.xiaohongshu.com", + "strategy": "cookie", + "browser": true, + "args": [], + "columns": [ + "logged_in", + "site", + "username", + "followers" + ], + "type": "js", + "modulePath": "xiaohongshu/auth.js", + "sourceFile": "xiaohongshu/auth.js", + "navigateBefore": false + }, { "site": "xiaoyuzhou", "name": "download", diff --git a/clis/_shared/site-auth.js b/clis/_shared/site-auth.js new file mode 100644 index 000000000..d369e4f84 --- /dev/null +++ b/clis/_shared/site-auth.js @@ -0,0 +1,104 @@ +import { AuthRequiredError, TimeoutError, getErrorMessage } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; + +const DEFAULT_TIMEOUT_SECONDS = 300; +const POLL_INTERVAL_MS = 2000; + +function normalizeIdentity(site, identity) { + const row = identity && typeof identity === 'object' && !Array.isArray(identity) + ? identity + : {}; + return { logged_in: true, site, ...row }; +} + +function isAuthRequired(error) { + return error instanceof AuthRequiredError; +} + +async function tryProbe(config, page, phase) { + const probe = phase === 'poll' && config.poll ? config.poll : config.verify; + return normalizeIdentity(config.site, await probe(page, { phase })); +} + +function authHint(config) { + return `Run \`opencli ${config.site} login\` to open the login page, then retry.`; +} + +function commandColumns(config) { + const identityColumns = config.columns ?? ['id', 'username', 'name']; + return ['logged_in', 'site', ...identityColumns]; +} + +export function registerSiteAuthCommands(config) { + if (!config?.site || !config?.domain || !config?.loginUrl || typeof config.verify !== 'function') { + throw new Error('registerSiteAuthCommands requires site, domain, loginUrl, and verify(page)'); + } + + cli({ + site: config.site, + name: 'whoami', + access: 'read', + description: config.whoamiDescription ?? `Show the current logged-in ${config.site} account`, + domain: config.domain, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + args: [], + columns: commandColumns(config), + func: async (page) => { + try { + return tryProbe(config, page, 'identity'); + } catch (error) { + if (isAuthRequired(error)) { + throw new AuthRequiredError(config.domain, error.message || `Not logged in to ${config.site}`); + } + throw error; + } + }, + }); + + cli({ + site: config.site, + name: 'login', + access: 'write', + description: config.loginDescription ?? `Open ${config.site} login and wait until the browser session is authenticated`, + domain: config.domain, + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + defaultWindowMode: 'foreground', + args: [ + { name: 'timeout', type: 'int', default: DEFAULT_TIMEOUT_SECONDS, help: 'Maximum seconds to wait for the user to finish login' }, + ], + columns: ['status', ...commandColumns(config)], + func: async (page, kwargs) => { + try { + return { status: 'already_logged_in', ...await tryProbe(config, page, 'identity') }; + } catch (error) { + if (!isAuthRequired(error)) throw error; + } + + await page.goto(config.loginUrl); + const timeoutSeconds = Number(kwargs.timeout ?? DEFAULT_TIMEOUT_SECONDS); + const deadline = Date.now() + timeoutSeconds * 1000; + let lastAuthMessage = ''; + + while (Date.now() < deadline) { + await page.wait(Math.min(POLL_INTERVAL_MS / 1000, Math.max(0.2, (deadline - Date.now()) / 1000))); + try { + const identity = await tryProbe(config, page, 'poll'); + return { status: 'login_complete', ...identity }; + } catch (error) { + if (!isAuthRequired(error)) throw error; + lastAuthMessage = getErrorMessage(error); + } + } + + throw new TimeoutError( + `${config.site} login`, + timeoutSeconds, + lastAuthMessage ? `${authHint(config)} Last auth check: ${lastAuthMessage}` : authHint(config), + ); + }, + }); +} diff --git a/clis/_shared/site-auth.test.js b/clis/_shared/site-auth.test.js new file mode 100644 index 000000000..ec0bc990f --- /dev/null +++ b/clis/_shared/site-auth.test.js @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AuthRequiredError, TimeoutError } from '@jackwener/opencli/errors'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { registerSiteAuthCommands } from './site-auth.js'; + +function pageMock() { + return { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('site auth command helper', () => { + it('registers whoami and foreground login commands', () => { + registerSiteAuthCommands({ + site: 'auth-helper-registration', + domain: 'example.com', + loginUrl: 'https://example.com/login', + columns: ['username'], + verify: async () => ({ username: 'alice' }), + }); + + expect(getRegistry().get('auth-helper-registration/whoami')).toMatchObject({ + access: 'read', + browser: true, + navigateBefore: false, + columns: ['logged_in', 'site', 'username'], + }); + expect(getRegistry().get('auth-helper-registration/login')).toMatchObject({ + access: 'write', + browser: true, + navigateBefore: false, + defaultWindowMode: 'foreground', + columns: ['status', 'logged_in', 'site', 'username'], + }); + }); + + it('whoami returns normalized identity without opening login', async () => { + registerSiteAuthCommands({ + site: 'auth-helper-whoami', + domain: 'example.com', + loginUrl: 'https://example.com/login', + columns: ['username'], + verify: async () => ({ username: 'alice' }), + }); + const cmd = getRegistry().get('auth-helper-whoami/whoami'); + const page = pageMock(); + + await expect(cmd.func(page, {})).resolves.toEqual({ + logged_in: true, + site: 'auth-helper-whoami', + username: 'alice', + }); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('login opens the login URL and polls until authenticated', async () => { + const poll = vi.fn() + .mockRejectedValueOnce(new AuthRequiredError('example.com', 'not yet')) + .mockResolvedValueOnce({ username: 'alice' }); + registerSiteAuthCommands({ + site: 'auth-helper-login', + domain: 'example.com', + loginUrl: 'https://example.com/login', + columns: ['username'], + verify: async () => { throw new AuthRequiredError('example.com', 'missing'); }, + poll, + }); + const cmd = getRegistry().get('auth-helper-login/login'); + const page = pageMock(); + + await expect(cmd.func(page, { timeout: 1 })).resolves.toEqual({ + status: 'login_complete', + logged_in: true, + site: 'auth-helper-login', + username: 'alice', + }); + expect(page.goto).toHaveBeenCalledWith('https://example.com/login'); + expect(page.wait).toHaveBeenCalled(); + expect(poll).toHaveBeenCalledTimes(2); + }); + + it('login times out when auth never completes', async () => { + registerSiteAuthCommands({ + site: 'auth-helper-timeout', + domain: 'example.com', + loginUrl: 'https://example.com/login', + verify: async () => { throw new AuthRequiredError('example.com', 'missing'); }, + poll: async () => { throw new AuthRequiredError('example.com', 'still missing'); }, + }); + const cmd = getRegistry().get('auth-helper-timeout/login'); + const page = pageMock(); + + await expect(cmd.func(page, { timeout: 0 })).rejects.toBeInstanceOf(TimeoutError); + expect(page.goto).toHaveBeenCalledWith('https://example.com/login'); + }); +}); diff --git a/clis/bilibili/auth.js b/clis/bilibili/auth.js new file mode 100644 index 000000000..19560f9f6 --- /dev/null +++ b/clis/bilibili/auth.js @@ -0,0 +1,35 @@ +import { AuthRequiredError } from '@jackwener/opencli/errors'; +import { registerSiteAuthCommands } from '../_shared/site-auth.js'; +import { apiGet, getSelfUid } from './utils.js'; + +async function hasBilibiliSessionCookies(page) { + const cookies = await page.getCookies({ url: 'https://www.bilibili.com' }); + const names = new Set(cookies.map(cookie => cookie.name)); + return names.has('SESSDATA') && names.has('DedeUserID'); +} + +async function verifyBilibiliIdentity(page) { + await page.goto('https://www.bilibili.com'); + const uid = await getSelfUid(page); + const payload = await apiGet(page, '/x/space/wbi/acc/info', { params: { mid: uid }, signed: true }); + const data = payload?.data ?? {}; + return { + id: String(data.mid ?? uid), + username: data.name ?? '', + level: data.level ?? 0, + }; +} + +registerSiteAuthCommands({ + site: 'bilibili', + domain: 'www.bilibili.com', + loginUrl: 'https://passport.bilibili.com/login', + columns: ['id', 'username', 'level'], + verify: verifyBilibiliIdentity, + poll: async (page) => { + if (!await hasBilibiliSessionCookies(page)) { + throw new AuthRequiredError('bilibili.com', 'Waiting for Bilibili session cookies'); + } + return verifyBilibiliIdentity(page); + }, +}); diff --git a/clis/douyin/auth.js b/clis/douyin/auth.js new file mode 100644 index 000000000..8fac45bfe --- /dev/null +++ b/clis/douyin/auth.js @@ -0,0 +1,38 @@ +import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { registerSiteAuthCommands } from '../_shared/site-auth.js'; +import { browserFetch } from './_shared/browser-fetch.js'; + +async function hasDouyinSessionCookies(page) { + const cookies = await page.getCookies({ url: 'https://creator.douyin.com' }); + const names = new Set(cookies.map(cookie => cookie.name)); + return names.has('sessionid') || names.has('uid_tt') || names.has('passport_csrf_token'); +} + +async function verifyDouyinIdentity(page) { + await page.goto('https://creator.douyin.com'); + const url = 'https://creator.douyin.com/web/api/media/user/info/?aid=1128'; + const payload = await browserFetch(page, 'GET', url); + const user = payload.user_info ?? payload.user; + if (!user) { + throw new CommandExecutionError('Douyin user info response is missing user_info'); + } + return { + id: user.uid ?? '', + username: user.nickname ?? '', + followers: user.follower_count ?? 0, + }; +} + +registerSiteAuthCommands({ + site: 'douyin', + domain: 'creator.douyin.com', + loginUrl: 'https://creator.douyin.com/', + columns: ['id', 'username', 'followers'], + verify: verifyDouyinIdentity, + poll: async (page) => { + if (!await hasDouyinSessionCookies(page)) { + throw new AuthRequiredError('creator.douyin.com', 'Waiting for Douyin creator session cookies'); + } + return verifyDouyinIdentity(page); + }, +}); diff --git a/clis/github/auth.js b/clis/github/auth.js new file mode 100644 index 000000000..3facb20bc --- /dev/null +++ b/clis/github/auth.js @@ -0,0 +1,42 @@ +import { AuthRequiredError } from '@jackwener/opencli/errors'; +import { registerSiteAuthCommands } from '../_shared/site-auth.js'; + +async function hasGithubSessionCookies(page) { + const cookies = await page.getCookies({ url: 'https://github.com' }); + const names = new Set(cookies.map(cookie => cookie.name)); + return names.has('user_session') || names.has('dotcom_user') || names.has('logged_in'); +} + +async function verifyGithubIdentity(page) { + await page.goto('https://github.com/settings/profile'); + await page.wait(1); + const identity = await page.evaluate(`() => { + const meta = (name) => document.querySelector('meta[name="' + name + '"]')?.getAttribute('content') || ''; + const username = meta('octolytics-actor-login'); + const id = meta('octolytics-actor-id'); + const name = document.querySelector('input#user_profile_name')?.value || ''; + return { username, id, name, url: location.href }; + }`); + if (!identity?.username || /\/login(?:\?|$)/.test(String(identity?.url ?? ''))) { + throw new AuthRequiredError('github.com', 'Could not detect a logged-in GitHub account'); + } + return { + id: identity.id || '', + username: identity.username, + name: identity.name || '', + }; +} + +registerSiteAuthCommands({ + site: 'github', + domain: 'github.com', + loginUrl: 'https://github.com/login', + columns: ['id', 'username', 'name'], + verify: verifyGithubIdentity, + poll: async (page) => { + if (!await hasGithubSessionCookies(page)) { + throw new AuthRequiredError('github.com', 'Waiting for GitHub session cookies'); + } + return verifyGithubIdentity(page); + }, +}); diff --git a/clis/twitter/auth.js b/clis/twitter/auth.js new file mode 100644 index 000000000..1c1134e0e --- /dev/null +++ b/clis/twitter/auth.js @@ -0,0 +1,40 @@ +import { AuthRequiredError } from '@jackwener/opencli/errors'; +import { registerSiteAuthCommands } from '../_shared/site-auth.js'; +import { normalizeTwitterScreenName, unwrapBrowserResult } from './shared.js'; + +async function hasTwitterSessionCookies(page) { + const cookies = await page.getCookies({ url: 'https://x.com' }); + const names = new Set(cookies.map(cookie => cookie.name)); + return names.has('auth_token') && names.has('ct0'); +} + +async function verifyTwitterIdentity(page) { + if (!await hasTwitterSessionCookies(page)) { + throw new AuthRequiredError('x.com', 'Twitter/X auth cookies are missing'); + } + await page.goto('https://x.com/home'); + await page.wait(1); + const href = unwrapBrowserResult(await page.evaluate(`() => { + const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]'); + return link ? link.getAttribute('href') : null; + }`)); + const username = normalizeTwitterScreenName(typeof href === 'string' ? href : ''); + if (!username) { + throw new AuthRequiredError('x.com', 'Could not detect the logged-in Twitter/X profile link'); + } + return { username, url: `https://x.com/${username}` }; +} + +registerSiteAuthCommands({ + site: 'twitter', + domain: 'x.com', + loginUrl: 'https://x.com/i/flow/login', + columns: ['username', 'url'], + verify: verifyTwitterIdentity, + poll: async (page) => { + if (!await hasTwitterSessionCookies(page)) { + throw new AuthRequiredError('x.com', 'Waiting for Twitter/X auth cookies'); + } + return verifyTwitterIdentity(page); + }, +}); diff --git a/clis/xiaohongshu/auth.js b/clis/xiaohongshu/auth.js new file mode 100644 index 000000000..cff71e101 --- /dev/null +++ b/clis/xiaohongshu/auth.js @@ -0,0 +1,51 @@ +import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { registerSiteAuthCommands } from '../_shared/site-auth.js'; + +async function hasXhsSessionCookies(page) { + const cookies = await page.getCookies({ url: 'https://creator.xiaohongshu.com' }); + const names = new Set(cookies.map(cookie => cookie.name)); + return names.has('web_session'); +} + +async function verifyXhsIdentity(page) { + await page.goto('https://creator.xiaohongshu.com/new/home'); + const payload = await page.evaluate(` + async () => { + try { + const resp = await fetch('/api/galaxy/creator/home/personal_info', { credentials: 'include' }); + const text = await resp.text(); + let data = null; + try { data = JSON.parse(text); } catch {} + return { ok: resp.ok, status: resp.status, data, body: text.slice(0, 200) }; + } catch (error) { + return { ok: false, status: 0, error: String(error && error.message || error) }; + } + } + `); + if (!payload?.ok) { + const detail = payload?.error ?? payload?.data?.msg ?? payload?.body ?? `HTTP ${payload?.status ?? ''}`; + throw new AuthRequiredError('creator.xiaohongshu.com', `Xiaohongshu creator profile requires login: ${detail}`); + } + const data = payload?.data?.data; + if (!data) { + throw new CommandExecutionError('Xiaohongshu creator profile returned malformed personal_info payload'); + } + return { + username: data.name ?? '', + followers: data.fans_count ?? 0, + }; +} + +registerSiteAuthCommands({ + site: 'xiaohongshu', + domain: 'creator.xiaohongshu.com', + loginUrl: 'https://creator.xiaohongshu.com/', + columns: ['username', 'followers'], + verify: verifyXhsIdentity, + poll: async (page) => { + if (!await hasXhsSessionCookies(page)) { + throw new AuthRequiredError('creator.xiaohongshu.com', 'Waiting for Xiaohongshu session cookies'); + } + return verifyXhsIdentity(page); + }, +}); diff --git a/src/build-manifest.ts b/src/build-manifest.ts index 9e3e8ac86..9f9923795 100644 --- a/src/build-manifest.ts +++ b/src/build-manifest.ts @@ -44,7 +44,7 @@ const OUTPUT = getCliManifestPath(CLIS_DIR); // wraps `cli(...)`. Without (2), shared-factory adapters // (codex/cursor/chatwise new/status/dump/screenshot) match no `cli(` // token at the top level and silently drop out of the manifest. -const CLI_MODULE_PATTERN = /\bcli\s*\(|\bmake[A-Z]\w*Command\s*\(/; +const CLI_MODULE_PATTERN = /\bcli\s*\(|\bregisterSiteAuthCommands\s*\(|\bmake[A-Z]\w*Command\s*\(/; /** * Thrown by `loadManifestEntries` when an adapter file looks like a CLI @@ -127,6 +127,7 @@ function toManifestEntry(cmd: CliCommand, modulePath: string, sourceFile?: strin sourceFile, navigateBefore: cmd.navigateBefore, siteSession: cmd.siteSession, + defaultWindowMode: cmd.defaultWindowMode, }; } diff --git a/src/discovery.ts b/src/discovery.ts index 9a179e8b1..4b47847b3 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -25,7 +25,7 @@ export const USER_CLIS_DIR = path.join(USER_OPENCLI_DIR, 'clis'); /** Plugins directory: ~/.opencli/plugins/ */ export const PLUGINS_DIR = path.join(USER_OPENCLI_DIR, 'plugins'); /** Matches files that register commands via cli() or lifecycle hooks */ -const PLUGIN_MODULE_PATTERN = /\b(?:cli|onStartup|onBeforeExecute|onAfterExecute)\s*\(/; +const PLUGIN_MODULE_PATTERN = /\b(?:cli|registerSiteAuthCommands|onStartup|onBeforeExecute|onAfterExecute)\s*\(/; function parseStrategy(rawStrategy: string | undefined, fallback: Strategy = Strategy.COOKIE): Strategy { if (!rawStrategy) return fallback; @@ -135,6 +135,7 @@ async function loadFromManifest(manifestPath: string, clisDir: string): Promise< source: entry.sourceFile ? path.resolve(clisDir, entry.sourceFile) : modulePath, navigateBefore: entry.navigateBefore, siteSession: entry.siteSession, + defaultWindowMode: entry.defaultWindowMode, _lazy: true, _modulePath: modulePath, }; diff --git a/src/execution.test.ts b/src/execution.test.ts index 07d9af669..287b12e5f 100644 --- a/src/execution.test.ts +++ b/src/execution.test.ts @@ -518,6 +518,33 @@ describe('executeCommand โ€” non-browser timeout', () => { vi.restoreAllMocks(); }); + it('uses command defaultWindowMode when the user does not pass --window', async () => { + const closeWindow = vi.fn().mockResolvedValue(undefined); + const mockPage = { closeWindow } as any; + const sessionOpts: Array<{ windowMode?: string }> = []; + + vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true); + vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn, opts) => { + sessionOpts.push(opts ?? {}); + return fn(mockPage); + }); + + const cmd = cli({ + site: 'test-execution', + name: 'browser-default-window-mode', access: 'write', + description: 'test command default window mode', + browser: true, + strategy: Strategy.PUBLIC, + defaultWindowMode: 'foreground', + func: async () => [{ ok: true }], + }); + + await executeCommand(cmd, {}); + + expect(sessionOpts[0]).toMatchObject({ windowMode: 'foreground' }); + vi.restoreAllMocks(); + }); + it('does not re-run custom validation when args are already prepared', async () => { const validateArgs = vi.fn(); const cmd: CliCommand = { diff --git a/src/execution.ts b/src/execution.ts index 8c823660c..e8d1a5c0d 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -254,7 +254,7 @@ export async function executeCommand( const siteSession = resolveSiteSession(cmd, opts.siteSession); const session = resolveAdapterBrowserSession(cmd, siteSession); const keepTab = resolveKeepTab(siteSession, opts.keepTab); - const windowMode = resolveBrowserWindowMode('background', opts.windowMode); + const windowMode = resolveBrowserWindowMode(cmd.defaultWindowMode ?? 'background', opts.windowMode); result = await browserSession(BrowserFactory, async (page) => { const observation = traceMode === 'off' ? null diff --git a/src/manifest-types.ts b/src/manifest-types.ts index f4e75a472..6e35dc622 100644 --- a/src/manifest-types.ts +++ b/src/manifest-types.ts @@ -39,4 +39,6 @@ export interface ManifestEntry { navigateBefore?: boolean | string; /** Site session lifecycle defaults โ€” see CliCommand.siteSession */ siteSession?: 'ephemeral' | 'persistent'; + /** Default browser window visibility โ€” see CliCommand.defaultWindowMode */ + defaultWindowMode?: 'foreground' | 'background'; } diff --git a/src/registry.ts b/src/registry.ts index 41d90d93c..29dc0072f 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -64,6 +64,8 @@ interface BaseCliCommand { navigateBefore?: boolean | string; /** Site session lifecycle for adapter commands. */ siteSession?: SiteSessionMode; + /** Default browser window mode for commands whose UX requires visibility. */ + defaultWindowMode?: 'foreground' | 'background'; /** Override the default CLI output format when the user does not pass -f/--format. */ defaultFormat?: 'table' | 'plain' | 'json' | 'yaml' | 'yml' | 'md' | 'markdown' | 'csv'; } @@ -138,6 +140,7 @@ export function cli(opts: CliOptions): CliCommand { validateArgs: opts.validateArgs, navigateBefore: opts.navigateBefore, siteSession: opts.siteSession, + defaultWindowMode: opts.defaultWindowMode, defaultFormat: opts.defaultFormat, }; From 67fe9579cb00b88a26a34d28735e84836ff9cd7b Mon Sep 17 00:00:00 2001 From: jackwener Date: Thu, 4 Jun 2026 18:33:33 +0800 Subject: [PATCH 2/2] fix(auth): satisfy docs and column audits --- cli-manifest.json | 6 ++++-- clis/github/auth.js | 3 ++- clis/xiaohongshu/auth.js | 15 ++++++++------- docs/adapters/browser/github.md | 34 +++++++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 10 deletions(-) create mode 100644 docs/adapters/browser/github.md diff --git a/cli-manifest.json b/cli-manifest.json index 6c45af7b8..f2d5616ec 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -12499,7 +12499,8 @@ "site", "id", "username", - "name" + "name", + "url" ], "type": "js", "modulePath": "github/auth.js", @@ -12521,7 +12522,8 @@ "site", "id", "username", - "name" + "name", + "url" ], "type": "js", "modulePath": "github/auth.js", diff --git a/clis/github/auth.js b/clis/github/auth.js index 3facb20bc..e1359b4c6 100644 --- a/clis/github/auth.js +++ b/clis/github/auth.js @@ -24,6 +24,7 @@ async function verifyGithubIdentity(page) { id: identity.id || '', username: identity.username, name: identity.name || '', + url: `https://github.com/${identity.username}`, }; } @@ -31,7 +32,7 @@ registerSiteAuthCommands({ site: 'github', domain: 'github.com', loginUrl: 'https://github.com/login', - columns: ['id', 'username', 'name'], + columns: ['id', 'username', 'name', 'url'], verify: verifyGithubIdentity, poll: async (page) => { if (!await hasGithubSessionCookies(page)) { diff --git a/clis/xiaohongshu/auth.js b/clis/xiaohongshu/auth.js index cff71e101..33c320818 100644 --- a/clis/xiaohongshu/auth.js +++ b/clis/xiaohongshu/auth.js @@ -14,19 +14,20 @@ async function verifyXhsIdentity(page) { try { const resp = await fetch('/api/galaxy/creator/home/personal_info', { credentials: 'include' }); const text = await resp.text(); - let data = null; - try { data = JSON.parse(text); } catch {} - return { ok: resp.ok, status: resp.status, data, body: text.slice(0, 200) }; + let parsed = null; + try { parsed = JSON.parse(text); } catch {} + return [resp.ok, resp.status, parsed, text.slice(0, 200)]; } catch (error) { - return { ok: false, status: 0, error: String(error && error.message || error) }; + return [false, 0, null, String(error && error.message || error)]; } } `); - if (!payload?.ok) { - const detail = payload?.error ?? payload?.data?.msg ?? payload?.body ?? `HTTP ${payload?.status ?? ''}`; + const [ok, status, parsed, preview] = Array.isArray(payload) ? payload : []; + if (!ok) { + const detail = parsed?.msg ?? preview ?? `HTTP ${status ?? ''}`; throw new AuthRequiredError('creator.xiaohongshu.com', `Xiaohongshu creator profile requires login: ${detail}`); } - const data = payload?.data?.data; + const data = parsed?.data; if (!data) { throw new CommandExecutionError('Xiaohongshu creator profile returned malformed personal_info payload'); } diff --git a/docs/adapters/browser/github.md b/docs/adapters/browser/github.md new file mode 100644 index 000000000..66ad29376 --- /dev/null +++ b/docs/adapters/browser/github.md @@ -0,0 +1,34 @@ +# GitHub + +**Mode**: ๐Ÿ” Browser ยท **Domain**: `github.com` + +## Commands + +| Command | Description | +|---------|-------------| +| `opencli github whoami` | Show the currently logged-in GitHub account | +| `opencli github login` | Open GitHub login and wait until the browser session is authenticated | + +## Usage Examples + +```bash +# Check current GitHub identity +opencli github whoami + +# Open the login page if the current browser session is not authenticated +opencli github login + +# JSON output for agents/scripts +opencli github whoami -f json +``` + +## Prerequisites + +- Chrome running and **logged into** github.com +- [Browser Bridge extension](/guide/browser-bridge) installed + +## Notes + +- `whoami` verifies the current browser cookie session and does not open the login page. +- `login` opens `https://github.com/login` in a foreground browser window and waits until OpenCLI can verify the account. +- OpenCLI never fills credentials, CAPTCHA, 2FA, or passkeys. The user completes authentication in the browser; OpenCLI only verifies the resulting session.