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:
2026-08-12 21:24:07 +08:00
parent 4f387b5ae7
commit b843eea9ae
3 changed files with 206 additions and 3 deletions
+39
View File
@@ -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
View File
@@ -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();
+1 -1
View File
@@ -5,5 +5,5 @@
"rootDir": "./src"
},
"include": ["src"],
"references": [{ "path": "../core" }]
"references": [{ "path": "../core" }, { "path": "../daemon" }]
}