@jackwener/opencli 1.5.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser/cdp.js +5 -0
- package/dist/browser/page.d.ts +3 -0
- package/dist/browser/page.js +24 -1
- package/dist/cli-manifest.json +465 -5
- package/dist/cli.js +34 -3
- package/dist/clis/bluesky/feeds.yaml +29 -0
- package/dist/clis/bluesky/followers.yaml +33 -0
- package/dist/clis/bluesky/following.yaml +33 -0
- package/dist/clis/bluesky/profile.yaml +27 -0
- package/dist/clis/bluesky/search.yaml +34 -0
- package/dist/clis/bluesky/starter-packs.yaml +34 -0
- package/dist/clis/bluesky/thread.yaml +32 -0
- package/dist/clis/bluesky/trending.yaml +27 -0
- package/dist/clis/bluesky/user.yaml +34 -0
- package/dist/clis/twitter/trending.js +29 -61
- package/dist/clis/v2ex/hot.yaml +17 -3
- package/dist/clis/xiaohongshu/publish.js +78 -42
- package/dist/clis/xiaohongshu/publish.test.js +20 -8
- package/dist/clis/xiaohongshu/search.d.ts +8 -1
- package/dist/clis/xiaohongshu/search.js +20 -1
- package/dist/clis/xiaohongshu/search.test.d.ts +1 -1
- package/dist/clis/xiaohongshu/search.test.js +32 -1
- package/dist/discovery.js +40 -28
- package/dist/doctor.d.ts +1 -2
- package/dist/doctor.js +2 -2
- package/dist/engine.test.js +42 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +2 -2
- package/dist/execution.js +45 -7
- package/dist/execution.test.d.ts +1 -0
- package/dist/execution.test.js +40 -0
- package/dist/external.js +6 -1
- package/dist/main.js +1 -0
- package/dist/plugin-scaffold.d.ts +28 -0
- package/dist/plugin-scaffold.js +142 -0
- package/dist/plugin-scaffold.test.d.ts +4 -0
- package/dist/plugin-scaffold.test.js +83 -0
- package/dist/plugin.d.ts +55 -17
- package/dist/plugin.js +706 -154
- package/dist/plugin.test.js +836 -38
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.js +1 -1
- package/dist/types.d.ts +2 -0
- package/docs/adapters/browser/bluesky.md +53 -0
- package/docs/guide/plugins.md +10 -0
- package/package.json +1 -1
- package/src/browser/cdp.ts +6 -0
- package/src/browser/page.ts +24 -1
- package/src/cli.ts +34 -3
- package/src/clis/bluesky/feeds.yaml +29 -0
- package/src/clis/bluesky/followers.yaml +33 -0
- package/src/clis/bluesky/following.yaml +33 -0
- package/src/clis/bluesky/profile.yaml +27 -0
- package/src/clis/bluesky/search.yaml +34 -0
- package/src/clis/bluesky/starter-packs.yaml +34 -0
- package/src/clis/bluesky/thread.yaml +32 -0
- package/src/clis/bluesky/trending.yaml +27 -0
- package/src/clis/bluesky/user.yaml +34 -0
- package/src/clis/twitter/trending.ts +29 -77
- package/src/clis/v2ex/hot.yaml +17 -3
- package/src/clis/xiaohongshu/publish.test.ts +22 -8
- package/src/clis/xiaohongshu/publish.ts +93 -52
- package/src/clis/xiaohongshu/search.test.ts +39 -1
- package/src/clis/xiaohongshu/search.ts +19 -1
- package/src/discovery.ts +41 -33
- package/src/doctor.ts +2 -3
- package/src/engine.test.ts +38 -0
- package/src/errors.ts +6 -2
- package/src/execution.test.ts +47 -0
- package/src/execution.ts +39 -6
- package/src/external.ts +6 -1
- package/src/main.ts +1 -0
- package/src/plugin-scaffold.test.ts +98 -0
- package/src/plugin-scaffold.ts +170 -0
- package/src/plugin.test.ts +881 -38
- package/src/plugin.ts +871 -158
- package/src/runtime.ts +2 -2
- package/src/types.ts +2 -0
- package/tests/e2e/browser-public.test.ts +1 -1
|
@@ -43,9 +43,10 @@ describe('xiaohongshu publish', () => {
|
|
|
43
43
|
const page = createPageMock([
|
|
44
44
|
'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
|
|
45
45
|
{ ok: true, target: '上传图文', text: '上传图文' },
|
|
46
|
-
{ hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
|
|
46
|
+
{ state: 'editor_ready', hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
|
|
47
47
|
{ ok: true, count: 1 },
|
|
48
48
|
false,
|
|
49
|
+
true, // waitForEditForm: editor appeared
|
|
49
50
|
{ ok: true, sel: 'input[maxlength="20"]' },
|
|
50
51
|
{ ok: true, sel: '[contenteditable="true"][class*="content"]' },
|
|
51
52
|
true,
|
|
@@ -72,17 +73,21 @@ describe('xiaohongshu publish', () => {
|
|
|
72
73
|
it('fails early with a clear error when still on the video page', async () => {
|
|
73
74
|
const cmd = getRegistry().get('xiaohongshu/publish');
|
|
74
75
|
expect(cmd?.func).toBeTypeOf('function');
|
|
76
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
|
|
77
|
+
const imagePath = path.join(tempDir, 'demo.jpg');
|
|
78
|
+
fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
|
|
75
79
|
const page = createPageMock([
|
|
76
80
|
'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
|
|
77
81
|
{ ok: false, visibleTexts: ['上传视频', '上传图文'] },
|
|
78
|
-
{ hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
|
|
79
|
-
{ hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
|
|
80
|
-
{ hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
|
|
81
|
-
{ hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
|
|
82
|
+
{ state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
|
|
83
|
+
{ state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
|
|
84
|
+
{ state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
|
|
85
|
+
{ state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
|
|
82
86
|
]);
|
|
83
87
|
await expect(cmd.func(page, {
|
|
84
88
|
title: 'DeepSeek别乱问',
|
|
85
89
|
content: '一篇真实一点的小红书正文',
|
|
90
|
+
images: imagePath,
|
|
86
91
|
topics: '',
|
|
87
92
|
draft: false,
|
|
88
93
|
})).rejects.toThrow('Still on the video publish page after trying to select 图文');
|
|
@@ -91,11 +96,17 @@ describe('xiaohongshu publish', () => {
|
|
|
91
96
|
it('waits for the image-text surface to appear after clicking the tab', async () => {
|
|
92
97
|
const cmd = getRegistry().get('xiaohongshu/publish');
|
|
93
98
|
expect(cmd?.func).toBeTypeOf('function');
|
|
99
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
|
|
100
|
+
const imagePath = path.join(tempDir, 'demo.jpg');
|
|
101
|
+
fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
|
|
94
102
|
const page = createPageMock([
|
|
95
103
|
'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
|
|
96
104
|
{ ok: true, target: '上传图文', text: '上传图文' },
|
|
97
|
-
{ hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
|
|
98
|
-
{ hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
|
|
105
|
+
{ state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
|
|
106
|
+
{ state: 'editor_ready', hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
|
|
107
|
+
{ ok: true, count: 1 }, // injectImages
|
|
108
|
+
false, // waitForUploads: no progress indicator
|
|
109
|
+
true, // waitForEditForm: editor appeared
|
|
99
110
|
{ ok: true, sel: 'input[maxlength="20"]' },
|
|
100
111
|
{ ok: true, sel: '[contenteditable="true"][class*="content"]' },
|
|
101
112
|
true,
|
|
@@ -105,6 +116,7 @@ describe('xiaohongshu publish', () => {
|
|
|
105
116
|
const result = await cmd.func(page, {
|
|
106
117
|
title: '延迟切换也能过',
|
|
107
118
|
content: '图文页切换慢一点也继续等',
|
|
119
|
+
images: imagePath,
|
|
108
120
|
topics: '',
|
|
109
121
|
draft: false,
|
|
110
122
|
});
|
|
@@ -112,7 +124,7 @@ describe('xiaohongshu publish', () => {
|
|
|
112
124
|
expect(result).toEqual([
|
|
113
125
|
{
|
|
114
126
|
status: '✅ 发布成功',
|
|
115
|
-
detail: '"延迟切换也能过" ·
|
|
127
|
+
detail: '"延迟切换也能过" · 1张图片 · 发布成功',
|
|
116
128
|
},
|
|
117
129
|
]);
|
|
118
130
|
});
|
|
@@ -5,4 +5,11 @@
|
|
|
5
5
|
* the search results page and extracts data from rendered DOM elements.
|
|
6
6
|
* Ref: https://github.com/jackwener/opencli/issues/10
|
|
7
7
|
*/
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Extract approximate publish date from a Xiaohongshu note URL.
|
|
10
|
+
* XHS note IDs follow MongoDB ObjectID format where the first 8 hex
|
|
11
|
+
* characters encode a Unix timestamp (the moment the ID was generated,
|
|
12
|
+
* which closely matches publish time but is not an official API field).
|
|
13
|
+
* e.g. "697f6c74..." → 0x697f6c74 = 1769958516 → 2026-02-01
|
|
14
|
+
*/
|
|
15
|
+
export declare function noteIdToDate(url: string): string;
|
|
@@ -7,6 +7,24 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { cli, Strategy } from '../../registry.js';
|
|
9
9
|
import { AuthRequiredError } from '../../errors.js';
|
|
10
|
+
/**
|
|
11
|
+
* Extract approximate publish date from a Xiaohongshu note URL.
|
|
12
|
+
* XHS note IDs follow MongoDB ObjectID format where the first 8 hex
|
|
13
|
+
* characters encode a Unix timestamp (the moment the ID was generated,
|
|
14
|
+
* which closely matches publish time but is not an official API field).
|
|
15
|
+
* e.g. "697f6c74..." → 0x697f6c74 = 1769958516 → 2026-02-01
|
|
16
|
+
*/
|
|
17
|
+
export function noteIdToDate(url) {
|
|
18
|
+
const match = url.match(/\/(?:search_result|explore|note)\/([0-9a-f]{24})(?=[?#/]|$)/i);
|
|
19
|
+
if (!match)
|
|
20
|
+
return '';
|
|
21
|
+
const hex = match[1].substring(0, 8);
|
|
22
|
+
const ts = parseInt(hex, 16);
|
|
23
|
+
if (!ts || ts < 1_000_000_000 || ts > 4_000_000_000)
|
|
24
|
+
return '';
|
|
25
|
+
// Offset by UTC+8 (China Standard Time) so the date matches what XHS users see
|
|
26
|
+
return new Date((ts + 8 * 3600) * 1000).toISOString().slice(0, 10);
|
|
27
|
+
}
|
|
10
28
|
cli({
|
|
11
29
|
site: 'xiaohongshu',
|
|
12
30
|
name: 'search',
|
|
@@ -17,7 +35,7 @@ cli({
|
|
|
17
35
|
{ name: 'query', required: true, positional: true, help: 'Search keyword' },
|
|
18
36
|
{ name: 'limit', type: 'int', default: 20, help: 'Number of results' },
|
|
19
37
|
],
|
|
20
|
-
columns: ['rank', 'title', 'author', 'likes', 'url'],
|
|
38
|
+
columns: ['rank', 'title', 'author', 'likes', 'published_at', 'url'],
|
|
21
39
|
func: async (page, kwargs) => {
|
|
22
40
|
const keyword = encodeURIComponent(kwargs.query);
|
|
23
41
|
await page.goto(`https://www.xiaohongshu.com/search_result?keyword=${keyword}&source=web_search_result_notes`);
|
|
@@ -89,6 +107,7 @@ cli({
|
|
|
89
107
|
.map((item, i) => ({
|
|
90
108
|
rank: i + 1,
|
|
91
109
|
...item,
|
|
110
|
+
published_at: noteIdToDate(item.url),
|
|
92
111
|
}));
|
|
93
112
|
},
|
|
94
113
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
export {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { getRegistry } from '../../registry.js';
|
|
3
|
-
import './search.js';
|
|
3
|
+
import { noteIdToDate } from './search.js';
|
|
4
4
|
function createPageMock(evaluateResults) {
|
|
5
5
|
const evaluate = vi.fn();
|
|
6
6
|
for (const result of evaluateResults) {
|
|
@@ -70,6 +70,7 @@ describe('xiaohongshu search', () => {
|
|
|
70
70
|
title: '某鱼买FSD被坑了4万',
|
|
71
71
|
author: '随风',
|
|
72
72
|
likes: '261',
|
|
73
|
+
published_at: '2025-10-10',
|
|
73
74
|
url: detailUrl,
|
|
74
75
|
author_url: authorUrl,
|
|
75
76
|
},
|
|
@@ -112,3 +113,33 @@ describe('xiaohongshu search', () => {
|
|
|
112
113
|
expect(result[0]).toMatchObject({ rank: 1, title: 'Result A' });
|
|
113
114
|
});
|
|
114
115
|
});
|
|
116
|
+
describe('noteIdToDate (ObjectID timestamp parsing)', () => {
|
|
117
|
+
it('parses a known note ID to the correct China-timezone date', () => {
|
|
118
|
+
// 0x697f6c74 = 1769958516 → 2026-02-01 in UTC+8
|
|
119
|
+
expect(noteIdToDate('https://www.xiaohongshu.com/search_result/697f6c74000000002103de17')).toBe('2026-02-01');
|
|
120
|
+
// 0x68e90be8 → 2025-10-10 in UTC+8
|
|
121
|
+
expect(noteIdToDate('https://www.xiaohongshu.com/explore/68e90be80000000004022e66')).toBe('2025-10-10');
|
|
122
|
+
});
|
|
123
|
+
it('returns China date when UTC+8 crosses into the next day', () => {
|
|
124
|
+
// 0x69b739f0 = 2026-03-15 23:00 UTC = 2026-03-16 07:00 CST
|
|
125
|
+
// Without UTC+8 offset this would incorrectly return 2026-03-15
|
|
126
|
+
expect(noteIdToDate('https://www.xiaohongshu.com/search_result/69b739f00000000000000000')).toBe('2026-03-16');
|
|
127
|
+
});
|
|
128
|
+
it('handles /note/ path variant', () => {
|
|
129
|
+
expect(noteIdToDate('https://www.xiaohongshu.com/note/697f6c74000000002103de17')).toBe('2026-02-01');
|
|
130
|
+
});
|
|
131
|
+
it('handles URL with query parameters', () => {
|
|
132
|
+
expect(noteIdToDate('https://www.xiaohongshu.com/search_result/697f6c74000000002103de17?xsec_token=abc')).toBe('2026-02-01');
|
|
133
|
+
});
|
|
134
|
+
it('returns empty string for non-matching URLs', () => {
|
|
135
|
+
expect(noteIdToDate('https://www.xiaohongshu.com/user/profile/635a9c720000000018028b40')).toBe('');
|
|
136
|
+
expect(noteIdToDate('https://www.xiaohongshu.com/')).toBe('');
|
|
137
|
+
});
|
|
138
|
+
it('returns empty string for IDs shorter than 24 hex chars', () => {
|
|
139
|
+
expect(noteIdToDate('https://www.xiaohongshu.com/search_result/abcdef')).toBe('');
|
|
140
|
+
});
|
|
141
|
+
it('returns empty string when timestamp is out of range', () => {
|
|
142
|
+
// All zeros → ts = 0
|
|
143
|
+
expect(noteIdToDate('https://www.xiaohongshu.com/search_result/000000000000000000000000')).toBe('');
|
|
144
|
+
});
|
|
145
|
+
});
|
package/dist/discovery.js
CHANGED
|
@@ -127,22 +127,20 @@ async function discoverClisFromFs(dir) {
|
|
|
127
127
|
const site = entry.name;
|
|
128
128
|
const siteDir = path.join(dir, site);
|
|
129
129
|
const files = await fs.promises.readdir(siteDir);
|
|
130
|
-
|
|
131
|
-
for (const file of files) {
|
|
130
|
+
await Promise.all(files.map(async (file) => {
|
|
132
131
|
const filePath = path.join(siteDir, file);
|
|
133
132
|
if (file.endsWith('.yaml') || file.endsWith('.yml')) {
|
|
134
|
-
|
|
133
|
+
await registerYamlCli(filePath, site);
|
|
135
134
|
}
|
|
136
135
|
else if ((file.endsWith('.js') && !file.endsWith('.d.js')) ||
|
|
137
136
|
(file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts'))) {
|
|
138
137
|
if (!(await isCliModule(filePath)))
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
return;
|
|
139
|
+
await import(pathToFileURL(filePath).href).catch((err) => {
|
|
141
140
|
log.warn(`Failed to load module ${filePath}: ${getErrorMessage(err)}`);
|
|
142
|
-
})
|
|
141
|
+
});
|
|
143
142
|
}
|
|
144
|
-
}
|
|
145
|
-
await Promise.all(filePromises);
|
|
143
|
+
}));
|
|
146
144
|
});
|
|
147
145
|
await Promise.all(sitePromises);
|
|
148
146
|
}
|
|
@@ -194,11 +192,12 @@ export async function discoverPlugins() {
|
|
|
194
192
|
return;
|
|
195
193
|
}
|
|
196
194
|
const entries = await fs.promises.readdir(PLUGINS_DIR, { withFileTypes: true });
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
195
|
+
await Promise.all(entries.map(async (entry) => {
|
|
196
|
+
const pluginDir = path.join(PLUGINS_DIR, entry.name);
|
|
197
|
+
if (!(await isDiscoverablePluginDir(entry, pluginDir)))
|
|
198
|
+
return;
|
|
199
|
+
await discoverPluginDir(pluginDir, entry.name);
|
|
200
|
+
}));
|
|
202
201
|
}
|
|
203
202
|
/**
|
|
204
203
|
* Flat scan: read yaml/ts files directly in a plugin directory.
|
|
@@ -207,32 +206,29 @@ export async function discoverPlugins() {
|
|
|
207
206
|
async function discoverPluginDir(dir, site) {
|
|
208
207
|
const files = await fs.promises.readdir(dir);
|
|
209
208
|
const fileSet = new Set(files);
|
|
210
|
-
|
|
211
|
-
for (const file of files) {
|
|
209
|
+
await Promise.all(files.map(async (file) => {
|
|
212
210
|
const filePath = path.join(dir, file);
|
|
213
211
|
if (file.endsWith('.yaml') || file.endsWith('.yml')) {
|
|
214
|
-
|
|
212
|
+
await registerYamlCli(filePath, site);
|
|
215
213
|
}
|
|
216
214
|
else if (file.endsWith('.js') && !file.endsWith('.d.js')) {
|
|
217
215
|
if (!(await isCliModule(filePath)))
|
|
218
|
-
|
|
219
|
-
|
|
216
|
+
return;
|
|
217
|
+
await import(pathToFileURL(filePath).href).catch((err) => {
|
|
220
218
|
log.warn(`Plugin ${site}/${file}: ${getErrorMessage(err)}`);
|
|
221
|
-
})
|
|
219
|
+
});
|
|
222
220
|
}
|
|
223
221
|
else if (file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts')) {
|
|
224
|
-
// Skip .ts if a compiled .js sibling exists (production mode can't load .ts)
|
|
225
222
|
const jsFile = file.replace(/\.ts$/, '.js');
|
|
223
|
+
// Prefer compiled .js — skip the .ts source file
|
|
226
224
|
if (fileSet.has(jsFile))
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}));
|
|
225
|
+
return;
|
|
226
|
+
// No compiled .js found — cannot import raw .ts in production Node.js.
|
|
227
|
+
// This typically means esbuild transpilation failed during plugin install.
|
|
228
|
+
log.warn(`Plugin ${site}/${file}: no compiled .js found. ` +
|
|
229
|
+
`Run "opencli plugin update ${site}" to re-transpile, or install esbuild.`);
|
|
233
230
|
}
|
|
234
|
-
}
|
|
235
|
-
await Promise.all(promises);
|
|
231
|
+
}));
|
|
236
232
|
}
|
|
237
233
|
async function isCliModule(filePath) {
|
|
238
234
|
try {
|
|
@@ -244,3 +240,19 @@ async function isCliModule(filePath) {
|
|
|
244
240
|
return false;
|
|
245
241
|
}
|
|
246
242
|
}
|
|
243
|
+
async function isDiscoverablePluginDir(entry, pluginDir) {
|
|
244
|
+
if (entry.isDirectory())
|
|
245
|
+
return true;
|
|
246
|
+
if (!entry.isSymbolicLink())
|
|
247
|
+
return false;
|
|
248
|
+
try {
|
|
249
|
+
return (await fs.promises.stat(pluginDir)).isDirectory();
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
const code = err.code;
|
|
253
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
|
254
|
+
log.warn(`Failed to inspect plugin link ${pluginDir}: ${getErrorMessage(err)}`);
|
|
255
|
+
}
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
package/dist/doctor.d.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* opencli doctor — diagnose
|
|
2
|
+
* opencli doctor — diagnose browser connectivity.
|
|
3
3
|
*
|
|
4
4
|
* Simplified for the daemon-based architecture. No more token management,
|
|
5
5
|
* MCP path discovery, or config file scanning.
|
|
6
6
|
*/
|
|
7
7
|
export type DoctorOptions = {
|
|
8
|
-
fix?: boolean;
|
|
9
8
|
yes?: boolean;
|
|
10
9
|
live?: boolean;
|
|
11
10
|
sessions?: boolean;
|
package/dist/doctor.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* opencli doctor — diagnose
|
|
2
|
+
* opencli doctor — diagnose browser connectivity.
|
|
3
3
|
*
|
|
4
4
|
* Simplified for the daemon-based architecture. No more token management,
|
|
5
5
|
* MCP path discovery, or config file scanning.
|
|
@@ -58,7 +58,7 @@ export async function runBrowserDoctor(opts = {}) {
|
|
|
58
58
|
if (status.running && !status.extensionConnected) {
|
|
59
59
|
issues.push('Daemon is running but the Chrome extension is not connected.\n' +
|
|
60
60
|
'Please install the opencli Browser Bridge extension:\n' +
|
|
61
|
-
' 1. Download from
|
|
61
|
+
' 1. Download from https://github.com/jackwener/opencli/releases\n' +
|
|
62
62
|
' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
|
|
63
63
|
' 3. Click "Load unpacked" → select the extension folder');
|
|
64
64
|
}
|
package/dist/engine.test.js
CHANGED
|
@@ -75,11 +75,26 @@ cli({
|
|
|
75
75
|
describe('discoverPlugins', () => {
|
|
76
76
|
const testPluginDir = path.join(PLUGINS_DIR, '__test-plugin__');
|
|
77
77
|
const yamlPath = path.join(testPluginDir, 'greeting.yaml');
|
|
78
|
+
const symlinkTargetDir = path.join(os.tmpdir(), '__test-plugin-symlink-target__');
|
|
79
|
+
const symlinkPluginDir = path.join(PLUGINS_DIR, '__test-plugin-symlink__');
|
|
80
|
+
const brokenSymlinkDir = path.join(PLUGINS_DIR, '__test-plugin-broken__');
|
|
78
81
|
afterEach(async () => {
|
|
79
82
|
try {
|
|
80
83
|
await fs.promises.rm(testPluginDir, { recursive: true });
|
|
81
84
|
}
|
|
82
85
|
catch { }
|
|
86
|
+
try {
|
|
87
|
+
await fs.promises.rm(symlinkPluginDir, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
catch { }
|
|
90
|
+
try {
|
|
91
|
+
await fs.promises.rm(symlinkTargetDir, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
catch { }
|
|
94
|
+
try {
|
|
95
|
+
await fs.promises.rm(brokenSymlinkDir, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
catch { }
|
|
83
98
|
});
|
|
84
99
|
it('discovers YAML plugins from ~/.opencli/plugins/', async () => {
|
|
85
100
|
// Create a simple YAML adapter in the plugins directory
|
|
@@ -108,6 +123,33 @@ columns: [message]
|
|
|
108
123
|
// discoverPlugins should not throw if ~/.opencli/plugins/ does not exist
|
|
109
124
|
await expect(discoverPlugins()).resolves.not.toThrow();
|
|
110
125
|
});
|
|
126
|
+
it('discovers YAML plugins from symlinked plugin directories', async () => {
|
|
127
|
+
await fs.promises.mkdir(PLUGINS_DIR, { recursive: true });
|
|
128
|
+
await fs.promises.mkdir(symlinkTargetDir, { recursive: true });
|
|
129
|
+
await fs.promises.writeFile(path.join(symlinkTargetDir, 'hello.yaml'), `
|
|
130
|
+
site: __test-plugin-symlink__
|
|
131
|
+
name: hello
|
|
132
|
+
description: Test plugin greeting via symlink
|
|
133
|
+
strategy: public
|
|
134
|
+
browser: false
|
|
135
|
+
|
|
136
|
+
pipeline:
|
|
137
|
+
- evaluate: "() => [{ message: 'hello from symlink plugin' }]"
|
|
138
|
+
|
|
139
|
+
columns: [message]
|
|
140
|
+
`);
|
|
141
|
+
await fs.promises.symlink(symlinkTargetDir, symlinkPluginDir, 'dir');
|
|
142
|
+
await discoverPlugins();
|
|
143
|
+
const cmd = getRegistry().get('__test-plugin-symlink__/hello');
|
|
144
|
+
expect(cmd).toBeDefined();
|
|
145
|
+
expect(cmd.description).toBe('Test plugin greeting via symlink');
|
|
146
|
+
});
|
|
147
|
+
it('skips broken plugin symlinks without throwing', async () => {
|
|
148
|
+
await fs.promises.mkdir(PLUGINS_DIR, { recursive: true });
|
|
149
|
+
await fs.promises.symlink(path.join(os.tmpdir(), '__missing-plugin-target__'), brokenSymlinkDir, 'dir');
|
|
150
|
+
await expect(discoverPlugins()).resolves.not.toThrow();
|
|
151
|
+
expect(getRegistry().get('__test-plugin-broken__/hello')).toBeUndefined();
|
|
152
|
+
});
|
|
111
153
|
});
|
|
112
154
|
describe('executeCommand', () => {
|
|
113
155
|
beforeEach(() => {
|
package/dist/errors.d.ts
CHANGED
|
@@ -31,7 +31,7 @@ export declare class AuthRequiredError extends CliError {
|
|
|
31
31
|
constructor(domain: string, message?: string);
|
|
32
32
|
}
|
|
33
33
|
export declare class TimeoutError extends CliError {
|
|
34
|
-
constructor(label: string, seconds: number);
|
|
34
|
+
constructor(label: string, seconds: number, hint?: string);
|
|
35
35
|
}
|
|
36
36
|
export declare class ArgumentError extends CliError {
|
|
37
37
|
constructor(message: string, hint?: string);
|
package/dist/errors.js
CHANGED
|
@@ -41,8 +41,8 @@ export class AuthRequiredError extends CliError {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
export class TimeoutError extends CliError {
|
|
44
|
-
constructor(label, seconds) {
|
|
45
|
-
super('TIMEOUT', `${label} timed out after ${seconds}s`, 'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var');
|
|
44
|
+
constructor(label, seconds, hint) {
|
|
45
|
+
super('TIMEOUT', `${label} timed out after ${seconds}s`, hint ?? 'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var');
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
export class ArgumentError extends CliError {
|
package/dist/execution.js
CHANGED
|
@@ -110,6 +110,25 @@ function ensureRequiredEnv(cmd) {
|
|
|
110
110
|
return;
|
|
111
111
|
throw new CommandExecutionError(`Command ${fullName(cmd)} requires environment variable ${missing.name}.`, missing.help ?? `Set ${missing.name} before running ${fullName(cmd)}.`);
|
|
112
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Check if the browser is already on the target domain, avoiding redundant navigation.
|
|
115
|
+
* Returns true if current page hostname matches the pre-nav URL hostname.
|
|
116
|
+
*/
|
|
117
|
+
async function isAlreadyOnDomain(page, targetUrl) {
|
|
118
|
+
if (!page.getCurrentUrl)
|
|
119
|
+
return false;
|
|
120
|
+
try {
|
|
121
|
+
const currentUrl = await page.getCurrentUrl();
|
|
122
|
+
if (!currentUrl)
|
|
123
|
+
return false;
|
|
124
|
+
const currentHost = new URL(currentUrl).hostname;
|
|
125
|
+
const targetHost = new URL(targetUrl).hostname;
|
|
126
|
+
return currentHost === targetHost;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
113
132
|
export async function executeCommand(cmd, rawKwargs, debug = false) {
|
|
114
133
|
let kwargs;
|
|
115
134
|
try {
|
|
@@ -151,13 +170,21 @@ export async function executeCommand(cmd, rawKwargs, debug = false) {
|
|
|
151
170
|
result = await browserSession(BrowserFactory, async (page) => {
|
|
152
171
|
const preNavUrl = resolvePreNav(cmd);
|
|
153
172
|
if (preNavUrl) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
await page.wait(2);
|
|
157
|
-
}
|
|
158
|
-
catch (err) {
|
|
173
|
+
const skip = await isAlreadyOnDomain(page, preNavUrl);
|
|
174
|
+
if (skip) {
|
|
159
175
|
if (debug)
|
|
160
|
-
console.error(`[pre-nav]
|
|
176
|
+
console.error(`[pre-nav] Already on target domain, skipping navigation`);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
try {
|
|
180
|
+
// goto() already includes smart DOM-settle detection (waitForDomStable).
|
|
181
|
+
// No additional fixed sleep needed.
|
|
182
|
+
await page.goto(preNavUrl);
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
if (debug)
|
|
186
|
+
console.error(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
|
|
187
|
+
}
|
|
161
188
|
}
|
|
162
189
|
}
|
|
163
190
|
return runWithTimeout(runCommand(cmd, page, kwargs, debug), {
|
|
@@ -167,7 +194,18 @@ export async function executeCommand(cmd, rawKwargs, debug = false) {
|
|
|
167
194
|
}, { workspace: `site:${cmd.site}` });
|
|
168
195
|
}
|
|
169
196
|
else {
|
|
170
|
-
|
|
197
|
+
// Non-browser commands: apply timeout only when explicitly configured.
|
|
198
|
+
const timeout = cmd.timeoutSeconds;
|
|
199
|
+
if (timeout !== undefined && timeout > 0) {
|
|
200
|
+
result = await runWithTimeout(runCommand(cmd, null, kwargs, debug), {
|
|
201
|
+
timeout,
|
|
202
|
+
label: fullName(cmd),
|
|
203
|
+
hint: `Increase the adapter's timeoutSeconds setting (currently ${timeout}s)`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
result = await runCommand(cmd, null, kwargs, debug);
|
|
208
|
+
}
|
|
171
209
|
}
|
|
172
210
|
}
|
|
173
211
|
catch (err) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { executeCommand } from './execution.js';
|
|
3
|
+
import { TimeoutError } from './errors.js';
|
|
4
|
+
import { cli, Strategy } from './registry.js';
|
|
5
|
+
import { withTimeoutMs } from './runtime.js';
|
|
6
|
+
describe('executeCommand — non-browser timeout', () => {
|
|
7
|
+
it('applies timeoutSeconds to non-browser commands', async () => {
|
|
8
|
+
const cmd = cli({
|
|
9
|
+
site: 'test-execution',
|
|
10
|
+
name: 'non-browser-timeout',
|
|
11
|
+
description: 'test non-browser timeout',
|
|
12
|
+
browser: false,
|
|
13
|
+
strategy: Strategy.PUBLIC,
|
|
14
|
+
timeoutSeconds: 0.01,
|
|
15
|
+
func: () => new Promise(() => { }),
|
|
16
|
+
});
|
|
17
|
+
// Sentinel timeout at 200ms — if the inner 10ms timeout fires first,
|
|
18
|
+
// the error will be a TimeoutError with the command label, not 'sentinel'.
|
|
19
|
+
const error = await withTimeoutMs(executeCommand(cmd, {}), 200, 'sentinel timeout')
|
|
20
|
+
.catch((err) => err);
|
|
21
|
+
expect(error).toBeInstanceOf(TimeoutError);
|
|
22
|
+
expect(error).toMatchObject({
|
|
23
|
+
code: 'TIMEOUT',
|
|
24
|
+
message: 'test-execution/non-browser-timeout timed out after 0.01s',
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
it('skips timeout when timeoutSeconds is 0', async () => {
|
|
28
|
+
const cmd = cli({
|
|
29
|
+
site: 'test-execution',
|
|
30
|
+
name: 'non-browser-zero-timeout',
|
|
31
|
+
description: 'test zero timeout bypasses wrapping',
|
|
32
|
+
browser: false,
|
|
33
|
+
strategy: Strategy.PUBLIC,
|
|
34
|
+
timeoutSeconds: 0,
|
|
35
|
+
func: () => new Promise(() => { }),
|
|
36
|
+
});
|
|
37
|
+
// With timeout guard skipped, the sentinel fires instead.
|
|
38
|
+
await expect(withTimeoutMs(executeCommand(cmd, {}), 50, 'sentinel timeout')).rejects.toThrow('sentinel timeout');
|
|
39
|
+
});
|
|
40
|
+
});
|
package/dist/external.js
CHANGED
|
@@ -12,7 +12,10 @@ function getUserRegistryPath() {
|
|
|
12
12
|
const home = os.homedir();
|
|
13
13
|
return path.join(home, '.opencli', 'external-clis.yaml');
|
|
14
14
|
}
|
|
15
|
+
let _cachedExternalClis = null;
|
|
15
16
|
export function loadExternalClis() {
|
|
17
|
+
if (_cachedExternalClis)
|
|
18
|
+
return _cachedExternalClis;
|
|
16
19
|
const configs = new Map();
|
|
17
20
|
// 1. Load built-in
|
|
18
21
|
const builtinPath = path.resolve(__dirname, 'external-clis.yaml');
|
|
@@ -41,7 +44,8 @@ export function loadExternalClis() {
|
|
|
41
44
|
catch (err) {
|
|
42
45
|
log.warn(`Failed to parse user external-clis.yaml: ${getErrorMessage(err)}`);
|
|
43
46
|
}
|
|
44
|
-
|
|
47
|
+
_cachedExternalClis = Array.from(configs.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
48
|
+
return _cachedExternalClis;
|
|
45
49
|
}
|
|
46
50
|
export function isBinaryInstalled(binary) {
|
|
47
51
|
try {
|
|
@@ -200,5 +204,6 @@ export function registerExternalCli(name, opts) {
|
|
|
200
204
|
}
|
|
201
205
|
const dump = yaml.dump(items, { indent: 2, sortKeys: true });
|
|
202
206
|
fs.writeFileSync(userPath, dump, 'utf8');
|
|
207
|
+
_cachedExternalClis = null; // Invalidate cache so next load reflects the change
|
|
203
208
|
console.log(chalk.dim(userPath));
|
|
204
209
|
}
|
package/dist/main.js
CHANGED
|
@@ -24,6 +24,7 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
24
24
|
const __dirname = path.dirname(__filename);
|
|
25
25
|
const BUILTIN_CLIS = path.resolve(__dirname, 'clis');
|
|
26
26
|
const USER_CLIS = path.join(os.homedir(), '.opencli', 'clis');
|
|
27
|
+
// Sequential: plugins must run after built-in discovery so they can override built-in commands.
|
|
27
28
|
await discoverClis(BUILTIN_CLIS, USER_CLIS);
|
|
28
29
|
await discoverPlugins();
|
|
29
30
|
// Register exit hook: notice appears after command output (same as npm/gh/yarn)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin scaffold: generates a ready-to-develop plugin directory.
|
|
3
|
+
*
|
|
4
|
+
* Usage: opencli plugin create <name> [--dir <path>]
|
|
5
|
+
*
|
|
6
|
+
* Creates:
|
|
7
|
+
* <name>/
|
|
8
|
+
* opencli-plugin.json — manifest with name, version, description
|
|
9
|
+
* package.json — ESM package with opencli peer dependency
|
|
10
|
+
* hello.yaml — sample YAML command
|
|
11
|
+
* greet.ts — sample TS command using the current registry API
|
|
12
|
+
* README.md — basic documentation
|
|
13
|
+
*/
|
|
14
|
+
export interface ScaffoldOptions {
|
|
15
|
+
/** Directory to create the plugin in. Defaults to `./<name>` */
|
|
16
|
+
dir?: string;
|
|
17
|
+
/** Plugin description */
|
|
18
|
+
description?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ScaffoldResult {
|
|
21
|
+
name: string;
|
|
22
|
+
dir: string;
|
|
23
|
+
files: string[];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Create a new plugin scaffold directory.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createPluginScaffold(name: string, opts?: ScaffoldOptions): ScaffoldResult;
|