From 7869bdb2ca159495a9608b282a1b319ccdb15bf4 Mon Sep 17 00:00:00 2001 From: jakevin Date: Sun, 3 May 2026 19:03:38 +0800 Subject: [PATCH] feat(browser): polish adapter author verify workflow --- README.md | 6 +-- README.zh-CN.md | 6 +-- docs/developer/ai-workflow.md | 10 ++-- docs/guide/extending-opencli.md | 16 +++++- docs/zh/guide/extending-opencli.md | 16 +++++- src/adapter-shadow.test.ts | 53 ++++++++++++++++++ src/adapter-shadow.ts | 87 ++++++++++++++++++++++++++++++ src/browser/verify-fixture.test.ts | 21 +++++++- src/browser/verify-fixture.ts | 15 ++++++ src/cli.test.ts | 56 +++++++++++++++++++ src/cli.ts | 19 ++++--- src/doctor.test.ts | 41 +++++++++++++- src/doctor.ts | 8 +++ 13 files changed, 330 insertions(+), 24 deletions(-) create mode 100644 src/adapter-shadow.test.ts create mode 100644 src/adapter-shadow.ts diff --git a/README.md b/README.md index 95270489..7e5c1be6 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ If you want to add your own commands, start with the [Extending OpenCLI guide](. |------|------------------| | Keep personal website commands in your own Git repo | `opencli plugin create` + `opencli plugin install file://...` | | Quickly draft a private local adapter | `opencli browser init /` in `~/.opencli/clis/` | -| Modify an official adapter locally | `opencli adapter eject/status/reset` | +| Modify an official adapter locally | `opencli adapter eject ` + `opencli adapter reset ` | | Publish or install third-party commands | `opencli plugin install github:user/repo` | | Wrap an existing local binary | `opencli external register ` | @@ -174,7 +174,7 @@ When the site you need is not yet covered, use the `opencli-adapter-author` skil 2. Discover the right endpoint — network inspection, initial state, bundle search, token trace, or interceptor fallback. 3. Decide the auth strategy — `PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`. 4. Decode response fields and design output columns. -5. `opencli browser init /` → write adapter → `opencli browser verify /`. +5. `opencli browser analyze ` for one-shot recon, then `opencli browser init /` → write adapter → `opencli browser verify /`. 6. Persist site knowledge to `~/.opencli/sites//` so the next adapter for the same site is faster. ### CLI Hub and desktop adapters @@ -407,7 +407,7 @@ Before writing any adapter code, read the [`opencli-adapter-author` skill](./ski - Recon the site and pick a pattern (SPA / SSR / JSONP / Token / Streaming). - Discover the right endpoint via `opencli browser network`, `eval`, or the interceptor fallback. - Decide auth strategy (`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`). -- Decode response fields, design columns, scaffold with `opencli browser init`. +- Run `opencli browser analyze ` for one-shot recon, decode response fields, design columns, scaffold with `opencli browser init`. - Verify with `opencli browser verify /` before shipping. For long-lived personal commands that should live in your own Git repo, use a local plugin instead; see [Extending OpenCLI](./docs/guide/extending-opencli.md). Quick private adapters can still live at `~/.opencli/clis//.js`. Site knowledge (endpoints, field maps, fixtures) accumulates in `~/.opencli/sites//` so the next adapter for the same site starts from context instead of zero. diff --git a/README.zh-CN.md b/README.zh-CN.md index 844bf2d4..facd3081 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -81,7 +81,7 @@ opencli bilibili hot --limit 5 |------|----------| | 把个人网站命令放在自己的 Git repo | `opencli plugin create` + `opencli plugin install file://...` | | 快速写一个本机私人 adapter | `opencli browser init /`,放在 `~/.opencli/clis/` | -| 本地修改官方 adapter | `opencli adapter eject/status/reset` | +| 本地修改官方 adapter | `opencli adapter eject ` + `opencli adapter reset ` | | 发布或安装第三方命令 | `opencli plugin install github:user/repo` | | 包装已有本机 binary | `opencli external register ` | @@ -158,7 +158,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自 2. 发现目标 endpoint——network 精读、initial state、bundle 搜索、token 溯源,或 interceptor 兜底 3. 定认证策略——`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT` 4. 字段解码 + 设计输出列 -5. `opencli browser init /` → 写适配器 → `opencli browser verify /` +5. `opencli browser analyze ` 一步侦察,再 `opencli browser init /` → 写适配器 → `opencli browser verify /` 6. 把站点知识沉到 `~/.opencli/sites//`,下次写同站点的其他命令直接吃缓存 ### CLI 枢纽与桌面端适配器 @@ -505,7 +505,7 @@ opencli plugin uninstall my-tool # 卸载 - 侦察站点,选定 pattern(SPA / SSR / JSONP / Token / Streaming) - 用 `opencli browser network`、`eval`、interceptor 等找到目标 endpoint - 定认证策略(`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`) -- 字段解码、设计 columns、`opencli browser init` 生成骨架 +- 先用 `opencli browser analyze ` 一步侦察,再字段解码、设计 columns、`opencli browser init` 生成骨架 - 交付前用 `opencli browser verify /` 验证 在仓库外写的私有适配器放到 `~/.opencli/clis//.js`;每个站点的 endpoint、字段映射、抓包样本会累积在 `~/.opencli/sites//`,下次写同站点的其他命令可以直接复用。 diff --git a/docs/developer/ai-workflow.md b/docs/developer/ai-workflow.md index 4426b235..fa051f8b 100644 --- a/docs/developer/ai-workflow.md +++ b/docs/developer/ai-workflow.md @@ -11,10 +11,11 @@ From a new site URL to a passing `opencli browser verify` — one skill, one set # skills/opencli-adapter-author/SKILL.md # 2. Reconnaissance -opencli browser open https://example.com -opencli browser wait time 3 -opencli browser network # inspect XHR / fetch calls -opencli browser state # extract __INITIAL_STATE__ / __NEXT_DATA__ +opencli browser analyze https://example.com +# Fallback primitives when analyze says deeper inspection is needed: +# opencli browser open https://example.com +# opencli browser network # inspect XHR / fetch calls +# opencli browser state # extract __INITIAL_STATE__ / __NEXT_DATA__ # 3. Scaffold + verify opencli browser init / @@ -30,6 +31,7 @@ See [skills/opencli-adapter-author/SKILL.md](https://github.com/jackwener/opencl | Command | Purpose | |---------|---------| | `opencli doctor` | Sanity check: bridge, Chrome, signals | +| `opencli browser analyze ` | One-shot site recon: anti-bot, pattern, nearest adapter, next step | | `opencli browser open ` | Open a tab in the Chrome session | | `opencli browser network` | List recent XHR / fetch calls | | `opencli browser state` | Page state: URL, title, interactive elements | diff --git a/docs/guide/extending-opencli.md b/docs/guide/extending-opencli.md index 831d838a..0c6ce7bf 100644 --- a/docs/guide/extending-opencli.md +++ b/docs/guide/extending-opencli.md @@ -45,18 +45,30 @@ User adapters are loaded from: This path is convenient for quick local automation. For code you want to version, review, or share, prefer a plugin. +If the command takes required positional args and no fixture exists yet, seed the first verify run explicitly: + +```bash +opencli browser verify instagram/collection-create --write-fixture --seed-args opencli-verify +opencli browser verify example/detail --write-fixture --seed-args '["https://example.com/item/1", "--limit", 3]' +``` + +`--seed-args` is only used when the fixture has no `args`. Once the fixture is written, `opencli browser verify` reads args from `~/.opencli/sites//verify/.json`. + ## Local overrides for official adapters Use `adapter eject` when you want to customize an existing official adapter. ```bash opencli adapter eject twitter -opencli adapter status # edit ~/.opencli/clis/twitter/*.js opencli adapter reset twitter ``` -Ejected adapters override the packaged official adapter on this machine. `adapter reset` removes the local override and returns to the packaged version. +Files in `~/.opencli/clis//.js` override packaged adapters with the same `site/command` on this machine. `opencli browser verify /` also runs the local override, so a passing local verify does not prove that the packaged adapter was changed. + +The packaged `cli-manifest.json` only describes bundled adapters. User adapters are discovered at runtime and do not need manifest entries. + +After copying a local fix into the repository for a PR, remove the local copy or run `opencli adapter reset ` after merge. Otherwise the local file keeps shadowing future package updates. `opencli doctor` warns when it detects this shadowing. ## Plugins for sharing commands diff --git a/docs/zh/guide/extending-opencli.md b/docs/zh/guide/extending-opencli.md index 4f10eb63..ec756ce2 100644 --- a/docs/zh/guide/extending-opencli.md +++ b/docs/zh/guide/extending-opencli.md @@ -45,18 +45,30 @@ User adapter 加载路径是: 这条路径适合快速本地自动化。需要版本管理、review、共享的代码推荐做成 plugin。 +如果命令有 required positional args,而且 fixture 还没创建,第一次 verify 时直接传 seed: + +```bash +opencli browser verify instagram/collection-create --write-fixture --seed-args opencli-verify +opencli browser verify example/detail --write-fixture --seed-args '["https://example.com/item/1", "--limit", 3]' +``` + +`--seed-args` 只在 fixture 没有 `args` 时生效。fixture 写出后,`opencli browser verify` 会从 `~/.opencli/sites//verify/.json` 读取 args。 + ## 本地覆盖官方 adapter 如果你想改一个已有官方 adapter,用 `adapter eject`。 ```bash opencli adapter eject twitter -opencli adapter status # edit ~/.opencli/clis/twitter/*.js opencli adapter reset twitter ``` -Ejected adapter 会在本机覆盖 package 里的官方 adapter。`adapter reset` 会移除本地覆盖,恢复到 package 版本。 +`~/.opencli/clis//.js` 会在本机覆盖同名 package adapter。`opencli browser verify /` 也会跑本地覆盖版本,所以本地 verify 通过不代表 package 里的 adapter 已经改好。 + +Package 里的 `cli-manifest.json` 只描述 bundled adapter。User adapter 是运行时发现的,不需要写 manifest。 + +把本地修复复制到仓库发 PR 后,merge 后要删除本地副本,或运行 `opencli adapter reset `。否则本地文件会继续 shadow 后续 package 更新。`opencli doctor` 会在发现这种 shadowing 时给出 warning。 ## Plugin:共享命令 diff --git a/src/adapter-shadow.test.ts b/src/adapter-shadow.test.ts new file mode 100644 index 00000000..344117e2 --- /dev/null +++ b/src/adapter-shadow.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { findShadowedUserAdapters, formatAdapterShadowIssue } from './adapter-shadow.js'; + +describe('adapter shadow detection', () => { + it('reports user adapters that shadow packaged manifest commands', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-adapter-shadow-')); + try { + const userClisDir = path.join(root, 'user-clis'); + const builtinRoot = path.join(root, 'pkg'); + const builtinClisDir = path.join(builtinRoot, 'clis'); + fs.mkdirSync(path.join(userClisDir, 'instagram'), { recursive: true }); + fs.mkdirSync(path.join(userClisDir, 'twitter'), { recursive: true }); + fs.mkdirSync(path.join(builtinClisDir, 'instagram'), { recursive: true }); + fs.mkdirSync(path.join(builtinClisDir, 'twitter'), { recursive: true }); + + fs.writeFileSync(path.join(userClisDir, 'instagram', 'saved.js'), '', 'utf-8'); + fs.writeFileSync(path.join(userClisDir, 'instagram', 'utils.js'), '', 'utf-8'); + fs.writeFileSync(path.join(userClisDir, 'twitter', 'search.js'), '', 'utf-8'); + fs.writeFileSync(path.join(builtinClisDir, 'instagram', 'saved.js'), '', 'utf-8'); + fs.writeFileSync(path.join(builtinClisDir, 'instagram', 'utils.js'), '', 'utf-8'); + fs.writeFileSync(path.join(builtinClisDir, 'twitter', 'search.js'), '', 'utf-8'); + fs.writeFileSync(path.join(builtinRoot, 'cli-manifest.json'), `${JSON.stringify([ + { site: 'instagram', name: 'saved', sourceFile: 'instagram/saved.js' }, + ])}\n`, 'utf-8'); + + expect(findShadowedUserAdapters({ userClisDir, builtinClisDir })).toEqual([ + { + name: 'instagram/saved', + userPath: path.join(userClisDir, 'instagram', 'saved.js'), + builtinPath: path.join(builtinClisDir, 'instagram', 'saved.js'), + }, + ]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('formats a concise doctor issue', () => { + const issue = formatAdapterShadowIssue([ + { + name: 'instagram/saved', + userPath: '/home/me/.opencli/clis/instagram/saved.js', + builtinPath: '/pkg/clis/instagram/saved.js', + }, + ]); + + expect(issue).toContain('instagram/saved'); + expect(issue).toContain('opencli adapter reset '); + }); +}); diff --git a/src/adapter-shadow.ts b/src/adapter-shadow.ts new file mode 100644 index 00000000..aecf5101 --- /dev/null +++ b/src/adapter-shadow.ts @@ -0,0 +1,87 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { ManifestEntry } from './manifest-types.js'; +import { findPackageRoot, getCliManifestPath } from './package-paths.js'; + +export type AdapterShadow = { + name: string; + userPath: string; + builtinPath: string; +}; + +export type AdapterShadowOptions = { + userClisDir?: string; + builtinClisDir?: string; +}; + +function defaultBuiltinClisDir(): string { + return path.join(findPackageRoot(fileURLToPath(import.meta.url)), 'clis'); +} + +function safeReaddir(dir: string): fs.Dirent[] { + try { + return fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } +} + +function loadBuiltinCommandFiles(builtinClisDir: string): Set { + try { + const raw = fs.readFileSync(getCliManifestPath(builtinClisDir), 'utf-8'); + const entries = JSON.parse(raw) as ManifestEntry[]; + const files = new Set(); + for (const entry of entries) { + const rel = entry.sourceFile ?? entry.modulePath; + if (rel) files.add(path.resolve(builtinClisDir, rel)); + } + return files; + } catch { + return new Set(); + } +} + +export function findShadowedUserAdapters(opts: AdapterShadowOptions = {}): AdapterShadow[] { + const userClisDir = opts.userClisDir ?? path.join(os.homedir(), '.opencli', 'clis'); + const builtinClisDir = opts.builtinClisDir ?? defaultBuiltinClisDir(); + const builtinCommandFiles = loadBuiltinCommandFiles(builtinClisDir); + const shadows: AdapterShadow[] = []; + + for (const siteEntry of safeReaddir(userClisDir)) { + if (!siteEntry.isDirectory()) continue; + const site = siteEntry.name; + const userSiteDir = path.join(userClisDir, site); + const builtinSiteDir = path.join(builtinClisDir, site); + + for (const commandEntry of safeReaddir(userSiteDir)) { + if (!commandEntry.isFile() || !commandEntry.name.endsWith('.js')) continue; + const userPath = path.join(userSiteDir, commandEntry.name); + const builtinPath = path.join(builtinSiteDir, commandEntry.name); + const builtinResolved = path.resolve(builtinPath); + if (!builtinCommandFiles.has(builtinResolved)) continue; + + shadows.push({ + name: `${site}/${commandEntry.name.replace(/\.js$/, '')}`, + userPath, + builtinPath, + }); + } + } + + return shadows.sort((a, b) => a.name.localeCompare(b.name)); +} + +export function formatAdapterShadowIssue(shadows: AdapterShadow[]): string { + const visible = shadows.slice(0, 10); + const lines = ['Local adapter overrides shadow packaged adapters:']; + for (const shadow of visible) { + lines.push(` ${shadow.name}: ${shadow.userPath} overrides ${shadow.builtinPath}`); + } + if (shadows.length > visible.length) { + lines.push(` ... and ${shadows.length - visible.length} more`); + } + lines.push('Remove the local ~/.opencli/clis copy, or run opencli adapter reset , when you want packaged updates.'); + return lines.join('\n'); +} diff --git a/src/browser/verify-fixture.test.ts b/src/browser/verify-fixture.test.ts index 0da41718..3f28ca7b 100644 --- a/src/browser/verify-fixture.test.ts +++ b/src/browser/verify-fixture.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { deriveFixture, expandFixtureArgs, validateRows, type Fixture } from './verify-fixture.js'; +import { deriveFixture, expandFixtureArgs, parseSeedArgs, validateRows, type Fixture } from './verify-fixture.js'; describe('validateRows', () => { it('passes when rows meet all expectations', () => { @@ -221,3 +221,22 @@ describe('expandFixtureArgs', () => { ]); }); }); + +describe('parseSeedArgs', () => { + it('treats plain text as one positional arg', () => { + expect(parseSeedArgs('opencli-verify')).toEqual(['opencli-verify']); + }); + + it('accepts JSON array seed args', () => { + expect(parseSeedArgs('["subject", "--limit", 3]')).toEqual(['subject', '--limit', 3]); + }); + + it('accepts JSON object seed args', () => { + expect(parseSeedArgs('{"limit":3,"sort":"hot"}')).toEqual({ limit: 3, sort: 'hot' }); + }); + + it('ignores empty input', () => { + expect(parseSeedArgs(undefined)).toBeUndefined(); + expect(parseSeedArgs(' ')).toBeUndefined(); + }); +}); diff --git a/src/browser/verify-fixture.ts b/src/browser/verify-fixture.ts index a0bfc19b..80509559 100644 --- a/src/browser/verify-fixture.ts +++ b/src/browser/verify-fixture.ts @@ -239,6 +239,21 @@ export function expandFixtureArgs(args: FixtureArgs | undefined): string[] { return out; } +export function parseSeedArgs(raw: string | undefined): FixtureArgs | undefined { + if (raw === undefined) return undefined; + const trimmed = raw.trim(); + if (!trimmed) return undefined; + + try { + const parsed = JSON.parse(trimmed) as unknown; + if (Array.isArray(parsed)) return parsed; + if (parsed !== null && typeof parsed === 'object') return parsed as Record; + return [parsed]; + } catch { + return [raw]; + } +} + function jsType(v: unknown): string { if (v === null) return 'null'; if (Array.isArray(v)) return 'array'; diff --git a/src/cli.test.ts b/src/cli.test.ts index efda4005..1dd90bd3 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -174,6 +174,62 @@ describe('browser verify', () => { fs.rmSync(fakeHome, { recursive: true, force: true }); } }); + + it('uses --seed-args when no fixture args exist', async () => { + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browser-verify-seed-')); + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + + try { + const adapterDir = path.join(fakeHome, '.opencli', 'clis', 'hn'); + fs.mkdirSync(adapterDir, { recursive: true }); + fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8'); + + const program = createProgram('', ''); + await program.parseAsync(['node', 'opencli', 'browser', 'verify', 'hn/top', '--no-fixture', '--seed-args', 'opencli-verify']); + + expect(mockExecFileSync).toHaveBeenCalledTimes(1); + const [, execArgs] = mockExecFileSync.mock.calls[0] as [string, string[]]; + expect(execArgs.slice(-5)).toEqual(['hn', 'top', 'opencli-verify', '--format', 'json']); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + fs.rmSync(fakeHome, { recursive: true, force: true }); + } + }); + + it('writes --seed-args into a starter fixture', async () => { + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browser-verify-write-seed-')); + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + mockExecFileSync.mockReturnValue(JSON.stringify([{ title: 'ok' }])); + + try { + const adapterDir = path.join(fakeHome, '.opencli', 'clis', 'hn'); + fs.mkdirSync(adapterDir, { recursive: true }); + fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8'); + + const program = createProgram('', ''); + await program.parseAsync(['node', 'opencli', 'browser', 'verify', 'hn/top', '--write-fixture', '--seed-args', 'opencli-verify']); + + const fixtureFile = path.join(fakeHome, '.opencli', 'sites', 'hn', 'verify', 'top.json'); + const fixture = JSON.parse(fs.readFileSync(fixtureFile, 'utf-8')); + expect(fixture.args).toEqual(['opencli-verify']); + expect(fixture.expect.columns).toEqual(['title']); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + fs.rmSync(fakeHome, { recursive: true, force: true }); + } + }); }); describe('profile list', () => { diff --git a/src/cli.ts b/src/cli.ts index 266b3682..8fac11c6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1892,6 +1892,7 @@ cli({ fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(filePath, template, 'utf-8'); console.log(`Created: ${filePath}`); + console.log('First time on this site? Run: opencli browser analyze '); console.log(`Edit the file to implement your adapter, then run: opencli browser verify ${name}`); } catch (err) { console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); @@ -1907,9 +1908,10 @@ cli({ .option('--update-fixture', 'Overwrite an existing fixture with one derived from current output') .option('--no-fixture', 'Ignore any fixture file for this run (no value-level validation)') .option('--strict-memory', 'Fail (not just warn) when ~/.opencli/sites//endpoints.json or notes.md is missing') + .option('--seed-args ', 'Seed args when no fixture exists; use JSON array/object for multiple args or flags') .option('--trace ', 'Trace capture for the adapter subprocess: off, on, retain-on-failure', 'off') .description('Execute an adapter and validate output; uses fixture at ~/.opencli/sites//verify/.json when present') - .action(async (name: string, opts: { fixture?: boolean; writeFixture?: boolean; updateFixture?: boolean; strictMemory?: boolean; trace?: string } = {}) => { + .action(async (name: string, opts: { fixture?: boolean; writeFixture?: boolean; updateFixture?: boolean; strictMemory?: boolean; seedArgs?: string; trace?: string } = {}) => { try { const parts = name.split('/'); if (parts.length !== 2) { console.error('Name must be site/command format'); process.exitCode = EXIT_CODES.USAGE_ERROR; return; } @@ -1921,7 +1923,7 @@ cli({ } const { execFileSync } = await import('node:child_process'); - const { loadFixture, writeFixture, deriveFixture, validateRows, fixturePath, expandFixtureArgs } = await import('./browser/verify-fixture.js'); + const { loadFixture, writeFixture, deriveFixture, validateRows, fixturePath, expandFixtureArgs, parseSeedArgs } = await import('./browser/verify-fixture.js'); const filePath = path.join(os.homedir(), '.opencli', 'clis', site, `${command}.js`); if (!fs.existsSync(filePath)) { console.error(`Adapter not found: ${filePath}`); @@ -1941,9 +1943,10 @@ cli({ // - array form ["123", "--limit", "3"] → verbatim (for positional subjects) const adapterSrc = fs.readFileSync(filePath, 'utf-8'); const hasLimitArg = /['"]limit['"]/.test(adapterSrc); - const fixtureArgs = fixture?.args; - const cliArgs: string[] = expandFixtureArgs(fixtureArgs); - if (cliArgs.length === 0 && hasLimitArg) cliArgs.push('--limit', '3'); + const seedArgs = parseSeedArgs(opts.seedArgs); + const explicitArgs = fixture?.args ?? seedArgs; + const cliArgs: string[] = expandFixtureArgs(explicitArgs); + if (explicitArgs === undefined && cliArgs.length === 0 && hasLimitArg) cliArgs.push('--limit', '3'); const traceArgs = opts.trace && opts.trace !== 'off' ? ['--trace', opts.trace] : []; const argDisplay = [...cliArgs, ...traceArgs].join(' '); @@ -1993,10 +1996,10 @@ cli({ console.log(`\n Fixture already exists at ${fixturePath(site, command)}.`); console.log(` Use --update-fixture to overwrite.`); } else { - const seedArgs = fixtureArgs !== undefined - ? fixtureArgs + const fixtureArgs = explicitArgs !== undefined + ? explicitArgs : (hasLimitArg ? { limit: 3 } : undefined); - const derived = deriveFixture(rows, seedArgs); + const derived = deriveFixture(rows, fixtureArgs); const p = writeFixture(site, command, derived); console.log(`\n ${fixture ? '↻ Updated' : '✎ Wrote'} fixture: ${p}`); console.log(` Review and hand-tune the derived expectations (add patterns / notEmpty, tighten rowCount).`); diff --git a/src/doctor.test.ts b/src/doctor.test.ts index a1359c33..69d59341 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -1,10 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { mockGetDaemonHealth, mockListSessions, mockConnect, mockClose } = vi.hoisted(() => ({ +const { mockGetDaemonHealth, mockListSessions, mockConnect, mockClose, mockFindShadowedUserAdapters } = vi.hoisted(() => ({ mockGetDaemonHealth: vi.fn(), mockListSessions: vi.fn(), mockConnect: vi.fn(), mockClose: vi.fn(), + mockFindShadowedUserAdapters: vi.fn(), })); vi.mock('./browser/daemon-client.js', () => ({ @@ -19,6 +20,14 @@ vi.mock('./browser/index.js', () => ({ }, })); +vi.mock('./adapter-shadow.js', async () => { + const actual = await vi.importActual('./adapter-shadow.js'); + return { + ...actual, + findShadowedUserAdapters: mockFindShadowedUserAdapters, + }; +}); + import { renderBrowserDoctorReport, runBrowserDoctor } from './doctor.js'; describe('doctor report rendering', () => { @@ -26,6 +35,7 @@ describe('doctor report rendering', () => { beforeEach(() => { vi.clearAllMocks(); + mockFindShadowedUserAdapters.mockReturnValue([]); }); it('renders OK-style report when daemon and extension connected', () => { @@ -42,6 +52,7 @@ describe('doctor report rendering', () => { expect(text).toContain('(v1.7.9)'); expect(text).toContain('[OK] Extension: connected (v1.6.8)'); expect(text).toContain('Everything looks good!'); + expect(text).toContain('opencli browser analyze '); }); it('renders a warning when daemon version is stale', () => { @@ -323,6 +334,34 @@ describe('doctor report rendering', () => { ])); }); + it('reports local adapter shadows as a warning issue', async () => { + const status = { + state: 'ready' as const, + status: { + daemonVersion: '1.7.9', + extensionConnected: true, + extensionVersion: '1.0.3', + }, + }; + mockGetDaemonHealth + .mockResolvedValueOnce(status) + .mockResolvedValueOnce(status); + mockFindShadowedUserAdapters.mockReturnValueOnce([ + { + name: 'instagram/saved', + userPath: '/home/me/.opencli/clis/instagram/saved.js', + builtinPath: '/pkg/clis/instagram/saved.js', + }, + ]); + + const report = await runBrowserDoctor({ live: false, cliVersion: '1.7.9' }); + + expect(report.adapterShadows).toHaveLength(1); + expect(report.issues).toEqual(expect.arrayContaining([ + expect.stringContaining('Local adapter overrides shadow packaged adapters'), + ])); + }); + it('reports profile-required when multiple profiles are connected without a selection', async () => { const status = { state: 'profile-required' as const, diff --git a/src/doctor.ts b/src/doctor.ts index d3460880..e4e6d19c 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -15,6 +15,7 @@ import type { BrowserSessionInfo } from './types.js'; import type { BrowserProfileStatus } from './browser/daemon-client.js'; import { aliasForContextId, loadProfileConfig } from './browser/profile.js'; import { formatDaemonVersion, isDaemonStale, staleDaemonIssue } from './browser/daemon-version.js'; +import { findShadowedUserAdapters, formatAdapterShadowIssue, type AdapterShadow } from './adapter-shadow.js'; const DOCTOR_LIVE_TIMEOUT_SECONDS = 8; @@ -73,6 +74,7 @@ export type DoctorReport = { connectivity?: ConnectivityResult; sessions?: BrowserSessionInfo[]; profiles?: BrowserProfileStatus[]; + adapterShadows?: AdapterShadow[]; issues: string[]; }; @@ -138,6 +140,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise 0) { + issues.push(formatAdapterShadowIssue(adapterShadows)); + } return { cliVersion: opts.cliVersion, @@ -231,6 +237,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise` for one-shot site recon.')); } return lines.join('\n');