feat(cli): implement CLI entry point with auto-daemon launcher (Task 18)
- Add auto-daemon.ts: ensureDaemonRunning() checks health, reads pidfile, spawns daemon if needed, polls health up to 3s - Rewrite index.ts: commander-based CLI with all subcommands (health, open, list, get, kill, navigate, click, type, scroll, eval, wait, screenshot, text, html, profiles) - Add daemon project reference to cli tsconfig
This commit is contained in:
@@ -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<VisionLClient> {
|
||||
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');
|
||||
}
|
||||
+166
-2
@@ -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 <n>', 'daemon port', String(DEFAULT_PORT));
|
||||
program.option('--pretty', 'human-readable output');
|
||||
|
||||
function getClient(): Promise<VisionLClient> {
|
||||
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 <url>')
|
||||
.description('Open a URL in a new page')
|
||||
.option('-a, --alias <alias>', 'Page alias')
|
||||
.option('-p, --profile <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 <id>')
|
||||
.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 <id>')
|
||||
.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 <id> <url>')
|
||||
.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 <id> <selector>')
|
||||
.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 <id> <selector> <text>')
|
||||
.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 <id>')
|
||||
.description('Scroll on a page')
|
||||
.option('-y, --delta-y <n>', '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 <id> <code>')
|
||||
.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 <id>')
|
||||
.description('Wait for selector or duration on a page')
|
||||
.option('-s, --selector <selector>', 'Wait for CSS selector')
|
||||
.option('-m, --ms <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 <id>')
|
||||
.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 <id>')
|
||||
.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 <id>')
|
||||
.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();
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "../core" }]
|
||||
"references": [{ "path": "../core" }, { "path": "../daemon" }]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user