Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ddb022ca99 | |||
| 5e73940a4b |
@@ -0,0 +1,41 @@
|
||||
# Doubao App
|
||||
|
||||
Drive the **Doubao (豆包) AI desktop app** via Chrome DevTools Protocol. This adapter controls the Electron-based Doubao client directly.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Install the Doubao desktop app from [doubao.com](https://www.doubao.com/).
|
||||
2. Launch with remote debugging enabled:
|
||||
|
||||
```bash
|
||||
/Applications/Doubao.app/Contents/MacOS/Doubao \
|
||||
--remote-debugging-port=9226
|
||||
```
|
||||
|
||||
3. Set the CDP endpoint:
|
||||
|
||||
```bash
|
||||
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9226"
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli doubao-app status` | Check if the Doubao app is running and reachable |
|
||||
| `opencli doubao-app new` | Start a new conversation |
|
||||
| `opencli doubao-app send "message"` | Send a message to the active chat |
|
||||
| `opencli doubao-app read` | Read all messages in the current conversation |
|
||||
| `opencli doubao-app ask "message"` | Send a prompt and wait for the reply |
|
||||
| `opencli doubao-app screenshot` | Capture a screenshot of the app window |
|
||||
| `opencli doubao-app dump` | Dump the current page DOM snapshot |
|
||||
|
||||
## How It Works
|
||||
|
||||
The adapter connects to Doubao's Electron renderer via CDP and uses `data-testid` selectors to interact with the chat UI. Text injection uses React's internal value setter for reliable textarea updates.
|
||||
|
||||
## Limitations
|
||||
|
||||
- macOS only (Electron app path)
|
||||
- Requires Doubao to be launched with `--remote-debugging-port`
|
||||
- `read` returns messages visible in the current conversation only
|
||||
+17
-11
@@ -5,31 +5,37 @@
|
||||
* The daemon architecture has a single failure mode: daemon not reachable or extension not connected.
|
||||
*/
|
||||
|
||||
import { BrowserConnectError } from '../errors.js';
|
||||
|
||||
export type ConnectFailureKind = 'daemon-not-running' | 'extension-not-connected' | 'command-failed' | 'unknown';
|
||||
|
||||
export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: string): Error {
|
||||
export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: string): BrowserConnectError {
|
||||
switch (kind) {
|
||||
case 'daemon-not-running':
|
||||
return new Error(
|
||||
'Cannot connect to opencli daemon.\n\n' +
|
||||
return new BrowserConnectError(
|
||||
'Cannot connect to opencli daemon.' +
|
||||
(detail ? `\n\n${detail}` : ''),
|
||||
'The daemon should start automatically. If it doesn\'t, try:\n' +
|
||||
' node dist/daemon.js\n' +
|
||||
'Make sure port 19825 is available.' +
|
||||
(detail ? `\n\n${detail}` : ''),
|
||||
'Make sure port 19825 is available.',
|
||||
);
|
||||
case 'extension-not-connected':
|
||||
return new Error(
|
||||
'opencli Browser Bridge extension is not connected.\n\n' +
|
||||
return new BrowserConnectError(
|
||||
'opencli Browser Bridge extension is not connected.' +
|
||||
(detail ? `\n\n${detail}` : ''),
|
||||
'Please install the extension:\n' +
|
||||
' 1. Download from GitHub Releases\n' +
|
||||
' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
|
||||
' 3. Click "Load unpacked" → select the extension folder\n' +
|
||||
' 4. Make sure Chrome is running' +
|
||||
(detail ? `\n\n${detail}` : ''),
|
||||
' 4. Make sure Chrome is running',
|
||||
);
|
||||
case 'command-failed':
|
||||
return new Error(`Browser command failed: ${detail ?? 'unknown error'}`);
|
||||
return new BrowserConnectError(
|
||||
`Browser command failed: ${detail ?? 'unknown error'}`,
|
||||
);
|
||||
default:
|
||||
return new Error(detail ?? 'Failed to connect to browser');
|
||||
return new BrowserConnectError(
|
||||
detail ?? 'Failed to connect to browser',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import yaml from 'js-yaml';
|
||||
import { getErrorMessage } from './errors.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CLIS_DIR = path.resolve(__dirname, 'clis');
|
||||
@@ -73,9 +74,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
|
||||
function extractBalancedBlock(
|
||||
source: string,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { IPage } from '../../types.js';
|
||||
import { AuthRequiredError } from '../../errors.js';
|
||||
|
||||
const MIXIN_KEY_ENC_TAB = [
|
||||
46,47,18,2,53,8,23,32,15,50,10,31,58,3,45,35,27,43,5,49,
|
||||
@@ -98,7 +99,7 @@ export async function fetchJson(page: IPage, url: string): Promise<any> {
|
||||
export async function getSelfUid(page: IPage): Promise<string> {
|
||||
const nav = await getNavData(page);
|
||||
const mid = nav?.data?.mid;
|
||||
if (!mid) throw new Error('Not logged in to Bilibili');
|
||||
if (!mid) throw new AuthRequiredError('bilibili.com');
|
||||
return String(mid);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { AuthRequiredError } from '../../errors.js';
|
||||
import {
|
||||
getCourses, initSession, enterCourse, getTabIframeUrl,
|
||||
parseAssignmentsFromDom, sleep,
|
||||
@@ -33,7 +34,7 @@ cli({
|
||||
|
||||
// 2. Get courses
|
||||
const courses = await getCourses(page);
|
||||
if (!courses.length) throw new Error('未获取到课程列表,请确认已登录学习通');
|
||||
if (!courses.length) throw new AuthRequiredError('mooc2-ans.chaoxing.com', '未获取到课程列表');
|
||||
|
||||
const filtered = courseFilter
|
||||
? courses.filter(c => c.title.includes(courseFilter))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { AuthRequiredError } from '../../errors.js';
|
||||
|
||||
// ── Twitter GraphQL constants ──────────────────────────────────────────
|
||||
|
||||
@@ -139,7 +140,7 @@ cli({
|
||||
const ct0 = await page.evaluate(`() => {
|
||||
return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
|
||||
}`);
|
||||
if (!ct0) throw new Error('Not logged into x.com (no ct0 cookie)');
|
||||
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
||||
|
||||
// Build auth headers in TypeScript
|
||||
const headers = JSON.stringify({
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { AuthRequiredError } from '../../errors.js';
|
||||
|
||||
cli({
|
||||
site: 'xiaohongshu',
|
||||
@@ -86,10 +87,7 @@ cli({
|
||||
if (!payload || typeof payload !== 'object') return [];
|
||||
|
||||
if ((payload as any).loginWall) {
|
||||
throw new Error(
|
||||
'Xiaohongshu search results are blocked behind a login wall for the current browser session. ' +
|
||||
'Open https://www.xiaohongshu.com/search_result in Chrome and sign in, then retry.'
|
||||
);
|
||||
throw new AuthRequiredError('www.xiaohongshu.com', 'Xiaohongshu search results are blocked behind a login wall');
|
||||
}
|
||||
|
||||
const data: any[] = Array.isArray((payload as any).results) ? (payload as any).results : [];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { AuthRequiredError } from '../../errors.js';
|
||||
|
||||
cli({
|
||||
site: 'zhihu',
|
||||
@@ -31,7 +32,7 @@ cli({
|
||||
}
|
||||
`);
|
||||
|
||||
if (!result || result.error) throw new Error('Failed to fetch question. Are you logged in?');
|
||||
if (!result || result.error) throw new AuthRequiredError('www.zhihu.com', 'Failed to fetch question data from Zhihu');
|
||||
|
||||
const answers = (result.answers ?? []).slice(0, Number(limit)).map((a: any, i: number) => ({
|
||||
rank: i + 1,
|
||||
|
||||
@@ -16,11 +16,7 @@ import { type CliCommand, fullName, getRegistry } from './registry.js';
|
||||
import { formatRegistryHelpText } from './serialization.js';
|
||||
import { render as renderOutput } from './output.js';
|
||||
import { executeCommand } from './execution.js';
|
||||
import { CliError } from './errors.js';
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
import { CliError, ERROR_ICONS, getErrorMessage } from './errors.js';
|
||||
|
||||
/**
|
||||
* Register a single CliCommand as a Commander subcommand.
|
||||
@@ -90,8 +86,9 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof CliError) {
|
||||
console.error(chalk.red(`Error [${err.code}]: ${err.message}`));
|
||||
if (err.hint) console.error(chalk.yellow(`Hint: ${err.hint}`));
|
||||
const icon = ERROR_ICONS[err.code] ?? '⚠️';
|
||||
console.error(chalk.red(`${icon} ${err.message}`));
|
||||
if (err.hint) console.error(chalk.yellow(`→ ${err.hint}`));
|
||||
} else if (optionsRecord.verbose === true && err instanceof Error && err.stack) {
|
||||
console.error(chalk.red(err.stack));
|
||||
} else {
|
||||
|
||||
+2
-3
@@ -15,6 +15,7 @@ import { pathToFileURL } from 'node:url';
|
||||
import yaml from 'js-yaml';
|
||||
import { type CliCommand, type InternalCliCommand, type Arg, Strategy, registerCommand } from './registry.js';
|
||||
import { log } from './logger.js';
|
||||
import { getErrorMessage } from './errors.js';
|
||||
import type { ManifestEntry } from './build-manifest.js';
|
||||
|
||||
/** Plugins directory: ~/.opencli/plugins/ */
|
||||
@@ -45,9 +46,7 @@ interface YamlCliDefinition {
|
||||
navigateBefore?: boolean | string;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
|
||||
function parseStrategy(rawStrategy: string | undefined, fallback: Strategy = Strategy.COOKIE): Strategy {
|
||||
if (!rawStrategy) return fallback;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
CliError,
|
||||
BrowserConnectError,
|
||||
AdapterLoadError,
|
||||
CommandExecutionError,
|
||||
ConfigError,
|
||||
AuthRequiredError,
|
||||
TimeoutError,
|
||||
ArgumentError,
|
||||
EmptyResultError,
|
||||
SelectorError,
|
||||
} from './errors.js';
|
||||
|
||||
describe('Error type hierarchy', () => {
|
||||
it('all error types extend CliError', () => {
|
||||
const errors = [
|
||||
new BrowserConnectError('test'),
|
||||
new AdapterLoadError('test'),
|
||||
new CommandExecutionError('test'),
|
||||
new ConfigError('test'),
|
||||
new AuthRequiredError('example.com'),
|
||||
new TimeoutError('test', 30),
|
||||
new ArgumentError('test'),
|
||||
new EmptyResultError('test/cmd'),
|
||||
new SelectorError('.btn'),
|
||||
];
|
||||
|
||||
for (const err of errors) {
|
||||
expect(err).toBeInstanceOf(CliError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
}
|
||||
});
|
||||
|
||||
it('AuthRequiredError has correct code, domain, and auto-generated hint', () => {
|
||||
const err = new AuthRequiredError('bilibili.com');
|
||||
expect(err.code).toBe('AUTH_REQUIRED');
|
||||
expect(err.domain).toBe('bilibili.com');
|
||||
expect(err.message).toBe('Not logged in to bilibili.com');
|
||||
expect(err.hint).toContain('https://bilibili.com');
|
||||
});
|
||||
|
||||
it('AuthRequiredError accepts custom message', () => {
|
||||
const err = new AuthRequiredError('x.com', 'No ct0 cookie found');
|
||||
expect(err.message).toBe('No ct0 cookie found');
|
||||
expect(err.hint).toContain('https://x.com');
|
||||
});
|
||||
|
||||
it('TimeoutError has correct code and hint', () => {
|
||||
const err = new TimeoutError('bilibili/hot', 60);
|
||||
expect(err.code).toBe('TIMEOUT');
|
||||
expect(err.message).toBe('bilibili/hot timed out after 60s');
|
||||
expect(err.hint).toContain('timeout');
|
||||
});
|
||||
|
||||
it('ArgumentError has correct code', () => {
|
||||
const err = new ArgumentError('Argument "limit" must be a valid number');
|
||||
expect(err.code).toBe('ARGUMENT');
|
||||
});
|
||||
|
||||
it('EmptyResultError has default hint', () => {
|
||||
const err = new EmptyResultError('hackernews/top');
|
||||
expect(err.code).toBe('EMPTY_RESULT');
|
||||
expect(err.message).toBe('hackernews/top returned no data');
|
||||
expect(err.hint).toBeTruthy();
|
||||
});
|
||||
|
||||
it('SelectorError has default hint about page changes', () => {
|
||||
const err = new SelectorError('.submit-btn');
|
||||
expect(err.code).toBe('SELECTOR');
|
||||
expect(err.message).toContain('.submit-btn');
|
||||
expect(err.hint).toContain('report');
|
||||
});
|
||||
|
||||
it('BrowserConnectError has correct code', () => {
|
||||
const err = new BrowserConnectError('Cannot connect');
|
||||
expect(err.code).toBe('BROWSER_CONNECT');
|
||||
});
|
||||
});
|
||||
+92
-2
@@ -2,11 +2,12 @@
|
||||
* Unified error types for opencli.
|
||||
*
|
||||
* All errors thrown by the framework should extend CliError so that
|
||||
* the top-level handler in main.ts can render consistent, helpful output.
|
||||
* the top-level handler in commanderAdapter.ts can render consistent,
|
||||
* helpful output with emoji-coded severity and actionable hints.
|
||||
*/
|
||||
|
||||
export class CliError extends Error {
|
||||
/** Machine-readable error code (e.g. 'BROWSER_CONNECT', 'ADAPTER_LOAD') */
|
||||
/** Machine-readable error code (e.g. 'BROWSER_CONNECT', 'AUTH_REQUIRED') */
|
||||
readonly code: string;
|
||||
/** Human-readable hint on how to fix the problem */
|
||||
readonly hint?: string;
|
||||
@@ -19,6 +20,8 @@ export class CliError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Browser / Connection ────────────────────────────────────────────────────
|
||||
|
||||
export class BrowserConnectError extends CliError {
|
||||
constructor(message: string, hint?: string) {
|
||||
super('BROWSER_CONNECT', message, hint);
|
||||
@@ -26,6 +29,8 @@ export class BrowserConnectError extends CliError {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Adapter loading ─────────────────────────────────────────────────────────
|
||||
|
||||
export class AdapterLoadError extends CliError {
|
||||
constructor(message: string, hint?: string) {
|
||||
super('ADAPTER_LOAD', message, hint);
|
||||
@@ -33,6 +38,8 @@ export class AdapterLoadError extends CliError {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command execution ───────────────────────────────────────────────────────
|
||||
|
||||
export class CommandExecutionError extends CliError {
|
||||
constructor(message: string, hint?: string) {
|
||||
super('COMMAND_EXEC', message, hint);
|
||||
@@ -40,9 +47,92 @@ export class CommandExecutionError extends CliError {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Configuration ───────────────────────────────────────────────────────────
|
||||
|
||||
export class ConfigError extends CliError {
|
||||
constructor(message: string, hint?: string) {
|
||||
super('CONFIG', message, hint);
|
||||
this.name = 'ConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Authentication / Login ──────────────────────────────────────────────────
|
||||
|
||||
export class AuthRequiredError extends CliError {
|
||||
readonly domain: string;
|
||||
|
||||
constructor(domain: string, message?: string) {
|
||||
super(
|
||||
'AUTH_REQUIRED',
|
||||
message ?? `Not logged in to ${domain}`,
|
||||
`Please open Chrome and log in to https://${domain}`,
|
||||
);
|
||||
this.name = 'AuthRequiredError';
|
||||
this.domain = domain;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Timeout ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export class TimeoutError extends CliError {
|
||||
constructor(label: string, seconds: number) {
|
||||
super(
|
||||
'TIMEOUT',
|
||||
`${label} timed out after ${seconds}s`,
|
||||
'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var',
|
||||
);
|
||||
this.name = 'TimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Argument validation ─────────────────────────────────────────────────────
|
||||
|
||||
export class ArgumentError extends CliError {
|
||||
constructor(message: string, hint?: string) {
|
||||
super('ARGUMENT', message, hint);
|
||||
this.name = 'ArgumentError';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Empty result ────────────────────────────────────────────────────────────
|
||||
|
||||
export class EmptyResultError extends CliError {
|
||||
constructor(command: string, hint?: string) {
|
||||
super(
|
||||
'EMPTY_RESULT',
|
||||
`${command} returned no data`,
|
||||
hint ?? 'The page structure may have changed, or you may need to log in',
|
||||
);
|
||||
this.name = 'EmptyResultError';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Selector / DOM ──────────────────────────────────────────────────────────
|
||||
|
||||
export class SelectorError extends CliError {
|
||||
constructor(selector: string, hint?: string) {
|
||||
super(
|
||||
'SELECTOR',
|
||||
`Could not find element: ${selector}`,
|
||||
hint ?? 'The page UI may have changed. Please report this issue.',
|
||||
);
|
||||
this.name = 'SelectorError';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utilities ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Extract a human-readable message from an unknown caught value. */
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/** Error code → emoji mapping for CLI output rendering. */
|
||||
export const ERROR_ICONS: Record<string, string> = {
|
||||
AUTH_REQUIRED: '🔒',
|
||||
BROWSER_CONNECT: '🔌',
|
||||
TIMEOUT: '⏱ ',
|
||||
ARGUMENT: '❌',
|
||||
EMPTY_RESULT: '📭',
|
||||
SELECTOR: '🔍',
|
||||
};
|
||||
|
||||
+15
-10
@@ -13,7 +13,7 @@ import { type CliCommand, type InternalCliCommand, type Arg, Strategy, getRegist
|
||||
import type { IPage } from './types.js';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { executePipeline } from './pipeline/index.js';
|
||||
import { AdapterLoadError } from './errors.js';
|
||||
import { AdapterLoadError, ArgumentError, CommandExecutionError, getErrorMessage } from './errors.js';
|
||||
import { shouldUseBrowserSession } from './capabilityRouting.js';
|
||||
import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
|
||||
|
||||
@@ -21,9 +21,7 @@ import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMM
|
||||
const _loadedModules = new Set<string>();
|
||||
type CommandArgs = Record<string, unknown>;
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validates and coerces arguments based on the command's Arg definitions.
|
||||
@@ -36,7 +34,10 @@ export function coerceAndValidateArgs(cmdArgs: Arg[], kwargs: CommandArgs): Comm
|
||||
|
||||
// 1. Check required
|
||||
if (argDef.required && (val === undefined || val === null || val === '')) {
|
||||
throw new Error(`Argument "${argDef.name}" is required.\n${argDef.help ? `Hint: ${argDef.help}` : ''}`);
|
||||
throw new ArgumentError(
|
||||
`Argument "${argDef.name}" is required.`,
|
||||
argDef.help ?? `Provide a value for --${argDef.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (val !== undefined && val !== null) {
|
||||
@@ -44,7 +45,7 @@ export function coerceAndValidateArgs(cmdArgs: Arg[], kwargs: CommandArgs): Comm
|
||||
if (argDef.type === 'int' || argDef.type === 'number') {
|
||||
const num = Number(val);
|
||||
if (Number.isNaN(num)) {
|
||||
throw new Error(`Argument "${argDef.name}" must be a valid number. Received: "${val}"`);
|
||||
throw new ArgumentError(`Argument "${argDef.name}" must be a valid number. Received: "${val}"`);
|
||||
}
|
||||
result[argDef.name] = num;
|
||||
} else if (argDef.type === 'boolean' || argDef.type === 'bool') {
|
||||
@@ -52,7 +53,7 @@ export function coerceAndValidateArgs(cmdArgs: Arg[], kwargs: CommandArgs): Comm
|
||||
const lower = val.toLowerCase();
|
||||
if (lower === 'true' || lower === '1') result[argDef.name] = true;
|
||||
else if (lower === 'false' || lower === '0') result[argDef.name] = false;
|
||||
else throw new Error(`Argument "${argDef.name}" must be a boolean (true/false). Received: "${val}"`);
|
||||
else throw new ArgumentError(`Argument "${argDef.name}" must be a boolean (true/false). Received: "${val}"`);
|
||||
} else {
|
||||
result[argDef.name] = Boolean(val);
|
||||
}
|
||||
@@ -62,7 +63,7 @@ export function coerceAndValidateArgs(cmdArgs: Arg[], kwargs: CommandArgs): Comm
|
||||
const coercedVal = result[argDef.name];
|
||||
if (argDef.choices && argDef.choices.length > 0) {
|
||||
if (!argDef.choices.map(String).includes(String(coercedVal))) {
|
||||
throw new Error(`Argument "${argDef.name}" must be one of: ${argDef.choices.join(', ')}. Received: "${coercedVal}"`);
|
||||
throw new ArgumentError(`Argument "${argDef.name}" must be one of: ${argDef.choices.join(', ')}. Received: "${coercedVal}"`);
|
||||
}
|
||||
}
|
||||
} else if (argDef.default !== undefined) {
|
||||
@@ -104,7 +105,10 @@ async function runCommand(
|
||||
|
||||
if (cmd.func) return cmd.func(page!, kwargs, debug);
|
||||
if (cmd.pipeline) return executePipeline(page, cmd.pipeline, { args: kwargs, debug });
|
||||
throw new Error(`Command ${fullName(cmd)} has no func or pipeline`);
|
||||
throw new CommandExecutionError(
|
||||
`Command ${fullName(cmd)} has no func or pipeline`,
|
||||
'This is likely a bug in the adapter definition. Please report this issue.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,7 +144,8 @@ export async function executeCommand(
|
||||
try {
|
||||
kwargs = coerceAndValidateArgs(cmd.args, rawKwargs);
|
||||
} catch (err) {
|
||||
throw new Error(`[Argument Validation Error]\n${getErrorMessage(err)}`);
|
||||
if (err instanceof ArgumentError) throw err;
|
||||
throw new ArgumentError(getErrorMessage(err));
|
||||
}
|
||||
|
||||
if (shouldUseBrowserSession(cmd)) {
|
||||
|
||||
+15
-3
@@ -1,5 +1,6 @@
|
||||
import { BrowserBridge, CDPBridge } from './browser/index.js';
|
||||
import type { IPage } from './types.js';
|
||||
import { TimeoutError } from './errors.js';
|
||||
|
||||
/**
|
||||
* Returns the appropriate browser factory based on environment config.
|
||||
@@ -21,15 +22,26 @@ export async function runWithTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
opts: { timeout: number; label?: string },
|
||||
): Promise<T> {
|
||||
return withTimeoutMs(promise, opts.timeout * 1000, `${opts.label ?? 'Operation'} timed out after ${opts.timeout}s`);
|
||||
const label = opts.label ?? 'Operation';
|
||||
return withTimeoutMs(promise, opts.timeout * 1000,
|
||||
() => new TimeoutError(label, opts.timeout));
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeout with milliseconds unit. Used for low-level internal timeouts.
|
||||
* Accepts a factory function to create the rejection error, keeping this
|
||||
* utility decoupled from specific error types.
|
||||
*/
|
||||
export function withTimeoutMs<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||
export function withTimeoutMs<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
makeError: string | (() => Error) = 'Operation timed out',
|
||||
): Promise<T> {
|
||||
const reject_ = typeof makeError === 'string'
|
||||
? () => new Error(makeError)
|
||||
: makeError;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
const timer = setTimeout(() => reject(reject_()), timeoutMs);
|
||||
promise.then(
|
||||
(value) => { clearTimeout(timer); resolve(value); },
|
||||
(error) => { clearTimeout(timer); reject(error); },
|
||||
|
||||
+2
-3
@@ -2,6 +2,7 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import yaml from 'js-yaml';
|
||||
import { getErrorMessage } from './errors.js';
|
||||
|
||||
/** All recognized pipeline step names */
|
||||
const KNOWN_STEP_NAMES = new Set([
|
||||
@@ -37,9 +38,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
|
||||
export function validateClisWithTarget(dirs: string[], target?: string): ValidationReport {
|
||||
const results: FileValidationResult[] = [];
|
||||
|
||||
Reference in New Issue
Block a user