Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 308496e25d | |||
| d2e3ed4707 | |||
| 67fe9579cb | |||
| 0609862bb2 |
@@ -3386,6 +3386,38 @@
|
||||
"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,
|
||||
"siteSession": "persistent",
|
||||
"defaultWindowMode": "foreground"
|
||||
},
|
||||
{
|
||||
"site": "bilibili",
|
||||
"name": "me",
|
||||
@@ -3623,6 +3655,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 +10341,38 @@
|
||||
"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,
|
||||
"siteSession": "persistent",
|
||||
"defaultWindowMode": "foreground"
|
||||
},
|
||||
{
|
||||
"site": "douyin",
|
||||
"name": "profile",
|
||||
@@ -10637,6 +10722,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 +12478,61 @@
|
||||
"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",
|
||||
"url"
|
||||
],
|
||||
"type": "js",
|
||||
"modulePath": "github/auth.js",
|
||||
"sourceFile": "github/auth.js",
|
||||
"navigateBefore": false,
|
||||
"siteSession": "persistent",
|
||||
"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",
|
||||
"url"
|
||||
],
|
||||
"type": "js",
|
||||
"modulePath": "github/auth.js",
|
||||
"sourceFile": "github/auth.js",
|
||||
"navigateBefore": false
|
||||
},
|
||||
{
|
||||
"site": "google",
|
||||
"name": "news",
|
||||
@@ -30194,6 +30355,37 @@
|
||||
"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,
|
||||
"siteSession": "persistent",
|
||||
"defaultWindowMode": "foreground"
|
||||
},
|
||||
{
|
||||
"site": "twitter",
|
||||
"name": "notifications",
|
||||
@@ -30878,6 +31070,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 +34393,37 @@
|
||||
"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,
|
||||
"siteSession": "persistent",
|
||||
"defaultWindowMode": "foreground"
|
||||
},
|
||||
{
|
||||
"site": "xiaohongshu",
|
||||
"name": "note",
|
||||
@@ -34368,6 +34611,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",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
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) => tryProbe(config, page, 'identity'),
|
||||
});
|
||||
|
||||
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',
|
||||
siteSession: 'persistent',
|
||||
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),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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',
|
||||
siteSession: 'persistent',
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
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 || '',
|
||||
url: `https://github.com/${identity.username}`,
|
||||
};
|
||||
}
|
||||
|
||||
registerSiteAuthCommands({
|
||||
site: 'github',
|
||||
domain: 'github.com',
|
||||
loginUrl: 'https://github.com/login',
|
||||
columns: ['id', 'username', 'name', 'url'],
|
||||
verify: verifyGithubIdentity,
|
||||
poll: async (page) => {
|
||||
if (!await hasGithubSessionCookies(page)) {
|
||||
throw new AuthRequiredError('github.com', 'Waiting for GitHub session cookies');
|
||||
}
|
||||
return verifyGithubIdentity(page);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
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 parsed = null;
|
||||
try { parsed = JSON.parse(text); } catch {}
|
||||
return [resp.ok, resp.status, parsed, text.slice(0, 200)];
|
||||
} catch (error) {
|
||||
return [false, 0, null, String(error && error.message || error)];
|
||||
}
|
||||
}
|
||||
`);
|
||||
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 = parsed?.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);
|
||||
},
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user