mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
feat(browser): polish adapter author verify workflow
This commit is contained in:
@@ -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://...` |
|
| 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 <site>/<command>` in `~/.opencli/clis/` |
|
| Quickly draft a private local adapter | `opencli browser init <site>/<command>` in `~/.opencli/clis/` |
|
||||||
| Modify an official adapter locally | `opencli adapter eject/status/reset` |
|
| Modify an official adapter locally | `opencli adapter eject <site>` + `opencli adapter reset <site>` |
|
||||||
| Publish or install third-party commands | `opencli plugin install github:user/repo` |
|
| Publish or install third-party commands | `opencli plugin install github:user/repo` |
|
||||||
| Wrap an existing local binary | `opencli external register <name>` |
|
| Wrap an existing local binary | `opencli external register <name>` |
|
||||||
|
|
||||||
@@ -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.
|
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`.
|
3. Decide the auth strategy — `PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`.
|
||||||
4. Decode response fields and design output columns.
|
4. Decode response fields and design output columns.
|
||||||
5. `opencli browser init <site>/<name>` → write adapter → `opencli browser verify <site>/<name>`.
|
5. `opencli browser analyze <url>` for one-shot recon, then `opencli browser init <site>/<name>` → write adapter → `opencli browser verify <site>/<name>`.
|
||||||
6. Persist site knowledge to `~/.opencli/sites/<site>/` so the next adapter for the same site is faster.
|
6. Persist site knowledge to `~/.opencli/sites/<site>/` so the next adapter for the same site is faster.
|
||||||
|
|
||||||
### CLI Hub and desktop adapters
|
### 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).
|
- 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.
|
- Discover the right endpoint via `opencli browser network`, `eval`, or the interceptor fallback.
|
||||||
- Decide auth strategy (`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`).
|
- Decide auth strategy (`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`).
|
||||||
- Decode response fields, design columns, scaffold with `opencli browser init`.
|
- Run `opencli browser analyze <url>` for one-shot recon, decode response fields, design columns, scaffold with `opencli browser init`.
|
||||||
- Verify with `opencli browser verify <site>/<name>` before shipping.
|
- Verify with `opencli browser verify <site>/<name>` 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/<site>/<name>.js`. Site knowledge (endpoints, field maps, fixtures) accumulates in `~/.opencli/sites/<site>/` so the next adapter for the same site starts from context instead of zero.
|
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/<site>/<name>.js`. Site knowledge (endpoints, field maps, fixtures) accumulates in `~/.opencli/sites/<site>/` so the next adapter for the same site starts from context instead of zero.
|
||||||
|
|||||||
+3
-3
@@ -81,7 +81,7 @@ opencli bilibili hot --limit 5
|
|||||||
|------|----------|
|
|------|----------|
|
||||||
| 把个人网站命令放在自己的 Git repo | `opencli plugin create` + `opencli plugin install file://...` |
|
| 把个人网站命令放在自己的 Git repo | `opencli plugin create` + `opencli plugin install file://...` |
|
||||||
| 快速写一个本机私人 adapter | `opencli browser init <site>/<command>`,放在 `~/.opencli/clis/` |
|
| 快速写一个本机私人 adapter | `opencli browser init <site>/<command>`,放在 `~/.opencli/clis/` |
|
||||||
| 本地修改官方 adapter | `opencli adapter eject/status/reset` |
|
| 本地修改官方 adapter | `opencli adapter eject <site>` + `opencli adapter reset <site>` |
|
||||||
| 发布或安装第三方命令 | `opencli plugin install github:user/repo` |
|
| 发布或安装第三方命令 | `opencli plugin install github:user/repo` |
|
||||||
| 包装已有本机 binary | `opencli external register <name>` |
|
| 包装已有本机 binary | `opencli external register <name>` |
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自
|
|||||||
2. 发现目标 endpoint——network 精读、initial state、bundle 搜索、token 溯源,或 interceptor 兜底
|
2. 发现目标 endpoint——network 精读、initial state、bundle 搜索、token 溯源,或 interceptor 兜底
|
||||||
3. 定认证策略——`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`
|
3. 定认证策略——`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`
|
||||||
4. 字段解码 + 设计输出列
|
4. 字段解码 + 设计输出列
|
||||||
5. `opencli browser init <site>/<name>` → 写适配器 → `opencli browser verify <site>/<name>`
|
5. `opencli browser analyze <url>` 一步侦察,再 `opencli browser init <site>/<name>` → 写适配器 → `opencli browser verify <site>/<name>`
|
||||||
6. 把站点知识沉到 `~/.opencli/sites/<site>/`,下次写同站点的其他命令直接吃缓存
|
6. 把站点知识沉到 `~/.opencli/sites/<site>/`,下次写同站点的其他命令直接吃缓存
|
||||||
|
|
||||||
### CLI 枢纽与桌面端适配器
|
### CLI 枢纽与桌面端适配器
|
||||||
@@ -505,7 +505,7 @@ opencli plugin uninstall my-tool # 卸载
|
|||||||
- 侦察站点,选定 pattern(SPA / SSR / JSONP / Token / Streaming)
|
- 侦察站点,选定 pattern(SPA / SSR / JSONP / Token / Streaming)
|
||||||
- 用 `opencli browser network`、`eval`、interceptor 等找到目标 endpoint
|
- 用 `opencli browser network`、`eval`、interceptor 等找到目标 endpoint
|
||||||
- 定认证策略(`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`)
|
- 定认证策略(`PUBLIC` / `COOKIE` / `HEADER` / `INTERCEPT`)
|
||||||
- 字段解码、设计 columns、`opencli browser init` 生成骨架
|
- 先用 `opencli browser analyze <url>` 一步侦察,再字段解码、设计 columns、`opencli browser init` 生成骨架
|
||||||
- 交付前用 `opencli browser verify <site>/<name>` 验证
|
- 交付前用 `opencli browser verify <site>/<name>` 验证
|
||||||
|
|
||||||
在仓库外写的私有适配器放到 `~/.opencli/clis/<site>/<name>.js`;每个站点的 endpoint、字段映射、抓包样本会累积在 `~/.opencli/sites/<site>/`,下次写同站点的其他命令可以直接复用。
|
在仓库外写的私有适配器放到 `~/.opencli/clis/<site>/<name>.js`;每个站点的 endpoint、字段映射、抓包样本会累积在 `~/.opencli/sites/<site>/`,下次写同站点的其他命令可以直接复用。
|
||||||
|
|||||||
@@ -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
|
# skills/opencli-adapter-author/SKILL.md
|
||||||
|
|
||||||
# 2. Reconnaissance
|
# 2. Reconnaissance
|
||||||
opencli browser open https://example.com
|
opencli browser analyze https://example.com
|
||||||
opencli browser wait time 3
|
# Fallback primitives when analyze says deeper inspection is needed:
|
||||||
opencli browser network # inspect XHR / fetch calls
|
# opencli browser open https://example.com
|
||||||
opencli browser state # extract __INITIAL_STATE__ / __NEXT_DATA__
|
# opencli browser network # inspect XHR / fetch calls
|
||||||
|
# opencli browser state # extract __INITIAL_STATE__ / __NEXT_DATA__
|
||||||
|
|
||||||
# 3. Scaffold + verify
|
# 3. Scaffold + verify
|
||||||
opencli browser init <site>/<name>
|
opencli browser init <site>/<name>
|
||||||
@@ -30,6 +31,7 @@ See [skills/opencli-adapter-author/SKILL.md](https://github.com/jackwener/opencl
|
|||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `opencli doctor` | Sanity check: bridge, Chrome, signals |
|
| `opencli doctor` | Sanity check: bridge, Chrome, signals |
|
||||||
|
| `opencli browser analyze <url>` | One-shot site recon: anti-bot, pattern, nearest adapter, next step |
|
||||||
| `opencli browser open <url>` | Open a tab in the Chrome session |
|
| `opencli browser open <url>` | Open a tab in the Chrome session |
|
||||||
| `opencli browser network` | List recent XHR / fetch calls |
|
| `opencli browser network` | List recent XHR / fetch calls |
|
||||||
| `opencli browser state` | Page state: URL, title, interactive elements |
|
| `opencli browser state` | Page state: URL, title, interactive elements |
|
||||||
|
|||||||
@@ -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.
|
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/<site>/verify/<command>.json`.
|
||||||
|
|
||||||
## Local overrides for official adapters
|
## Local overrides for official adapters
|
||||||
|
|
||||||
Use `adapter eject` when you want to customize an existing official adapter.
|
Use `adapter eject` when you want to customize an existing official adapter.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
opencli adapter eject twitter
|
opencli adapter eject twitter
|
||||||
opencli adapter status
|
|
||||||
# edit ~/.opencli/clis/twitter/*.js
|
# edit ~/.opencli/clis/twitter/*.js
|
||||||
opencli adapter reset twitter
|
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/<site>/<command>.js` override packaged adapters with the same `site/command` on this machine. `opencli browser verify <site>/<command>` 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 <site>` after merge. Otherwise the local file keeps shadowing future package updates. `opencli doctor` warns when it detects this shadowing.
|
||||||
|
|
||||||
## Plugins for sharing commands
|
## Plugins for sharing commands
|
||||||
|
|
||||||
|
|||||||
@@ -45,18 +45,30 @@ User adapter 加载路径是:
|
|||||||
|
|
||||||
这条路径适合快速本地自动化。需要版本管理、review、共享的代码推荐做成 plugin。
|
这条路径适合快速本地自动化。需要版本管理、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/<site>/verify/<command>.json` 读取 args。
|
||||||
|
|
||||||
## 本地覆盖官方 adapter
|
## 本地覆盖官方 adapter
|
||||||
|
|
||||||
如果你想改一个已有官方 adapter,用 `adapter eject`。
|
如果你想改一个已有官方 adapter,用 `adapter eject`。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
opencli adapter eject twitter
|
opencli adapter eject twitter
|
||||||
opencli adapter status
|
|
||||||
# edit ~/.opencli/clis/twitter/*.js
|
# edit ~/.opencli/clis/twitter/*.js
|
||||||
opencli adapter reset twitter
|
opencli adapter reset twitter
|
||||||
```
|
```
|
||||||
|
|
||||||
Ejected adapter 会在本机覆盖 package 里的官方 adapter。`adapter reset` 会移除本地覆盖,恢复到 package 版本。
|
`~/.opencli/clis/<site>/<command>.js` 会在本机覆盖同名 package adapter。`opencli browser verify <site>/<command>` 也会跑本地覆盖版本,所以本地 verify 通过不代表 package 里的 adapter 已经改好。
|
||||||
|
|
||||||
|
Package 里的 `cli-manifest.json` 只描述 bundled adapter。User adapter 是运行时发现的,不需要写 manifest。
|
||||||
|
|
||||||
|
把本地修复复制到仓库发 PR 后,merge 后要删除本地副本,或运行 `opencli adapter reset <site>`。否则本地文件会继续 shadow 后续 package 更新。`opencli doctor` 会在发现这种 shadowing 时给出 warning。
|
||||||
|
|
||||||
## Plugin:共享命令
|
## Plugin:共享命令
|
||||||
|
|
||||||
|
|||||||
@@ -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 <site>');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string> {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(getCliManifestPath(builtinClisDir), 'utf-8');
|
||||||
|
const entries = JSON.parse(raw) as ManifestEntry[];
|
||||||
|
const files = new Set<string>();
|
||||||
|
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 <site>, when you want packaged updates.');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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', () => {
|
describe('validateRows', () => {
|
||||||
it('passes when rows meet all expectations', () => {
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -239,6 +239,21 @@ export function expandFixtureArgs(args: FixtureArgs | undefined): string[] {
|
|||||||
return out;
|
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<string, unknown>;
|
||||||
|
return [parsed];
|
||||||
|
} catch {
|
||||||
|
return [raw];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function jsType(v: unknown): string {
|
function jsType(v: unknown): string {
|
||||||
if (v === null) return 'null';
|
if (v === null) return 'null';
|
||||||
if (Array.isArray(v)) return 'array';
|
if (Array.isArray(v)) return 'array';
|
||||||
|
|||||||
@@ -174,6 +174,62 @@ describe('browser verify', () => {
|
|||||||
fs.rmSync(fakeHome, { recursive: true, force: true });
|
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', () => {
|
describe('profile list', () => {
|
||||||
|
|||||||
+11
-8
@@ -1892,6 +1892,7 @@ cli({
|
|||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
fs.writeFileSync(filePath, template, 'utf-8');
|
fs.writeFileSync(filePath, template, 'utf-8');
|
||||||
console.log(`Created: ${filePath}`);
|
console.log(`Created: ${filePath}`);
|
||||||
|
console.log('First time on this site? Run: opencli browser analyze <url>');
|
||||||
console.log(`Edit the file to implement your adapter, then run: opencli browser verify ${name}`);
|
console.log(`Edit the file to implement your adapter, then run: opencli browser verify ${name}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Error: ${err instanceof Error ? err.message : String(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('--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('--no-fixture', 'Ignore any fixture file for this run (no value-level validation)')
|
||||||
.option('--strict-memory', 'Fail (not just warn) when ~/.opencli/sites/<site>/endpoints.json or notes.md is missing')
|
.option('--strict-memory', 'Fail (not just warn) when ~/.opencli/sites/<site>/endpoints.json or notes.md is missing')
|
||||||
|
.option('--seed-args <value>', 'Seed args when no fixture exists; use JSON array/object for multiple args or flags')
|
||||||
.option('--trace <mode>', 'Trace capture for the adapter subprocess: off, on, retain-on-failure', 'off')
|
.option('--trace <mode>', 'Trace capture for the adapter subprocess: off, on, retain-on-failure', 'off')
|
||||||
.description('Execute an adapter and validate output; uses fixture at ~/.opencli/sites/<site>/verify/<cmd>.json when present')
|
.description('Execute an adapter and validate output; uses fixture at ~/.opencli/sites/<site>/verify/<cmd>.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 {
|
try {
|
||||||
const parts = name.split('/');
|
const parts = name.split('/');
|
||||||
if (parts.length !== 2) { console.error('Name must be site/command format'); process.exitCode = EXIT_CODES.USAGE_ERROR; return; }
|
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 { 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`);
|
const filePath = path.join(os.homedir(), '.opencli', 'clis', site, `${command}.js`);
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
console.error(`Adapter not found: ${filePath}`);
|
console.error(`Adapter not found: ${filePath}`);
|
||||||
@@ -1941,9 +1943,10 @@ cli({
|
|||||||
// - array form ["123", "--limit", "3"] → verbatim (for positional subjects)
|
// - array form ["123", "--limit", "3"] → verbatim (for positional subjects)
|
||||||
const adapterSrc = fs.readFileSync(filePath, 'utf-8');
|
const adapterSrc = fs.readFileSync(filePath, 'utf-8');
|
||||||
const hasLimitArg = /['"]limit['"]/.test(adapterSrc);
|
const hasLimitArg = /['"]limit['"]/.test(adapterSrc);
|
||||||
const fixtureArgs = fixture?.args;
|
const seedArgs = parseSeedArgs(opts.seedArgs);
|
||||||
const cliArgs: string[] = expandFixtureArgs(fixtureArgs);
|
const explicitArgs = fixture?.args ?? seedArgs;
|
||||||
if (cliArgs.length === 0 && hasLimitArg) cliArgs.push('--limit', '3');
|
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 traceArgs = opts.trace && opts.trace !== 'off' ? ['--trace', opts.trace] : [];
|
||||||
const argDisplay = [...cliArgs, ...traceArgs].join(' ');
|
const argDisplay = [...cliArgs, ...traceArgs].join(' ');
|
||||||
@@ -1993,10 +1996,10 @@ cli({
|
|||||||
console.log(`\n Fixture already exists at ${fixturePath(site, command)}.`);
|
console.log(`\n Fixture already exists at ${fixturePath(site, command)}.`);
|
||||||
console.log(` Use --update-fixture to overwrite.`);
|
console.log(` Use --update-fixture to overwrite.`);
|
||||||
} else {
|
} else {
|
||||||
const seedArgs = fixtureArgs !== undefined
|
const fixtureArgs = explicitArgs !== undefined
|
||||||
? fixtureArgs
|
? explicitArgs
|
||||||
: (hasLimitArg ? { limit: 3 } : undefined);
|
: (hasLimitArg ? { limit: 3 } : undefined);
|
||||||
const derived = deriveFixture(rows, seedArgs);
|
const derived = deriveFixture(rows, fixtureArgs);
|
||||||
const p = writeFixture(site, command, derived);
|
const p = writeFixture(site, command, derived);
|
||||||
console.log(`\n ${fixture ? '↻ Updated' : '✎ Wrote'} fixture: ${p}`);
|
console.log(`\n ${fixture ? '↻ Updated' : '✎ Wrote'} fixture: ${p}`);
|
||||||
console.log(` Review and hand-tune the derived expectations (add patterns / notEmpty, tighten rowCount).`);
|
console.log(` Review and hand-tune the derived expectations (add patterns / notEmpty, tighten rowCount).`);
|
||||||
|
|||||||
+40
-1
@@ -1,10 +1,11 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
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(),
|
mockGetDaemonHealth: vi.fn(),
|
||||||
mockListSessions: vi.fn(),
|
mockListSessions: vi.fn(),
|
||||||
mockConnect: vi.fn(),
|
mockConnect: vi.fn(),
|
||||||
mockClose: vi.fn(),
|
mockClose: vi.fn(),
|
||||||
|
mockFindShadowedUserAdapters: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./browser/daemon-client.js', () => ({
|
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<typeof import('./adapter-shadow.js')>('./adapter-shadow.js');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
findShadowedUserAdapters: mockFindShadowedUserAdapters,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
import { renderBrowserDoctorReport, runBrowserDoctor } from './doctor.js';
|
import { renderBrowserDoctorReport, runBrowserDoctor } from './doctor.js';
|
||||||
|
|
||||||
describe('doctor report rendering', () => {
|
describe('doctor report rendering', () => {
|
||||||
@@ -26,6 +35,7 @@ describe('doctor report rendering', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockFindShadowedUserAdapters.mockReturnValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders OK-style report when daemon and extension connected', () => {
|
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('(v1.7.9)');
|
||||||
expect(text).toContain('[OK] Extension: connected (v1.6.8)');
|
expect(text).toContain('[OK] Extension: connected (v1.6.8)');
|
||||||
expect(text).toContain('Everything looks good!');
|
expect(text).toContain('Everything looks good!');
|
||||||
|
expect(text).toContain('opencli browser analyze <url>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders a warning when daemon version is stale', () => {
|
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 () => {
|
it('reports profile-required when multiple profiles are connected without a selection', async () => {
|
||||||
const status = {
|
const status = {
|
||||||
state: 'profile-required' as const,
|
state: 'profile-required' as const,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { BrowserSessionInfo } from './types.js';
|
|||||||
import type { BrowserProfileStatus } from './browser/daemon-client.js';
|
import type { BrowserProfileStatus } from './browser/daemon-client.js';
|
||||||
import { aliasForContextId, loadProfileConfig } from './browser/profile.js';
|
import { aliasForContextId, loadProfileConfig } from './browser/profile.js';
|
||||||
import { formatDaemonVersion, isDaemonStale, staleDaemonIssue } from './browser/daemon-version.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;
|
const DOCTOR_LIVE_TIMEOUT_SECONDS = 8;
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ export type DoctorReport = {
|
|||||||
connectivity?: ConnectivityResult;
|
connectivity?: ConnectivityResult;
|
||||||
sessions?: BrowserSessionInfo[];
|
sessions?: BrowserSessionInfo[];
|
||||||
profiles?: BrowserProfileStatus[];
|
profiles?: BrowserProfileStatus[];
|
||||||
|
adapterShadows?: AdapterShadow[];
|
||||||
issues: string[];
|
issues: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -138,6 +140,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const extensionVersion = health.status?.extensionVersion;
|
const extensionVersion = health.status?.extensionVersion;
|
||||||
|
const adapterShadows = findShadowedUserAdapters();
|
||||||
|
|
||||||
const issues: string[] = [];
|
const issues: string[] = [];
|
||||||
if (daemonFlaky) {
|
if (daemonFlaky) {
|
||||||
@@ -217,6 +220,9 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
|
|||||||
' Download from: https://github.com/jackwener/opencli/releases',
|
' Download from: https://github.com/jackwener/opencli/releases',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (adapterShadows.length > 0) {
|
||||||
|
issues.push(formatAdapterShadowIssue(adapterShadows));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cliVersion: opts.cliVersion,
|
cliVersion: opts.cliVersion,
|
||||||
@@ -231,6 +237,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
|
|||||||
connectivity,
|
connectivity,
|
||||||
sessions,
|
sessions,
|
||||||
profiles,
|
profiles,
|
||||||
|
adapterShadows,
|
||||||
issues,
|
issues,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -329,6 +336,7 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
|
|||||||
}
|
}
|
||||||
} else if (report.daemonRunning && report.extensionConnected) {
|
} else if (report.daemonRunning && report.extensionConnected) {
|
||||||
lines.push('', styleText('green', 'Everything looks good!'));
|
lines.push('', styleText('green', 'Everything looks good!'));
|
||||||
|
lines.push(styleText('dim', 'Tip: writing a new adapter? Run `opencli browser analyze <url>` for one-shot site recon.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
|
|||||||
Reference in New Issue
Block a user