@jackwener/opencli 1.7.13 → 1.7.14
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 +112 -0
- package/clis/twitter/quote.js +139 -0
- package/clis/twitter/quote.test.js +106 -0
- package/clis/twitter/retweet.js +99 -0
- package/clis/twitter/retweet.test.js +69 -0
- package/clis/twitter/shared.js +38 -0
- package/clis/twitter/shared.test.js +28 -1
- package/clis/twitter/unlike.js +87 -0
- package/clis/twitter/unlike.test.js +72 -0
- package/clis/twitter/unretweet.js +99 -0
- package/clis/twitter/unretweet.test.js +69 -0
- package/dist/src/browser/bridge.js +47 -45
- package/dist/src/browser.test.js +18 -0
- package/dist/src/cli.test.js +91 -1
- package/dist/src/commanderAdapter.js +23 -4
- package/dist/src/help.d.ts +4 -0
- package/dist/src/help.js +156 -5
- package/package.json +1 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
import { parseTweetUrl } from './shared.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'twitter',
|
|
7
|
+
name: 'unlike',
|
|
8
|
+
access: 'write',
|
|
9
|
+
description: 'Remove a like from a specific tweet',
|
|
10
|
+
domain: 'x.com',
|
|
11
|
+
strategy: Strategy.UI,
|
|
12
|
+
browser: true,
|
|
13
|
+
args: [
|
|
14
|
+
{ name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to unlike' },
|
|
15
|
+
],
|
|
16
|
+
columns: ['status', 'message'],
|
|
17
|
+
func: async (page, kwargs) => {
|
|
18
|
+
if (!page)
|
|
19
|
+
throw new CommandExecutionError('Browser session required for twitter unlike');
|
|
20
|
+
const target = parseTweetUrl(kwargs.url);
|
|
21
|
+
await page.goto(target.url);
|
|
22
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
23
|
+
const result = await page.evaluate(`(async () => {
|
|
24
|
+
try {
|
|
25
|
+
const tweetId = ${JSON.stringify(target.id)};
|
|
26
|
+
const findTargetArticle = () => Array.from(document.querySelectorAll('article')).find((article) =>
|
|
27
|
+
Array.from(article.querySelectorAll('a[href*="/status/"]')).some((link) => {
|
|
28
|
+
try {
|
|
29
|
+
const match = new URL(link.href, window.location.origin).pathname.match(/^\/(?:[^/]+|i)\/status\/(\d+)\/?$/);
|
|
30
|
+
return match?.[1] === tweetId;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
);
|
|
36
|
+
// Poll for the tweet to render
|
|
37
|
+
let attempts = 0;
|
|
38
|
+
let likeBtn = null;
|
|
39
|
+
let unlikeBtn = null;
|
|
40
|
+
let targetArticle = null;
|
|
41
|
+
|
|
42
|
+
while (attempts < 20) {
|
|
43
|
+
targetArticle = findTargetArticle();
|
|
44
|
+
likeBtn = targetArticle?.querySelector('[data-testid="like"]') || null;
|
|
45
|
+
unlikeBtn = targetArticle?.querySelector('[data-testid="unlike"]') || null;
|
|
46
|
+
|
|
47
|
+
if (likeBtn || unlikeBtn) break;
|
|
48
|
+
|
|
49
|
+
await new Promise(r => setTimeout(r, 500));
|
|
50
|
+
attempts++;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check if it's already not liked
|
|
54
|
+
if (likeBtn) {
|
|
55
|
+
return { ok: true, message: 'Tweet is not liked (already unliked).' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!unlikeBtn) {
|
|
59
|
+
return { ok: false, message: 'Could not find the Unlike button on this tweet after waiting 10 seconds. Are you logged in?' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Click Unlike
|
|
63
|
+
unlikeBtn.click();
|
|
64
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
65
|
+
|
|
66
|
+
// Verify success by checking if the 'like' button reappeared
|
|
67
|
+
const verifyArticle = findTargetArticle() || targetArticle;
|
|
68
|
+
const verifyBtn = verifyArticle?.querySelector('[data-testid="like"]');
|
|
69
|
+
if (verifyBtn) {
|
|
70
|
+
return { ok: true, message: 'Tweet successfully unliked.' };
|
|
71
|
+
} else {
|
|
72
|
+
return { ok: false, message: 'Unlike action was initiated but UI did not update as expected.' };
|
|
73
|
+
}
|
|
74
|
+
} catch (e) {
|
|
75
|
+
return { ok: false, message: e.toString() };
|
|
76
|
+
}
|
|
77
|
+
})()`);
|
|
78
|
+
if (result.ok) {
|
|
79
|
+
// Wait for the unlike network request to be processed
|
|
80
|
+
await page.wait(2);
|
|
81
|
+
}
|
|
82
|
+
return [{
|
|
83
|
+
status: result.ok ? 'success' : 'failed',
|
|
84
|
+
message: result.message
|
|
85
|
+
}];
|
|
86
|
+
}
|
|
87
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
4
|
+
import './unlike.js';
|
|
5
|
+
import { createPageMock } from '../test-utils.js';
|
|
6
|
+
|
|
7
|
+
describe('twitter unlike command', () => {
|
|
8
|
+
it('navigates to the tweet URL and reports success when the unlike script confirms', async () => {
|
|
9
|
+
const cmd = getRegistry().get('twitter/unlike');
|
|
10
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
11
|
+
const page = createPageMock([
|
|
12
|
+
{ ok: true, message: 'Tweet successfully unliked.' },
|
|
13
|
+
]);
|
|
14
|
+
const result = await cmd.func(page, {
|
|
15
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
16
|
+
});
|
|
17
|
+
expect(page.goto).toHaveBeenCalledWith('https://x.com/alice/status/2040254679301718161');
|
|
18
|
+
expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="primaryColumn"]' });
|
|
19
|
+
// After ok:true the adapter waits an extra 2s for the network round-trip.
|
|
20
|
+
expect(page.wait).toHaveBeenNthCalledWith(2, 2);
|
|
21
|
+
const script = page.evaluate.mock.calls[0][0];
|
|
22
|
+
// Idempotency check: looks for the like button (already-not-liked path) before clicking unlike.
|
|
23
|
+
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"like\"]')");
|
|
24
|
+
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"unlike\"]')");
|
|
25
|
+
expect(script).toContain('unlikeBtn.click()');
|
|
26
|
+
expect(script).toContain("document.querySelectorAll('article')");
|
|
27
|
+
expect(script).toContain('match?.[1] === tweetId');
|
|
28
|
+
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"unlike\"]')");
|
|
29
|
+
expect(result).toEqual([
|
|
30
|
+
{ status: 'success', message: 'Tweet successfully unliked.' },
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns a failed row without re-waiting when the unlike script reports a UI mismatch', async () => {
|
|
35
|
+
const cmd = getRegistry().get('twitter/unlike');
|
|
36
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
37
|
+
const page = createPageMock([
|
|
38
|
+
{
|
|
39
|
+
ok: false,
|
|
40
|
+
message: 'Could not find the Unlike button on this tweet after waiting 10 seconds. Are you logged in?',
|
|
41
|
+
},
|
|
42
|
+
]);
|
|
43
|
+
const result = await cmd.func(page, {
|
|
44
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
45
|
+
});
|
|
46
|
+
expect(result).toEqual([
|
|
47
|
+
{
|
|
48
|
+
status: 'failed',
|
|
49
|
+
message: 'Could not find the Unlike button on this tweet after waiting 10 seconds. Are you logged in?',
|
|
50
|
+
},
|
|
51
|
+
]);
|
|
52
|
+
// Only the primaryColumn wait should run when ok is false.
|
|
53
|
+
expect(page.wait).toHaveBeenCalledTimes(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('throws CommandExecutionError when no page is provided', async () => {
|
|
57
|
+
const cmd = getRegistry().get('twitter/unlike');
|
|
58
|
+
await expect(cmd.func(undefined, {
|
|
59
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
60
|
+
})).rejects.toThrow(CommandExecutionError);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('rejects invalid tweet URLs before navigation', async () => {
|
|
64
|
+
const cmd = getRegistry().get('twitter/unlike');
|
|
65
|
+
const page = createPageMock([]);
|
|
66
|
+
await expect(cmd.func(page, {
|
|
67
|
+
url: 'https://x.com/alice/status/2040254679301718161/photo/1',
|
|
68
|
+
})).rejects.toThrow(ArgumentError);
|
|
69
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
70
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
import { parseTweetUrl } from './shared.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'twitter',
|
|
7
|
+
name: 'unretweet',
|
|
8
|
+
access: 'write',
|
|
9
|
+
description: 'Undo a retweet on a specific tweet',
|
|
10
|
+
domain: 'x.com',
|
|
11
|
+
strategy: Strategy.UI,
|
|
12
|
+
browser: true,
|
|
13
|
+
args: [
|
|
14
|
+
{ name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to unretweet' },
|
|
15
|
+
],
|
|
16
|
+
columns: ['status', 'message'],
|
|
17
|
+
func: async (page, kwargs) => {
|
|
18
|
+
if (!page)
|
|
19
|
+
throw new CommandExecutionError('Browser session required for twitter unretweet');
|
|
20
|
+
const target = parseTweetUrl(kwargs.url);
|
|
21
|
+
await page.goto(target.url);
|
|
22
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
23
|
+
const result = await page.evaluate(`(async () => {
|
|
24
|
+
try {
|
|
25
|
+
const tweetId = ${JSON.stringify(target.id)};
|
|
26
|
+
const findTargetArticle = () => Array.from(document.querySelectorAll('article')).find((article) =>
|
|
27
|
+
Array.from(article.querySelectorAll('a[href*="/status/"]')).some((link) => {
|
|
28
|
+
try {
|
|
29
|
+
const match = new URL(link.href, window.location.origin).pathname.match(/^\/(?:[^/]+|i)\/status\/(\d+)\/?$/);
|
|
30
|
+
return match?.[1] === tweetId;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
);
|
|
36
|
+
// Poll for the tweet to render
|
|
37
|
+
let attempts = 0;
|
|
38
|
+
let retweetBtn = null;
|
|
39
|
+
let unretweetBtn = null;
|
|
40
|
+
let targetArticle = null;
|
|
41
|
+
|
|
42
|
+
while (attempts < 20) {
|
|
43
|
+
targetArticle = findTargetArticle();
|
|
44
|
+
retweetBtn = targetArticle?.querySelector('[data-testid="retweet"]') || null;
|
|
45
|
+
unretweetBtn = targetArticle?.querySelector('[data-testid="unretweet"]') || null;
|
|
46
|
+
|
|
47
|
+
if (retweetBtn || unretweetBtn) break;
|
|
48
|
+
|
|
49
|
+
await new Promise(r => setTimeout(r, 500));
|
|
50
|
+
attempts++;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Already not retweeted: idempotent success
|
|
54
|
+
if (retweetBtn) {
|
|
55
|
+
return { ok: true, message: 'Tweet is not retweeted (already removed).' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!unretweetBtn) {
|
|
59
|
+
return { ok: false, message: 'Could not find the Unretweet button on this tweet after waiting 10 seconds. Are you logged in?' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Step 1: click Unretweet button → opens menu
|
|
63
|
+
unretweetBtn.click();
|
|
64
|
+
|
|
65
|
+
// Step 2: wait for the confirm menu item to appear, then click it
|
|
66
|
+
let confirmBtn = null;
|
|
67
|
+
for (let i = 0; i < 20; i++) {
|
|
68
|
+
await new Promise(r => setTimeout(r, 250));
|
|
69
|
+
confirmBtn = document.querySelector('[data-testid="unretweetConfirm"]');
|
|
70
|
+
if (confirmBtn) break;
|
|
71
|
+
}
|
|
72
|
+
if (!confirmBtn) {
|
|
73
|
+
return { ok: false, message: 'Unretweet menu opened but the confirm option did not appear.' };
|
|
74
|
+
}
|
|
75
|
+
confirmBtn.click();
|
|
76
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
77
|
+
|
|
78
|
+
// Verify success by checking if the 'retweet' button reappeared
|
|
79
|
+
const verifyArticle = findTargetArticle() || targetArticle;
|
|
80
|
+
const verifyBtn = verifyArticle?.querySelector('[data-testid="retweet"]');
|
|
81
|
+
if (verifyBtn) {
|
|
82
|
+
return { ok: true, message: 'Tweet successfully unretweeted.' };
|
|
83
|
+
} else {
|
|
84
|
+
return { ok: false, message: 'Unretweet action was initiated but UI did not update as expected.' };
|
|
85
|
+
}
|
|
86
|
+
} catch (e) {
|
|
87
|
+
return { ok: false, message: e.toString() };
|
|
88
|
+
}
|
|
89
|
+
})()`);
|
|
90
|
+
if (result.ok) {
|
|
91
|
+
// Wait for the unretweet network request to be processed
|
|
92
|
+
await page.wait(2);
|
|
93
|
+
}
|
|
94
|
+
return [{
|
|
95
|
+
status: result.ok ? 'success' : 'failed',
|
|
96
|
+
message: result.message
|
|
97
|
+
}];
|
|
98
|
+
}
|
|
99
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
4
|
+
import './unretweet.js';
|
|
5
|
+
import { createPageMock } from '../test-utils.js';
|
|
6
|
+
|
|
7
|
+
describe('twitter unretweet command', () => {
|
|
8
|
+
it('clicks the unretweet button then the confirm menu item and reports success', async () => {
|
|
9
|
+
const cmd = getRegistry().get('twitter/unretweet');
|
|
10
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
11
|
+
const page = createPageMock([
|
|
12
|
+
{ ok: true, message: 'Tweet successfully unretweeted.' },
|
|
13
|
+
]);
|
|
14
|
+
const result = await cmd.func(page, {
|
|
15
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
16
|
+
});
|
|
17
|
+
expect(page.goto).toHaveBeenCalledWith('https://x.com/alice/status/2040254679301718161');
|
|
18
|
+
expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="primaryColumn"]' });
|
|
19
|
+
expect(page.wait).toHaveBeenNthCalledWith(2, 2);
|
|
20
|
+
const script = page.evaluate.mock.calls[0][0];
|
|
21
|
+
// Two-step UI flow must be present:
|
|
22
|
+
// 1) click the unretweet button
|
|
23
|
+
// 2) wait for and click the confirm menu item (data-testid="unretweetConfirm")
|
|
24
|
+
expect(script).toContain('unretweetBtn.click()');
|
|
25
|
+
expect(script).toContain("document.querySelector('[data-testid=\"unretweetConfirm\"]')");
|
|
26
|
+
expect(script).toContain('confirmBtn.click()');
|
|
27
|
+
expect(script).toContain("document.querySelectorAll('article')");
|
|
28
|
+
expect(script).toContain('match?.[1] === tweetId');
|
|
29
|
+
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"unretweet\"]')");
|
|
30
|
+
// Idempotency probe: when already not retweeted ([data-testid="retweet"] present),
|
|
31
|
+
// the script returns ok:true with an "already removed" message.
|
|
32
|
+
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"retweet\"]')");
|
|
33
|
+
expect(result).toEqual([
|
|
34
|
+
{ status: 'success', message: 'Tweet successfully unretweeted.' },
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns a failed row when the confirm menu item never appears', async () => {
|
|
39
|
+
const cmd = getRegistry().get('twitter/unretweet');
|
|
40
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
41
|
+
const page = createPageMock([
|
|
42
|
+
{ ok: false, message: 'Unretweet menu opened but the confirm option did not appear.' },
|
|
43
|
+
]);
|
|
44
|
+
const result = await cmd.func(page, {
|
|
45
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
46
|
+
});
|
|
47
|
+
expect(result).toEqual([
|
|
48
|
+
{ status: 'failed', message: 'Unretweet menu opened but the confirm option did not appear.' },
|
|
49
|
+
]);
|
|
50
|
+
expect(page.wait).toHaveBeenCalledTimes(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('throws CommandExecutionError when no page is provided', async () => {
|
|
54
|
+
const cmd = getRegistry().get('twitter/unretweet');
|
|
55
|
+
await expect(cmd.func(undefined, {
|
|
56
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
57
|
+
})).rejects.toThrow(CommandExecutionError);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('rejects invalid tweet URLs before navigation', async () => {
|
|
61
|
+
const cmd = getRegistry().get('twitter/unretweet');
|
|
62
|
+
const page = createPageMock([]);
|
|
63
|
+
await expect(cmd.func(page, {
|
|
64
|
+
url: 'http://x.com/alice/status/2040254679301718161',
|
|
65
|
+
})).rejects.toThrow(ArgumentError);
|
|
66
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
67
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -54,62 +54,64 @@ export class BrowserBridge {
|
|
|
54
54
|
const effectiveSeconds = (timeoutSeconds && timeoutSeconds > 0) ? timeoutSeconds : Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000);
|
|
55
55
|
const timeoutMs = effectiveSeconds * 1000;
|
|
56
56
|
const health = await getDaemonHealth({ contextId });
|
|
57
|
+
// Detect stale daemon before any fast path. A stale daemon can still have
|
|
58
|
+
// the extension connected, so this cannot live only in the no-extension branch.
|
|
59
|
+
const daemonVersion = health.status?.daemonVersion;
|
|
60
|
+
const isStale = !!health.status && (!daemonVersion || daemonVersion !== PKG_VERSION);
|
|
61
|
+
let staleDaemonReplaced = false;
|
|
62
|
+
if (isStale) {
|
|
63
|
+
// Stale daemon — restart it so all browser commands run against the
|
|
64
|
+
// currently installed package code, not the old daemon binary.
|
|
65
|
+
const reason = daemonVersion
|
|
66
|
+
? `v${daemonVersion} ≠ v${PKG_VERSION}`
|
|
67
|
+
: `pre-version daemon, CLI is v${PKG_VERSION}`;
|
|
68
|
+
if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
|
|
69
|
+
process.stderr.write(`⚠️ Stale daemon detected (${reason}). Restarting...\n`);
|
|
70
|
+
}
|
|
71
|
+
const shutdownAccepted = await requestDaemonShutdown();
|
|
72
|
+
const portReleased = shutdownAccepted && await waitForDaemonStop(3000);
|
|
73
|
+
if (!portReleased) {
|
|
74
|
+
// Stale daemon replacement failed — don't blindly spawn on an occupied port
|
|
75
|
+
throw new BrowserConnectError('Stale daemon could not be replaced', `A stale daemon (${reason}) is running but did not shut down.\n` +
|
|
76
|
+
' Run manually: opencli daemon stop && opencli doctor', 'daemon-not-running');
|
|
77
|
+
}
|
|
78
|
+
// Port released — fall through to spawn a fresh daemon
|
|
79
|
+
staleDaemonReplaced = true;
|
|
80
|
+
}
|
|
57
81
|
// Fast path: everything ready
|
|
58
|
-
if (health.state === 'ready')
|
|
82
|
+
if (!staleDaemonReplaced && health.state === 'ready')
|
|
59
83
|
return;
|
|
60
|
-
if (health.state === 'profile-required') {
|
|
84
|
+
if (!staleDaemonReplaced && health.state === 'profile-required') {
|
|
61
85
|
throw new BrowserConnectError('Multiple Browser Bridge profiles are connected', 'Select one with --profile <name>, OPENCLI_PROFILE=<name>, or opencli profile use <name>.\n' +
|
|
62
86
|
'Run opencli profile list to see connected profiles.', 'profile-required');
|
|
63
87
|
}
|
|
64
|
-
if (health.state === 'profile-disconnected') {
|
|
88
|
+
if (!staleDaemonReplaced && health.state === 'profile-disconnected') {
|
|
65
89
|
const label = contextId ?? health.status.contextId ?? 'unknown';
|
|
66
90
|
throw new BrowserConnectError(`Browser profile "${label}" is not connected`, 'Open the matching Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.', 'profile-disconnected');
|
|
67
91
|
}
|
|
68
92
|
// Daemon running but no extension
|
|
69
|
-
if (health.state === 'no-extension') {
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
// Stale daemon — restart it so extension gets a fresh WebSocket endpoint
|
|
75
|
-
const reason = daemonVersion
|
|
76
|
-
? `v${daemonVersion} ≠ v${PKG_VERSION}`
|
|
77
|
-
: `pre-version daemon, CLI is v${PKG_VERSION}`;
|
|
78
|
-
if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
|
|
79
|
-
process.stderr.write(`⚠️ Stale daemon detected (${reason}). Restarting...\n`);
|
|
80
|
-
}
|
|
81
|
-
const shutdownAccepted = await requestDaemonShutdown();
|
|
82
|
-
const portReleased = shutdownAccepted && await waitForDaemonStop(3000);
|
|
83
|
-
if (!portReleased) {
|
|
84
|
-
// Stale daemon replacement failed — don't blindly spawn on an occupied port
|
|
85
|
-
throw new BrowserConnectError('Stale daemon could not be replaced', `A stale daemon (${reason}) is running but did not shut down.\n` +
|
|
86
|
-
' Run manually: opencli daemon stop && opencli doctor', 'daemon-not-running');
|
|
87
|
-
}
|
|
88
|
-
// Port released — fall through to spawn a fresh daemon
|
|
93
|
+
if (!staleDaemonReplaced && health.state === 'no-extension') {
|
|
94
|
+
// Same version — wait for extension to connect
|
|
95
|
+
if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
|
|
96
|
+
process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n');
|
|
97
|
+
process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n');
|
|
89
98
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (await this._pollUntilReady(timeoutMs, contextId))
|
|
97
|
-
return;
|
|
98
|
-
const finalHealth = await getDaemonHealth({ contextId });
|
|
99
|
-
if (finalHealth.state === 'profile-required') {
|
|
100
|
-
throw new BrowserConnectError('Multiple Browser Bridge profiles are connected', 'Select one with --profile <name>, OPENCLI_PROFILE=<name>, or opencli profile use <name>.\n' +
|
|
101
|
-
'Run opencli profile list to see connected profiles.', 'profile-required');
|
|
102
|
-
}
|
|
103
|
-
if (finalHealth.state === 'profile-disconnected') {
|
|
104
|
-
const label = contextId ?? finalHealth.status.contextId ?? 'unknown';
|
|
105
|
-
throw new BrowserConnectError(`Browser profile "${label}" is not connected`, 'Open the matching Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.', 'profile-disconnected');
|
|
106
|
-
}
|
|
107
|
-
throw new BrowserConnectError('Browser Bridge extension not connected', 'Make sure Chrome/Chromium is open and the extension is enabled.\n' +
|
|
108
|
-
'If the extension is installed, try: opencli daemon stop && opencli doctor\n' +
|
|
109
|
-
'If not installed:\n' +
|
|
110
|
-
' 1. Download: https://github.com/jackwener/opencli/releases\n' +
|
|
111
|
-
' 2. Open chrome://extensions → Developer Mode → Load unpacked', 'extension-not-connected');
|
|
99
|
+
if (await this._pollUntilReady(timeoutMs, contextId))
|
|
100
|
+
return;
|
|
101
|
+
const finalHealth = await getDaemonHealth({ contextId });
|
|
102
|
+
if (finalHealth.state === 'profile-required') {
|
|
103
|
+
throw new BrowserConnectError('Multiple Browser Bridge profiles are connected', 'Select one with --profile <name>, OPENCLI_PROFILE=<name>, or opencli profile use <name>.\n' +
|
|
104
|
+
'Run opencli profile list to see connected profiles.', 'profile-required');
|
|
112
105
|
}
|
|
106
|
+
if (finalHealth.state === 'profile-disconnected') {
|
|
107
|
+
const label = contextId ?? finalHealth.status.contextId ?? 'unknown';
|
|
108
|
+
throw new BrowserConnectError(`Browser profile "${label}" is not connected`, 'Open the matching Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.', 'profile-disconnected');
|
|
109
|
+
}
|
|
110
|
+
throw new BrowserConnectError('Browser Bridge extension not connected', 'Make sure Chrome/Chromium is open and the extension is enabled.\n' +
|
|
111
|
+
'If the extension is installed, try: opencli daemon stop && opencli doctor\n' +
|
|
112
|
+
'If not installed:\n' +
|
|
113
|
+
' 1. Download: https://github.com/jackwener/opencli/releases\n' +
|
|
114
|
+
' 2. Open chrome://extensions → Developer Mode → Load unpacked', 'extension-not-connected');
|
|
113
115
|
}
|
|
114
116
|
// No daemon — spawn one
|
|
115
117
|
if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
|
package/dist/src/browser.test.js
CHANGED
|
@@ -165,6 +165,24 @@ describe('BrowserBridge state', () => {
|
|
|
165
165
|
const bridge = new BrowserBridge();
|
|
166
166
|
await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced');
|
|
167
167
|
});
|
|
168
|
+
it('attempts stale daemon replacement even when extension is connected', async () => {
|
|
169
|
+
vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({
|
|
170
|
+
state: 'ready',
|
|
171
|
+
status: {
|
|
172
|
+
ok: true,
|
|
173
|
+
pid: 1,
|
|
174
|
+
uptime: 0,
|
|
175
|
+
daemonVersion: '0.0.1',
|
|
176
|
+
extensionConnected: true,
|
|
177
|
+
pending: 0,
|
|
178
|
+
memoryMB: 0,
|
|
179
|
+
port: 0,
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
vi.spyOn(daemonClient, 'requestDaemonShutdown').mockResolvedValue(false);
|
|
183
|
+
const bridge = new BrowserBridge();
|
|
184
|
+
await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced');
|
|
185
|
+
});
|
|
168
186
|
});
|
|
169
187
|
describe('stealth anti-detection', () => {
|
|
170
188
|
it('generates non-empty JS string', () => {
|
package/dist/src/cli.test.js
CHANGED
|
@@ -223,6 +223,7 @@ describe('createProgram root help descriptions', () => {
|
|
|
223
223
|
strategy: Strategy.PUBLIC,
|
|
224
224
|
browser: false,
|
|
225
225
|
args: [{ name: 'limit', type: 'int', default: 20, help: 'Number of videos' }],
|
|
226
|
+
columns: ['title', 'url'],
|
|
226
227
|
});
|
|
227
228
|
const program = createProgram('', '');
|
|
228
229
|
const site = program.commands.find(cmd => cmd.name() === 'bilibili');
|
|
@@ -235,10 +236,99 @@ describe('createProgram root help descriptions', () => {
|
|
|
235
236
|
name: 'hot',
|
|
236
237
|
access: 'read',
|
|
237
238
|
description: 'Bilibili hot videos',
|
|
239
|
+
browser: false,
|
|
238
240
|
example: 'opencli bilibili hot -f yaml',
|
|
239
|
-
|
|
241
|
+
command_options: [{ name: 'limit', type: 'int', default: 20 }],
|
|
242
|
+
columns: ['title', 'url'],
|
|
240
243
|
},
|
|
241
244
|
]);
|
|
245
|
+
expect(data.commands[0]).not.toHaveProperty('args');
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
process.argv = argv;
|
|
249
|
+
registry.clear();
|
|
250
|
+
for (const [key, value] of snapshot)
|
|
251
|
+
registry.set(key, value);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
it('renders per-site text help without per-command common option noise', () => {
|
|
255
|
+
const registry = getRegistry();
|
|
256
|
+
const snapshot = new Map(registry);
|
|
257
|
+
registry.clear();
|
|
258
|
+
try {
|
|
259
|
+
cli({
|
|
260
|
+
site: 'bilibili',
|
|
261
|
+
name: 'hot',
|
|
262
|
+
access: 'read',
|
|
263
|
+
description: 'Bilibili hot videos',
|
|
264
|
+
strategy: Strategy.PUBLIC,
|
|
265
|
+
browser: false,
|
|
266
|
+
args: [{ name: 'limit', type: 'int', default: 20, help: 'Number of videos' }],
|
|
267
|
+
});
|
|
268
|
+
cli({
|
|
269
|
+
site: 'bilibili',
|
|
270
|
+
name: 'video',
|
|
271
|
+
access: 'read',
|
|
272
|
+
description: 'Read one video',
|
|
273
|
+
domain: 'www.bilibili.com',
|
|
274
|
+
strategy: Strategy.PUBLIC,
|
|
275
|
+
browser: true,
|
|
276
|
+
args: [{ name: 'bvid', positional: true, required: true, help: 'Video id' }],
|
|
277
|
+
});
|
|
278
|
+
const program = createProgram('', '');
|
|
279
|
+
const site = program.commands.find(cmd => cmd.name() === 'bilibili');
|
|
280
|
+
expect(site).toBeTruthy();
|
|
281
|
+
const help = site.helpInformation();
|
|
282
|
+
expect(help).toContain('hot [options] [read] Bilibili hot videos');
|
|
283
|
+
expect(help).toContain('video <bvid> [read] Read one video');
|
|
284
|
+
expect(help).toContain('hot [options]');
|
|
285
|
+
expect(help).not.toContain('video <bvid> [options]');
|
|
286
|
+
expect(help).not.toContain('\nOptions:');
|
|
287
|
+
expect(help).toContain('Common options:');
|
|
288
|
+
expect(help).toContain('-f, --format <fmt>');
|
|
289
|
+
expect(help).toContain('--trace <mode>');
|
|
290
|
+
expect(help).toContain('get all command args/options in one structured response');
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
registry.clear();
|
|
294
|
+
for (const [key, value] of snapshot)
|
|
295
|
+
registry.set(key, value);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
it('separates command args from common options in structured help', () => {
|
|
299
|
+
const registry = getRegistry();
|
|
300
|
+
const snapshot = new Map(registry);
|
|
301
|
+
const argv = process.argv;
|
|
302
|
+
registry.clear();
|
|
303
|
+
try {
|
|
304
|
+
cli({
|
|
305
|
+
site: 'bilibili',
|
|
306
|
+
name: 'video',
|
|
307
|
+
access: 'read',
|
|
308
|
+
description: 'Read one video',
|
|
309
|
+
strategy: Strategy.PUBLIC,
|
|
310
|
+
domain: 'www.bilibili.com',
|
|
311
|
+
browser: true,
|
|
312
|
+
args: [
|
|
313
|
+
{ name: 'bvid', positional: true, required: true, help: 'Video id' },
|
|
314
|
+
{ name: 'with-comments', type: 'boolean', default: false, help: 'Include comments' },
|
|
315
|
+
],
|
|
316
|
+
columns: ['title', 'url'],
|
|
317
|
+
});
|
|
318
|
+
const program = createProgram('', '');
|
|
319
|
+
const site = program.commands.find(cmd => cmd.name() === 'bilibili');
|
|
320
|
+
const command = site.commands.find(cmd => cmd.name() === 'video');
|
|
321
|
+
expect(command).toBeTruthy();
|
|
322
|
+
process.argv = ['node', 'opencli', 'bilibili', 'video', '--help', '-f', 'yaml'];
|
|
323
|
+
const data = yaml.load(command.helpInformation());
|
|
324
|
+
expect(data.usage).toBe('opencli bilibili video <bvid> [options]');
|
|
325
|
+
expect(data.browser).toBe(true);
|
|
326
|
+
expect(data.domain).toBe('www.bilibili.com');
|
|
327
|
+
expect(data.positionals).toMatchObject([{ name: 'bvid', positional: true, required: true }]);
|
|
328
|
+
expect(data.command_options).toMatchObject([{ name: 'with-comments', default: false }]);
|
|
329
|
+
expect(data.common_options.map((option) => option.name)).toEqual(['format', 'trace', 'verbose', 'help']);
|
|
330
|
+
expect(data.columns).toEqual(['title', 'url']);
|
|
331
|
+
expect(data).not.toHaveProperty('args');
|
|
242
332
|
}
|
|
243
333
|
finally {
|
|
244
334
|
process.argv = argv;
|
|
@@ -12,10 +12,9 @@
|
|
|
12
12
|
import { log } from './logger.js';
|
|
13
13
|
import yaml from 'js-yaml';
|
|
14
14
|
import { fullName, getRegistry } from './registry.js';
|
|
15
|
-
import { formatRegistryHelpText } from './serialization.js';
|
|
16
15
|
import { render as renderOutput } from './output.js';
|
|
17
16
|
import { executeCommand, prepareCommandArgs } from './execution.js';
|
|
18
|
-
import { commandHelpData, formatSiteCommandDescription,
|
|
17
|
+
import { commandHelpData, formatCommandHelpText, formatCommandListTerm, formatSiteCommandDescription, formatSiteHelpText, getRequestedHelpFormat, renderStructuredHelp, siteHelpData, } from './help.js';
|
|
19
18
|
import { CliError, EXIT_CODES, toEnvelope, } from './errors.js';
|
|
20
19
|
/**
|
|
21
20
|
* Register a single CliCommand as a Commander subcommand.
|
|
@@ -49,7 +48,16 @@ export function registerCommandToProgram(siteCmd, cmd) {
|
|
|
49
48
|
.option('-f, --format <fmt>', 'Output format: table, plain, json, yaml, md, csv', 'table')
|
|
50
49
|
.option('--trace <mode>', 'Trace capture: off, on, retain-on-failure', 'off')
|
|
51
50
|
.option('-v, --verbose', 'Debug output', false);
|
|
52
|
-
|
|
51
|
+
const originalHelpInformation = subCmd.helpInformation.bind(subCmd);
|
|
52
|
+
subCmd.helpInformation = ((contextOptions) => {
|
|
53
|
+
const format = getRequestedHelpFormat();
|
|
54
|
+
if (format)
|
|
55
|
+
return renderStructuredHelp(commandHelpData(cmd), format);
|
|
56
|
+
// Keep a fallback reference so future Commander upgrades still initialize
|
|
57
|
+
// internal help state before we render the cleaner grouped command help.
|
|
58
|
+
void originalHelpInformation(contextOptions);
|
|
59
|
+
return formatCommandHelpText(cmd);
|
|
60
|
+
});
|
|
53
61
|
subCmd.action(async (...actionArgs) => {
|
|
54
62
|
const actionOpts = actionArgs[positionalArgs.length] ?? {};
|
|
55
63
|
const optionsRecord = typeof actionOpts === 'object' && actionOpts !== null ? actionOpts : {};
|
|
@@ -174,7 +182,18 @@ export function registerAllCommands(program, siteGroups) {
|
|
|
174
182
|
for (const cmd of commands) {
|
|
175
183
|
registerCommandToProgram(siteCmd, cmd);
|
|
176
184
|
}
|
|
177
|
-
|
|
185
|
+
const commandTerms = new Map(commands.map(cmd => [cmd.name, formatCommandListTerm(cmd)]));
|
|
186
|
+
siteCmd.configureHelp({
|
|
187
|
+
subcommandTerm: command => commandTerms.get(command.name()) ?? command.name(),
|
|
188
|
+
});
|
|
189
|
+
const originalSiteHelpInformation = siteCmd.helpInformation.bind(siteCmd);
|
|
190
|
+
siteCmd.helpInformation = ((contextOptions) => {
|
|
191
|
+
const format = getRequestedHelpFormat();
|
|
192
|
+
if (format)
|
|
193
|
+
return renderStructuredHelp(siteHelpData(site, commands), format);
|
|
194
|
+
void originalSiteHelpInformation(contextOptions);
|
|
195
|
+
return formatSiteHelpText(site, commands);
|
|
196
|
+
});
|
|
178
197
|
}
|
|
179
198
|
return [...commandsBySite.keys()].sort((a, b) => a.localeCompare(b));
|
|
180
199
|
}
|
package/dist/src/help.d.ts
CHANGED
|
@@ -29,8 +29,12 @@ export interface RootAdapterGroups {
|
|
|
29
29
|
sites: readonly string[];
|
|
30
30
|
}
|
|
31
31
|
export declare function formatRootAdapterHelpText(groups: RootAdapterGroups): string;
|
|
32
|
+
export declare function formatCommandListTerm(cmd: CliCommand): string;
|
|
32
33
|
export declare function rootHelpData(program: Command, groups: RootAdapterGroups): Record<string, unknown>;
|
|
33
34
|
export declare function siteHelpData(site: string, commands: readonly CliCommand[]): Record<string, unknown>;
|
|
34
35
|
export declare function commandHelpData(cmd: CliCommand): Record<string, unknown>;
|
|
36
|
+
export declare function formatCommonOptionsHelpText(): string;
|
|
37
|
+
export declare function formatSiteHelpText(site: string, commands: readonly CliCommand[]): string;
|
|
38
|
+
export declare function formatCommandHelpText(cmd: CliCommand): string;
|
|
35
39
|
export declare function installStructuredHelp(command: Command, data: () => unknown, textSuffix?: string | (() => string)): void;
|
|
36
40
|
export declare function formatSiteCommandDescription(cmd: CliCommand): string;
|