@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.
Files changed (43) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +3 -3
  3. package/cli-manifest.json +60 -1
  4. package/clis/instagram/collection-create.js +57 -0
  5. package/clis/instagram/collection-delete.js +91 -0
  6. package/clis/instagram/saved.js +21 -7
  7. package/dist/src/adapter-shadow.d.ts +11 -0
  8. package/dist/src/adapter-shadow.js +72 -0
  9. package/dist/src/adapter-shadow.test.d.ts +1 -0
  10. package/dist/src/adapter-shadow.test.js +49 -0
  11. package/dist/src/browser/base-page.d.ts +6 -2
  12. package/dist/src/browser/base-page.js +88 -6
  13. package/dist/src/browser/base-page.test.js +61 -1
  14. package/dist/src/browser/bridge.d.ts +0 -2
  15. package/dist/src/browser/bridge.js +4 -32
  16. package/dist/src/browser/cdp.js +48 -0
  17. package/dist/src/browser/cdp.test.js +23 -0
  18. package/dist/src/browser/daemon-lifecycle.d.ts +23 -0
  19. package/dist/src/browser/daemon-lifecycle.js +67 -0
  20. package/dist/src/browser/daemon-version.d.ts +4 -0
  21. package/dist/src/browser/daemon-version.js +12 -0
  22. package/dist/src/browser/dom-helpers.d.ts +1 -1
  23. package/dist/src/browser/dom-helpers.js +15 -3
  24. package/dist/src/browser/page.js +1 -1
  25. package/dist/src/browser/target-resolver.d.ts +8 -0
  26. package/dist/src/browser/target-resolver.js +75 -0
  27. package/dist/src/browser/verify-fixture.d.ts +1 -0
  28. package/dist/src/browser/verify-fixture.js +18 -0
  29. package/dist/src/browser/verify-fixture.test.js +16 -1
  30. package/dist/src/build-manifest.d.ts +68 -33
  31. package/dist/src/build-manifest.js +175 -29
  32. package/dist/src/build-manifest.test.js +75 -1
  33. package/dist/src/cli.js +25 -10
  34. package/dist/src/cli.test.js +153 -1
  35. package/dist/src/commands/daemon.d.ts +2 -0
  36. package/dist/src/commands/daemon.js +36 -1
  37. package/dist/src/commands/daemon.test.js +103 -2
  38. package/dist/src/doctor.d.ts +3 -0
  39. package/dist/src/doctor.js +27 -20
  40. package/dist/src/doctor.test.js +71 -1
  41. package/dist/src/manifest-types.d.ts +39 -0
  42. package/dist/src/manifest-types.js +9 -0
  43. package/package.json +2 -2
@@ -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
- const { mockBrowserConnect, mockBrowserClose, mockBindTab, mockSendCommand, browserState, } = vi.hoisted(() => ({
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
- console.log(`Daemon: ${styleText('green', 'running')} (PID ${status.pid})`);
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
- import { daemonStatus, daemonStop } from './daemon.js';
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
+ });
@@ -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
  /**
@@ -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
- const daemonVersion = health.status?.daemonVersion;
131
- const isStale = opts.cliVersion && (!daemonVersion || daemonVersion !== opts.cliVersion);
132
- if (isStale) {
133
- const reason = daemonVersion
134
- ? `daemon v${daemonVersion} CLI v${opts.cliVersion}`
135
- : `daemon predates version reporting, CLI is v${opts.cliVersion}`;
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.daemonRunning ? styleText('green', '[OK]') : styleText('red', '[MISSING]');
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 ? `running on port ${DEFAULT_DAEMON_PORT}` + (report.daemonVersion ? ` (v${report.daemonVersion})` : '') : 'not running';
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
  }
@@ -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',