diff --git a/packages/cli/src/auto-daemon.ts b/packages/cli/src/auto-daemon.ts new file mode 100644 index 0000000..99fc48b --- /dev/null +++ b/packages/cli/src/auto-daemon.ts @@ -0,0 +1,39 @@ +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { readPidfile, isProcessAlive } from '@visionl/daemon/dist/pidfile.js'; +import { VisionLClient } from '@visionl/core'; + +const POLL_INTERVAL_MS = 100; +const MAX_WAIT_MS = 3000; + +export async function ensureDaemonRunning(port: number): Promise { + const client = new VisionLClient(`http://127.0.0.1:${port}`); + + if (await client.health()) { + return client; + } + + const pidInfo = readPidfile(); + if (pidInfo && isProcessAlive(pidInfo.pid)) { + if (await client.health()) { + return client; + } + } + + const daemonEntryPath = path.resolve(__dirname, '../../daemon/dist/server.js'); + spawn('node', [daemonEntryPath], { + env: { ...process.env, VISIONL_PORT: String(port) }, + stdio: 'ignore', + detached: true, + }).unref(); + + const startTime = Date.now(); + while (Date.now() - startTime < MAX_WAIT_MS) { + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + if (await client.health()) { + return client; + } + } + + throw new Error('DAEMON_UNREACHABLE'); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 53f40e9..990fc2e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,2 +1,166 @@ -// @visionl/cli — placeholder, to be implemented in Task 4 -export const CLI_VERSION = '0.1.0'; +#!/usr/bin/env node +import { Command } from 'commander'; +import { ensureDaemonRunning } from './auto-daemon.js'; +import { safeStringify, VisionLClient } from '@visionl/core'; + +const DEFAULT_PORT = 9527; + +const program = new Command(); +program.name('visionl').version('0.1.0'); + +program.option('-p, --port ', 'daemon port', String(DEFAULT_PORT)); +program.option('--pretty', 'human-readable output'); + +function getClient(): Promise { + const opts = program.opts<{ port: string }>(); + const port = parseInt(opts.port, 10) || DEFAULT_PORT; + return ensureDaemonRunning(port); +} + +function print(result: unknown): void { + const opts = program.opts<{ pretty: boolean }>(); + if (opts.pretty) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(safeStringify(result)); + } +} + +// health — check daemon health +program.command('health') + .description('Check daemon health') + .action(async () => { + const client = await getClient(); + const ok = await client.health(); + print({ ok }); + }); + +// pages — page management +program.command('open ') + .description('Open a URL in a new page') + .option('-a, --alias ', 'Page alias') + .option('-p, --profile ', 'Browser profile') + .action(async (url: string, options: { alias?: string; profile?: string }) => { + const client = await getClient(); + const result = await client.openPage(url, options.alias, options.profile); + print(result); + }); + +program.command('list') + .description('List all open pages') + .action(async () => { + const client = await getClient(); + const result = await client.listPages(); + print(result); + }); + +program.command('get ') + .description('Get page info by ID or alias') + .action(async (id: string) => { + const client = await getClient(); + const result = await client.getPage(id); + print(result); + }); + +program.command('kill ') + .description('Close a page by ID or alias') + .action(async (id: string) => { + const client = await getClient(); + const result = await client.killPage(id); + print(result); + }); + +// navigation — page interactions +program.command('navigate ') + .description('Navigate an existing page to a URL') + .action(async (id: string, url: string) => { + const client = await getClient(); + const result = await client.navigate(id, url); + print(result); + }); + +program.command('click ') + .description('Click an element on a page') + .action(async (id: string, selector: string) => { + const client = await getClient(); + const result = await client.click(id, selector); + print(result); + }); + +program.command('type ') + .description('Type text into an element') + .action(async (id: string, selector: string, text: string) => { + const client = await getClient(); + const result = await client.type(id, selector, text); + print(result); + }); + +program.command('scroll ') + .description('Scroll on a page') + .option('-y, --delta-y ', 'Vertical scroll delta', '0') + .option('-b, --to-bottom', 'Scroll to bottom') + .action(async (id: string, options: { deltaY: string; toBottom?: boolean }) => { + const client = await getClient(); + const result = await client.scroll(id, { + deltaY: parseInt(options.deltaY, 10) || undefined, + toBottom: options.toBottom, + }); + print(result); + }); + +program.command('eval ') + .description('Evaluate JavaScript on a page') + .action(async (id: string, code: string) => { + const client = await getClient(); + const result = await client.eval(id, code); + print(result); + }); + +program.command('wait ') + .description('Wait for selector or duration on a page') + .option('-s, --selector ', 'Wait for CSS selector') + .option('-m, --ms ', 'Wait time in milliseconds') + .action(async (id: string, options: { selector?: string; ms?: string }) => { + const client = await getClient(); + const result = await client.wait(id, { + selector: options.selector, + ms: options.ms ? parseInt(options.ms, 10) : undefined, + }); + print(result); + }); + +// content — page content retrieval +program.command('screenshot ') + .description('Take a screenshot of a page (returns base64)') + .action(async (id: string) => { + const client = await getClient(); + const result = await client.screenshot(id); + print(result); + }); + +program.command('text ') + .description('Get the text content of a page') + .action(async (id: string) => { + const client = await getClient(); + const result = await client.text(id); + print(result); + }); + +program.command('html ') + .description('Get the HTML source of a page') + .action(async (id: string) => { + const client = await getClient(); + const result = await client.html(id); + print(result); + }); + +// profiles +program.command('profiles') + .description('List available fingerprint profiles') + .action(async () => { + const client = await getClient(); + const result = await client.getProfiles(); + print(result); + }); + +program.parse(); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 2e1ad34..3da059b 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -5,5 +5,5 @@ "rootDir": "./src" }, "include": ["src"], - "references": [{ "path": "../core" }] + "references": [{ "path": "../core" }, { "path": "../daemon" }] }