@jackwener/opencli 1.7.9 → 1.7.11
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 +3 -3
- package/README.zh-CN.md +3 -3
- package/cli-manifest.json +60 -1
- package/clis/instagram/collection-create.js +57 -0
- package/clis/instagram/collection-delete.js +91 -0
- package/clis/instagram/saved.js +21 -7
- package/dist/src/adapter-shadow.d.ts +11 -0
- package/dist/src/adapter-shadow.js +72 -0
- package/dist/src/adapter-shadow.test.d.ts +1 -0
- package/dist/src/adapter-shadow.test.js +49 -0
- package/dist/src/browser/base-page.d.ts +6 -2
- package/dist/src/browser/base-page.js +88 -6
- package/dist/src/browser/base-page.test.js +61 -1
- package/dist/src/browser/bridge.d.ts +0 -2
- package/dist/src/browser/bridge.js +4 -32
- package/dist/src/browser/cdp.js +48 -0
- package/dist/src/browser/cdp.test.js +23 -0
- package/dist/src/browser/daemon-lifecycle.d.ts +23 -0
- package/dist/src/browser/daemon-lifecycle.js +67 -0
- package/dist/src/browser/daemon-version.d.ts +4 -0
- package/dist/src/browser/daemon-version.js +12 -0
- package/dist/src/browser/dom-helpers.d.ts +1 -1
- package/dist/src/browser/dom-helpers.js +15 -3
- package/dist/src/browser/page.js +1 -1
- package/dist/src/browser/target-resolver.d.ts +8 -0
- package/dist/src/browser/target-resolver.js +75 -0
- package/dist/src/browser/verify-fixture.d.ts +1 -0
- package/dist/src/browser/verify-fixture.js +18 -0
- package/dist/src/browser/verify-fixture.test.js +16 -1
- package/dist/src/build-manifest.d.ts +68 -33
- package/dist/src/build-manifest.js +175 -29
- package/dist/src/build-manifest.test.js +75 -1
- package/dist/src/cli.js +25 -10
- package/dist/src/cli.test.js +153 -1
- package/dist/src/commands/daemon.d.ts +2 -0
- package/dist/src/commands/daemon.js +36 -1
- package/dist/src/commands/daemon.test.js +103 -2
- package/dist/src/doctor.d.ts +3 -0
- package/dist/src/doctor.js +27 -20
- package/dist/src/doctor.test.js +71 -1
- package/dist/src/manifest-types.d.ts +39 -0
- package/dist/src/manifest-types.js +9 -0
- package/package.json +2 -2
package/dist/src/cli.test.js
CHANGED
|
@@ -4,11 +4,13 @@ import * as os from 'node:os';
|
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import { BrowserCommandError } from './browser/daemon-client.js';
|
|
6
6
|
import { TargetError } from './browser/target-errors.js';
|
|
7
|
-
|
|
7
|
+
import { PKG_VERSION } from './version.js';
|
|
8
|
+
const { mockBrowserConnect, mockBrowserClose, mockBindTab, mockSendCommand, mockExecFileSync, browserState, } = vi.hoisted(() => ({
|
|
8
9
|
mockBrowserConnect: vi.fn(),
|
|
9
10
|
mockBrowserClose: vi.fn(),
|
|
10
11
|
mockBindTab: vi.fn(),
|
|
11
12
|
mockSendCommand: vi.fn(),
|
|
13
|
+
mockExecFileSync: vi.fn(),
|
|
12
14
|
browserState: { page: null },
|
|
13
15
|
}));
|
|
14
16
|
vi.mock('./browser/index.js', () => {
|
|
@@ -28,6 +30,13 @@ vi.mock('./browser/daemon-client.js', async () => {
|
|
|
28
30
|
sendCommand: mockSendCommand,
|
|
29
31
|
};
|
|
30
32
|
});
|
|
33
|
+
vi.mock('node:child_process', async () => {
|
|
34
|
+
const actual = await vi.importActual('node:child_process');
|
|
35
|
+
return {
|
|
36
|
+
...actual,
|
|
37
|
+
execFileSync: mockExecFileSync,
|
|
38
|
+
};
|
|
39
|
+
});
|
|
31
40
|
import { createProgram, findPackageRoot, normalizeVerifyRows, renderVerifyPreview, resolveBrowserVerifyInvocation, selectFreshByTimestamp } from './cli.js';
|
|
32
41
|
describe('resolveBrowserVerifyInvocation', () => {
|
|
33
42
|
it('prefers the built entry declared in package metadata', () => {
|
|
@@ -109,6 +118,149 @@ describe('selectFreshByTimestamp', () => {
|
|
|
109
118
|
expect(rolled.lastSeenTs).toBe(3);
|
|
110
119
|
});
|
|
111
120
|
});
|
|
121
|
+
describe('browser verify', () => {
|
|
122
|
+
beforeEach(() => {
|
|
123
|
+
process.exitCode = undefined;
|
|
124
|
+
mockExecFileSync.mockReset().mockReturnValue('[]');
|
|
125
|
+
});
|
|
126
|
+
it('passes --trace through to the adapter subprocess', async () => {
|
|
127
|
+
const originalHome = process.env.HOME;
|
|
128
|
+
const originalUserProfile = process.env.USERPROFILE;
|
|
129
|
+
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browser-verify-trace-'));
|
|
130
|
+
process.env.HOME = fakeHome;
|
|
131
|
+
process.env.USERPROFILE = fakeHome;
|
|
132
|
+
try {
|
|
133
|
+
const adapterDir = path.join(fakeHome, '.opencli', 'clis', 'hn');
|
|
134
|
+
fs.mkdirSync(adapterDir, { recursive: true });
|
|
135
|
+
fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8');
|
|
136
|
+
const program = createProgram('', '');
|
|
137
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'verify', 'hn/top', '--no-fixture', '--trace', 'retain-on-failure']);
|
|
138
|
+
expect(mockExecFileSync).toHaveBeenCalledTimes(1);
|
|
139
|
+
const [, execArgs] = mockExecFileSync.mock.calls[0];
|
|
140
|
+
expect(execArgs.slice(-6)).toEqual(['hn', 'top', '--trace', 'retain-on-failure', '--format', 'json']);
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
if (originalHome === undefined)
|
|
144
|
+
delete process.env.HOME;
|
|
145
|
+
else
|
|
146
|
+
process.env.HOME = originalHome;
|
|
147
|
+
if (originalUserProfile === undefined)
|
|
148
|
+
delete process.env.USERPROFILE;
|
|
149
|
+
else
|
|
150
|
+
process.env.USERPROFILE = originalUserProfile;
|
|
151
|
+
fs.rmSync(fakeHome, { recursive: true, force: true });
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
it('uses --seed-args when no fixture args exist', async () => {
|
|
155
|
+
const originalHome = process.env.HOME;
|
|
156
|
+
const originalUserProfile = process.env.USERPROFILE;
|
|
157
|
+
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browser-verify-seed-'));
|
|
158
|
+
process.env.HOME = fakeHome;
|
|
159
|
+
process.env.USERPROFILE = fakeHome;
|
|
160
|
+
try {
|
|
161
|
+
const adapterDir = path.join(fakeHome, '.opencli', 'clis', 'hn');
|
|
162
|
+
fs.mkdirSync(adapterDir, { recursive: true });
|
|
163
|
+
fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8');
|
|
164
|
+
const program = createProgram('', '');
|
|
165
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'verify', 'hn/top', '--no-fixture', '--seed-args', 'opencli-verify']);
|
|
166
|
+
expect(mockExecFileSync).toHaveBeenCalledTimes(1);
|
|
167
|
+
const [, execArgs] = mockExecFileSync.mock.calls[0];
|
|
168
|
+
expect(execArgs.slice(-5)).toEqual(['hn', 'top', 'opencli-verify', '--format', 'json']);
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
if (originalHome === undefined)
|
|
172
|
+
delete process.env.HOME;
|
|
173
|
+
else
|
|
174
|
+
process.env.HOME = originalHome;
|
|
175
|
+
if (originalUserProfile === undefined)
|
|
176
|
+
delete process.env.USERPROFILE;
|
|
177
|
+
else
|
|
178
|
+
process.env.USERPROFILE = originalUserProfile;
|
|
179
|
+
fs.rmSync(fakeHome, { recursive: true, force: true });
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
it('writes --seed-args into a starter fixture', async () => {
|
|
183
|
+
const originalHome = process.env.HOME;
|
|
184
|
+
const originalUserProfile = process.env.USERPROFILE;
|
|
185
|
+
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browser-verify-write-seed-'));
|
|
186
|
+
process.env.HOME = fakeHome;
|
|
187
|
+
process.env.USERPROFILE = fakeHome;
|
|
188
|
+
mockExecFileSync.mockReturnValue(JSON.stringify([{ title: 'ok' }]));
|
|
189
|
+
try {
|
|
190
|
+
const adapterDir = path.join(fakeHome, '.opencli', 'clis', 'hn');
|
|
191
|
+
fs.mkdirSync(adapterDir, { recursive: true });
|
|
192
|
+
fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8');
|
|
193
|
+
const program = createProgram('', '');
|
|
194
|
+
await program.parseAsync(['node', 'opencli', 'browser', 'verify', 'hn/top', '--write-fixture', '--seed-args', 'opencli-verify']);
|
|
195
|
+
const fixtureFile = path.join(fakeHome, '.opencli', 'sites', 'hn', 'verify', 'top.json');
|
|
196
|
+
const fixture = JSON.parse(fs.readFileSync(fixtureFile, 'utf-8'));
|
|
197
|
+
expect(fixture.args).toEqual(['opencli-verify']);
|
|
198
|
+
expect(fixture.expect.columns).toEqual(['title']);
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
if (originalHome === undefined)
|
|
202
|
+
delete process.env.HOME;
|
|
203
|
+
else
|
|
204
|
+
process.env.HOME = originalHome;
|
|
205
|
+
if (originalUserProfile === undefined)
|
|
206
|
+
delete process.env.USERPROFILE;
|
|
207
|
+
else
|
|
208
|
+
process.env.USERPROFILE = originalUserProfile;
|
|
209
|
+
fs.rmSync(fakeHome, { recursive: true, force: true });
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
describe('profile list', () => {
|
|
214
|
+
const stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
215
|
+
beforeEach(() => {
|
|
216
|
+
process.exitCode = undefined;
|
|
217
|
+
stdoutSpy.mockClear();
|
|
218
|
+
vi.stubGlobal('fetch', vi.fn());
|
|
219
|
+
});
|
|
220
|
+
it('reports stale daemon instead of no profiles when status lacks profile support', async () => {
|
|
221
|
+
vi.mocked(fetch).mockResolvedValue({
|
|
222
|
+
ok: true,
|
|
223
|
+
json: async () => ({
|
|
224
|
+
ok: true,
|
|
225
|
+
pid: 123,
|
|
226
|
+
uptime: 1,
|
|
227
|
+
daemonVersion: '1.7.6',
|
|
228
|
+
extensionConnected: true,
|
|
229
|
+
extensionVersion: '1.0.3',
|
|
230
|
+
pending: 0,
|
|
231
|
+
memoryMB: 20,
|
|
232
|
+
port: 19825,
|
|
233
|
+
}),
|
|
234
|
+
});
|
|
235
|
+
const program = createProgram('', '');
|
|
236
|
+
await program.parseAsync(['node', 'opencli', 'profile', 'list']);
|
|
237
|
+
const output = stdoutSpy.mock.calls.flat().join('\n');
|
|
238
|
+
expect(output).toContain('stale');
|
|
239
|
+
expect(output).toContain('opencli daemon restart');
|
|
240
|
+
expect(output).not.toContain('No Browser Bridge profiles connected');
|
|
241
|
+
});
|
|
242
|
+
it('keeps the empty profile message for current daemon status with no profiles', async () => {
|
|
243
|
+
vi.mocked(fetch).mockResolvedValue({
|
|
244
|
+
ok: true,
|
|
245
|
+
json: async () => ({
|
|
246
|
+
ok: true,
|
|
247
|
+
pid: 123,
|
|
248
|
+
uptime: 1,
|
|
249
|
+
daemonVersion: PKG_VERSION,
|
|
250
|
+
extensionConnected: false,
|
|
251
|
+
profiles: [],
|
|
252
|
+
pending: 0,
|
|
253
|
+
memoryMB: 20,
|
|
254
|
+
port: 19825,
|
|
255
|
+
}),
|
|
256
|
+
});
|
|
257
|
+
const program = createProgram('', '');
|
|
258
|
+
await program.parseAsync(['node', 'opencli', 'profile', 'list']);
|
|
259
|
+
const output = stdoutSpy.mock.calls.flat().join('\n');
|
|
260
|
+
expect(output).toContain('No Browser Bridge profiles connected');
|
|
261
|
+
expect(output).not.toContain('opencli daemon restart');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
112
264
|
describe('browser tab targeting commands', () => {
|
|
113
265
|
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
114
266
|
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* CLI commands for daemon lifecycle:
|
|
3
3
|
* opencli daemon status — show daemon state
|
|
4
4
|
* opencli daemon stop — graceful shutdown
|
|
5
|
+
* opencli daemon restart — graceful shutdown, then start a fresh daemon
|
|
5
6
|
*/
|
|
6
7
|
export declare function daemonStatus(): Promise<void>;
|
|
7
8
|
export declare function daemonStop(): Promise<void>;
|
|
9
|
+
export declare function daemonRestart(): Promise<void>;
|
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
* CLI commands for daemon lifecycle:
|
|
3
3
|
* opencli daemon status — show daemon state
|
|
4
4
|
* opencli daemon stop — graceful shutdown
|
|
5
|
+
* opencli daemon restart — graceful shutdown, then start a fresh daemon
|
|
5
6
|
*/
|
|
6
7
|
import { styleText } from 'node:util';
|
|
7
8
|
import { fetchDaemonStatus, requestDaemonShutdown } from '../browser/daemon-client.js';
|
|
9
|
+
import { restartDaemon } from '../browser/daemon-lifecycle.js';
|
|
8
10
|
import { formatDuration } from '../download/progress.js';
|
|
9
11
|
import { log } from '../logger.js';
|
|
12
|
+
import { PKG_VERSION } from '../version.js';
|
|
13
|
+
import { formatDaemonVersion, isDaemonStale } from '../browser/daemon-version.js';
|
|
10
14
|
export async function daemonStatus() {
|
|
11
15
|
const status = await fetchDaemonStatus();
|
|
12
16
|
if (!status) {
|
|
@@ -18,7 +22,10 @@ export async function daemonStatus() {
|
|
|
18
22
|
: status.extensionVersion
|
|
19
23
|
? `${styleText('green', 'connected')} ${styleText('dim', `(v${status.extensionVersion})`)}`
|
|
20
24
|
: `${styleText('yellow', 'connected')} ${styleText('dim', '(version unknown)')}`;
|
|
21
|
-
|
|
25
|
+
const daemonVersion = formatDaemonVersion(status);
|
|
26
|
+
const stale = isDaemonStale(status, PKG_VERSION);
|
|
27
|
+
console.log(`Daemon: ${stale ? styleText('yellow', 'stale') : styleText('green', 'running')} (PID ${status.pid})`);
|
|
28
|
+
console.log(`Version: ${daemonVersion}${stale ? styleText('yellow', ` (CLI v${PKG_VERSION}; run: opencli daemon restart)`) : ''}`);
|
|
22
29
|
console.log(`Uptime: ${formatDuration(Math.round(status.uptime * 1000))}`);
|
|
23
30
|
console.log(`Extension: ${extensionLabel}`);
|
|
24
31
|
if (status.profiles && status.profiles.length > 0) {
|
|
@@ -45,3 +52,31 @@ export async function daemonStop() {
|
|
|
45
52
|
process.exitCode = 1;
|
|
46
53
|
}
|
|
47
54
|
}
|
|
55
|
+
export async function daemonRestart() {
|
|
56
|
+
const before = await fetchDaemonStatus();
|
|
57
|
+
if (before?.profiles && before.profiles.length > 0) {
|
|
58
|
+
log.warn(`Restarting daemon will disconnect ${before.profiles.length} browser profile(s); the extension should reconnect automatically.`);
|
|
59
|
+
}
|
|
60
|
+
const result = await restartDaemon();
|
|
61
|
+
if (!result.stopped) {
|
|
62
|
+
log.error('Failed to stop daemon before restart.');
|
|
63
|
+
process.exitCode = 1;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (!result.status) {
|
|
67
|
+
log.error('Daemon restart timed out before the new daemon reported status.');
|
|
68
|
+
process.exitCode = 1;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const action = result.previousStatus ? 'restarted' : 'started';
|
|
72
|
+
const version = formatDaemonVersion(result.status);
|
|
73
|
+
log.success(`Daemon ${action} on port ${result.status.port} (${version}).`);
|
|
74
|
+
if (result.status.extensionConnected) {
|
|
75
|
+
const profiles = result.status.profiles?.length ?? 0;
|
|
76
|
+
const profileText = profiles > 0 ? `; profiles connected: ${profiles}` : '';
|
|
77
|
+
log.status(`Extension connected${profileText}.`);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
log.warn('Daemon is running, but the Browser Bridge extension has not connected yet.');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
const { fetchDaemonStatusMock, requestDaemonShutdownMock, } = vi.hoisted(() => ({
|
|
2
|
+
const { fetchDaemonStatusMock, requestDaemonShutdownMock, restartDaemonMock, } = vi.hoisted(() => ({
|
|
3
3
|
fetchDaemonStatusMock: vi.fn(),
|
|
4
4
|
requestDaemonShutdownMock: vi.fn(),
|
|
5
|
+
restartDaemonMock: vi.fn(),
|
|
5
6
|
}));
|
|
6
7
|
vi.mock('../browser/daemon-client.js', () => ({
|
|
7
8
|
fetchDaemonStatus: fetchDaemonStatusMock,
|
|
8
9
|
requestDaemonShutdown: requestDaemonShutdownMock,
|
|
9
10
|
}));
|
|
10
|
-
|
|
11
|
+
vi.mock('../browser/daemon-lifecycle.js', () => ({
|
|
12
|
+
restartDaemon: restartDaemonMock,
|
|
13
|
+
}));
|
|
14
|
+
import { daemonRestart, daemonStatus, daemonStop } from './daemon.js';
|
|
15
|
+
import { PKG_VERSION } from '../version.js';
|
|
11
16
|
describe('daemonStatus', () => {
|
|
12
17
|
let stdoutSpy;
|
|
13
18
|
beforeEach(() => {
|
|
@@ -28,6 +33,7 @@ describe('daemonStatus', () => {
|
|
|
28
33
|
ok: true,
|
|
29
34
|
pid: 12345,
|
|
30
35
|
uptime: 3661,
|
|
36
|
+
daemonVersion: PKG_VERSION,
|
|
31
37
|
extensionConnected: true,
|
|
32
38
|
extensionVersion: '1.6.8',
|
|
33
39
|
pending: 0,
|
|
@@ -37,6 +43,7 @@ describe('daemonStatus', () => {
|
|
|
37
43
|
await daemonStatus();
|
|
38
44
|
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('running'));
|
|
39
45
|
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('PID 12345'));
|
|
46
|
+
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining(`v${PKG_VERSION}`));
|
|
40
47
|
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('1h 1m'));
|
|
41
48
|
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('connected'));
|
|
42
49
|
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('v1.6.8'));
|
|
@@ -48,6 +55,7 @@ describe('daemonStatus', () => {
|
|
|
48
55
|
ok: true,
|
|
49
56
|
pid: 99,
|
|
50
57
|
uptime: 120,
|
|
58
|
+
daemonVersion: PKG_VERSION,
|
|
51
59
|
extensionConnected: false,
|
|
52
60
|
pending: 0,
|
|
53
61
|
memoryMB: 32,
|
|
@@ -61,6 +69,7 @@ describe('daemonStatus', () => {
|
|
|
61
69
|
ok: true,
|
|
62
70
|
pid: 99,
|
|
63
71
|
uptime: 120,
|
|
72
|
+
daemonVersion: PKG_VERSION,
|
|
64
73
|
extensionConnected: true,
|
|
65
74
|
extensionVersion: undefined,
|
|
66
75
|
pending: 0,
|
|
@@ -91,6 +100,7 @@ describe('daemonStop', () => {
|
|
|
91
100
|
ok: true,
|
|
92
101
|
pid: 12345,
|
|
93
102
|
uptime: 100,
|
|
103
|
+
daemonVersion: PKG_VERSION,
|
|
94
104
|
extensionConnected: true,
|
|
95
105
|
pending: 0,
|
|
96
106
|
memoryMB: 50,
|
|
@@ -106,6 +116,7 @@ describe('daemonStop', () => {
|
|
|
106
116
|
ok: true,
|
|
107
117
|
pid: 12345,
|
|
108
118
|
uptime: 100,
|
|
119
|
+
daemonVersion: PKG_VERSION,
|
|
109
120
|
extensionConnected: true,
|
|
110
121
|
pending: 0,
|
|
111
122
|
memoryMB: 50,
|
|
@@ -116,3 +127,93 @@ describe('daemonStop', () => {
|
|
|
116
127
|
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to stop daemon'));
|
|
117
128
|
});
|
|
118
129
|
});
|
|
130
|
+
describe('daemonRestart', () => {
|
|
131
|
+
let stderrSpy;
|
|
132
|
+
beforeEach(() => {
|
|
133
|
+
stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
134
|
+
fetchDaemonStatusMock.mockReset();
|
|
135
|
+
requestDaemonShutdownMock.mockReset();
|
|
136
|
+
restartDaemonMock.mockReset();
|
|
137
|
+
process.exitCode = undefined;
|
|
138
|
+
});
|
|
139
|
+
afterEach(() => {
|
|
140
|
+
vi.restoreAllMocks();
|
|
141
|
+
process.exitCode = undefined;
|
|
142
|
+
});
|
|
143
|
+
it('restarts a running daemon and reports the new version', async () => {
|
|
144
|
+
fetchDaemonStatusMock.mockResolvedValue({
|
|
145
|
+
ok: true,
|
|
146
|
+
pid: 12345,
|
|
147
|
+
uptime: 100,
|
|
148
|
+
daemonVersion: '1.7.6',
|
|
149
|
+
extensionConnected: true,
|
|
150
|
+
profiles: [{ contextId: 'work', extensionConnected: true, pending: 0 }],
|
|
151
|
+
pending: 0,
|
|
152
|
+
memoryMB: 50,
|
|
153
|
+
port: 19825,
|
|
154
|
+
});
|
|
155
|
+
restartDaemonMock.mockResolvedValue({
|
|
156
|
+
previousStatus: { daemonVersion: '1.7.6' },
|
|
157
|
+
stopped: true,
|
|
158
|
+
spawned: true,
|
|
159
|
+
status: {
|
|
160
|
+
ok: true,
|
|
161
|
+
pid: 12346,
|
|
162
|
+
uptime: 1,
|
|
163
|
+
daemonVersion: PKG_VERSION,
|
|
164
|
+
extensionConnected: true,
|
|
165
|
+
profiles: [{ contextId: 'work', extensionConnected: true, pending: 0 }],
|
|
166
|
+
pending: 0,
|
|
167
|
+
memoryMB: 51,
|
|
168
|
+
port: 19825,
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
await daemonRestart();
|
|
172
|
+
expect(restartDaemonMock).toHaveBeenCalledTimes(1);
|
|
173
|
+
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('will disconnect 1 browser profile'));
|
|
174
|
+
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining(`Daemon restarted on port 19825 (v${PKG_VERSION})`));
|
|
175
|
+
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Extension connected; profiles connected: 1'));
|
|
176
|
+
});
|
|
177
|
+
it('starts a new daemon when none was running', async () => {
|
|
178
|
+
fetchDaemonStatusMock.mockResolvedValue(null);
|
|
179
|
+
restartDaemonMock.mockResolvedValue({
|
|
180
|
+
previousStatus: null,
|
|
181
|
+
stopped: true,
|
|
182
|
+
spawned: true,
|
|
183
|
+
status: {
|
|
184
|
+
ok: true,
|
|
185
|
+
pid: 12346,
|
|
186
|
+
uptime: 1,
|
|
187
|
+
daemonVersion: PKG_VERSION,
|
|
188
|
+
extensionConnected: false,
|
|
189
|
+
pending: 0,
|
|
190
|
+
memoryMB: 51,
|
|
191
|
+
port: 19825,
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
await daemonRestart();
|
|
195
|
+
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining(`Daemon started on port 19825 (v${PKG_VERSION})`));
|
|
196
|
+
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('extension has not connected yet'));
|
|
197
|
+
});
|
|
198
|
+
it('reports failure when the daemon cannot stop', async () => {
|
|
199
|
+
fetchDaemonStatusMock.mockResolvedValue({
|
|
200
|
+
ok: true,
|
|
201
|
+
pid: 12345,
|
|
202
|
+
uptime: 100,
|
|
203
|
+
daemonVersion: '1.7.6',
|
|
204
|
+
extensionConnected: true,
|
|
205
|
+
pending: 0,
|
|
206
|
+
memoryMB: 50,
|
|
207
|
+
port: 19825,
|
|
208
|
+
});
|
|
209
|
+
restartDaemonMock.mockResolvedValue({
|
|
210
|
+
previousStatus: { daemonVersion: '1.7.6' },
|
|
211
|
+
status: { daemonVersion: '1.7.6' },
|
|
212
|
+
stopped: false,
|
|
213
|
+
spawned: false,
|
|
214
|
+
});
|
|
215
|
+
await daemonRestart();
|
|
216
|
+
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to stop daemon before restart'));
|
|
217
|
+
expect(process.exitCode).toBe(1);
|
|
218
|
+
});
|
|
219
|
+
});
|
package/dist/src/doctor.d.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { BrowserSessionInfo } from './types.js';
|
|
7
7
|
import type { BrowserProfileStatus } from './browser/daemon-client.js';
|
|
8
|
+
import { type AdapterShadow } from './adapter-shadow.js';
|
|
8
9
|
export type DoctorOptions = {
|
|
9
10
|
yes?: boolean;
|
|
10
11
|
live?: boolean;
|
|
@@ -20,6 +21,7 @@ export type DoctorReport = {
|
|
|
20
21
|
cliVersion?: string;
|
|
21
22
|
daemonRunning: boolean;
|
|
22
23
|
daemonFlaky?: boolean;
|
|
24
|
+
daemonStale?: boolean;
|
|
23
25
|
daemonVersion?: string;
|
|
24
26
|
extensionConnected: boolean;
|
|
25
27
|
extensionFlaky?: boolean;
|
|
@@ -28,6 +30,7 @@ export type DoctorReport = {
|
|
|
28
30
|
connectivity?: ConnectivityResult;
|
|
29
31
|
sessions?: BrowserSessionInfo[];
|
|
30
32
|
profiles?: BrowserProfileStatus[];
|
|
33
|
+
adapterShadows?: AdapterShadow[];
|
|
31
34
|
issues: string[];
|
|
32
35
|
};
|
|
33
36
|
/**
|
package/dist/src/doctor.js
CHANGED
|
@@ -11,6 +11,8 @@ import { getErrorMessage } from './errors.js';
|
|
|
11
11
|
import { getRuntimeLabel } from './runtime-detect.js';
|
|
12
12
|
import { getCachedLatestExtensionVersion } from './update-check.js';
|
|
13
13
|
import { aliasForContextId, loadProfileConfig } from './browser/profile.js';
|
|
14
|
+
import { formatDaemonVersion, isDaemonStale, staleDaemonIssue } from './browser/daemon-version.js';
|
|
15
|
+
import { findShadowedUserAdapters, formatAdapterShadowIssue } from './adapter-shadow.js';
|
|
14
16
|
const DOCTOR_LIVE_TIMEOUT_SECONDS = 8;
|
|
15
17
|
/** Parse a semver string into [major, minor, patch]. Returns null on invalid input. */
|
|
16
18
|
function parseSemver(v) {
|
|
@@ -90,6 +92,7 @@ export async function runBrowserDoctor(opts = {}) {
|
|
|
90
92
|
const extensionConnected = health.state === 'ready';
|
|
91
93
|
const daemonFlaky = !!(connectivity?.ok && !daemonRunning);
|
|
92
94
|
const extensionFlaky = !!(connectivity?.ok && daemonRunning && !extensionConnected);
|
|
95
|
+
const daemonStale = isDaemonStale(health.status, opts.cliVersion);
|
|
93
96
|
const profiles = health.status?.profiles;
|
|
94
97
|
let sessions;
|
|
95
98
|
if (opts.sessions) {
|
|
@@ -105,6 +108,7 @@ export async function runBrowserDoctor(opts = {}) {
|
|
|
105
108
|
}
|
|
106
109
|
}
|
|
107
110
|
const extensionVersion = health.status?.extensionVersion;
|
|
111
|
+
const adapterShadows = findShadowedUserAdapters();
|
|
108
112
|
const issues = [];
|
|
109
113
|
if (daemonFlaky) {
|
|
110
114
|
issues.push('Daemon connectivity is unstable. The live browser test succeeded, but the daemon was no longer running immediately afterward.\n' +
|
|
@@ -113,6 +117,9 @@ export async function runBrowserDoctor(opts = {}) {
|
|
|
113
117
|
else if (!daemonRunning) {
|
|
114
118
|
issues.push('Daemon is not running. It should start automatically when you run an opencli browser command.');
|
|
115
119
|
}
|
|
120
|
+
if (daemonStale && opts.cliVersion) {
|
|
121
|
+
issues.push(staleDaemonIssue(health.status, opts.cliVersion));
|
|
122
|
+
}
|
|
116
123
|
if (extensionFlaky) {
|
|
117
124
|
issues.push('Extension connection is unstable. The live browser test succeeded, but the daemon reported the extension disconnected immediately afterward.\n' +
|
|
118
125
|
'This usually means the Browser Bridge service worker is reconnecting slowly or Chrome suspended it.');
|
|
@@ -127,24 +134,12 @@ export async function runBrowserDoctor(opts = {}) {
|
|
|
127
134
|
' Open that Chrome profile and make sure the OpenCLI extension is enabled.');
|
|
128
135
|
}
|
|
129
136
|
else {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
issues.push(`Stale daemon detected: ${reason}.\n` +
|
|
137
|
-
'The daemon was started by an older CLI version and may have missed the extension registration.\n' +
|
|
138
|
-
' Quick fix: opencli daemon stop && opencli doctor');
|
|
139
|
-
}
|
|
140
|
-
else {
|
|
141
|
-
issues.push('Daemon is running but the Chrome/Chromium extension is not connected.\n' +
|
|
142
|
-
'If the extension is already installed, try: opencli daemon stop && opencli doctor\n' +
|
|
143
|
-
'If the extension is not installed:\n' +
|
|
144
|
-
' 1. Download from https://github.com/jackwener/opencli/releases\n' +
|
|
145
|
-
' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
|
|
146
|
-
' 3. Click "Load unpacked" → select the extension folder');
|
|
147
|
-
}
|
|
137
|
+
issues.push('Daemon is running but the Chrome/Chromium extension is not connected.\n' +
|
|
138
|
+
'If the extension is already installed, try: opencli daemon restart\n' +
|
|
139
|
+
'If the extension is not installed:\n' +
|
|
140
|
+
' 1. Download from https://github.com/jackwener/opencli/releases\n' +
|
|
141
|
+
' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
|
|
142
|
+
' 3. Click "Load unpacked" → select the extension folder');
|
|
148
143
|
}
|
|
149
144
|
}
|
|
150
145
|
if (extensionConnected && !extensionVersion) {
|
|
@@ -178,10 +173,14 @@ export async function runBrowserDoctor(opts = {}) {
|
|
|
178
173
|
issues.push(`Extension update available: v${extensionVersion} → v${latestExtensionVersion}\n` +
|
|
179
174
|
' Download from: https://github.com/jackwener/opencli/releases');
|
|
180
175
|
}
|
|
176
|
+
if (adapterShadows.length > 0) {
|
|
177
|
+
issues.push(formatAdapterShadowIssue(adapterShadows));
|
|
178
|
+
}
|
|
181
179
|
return {
|
|
182
180
|
cliVersion: opts.cliVersion,
|
|
183
181
|
daemonRunning,
|
|
184
182
|
daemonFlaky,
|
|
183
|
+
daemonStale,
|
|
185
184
|
daemonVersion: health.status?.daemonVersion,
|
|
186
185
|
extensionConnected,
|
|
187
186
|
extensionFlaky,
|
|
@@ -190,6 +189,7 @@ export async function runBrowserDoctor(opts = {}) {
|
|
|
190
189
|
connectivity,
|
|
191
190
|
sessions,
|
|
192
191
|
profiles,
|
|
192
|
+
adapterShadows,
|
|
193
193
|
issues,
|
|
194
194
|
};
|
|
195
195
|
}
|
|
@@ -198,10 +198,16 @@ export function renderBrowserDoctorReport(report) {
|
|
|
198
198
|
// Daemon status
|
|
199
199
|
const daemonIcon = report.daemonFlaky
|
|
200
200
|
? styleText('yellow', '[WARN]')
|
|
201
|
-
: report.
|
|
201
|
+
: report.daemonStale
|
|
202
|
+
? styleText('yellow', '[WARN]')
|
|
203
|
+
: report.daemonRunning ? styleText('green', '[OK]') : styleText('red', '[MISSING]');
|
|
202
204
|
const daemonLabel = report.daemonFlaky
|
|
203
205
|
? 'unstable (running during live check, then stopped)'
|
|
204
|
-
: report.daemonRunning
|
|
206
|
+
: report.daemonRunning
|
|
207
|
+
? `running on port ${DEFAULT_DAEMON_PORT} (${report.daemonStale
|
|
208
|
+
? `${formatDaemonVersion(report)}, stale; CLI v${report.cliVersion ?? 'unknown'}`
|
|
209
|
+
: formatDaemonVersion(report)})`
|
|
210
|
+
: 'not running';
|
|
205
211
|
lines.push(`${daemonIcon} Daemon: ${daemonLabel}`);
|
|
206
212
|
// Extension status
|
|
207
213
|
const extIcon = report.extensionFlaky || (report.extensionConnected && !report.extensionVersion)
|
|
@@ -279,6 +285,7 @@ export function renderBrowserDoctorReport(report) {
|
|
|
279
285
|
}
|
|
280
286
|
else if (report.daemonRunning && report.extensionConnected) {
|
|
281
287
|
lines.push('', styleText('green', 'Everything looks good!'));
|
|
288
|
+
lines.push(styleText('dim', 'Tip: writing a new adapter? Run `opencli browser analyze <url>` for one-shot site recon.'));
|
|
282
289
|
}
|
|
283
290
|
return lines.join('\n');
|
|
284
291
|
}
|
package/dist/src/doctor.test.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
const { mockGetDaemonHealth, mockListSessions, mockConnect, mockClose } = vi.hoisted(() => ({
|
|
2
|
+
const { mockGetDaemonHealth, mockListSessions, mockConnect, mockClose, mockFindShadowedUserAdapters } = vi.hoisted(() => ({
|
|
3
3
|
mockGetDaemonHealth: vi.fn(),
|
|
4
4
|
mockListSessions: vi.fn(),
|
|
5
5
|
mockConnect: vi.fn(),
|
|
6
6
|
mockClose: vi.fn(),
|
|
7
|
+
mockFindShadowedUserAdapters: vi.fn(),
|
|
7
8
|
}));
|
|
8
9
|
vi.mock('./browser/daemon-client.js', () => ({
|
|
9
10
|
getDaemonHealth: mockGetDaemonHealth,
|
|
@@ -15,22 +16,48 @@ vi.mock('./browser/index.js', () => ({
|
|
|
15
16
|
close = mockClose;
|
|
16
17
|
},
|
|
17
18
|
}));
|
|
19
|
+
vi.mock('./adapter-shadow.js', async () => {
|
|
20
|
+
const actual = await vi.importActual('./adapter-shadow.js');
|
|
21
|
+
return {
|
|
22
|
+
...actual,
|
|
23
|
+
findShadowedUserAdapters: mockFindShadowedUserAdapters,
|
|
24
|
+
};
|
|
25
|
+
});
|
|
18
26
|
import { renderBrowserDoctorReport, runBrowserDoctor } from './doctor.js';
|
|
19
27
|
describe('doctor report rendering', () => {
|
|
20
28
|
const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
21
29
|
beforeEach(() => {
|
|
22
30
|
vi.clearAllMocks();
|
|
31
|
+
mockFindShadowedUserAdapters.mockReturnValue([]);
|
|
23
32
|
});
|
|
24
33
|
it('renders OK-style report when daemon and extension connected', () => {
|
|
25
34
|
const text = strip(renderBrowserDoctorReport({
|
|
35
|
+
cliVersion: '1.7.9',
|
|
26
36
|
daemonRunning: true,
|
|
37
|
+
daemonVersion: '1.7.9',
|
|
27
38
|
extensionConnected: true,
|
|
28
39
|
extensionVersion: '1.6.8',
|
|
29
40
|
issues: [],
|
|
30
41
|
}));
|
|
31
42
|
expect(text).toContain('[OK] Daemon: running on port 19825');
|
|
43
|
+
expect(text).toContain('(v1.7.9)');
|
|
32
44
|
expect(text).toContain('[OK] Extension: connected (v1.6.8)');
|
|
33
45
|
expect(text).toContain('Everything looks good!');
|
|
46
|
+
expect(text).toContain('opencli browser analyze <url>');
|
|
47
|
+
});
|
|
48
|
+
it('renders a warning when daemon version is stale', () => {
|
|
49
|
+
const text = strip(renderBrowserDoctorReport({
|
|
50
|
+
cliVersion: '1.7.9',
|
|
51
|
+
daemonRunning: true,
|
|
52
|
+
daemonVersion: '1.7.6',
|
|
53
|
+
daemonStale: true,
|
|
54
|
+
extensionConnected: true,
|
|
55
|
+
extensionVersion: '1.0.3',
|
|
56
|
+
issues: ['Stale daemon detected: daemon v1.7.6 != CLI v1.7.9.\n Run: opencli daemon restart'],
|
|
57
|
+
}));
|
|
58
|
+
expect(text).toContain('[WARN] Daemon: running on port 19825 (v1.7.6, stale; CLI v1.7.9)');
|
|
59
|
+
expect(text).toContain('Run: opencli daemon restart');
|
|
60
|
+
expect(text).not.toContain('Everything looks good!');
|
|
34
61
|
});
|
|
35
62
|
it('renders MISSING when daemon not running', () => {
|
|
36
63
|
const text = strip(renderBrowserDoctorReport({
|
|
@@ -238,6 +265,49 @@ describe('doctor report rendering', () => {
|
|
|
238
265
|
expect.stringContaining('did not report a version'),
|
|
239
266
|
]));
|
|
240
267
|
});
|
|
268
|
+
it('reports an issue when daemon version differs from CLI version', async () => {
|
|
269
|
+
const status = {
|
|
270
|
+
state: 'ready',
|
|
271
|
+
status: {
|
|
272
|
+
daemonVersion: '1.7.6',
|
|
273
|
+
extensionConnected: true,
|
|
274
|
+
extensionVersion: '1.0.3',
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
mockGetDaemonHealth
|
|
278
|
+
.mockResolvedValueOnce(status)
|
|
279
|
+
.mockResolvedValueOnce(status);
|
|
280
|
+
const report = await runBrowserDoctor({ live: false, cliVersion: '1.7.9' });
|
|
281
|
+
expect(report.daemonStale).toBe(true);
|
|
282
|
+
expect(report.issues).toEqual(expect.arrayContaining([
|
|
283
|
+
expect.stringContaining('Stale daemon detected: daemon v1.7.6 != CLI v1.7.9'),
|
|
284
|
+
]));
|
|
285
|
+
});
|
|
286
|
+
it('reports local adapter shadows as a warning issue', async () => {
|
|
287
|
+
const status = {
|
|
288
|
+
state: 'ready',
|
|
289
|
+
status: {
|
|
290
|
+
daemonVersion: '1.7.9',
|
|
291
|
+
extensionConnected: true,
|
|
292
|
+
extensionVersion: '1.0.3',
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
mockGetDaemonHealth
|
|
296
|
+
.mockResolvedValueOnce(status)
|
|
297
|
+
.mockResolvedValueOnce(status);
|
|
298
|
+
mockFindShadowedUserAdapters.mockReturnValueOnce([
|
|
299
|
+
{
|
|
300
|
+
name: 'instagram/saved',
|
|
301
|
+
userPath: '/home/me/.opencli/clis/instagram/saved.js',
|
|
302
|
+
builtinPath: '/pkg/clis/instagram/saved.js',
|
|
303
|
+
},
|
|
304
|
+
]);
|
|
305
|
+
const report = await runBrowserDoctor({ live: false, cliVersion: '1.7.9' });
|
|
306
|
+
expect(report.adapterShadows).toHaveLength(1);
|
|
307
|
+
expect(report.issues).toEqual(expect.arrayContaining([
|
|
308
|
+
expect.stringContaining('Local adapter overrides shadow packaged adapters'),
|
|
309
|
+
]));
|
|
310
|
+
});
|
|
241
311
|
it('reports profile-required when multiple profiles are connected without a selection', async () => {
|
|
242
312
|
const status = {
|
|
243
313
|
state: 'profile-required',
|