From 289b2d8f8e3b6819f0d6756c09a02e1965f432c9 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 5 Apr 2026 17:02:34 +0800 Subject: [PATCH] fix: remove direct third-party imports from adapters (#792) Adapters in ~/.opencli/clis/ cannot resolve bare third-party packages (chalk, turndown) because Node.js module resolution walks up from the file location and never reaches the package's node_modules/. - Replace chalk with log from @jackwener/opencli/logger in 11 yollomi adapters - Re-export TurndownService from @jackwener/opencli/utils - Replace turndown import in yuanbao/ask.ts with @jackwener/opencli/utils - Add regression test: adapters must not import runtime dependencies directly Closes #792 --- clis/yollomi/background.ts | 4 ++-- clis/yollomi/edit.ts | 6 +++--- clis/yollomi/face-swap.ts | 4 ++-- clis/yollomi/generate.ts | 6 +++--- clis/yollomi/object-remover.ts | 4 ++-- clis/yollomi/remove-bg.ts | 4 ++-- clis/yollomi/restore.ts | 4 ++-- clis/yollomi/try-on.ts | 4 ++-- clis/yollomi/upload.ts | 6 +++--- clis/yollomi/upscale.ts | 6 +++--- clis/yollomi/video.ts | 6 +++--- clis/yuanbao/ask.ts | 2 +- src/package-exports.test.ts | 37 +++++++++++++++++++++++++++++++--- src/utils.ts | 7 +++++++ 14 files changed, 69 insertions(+), 31 deletions(-) diff --git a/clis/yollomi/background.ts b/clis/yollomi/background.ts index 07cd5234..c003f92d 100644 --- a/clis/yollomi/background.ts +++ b/clis/yollomi/background.ts @@ -3,9 +3,9 @@ */ import * as path from 'node:path'; -import chalk from 'chalk'; import { cli, Strategy } from '@jackwener/opencli/registry'; import { CliError } from '@jackwener/opencli/errors'; +import { log } from '@jackwener/opencli/logger'; import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js'; cli({ @@ -25,7 +25,7 @@ cli({ const imageUrl = kwargs.image as string; const prompt = kwargs.prompt as string; - process.stderr.write(chalk.dim('Generating background...\n')); + log.info('Generating background...'); const data = await yollomiPost(page, '/api/ai/ai-background-generator', { images: [imageUrl], prompt: prompt || undefined, diff --git a/clis/yollomi/edit.ts b/clis/yollomi/edit.ts index fb0bd1b2..22f058cb 100644 --- a/clis/yollomi/edit.ts +++ b/clis/yollomi/edit.ts @@ -4,9 +4,9 @@ */ import * as path from 'node:path'; -import chalk from 'chalk'; import { cli, Strategy } from '@jackwener/opencli/registry'; import { CliError } from '@jackwener/opencli/errors'; +import { log } from '@jackwener/opencli/logger'; import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js'; cli({ @@ -36,7 +36,7 @@ cli({ } const apiPath = modelId === 'qwen-image-edit-plus' ? '/api/ai/qwen-image-edit-plus' : '/api/ai/qwen-image-edit'; - process.stderr.write(chalk.dim(`Editing with ${modelId}...\n`)); + log.info(`Editing with ${modelId}...`); const data = await yollomiPost(page, apiPath, body); const images: string[] = data.images || (data.image ? [data.image] : []); @@ -49,7 +49,7 @@ cli({ try { const filename = `yollomi_edit_${Date.now()}.png`; const { path: fp, size } = await downloadOutput(url, kwargs.output as string, filename); - if (credits !== undefined) process.stderr.write(chalk.dim(`Credits remaining: ${credits}\n`)); + if (credits !== undefined) log.info(`Credits remaining: ${credits}`); return [{ status: 'saved', file: path.relative('.', fp), size: fmtBytes(size), credits: credits ?? '-', url }]; } catch { return [{ status: 'download-failed', file: '-', size: '-', credits: credits ?? '-', url }]; diff --git a/clis/yollomi/face-swap.ts b/clis/yollomi/face-swap.ts index f30242bb..86ccee2f 100644 --- a/clis/yollomi/face-swap.ts +++ b/clis/yollomi/face-swap.ts @@ -4,9 +4,9 @@ */ import * as path from 'node:path'; -import chalk from 'chalk'; import { cli, Strategy } from '@jackwener/opencli/registry'; import { CliError } from '@jackwener/opencli/errors'; +import { log } from '@jackwener/opencli/logger'; import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js'; cli({ @@ -23,7 +23,7 @@ cli({ ], columns: ['status', 'file', 'size', 'url'], func: async (page, kwargs) => { - process.stderr.write(chalk.dim('Swapping faces...\n')); + log.info('Swapping faces...'); const data = await yollomiPost(page, '/api/ai/face-swap', { swap_image: kwargs.source as string, input_image: kwargs.target as string, diff --git a/clis/yollomi/generate.ts b/clis/yollomi/generate.ts index 314aea35..43feea08 100644 --- a/clis/yollomi/generate.ts +++ b/clis/yollomi/generate.ts @@ -8,9 +8,9 @@ */ import * as path from 'node:path'; -import chalk from 'chalk'; import { cli, Strategy } from '@jackwener/opencli/registry'; import { CliError } from '@jackwener/opencli/errors'; +import { log } from '@jackwener/opencli/logger'; import { YOLLOMI_DOMAIN, yollomiPost, resolveImageInput, downloadOutput, fmtBytes, MODEL_ROUTES } from './utils.js'; function getDimensions(ratio: string): { width: number; height: number } { @@ -62,7 +62,7 @@ cli({ if (kwargs.image) body.imageUrl = kwargs.image as string; } - process.stderr.write(chalk.dim(`Generating with ${modelId}...\n`)); + log.info(`Generating with ${modelId}...`); const data = await yollomiPost(page, apiPath, body); const images: string[] = data.images || (data.image ? [data.image] : []); @@ -89,7 +89,7 @@ cli({ } } - if (data.remainingCredits !== undefined) process.stderr.write(chalk.dim(`Credits remaining: ${data.remainingCredits}\n`)); + if (data.remainingCredits !== undefined) log.info(`Credits remaining: ${data.remainingCredits}`); return results; }, }); diff --git a/clis/yollomi/object-remover.ts b/clis/yollomi/object-remover.ts index 771c0a29..f912adc8 100644 --- a/clis/yollomi/object-remover.ts +++ b/clis/yollomi/object-remover.ts @@ -3,9 +3,9 @@ */ import * as path from 'node:path'; -import chalk from 'chalk'; import { cli, Strategy } from '@jackwener/opencli/registry'; import { CliError } from '@jackwener/opencli/errors'; +import { log } from '@jackwener/opencli/logger'; import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js'; cli({ @@ -22,7 +22,7 @@ cli({ ], columns: ['status', 'file', 'size', 'url'], func: async (page, kwargs) => { - process.stderr.write(chalk.dim('Removing object...\n')); + log.info('Removing object...'); const data = await yollomiPost(page, '/api/ai/object-remover', { image: kwargs.image as string, mask: kwargs.mask as string, diff --git a/clis/yollomi/remove-bg.ts b/clis/yollomi/remove-bg.ts index 44076518..2f9228de 100644 --- a/clis/yollomi/remove-bg.ts +++ b/clis/yollomi/remove-bg.ts @@ -3,9 +3,9 @@ */ import * as path from 'node:path'; -import chalk from 'chalk'; import { cli, Strategy } from '@jackwener/opencli/registry'; import { CliError } from '@jackwener/opencli/errors'; +import { log } from '@jackwener/opencli/logger'; import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js'; cli({ @@ -21,7 +21,7 @@ cli({ ], columns: ['status', 'file', 'size', 'url'], func: async (page, kwargs) => { - process.stderr.write(chalk.dim('Removing background...\n')); + log.info('Removing background...'); const data = await yollomiPost(page, '/api/ai/remove-bg', { imageUrl: kwargs.image as string }); const url = data.image || (data.images?.[0]); diff --git a/clis/yollomi/restore.ts b/clis/yollomi/restore.ts index 44a1eddc..2fecae1b 100644 --- a/clis/yollomi/restore.ts +++ b/clis/yollomi/restore.ts @@ -3,9 +3,9 @@ */ import * as path from 'node:path'; -import chalk from 'chalk'; import { cli, Strategy } from '@jackwener/opencli/registry'; import { CliError } from '@jackwener/opencli/errors'; +import { log } from '@jackwener/opencli/logger'; import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js'; cli({ @@ -21,7 +21,7 @@ cli({ ], columns: ['status', 'file', 'size', 'url'], func: async (page, kwargs) => { - process.stderr.write(chalk.dim('Restoring photo...\n')); + log.info('Restoring photo...'); const data = await yollomiPost(page, '/api/ai/photo-restoration', { imageUrl: kwargs.image as string }); const url = data.image || (data.images?.[0]); diff --git a/clis/yollomi/try-on.ts b/clis/yollomi/try-on.ts index d46ae2bf..caa71324 100644 --- a/clis/yollomi/try-on.ts +++ b/clis/yollomi/try-on.ts @@ -3,9 +3,9 @@ */ import * as path from 'node:path'; -import chalk from 'chalk'; import { cli, Strategy } from '@jackwener/opencli/registry'; import { CliError } from '@jackwener/opencli/errors'; +import { log } from '@jackwener/opencli/logger'; import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js'; cli({ @@ -23,7 +23,7 @@ cli({ ], columns: ['status', 'file', 'size', 'url'], func: async (page, kwargs) => { - process.stderr.write(chalk.dim('Processing virtual try-on...\n')); + log.info('Processing virtual try-on...'); const data = await yollomiPost(page, '/api/ai/virtual-try-on', { person_image: kwargs.person as string, cloth_image: kwargs.cloth as string, diff --git a/clis/yollomi/upload.ts b/clis/yollomi/upload.ts index 42f76b73..14750ff4 100644 --- a/clis/yollomi/upload.ts +++ b/clis/yollomi/upload.ts @@ -7,9 +7,9 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import chalk from 'chalk'; import { cli, Strategy } from '@jackwener/opencli/registry'; import { CliError } from '@jackwener/opencli/errors'; +import { log } from '@jackwener/opencli/logger'; import { YOLLOMI_DOMAIN, ensureOnYollomi, fmtBytes } from './utils.js'; const MIME_MAP: Record = { @@ -46,7 +46,7 @@ cli({ const b64 = data.toString('base64'); const fileName = path.basename(filePath); - process.stderr.write(chalk.dim(`Uploading ${fileName} (${fmtBytes(data.length)})...\n`)); + log.info(`Uploading ${fileName} (${fmtBytes(data.length)})...`); await ensureOnYollomi(page); const result = await page.evaluate(` @@ -72,7 +72,7 @@ cli({ } const url = result.data.url; - process.stderr.write(chalk.green(`Uploaded! Use this URL as input for other commands.\n`)); + log.info('Uploaded! Use this URL as input for other commands.'); return [{ status: 'uploaded', file: fileName, size: fmtBytes(data.length), url }]; }, }); diff --git a/clis/yollomi/upscale.ts b/clis/yollomi/upscale.ts index cfcf3401..3ddf44db 100644 --- a/clis/yollomi/upscale.ts +++ b/clis/yollomi/upscale.ts @@ -3,9 +3,9 @@ */ import * as path from 'node:path'; -import chalk from 'chalk'; import { cli, Strategy } from '@jackwener/opencli/registry'; import { CliError } from '@jackwener/opencli/errors'; +import { log } from '@jackwener/opencli/logger'; import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js'; cli({ @@ -23,7 +23,7 @@ cli({ columns: ['status', 'file', 'size', 'scale', 'url'], func: async (page, kwargs) => { const scale = parseInt(kwargs.scale as string, 10); - process.stderr.write(chalk.dim(`Upscaling ${scale}x...\n`)); + log.info(`Upscaling ${scale}x...`); const data = await yollomiPost(page, '/api/ai/image-upscaler', { imageUrl: kwargs.image as string, scale, @@ -40,7 +40,7 @@ cli({ const ext = urlPath.endsWith('.png') || urlPath.endsWith('.webp') ? urlPath.slice(urlPath.lastIndexOf('.')) : '.jpg'; const filename = `yollomi_upscale_${scale}x_${Date.now()}${ext}`; const { path: fp, size } = await downloadOutput(url, kwargs.output as string, filename); - if (data.remainingCredits !== undefined) process.stderr.write(chalk.dim(`Credits remaining: ${data.remainingCredits}\n`)); + if (data.remainingCredits !== undefined) log.info(`Credits remaining: ${data.remainingCredits}`); return [{ status: 'saved', file: path.relative('.', fp), size: fmtBytes(size), scale: `${scale}x`, url }]; } catch { return [{ status: 'download-failed', file: '-', size: '-', scale: `${scale}x`, url }]; diff --git a/clis/yollomi/video.ts b/clis/yollomi/video.ts index 177d3da8..325e2412 100644 --- a/clis/yollomi/video.ts +++ b/clis/yollomi/video.ts @@ -4,9 +4,9 @@ */ import * as path from 'node:path'; -import chalk from 'chalk'; import { cli, Strategy } from '@jackwener/opencli/registry'; import { CliError } from '@jackwener/opencli/errors'; +import { log } from '@jackwener/opencli/logger'; import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js'; cli({ @@ -35,7 +35,7 @@ cli({ const body = { modelId, prompt, inputs }; - process.stderr.write(chalk.dim(`Generating video with ${modelId} (may take a while)...\n`)); + log.info(`Generating video with ${modelId} (may take a while)...`); const data = await yollomiPost(page, '/api/ai/video', body); const videoUrl: string = data.video || ''; @@ -52,7 +52,7 @@ cli({ try { const filename = `yollomi_${modelId}_${Date.now()}.mp4`; const { path: fp, size } = await downloadOutput(videoUrl, outputDir, filename); - if (credits !== undefined) process.stderr.write(chalk.dim(`Credits remaining: ${credits}\n`)); + if (credits !== undefined) log.info(`Credits remaining: ${credits}`); return [{ status: 'saved', file: path.relative('.', fp), size: fmtBytes(size), credits: credits ?? '-', url: videoUrl }]; } catch { return [{ status: 'download-failed', file: '-', size: '-', credits: credits ?? '-', url: videoUrl }]; diff --git a/clis/yuanbao/ask.ts b/clis/yuanbao/ask.ts index 0e433d2d..580674da 100644 --- a/clis/yuanbao/ask.ts +++ b/clis/yuanbao/ask.ts @@ -1,6 +1,6 @@ import { cli, Strategy } from '@jackwener/opencli/registry'; import type { IPage } from '@jackwener/opencli/types'; -import TurndownService from 'turndown'; +import { TurndownService } from '@jackwener/opencli/utils'; import { CommandExecutionError, TimeoutError } from '@jackwener/opencli/errors'; import { YUANBAO_DOMAIN, YUANBAO_URL, IS_VISIBLE_JS, authRequired, isOnYuanbao, ensureYuanbaoPage, hasLoginGate } from './shared.js'; diff --git a/src/package-exports.test.ts b/src/package-exports.test.ts index ad703f51..8e3505d8 100644 --- a/src/package-exports.test.ts +++ b/src/package-exports.test.ts @@ -14,14 +14,15 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); const CLIS_DIR = path.join(ROOT, 'clis'); -/** Recursively collect all .ts files in a directory. */ -function collectTsFiles(dir: string): string[] { +/** Recursively collect .ts files in a directory, optionally excluding test files. */ +function collectTsFiles(dir: string, opts?: { excludeTests?: boolean }): string[] { const results: string[] = []; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { - results.push(...collectTsFiles(full)); + results.push(...collectTsFiles(full, opts)); } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) { + if (opts?.excludeTests && entry.name.endsWith('.test.ts')) continue; results.push(full); } } @@ -61,6 +62,36 @@ describe('adapter imports use package exports', () => { }); }); +describe('adapters do not import third-party packages directly', () => { + const pkgJson = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8')); + const deps = Object.keys(pkgJson.dependencies ?? {}); + // Build a pattern that matches: from 'chalk' / from "turndown" etc. + // Excludes node: builtins and relative/package imports. + const depPattern = new RegExp( + `(?:from|mock|importActual)\\s*\\(?['"](?:${deps.map(d => d.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})['"]`, + ); + + // Only check non-test adapter files (test files run inside the package tree) + const nonTestFiles = collectTsFiles(CLIS_DIR, { excludeTests: true }); + + it('found non-test adapter files to check', () => { + expect(nonTestFiles.length).toBeGreaterThan(100); + }); + + it('no adapter directly imports opencli runtime dependencies', () => { + const violations: string[] = []; + for (const file of nonTestFiles) { + const content = fs.readFileSync(file, 'utf-8'); + if (depPattern.test(content)) { + const rel = path.relative(ROOT, file); + const match = content.match(depPattern)?.[0]; + violations.push(`${rel}: ${match}`); + } + } + expect(violations).toEqual([]); + }); +}); + describe('package.json exports resolve to real files', () => { const pkgJson = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8')); const exports = pkgJson.exports as Record; diff --git a/src/utils.ts b/src/utils.ts index 51b08474..09b7ca37 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,9 +1,16 @@ /** * Shared utility functions used across the codebase. + * + * Adapters should import from '@jackwener/opencli/utils' instead of + * depending on third-party packages directly, so they work correctly + * when loaded from ~/.opencli/clis/. */ import * as fs from 'node:fs'; import * as path from 'node:path'; +import TurndownService from 'turndown'; + +export { TurndownService }; /** Type guard: checks if a value is a non-null, non-array object. */ export function isRecord(value: unknown): value is Record {