From 1bd08e96444342353e18b96389eb4f978d87f72b Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 5 Apr 2026 18:41:02 +0800 Subject: [PATCH] refactor: centralize build path resolution --- TESTING.md | 4 +-- autoresearch/eval-save.ts | 2 +- docs/developer/testing.md | 4 +-- src/build-manifest.ts | 11 ++++---- src/cli.ts | 44 ++--------------------------- src/discovery.ts | 20 ++++---------- src/package-paths.ts | 58 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 76 insertions(+), 67 deletions(-) create mode 100644 src/package-paths.ts diff --git a/TESTING.md b/TESTING.md index e90e19ea2..b173c8963 100644 --- a/TESTING.md +++ b/TESTING.md @@ -94,7 +94,7 @@ find tests/smoke -name '*.test.ts' | sort ```bash npm ci # 安装依赖 -npm run build # 编译(E2E / smoke 测试需要 dist/main.js) +npm run build # 编译(E2E / smoke 测试需要 dist/src/main.js) ``` ### 运行命令 @@ -123,7 +123,7 @@ npx vitest src/ ### 浏览器命令本地测试须知 - opencli 通过 Browser Bridge 扩展连接已运行的 Chrome 浏览器 -- E2E 测试通过 `tests/e2e/helpers.ts` 里的 `runCli()` 调用已构建的 `dist/main.js` +- E2E 测试通过 `tests/e2e/helpers.ts` 里的 `runCli()` 调用已构建的 `dist/src/main.js` - `browser-public.test.ts` 使用 `tryBrowserCommand()`,站点反爬或地域限制导致空数据时会 warn + pass - `browser-auth.test.ts` 验证 **graceful failure**,重点是不 crash、不 hang、错误信息可控 - 如需测试完整登录态,保持 Chrome 登录态并安装 Browser Bridge 扩展,再手动运行对应测试 diff --git a/autoresearch/eval-save.ts b/autoresearch/eval-save.ts index acce88f6c..217bb25db 100644 --- a/autoresearch/eval-save.ts +++ b/autoresearch/eval-save.ts @@ -80,7 +80,7 @@ function judge(criteria: JudgeCriteria, output: string): boolean { const PROJECT_ROOT = join(__dirname, '..'); -/** Run a command, using local dist/main.js instead of global opencli for consistency */ +/** Run a command, using the local built entrypoint instead of global opencli for consistency */ function runCommand(cmd: string, timeout = 30000): string { // Use local build so tests always run against the current source const localCmd = cmd.replace(/^opencli /, `node dist/src/main.js `); diff --git a/docs/developer/testing.md b/docs/developer/testing.md index f4ed6529c..bc91cacfd 100644 --- a/docs/developer/testing.md +++ b/docs/developer/testing.md @@ -95,7 +95,7 @@ find tests/smoke -name '*.test.ts' | sort ```bash npm ci # 安装依赖 -npm run build # 编译(E2E / smoke 测试需要 dist/main.js) +npm run build # 编译(E2E / smoke 测试需要 dist/src/main.js) ``` ### 运行命令 @@ -127,7 +127,7 @@ npx vitest src/ ### 浏览器命令本地测试须知 - opencli 通过 Browser Bridge 扩展连接已运行的 Chrome 浏览器 -- E2E 测试通过 `tests/e2e/helpers.ts` 里的 `runCli()` 调用已构建的 `dist/main.js` +- E2E 测试通过 `tests/e2e/helpers.ts` 里的 `runCli()` 调用已构建的 `dist/src/main.js` - `browser-public.test.ts` 使用 `tryBrowserCommand()`,站点反爬或地域限制导致空数据时会 warn + pass - `browser-auth.test.ts` 验证 **graceful failure**,重点是不 crash、不 hang、错误信息可控 - 如需测试完整登录态,保持 Chrome 登录态并安装 Browser Bridge 扩展,再手动运行对应测试 diff --git a/src/build-manifest.ts b/src/build-manifest.ts index f50735206..857eea680 100644 --- a/src/build-manifest.ts +++ b/src/build-manifest.ts @@ -6,7 +6,7 @@ * manifest.json for instant cold-start registration (no runtime YAML parsing). * * Usage: npx tsx src/build-manifest.ts - * Output: dist/cli-manifest.json + * Output: cli-manifest.json at the package root */ import * as fs from 'node:fs'; @@ -15,10 +15,11 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import yaml from 'js-yaml'; import { getErrorMessage } from './errors.js'; import { fullName, getRegistry, type CliCommand } from './registry.js'; +import { findPackageRoot, getCliManifestPath } from './package-paths.js'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CLIS_DIR = path.resolve(__dirname, '..', 'clis'); -const OUTPUT = path.resolve(__dirname, '..', 'cli-manifest.json'); +const PACKAGE_ROOT = findPackageRoot(fileURLToPath(import.meta.url)); +const CLIS_DIR = path.join(PACKAGE_ROOT, 'clis'); +const OUTPUT = getCliManifestPath(CLIS_DIR); export interface ManifestEntry { site: string; @@ -254,7 +255,7 @@ async function main(): Promise { // entry-point loses its executable permission, causing "Permission denied". // See: https://github.com/jackwener/opencli/issues/446 if (process.platform !== 'win32') { - const projectRoot = path.resolve(__dirname, '..', '..'); + const projectRoot = PACKAGE_ROOT; const pkgPath = path.resolve(projectRoot, 'package.json'); try { const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); diff --git a/src/cli.ts b/src/cli.ts index 3dc884a59..93f7aec0f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,7 @@ import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { Command } from 'commander'; import chalk from 'chalk'; +import { findPackageRoot, getBuiltEntryCandidates } from './package-paths.js'; import { type CliCommand, fullName, getRegistry, strategyLabel } from './registry.js'; import { serializeCommand, formatArgSummary } from './serialization.js'; import { render as renderOutput } from './output.js'; @@ -1016,48 +1017,7 @@ export interface OperateVerifyInvocation { shell?: boolean; } -export function findPackageRoot(startFile: string, fileExists: (path: string) => boolean = fs.existsSync): string { - let dir = path.dirname(startFile); - - while (true) { - if (fileExists(path.join(dir, 'package.json'))) return dir; - const parent = path.dirname(dir); - if (parent === dir) { - throw new Error(`Could not find package.json above ${startFile}`); - } - dir = parent; - } -} - -function getBuiltEntryCandidates(packageRoot: string, readFile: (path: string) => string): string[] { - const candidates: string[] = []; - try { - const pkg = JSON.parse(readFile(path.join(packageRoot, 'package.json'))) as { - bin?: string | Record; - main?: string; - }; - - if (typeof pkg.bin === 'string') { - candidates.push(path.join(packageRoot, pkg.bin)); - } else if (pkg.bin && typeof pkg.bin === 'object' && typeof pkg.bin.opencli === 'string') { - candidates.push(path.join(packageRoot, pkg.bin.opencli)); - } - - if (typeof pkg.main === 'string') { - candidates.push(path.join(packageRoot, pkg.main)); - } - } catch { - // Fall through to compatibility candidates below. - } - - // Compatibility fallback for partially-built trees or older layouts. - candidates.push( - path.join(packageRoot, 'dist', 'src', 'main.js'), - path.join(packageRoot, 'dist', 'main.js'), - ); - - return [...new Set(candidates)]; -} +export { findPackageRoot }; export function resolveOperateVerifyInvocation(opts: { projectRoot?: string; diff --git a/src/discovery.ts b/src/discovery.ts index 81ae662aa..cf8bb5be7 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -17,6 +17,7 @@ import { type CliCommand, type InternalCliCommand, type Arg, Strategy, registerC import { getErrorMessage } from './errors.js'; import { log } from './logger.js'; import type { ManifestEntry } from './build-manifest.js'; +import { findPackageRoot, getCliManifestPath, getFetchAdaptersScriptPath } from './package-paths.js'; /** User runtime directory: ~/.opencli */ export const USER_OPENCLI_DIR = path.join(os.homedir(), '.opencli'); @@ -37,18 +38,7 @@ function parseStrategy(rawStrategy: string | undefined, fallback: Strategy = Str import { isRecord } from './utils.js'; -/** - * Find the package root (directory containing package.json). - * Dev: import.meta.url is in src/ → one level up. - * Prod: import.meta.url is in dist/src/ → two levels up. - */ -function findPackageRoot(): string { - let dir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); - if (!fs.existsSync(path.join(dir, 'package.json'))) { - dir = path.resolve(dir, '..'); - } - return dir; -} +const PACKAGE_ROOT = findPackageRoot(fileURLToPath(import.meta.url)); /** * Ensure ~/.opencli/node_modules/@jackwener/opencli symlink exists so that @@ -72,7 +62,7 @@ export async function ensureUserCliCompatShims(baseDir: string = USER_OPENCLI_DI } // Create node_modules/@jackwener/opencli symlink pointing to the installed package root. - const opencliRoot = findPackageRoot(); + const opencliRoot = PACKAGE_ROOT; const symlinkDir = path.join(baseDir, 'node_modules', '@jackwener'); const symlinkPath = path.join(symlinkDir, 'opencli'); try { @@ -118,7 +108,7 @@ export async function ensureUserAdapters(): Promise { log.info('First run detected — copying adapters (one-time setup)...'); try { const { execFileSync } = await import('node:child_process'); - const scriptPath = path.join(findPackageRoot(), 'scripts', 'fetch-adapters.js'); + const scriptPath = getFetchAdaptersScriptPath(PACKAGE_ROOT); execFileSync(process.execPath, [scriptPath], { stdio: 'inherit', env: { ...process.env, _OPENCLI_FIRST_RUN: '1' }, @@ -137,7 +127,7 @@ export async function ensureUserAdapters(): Promise { export async function discoverClis(...dirs: string[]): Promise { // Fast path: try manifest first (production / post-build) for (const dir of dirs) { - const manifestPath = path.resolve(dir, '..', 'cli-manifest.json'); + const manifestPath = getCliManifestPath(dir); try { await fs.promises.access(manifestPath); const loaded = await loadFromManifest(manifestPath, dir); diff --git a/src/package-paths.ts b/src/package-paths.ts new file mode 100644 index 000000000..9f7c5f022 --- /dev/null +++ b/src/package-paths.ts @@ -0,0 +1,58 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export interface PackageJsonLike { + bin?: string | Record; + main?: string; +} + +export function findPackageRoot(startFile: string, fileExists: (candidate: string) => boolean = fs.existsSync): string { + let dir = path.dirname(startFile); + + while (true) { + if (fileExists(path.join(dir, 'package.json'))) return dir; + const parent = path.dirname(dir); + if (parent === dir) { + throw new Error(`Could not find package.json above ${startFile}`); + } + dir = parent; + } +} + +export function getBuiltEntryCandidates( + packageRoot: string, + readFile: (filePath: string) => string = (filePath) => fs.readFileSync(filePath, 'utf-8'), +): string[] { + const candidates: string[] = []; + try { + const pkg = JSON.parse(readFile(path.join(packageRoot, 'package.json'))) as PackageJsonLike; + + if (typeof pkg.bin === 'string') { + candidates.push(path.join(packageRoot, pkg.bin)); + } else if (pkg.bin && typeof pkg.bin === 'object' && typeof pkg.bin.opencli === 'string') { + candidates.push(path.join(packageRoot, pkg.bin.opencli)); + } + + if (typeof pkg.main === 'string') { + candidates.push(path.join(packageRoot, pkg.main)); + } + } catch { + // Fall through to compatibility candidates below. + } + + // Compatibility fallback for partially-built trees or older layouts. + candidates.push( + path.join(packageRoot, 'dist', 'src', 'main.js'), + path.join(packageRoot, 'dist', 'main.js'), + ); + + return [...new Set(candidates)]; +} + +export function getCliManifestPath(clisDir: string): string { + return path.resolve(clisDir, '..', 'cli-manifest.json'); +} + +export function getFetchAdaptersScriptPath(packageRoot: string): string { + return path.join(packageRoot, 'scripts', 'fetch-adapters.js'); +}