@jackwener/opencli 1.6.10 → 1.7.0
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/README.md +15 -2
- package/README.zh-CN.md +15 -2
- package/dist/clis/jianyu/detail.js +20 -0
- package/dist/clis/jianyu/search.d.ts +41 -4
- package/dist/clis/jianyu/search.js +458 -96
- package/dist/clis/jianyu/search.test.js +105 -0
- package/dist/clis/jianyu/shared/china-bid-search.d.ts +12 -0
- package/dist/clis/jianyu/shared/china-bid-search.js +165 -0
- package/dist/clis/jianyu/shared/procurement-contract.d.ts +68 -0
- package/dist/clis/jianyu/shared/procurement-contract.js +324 -0
- package/dist/clis/jianyu/shared/procurement-contract.test.d.ts +1 -0
- package/dist/clis/jianyu/shared/procurement-contract.test.js +72 -0
- package/dist/clis/jianyu/shared/procurement-detail.d.ts +6 -0
- package/dist/clis/jianyu/shared/procurement-detail.js +92 -0
- package/dist/clis/jianyu/shared/procurement-detail.test.d.ts +1 -0
- package/dist/clis/jianyu/shared/procurement-detail.test.js +72 -0
- package/dist/clis/xiaoe/catalog.js +36 -0
- package/dist/src/browser/bridge.js +1 -1
- package/dist/src/browser/daemon-client.d.ts +2 -1
- package/dist/src/browser/daemon-client.js +3 -1
- package/dist/src/browser/daemon-client.test.js +0 -3
- package/dist/src/browser.test.js +0 -1
- package/dist/src/cli.js +1 -9
- package/dist/src/commands/daemon.d.ts +2 -6
- package/dist/src/commands/daemon.js +2 -58
- package/dist/src/commands/daemon.test.js +24 -120
- package/dist/src/constants.d.ts +0 -2
- package/dist/src/constants.js +0 -2
- package/dist/src/daemon.d.ts +1 -1
- package/dist/src/daemon.js +2 -15
- package/dist/src/execution.js +5 -1
- package/package.json +2 -1
- package/dist/src/daemon.test.js +0 -65
- package/dist/src/idle-manager.d.ts +0 -19
- package/dist/src/idle-manager.js +0 -54
- /package/dist/{src/daemon.test.d.ts → clis/jianyu/detail.d.ts} +0 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { __test__, toProcurementDetailRecord, toProcurementSearchRecords, } from './procurement-contract.js';
|
|
3
|
+
describe('procurement contract helpers', () => {
|
|
4
|
+
it('builds v2 search records with compatibility fields', () => {
|
|
5
|
+
const rows = toProcurementSearchRecords([
|
|
6
|
+
{
|
|
7
|
+
title: '某项目电梯采购公告',
|
|
8
|
+
url: 'https://example.com/notice/detail?id=1',
|
|
9
|
+
date: '2026-04-09',
|
|
10
|
+
contextText: '招标公告 项目编号:ABC-123 预算金额:100万元 投标截止时间:2026-04-30',
|
|
11
|
+
},
|
|
12
|
+
], {
|
|
13
|
+
site: 'jianyu',
|
|
14
|
+
query: '电梯',
|
|
15
|
+
limit: 10,
|
|
16
|
+
});
|
|
17
|
+
expect(rows).toHaveLength(1);
|
|
18
|
+
expect(rows[0].rank).toBe(1);
|
|
19
|
+
expect(rows[0].publish_time).toBe('2026-04-09');
|
|
20
|
+
expect(rows[0].date).toBe('2026-04-09');
|
|
21
|
+
expect(rows[0].summary).toBe(rows[0].snippet);
|
|
22
|
+
expect(rows[0].content_type).toBe('notice');
|
|
23
|
+
expect(rows[0].source_site).toBe('jianyu');
|
|
24
|
+
expect(rows[0].project_code).toContain('ABC-123');
|
|
25
|
+
});
|
|
26
|
+
it('throws extraction_drift when all rows are navigation noise', () => {
|
|
27
|
+
expect(() => toProcurementSearchRecords([
|
|
28
|
+
{
|
|
29
|
+
title: '官网首页',
|
|
30
|
+
url: 'https://example.com/index',
|
|
31
|
+
contextText: '官网首页 联系我们',
|
|
32
|
+
},
|
|
33
|
+
], {
|
|
34
|
+
site: 'ggzy',
|
|
35
|
+
query: '电梯',
|
|
36
|
+
limit: 10,
|
|
37
|
+
})).toThrow('[taxonomy=extraction_drift]');
|
|
38
|
+
});
|
|
39
|
+
it('rejects rows that look like procurement notices but miss the query', () => {
|
|
40
|
+
expect(() => toProcurementSearchRecords([
|
|
41
|
+
{
|
|
42
|
+
title: '某项目采购公告',
|
|
43
|
+
url: 'https://example.com/notice/detail?id=1',
|
|
44
|
+
contextText: '招标公告 项目编号:ABC-123 预算金额:100万元',
|
|
45
|
+
},
|
|
46
|
+
], {
|
|
47
|
+
site: 'jianyu',
|
|
48
|
+
query: '电梯',
|
|
49
|
+
limit: 10,
|
|
50
|
+
})).toThrow('[taxonomy=extraction_drift]');
|
|
51
|
+
});
|
|
52
|
+
it('builds detail record with evidence blocks', () => {
|
|
53
|
+
const detail = toProcurementDetailRecord({
|
|
54
|
+
title: '电梯采购公告',
|
|
55
|
+
url: 'https://example.com/notice/detail/100',
|
|
56
|
+
contextText: '项目编号:A-100。预算金额:200万元。投标截止时间:2026年04月30日。',
|
|
57
|
+
}, {
|
|
58
|
+
site: 'powerchina',
|
|
59
|
+
query: '电梯',
|
|
60
|
+
});
|
|
61
|
+
expect(detail.content_type).toBe('notice');
|
|
62
|
+
expect(detail.detail_text).toContain('预算金额');
|
|
63
|
+
expect(detail.evidence_blocks.length).toBeGreaterThan(0);
|
|
64
|
+
});
|
|
65
|
+
it('classifies detail urls and content type', () => {
|
|
66
|
+
expect(__test__.isDetailPage('https://a.com/notice/detail?id=1')).toBe(true);
|
|
67
|
+
expect(__test__.isDetailPage('https://shandong.jianyu360.cn/jybx/20260310_26030938267551.html')).toBe(true);
|
|
68
|
+
expect(__test__.isDetailPage('https://a.com/search?page=1')).toBe(false);
|
|
69
|
+
expect(__test__.classifyContentType('中标结果公告', 'https://a.com/detail/1', '中标候选人')).toBe('result');
|
|
70
|
+
expect(__test__.classifyContentType('电梯采购公告', 'https://shandong.jianyu360.cn/jybx/20260310_26030938267551.html', '首页 帮助中心 招标公告')).toBe('notice');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { cleanText, toProcurementDetailRecord, taxonomyError, } from './procurement-contract.js';
|
|
2
|
+
const DETAIL_MAX_ATTEMPTS = 3;
|
|
3
|
+
const RETRYABLE_DETAIL_ERROR_PATTERNS = [
|
|
4
|
+
/execution context was destroyed/i,
|
|
5
|
+
/detached/i,
|
|
6
|
+
/target closed/i,
|
|
7
|
+
/cannot find context with specified id/i,
|
|
8
|
+
/\[taxonomy=empty_result\]/i,
|
|
9
|
+
];
|
|
10
|
+
function isRetryableDetailError(error) {
|
|
11
|
+
const message = error instanceof Error
|
|
12
|
+
? cleanText(error.message)
|
|
13
|
+
: cleanText(String(error ?? ''));
|
|
14
|
+
if (!message)
|
|
15
|
+
return false;
|
|
16
|
+
return RETRYABLE_DETAIL_ERROR_PATTERNS.some((pattern) => pattern.test(message));
|
|
17
|
+
}
|
|
18
|
+
async function extractDetailPayload(page, targetUrl) {
|
|
19
|
+
await page.goto(targetUrl);
|
|
20
|
+
await page.wait(2);
|
|
21
|
+
return await page.evaluate(`
|
|
22
|
+
(() => {
|
|
23
|
+
const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
|
24
|
+
const title = clean(document.title || '');
|
|
25
|
+
const bodyText = clean(document.body ? document.body.innerText : '');
|
|
26
|
+
const maxLength = 12000;
|
|
27
|
+
const limitedText = bodyText.length > maxLength ? bodyText.slice(0, maxLength) : bodyText;
|
|
28
|
+
const dateMatch = limitedText.match(/(20\\d{2})[.\\-/年](\\d{1,2})[.\\-/月](\\d{1,2})/);
|
|
29
|
+
const publishTime = dateMatch
|
|
30
|
+
? dateMatch[1] + '-' + String(dateMatch[2]).padStart(2, '0') + '-' + String(dateMatch[3]).padStart(2, '0')
|
|
31
|
+
: '';
|
|
32
|
+
return {
|
|
33
|
+
title,
|
|
34
|
+
detailText: limitedText,
|
|
35
|
+
publishTime,
|
|
36
|
+
};
|
|
37
|
+
})()
|
|
38
|
+
`);
|
|
39
|
+
}
|
|
40
|
+
export async function runProcurementDetail(page, { url, site, query = '', }) {
|
|
41
|
+
const targetUrl = cleanText(url);
|
|
42
|
+
if (!targetUrl) {
|
|
43
|
+
throw taxonomyError('relay_unavailable', {
|
|
44
|
+
site,
|
|
45
|
+
command: 'detail',
|
|
46
|
+
detail: 'missing required detail url',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
let lastError = null;
|
|
50
|
+
for (let attempt = 1; attempt <= DETAIL_MAX_ATTEMPTS; attempt += 1) {
|
|
51
|
+
try {
|
|
52
|
+
const payload = await extractDetailPayload(page, targetUrl);
|
|
53
|
+
if (!payload || typeof payload !== 'object') {
|
|
54
|
+
throw taxonomyError('extraction_drift', {
|
|
55
|
+
site,
|
|
56
|
+
command: 'detail',
|
|
57
|
+
detail: `detail extraction returned invalid payload: ${targetUrl}`,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const row = payload;
|
|
61
|
+
const title = cleanText(row.title);
|
|
62
|
+
const detailText = cleanText(row.detailText);
|
|
63
|
+
const publishTime = cleanText(row.publishTime);
|
|
64
|
+
if (!title && !detailText) {
|
|
65
|
+
throw taxonomyError('empty_result', {
|
|
66
|
+
site,
|
|
67
|
+
command: 'detail',
|
|
68
|
+
detail: `detail page has no readable content: ${targetUrl}`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return [
|
|
72
|
+
toProcurementDetailRecord({
|
|
73
|
+
title: title || targetUrl,
|
|
74
|
+
url: targetUrl,
|
|
75
|
+
contextText: detailText,
|
|
76
|
+
publishTime,
|
|
77
|
+
}, {
|
|
78
|
+
site,
|
|
79
|
+
query,
|
|
80
|
+
}),
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
lastError = error;
|
|
85
|
+
if (attempt >= DETAIL_MAX_ATTEMPTS || !isRetryableDetailError(error)) {
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
await page.wait(Math.min(1.5, 0.5 * attempt));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
throw lastError;
|
|
92
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { runProcurementDetail } from './procurement-detail.js';
|
|
3
|
+
function createPage(evaluateImpl) {
|
|
4
|
+
return {
|
|
5
|
+
goto: async () => { },
|
|
6
|
+
wait: async () => { },
|
|
7
|
+
evaluate: async () => evaluateImpl(),
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
describe('procurement detail runner', () => {
|
|
11
|
+
it('retries transient execution-context errors and succeeds', async () => {
|
|
12
|
+
let attempts = 0;
|
|
13
|
+
const page = createPage(async () => {
|
|
14
|
+
attempts += 1;
|
|
15
|
+
if (attempts < 3) {
|
|
16
|
+
throw new Error('Execution context was destroyed.');
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
title: '电梯采购公告',
|
|
20
|
+
detailText: '项目编号:ABC-100 预算金额:100万元 截止时间:2026-04-30',
|
|
21
|
+
publishTime: '2026-04-09',
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
const rows = await runProcurementDetail(page, {
|
|
25
|
+
url: 'https://example.com/jybx/20260409_1.html',
|
|
26
|
+
site: 'jianyu',
|
|
27
|
+
query: '电梯',
|
|
28
|
+
});
|
|
29
|
+
expect(attempts).toBe(3);
|
|
30
|
+
expect(rows).toHaveLength(1);
|
|
31
|
+
expect(rows[0].title).toContain('电梯采购公告');
|
|
32
|
+
});
|
|
33
|
+
it('retries empty_result once and succeeds on the next attempt', async () => {
|
|
34
|
+
let attempts = 0;
|
|
35
|
+
const page = createPage(async () => {
|
|
36
|
+
attempts += 1;
|
|
37
|
+
if (attempts === 1) {
|
|
38
|
+
return {
|
|
39
|
+
title: '',
|
|
40
|
+
detailText: '',
|
|
41
|
+
publishTime: '',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
title: '防爆电梯采购公告',
|
|
46
|
+
detailText: '采购内容:防爆电梯2台。',
|
|
47
|
+
publishTime: '2026-03-10',
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
const rows = await runProcurementDetail(page, {
|
|
51
|
+
url: 'https://example.com/jybx/20260310_1.html',
|
|
52
|
+
site: 'jianyu',
|
|
53
|
+
query: '防爆电梯',
|
|
54
|
+
});
|
|
55
|
+
expect(attempts).toBe(2);
|
|
56
|
+
expect(rows).toHaveLength(1);
|
|
57
|
+
expect(rows[0].title).toContain('防爆电梯');
|
|
58
|
+
});
|
|
59
|
+
it('does not retry non-retryable extraction_drift errors', async () => {
|
|
60
|
+
let attempts = 0;
|
|
61
|
+
const page = createPage(async () => {
|
|
62
|
+
attempts += 1;
|
|
63
|
+
return null;
|
|
64
|
+
});
|
|
65
|
+
await expect(runProcurementDetail(page, {
|
|
66
|
+
url: 'https://example.com/jybx/20260310_1.html',
|
|
67
|
+
site: 'jianyu',
|
|
68
|
+
query: '电梯',
|
|
69
|
+
})).rejects.toThrow('[taxonomy=extraction_drift]');
|
|
70
|
+
expect(attempts).toBe(1);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -40,6 +40,42 @@ cli({
|
|
|
40
40
|
clickTab('目录');
|
|
41
41
|
await new Promise(function(r) { setTimeout(r, 2000); });
|
|
42
42
|
|
|
43
|
+
function getScrollTargets() {
|
|
44
|
+
return document.querySelectorAll('.scroll-view, .list-wrap, .scroller, #app');
|
|
45
|
+
}
|
|
46
|
+
function getMaxScrollHeight(scrollers) {
|
|
47
|
+
var maxHeight = document.body.scrollHeight;
|
|
48
|
+
for (var i = 0; i < scrollers.length; i++) {
|
|
49
|
+
if (scrollers[i].scrollHeight > maxHeight) maxHeight = scrollers[i].scrollHeight;
|
|
50
|
+
}
|
|
51
|
+
return maxHeight;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 模拟滚动以实现动态加载
|
|
55
|
+
var prevMaxScrollHeight = 0;
|
|
56
|
+
for (var sc = 0; sc < 20; sc++) {
|
|
57
|
+
window.scrollTo(0, 999999);
|
|
58
|
+
var scrollers = getScrollTargets();
|
|
59
|
+
for(var si = 0; si < scrollers.length; si++) {
|
|
60
|
+
if(scrollers[si].scrollHeight > scrollers[si].clientHeight) scrollers[si].scrollTop = scrollers[si].scrollHeight;
|
|
61
|
+
}
|
|
62
|
+
await new Promise(function(r) { setTimeout(r, 800); });
|
|
63
|
+
|
|
64
|
+
// 点击可能存在的下拉/加载更多
|
|
65
|
+
var moreTabs = document.querySelectorAll('span, div, p');
|
|
66
|
+
for (var bi = 0; bi < moreTabs.length; bi++) {
|
|
67
|
+
var t = moreTabs[bi].textContent.trim();
|
|
68
|
+
if ((t === '点击加载更多' || t === '展开更多' || t === '加载更多') && moreTabs[bi].clientHeight > 0) {
|
|
69
|
+
try { moreTabs[bi].click(); } catch(e){}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
var maxScrollHeight = getMaxScrollHeight(getScrollTargets());
|
|
74
|
+
if (sc > 3 && maxScrollHeight === prevMaxScrollHeight) break;
|
|
75
|
+
prevMaxScrollHeight = maxScrollHeight;
|
|
76
|
+
}
|
|
77
|
+
await new Promise(function(r) { setTimeout(r, 1000); });
|
|
78
|
+
|
|
43
79
|
// ===== 专栏 / 大专栏 =====
|
|
44
80
|
if (resourceType === 6 || resourceType === 8) {
|
|
45
81
|
await new Promise(function(r) { setTimeout(r, 1000); });
|
|
@@ -45,7 +45,7 @@ export class BrowserBridge {
|
|
|
45
45
|
if (this._state === 'closed')
|
|
46
46
|
return;
|
|
47
47
|
this._state = 'closing';
|
|
48
|
-
// We don't kill the daemon — it
|
|
48
|
+
// We don't kill the daemon — it's persistent.
|
|
49
49
|
// Just clean up our reference.
|
|
50
50
|
this._page = null;
|
|
51
51
|
this._state = 'closed';
|
|
@@ -32,6 +32,8 @@ export interface DaemonCommand {
|
|
|
32
32
|
pattern?: string;
|
|
33
33
|
cdpMethod?: string;
|
|
34
34
|
cdpParams?: Record<string, unknown>;
|
|
35
|
+
/** When true, automation windows are created in the foreground */
|
|
36
|
+
windowFocused?: boolean;
|
|
35
37
|
}
|
|
36
38
|
export interface DaemonResult {
|
|
37
39
|
id: string;
|
|
@@ -48,7 +50,6 @@ export interface DaemonStatus {
|
|
|
48
50
|
extensionConnected: boolean;
|
|
49
51
|
extensionVersion?: string;
|
|
50
52
|
pending: number;
|
|
51
|
-
lastCliRequestTime: number;
|
|
52
53
|
memoryMB: number;
|
|
53
54
|
port: number;
|
|
54
55
|
}
|
|
@@ -73,7 +73,9 @@ async function sendCommandRaw(action, params) {
|
|
|
73
73
|
const maxRetries = 4;
|
|
74
74
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
75
75
|
const id = generateId();
|
|
76
|
-
const
|
|
76
|
+
const wf = process.env.OPENCLI_WINDOW_FOCUSED;
|
|
77
|
+
const windowFocused = (wf === '1' || wf === 'true') ? true : undefined;
|
|
78
|
+
const command = { id, action, ...params, ...(windowFocused && { windowFocused }) };
|
|
77
79
|
try {
|
|
78
80
|
const res = await requestDaemon('/command', {
|
|
79
81
|
method: 'POST',
|
|
@@ -15,7 +15,6 @@ describe('daemon-client', () => {
|
|
|
15
15
|
extensionConnected: true,
|
|
16
16
|
extensionVersion: '1.2.3',
|
|
17
17
|
pending: 0,
|
|
18
|
-
lastCliRequestTime: Date.now(),
|
|
19
18
|
memoryMB: 32,
|
|
20
19
|
port: 19825,
|
|
21
20
|
};
|
|
@@ -53,7 +52,6 @@ describe('daemon-client', () => {
|
|
|
53
52
|
uptime: 10,
|
|
54
53
|
extensionConnected: false,
|
|
55
54
|
pending: 0,
|
|
56
|
-
lastCliRequestTime: Date.now(),
|
|
57
55
|
memoryMB: 16,
|
|
58
56
|
port: 19825,
|
|
59
57
|
};
|
|
@@ -71,7 +69,6 @@ describe('daemon-client', () => {
|
|
|
71
69
|
extensionConnected: true,
|
|
72
70
|
extensionVersion: '1.2.3',
|
|
73
71
|
pending: 0,
|
|
74
|
-
lastCliRequestTime: Date.now(),
|
|
75
72
|
memoryMB: 32,
|
|
76
73
|
port: 19825,
|
|
77
74
|
};
|
package/dist/src/browser.test.js
CHANGED
package/dist/src/cli.js
CHANGED
|
@@ -19,7 +19,7 @@ import { printCompletionScript } from './completion.js';
|
|
|
19
19
|
import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js';
|
|
20
20
|
import { registerAllCommands } from './commanderAdapter.js';
|
|
21
21
|
import { EXIT_CODES, getErrorMessage } from './errors.js';
|
|
22
|
-
import {
|
|
22
|
+
import { daemonStop } from './commands/daemon.js';
|
|
23
23
|
const CLI_FILE = fileURLToPath(import.meta.url);
|
|
24
24
|
/** Create a browser page for browser commands. Uses a dedicated browser workspace for session persistence. */
|
|
25
25
|
async function getBrowserPage() {
|
|
@@ -892,18 +892,10 @@ cli({
|
|
|
892
892
|
});
|
|
893
893
|
// ── Built-in: daemon ──────────────────────────────────────────────────────
|
|
894
894
|
const daemonCmd = program.command('daemon').description('Manage the opencli daemon');
|
|
895
|
-
daemonCmd
|
|
896
|
-
.command('status')
|
|
897
|
-
.description('Show daemon status')
|
|
898
|
-
.action(async () => { await daemonStatus(); });
|
|
899
895
|
daemonCmd
|
|
900
896
|
.command('stop')
|
|
901
897
|
.description('Stop the daemon')
|
|
902
898
|
.action(async () => { await daemonStop(); });
|
|
903
|
-
daemonCmd
|
|
904
|
-
.command('restart')
|
|
905
|
-
.description('Restart the daemon')
|
|
906
|
-
.action(async () => { await daemonRestart(); });
|
|
907
899
|
// ── External CLIs ─────────────────────────────────────────────────────────
|
|
908
900
|
const externalClis = loadExternalClis();
|
|
909
901
|
program
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLI
|
|
3
|
-
* opencli daemon
|
|
4
|
-
* opencli daemon stop — graceful shutdown
|
|
5
|
-
* opencli daemon restart — stop + respawn
|
|
2
|
+
* CLI command for daemon lifecycle:
|
|
3
|
+
* opencli daemon stop — graceful shutdown
|
|
6
4
|
*/
|
|
7
|
-
export declare function daemonStatus(): Promise<void>;
|
|
8
5
|
export declare function daemonStop(): Promise<void>;
|
|
9
|
-
export declare function daemonRestart(): Promise<void>;
|
|
@@ -1,35 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLI
|
|
3
|
-
* opencli daemon
|
|
4
|
-
* opencli daemon stop — graceful shutdown
|
|
5
|
-
* opencli daemon restart — stop + respawn
|
|
2
|
+
* CLI command for daemon lifecycle:
|
|
3
|
+
* opencli daemon stop — graceful shutdown
|
|
6
4
|
*/
|
|
7
5
|
import chalk from 'chalk';
|
|
8
6
|
import { fetchDaemonStatus, requestDaemonShutdown } from '../browser/daemon-client.js';
|
|
9
|
-
import { formatDuration } from '../download/progress.js';
|
|
10
|
-
function formatTimeSince(timestampMs) {
|
|
11
|
-
const seconds = (Date.now() - timestampMs) / 1000;
|
|
12
|
-
if (seconds < 60)
|
|
13
|
-
return `${Math.floor(seconds)}s ago`;
|
|
14
|
-
const m = Math.floor(seconds / 60);
|
|
15
|
-
if (m < 60)
|
|
16
|
-
return `${m} min ago`;
|
|
17
|
-
const h = Math.floor(m / 60);
|
|
18
|
-
return `${h}h ${m % 60}m ago`;
|
|
19
|
-
}
|
|
20
|
-
export async function daemonStatus() {
|
|
21
|
-
const status = await fetchDaemonStatus();
|
|
22
|
-
if (!status) {
|
|
23
|
-
console.log(`Daemon: ${chalk.dim('not running')}`);
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
26
|
-
console.log(`Daemon: ${chalk.green('running')} (PID ${status.pid})`);
|
|
27
|
-
console.log(`Uptime: ${formatDuration(Math.round(status.uptime * 1000))}`);
|
|
28
|
-
console.log(`Extension: ${status.extensionConnected ? chalk.green('connected') : chalk.yellow('disconnected')}`);
|
|
29
|
-
console.log(`Last CLI request: ${formatTimeSince(status.lastCliRequestTime)}`);
|
|
30
|
-
console.log(`Memory: ${status.memoryMB} MB`);
|
|
31
|
-
console.log(`Port: ${status.port}`);
|
|
32
|
-
}
|
|
33
7
|
export async function daemonStop() {
|
|
34
8
|
const status = await fetchDaemonStatus();
|
|
35
9
|
if (!status) {
|
|
@@ -45,33 +19,3 @@ export async function daemonStop() {
|
|
|
45
19
|
process.exitCode = 1;
|
|
46
20
|
}
|
|
47
21
|
}
|
|
48
|
-
export async function daemonRestart() {
|
|
49
|
-
const status = await fetchDaemonStatus();
|
|
50
|
-
if (status) {
|
|
51
|
-
const ok = await requestDaemonShutdown();
|
|
52
|
-
if (!ok) {
|
|
53
|
-
console.error(chalk.red('Failed to stop daemon.'));
|
|
54
|
-
process.exitCode = 1;
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
// Wait for daemon to actually exit (poll until unreachable)
|
|
58
|
-
const deadline = Date.now() + 5000;
|
|
59
|
-
while (Date.now() < deadline) {
|
|
60
|
-
await new Promise(r => setTimeout(r, 200));
|
|
61
|
-
if (!(await fetchDaemonStatus()))
|
|
62
|
-
break;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
// Import BrowserBridge to spawn a new daemon
|
|
66
|
-
const { BrowserBridge } = await import('../browser/bridge.js');
|
|
67
|
-
const bridge = new BrowserBridge();
|
|
68
|
-
try {
|
|
69
|
-
console.log('Starting daemon...');
|
|
70
|
-
await bridge.connect({ timeout: 10 });
|
|
71
|
-
console.log(chalk.green('Daemon restarted.'));
|
|
72
|
-
}
|
|
73
|
-
catch (err) {
|
|
74
|
-
console.error(chalk.red(`Failed to restart daemon: ${err instanceof Error ? err.message : err}`));
|
|
75
|
-
process.exitCode = 1;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
@@ -6,23 +6,16 @@ const { fetchDaemonStatusMock, requestDaemonShutdownMock, } = vi.hoisted(() => (
|
|
|
6
6
|
vi.mock('chalk', () => ({
|
|
7
7
|
default: {
|
|
8
8
|
green: (s) => s,
|
|
9
|
-
yellow: (s) => s,
|
|
10
9
|
red: (s) => s,
|
|
11
10
|
dim: (s) => s,
|
|
12
11
|
},
|
|
13
12
|
}));
|
|
14
|
-
const mockConnect = vi.fn();
|
|
15
|
-
vi.mock('../browser/bridge.js', () => ({
|
|
16
|
-
BrowserBridge: class {
|
|
17
|
-
connect = mockConnect;
|
|
18
|
-
},
|
|
19
|
-
}));
|
|
20
13
|
vi.mock('../browser/daemon-client.js', () => ({
|
|
21
14
|
fetchDaemonStatus: fetchDaemonStatusMock,
|
|
22
15
|
requestDaemonShutdown: requestDaemonShutdownMock,
|
|
23
16
|
}));
|
|
24
|
-
import {
|
|
25
|
-
describe('
|
|
17
|
+
import { daemonStop } from './daemon.js';
|
|
18
|
+
describe('daemonStop', () => {
|
|
26
19
|
let logSpy;
|
|
27
20
|
let errorSpy;
|
|
28
21
|
beforeEach(() => {
|
|
@@ -33,128 +26,39 @@ describe('daemon commands', () => {
|
|
|
33
26
|
});
|
|
34
27
|
afterEach(() => {
|
|
35
28
|
vi.restoreAllMocks();
|
|
36
|
-
mockConnect.mockReset();
|
|
37
29
|
});
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('not running'));
|
|
43
|
-
});
|
|
44
|
-
it('shows "not running" when daemon returns non-ok response', async () => {
|
|
45
|
-
fetchDaemonStatusMock.mockResolvedValue(null);
|
|
46
|
-
await daemonStatus();
|
|
47
|
-
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('not running'));
|
|
48
|
-
});
|
|
49
|
-
it('shows daemon info when running', async () => {
|
|
50
|
-
const status = {
|
|
51
|
-
ok: true,
|
|
52
|
-
pid: 12345,
|
|
53
|
-
uptime: 3661,
|
|
54
|
-
extensionConnected: true,
|
|
55
|
-
pending: 0,
|
|
56
|
-
lastCliRequestTime: Date.now() - 30_000,
|
|
57
|
-
memoryMB: 64,
|
|
58
|
-
port: 19825,
|
|
59
|
-
};
|
|
60
|
-
fetchDaemonStatusMock.mockResolvedValue(status);
|
|
61
|
-
await daemonStatus();
|
|
62
|
-
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('running'));
|
|
63
|
-
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('PID 12345'));
|
|
64
|
-
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('1h 1m'));
|
|
65
|
-
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('connected'));
|
|
66
|
-
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('64 MB'));
|
|
67
|
-
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('19825'));
|
|
68
|
-
});
|
|
69
|
-
it('shows disconnected when extension is not connected', async () => {
|
|
70
|
-
const status = {
|
|
71
|
-
ok: true,
|
|
72
|
-
pid: 99,
|
|
73
|
-
uptime: 120,
|
|
74
|
-
extensionConnected: false,
|
|
75
|
-
pending: 0,
|
|
76
|
-
lastCliRequestTime: Date.now() - 5000,
|
|
77
|
-
memoryMB: 32,
|
|
78
|
-
port: 19825,
|
|
79
|
-
};
|
|
80
|
-
fetchDaemonStatusMock.mockResolvedValue(status);
|
|
81
|
-
await daemonStatus();
|
|
82
|
-
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('disconnected'));
|
|
83
|
-
});
|
|
30
|
+
it('reports "not running" when daemon is unreachable', async () => {
|
|
31
|
+
fetchDaemonStatusMock.mockResolvedValue(null);
|
|
32
|
+
await daemonStop();
|
|
33
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('not running'));
|
|
84
34
|
});
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
pid: 12345,
|
|
95
|
-
uptime: 100,
|
|
96
|
-
extensionConnected: true,
|
|
97
|
-
pending: 0,
|
|
98
|
-
lastCliRequestTime: Date.now(),
|
|
99
|
-
memoryMB: 50,
|
|
100
|
-
port: 19825,
|
|
101
|
-
});
|
|
102
|
-
requestDaemonShutdownMock.mockResolvedValue(true);
|
|
103
|
-
await daemonStop();
|
|
104
|
-
expect(requestDaemonShutdownMock).toHaveBeenCalledTimes(1);
|
|
105
|
-
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Daemon stopped'));
|
|
106
|
-
});
|
|
107
|
-
it('reports failure when shutdown request fails', async () => {
|
|
108
|
-
fetchDaemonStatusMock.mockResolvedValue({
|
|
109
|
-
ok: true,
|
|
110
|
-
pid: 12345,
|
|
111
|
-
uptime: 100,
|
|
112
|
-
extensionConnected: true,
|
|
113
|
-
pending: 0,
|
|
114
|
-
lastCliRequestTime: Date.now(),
|
|
115
|
-
memoryMB: 50,
|
|
116
|
-
port: 19825,
|
|
117
|
-
});
|
|
118
|
-
requestDaemonShutdownMock.mockResolvedValue(false);
|
|
119
|
-
await daemonStop();
|
|
120
|
-
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to stop daemon'));
|
|
35
|
+
it('sends shutdown and reports success', async () => {
|
|
36
|
+
fetchDaemonStatusMock.mockResolvedValue({
|
|
37
|
+
ok: true,
|
|
38
|
+
pid: 12345,
|
|
39
|
+
uptime: 100,
|
|
40
|
+
extensionConnected: true,
|
|
41
|
+
pending: 0,
|
|
42
|
+
memoryMB: 50,
|
|
43
|
+
port: 19825,
|
|
121
44
|
});
|
|
45
|
+
requestDaemonShutdownMock.mockResolvedValue(true);
|
|
46
|
+
await daemonStop();
|
|
47
|
+
expect(requestDaemonShutdownMock).toHaveBeenCalledTimes(1);
|
|
48
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Daemon stopped'));
|
|
122
49
|
});
|
|
123
|
-
|
|
124
|
-
|
|
50
|
+
it('reports failure when shutdown request fails', async () => {
|
|
51
|
+
fetchDaemonStatusMock.mockResolvedValue({
|
|
125
52
|
ok: true,
|
|
126
53
|
pid: 12345,
|
|
127
54
|
uptime: 100,
|
|
128
55
|
extensionConnected: true,
|
|
129
56
|
pending: 0,
|
|
130
|
-
lastCliRequestTime: Date.now(),
|
|
131
57
|
memoryMB: 50,
|
|
132
58
|
port: 19825,
|
|
133
|
-
};
|
|
134
|
-
it('starts daemon directly when not running', async () => {
|
|
135
|
-
fetchDaemonStatusMock.mockResolvedValue(null);
|
|
136
|
-
mockConnect.mockResolvedValue(undefined);
|
|
137
|
-
await daemonRestart();
|
|
138
|
-
expect(mockConnect).toHaveBeenCalledWith({ timeout: 10 });
|
|
139
|
-
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Daemon restarted'));
|
|
140
|
-
});
|
|
141
|
-
it('stops then starts when daemon is running', async () => {
|
|
142
|
-
fetchDaemonStatusMock
|
|
143
|
-
.mockResolvedValueOnce(statusData)
|
|
144
|
-
.mockResolvedValueOnce(null);
|
|
145
|
-
requestDaemonShutdownMock.mockResolvedValue(true);
|
|
146
|
-
mockConnect.mockResolvedValue(undefined);
|
|
147
|
-
await daemonRestart();
|
|
148
|
-
expect(requestDaemonShutdownMock).toHaveBeenCalledTimes(1);
|
|
149
|
-
expect(mockConnect).toHaveBeenCalledWith({ timeout: 10 });
|
|
150
|
-
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Daemon restarted'));
|
|
151
|
-
});
|
|
152
|
-
it('aborts when shutdown fails', async () => {
|
|
153
|
-
fetchDaemonStatusMock.mockResolvedValue(statusData);
|
|
154
|
-
requestDaemonShutdownMock.mockResolvedValue(false);
|
|
155
|
-
await daemonRestart();
|
|
156
|
-
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to stop daemon'));
|
|
157
|
-
expect(mockConnect).not.toHaveBeenCalled();
|
|
158
59
|
});
|
|
60
|
+
requestDaemonShutdownMock.mockResolvedValue(false);
|
|
61
|
+
await daemonStop();
|
|
62
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to stop daemon'));
|
|
159
63
|
});
|
|
160
64
|
});
|
package/dist/src/constants.d.ts
CHANGED
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
*/
|
|
4
4
|
/** Default daemon port for HTTP/WebSocket communication with browser extension */
|
|
5
5
|
export declare const DEFAULT_DAEMON_PORT = 19825;
|
|
6
|
-
/** Default idle timeout before daemon auto-exits (ms). Override via OPENCLI_DAEMON_TIMEOUT env var. */
|
|
7
|
-
export declare const DEFAULT_DAEMON_IDLE_TIMEOUT: number;
|
|
8
6
|
/** URL query params that are volatile/ephemeral and should be stripped from patterns */
|
|
9
7
|
export declare const VOLATILE_PARAMS: Set<string>;
|
|
10
8
|
/** Search-related query parameter names */
|