Compare commits

...

4 Commits

Author SHA1 Message Date
jackwener fdcecb193e fix: tighten deep-review follow-up 2026-04-10 22:45:15 +08:00
jackwener fba150b9b3 refactor: replace chalk with Node.js built-in util.styleText
- Remove chalk dependency, use `styleText` from `node:util` (stable in Node 21+)
- Bump engines to Node >= 21
- Update all 10 source files that used chalk
- Remove stale chalk mock from daemon.test.ts
- One fewer runtime dependency
2026-04-10 22:39:09 +08:00
jackwener 3f49ef1870 chore: remove unused chalk imports from external.ts and commanderAdapter.ts 2026-04-10 22:26:45 +08:00
jackwener 4a59c7fc0c fix: address deep review findings (security, correctness, consistency)
1. Security: add path traversal guard for plugin manifest entry.path
2. Security: sanitize evaluate() index param via JSON.stringify
3. Correctness: fix startNetworkCapture idempotency (don't wipe entries on re-call)
4. Correctness: log pre-navigation failures instead of silently swallowing
5. Consistency: replace console.log/error with log module in commanderAdapter, external
6. Consistency: add PluginError class, convert user-facing plugin errors
7. Dedup: remove local isRecord() in plugin.ts, use shared utils.ts version
8. Clarify: document intentional double validateArgs call
2026-04-10 21:03:10 +08:00
21 changed files with 356 additions and 250 deletions
+11 -40
View File
@@ -10,7 +10,6 @@
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"chalk": "^5.3.0",
"cli-table3": "^0.6.5",
"commander": "^14.0.3",
"js-yaml": "^4.1.0",
@@ -32,7 +31,7 @@
"vitest": "^4.1.0"
},
"engines": {
"node": ">=20.0.0"
"node": ">=21.0.0"
}
},
"node_modules/@algolia/abtesting": {
@@ -196,6 +195,7 @@
"integrity": "sha512-y1IOpG6OSmTpGg/CT0YBb/EAhR2nsC18QWp9Jy8HO9iGySpcwaTvs5kHa17daP3BMTwWyaX9/1tDTDQshZzXdg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/client-common": "5.49.2",
"@algolia/requester-browser-xhr": "5.49.2",
@@ -404,31 +404,6 @@
}
}
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -436,7 +411,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -2168,6 +2142,7 @@
"integrity": "sha512-1K0wtDaRONwfhL4h8bbJ9qTjmY6rhGgRvvagXkMBsAOMNr+3Q2SffHECh9DIuNVrMA1JwA0zCwhyepgBZVakng==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/abtesting": "1.15.2",
"@algolia/client-abtesting": "5.49.2",
@@ -2244,18 +2219,6 @@
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/character-entities-html4": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
@@ -2496,6 +2459,7 @@
"integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"tabbable": "^6.4.0"
}
@@ -2624,6 +2588,7 @@
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"peer": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
@@ -3095,6 +3060,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3483,6 +3449,7 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -3512,6 +3479,7 @@
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -3645,6 +3613,7 @@
"integrity": "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -4209,6 +4178,7 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -4351,6 +4321,7 @@
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.30",
"@vue/compiler-sfc": "3.5.30",
+1 -2
View File
@@ -6,7 +6,7 @@
},
"description": "Make any website or Electron App your CLI. AI-powered.",
"engines": {
"node": ">=20.0.0"
"node": ">=21.0.0"
},
"type": "module",
"main": "dist/src/main.js",
@@ -75,7 +75,6 @@
"url": "git+https://github.com/jackwener/opencli.git"
},
"dependencies": {
"chalk": "^5.3.0",
"cli-table3": "^0.6.5",
"commander": "^14.0.3",
"js-yaml": "^4.1.0",
+6 -4
View File
@@ -233,18 +233,20 @@ class CDPPage extends BasePage {
}
async startNetworkCapture(pattern: string = ''): Promise<void> {
// Always update the filter pattern
this._networkCapturePattern = pattern;
this._networkEntries = [];
this._pendingRequests.clear();
this._pendingBodyFetches.clear();
// Reset state only on first start; avoid wiping entries if already capturing
if (!this._networkCapturing) {
this._networkEntries = [];
this._pendingRequests.clear();
this._pendingBodyFetches.clear();
await this.bridge.send('Network.enable');
// Step 1: Record request method/url on requestWillBeSent
this.bridge.on('Network.requestWillBeSent', (params: unknown) => {
const p = params as { requestId: string; request: { method: string; url: string }; timestamp: number };
if (!pattern || p.request.url.includes(pattern)) {
if (!this._networkCapturePattern || p.request.url.includes(this._networkCapturePattern)) {
const idx = this._networkEntries.push({
url: p.request.url,
method: p.request.method,
+56 -56
View File
@@ -9,7 +9,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Command } from 'commander';
import chalk from 'chalk';
import { styleText } from 'node:util';
import { findPackageRoot, getBuiltEntryCandidates } from './package-paths.js';
import { type CliCommand, fullName, getRegistry, strategyLabel } from './registry.js';
import { serializeCommand, formatArgSummary } from './serialization.js';
@@ -90,33 +90,33 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
}
console.log();
console.log(chalk.bold(' opencli') + chalk.dim(' — available commands'));
console.log(styleText('bold', ' opencli') + styleText('dim', ' — available commands'));
console.log();
for (const [site, cmds] of sites) {
console.log(chalk.bold.cyan(` ${site}`));
console.log(styleText(['bold', 'cyan'], ` ${site}`));
for (const cmd of cmds) {
const label = strategyLabel(cmd);
const tag = label === 'public'
? chalk.green('[public]')
: chalk.yellow(`[${label}]`);
const aliases = cmd.aliases?.length ? chalk.dim(` (aliases: ${cmd.aliases.join(', ')})`) : '';
console.log(` ${cmd.name} ${tag}${aliases}${cmd.description ? chalk.dim(`${cmd.description}`) : ''}`);
? styleText('green', '[public]')
: styleText('yellow', `[${label}]`);
const aliases = cmd.aliases?.length ? styleText('dim', ` (aliases: ${cmd.aliases.join(', ')})`) : '';
console.log(` ${cmd.name} ${tag}${aliases}${cmd.description ? styleText('dim', `${cmd.description}`) : ''}`);
}
console.log();
}
const externalClis = loadExternalClis();
if (externalClis.length > 0) {
console.log(chalk.bold.cyan(' external CLIs'));
console.log(styleText(['bold', 'cyan'], ' external CLIs'));
for (const ext of externalClis) {
const isInstalled = isBinaryInstalled(ext.binary);
const tag = isInstalled ? chalk.green('[installed]') : chalk.yellow('[auto-install]');
console.log(` ${ext.name} ${tag}${ext.description ? chalk.dim(`${ext.description}`) : ''}`);
const tag = isInstalled ? styleText('green', '[installed]') : styleText('yellow', '[auto-install]');
console.log(` ${ext.name} ${tag}${ext.description ? styleText('dim', `${ext.description}`) : ''}`);
}
console.log();
}
console.log(chalk.dim(` ${commands.length} built-in commands across ${sites.size} sites, ${externalClis.length} external CLIs`));
console.log(styleText('dim', ` ${commands.length} built-in commands across ${sites.size} sites, ${externalClis.length} external CLIs`));
console.log();
});
@@ -385,13 +385,13 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
get.command('text').argument('<index>', 'Element index').description('Element text content')
.action(browserAction(async (page, index) => {
const text = await page.evaluate(`document.querySelector('[data-opencli-ref="${index}"]')?.textContent?.trim()`);
const text = await page.evaluate(`((idx) => document.querySelector('[data-opencli-ref="' + idx + '"]')?.textContent?.trim())(${JSON.stringify(String(index))})`);
console.log(text ?? '(empty)');
}));
get.command('value').argument('<index>', 'Element index').description('Input/textarea value')
.action(browserAction(async (page, index) => {
const val = await page.evaluate(`document.querySelector('[data-opencli-ref="${index}"]')?.value`);
const val = await page.evaluate(`((idx) => document.querySelector('[data-opencli-ref="' + idx + '"]')?.value)(${JSON.stringify(String(index))})`);
console.log(val ?? '(empty)');
}));
@@ -404,7 +404,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
get.command('attributes').argument('<index>', 'Element index').description('Element attributes')
.action(browserAction(async (page, index) => {
const attrs = await page.evaluate(`JSON.stringify(Object.fromEntries([...document.querySelector('[data-opencli-ref="${index}"]')?.attributes].map(a=>[a.name,a.value])))`);
const attrs = await page.evaluate(`((idx) => JSON.stringify(Object.fromEntries([...document.querySelector('[data-opencli-ref="' + idx + '"]')?.attributes].map(a=>[a.name,a.value]))))(${JSON.stringify(String(index))})`);
console.log(attrs ?? '{}');
}));
@@ -755,15 +755,15 @@ cli({
await discoverPlugins();
if (Array.isArray(result)) {
if (result.length === 0) {
console.log(chalk.yellow('No plugins were installed (all skipped or incompatible).'));
console.log(styleText('yellow', 'No plugins were installed (all skipped or incompatible).'));
} else {
console.log(chalk.green(`\u2705 Installed ${result.length} plugin(s) from monorepo: ${result.join(', ')}`));
console.log(styleText('green', `\u2705 Installed ${result.length} plugin(s) from monorepo: ${result.join(', ')}`));
}
} else {
console.log(chalk.green(`\u2705 Plugin "${result}" installed successfully. Commands are ready to use.`));
console.log(styleText('green', `\u2705 Plugin "${result}" installed successfully. Commands are ready to use.`));
}
} catch (err) {
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
console.error(styleText('red', `Error: ${getErrorMessage(err)}`));
process.exitCode = EXIT_CODES.GENERIC_ERROR;
}
});
@@ -776,9 +776,9 @@ cli({
const { uninstallPlugin } = await import('./plugin.js');
try {
uninstallPlugin(name);
console.log(chalk.green(`✅ Plugin "${name}" uninstalled.`));
console.log(styleText('green', `✅ Plugin "${name}" uninstalled.`));
} catch (err) {
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
console.error(styleText('red', `Error: ${getErrorMessage(err)}`));
process.exitCode = EXIT_CODES.GENERIC_ERROR;
}
});
@@ -790,12 +790,12 @@ cli({
.option('--all', 'Update all installed plugins')
.action(async (name: string | undefined, opts: { all?: boolean }) => {
if (!name && !opts.all) {
console.error(chalk.red('Error: Please specify a plugin name or use the --all flag.'));
console.error(styleText('red', 'Error: Please specify a plugin name or use the --all flag.'));
process.exitCode = EXIT_CODES.USAGE_ERROR;
return;
}
if (name && opts.all) {
console.error(chalk.red('Error: Cannot specify both a plugin name and --all.'));
console.error(styleText('red', 'Error: Cannot specify both a plugin name and --all.'));
process.exitCode = EXIT_CODES.USAGE_ERROR;
return;
}
@@ -809,27 +809,27 @@ cli({
}
let hasErrors = false;
console.log(chalk.bold(' Update Results:'));
console.log(styleText('bold', ' Update Results:'));
for (const result of results) {
if (result.success) {
console.log(` ${chalk.green('✓')} ${result.name}`);
console.log(` ${styleText('green', '✓')} ${result.name}`);
continue;
}
hasErrors = true;
console.log(` ${chalk.red('✗')} ${result.name}${chalk.dim(result.error)}`);
console.log(` ${styleText('red', '✗')} ${result.name}${styleText('dim', String(result.error))}`);
}
if (results.length === 0) {
console.log(chalk.dim(' No plugins installed.'));
console.log(styleText('dim', ' No plugins installed.'));
return;
}
console.log();
if (hasErrors) {
console.error(chalk.red('Completed with some errors.'));
console.error(styleText('red', 'Completed with some errors.'));
process.exitCode = EXIT_CODES.GENERIC_ERROR;
} else {
console.log(chalk.green('✅ All plugins updated successfully.'));
console.log(styleText('green', '✅ All plugins updated successfully.'));
}
return;
}
@@ -837,9 +837,9 @@ cli({
try {
updatePlugin(name!);
await discoverPlugins();
console.log(chalk.green(`✅ Plugin "${name}" updated successfully.`));
console.log(styleText('green', `✅ Plugin "${name}" updated successfully.`));
} catch (err) {
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
console.error(styleText('red', `Error: ${getErrorMessage(err)}`));
process.exitCode = EXIT_CODES.GENERIC_ERROR;
}
});
@@ -853,8 +853,8 @@ cli({
const { listPlugins } = await import('./plugin.js');
const plugins = listPlugins();
if (plugins.length === 0) {
console.log(chalk.dim(' No plugins installed.'));
console.log(chalk.dim(` Install one with: opencli plugin install github:user/repo`));
console.log(styleText('dim', ' No plugins installed.'));
console.log(styleText('dim', ' Install one with: opencli plugin install github:user/repo'));
return;
}
if (opts.format === 'json') {
@@ -867,7 +867,7 @@ cli({
return;
}
console.log();
console.log(chalk.bold(' Installed plugins'));
console.log(styleText('bold', ' Installed plugins'));
console.log();
// Group by monorepo
@@ -881,26 +881,26 @@ cli({
}
for (const p of standalone) {
const version = p.version ? chalk.green(` @${p.version}`) : '';
const desc = p.description ? chalk.dim(`${p.description}`) : '';
const cmds = p.commands.length > 0 ? chalk.dim(` (${p.commands.join(', ')})`) : '';
const src = p.source ? chalk.dim(`${p.source}`) : '';
console.log(` ${chalk.cyan(p.name)}${version}${desc}${cmds}${src}`);
const version = p.version ? styleText('green', ` @${p.version}`) : '';
const desc = p.description ? styleText('dim', `${p.description}`) : '';
const cmds = p.commands.length > 0 ? styleText('dim', ` (${p.commands.join(', ')})`) : '';
const src = p.source ? styleText('dim', `${p.source}`) : '';
console.log(` ${styleText('cyan', p.name)}${version}${desc}${cmds}${src}`);
}
for (const [mono, group] of monoGroups) {
console.log();
console.log(chalk.bold.magenta(` 📦 ${mono}`) + chalk.dim(' (monorepo)'));
console.log(styleText(['bold', 'magenta'], ` 📦 ${mono}`) + styleText('dim', ' (monorepo)'));
for (const p of group) {
const version = p.version ? chalk.green(` @${p.version}`) : '';
const desc = p.description ? chalk.dim(`${p.description}`) : '';
const cmds = p.commands.length > 0 ? chalk.dim(` (${p.commands.join(', ')})`) : '';
console.log(` ${chalk.cyan(p.name)}${version}${desc}${cmds}`);
const version = p.version ? styleText('green', ` @${p.version}`) : '';
const desc = p.description ? styleText('dim', `${p.description}`) : '';
const cmds = p.commands.length > 0 ? styleText('dim', ` (${p.commands.join(', ')})`) : '';
console.log(` ${styleText('cyan', p.name)}${version}${desc}${cmds}`);
}
}
console.log();
console.log(chalk.dim(` ${plugins.length} plugin(s) installed`));
console.log(styleText('dim', ` ${plugins.length} plugin(s) installed`));
console.log();
});
@@ -917,19 +917,19 @@ cli({
dir: opts.dir,
description: opts.description,
});
console.log(chalk.green(`✅ Plugin scaffold created at ${result.dir}`));
console.log(styleText('green', `✅ Plugin scaffold created at ${result.dir}`));
console.log();
console.log(chalk.bold(' Files created:'));
console.log(styleText('bold', ' Files created:'));
for (const f of result.files) {
console.log(` ${chalk.cyan(f)}`);
console.log(` ${styleText('cyan', f)}`);
}
console.log();
console.log(chalk.dim(' Next steps:'));
console.log(chalk.dim(` cd ${result.dir}`));
console.log(chalk.dim(` opencli plugin install file://${result.dir}`));
console.log(chalk.dim(` opencli ${name} hello`));
console.log(styleText('dim', ' Next steps:'));
console.log(styleText('dim', ` cd ${result.dir}`));
console.log(styleText('dim', ` opencli plugin install file://${result.dir}`));
console.log(styleText('dim', ` opencli ${name} hello`));
} catch (err) {
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
console.error(styleText('red', `Error: ${getErrorMessage(err)}`));
process.exitCode = EXIT_CODES.GENERIC_ERROR;
}
});
@@ -952,7 +952,7 @@ cli({
.action((name: string) => {
const ext = externalClis.find(e => e.name === name);
if (!ext) {
console.error(chalk.red(`External CLI '${name}' not found in registry.`));
console.error(styleText('red', `External CLI '${name}' not found in registry.`));
process.exitCode = EXIT_CODES.USAGE_ERROR;
return;
}
@@ -978,7 +978,7 @@ cli({
try {
executeExternalCli(name, args, externalClis);
} catch (err) {
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
console.error(styleText('red', `Error: ${getErrorMessage(err)}`));
process.exitCode = EXIT_CODES.GENERIC_ERROR;
}
}
@@ -1020,9 +1020,9 @@ cli({
program.on('command:*', (operands: string[]) => {
const binary = operands[0];
console.error(chalk.red(`error: unknown command '${binary}'`));
console.error(styleText('red', `error: unknown command '${binary}'`));
if (isBinaryInstalled(binary)) {
console.error(chalk.dim(` Tip: '${binary}' exists on your PATH. Use 'opencli register ${binary}' to add it as an external CLI.`));
console.error(styleText('dim', ` Tip: '${binary}' exists on your PATH. Use 'opencli register ${binary}' to add it as an external CLI.`));
}
program.outputHelp();
process.exitCode = EXIT_CODES.USAGE_ERROR;
+44 -4
View File
@@ -8,9 +8,13 @@ const { mockExecuteCommand, mockRenderOutput } = vi.hoisted(() => ({
mockRenderOutput: vi.fn(),
}));
vi.mock('./execution.js', () => ({
executeCommand: mockExecuteCommand,
}));
vi.mock('./execution.js', async () => {
const actual = await vi.importActual<typeof import('./execution.js')>('./execution.js');
return {
...actual,
executeCommand: mockExecuteCommand,
};
});
vi.mock('./output.js', () => ({
render: mockRenderOutput,
@@ -202,7 +206,43 @@ describe('commanderAdapter command aliases', () => {
await program.parseAsync(['node', 'opencli', 'notebooklm', 'metadata']);
expect(mockExecuteCommand).toHaveBeenCalledWith(cmd, {}, false);
expect(mockExecuteCommand).toHaveBeenCalledWith(cmd, {}, false, { prepared: true });
});
});
describe('commanderAdapter validation preparation', () => {
beforeEach(() => {
mockExecuteCommand.mockReset();
mockExecuteCommand.mockResolvedValue([]);
mockRenderOutput.mockReset();
delete process.env.OPENCLI_VERBOSE;
process.exitCode = undefined;
});
it('prepares args once before dispatching to executeCommand', async () => {
const validateArgs = vi.fn();
const program = new Command();
const siteCmd = program.command('test');
registerCommandToProgram(siteCmd, {
site: 'test',
name: 'run',
description: 'Run test command',
browser: false,
args: [{ name: 'count', default: '1', help: 'Count' }],
validateArgs,
func: vi.fn(),
});
await program.parseAsync(['node', 'opencli', 'test', 'run']);
expect(validateArgs).toHaveBeenCalledTimes(1);
expect(mockExecuteCommand).toHaveBeenCalledWith(
expect.objectContaining({ site: 'test', name: 'run' }),
{ count: '1' },
false,
{ prepared: true },
);
});
});
+9 -9
View File
@@ -11,12 +11,12 @@
*/
import { Command } from 'commander';
import chalk from 'chalk';
import { log } from './logger.js';
import yaml from 'js-yaml';
import { type CliCommand, fullName, getRegistry } from './registry.js';
import { formatRegistryHelpText } from './serialization.js';
import { render as renderOutput } from './output.js';
import { executeCommand } from './execution.js';
import { executeCommand, prepareCommandArgs } from './execution.js';
import {
CliError,
EXIT_CODES,
@@ -76,18 +76,18 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
// ── Execute + render ────────────────────────────────────────────────
try {
// ── Collect kwargs ────────────────────────────────────────────────
const kwargs: Record<string, unknown> = {};
const rawKwargs: Record<string, unknown> = {};
for (let i = 0; i < positionalArgs.length; i++) {
const v = actionArgs[i];
if (v !== undefined) kwargs[positionalArgs[i].name] = v;
if (v !== undefined) rawKwargs[positionalArgs[i].name] = v;
}
for (const arg of cmd.args) {
if (arg.positional) continue;
const camelName = arg.name.replace(/-([a-z])/g, (_m, ch: string) => ch.toUpperCase());
const v = optionsRecord[arg.name] ?? optionsRecord[camelName];
if (v !== undefined) kwargs[arg.name] = normalizeArgValue(arg.type, v, arg.name);
if (v !== undefined) rawKwargs[arg.name] = normalizeArgValue(arg.type, v, arg.name);
}
cmd.validateArgs?.(kwargs);
const kwargs = prepareCommandArgs(cmd, rawKwargs);
const verbose = optionsRecord.verbose === true;
let format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'table';
@@ -96,10 +96,10 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
if (cmd.deprecated) {
const message = typeof cmd.deprecated === 'string' ? cmd.deprecated : `${fullName(cmd)} is deprecated.`;
const replacement = cmd.replacedBy ? ` Use ${cmd.replacedBy} instead.` : '';
console.error(chalk.yellow(`Deprecated: ${message}${replacement}`));
log.warn(`Deprecated: ${message}${replacement}`);
}
const result = await executeCommand(cmd, kwargs, verbose);
const result = await executeCommand(cmd, kwargs, verbose, { prepared: true });
if (result === null || result === undefined) {
return;
}
@@ -110,7 +110,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
}
if (verbose && (!result || (Array.isArray(result) && result.length === 0))) {
console.error(chalk.yellow('[Verbose] Warning: Command returned an empty result.'));
log.warn('Command returned an empty result.');
}
renderOutput(result, {
fmt: format,
-8
View File
@@ -8,14 +8,6 @@ const {
requestDaemonShutdownMock: vi.fn(),
}));
vi.mock('chalk', () => ({
default: {
green: (s: string) => s,
red: (s: string) => s,
dim: (s: string) => s,
},
}));
vi.mock('../browser/daemon-client.js', () => ({
fetchDaemonStatus: fetchDaemonStatusMock,
requestDaemonShutdown: requestDaemonShutdownMock,
+4 -4
View File
@@ -3,21 +3,21 @@
* opencli daemon stop — graceful shutdown
*/
import chalk from 'chalk';
import { styleText } from 'node:util';
import { fetchDaemonStatus, requestDaemonShutdown } from '../browser/daemon-client.js';
export async function daemonStop(): Promise<void> {
const status = await fetchDaemonStatus();
if (!status) {
console.log(chalk.dim('Daemon is not running.'));
console.log(styleText('dim', 'Daemon is not running.'));
return;
}
const ok = await requestDaemonShutdown();
if (ok) {
console.log(chalk.green('Daemon stopped.'));
console.log(styleText('green', 'Daemon stopped.'));
} else {
console.error(chalk.red('Failed to stop daemon.'));
console.error(styleText('red', 'Failed to stop daemon.'));
process.exitCode = 1;
}
}
+15 -15
View File
@@ -4,7 +4,7 @@
* Simplified for the daemon-based architecture.
*/
import chalk from 'chalk';
import { styleText } from 'node:util';
import { DEFAULT_DAEMON_PORT } from './constants.js';
import { BrowserBridge } from './browser/index.js';
import { getDaemonHealth, listSessions } from './browser/daemon-client.js';
@@ -138,12 +138,12 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
}
export function renderBrowserDoctorReport(report: DoctorReport): string {
const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`) + chalk.dim(` (${getRuntimeLabel()})`), ''];
const lines = [styleText('bold', `opencli v${report.cliVersion ?? 'unknown'} doctor`) + styleText('dim', ` (${getRuntimeLabel()})`), ''];
// Daemon status
const daemonIcon = report.daemonFlaky
? chalk.yellow('[WARN]')
: report.daemonRunning ? chalk.green('[OK]') : chalk.red('[MISSING]');
? styleText('yellow', '[WARN]')
: report.daemonRunning ? styleText('green', '[OK]') : styleText('red', '[MISSING]');
const daemonLabel = report.daemonFlaky
? 'unstable (running during live check, then stopped)'
: report.daemonRunning ? `running on port ${DEFAULT_DAEMON_PORT}` : 'not running';
@@ -151,9 +151,9 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
// Extension status
const extIcon = report.extensionFlaky
? chalk.yellow('[WARN]')
: report.extensionConnected ? chalk.green('[OK]') : chalk.yellow('[MISSING]');
const extVersion = report.extensionVersion ? chalk.dim(` (v${report.extensionVersion})`) : '';
? styleText('yellow', '[WARN]')
: report.extensionConnected ? styleText('green', '[OK]') : styleText('yellow', '[MISSING]');
const extVersion = report.extensionVersion ? styleText('dim', ` (v${report.extensionVersion})`) : '';
const extLabel = report.extensionFlaky
? 'unstable (connected during live check, then disconnected)'
: report.extensionConnected ? 'connected' : 'not connected';
@@ -161,33 +161,33 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
// Connectivity
if (report.connectivity) {
const connIcon = report.connectivity.ok ? chalk.green('[OK]') : chalk.red('[FAIL]');
const connIcon = report.connectivity.ok ? styleText('green', '[OK]') : styleText('red', '[FAIL]');
const detail = report.connectivity.ok
? `connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
: `failed (${report.connectivity.error ?? 'unknown'})`;
lines.push(`${connIcon} Connectivity: ${detail}`);
} else {
lines.push(`${chalk.dim('[SKIP]')} Connectivity: skipped (--no-live)`);
lines.push(`${styleText('dim', '[SKIP]')} Connectivity: skipped (--no-live)`);
}
if (report.sessions) {
lines.push('', chalk.bold('Sessions:'));
lines.push('', styleText('bold', 'Sessions:'));
if (report.sessions.length === 0) {
lines.push(chalk.dim(' • no active automation sessions'));
lines.push(styleText('dim', ' • no active automation sessions'));
} else {
for (const session of report.sessions) {
lines.push(chalk.dim(`${session.workspace} → window ${session.windowId}, tabs=${session.tabCount}, idle=${Math.ceil(session.idleMsRemaining / 1000)}s`));
lines.push(styleText('dim', `${session.workspace} → window ${session.windowId}, tabs=${session.tabCount}, idle=${Math.ceil(session.idleMsRemaining / 1000)}s`));
}
}
}
if (report.issues.length) {
lines.push('', chalk.yellow('Issues:'));
lines.push('', styleText('yellow', 'Issues:'));
for (const issue of report.issues) {
lines.push(chalk.dim(`${issue}`));
lines.push(styleText('dim', `${issue}`));
}
} else if (report.daemonRunning && report.extensionConnected) {
lines.push('', chalk.green('Everything looks good!'));
lines.push('', styleText('green', 'Everything looks good!'));
}
return lines.join('\n');
+10 -10
View File
@@ -2,7 +2,7 @@
* Download progress display: terminal progress bars, status updates.
*/
import chalk from 'chalk';
import { styleText } from 'node:util';
export interface ProgressBar {
update(current: number, total: number, label?: string): void;
@@ -42,7 +42,7 @@ export function formatDuration(ms: number): string {
* Create a simple progress bar for terminal display.
*/
export function createProgressBar(filename: string, index: number, total: number): ProgressBar {
const prefix = chalk.dim(`[${index + 1}/${total}]`);
const prefix = styleText('dim', `[${index + 1}/${total}]`);
const truncatedName = filename.length > 40 ? filename.slice(0, 37) + '...' : filename;
return {
@@ -54,12 +54,12 @@ export function createProgressBar(filename: string, index: number, total: number
process.stderr.write(`\r${prefix} ${truncatedName} ${bar} ${percent}% ${size}${extra}`);
},
complete(success: boolean, message?: string) {
const icon = success ? chalk.green('✓') : chalk.red('✗');
const msg = message ? ` ${chalk.dim(message)}` : '';
const icon = success ? styleText('green', '✓') : styleText('red', '✗');
const msg = message ? ` ${styleText('dim', message)}` : '';
process.stderr.write(`\r${prefix} ${icon} ${truncatedName}${msg}\n`);
},
fail(error: string) {
process.stderr.write(`\r${prefix} ${chalk.red('✗')} ${truncatedName} ${chalk.red(error)}\n`);
process.stderr.write(`\r${prefix} ${styleText('red', '✗')} ${truncatedName} ${styleText('red', error)}\n`);
},
};
}
@@ -70,7 +70,7 @@ export function createProgressBar(filename: string, index: number, total: number
function createBar(percent: number, width: number = 20): string {
const filled = Math.round((percent / 100) * width);
const empty = width - filled;
return chalk.cyan('█'.repeat(filled)) + chalk.dim('░'.repeat(empty));
return styleText('cyan', '█'.repeat(filled)) + styleText('dim', '░'.repeat(empty));
}
/**
@@ -110,13 +110,13 @@ export class DownloadProgressTracker {
const parts: string[] = [];
if (this.completed > 0) {
parts.push(chalk.green(`${this.completed} downloaded`));
parts.push(styleText('green', `${this.completed} downloaded`));
}
if (this.skipped > 0) {
parts.push(chalk.yellow(`${this.skipped} skipped`));
parts.push(styleText('yellow', `${this.skipped} skipped`));
}
if (this.failed > 0) {
parts.push(chalk.red(`${this.failed} failed`));
parts.push(styleText('red', `${this.failed} failed`));
}
return `${parts.join(', ')} in ${elapsed}`;
@@ -124,7 +124,7 @@ export class DownloadProgressTracker {
finish(): void {
if (this.verbose) {
process.stderr.write(`\n${chalk.bold('Download complete:')} ${this.getSummary()}\n`);
process.stderr.write(`\n${styleText('bold', 'Download complete:')} ${this.getSummary()}\n`);
}
}
}
+6
View File
@@ -137,6 +137,12 @@ export class SelectorError extends CliError {
}
}
export class PluginError extends CliError {
constructor(message: string, hint?: string) {
super('PLUGIN', message, hint, EXIT_CODES.GENERIC_ERROR);
}
}
// ── Error Envelope ──────────────────────────────────────────────────────────
/** Structured error output — unified contract for all consumers (AI agents, scripts, humans). */
+22 -2
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { executeCommand } from './execution.js';
import { describe, expect, it, vi } from 'vitest';
import type { CliCommand } from './registry.js';
import { executeCommand, prepareCommandArgs } from './execution.js';
import { TimeoutError } from './errors.js';
import { cli, Strategy } from './registry.js';
import { withTimeoutMs } from './runtime.js';
@@ -44,4 +45,23 @@ describe('executeCommand — non-browser timeout', () => {
withTimeoutMs(executeCommand(cmd, {}), 50, 'sentinel timeout'),
).rejects.toThrow('sentinel timeout');
});
it('does not re-run custom validation when args are already prepared', async () => {
const validateArgs = vi.fn();
const cmd: CliCommand = {
site: 'test-execution',
name: 'prepared-validation',
description: 'test prepared validation path',
browser: false,
strategy: Strategy.PUBLIC,
args: [],
validateArgs,
func: async () => [],
};
const kwargs = prepareCommandArgs(cmd, {});
await executeCommand(cmd, kwargs, false, { prepared: true });
expect(validateArgs).toHaveBeenCalledTimes(1);
});
});
+12 -3
View File
@@ -135,11 +135,11 @@ export async function executeCommand(
cmd: CliCommand,
rawKwargs: CommandArgs,
debug: boolean = false,
opts: { prepared?: boolean } = {},
): Promise<unknown> {
let kwargs: CommandArgs;
try {
kwargs = coerceAndValidateArgs(cmd.args, rawKwargs);
cmd.validateArgs?.(kwargs);
kwargs = opts.prepared ? rawKwargs : prepareCommandArgs(cmd, rawKwargs);
} catch (err) {
if (err instanceof ArgumentError) throw err;
throw new ArgumentError(getErrorMessage(err));
@@ -189,7 +189,7 @@ export async function executeCommand(
try {
await page.goto(preNavUrl);
} catch (err) {
if (debug) log.debug(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
log.warn(`Pre-navigation to ${preNavUrl} failed: ${err instanceof Error ? err.message : err}`);
}
}
try {
@@ -243,3 +243,12 @@ export async function executeCommand(
await emitHook('onAfterExecute', hookCtx, result);
return result;
}
export function prepareCommandArgs(
cmd: CliCommand,
rawKwargs: CommandArgs,
): CommandArgs {
const kwargs = coerceAndValidateArgs(cmd.args, rawKwargs);
cmd.validateArgs?.(kwargs);
return kwargs;
}
+12 -13
View File
@@ -4,7 +4,6 @@ import * as os from 'node:os';
import { fileURLToPath } from 'node:url';
import { spawnSync, execFileSync } from 'node:child_process';
import yaml from 'js-yaml';
import chalk from 'chalk';
import { log } from './logger.js';
import { EXIT_CODES, getErrorMessage } from './errors.js';
@@ -144,26 +143,26 @@ function runInstallCommand(cmd: string): void {
export function installExternalCli(cli: ExternalCliConfig): boolean {
if (!cli.install) {
console.error(chalk.red(`No auto-install command configured for '${cli.name}'.`));
console.error(`Please install '${cli.binary}' manually.`);
log.error(`No auto-install command configured for '${cli.name}'.`);
log.info(`Please install '${cli.binary}' manually.`);
return false;
}
const cmd = getInstallCmd(cli.install);
if (!cmd) {
console.error(chalk.red(`No install command for your platform (${os.platform()}) for '${cli.name}'.`));
if (cli.homepage) console.error(`See: ${cli.homepage}`);
log.error(`No install command for your platform (${os.platform()}) for '${cli.name}'.`);
if (cli.homepage) log.info(`See: ${cli.homepage}`);
return false;
}
console.log(chalk.cyan(`🔹 '${cli.name}' is not installed. Auto-installing...`));
console.log(chalk.dim(`$ ${cmd}`));
log.info(`'${cli.name}' is not installed. Auto-installing...`);
log.verbose(`$ ${cmd}`);
try {
runInstallCommand(cmd);
console.log(chalk.green(`Installed '${cli.name}' successfully.\n`));
log.success(`Installed '${cli.name}' successfully.`);
return true;
} catch (err) {
console.error(chalk.red(`Failed to install '${cli.name}': ${getErrorMessage(err)}`));
log.error(`Failed to install '${cli.name}': ${getErrorMessage(err)}`);
return false;
}
}
@@ -188,7 +187,7 @@ export function executeExternalCli(name: string, args: string[], preloaded?: Ext
// 3. Passthrough execution with stdio inherited
const result = spawnSync(cli.binary, args, { stdio: 'inherit' });
if (result.error) {
console.error(chalk.red(`Failed to execute '${cli.binary}': ${result.error.message}`));
log.error(`Failed to execute '${cli.binary}': ${result.error.message}`);
process.exitCode = EXIT_CODES.GENERIC_ERROR;
return;
}
@@ -233,14 +232,14 @@ export function registerExternalCli(name: string, opts?: RegisterOptions): void
if (existingIndex >= 0) {
items[existingIndex] = { ...items[existingIndex], ...newItem };
console.log(chalk.green(`Updated '${name}' in user registry.`));
log.success(`Updated '${name}' in user registry.`);
} else {
items.push(newItem);
console.log(chalk.green(`Registered '${name}' in user registry.`));
log.success(`Registered '${name}' in user registry.`);
}
const dump = yaml.dump(items, { indent: 2, sortKeys: true });
fs.writeFileSync(userPath, dump, 'utf8');
_cachedExternalClis = null; // Invalidate cache so next load reflects the change
console.log(chalk.dim(userPath));
log.verbose(userPath);
}
+10 -10
View File
@@ -5,7 +5,7 @@
* this module so that verbosity levels are respected consistently.
*/
import chalk from 'chalk';
import { styleText } from 'node:util';
function isVerbose(): boolean {
return !!process.env.OPENCLI_VERBOSE;
@@ -18,50 +18,50 @@ function isDebug(): boolean {
export const log = {
/** Informational message (always shown) */
info(msg: string): void {
process.stderr.write(`${chalk.blue('')} ${msg}\n`);
process.stderr.write(`${styleText('blue', '')} ${msg}\n`);
},
/** Lightweight status line for adapter progress updates */
status(msg: string): void {
process.stderr.write(`${chalk.dim(msg)}\n`);
process.stderr.write(`${styleText('dim', msg)}\n`);
},
/** Positive completion/status line without the heavier info prefix */
success(msg: string): void {
process.stderr.write(`${chalk.green(msg)}\n`);
process.stderr.write(`${styleText('green', msg)}\n`);
},
/** Warning (always shown) */
warn(msg: string): void {
process.stderr.write(`${chalk.yellow('⚠')} ${msg}\n`);
process.stderr.write(`${styleText('yellow', '⚠')} ${msg}\n`);
},
/** Error (always shown) */
error(msg: string): void {
process.stderr.write(`${chalk.red('✖')} ${msg}\n`);
process.stderr.write(`${styleText('red', '✖')} ${msg}\n`);
},
/** Verbose output (only when OPENCLI_VERBOSE is set or -v flag) */
verbose(msg: string): void {
if (isVerbose()) {
process.stderr.write(`${chalk.dim('[verbose]')} ${msg}\n`);
process.stderr.write(`${styleText('dim', '[verbose]')} ${msg}\n`);
}
},
/** Debug output (only when DEBUG includes 'opencli') */
debug(msg: string): void {
if (isDebug()) {
process.stderr.write(`${chalk.dim('[debug]')} ${msg}\n`);
process.stderr.write(`${styleText('dim', '[debug]')} ${msg}\n`);
}
},
/** Step-style debug (for pipeline steps, etc.) */
step(stepNum: number, total: number, op: string, preview: string = ''): void {
process.stderr.write(` ${chalk.dim(`[${stepNum}/${total}]`)} ${chalk.bold.cyan(op)}${preview}\n`);
process.stderr.write(` ${styleText('dim', `[${stepNum}/${total}]`)} ${styleText(['bold', 'cyan'], op)}${preview}\n`);
},
/** Step result summary */
stepResult(summary: string): void {
process.stderr.write(` ${chalk.dim(`${summary}`)}\n`);
process.stderr.write(` ${styleText('dim', `${summary}`)}\n`);
},
};
+5 -5
View File
@@ -2,7 +2,7 @@
* Output formatting: table, JSON, Markdown, CSV, YAML.
*/
import chalk from 'chalk';
import { styleText } from 'node:util';
import Table from 'cli-table3';
import yaml from 'js-yaml';
@@ -52,12 +52,12 @@ export function render(data: unknown, opts: RenderOptions = {}): void {
function renderTable(data: unknown, opts: RenderOptions): void {
const rows = normalizeRows(data);
if (!rows.length) { console.log(chalk.dim('(no data)')); return; }
if (!rows.length) { console.log(styleText('dim', '(no data)')); return; }
const columns = resolveColumns(rows, opts);
const header = columns.map(c => capitalize(c));
const table = new Table({
head: header.map(h => chalk.bold(h)),
head: header.map(h => styleText('bold', h)),
style: { head: [], border: [] },
wordWrap: true,
wrapOnWordBoundary: true,
@@ -71,14 +71,14 @@ function renderTable(data: unknown, opts: RenderOptions): void {
}
console.log();
if (opts.title) console.log(chalk.dim(` ${opts.title}`));
if (opts.title) console.log(styleText('dim', ` ${opts.title}`));
console.log(table.toString());
const footer: string[] = [];
footer.push(`${rows.length} items`);
if (opts.elapsed) footer.push(`${opts.elapsed.toFixed(1)}s`);
if (opts.source) footer.push(opts.source);
if (opts.footerExtra) footer.push(opts.footerExtra);
console.log(chalk.dim(footer.join(' · ')));
console.log(styleText('dim', footer.join(' · ')));
}
function renderJson(data: unknown): void {
+45
View File
@@ -1400,6 +1400,51 @@ describe('updatePlugin transactional staging', () => {
});
});
it('rejects monorepo updates whose manifest path escapes the repo root', () => {
const oldSubDir = path.join(monorepoRepoDir, 'packages', 'old-alpha');
fs.mkdirSync(oldSubDir, { recursive: true });
fs.writeFileSync(path.join(oldSubDir, 'old.js'), 'cli({ site: "old", name: "old" })');
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
fs.symlinkSync(oldSubDir, monorepoLink, 'dir');
const lock = _readLockFile();
lock[monorepoPluginName] = {
source: {
kind: 'monorepo',
url: 'https://github.com/user/opencli-plugins-__test-transactional-mono-update__.git',
repoName: monorepoName,
subPath: 'packages/old-alpha',
},
commitHash: 'oldmonooldmonooldmonooldmonooldmonoold',
installedAt: '2025-01-01T00:00:00.000Z',
};
_writeLockFile(lock);
mockExecFileSync.mockImplementation((cmd, args) => {
if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
const cloneDir = String(args[4]);
fs.mkdirSync(cloneDir, { recursive: true });
fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
plugins: {
[monorepoPluginName]: { path: '../outside-alpha' },
},
}));
return '';
}
if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
return '1234567890abcdef1234567890abcdef12345678\n';
}
return '';
});
expect(() => updatePlugin(monorepoPluginName)).toThrow('escapes repo root');
expect(fs.realpathSync(monorepoLink)).toBe(fs.realpathSync(oldSubDir));
expect(_readLockFile()[monorepoPluginName]?.source).toMatchObject({
kind: 'monorepo',
subPath: 'packages/old-alpha',
});
});
it('rolls back the monorepo repo swap when relinking fails', () => {
const oldSubDir = path.join(monorepoRepoDir, 'packages', 'old-alpha');
fs.mkdirSync(oldSubDir, { recursive: true });
+48 -25
View File
@@ -13,8 +13,9 @@ import * as path from 'node:path';
import { execSync, execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { PLUGINS_DIR } from './discovery.js';
import { getErrorMessage } from './errors.js';
import { getErrorMessage, PluginError } from './errors.js';
import { log } from './logger.js';
import { isRecord } from './utils.js';
import {
readPluginManifest,
isMonorepo,
@@ -100,9 +101,7 @@ function toLocalPluginSource(pluginDir: string): string {
return toStoredPluginSource({ kind: 'local', path: pluginDir });
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
// isRecord is imported from './utils.js'
function normalizeLegacyMonorepo(
value: unknown,
@@ -231,7 +230,7 @@ function createSiblingTempPath(dest: string, kind: 'tmp' | 'bak'): string {
*/
function promoteDir(stagingDir: string, dest: string, fsOps: PromoteDirFsOps = fs): void {
if (fsOps.existsSync(dest)) {
throw new Error(`Destination already exists: ${dest}`);
throw new PluginError(`Destination already exists: ${dest}`);
}
fsOps.mkdirSync(path.dirname(dest), { recursive: true });
@@ -263,7 +262,7 @@ function cloneRepoToTemp(cloneUrl: string): string {
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch (err) {
throw new Error(`Failed to clone plugin: ${getErrorMessage(err)}`);
throw new PluginError(`Failed to clone plugin: ${getErrorMessage(err)}`, 'Check the repository URL and your network connection.');
}
return tmpCloneDir;
@@ -295,6 +294,14 @@ function pathExistsSync(p: string): boolean {
}
}
function resolveRepoContainedPath(repoRoot: string, subPath: string): string {
const resolved = path.resolve(repoRoot, subPath);
if (!resolved.startsWith(repoRoot + path.sep) && resolved !== repoRoot) {
throw new PluginError(`Plugin path "${subPath}" escapes repo root.`);
}
return resolved;
}
function removePathSync(p: string): void {
try {
const stat = fs.lstatSync(p);
@@ -578,7 +585,7 @@ function installDependencies(dir: string): void {
...(isWindows && { shell: true }),
});
} catch (err) {
throw new Error(`npm install failed in ${dir}: ${getErrorMessage(err)}`);
throw new PluginError(`npm install failed in ${dir}: ${getErrorMessage(err)}`, 'Check your network connection and npm configuration.');
}
}
@@ -612,7 +619,7 @@ function postInstallMonorepoLifecycle(repoDir: string, pluginDirs: string[]): vo
function ensureStandalonePluginReady(pluginDir: string): void {
const validation = validatePluginStructure(pluginDir);
if (!validation.valid) {
throw new Error(`Invalid plugin structure:\n- ${validation.errors.join('\n- ')}`);
throw new PluginError(`Invalid plugin structure:\n- ${validation.errors.join('\n- ')}`);
}
postInstallLifecycle(pluginDir);
@@ -635,7 +642,7 @@ function publishStandalonePlugin(
stagingDir: string,
targetDir: string,
writeLock: (commitHash: string | undefined) => void,
): void {
): void {
runTransaction((tx) => {
tx.track(beginReplaceDir(stagingDir, targetDir));
writeLock(getCommitHash(targetDir));
@@ -663,7 +670,7 @@ function publishMonorepoPlugins(
const commitHash = getCommitHash(repoDir);
for (const plugin of plugins) {
const linkPath = path.join(pluginsDir, plugin.name);
const subDir = path.join(repoDir, plugin.subPath);
const subDir = resolveRepoContainedPath(repoDir, plugin.subPath);
tx.track(beginReplaceSymlink(subDir, linkPath));
}
@@ -735,7 +742,7 @@ function installSinglePlugin(
const targetDir = path.join(PLUGINS_DIR, pluginName);
if (fs.existsSync(targetDir)) {
throw new Error(`Plugin "${pluginName}" is already installed at ${targetDir}`);
throw new PluginError(`Plugin "${pluginName}" is already installed at ${targetDir}`, 'Use "opencli plugin uninstall" first, or pick a different name.');
}
ensureStandalonePluginReady(cloneDir);
@@ -760,19 +767,20 @@ function installSinglePlugin(
*/
function installLocalPlugin(localPath: string, name: string): string {
if (!fs.existsSync(localPath)) {
throw new Error(`Local plugin path does not exist: ${localPath}`);
throw new PluginError(`Local plugin path does not exist: ${localPath}`);
}
const stat = fs.statSync(localPath);
if (!stat.isDirectory()) {
throw new Error(`Local plugin path is not a directory: ${localPath}`);
throw new PluginError(`Local plugin path is not a directory: ${localPath}`);
}
const manifest = readPluginManifest(localPath);
if (manifest?.opencli && !checkCompatibility(manifest.opencli)) {
throw new Error(
`Plugin requires opencli ${manifest.opencli}, but current version is incompatible.`
throw new PluginError(
`Plugin requires opencli ${manifest.opencli}, but current version is incompatible.`,
'Upgrade opencli to a compatible version.',
);
}
@@ -780,12 +788,12 @@ function installLocalPlugin(localPath: string, name: string): string {
const targetDir = path.join(PLUGINS_DIR, pluginName);
if (fs.existsSync(targetDir)) {
throw new Error(`Plugin "${pluginName}" is already installed at ${targetDir}`);
throw new PluginError(`Plugin "${pluginName}" is already installed at ${targetDir}`, 'Use "opencli plugin uninstall" first, or pick a different name.');
}
const validation = validatePluginStructure(localPath);
if (!validation.valid) {
throw new Error(`Invalid plugin structure:\n- ${validation.errors.join('\n- ')}`);
throw new PluginError(`Invalid plugin structure:\n- ${validation.errors.join('\n- ')}`);
}
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
@@ -847,7 +855,7 @@ function installMonorepo(
const effectiveManifest = repoAlreadyInstalled ? readPluginManifest(repoDir) : manifest;
if (!effectiveManifest || !isMonorepo(effectiveManifest)) {
throw new Error(`Monorepo manifest missing or invalid at ${repoRoot}`);
throw new PluginError(`Monorepo manifest missing or invalid at ${repoRoot}`);
}
let pluginsToInstall = getEnabledPlugins(effectiveManifest);
@@ -859,9 +867,9 @@ function installMonorepo(
// Check if it exists but is disabled
const disabled = effectiveManifest.plugins?.[subPlugin];
if (disabled) {
throw new Error(`Sub-plugin "${subPlugin}" is disabled in the manifest.`);
throw new PluginError(`Sub-plugin "${subPlugin}" is disabled in the manifest.`);
}
throw new Error(
throw new PluginError(
`Sub-plugin "${subPlugin}" not found in monorepo. Available: ${Object.keys(effectiveManifest.plugins ?? {}).join(', ')}`
);
}
@@ -880,7 +888,13 @@ function installMonorepo(
continue;
}
const subDir = path.join(repoRoot, entry.path);
let subDir: string;
try {
subDir = resolveRepoContainedPath(repoRoot, entry.path);
} catch {
log.warn(`Skipping "${name}": path "${entry.path}" escapes repo root.`);
continue;
}
if (!fs.existsSync(subDir)) {
log.warn(`Skipping "${name}": path "${entry.path}" not found in repo.`);
continue;
@@ -908,9 +922,15 @@ function installMonorepo(
const publishPlugins = eligiblePlugins.map(({ name, entry }) => ({ name, subPath: entry.path }));
if (repoAlreadyInstalled) {
postInstallMonorepoLifecycle(repoDir, eligiblePlugins.map((p) => path.join(repoDir, p.entry.path)));
postInstallMonorepoLifecycle(
repoDir,
eligiblePlugins.map((p) => resolveRepoContainedPath(repoDir, p.entry.path)),
);
} else {
postInstallMonorepoLifecycle(cloneDir, eligiblePlugins.map((p) => path.join(cloneDir, p.entry.path)));
postInstallMonorepoLifecycle(
cloneDir,
eligiblePlugins.map((p) => resolveRepoContainedPath(cloneDir, p.entry.path)),
);
}
publishMonorepoPlugins(
@@ -967,7 +987,7 @@ function collectUpdatedMonorepoPlugins(
throw new Error(`Sub-plugin "${pluginName}" requires opencli ${manifestEntry.opencli}`);
}
const subDir = path.join(tmpCloneDir, manifestEntry.path);
const subDir = resolveRepoContainedPath(tmpCloneDir, manifestEntry.path);
const validation = validatePluginStructure(subDir);
if (!validation.valid) {
throw new Error(`Updated sub-plugin "${pluginName}" is invalid:\n- ${validation.errors.join('\n- ')}`);
@@ -1118,7 +1138,10 @@ export function updatePlugin(name: string): void {
);
if (updatedPlugins.length > 0) {
postInstallMonorepoLifecycle(tmpCloneDir, updatedPlugins.map((plugin) => path.join(tmpCloneDir, plugin.manifestEntry.path)));
postInstallMonorepoLifecycle(
tmpCloneDir,
updatedPlugins.map((plugin) => resolveRepoContainedPath(tmpCloneDir, plugin.manifestEntry.path)),
);
}
publishMonorepoPlugins(
+23 -23
View File
@@ -15,7 +15,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as readline from 'node:readline';
import chalk from 'chalk';
import { styleText } from 'node:util';
import { sendCommand } from './browser/daemon-client.js';
import type { IPage } from './types.js';
@@ -566,10 +566,10 @@ export async function recordSession(opts: RecordOptions): Promise<RecordResult>
const workspace = `record:${site}`;
console.log(chalk.bold.cyan('\n opencli record'));
console.log(chalk.dim(` Site: ${site} URL: ${opts.url}`));
console.log(chalk.dim(` Timeout: ${timeoutMs / 1000}s Poll: ${pollMs}ms`));
console.log(chalk.dim(' Navigating…'));
console.log(styleText(['bold', 'cyan'], '\n opencli record'));
console.log(styleText('dim', ` Site: ${site} URL: ${opts.url}`));
console.log(styleText('dim', ` Timeout: ${timeoutMs / 1000}s Poll: ${pollMs}ms`));
console.log(styleText('dim', ' Navigating…'));
const factory = new opts.BrowserFactory();
const page = await factory.connect({ timeout: 30, workspace });
@@ -584,8 +584,8 @@ export async function recordSession(opts: RecordOptions): Promise<RecordResult>
if (tab.page) await injectIntoPage(workspace, tab.page, injectedPages);
}
console.log(chalk.bold('\n Recording. Use the page in the browser automation window.'));
console.log(chalk.dim(` Will auto-stop after ${timeoutMs / 1000}s, or press Enter to stop now.\n`));
console.log(styleText('bold', '\n Recording. Use the page in the browser automation window.'));
console.log(styleText('dim', ` Will auto-stop after ${timeoutMs / 1000}s, or press Enter to stop now.\n`));
// Race: Enter key vs timeout
let stopped = false;
@@ -613,7 +613,7 @@ export async function recordSession(opts: RecordOptions): Promise<RecordResult>
const batch = await execOnPage(workspace, page, generateReadRecordedJs()) as RecordedRequest[] | null;
if (Array.isArray(batch) && batch.length > 0) {
for (const r of batch) allRequests.push(r);
console.log(chalk.dim(` [page:${page.slice(0, 8)}] +${batch.length} captured — total: ${allRequests.length}`));
console.log(styleText('dim', ` [page:${page.slice(0, 8)}] +${batch.length} captured — total: ${allRequests.length}`));
}
}
} catch {
@@ -635,7 +635,7 @@ export async function recordSession(opts: RecordOptions): Promise<RecordResult>
} catch {}
}
console.log(chalk.dim(`\n Stopped. Analyzing ${allRequests.length} captured requests…`));
console.log(styleText('dim', `\n Stopped. Analyzing ${allRequests.length} captured requests…`));
const result = analyzeAndWrite(site, opts.url, allRequests, opts.outDir);
await factory.close().catch(() => {});
@@ -666,7 +666,7 @@ async function injectIntoPage(workspace: string, page: string, injectedPages: Se
await execOnPage(workspace, page, generateFullCaptureInterceptorJs());
if (!injectedPages.has(page)) {
injectedPages.add(page);
console.log(chalk.green(` ✓ Interceptor injected into page:${page.slice(0, 8)}`));
console.log(styleText('green', ` ✓ Interceptor injected into page:${page.slice(0, 8)}`));
}
} catch {
// Page not debuggable (e.g. chrome:// pages) — skip silently
@@ -704,7 +704,7 @@ function analyzeAndWrite(
fs.mkdirSync(targetDir, { recursive: true });
if (requests.length === 0) {
console.log(chalk.yellow(' No API requests captured.'));
console.log(styleText('yellow', ' No API requests captured.'));
return { site, url: pageUrl, requests: [], outDir: targetDir, candidateCount: 0, candidates: [] };
}
@@ -722,20 +722,20 @@ function analyzeAndWrite(
const candidates: RecordResult['candidates'] = [];
const usedNames = new Set<string>();
console.log(chalk.bold('\n Captured endpoints:\n'));
console.log(styleText('bold', '\n Captured endpoints:\n'));
for (const entry of analysis.candidates.sort((a, b) => (b.arrayResult?.items.length ?? 0) - (a.arrayResult?.items.length ?? 0)).slice(0, 8)) {
const itemCount = entry.arrayResult?.items.length ?? 0;
const strategy = entry.kind === 'write'
? 'cookie'
: inferStrategy(detectAuthFromContent(entry.req.url, entry.req.responseBody));
const marker = entry.kind === 'write' ? chalk.magenta('✎') : itemCount > 5 ? chalk.green('★') : chalk.dim('·');
const marker = entry.kind === 'write' ? styleText('magenta', '✎') : itemCount > 5 ? styleText('green', '★') : styleText('dim', '·');
console.log(
` ${marker} ${chalk.white(urlToPattern(entry.req.url))}` +
chalk.dim(` [${strategy}]`) +
` ${marker} ${styleText('white', urlToPattern(entry.req.url))}` +
styleText('dim', ` [${strategy}]`) +
(entry.kind === 'write'
? chalk.magenta(' ← write')
: itemCount ? chalk.cyan(`${itemCount} items`) : ''),
? styleText('magenta', ' ← write')
: itemCount ? styleText('cyan', `${itemCount} items`) : ''),
);
}
@@ -753,13 +753,13 @@ function analyzeAndWrite(
fs.writeFileSync(filePath, JSON.stringify(entry.yaml, null, 2));
candidates.push({ name: entry.name, path: filePath, strategy: entry.strategy });
console.log(chalk.green(` ✓ Generated: ${chalk.bold(entry.name)}.json [${entry.strategy}]`));
console.log(chalk.dim(`${filePath}`));
console.log(styleText('green', ` ✓ Generated: ${styleText('bold', entry.name)}.json [${entry.strategy}]`));
console.log(styleText('dim', `${filePath}`));
}
if (candidates.length === 0) {
console.log(chalk.yellow(' No candidates found.'));
console.log(chalk.dim(' Tip: make sure you triggered JSON API calls (open lists, search, scroll).'));
console.log(styleText('yellow', ' No candidates found.'));
console.log(styleText('dim', ' Tip: make sure you triggered JSON API calls (open lists, search, scroll).'));
}
return {
@@ -774,7 +774,7 @@ function analyzeAndWrite(
export function renderRecordSummary(result: RecordResult): string {
const lines = [
`\n opencli record: ${result.candidateCount > 0 ? chalk.green('OK') : chalk.yellow('no candidates')}`,
`\n opencli record: ${result.candidateCount > 0 ? styleText('green', 'OK') : styleText('yellow', 'no candidates')}`,
` Site: ${result.site}`,
` Captured: ${result.requests.length} requests`,
` Candidates: ${result.candidateCount}`,
@@ -784,7 +784,7 @@ export function renderRecordSummary(result: RecordResult): string {
}
if (result.candidateCount > 0) {
lines.push('');
lines.push(chalk.dim(` Copy a candidate to clis/${result.site}/ and run: npm run build`));
lines.push(styleText('dim', ` Copy a candidate to clis/${result.site}/ and run: npm run build`));
}
return lines.join('\n');
}
+14 -14
View File
@@ -3,7 +3,7 @@
*
* Uses raw stdin mode + ANSI escape codes for interactive prompts.
*/
import chalk from 'chalk';
import { styleText } from 'node:util';
import { EXIT_CODES } from './errors.js';
export interface CheckboxItem {
@@ -43,11 +43,11 @@ export async function checkboxPrompt(
function colorStatus(status: string | undefined, color: CheckboxItem['statusColor']): string {
if (!status) return '';
switch (color) {
case 'green': return chalk.green(status);
case 'yellow': return chalk.yellow(status);
case 'red': return chalk.red(status);
case 'dim': return chalk.dim(status);
default: return chalk.dim(status);
case 'green': return styleText('green', status);
case 'yellow': return styleText('yellow', status);
case 'red': return styleText('red', status);
case 'dim': return styleText('dim', status);
default: return styleText('dim', status);
}
}
@@ -56,19 +56,19 @@ export async function checkboxPrompt(
let out = '';
if (opts.title) {
out += `\n${chalk.bold(opts.title)}\n\n`;
out += `\n${styleText('bold', opts.title)}\n\n`;
}
for (let i = 0; i < state.length; i++) {
const item = state[i];
const pointer = i === cursor ? chalk.cyan('') : ' ';
const checkbox = item.checked ? chalk.green('◉') : chalk.dim('○');
const label = i === cursor ? chalk.bold(item.label) : item.label;
const pointer = i === cursor ? styleText('cyan', '') : ' ';
const checkbox = item.checked ? styleText('green', '◉') : styleText('dim', '○');
const label = i === cursor ? styleText('bold', item.label) : item.label;
const status = colorStatus(item.status, item.statusColor);
out += ` ${pointer} ${checkbox} ${label}${status ? ` ${status}` : ''}\n`;
}
out += `\n ${chalk.dim('↑↓ navigate · Space toggle · a all · Enter confirm · q cancel')}\n`;
out += `\n ${styleText('dim', '↑↓ navigate · Space toggle · a all · Enter confirm · q cancel')}\n`;
return out;
}
@@ -146,7 +146,7 @@ export async function checkboxPrompt(
cleanup();
const selected = state.filter(i => i.checked).map(i => i.value);
// Show summary
stdout.write(` ${chalk.green('✓')} ${chalk.bold(`${selected.length} file(s) selected`)}\n\n`);
stdout.write(` ${styleText('green', '✓')} ${styleText('bold', `${selected.length} file(s) selected`)}\n\n`);
resolve(selected);
return;
}
@@ -154,7 +154,7 @@ export async function checkboxPrompt(
// q / Esc — cancel
if (key === 'q' || key === '\x1b') {
cleanup();
stdout.write(` ${chalk.yellow('✗')} ${chalk.dim('Cancelled')}\n\n`);
stdout.write(` ${styleText('yellow', '✗')} ${styleText('dim', 'Cancelled')}\n\n`);
resolve([]);
return;
}
@@ -185,7 +185,7 @@ export async function confirmPrompt(
if (!stdin.isTTY) return defaultYes;
const hint = defaultYes ? '[Y/n]' : '[y/N]';
stdout.write(` ${message} ${chalk.dim(hint)} `);
stdout.write(` ${message} ${styleText('dim', hint)} `);
return new Promise<boolean>((resolve) => {
const wasRaw = stdin.isRaw;
+3 -3
View File
@@ -12,7 +12,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import chalk from 'chalk';
import { styleText } from 'node:util';
import { PKG_VERSION } from './version.js';
const CACHE_DIR = path.join(os.homedir(), '.opencli');
@@ -76,8 +76,8 @@ export function registerUpdateNoticeOnExit(): void {
if (!isNewer(_cache.latestVersion, PKG_VERSION)) return;
try {
process.stderr.write(
chalk.yellow(`\n Update available: v${PKG_VERSION} → v${_cache.latestVersion}\n`) +
chalk.dim(` Run: npm install -g @jackwener/opencli\n\n`),
styleText('yellow', `\n Update available: v${PKG_VERSION} → v${_cache.latestVersion}\n`) +
styleText('dim', ` Run: npm install -g @jackwener/opencli\n\n`),
);
} catch {
// Ignore broken pipe (stderr closed before process exits)