@jackwener/opencli 1.7.9 → 1.7.10

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/cli-manifest.json CHANGED
@@ -8692,6 +8692,33 @@
8692
8692
  "modulePath": "imdb/trending.js",
8693
8693
  "sourceFile": "imdb/trending.js"
8694
8694
  },
8695
+ {
8696
+ "site": "instagram",
8697
+ "name": "collection-create",
8698
+ "description": "Create a new Instagram saved-posts collection (folder)",
8699
+ "domain": "www.instagram.com",
8700
+ "strategy": "cookie",
8701
+ "browser": true,
8702
+ "args": [
8703
+ {
8704
+ "name": "name",
8705
+ "type": "str",
8706
+ "required": true,
8707
+ "positional": true,
8708
+ "help": "Name of the collection to create"
8709
+ }
8710
+ ],
8711
+ "columns": [
8712
+ "status",
8713
+ "collectionId",
8714
+ "collectionName",
8715
+ "mediaCount"
8716
+ ],
8717
+ "type": "js",
8718
+ "modulePath": "instagram/collection-create.js",
8719
+ "sourceFile": "instagram/collection-create.js",
8720
+ "navigateBefore": "https://www.instagram.com"
8721
+ },
8695
8722
  {
8696
8723
  "site": "instagram",
8697
8724
  "name": "comment",
@@ -9078,7 +9105,7 @@
9078
9105
  {
9079
9106
  "site": "instagram",
9080
9107
  "name": "saved",
9081
- "description": "Get your saved Instagram posts",
9108
+ "description": "Get your saved Instagram posts (optionally from a specific collection)",
9082
9109
  "domain": "www.instagram.com",
9083
9110
  "strategy": "cookie",
9084
9111
  "browser": true,
@@ -9089,6 +9116,12 @@
9089
9116
  "default": 20,
9090
9117
  "required": false,
9091
9118
  "help": "Number of saved posts"
9119
+ },
9120
+ {
9121
+ "name": "collection",
9122
+ "type": "str",
9123
+ "required": false,
9124
+ "help": "Collection name (case-insensitive). Omit for the default \"All posts\" feed."
9092
9125
  }
9093
9126
  ],
9094
9127
  "columns": [
@@ -0,0 +1,57 @@
1
+ import { cli } from '@jackwener/opencli/registry';
2
+ cli({
3
+ site: 'instagram',
4
+ name: 'collection-create',
5
+ description: 'Create a new Instagram saved-posts collection (folder)',
6
+ domain: 'www.instagram.com',
7
+ args: [
8
+ {
9
+ name: 'name',
10
+ required: true,
11
+ positional: true,
12
+ help: 'Name of the collection to create',
13
+ },
14
+ ],
15
+ columns: ['status', 'collectionId', 'collectionName', 'mediaCount'],
16
+ pipeline: [
17
+ { navigate: 'https://www.instagram.com' },
18
+ { evaluate: `(async () => {
19
+ const name = \${{ args.name | json }};
20
+ if (!name || !String(name).trim()) {
21
+ throw new Error('Collection name cannot be empty');
22
+ }
23
+ const trimmed = String(name).trim();
24
+ const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';
25
+ if (!csrf) {
26
+ throw new Error('csrftoken cookie missing - make sure you are logged in to Instagram');
27
+ }
28
+ const fd = new FormData();
29
+ fd.append('name', trimmed);
30
+ fd.append('module_name', 'collection_create');
31
+ const res = await fetch('https://www.instagram.com/api/v1/collections/create/', {
32
+ method: 'POST',
33
+ credentials: 'include',
34
+ headers: {
35
+ 'X-IG-App-ID': '936619743392459',
36
+ 'X-CSRFToken': csrf,
37
+ },
38
+ body: fd,
39
+ });
40
+ if (!res.ok) {
41
+ const body = await res.text().catch(() => '');
42
+ throw new Error('Failed to create collection: HTTP ' + res.status + (body ? ' - ' + body.slice(0, 200) : ''));
43
+ }
44
+ const d = await res.json();
45
+ if (d?.status && d.status !== 'ok') {
46
+ throw new Error('Instagram returned non-ok status: ' + JSON.stringify(d).slice(0, 300));
47
+ }
48
+ return [{
49
+ status: 'Created',
50
+ collectionId: String(d?.collection_id ?? ''),
51
+ collectionName: String(d?.collection_name ?? trimmed),
52
+ mediaCount: d?.collection_media_count ?? 0,
53
+ }];
54
+ })()
55
+ ` },
56
+ ],
57
+ });
@@ -2,23 +2,37 @@ import { cli } from '@jackwener/opencli/registry';
2
2
  cli({
3
3
  site: 'instagram',
4
4
  name: 'saved',
5
- description: 'Get your saved Instagram posts',
5
+ description: 'Get your saved Instagram posts (optionally from a specific collection)',
6
6
  domain: 'www.instagram.com',
7
7
  args: [
8
8
  { name: 'limit', type: 'int', default: 20, help: 'Number of saved posts' },
9
+ { name: 'collection', help: 'Collection name (case-insensitive). Omit for the default "All posts" feed.' },
9
10
  ],
10
11
  columns: ['index', 'user', 'caption', 'likes', 'comments', 'type'],
11
12
  pipeline: [
12
13
  { navigate: 'https://www.instagram.com' },
13
14
  { evaluate: `(async () => {
14
15
  const limit = \${{ args.limit }};
15
- const res = await fetch(
16
- 'https://www.instagram.com/api/v1/feed/saved/posts/',
17
- {
18
- credentials: 'include',
19
- headers: { 'X-IG-App-ID': '936619743392459' }
16
+ const collectionArg = \${{ args.collection | json }};
17
+ const headers = { 'X-IG-App-ID': '936619743392459' };
18
+ const opts = { credentials: 'include', headers };
19
+
20
+ let endpoint = 'https://www.instagram.com/api/v1/feed/saved/posts/';
21
+ if (collectionArg && String(collectionArg).trim()) {
22
+ const wanted = String(collectionArg).trim().toLowerCase();
23
+ const listRes = await fetch('https://www.instagram.com/api/v1/collections/list/?collection_types=%5B%22MEDIA%22%2C%22ALL_MEDIA_AUTO_COLLECTION%22%5D', opts);
24
+ if (!listRes.ok) throw new Error('Failed to list collections: HTTP ' + listRes.status + ' - make sure you are logged in to Instagram');
25
+ const listData = await listRes.json();
26
+ const collections = listData?.items || [];
27
+ const match = collections.find((c) => String(c?.collection_name || '').trim().toLowerCase() === wanted);
28
+ if (!match) {
29
+ const names = collections.map((c) => c?.collection_name).filter(Boolean);
30
+ throw new Error('Collection not found: ' + collectionArg + '. Available: ' + (names.length ? names.join(', ') : '(none)'));
20
31
  }
21
- );
32
+ endpoint = 'https://www.instagram.com/api/v1/feed/collection/' + encodeURIComponent(match.collection_id) + '/posts/';
33
+ }
34
+
35
+ const res = await fetch(endpoint, opts);
22
36
  if (!res.ok) throw new Error('HTTP ' + res.status + ' - make sure you are logged in to Instagram');
23
37
  const data = await res.json();
24
38
  return (data?.items || []).slice(0, limit).map((item, i) => {
@@ -20,8 +20,6 @@ export declare class BrowserBridge implements IBrowserFactory {
20
20
  }): Promise<IPage>;
21
21
  close(): Promise<void>;
22
22
  private _ensureDaemon;
23
- /** Poll until daemon is fully stopped (port released). */
24
- private _waitForDaemonStop;
25
23
  /** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */
26
24
  private _pollUntilReady;
27
25
  }
@@ -1,16 +1,13 @@
1
1
  /**
2
2
  * Browser session manager — auto-spawns daemon and provides IPage.
3
3
  */
4
- import { spawn } from 'node:child_process';
5
- import { fileURLToPath } from 'node:url';
6
- import * as path from 'node:path';
7
- import * as fs from 'node:fs';
8
4
  import { Page } from './page.js';
9
5
  import { getDaemonHealth, requestDaemonShutdown } from './daemon-client.js';
10
6
  import { DEFAULT_DAEMON_PORT } from '../constants.js';
11
7
  import { BrowserConnectError } from '../errors.js';
12
8
  import { PKG_VERSION } from '../version.js';
13
9
  import { resolveProfileContextId } from './profile.js';
10
+ import { resolveDaemonLaunchSpec, spawnDaemonProcess, waitForDaemonStop } from './daemon-lifecycle.js';
14
11
  const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
15
12
  /**
16
13
  * Browser factory: manages daemon lifecycle and provides IPage instances.
@@ -82,7 +79,7 @@ export class BrowserBridge {
82
79
  process.stderr.write(`⚠️ Stale daemon detected (${reason}). Restarting...\n`);
83
80
  }
84
81
  const shutdownAccepted = await requestDaemonShutdown();
85
- const portReleased = shutdownAccepted && await this._waitForDaemonStop(3000);
82
+ const portReleased = shutdownAccepted && await waitForDaemonStop(3000);
86
83
  if (!portReleased) {
87
84
  // Stale daemon replacement failed — don't blindly spawn on an occupied port
88
85
  throw new BrowserConnectError('Stale daemon could not be replaced', `A stale daemon (${reason}) is running but did not shut down.\n` +
@@ -115,24 +112,10 @@ export class BrowserBridge {
115
112
  }
116
113
  }
117
114
  // No daemon — spawn one
118
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
119
- const parentDir = path.resolve(__dirname, '..');
120
- const daemonTs = path.join(parentDir, 'daemon.ts');
121
- const daemonJs = path.join(parentDir, 'daemon.js');
122
- const isTs = fs.existsSync(daemonTs);
123
- const daemonPath = isTs ? daemonTs : daemonJs;
124
115
  if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
125
116
  process.stderr.write('⏳ Starting daemon...\n');
126
117
  }
127
- const spawnArgs = isTs
128
- ? [process.execPath, '--import', 'tsx/esm', daemonPath]
129
- : [process.execPath, daemonPath];
130
- this._daemonProc = spawn(spawnArgs[0], spawnArgs.slice(1), {
131
- detached: true,
132
- stdio: 'ignore',
133
- env: { ...process.env },
134
- });
135
- this._daemonProc.unref();
118
+ this._daemonProc = spawnDaemonProcess();
136
119
  // Wait for daemon + extension
137
120
  if (await this._pollUntilReady(timeoutMs, contextId))
138
121
  return;
@@ -152,18 +135,7 @@ export class BrowserBridge {
152
135
  ' 1. Download: https://github.com/jackwener/opencli/releases\n' +
153
136
  ' 2. Open chrome://extensions → Developer Mode → Load unpacked', 'extension-not-connected');
154
137
  }
155
- throw new BrowserConnectError('Failed to start opencli daemon', `Try running manually:\n node ${daemonPath}\nMake sure port ${DEFAULT_DAEMON_PORT} is available.`, 'daemon-not-running');
156
- }
157
- /** Poll until daemon is fully stopped (port released). */
158
- async _waitForDaemonStop(timeoutMs) {
159
- const deadline = Date.now() + timeoutMs;
160
- while (Date.now() < deadline) {
161
- await new Promise(resolve => setTimeout(resolve, 200));
162
- const h = await getDaemonHealth();
163
- if (h.state === 'stopped')
164
- return true;
165
- }
166
- return false;
138
+ throw new BrowserConnectError('Failed to start opencli daemon', `Try running manually:\n node ${resolveDaemonLaunchSpec().scriptPath}\nMake sure port ${DEFAULT_DAEMON_PORT} is available.`, 'daemon-not-running');
167
139
  }
168
140
  /** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */
169
141
  async _pollUntilReady(timeoutMs, contextId) {
@@ -0,0 +1,23 @@
1
+ import { type ChildProcess } from 'node:child_process';
2
+ import { DEFAULT_DAEMON_PORT } from '../constants.js';
3
+ import { type DaemonStatus } from './daemon-client.js';
4
+ export interface DaemonLaunchSpec {
5
+ binary: string;
6
+ args: string[];
7
+ scriptPath: string;
8
+ }
9
+ export interface DaemonRestartResult {
10
+ previousStatus: DaemonStatus | null;
11
+ status: DaemonStatus | null;
12
+ stopped: boolean;
13
+ spawned: boolean;
14
+ }
15
+ export declare function resolveDaemonLaunchSpec(): DaemonLaunchSpec;
16
+ export declare function spawnDaemonProcess(): ChildProcess;
17
+ export declare function waitForDaemonStop(timeoutMs: number): Promise<boolean>;
18
+ export declare function waitForDaemonStatus(timeoutMs: number): Promise<DaemonStatus | null>;
19
+ export declare function restartDaemon(opts?: {
20
+ stopTimeoutMs?: number;
21
+ startTimeoutMs?: number;
22
+ }): Promise<DaemonRestartResult>;
23
+ export { DEFAULT_DAEMON_PORT };
@@ -0,0 +1,67 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { fileURLToPath } from 'node:url';
3
+ import * as fs from 'node:fs';
4
+ import * as path from 'node:path';
5
+ import { DEFAULT_DAEMON_PORT } from '../constants.js';
6
+ import { fetchDaemonStatus, getDaemonHealth, requestDaemonShutdown } from './daemon-client.js';
7
+ export function resolveDaemonLaunchSpec() {
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const parentDir = path.resolve(__dirname, '..');
10
+ const daemonTs = path.join(parentDir, 'daemon.ts');
11
+ const daemonJs = path.join(parentDir, 'daemon.js');
12
+ const isTs = fs.existsSync(daemonTs);
13
+ const scriptPath = isTs ? daemonTs : daemonJs;
14
+ return {
15
+ binary: process.execPath,
16
+ args: isTs ? ['--import', 'tsx/esm', scriptPath] : [scriptPath],
17
+ scriptPath,
18
+ };
19
+ }
20
+ export function spawnDaemonProcess() {
21
+ const launch = resolveDaemonLaunchSpec();
22
+ const proc = spawn(launch.binary, launch.args, {
23
+ detached: true,
24
+ stdio: 'ignore',
25
+ env: { ...process.env },
26
+ });
27
+ proc.unref();
28
+ return proc;
29
+ }
30
+ export async function waitForDaemonStop(timeoutMs) {
31
+ const deadline = Date.now() + timeoutMs;
32
+ while (Date.now() < deadline) {
33
+ await sleep(200);
34
+ const h = await getDaemonHealth();
35
+ if (h.state === 'stopped')
36
+ return true;
37
+ }
38
+ return false;
39
+ }
40
+ export async function waitForDaemonStatus(timeoutMs) {
41
+ const deadline = Date.now() + timeoutMs;
42
+ while (Date.now() < deadline) {
43
+ const status = await fetchDaemonStatus({ timeout: Math.min(1000, Math.max(100, deadline - Date.now())) });
44
+ if (status)
45
+ return status;
46
+ await sleep(200);
47
+ }
48
+ return null;
49
+ }
50
+ export async function restartDaemon(opts = {}) {
51
+ const previousStatus = await fetchDaemonStatus();
52
+ let stopped = previousStatus === null;
53
+ if (previousStatus) {
54
+ const shutdownAccepted = await requestDaemonShutdown();
55
+ stopped = shutdownAccepted && await waitForDaemonStop(opts.stopTimeoutMs ?? 3000);
56
+ if (!stopped) {
57
+ return { previousStatus, status: previousStatus, stopped: false, spawned: false };
58
+ }
59
+ }
60
+ spawnDaemonProcess();
61
+ const status = await waitForDaemonStatus(opts.startTimeoutMs ?? 5000);
62
+ return { previousStatus, status, stopped, spawned: true };
63
+ }
64
+ function sleep(ms) {
65
+ return new Promise((resolve) => setTimeout(resolve, ms));
66
+ }
67
+ export { DEFAULT_DAEMON_PORT };
@@ -0,0 +1,4 @@
1
+ import type { DaemonStatus } from './daemon-client.js';
2
+ export declare function isDaemonStale(status: Pick<DaemonStatus, 'daemonVersion'> | null | undefined, cliVersion?: string): boolean;
3
+ export declare function formatDaemonVersion(status: Pick<DaemonStatus, 'daemonVersion'> | null | undefined): string;
4
+ export declare function staleDaemonIssue(status: Pick<DaemonStatus, 'daemonVersion'> | null | undefined, cliVersion: string): string;
@@ -0,0 +1,12 @@
1
+ export function isDaemonStale(status, cliVersion) {
2
+ if (!status || !cliVersion)
3
+ return false;
4
+ return !status.daemonVersion || status.daemonVersion !== cliVersion;
5
+ }
6
+ export function formatDaemonVersion(status) {
7
+ return status?.daemonVersion ? `v${status.daemonVersion}` : 'version unknown';
8
+ }
9
+ export function staleDaemonIssue(status, cliVersion) {
10
+ return `Stale daemon detected: daemon ${formatDaemonVersion(status)} != CLI v${cliVersion}.\n` +
11
+ ' Run: opencli daemon restart';
12
+ }
package/dist/src/cli.js CHANGED
@@ -29,10 +29,11 @@ import { parseFilter, shapeMatchesFilter } from './browser/shape-filter.js';
29
29
  import { buildHtmlTreeJs } from './browser/html-tree.js';
30
30
  import { buildExtractHtmlJs, runExtractFromHtml } from './browser/extract.js';
31
31
  import { analyzeSite } from './browser/analyze.js';
32
- import { daemonStatus, daemonStop } from './commands/daemon.js';
32
+ import { daemonRestart, daemonStatus, daemonStop } from './commands/daemon.js';
33
33
  import { log } from './logger.js';
34
34
  import { bindTab, BrowserCommandError, fetchDaemonStatus, sendCommand } from './browser/daemon-client.js';
35
35
  import { aliasForContextId, loadProfileConfig, renameProfile, resolveProfileContextId, setDefaultProfile } from './browser/profile.js';
36
+ import { formatDaemonVersion, isDaemonStale } from './browser/daemon-version.js';
36
37
  const CLI_FILE = fileURLToPath(import.meta.url);
37
38
  const DEFAULT_BROWSER_WORKSPACE = 'browser:default';
38
39
  const DEFAULT_BOUND_WORKSPACE = 'bound:default';
@@ -1772,6 +1773,7 @@ cli({
1772
1773
  .option('--update-fixture', 'Overwrite an existing fixture with one derived from current output')
1773
1774
  .option('--no-fixture', 'Ignore any fixture file for this run (no value-level validation)')
1774
1775
  .option('--strict-memory', 'Fail (not just warn) when ~/.opencli/sites/<site>/endpoints.json or notes.md is missing')
1776
+ .option('--trace <mode>', 'Trace capture for the adapter subprocess: off, on, retain-on-failure', 'off')
1775
1777
  .description('Execute an adapter and validate output; uses fixture at ~/.opencli/sites/<site>/verify/<cmd>.json when present')
1776
1778
  .action(async (name, opts = {}) => {
1777
1779
  try {
@@ -1809,10 +1811,11 @@ cli({
1809
1811
  const cliArgs = expandFixtureArgs(fixtureArgs);
1810
1812
  if (cliArgs.length === 0 && hasLimitArg)
1811
1813
  cliArgs.push('--limit', '3');
1812
- const argDisplay = cliArgs.join(' ');
1814
+ const traceArgs = opts.trace && opts.trace !== 'off' ? ['--trace', opts.trace] : [];
1815
+ const argDisplay = [...cliArgs, ...traceArgs].join(' ');
1813
1816
  const invocation = resolveBrowserVerifyInvocation();
1814
1817
  // Always request JSON so we can validate structurally.
1815
- const execArgs = [...invocation.args, site, command, ...cliArgs, '--format', 'json'];
1818
+ const execArgs = [...invocation.args, site, command, ...cliArgs, ...traceArgs, '--format', 'json'];
1816
1819
  let rawJson;
1817
1820
  try {
1818
1821
  rawJson = execFileSync(invocation.binary, execArgs, {
@@ -2232,6 +2235,11 @@ cli({
2232
2235
  console.log(styleText('yellow', 'Daemon is not running. Run opencli doctor after opening Chrome.'));
2233
2236
  return;
2234
2237
  }
2238
+ if (isDaemonStale(status, PKG_VERSION) || !Array.isArray(status.profiles)) {
2239
+ console.log(styleText('yellow', `Daemon ${formatDaemonVersion(status)} is stale for CLI v${PKG_VERSION}.`));
2240
+ console.log(styleText('dim', 'Run: opencli daemon restart'));
2241
+ return;
2242
+ }
2235
2243
  if (profiles.length === 0) {
2236
2244
  console.log(styleText('yellow', 'No Browser Bridge profiles connected.'));
2237
2245
  console.log(styleText('dim', 'Open a Chrome profile with the OpenCLI extension installed, then run opencli profile list again.'));
@@ -2301,6 +2309,10 @@ cli({
2301
2309
  .command('stop')
2302
2310
  .description('Stop the daemon')
2303
2311
  .action(async () => { await daemonStop(); });
2312
+ daemonCmd
2313
+ .command('restart')
2314
+ .description('Restart the daemon')
2315
+ .action(async () => { await daemonRestart(); });
2304
2316
  // ── External CLIs ─────────────────────────────────────────────────────────
2305
2317
  const externalClis = loadExternalClis();
2306
2318
  const externalCmd = program
@@ -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,91 @@ 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
+ });
155
+ describe('profile list', () => {
156
+ const stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
157
+ beforeEach(() => {
158
+ process.exitCode = undefined;
159
+ stdoutSpy.mockClear();
160
+ vi.stubGlobal('fetch', vi.fn());
161
+ });
162
+ it('reports stale daemon instead of no profiles when status lacks profile support', async () => {
163
+ vi.mocked(fetch).mockResolvedValue({
164
+ ok: true,
165
+ json: async () => ({
166
+ ok: true,
167
+ pid: 123,
168
+ uptime: 1,
169
+ daemonVersion: '1.7.6',
170
+ extensionConnected: true,
171
+ extensionVersion: '1.0.3',
172
+ pending: 0,
173
+ memoryMB: 20,
174
+ port: 19825,
175
+ }),
176
+ });
177
+ const program = createProgram('', '');
178
+ await program.parseAsync(['node', 'opencli', 'profile', 'list']);
179
+ const output = stdoutSpy.mock.calls.flat().join('\n');
180
+ expect(output).toContain('stale');
181
+ expect(output).toContain('opencli daemon restart');
182
+ expect(output).not.toContain('No Browser Bridge profiles connected');
183
+ });
184
+ it('keeps the empty profile message for current daemon status with no profiles', async () => {
185
+ vi.mocked(fetch).mockResolvedValue({
186
+ ok: true,
187
+ json: async () => ({
188
+ ok: true,
189
+ pid: 123,
190
+ uptime: 1,
191
+ daemonVersion: PKG_VERSION,
192
+ extensionConnected: false,
193
+ profiles: [],
194
+ pending: 0,
195
+ memoryMB: 20,
196
+ port: 19825,
197
+ }),
198
+ });
199
+ const program = createProgram('', '');
200
+ await program.parseAsync(['node', 'opencli', 'profile', 'list']);
201
+ const output = stdoutSpy.mock.calls.flat().join('\n');
202
+ expect(output).toContain('No Browser Bridge profiles connected');
203
+ expect(output).not.toContain('opencli daemon restart');
204
+ });
205
+ });
112
206
  describe('browser tab targeting commands', () => {
113
207
  const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
114
208
  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
+ });
@@ -20,6 +20,7 @@ export type DoctorReport = {
20
20
  cliVersion?: string;
21
21
  daemonRunning: boolean;
22
22
  daemonFlaky?: boolean;
23
+ daemonStale?: boolean;
23
24
  daemonVersion?: string;
24
25
  extensionConnected: boolean;
25
26
  extensionFlaky?: boolean;
@@ -11,6 +11,7 @@ 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';
14
15
  const DOCTOR_LIVE_TIMEOUT_SECONDS = 8;
15
16
  /** Parse a semver string into [major, minor, patch]. Returns null on invalid input. */
16
17
  function parseSemver(v) {
@@ -90,6 +91,7 @@ export async function runBrowserDoctor(opts = {}) {
90
91
  const extensionConnected = health.state === 'ready';
91
92
  const daemonFlaky = !!(connectivity?.ok && !daemonRunning);
92
93
  const extensionFlaky = !!(connectivity?.ok && daemonRunning && !extensionConnected);
94
+ const daemonStale = isDaemonStale(health.status, opts.cliVersion);
93
95
  const profiles = health.status?.profiles;
94
96
  let sessions;
95
97
  if (opts.sessions) {
@@ -113,6 +115,9 @@ export async function runBrowserDoctor(opts = {}) {
113
115
  else if (!daemonRunning) {
114
116
  issues.push('Daemon is not running. It should start automatically when you run an opencli browser command.');
115
117
  }
118
+ if (daemonStale && opts.cliVersion) {
119
+ issues.push(staleDaemonIssue(health.status, opts.cliVersion));
120
+ }
116
121
  if (extensionFlaky) {
117
122
  issues.push('Extension connection is unstable. The live browser test succeeded, but the daemon reported the extension disconnected immediately afterward.\n' +
118
123
  'This usually means the Browser Bridge service worker is reconnecting slowly or Chrome suspended it.');
@@ -127,24 +132,12 @@ export async function runBrowserDoctor(opts = {}) {
127
132
  ' Open that Chrome profile and make sure the OpenCLI extension is enabled.');
128
133
  }
129
134
  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
- }
135
+ issues.push('Daemon is running but the Chrome/Chromium extension is not connected.\n' +
136
+ 'If the extension is already installed, try: opencli daemon restart\n' +
137
+ 'If the extension is not installed:\n' +
138
+ ' 1. Download from https://github.com/jackwener/opencli/releases\n' +
139
+ ' 2. Open chrome://extensions/ Enable Developer Mode\n' +
140
+ ' 3. Click "Load unpacked" select the extension folder');
148
141
  }
149
142
  }
150
143
  if (extensionConnected && !extensionVersion) {
@@ -182,6 +175,7 @@ export async function runBrowserDoctor(opts = {}) {
182
175
  cliVersion: opts.cliVersion,
183
176
  daemonRunning,
184
177
  daemonFlaky,
178
+ daemonStale,
185
179
  daemonVersion: health.status?.daemonVersion,
186
180
  extensionConnected,
187
181
  extensionFlaky,
@@ -198,10 +192,16 @@ export function renderBrowserDoctorReport(report) {
198
192
  // Daemon status
199
193
  const daemonIcon = report.daemonFlaky
200
194
  ? styleText('yellow', '[WARN]')
201
- : report.daemonRunning ? styleText('green', '[OK]') : styleText('red', '[MISSING]');
195
+ : report.daemonStale
196
+ ? styleText('yellow', '[WARN]')
197
+ : report.daemonRunning ? styleText('green', '[OK]') : styleText('red', '[MISSING]');
202
198
  const daemonLabel = report.daemonFlaky
203
199
  ? 'unstable (running during live check, then stopped)'
204
- : report.daemonRunning ? `running on port ${DEFAULT_DAEMON_PORT}` + (report.daemonVersion ? ` (v${report.daemonVersion})` : '') : 'not running';
200
+ : report.daemonRunning
201
+ ? `running on port ${DEFAULT_DAEMON_PORT} (${report.daemonStale
202
+ ? `${formatDaemonVersion(report)}, stale; CLI v${report.cliVersion ?? 'unknown'}`
203
+ : formatDaemonVersion(report)})`
204
+ : 'not running';
205
205
  lines.push(`${daemonIcon} Daemon: ${daemonLabel}`);
206
206
  // Extension status
207
207
  const extIcon = report.extensionFlaky || (report.extensionConnected && !report.extensionVersion)
@@ -23,15 +23,32 @@ describe('doctor report rendering', () => {
23
23
  });
24
24
  it('renders OK-style report when daemon and extension connected', () => {
25
25
  const text = strip(renderBrowserDoctorReport({
26
+ cliVersion: '1.7.9',
26
27
  daemonRunning: true,
28
+ daemonVersion: '1.7.9',
27
29
  extensionConnected: true,
28
30
  extensionVersion: '1.6.8',
29
31
  issues: [],
30
32
  }));
31
33
  expect(text).toContain('[OK] Daemon: running on port 19825');
34
+ expect(text).toContain('(v1.7.9)');
32
35
  expect(text).toContain('[OK] Extension: connected (v1.6.8)');
33
36
  expect(text).toContain('Everything looks good!');
34
37
  });
38
+ it('renders a warning when daemon version is stale', () => {
39
+ const text = strip(renderBrowserDoctorReport({
40
+ cliVersion: '1.7.9',
41
+ daemonRunning: true,
42
+ daemonVersion: '1.7.6',
43
+ daemonStale: true,
44
+ extensionConnected: true,
45
+ extensionVersion: '1.0.3',
46
+ issues: ['Stale daemon detected: daemon v1.7.6 != CLI v1.7.9.\n Run: opencli daemon restart'],
47
+ }));
48
+ expect(text).toContain('[WARN] Daemon: running on port 19825 (v1.7.6, stale; CLI v1.7.9)');
49
+ expect(text).toContain('Run: opencli daemon restart');
50
+ expect(text).not.toContain('Everything looks good!');
51
+ });
35
52
  it('renders MISSING when daemon not running', () => {
36
53
  const text = strip(renderBrowserDoctorReport({
37
54
  daemonRunning: false,
@@ -238,6 +255,24 @@ describe('doctor report rendering', () => {
238
255
  expect.stringContaining('did not report a version'),
239
256
  ]));
240
257
  });
258
+ it('reports an issue when daemon version differs from CLI version', async () => {
259
+ const status = {
260
+ state: 'ready',
261
+ status: {
262
+ daemonVersion: '1.7.6',
263
+ extensionConnected: true,
264
+ extensionVersion: '1.0.3',
265
+ },
266
+ };
267
+ mockGetDaemonHealth
268
+ .mockResolvedValueOnce(status)
269
+ .mockResolvedValueOnce(status);
270
+ const report = await runBrowserDoctor({ live: false, cliVersion: '1.7.9' });
271
+ expect(report.daemonStale).toBe(true);
272
+ expect(report.issues).toEqual(expect.arrayContaining([
273
+ expect.stringContaining('Stale daemon detected: daemon v1.7.6 != CLI v1.7.9'),
274
+ ]));
275
+ });
241
276
  it('reports profile-required when multiple profiles are connected without a selection', async () => {
242
277
  const status = {
243
278
  state: 'profile-required',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "1.7.9",
3
+ "version": "1.7.10",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },