diff --git a/src/build-manifest.ts b/src/build-manifest.ts index bf138970..61d2e3ab 100644 --- a/src/build-manifest.ts +++ b/src/build-manifest.ts @@ -51,7 +51,7 @@ export interface ManifestEntry { /** Relative path to the original source file from clis/ dir (for YAML: 'site/cmd.yaml') */ sourceFile?: string; /** Pre-navigation control — see CliCommand.navigateBefore */ - navigateBefore?: boolean | string; + navigateBefore?: boolean | string | { url: string; waitUntil?: 'load' | 'none'; settleMs?: number }; } import { type YamlCliDefinition, parseYamlArgs } from './yaml-schema.js'; diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index 117d0965..83d7499d 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -71,7 +71,8 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi } subCmd .option('-f, --format ', 'Output format: table, plain, json, yaml, md, csv', 'table') - .option('-v, --verbose', 'Debug output', false); + .option('-v, --verbose', 'Debug output', false) + .option('--raw', 'Output raw unformatted result as JSON (for adapter development)', false); subCmd.addHelpText('after', formatRegistryHelpText(cmd)); @@ -97,6 +98,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi cmd.validateArgs?.(kwargs); const verbose = optionsRecord.verbose === true; + const raw = optionsRecord.raw === true; let format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'table'; const formatExplicit = subCmd.getOptionValueSource('format') === 'cli'; if (verbose) process.env.OPENCLI_VERBOSE = '1'; @@ -111,6 +113,12 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi return; } + // --raw: output raw unformatted result as JSON, skip all formatting + if (raw) { + console.log(JSON.stringify(result, null, 2)); + return; + } + const resolved = getRegistry().get(fullName(cmd)) ?? cmd; if (!formatExplicit && format === 'table' && resolved.defaultFormat) { format = resolved.defaultFormat; diff --git a/src/errors.ts b/src/errors.ts index 9ea9adec..3b22507c 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -10,11 +10,11 @@ * opencli follows Unix conventions (sysexits.h) for process exit codes: * * 0 Success - * 1 Generic / unexpected error + * 1 Generic / unexpected error (ApiError, SelectorError) * 2 Argument / usage error (ArgumentError) - * 66 No input / empty result (EmptyResultError) + * 66 No input / empty result (EmptyResultError, PageChangedError) * 69 Service unavailable (BrowserConnectError, AdapterLoadError) - * 75 Temporary failure, retry later (TimeoutError) EX_TEMPFAIL + * 75 Temporary failure, retry later (TimeoutError, NetworkError) EX_TEMPFAIL * 77 Permission denied / auth needed (AuthRequiredError) * 78 Configuration error (ConfigError) * 130 Interrupted by Ctrl-C (set by tui.ts SIGINT handler) @@ -137,6 +137,43 @@ export class SelectorError extends CliError { } } +export class NetworkError extends CliError { + readonly statusCode?: number; + constructor(message: string, statusCode?: number, hint?: string) { + super( + 'NETWORK', + message, + hint ?? 'Check your network connection, or try again later', + EXIT_CODES.TEMPFAIL, + ); + this.statusCode = statusCode; + } +} + +export class ApiError extends CliError { + readonly apiCode?: number | string; + constructor(label: string, apiCode?: number | string, apiMessage?: string, hint?: string) { + super( + 'API_ERROR', + `${label}: API error${apiCode !== undefined ? ` code=${apiCode}` : ''}${apiMessage ? ` — ${apiMessage}` : ''}`, + hint ?? 'The API returned an unexpected error. The endpoint may have changed.', + EXIT_CODES.GENERIC_ERROR, + ); + this.apiCode = apiCode; + } +} + +export class PageChangedError extends CliError { + constructor(command: string, hint?: string) { + super( + 'PAGE_CHANGED', + `${command}: page structure has changed`, + hint ?? 'The website may have been updated. Please report this issue so the adapter can be fixed.', + EXIT_CODES.EMPTY_RESULT, + ); + } +} + // ── Utilities ─────────────────────────────────────────────────────────────── /** Extract a human-readable message from an unknown caught value. */ diff --git a/src/execution.ts b/src/execution.ts index fbd8226d..46f1c0b7 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -10,7 +10,7 @@ * 6. Lifecycle hooks (onBeforeExecute / onAfterExecute) */ -import { type CliCommand, type InternalCliCommand, type Arg, type CommandArgs, Strategy, getRegistry, fullName } from './registry.js'; +import { type CliCommand, type InternalCliCommand, type PreNavOptions, type Arg, type CommandArgs, Strategy, getRegistry, fullName } from './registry.js'; import type { IPage } from './types.js'; import { pathToFileURL } from 'node:url'; import { executePipeline } from './pipeline/index.js'; @@ -109,12 +109,24 @@ async function runCommand( ); } -function resolvePreNav(cmd: CliCommand): string | null { +interface ResolvedPreNav { + url: string; + waitUntil?: 'load' | 'none'; + settleMs?: number; +} + +function resolvePreNav(cmd: CliCommand): ResolvedPreNav | null { if (cmd.navigateBefore === false) return null; - if (typeof cmd.navigateBefore === 'string') return cmd.navigateBefore; + + if (typeof cmd.navigateBefore === 'object' && cmd.navigateBefore !== null && 'url' in cmd.navigateBefore) { + const opts = cmd.navigateBefore as PreNavOptions; + return { url: opts.url, waitUntil: opts.waitUntil, settleMs: opts.settleMs }; + } + + if (typeof cmd.navigateBefore === 'string') return { url: cmd.navigateBefore }; if ((cmd.strategy === Strategy.COOKIE || cmd.strategy === Strategy.HEADER) && cmd.domain) { - return `https://${cmd.domain}`; + return { url: `https://${cmd.domain}` }; } return null; } @@ -193,17 +205,20 @@ export async function executeCommand( ensureRequiredEnv(cmd); const BrowserFactory = getBrowserFactory(cmd.site); result = await browserSession(BrowserFactory, async (page) => { - const preNavUrl = resolvePreNav(cmd); - if (preNavUrl) { + const preNav = resolvePreNav(cmd); + if (preNav) { // Navigate directly — the extension's handleNavigate already has a fast-path // that skips navigation if the tab is already at the target URL. // This avoids an extra exec round-trip (getCurrentUrl) on first command and // lets the extension create the automation window with the target URL directly // instead of about:blank. + const gotoOpts = (preNav.waitUntil || preNav.settleMs) + ? { waitUntil: preNav.waitUntil, settleMs: preNav.settleMs } + : undefined; try { - await page.goto(preNavUrl); + await page.goto(preNav.url, gotoOpts); } catch (err) { - if (debug) log.debug(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`); + if (debug) log.debug(`[pre-nav] Failed to navigate to ${preNav.url}: ${err instanceof Error ? err.message : err}`); } } try { diff --git a/src/output.ts b/src/output.ts index ec8880ee..8a705ff8 100644 --- a/src/output.ts +++ b/src/output.ts @@ -28,6 +28,11 @@ function resolveColumns(rows: Record[], opts: RenderOptions): s } export function render(data: unknown, opts: RenderOptions = {}): void { + // RAW mode: bypass all formatting, output raw JSON (for adapter development) + if (process.env.RAW === '1' || process.env.OPENCLI_RAW === '1') { + console.log(JSON.stringify(data, null, 2)); + return; + } let fmt = opts.fmt ?? 'table'; // Non-TTY auto-downgrade only when format was NOT explicitly passed by user. // Priority: explicit -f (any value) > OUTPUT env var > TTY auto-detect > table diff --git a/src/pipeline/executor.ts b/src/pipeline/executor.ts index 37d12ce7..0a13eb6d 100644 --- a/src/pipeline/executor.ts +++ b/src/pipeline/executor.ts @@ -98,6 +98,9 @@ function debugStepResult(op: string, data: unknown): void { log.stepResult('(no data)'); } else if (Array.isArray(data)) { log.stepResult(`${data.length} items`); + if (data.length > 0) { + log.verbose(` [Sample] ${JSON.stringify(data[0]).slice(0, 300)}`); + } } else if (typeof data === 'object') { const keys = Object.keys(data).slice(0, 5); log.stepResult(`dict (${keys.join(', ')}${Object.keys(data).length > 5 ? '...' : ''})`); diff --git a/src/registry.ts b/src/registry.ts index f9a9d4f2..eefb5855 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -31,6 +31,22 @@ export interface RequiredEnv { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- kwargs from CLI parsing are inherently untyped export type CommandArgs = Record; +/** + * Options for navigateBefore when fine-grained control is needed. + * + * Useful for heavy SPAs where the default `waitUntil: 'load'` + 1s settle + * causes timeouts, or when a cookie-seeding navigation to a different + * subdomain is required before loading the actual target page. + */ +export interface PreNavOptions { + /** Primary URL to navigate to. */ + url: string; + /** Override waitUntil behavior ('load' or 'none'). Default: 'load'. */ + waitUntil?: 'load' | 'none'; + /** Milliseconds to wait for DOM stability after load. Default: 1000. */ + settleMs?: number; +} + export interface CliCommand { site: string; name: string; @@ -62,8 +78,9 @@ export interface CliCommand { * - `undefined` / `true`: navigate to `https://${domain}` (default) * - `false`: skip — adapter handles its own navigation (e.g. boss common.ts) * - `string`: navigate to this specific URL instead of the domain root + * - `object`: navigate with explicit options (waitUntil, settleMs) */ - navigateBefore?: boolean | string; + navigateBefore?: boolean | string | PreNavOptions; /** Override the default CLI output format when the user does not pass -f/--format. */ defaultFormat?: 'table' | 'plain' | 'json' | 'yaml' | 'yml' | 'md' | 'markdown' | 'csv'; }