@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 +34 -1
- package/clis/instagram/collection-create.js +57 -0
- package/clis/instagram/saved.js +21 -7
- package/dist/src/browser/bridge.d.ts +0 -2
- package/dist/src/browser/bridge.js +4 -32
- 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/cli.js +15 -3
- package/dist/src/cli.test.js +95 -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 +1 -0
- package/dist/src/doctor.js +20 -20
- package/dist/src/doctor.test.js +35 -0
- package/package.json +1 -1
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
|
+
});
|
package/clis/instagram/saved.js
CHANGED
|
@@ -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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
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
|
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,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
|
-
|
|
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
package/dist/src/doctor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
}
|
|
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.
|
|
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
|
|
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)
|
package/dist/src/doctor.test.js
CHANGED
|
@@ -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',
|