Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fdcecb193e | |||
| fba150b9b3 | |||
| 3f49ef1870 | |||
| 4a59c7fc0c |
Generated
+11
-40
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user