Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/adapter-hyperframes/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --test test/"
"test": "node ../../scripts/run-tests-if-present.mjs"
},
"dependencies": {
"@html-video/core": "workspace:*",
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
"types": "./dist/index.d.ts",
"files": ["dist", "README.md"],
"scripts": {
"build": "tsc -p tsconfig.json && chmod +x dist/bin.js",
"build": "tsc -p tsconfig.json && node scripts/chmod-bin.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit",
"smoke": "node dist/smoke.js",
"test": "node --test test/"
"test": "node ../../scripts/run-tests-if-present.mjs"
},
"dependencies": {
"@html-video/content-graph": "workspace:*",
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/scripts/chmod-bin.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { chmodSync, existsSync } from "node:fs";

const binPath = new URL("../dist/bin.js", import.meta.url);

if (existsSync(binPath)) {
chmodSync(binPath, 0o755);
}
52 changes: 34 additions & 18 deletions packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import { accessSync } from 'node:fs';
import type { CliContext } from '../context.js';
import { ok } from '../output.js';

Expand All @@ -12,26 +13,36 @@ interface Check {

function which(cmd: string): string | null {
try {
return execSync(`which ${cmd}`, { stdio: ['ignore', 'pipe', 'ignore'] })
.toString()
.trim() || null;
const lookup = process.platform === 'win32' ? 'where.exe' : 'which';
const output = execFileSync(lookup, [cmd], { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
const firstMatch = output.split(/\r?\n/)[0] ?? '';
return firstMatch.trim() || null;
} catch {
return null;
}
}

function version(cmd: string, args = '--version'): string | null {
try {
return execSync(`${cmd} ${args}`, { stdio: ['ignore', 'pipe', 'ignore'] })
const output = execFileSync(cmd, args.split(' '), { stdio: ['ignore', 'pipe', 'ignore'] })
.toString()
.trim()
.split('\n')[0]
?? null;
.split('\n')[0];
return output ?? null;
} catch {
return null;
}
}

function existsOnDisk(path: string): boolean {
try {
accessSync(path);
return true;
} catch {
return false;
}
}

export async function runDoctor(ctx: CliContext): Promise<void> {
const checks: Check[] = [];

Expand All @@ -55,24 +66,29 @@ export async function runDoctor(ctx: CliContext): Promise<void> {
});
}

// chromium / chrome (for HF puppeteer)
// chromium / chrome (for HF playwright)
const chromePaths = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/usr/bin/chromium',
'/usr/bin/google-chrome',
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
];
const chromiumOk = chromePaths.some((p) => {
try {
execSync(`test -x "${p}"`);
return true;
} catch {
return false;
}
});
const chromiumPath =
which('chromium') ||
which('chromium-browser') ||
which('google-chrome') ||
which('chrome') ||
which('msedge') ||
chromePaths.find(existsOnDisk) ||
null;
checks.push({
name: 'chromium',
status: chromiumOk ? 'ok' : 'warning',
detail: chromiumOk ? 'Chrome found in standard location' : 'Chrome/Chromium not detected; HF render will need a browser',
status: chromiumPath ? 'ok' : 'warning',
value: chromiumPath ?? undefined,
detail: chromiumPath ? 'Chrome/Chromium-compatible browser detected' : 'Chrome/Chromium not detected; HF render will need a browser',
});

// Engines
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/studio-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ export async function startStudioServer(ctx: CliContext, port: number): Promise<
const url = new URL(req.url, 'http://x');
const m = req.method ?? 'GET';

if (url.pathname === '/favicon.ico') {
res.writeHead(204);
res.end();
return;
}

// ============== API ==============

// List projects
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --test test/"
"test": "node ../../scripts/run-tests-if-present.mjs"
},
"dependencies": {
"@html-video/content-graph": "workspace:*",
Expand Down
40 changes: 35 additions & 5 deletions packages/runtime/src/detect.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,39 @@
import { execFile } from 'node:child_process';
import { accessSync, constants } from 'node:fs';
import { extname } from 'node:path';
import { promisify } from 'node:util';
import { AGENT_DEFS } from './registry.js';
import type { AgentDef, DetectedAgent } from './types.js';

const exec = promisify(execFile);

function pickPathMatch(stdout: string): string | null {
const matches = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (matches.length === 0) return null;
if (process.platform !== 'win32') return matches[0] ?? null;

return matches.find((p) => ['.exe', '.cmd', '.bat'].includes(extname(p).toLowerCase()))
?? matches[0]
?? null;
}

function quoteCmdArg(arg: string): string {
if (/^[A-Za-z0-9_./:\\-]+$/.test(arg)) return arg;
return `"${arg.replace(/"/g, '\\"')}"`;
}

function isWindowsCommandShim(bin: string): boolean {
return process.platform === 'win32' && ['.cmd', '.bat'].includes(extname(bin).toLowerCase());
}

async function which(bin: string): Promise<string | null> {
try {
const { stdout } = await exec('which', [bin], { timeout: 2000 });
return stdout.trim() || null;
const lookup = process.platform === 'win32' ? 'where.exe' : 'which';
const { stdout } = await exec(lookup, [bin], { timeout: 2000 });
return pickPathMatch(stdout);
} catch {
return null;
}
Expand All @@ -21,7 +45,7 @@ export async function resolveBin(def: AgentDef): Promise<string | null> {
if (onPath) return onPath;
for (const candidate of def.binFallbacks ?? []) {
try {
accessSync(candidate, constants.X_OK);
accessSync(candidate, constants.F_OK);
return candidate;
} catch {
/* not there / not executable — try next */
Expand All @@ -31,7 +55,7 @@ export async function resolveBin(def: AgentDef): Promise<string | null> {
try {
const resolved = await def.resolveBinFallback();
if (resolved) {
accessSync(resolved, constants.X_OK);
accessSync(resolved, constants.F_OK);
return resolved;
}
} catch {
Expand All @@ -43,7 +67,13 @@ export async function resolveBin(def: AgentDef): Promise<string | null> {

async function probeVersion(bin: string, args: string[]): Promise<string | null> {
try {
const { stdout } = await exec(bin, args, { timeout: 5000 });
const command = isWindowsCommandShim(bin)
? {
file: 'cmd.exe',
args: ['/d', '/s', '/c', [bin, ...args].map(quoteCmdArg).join(' ')],
}
: { file: bin, args };
const { stdout } = await exec(command.file, command.args, { timeout: 5000 });
return stdout.trim().split('\n')[0] ?? null;
} catch {
return null;
Expand Down
149 changes: 88 additions & 61 deletions packages/runtime/src/spawn.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
import { spawn as cpSpawn } from 'node:child_process';
import { extname } from 'node:path';
import type { AgentDef, AgentEvent, AgentInvokeContext, SpawnHandle } from './types.js';

function quoteCmdArg(arg: string): string {
if (/^[A-Za-z0-9_./:\\-]+$/.test(arg)) return arg;
return `"${arg.replace(/"/g, '\\"')}"`;
}

function spawnTarget(bin: string, args: string[]): { bin: string; args: string[] } {
const ext = extname(bin).toLowerCase();
if (process.platform === 'win32' && ['.cmd', '.bat'].includes(ext)) {
return {
bin: 'cmd.exe',
args: ['/d', '/s', '/c', [bin, ...args].map(quoteCmdArg).join(' ')],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Escape cmd.exe arguments safely

On Windows .cmd/.bat shims this builds a single cmd.exe /c command line, but quoteCmdArg escapes embedded quotes with backslashes, which cmd.exe does not treat as quote escaping. For agents that pass user/source text in argv, such as Hermes (packages/runtime/src/defs/hermes.ts:25), a prompt containing a quote followed by & ... can break out of the quoted argument and execute another shell command instead of being delivered as data.

Useful? React with 👍 / 👎.

};
}
return { bin, args };
}

/**
* Spawn an agent CLI and stream events to the listener.
* v0.1: only supports streamFormat='plain' fully (chunks emitted as text events).
Expand Down Expand Up @@ -68,82 +85,92 @@ export function spawnAgent(opts: SpawnOptions): SpawnHandle {
return { pid: 0, stop: () => ac.abort(), done };
}

const args = def.buildArgs(prompt, context);
const env = { ...process.env, ...(def.env ?? {}) };
let child: ReturnType<typeof cpSpawn> | null = null;
const done = (async () => {
const { resolveBin } = await import('./detect.js');
const bin = await resolveBin(def);
if (!bin) {
onEvent?.({ type: 'error', message: `${def.name}: binary "${def.bin}" not found` });
onEvent?.({ type: 'message_end', reason: 'error' });
return { exitCode: -1, signal: null as NodeJS.Signals | null };
}

const child = cpSpawn(def.bin, args, {
cwd: context.cwd,
env,
stdio: ['pipe', 'pipe', 'pipe'],
});
const args = def.buildArgs(prompt, context);
const env = { ...process.env, ...(def.env ?? {}) };
const target = spawnTarget(bin, args);

if (def.promptViaStdin && child.stdin) {
child.stdin.write(prompt);
child.stdin.end();
}
child = cpSpawn(target.bin, target.args, {
cwd: context.cwd,
env,
stdio: ['pipe', 'pipe', 'pipe'],
});

let stdoutBuf = '';
let stderrBuf = '';

child.stdout?.on('data', (chunk: Buffer) => {
const text = chunk.toString('utf8');
stdoutBuf += text;
if (def.streamFormat === 'plain') {
onEvent?.({ type: 'text', chunk: text });
} else if (def.streamFormat === 'claude-stream' || def.streamFormat === 'json-event-stream') {
// v0.2 hook: parse NDJSON and emit structured events
const lines = text.split('\n');
for (const line of lines) {
if (!line.trim()) continue;
try {
const obj = JSON.parse(line);
if (typeof obj === 'object' && obj && 'type' in obj) {
// claude stream-json events have richer shape; treat unknown as text
onEvent?.({ type: 'text', chunk: JSON.stringify(obj) + '\n' });
}
} catch {
onEvent?.({ type: 'text', chunk: line + '\n' });
}
}
if (def.promptViaStdin && child.stdin) {
child.stdin.write(prompt);
child.stdin.end();
}
});

child.stderr?.on('data', (chunk: Buffer) => {
stderrBuf += chunk.toString('utf8');
});
let stderrBuf = '';

if (opts.signal) {
opts.signal.addEventListener('abort', () => {
try {
child.kill('SIGTERM');
} catch {
// ignore
child.stdout?.on('data', (chunk: Buffer) => {
const text = chunk.toString('utf8');
if (def.streamFormat === 'plain') {
onEvent?.({ type: 'text', chunk: text });
} else if (def.streamFormat === 'claude-stream' || def.streamFormat === 'json-event-stream') {
// v0.2 hook: parse NDJSON and emit structured events
const lines = text.split('\n');
for (const line of lines) {
if (!line.trim()) continue;
try {
const obj = JSON.parse(line);
if (typeof obj === 'object' && obj && 'type' in obj) {
// claude stream-json events have richer shape; treat unknown as text
onEvent?.({ type: 'text', chunk: JSON.stringify(obj) + '\n' });
}
} catch {
onEvent?.({ type: 'text', chunk: line + '\n' });
}
}
}
});
}

const done = new Promise<{ exitCode: number; signal: NodeJS.Signals | null }>((resolve) => {
child.on('close', (code, signal) => {
if (code !== 0) {
onEvent?.({
type: 'error',
message: `agent exit code ${code}${stderrBuf ? `: ${stderrBuf.slice(0, 500)}` : ''}`,
});
}
onEvent?.({ type: 'message_end', reason: code === 0 ? 'ok' : 'error' });
resolve({ exitCode: code ?? 0, signal });
child.stderr?.on('data', (chunk: Buffer) => {
stderrBuf += chunk.toString('utf8');
});
child.on('error', (err) => {
onEvent?.({ type: 'error', message: err.message });
resolve({ exitCode: -1, signal: null });

if (opts.signal) {
opts.signal.addEventListener('abort', () => {
try {
child?.kill('SIGTERM');
} catch {
// ignore
}
});
}

return await new Promise<{ exitCode: number; signal: NodeJS.Signals | null }>((resolve) => {
child?.on('close', (code, signal) => {
if (code !== 0) {
onEvent?.({
type: 'error',
message: `agent exit code ${code}${stderrBuf ? `: ${stderrBuf.slice(0, 500)}` : ''}`,
});
}
onEvent?.({ type: 'message_end', reason: code === 0 ? 'ok' : 'error' });
resolve({ exitCode: code ?? 0, signal });
});
child?.on('error', (err) => {
onEvent?.({ type: 'error', message: err.message });
resolve({ exitCode: -1, signal: null });
});
});
});
})();

return {
pid: child.pid ?? 0,
pid: 0,
stop: () => {
try {
child.kill('SIGTERM');
child?.kill('SIGTERM');
} catch {
// ignore
}
Expand Down
Loading