@jackwener/opencli 1.6.8 → 1.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +2 -0
  2. package/README.zh-CN.md +2 -1
  3. package/dist/clis/jianyu/search.d.ts +14 -0
  4. package/dist/clis/jianyu/search.js +135 -0
  5. package/dist/clis/jianyu/search.test.d.ts +1 -0
  6. package/dist/clis/jianyu/search.test.js +23 -0
  7. package/dist/clis/quark/ls.d.ts +1 -0
  8. package/dist/clis/quark/ls.js +63 -0
  9. package/dist/clis/quark/mkdir.d.ts +1 -0
  10. package/dist/clis/quark/mkdir.js +36 -0
  11. package/dist/clis/quark/mv.d.ts +1 -0
  12. package/dist/clis/quark/mv.js +53 -0
  13. package/dist/clis/quark/rename.d.ts +1 -0
  14. package/dist/clis/quark/rename.js +26 -0
  15. package/dist/clis/quark/rm.d.ts +1 -0
  16. package/dist/clis/quark/rm.js +24 -0
  17. package/dist/clis/quark/save.d.ts +1 -0
  18. package/dist/clis/quark/save.js +80 -0
  19. package/dist/clis/quark/share-tree.d.ts +1 -0
  20. package/dist/clis/quark/share-tree.js +45 -0
  21. package/dist/clis/quark/utils.d.ts +50 -0
  22. package/dist/clis/quark/utils.js +146 -0
  23. package/dist/clis/quark/utils.test.d.ts +1 -0
  24. package/dist/clis/quark/utils.test.js +58 -0
  25. package/dist/clis/twitter/reply.js +3 -8
  26. package/dist/clis/twitter/reply.test.js +5 -5
  27. package/dist/clis/xiaohongshu/note.js +8 -3
  28. package/dist/clis/xiaohongshu/note.test.js +11 -0
  29. package/dist/clis/zhihu/answer.d.ts +1 -0
  30. package/dist/clis/zhihu/answer.js +194 -0
  31. package/dist/clis/zhihu/answer.test.d.ts +1 -0
  32. package/dist/clis/zhihu/answer.test.js +81 -0
  33. package/dist/clis/zhihu/comment.d.ts +1 -0
  34. package/dist/clis/zhihu/comment.js +335 -0
  35. package/dist/clis/zhihu/comment.test.d.ts +1 -0
  36. package/dist/clis/zhihu/comment.test.js +54 -0
  37. package/dist/clis/zhihu/favorite.d.ts +1 -0
  38. package/dist/clis/zhihu/favorite.js +224 -0
  39. package/dist/clis/zhihu/favorite.test.d.ts +1 -0
  40. package/dist/clis/zhihu/favorite.test.js +196 -0
  41. package/dist/clis/zhihu/follow.d.ts +1 -0
  42. package/dist/clis/zhihu/follow.js +80 -0
  43. package/dist/clis/zhihu/follow.test.d.ts +1 -0
  44. package/dist/clis/zhihu/follow.test.js +45 -0
  45. package/dist/clis/zhihu/like.d.ts +1 -0
  46. package/dist/clis/zhihu/like.js +91 -0
  47. package/dist/clis/zhihu/like.test.d.ts +1 -0
  48. package/dist/clis/zhihu/like.test.js +64 -0
  49. package/dist/clis/zhihu/target.d.ts +24 -0
  50. package/dist/clis/zhihu/target.js +91 -0
  51. package/dist/clis/zhihu/target.test.d.ts +1 -0
  52. package/dist/clis/zhihu/target.test.js +77 -0
  53. package/dist/clis/zhihu/write-shared.d.ts +32 -0
  54. package/dist/clis/zhihu/write-shared.js +221 -0
  55. package/dist/clis/zhihu/write-shared.test.d.ts +1 -0
  56. package/dist/clis/zhihu/write-shared.test.js +175 -0
  57. package/dist/src/browser/bridge.d.ts +2 -0
  58. package/dist/src/browser/bridge.js +30 -24
  59. package/dist/src/browser/daemon-client.d.ts +17 -8
  60. package/dist/src/browser/daemon-client.js +12 -13
  61. package/dist/src/browser/daemon-client.test.js +32 -25
  62. package/dist/src/browser/index.d.ts +2 -1
  63. package/dist/src/browser/index.js +1 -1
  64. package/dist/src/browser.test.js +2 -3
  65. package/dist/src/cli.js +3 -3
  66. package/dist/src/clis/binance/commands.test.d.ts +1 -0
  67. package/dist/src/clis/binance/commands.test.js +54 -0
  68. package/dist/src/commanderAdapter.js +19 -6
  69. package/dist/src/diagnostic.d.ts +1 -0
  70. package/dist/src/diagnostic.js +64 -2
  71. package/dist/src/diagnostic.test.js +91 -1
  72. package/dist/src/doctor.d.ts +2 -0
  73. package/dist/src/doctor.js +59 -31
  74. package/dist/src/doctor.test.js +89 -16
  75. package/dist/src/execution.js +1 -13
  76. package/dist/src/explore.js +1 -1
  77. package/dist/src/generate.d.ts +2 -5
  78. package/dist/src/generate.js +2 -5
  79. package/dist/src/plugin.d.ts +2 -1
  80. package/dist/src/plugin.js +25 -8
  81. package/dist/src/plugin.test.js +16 -1
  82. package/package.json +3 -3
  83. package/dist/src/browser/discover.d.ts +0 -15
  84. package/dist/src/browser/discover.js +0 -19
@@ -0,0 +1,77 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { CliError } from '@jackwener/opencli/errors';
3
+ import { __test__ } from './target.js';
4
+ describe('zhihu target parser', () => {
5
+ it('parses typed answer IDs into canonical targets', () => {
6
+ expect(__test__.parseTarget('answer:123:456')).toEqual({
7
+ kind: 'answer',
8
+ questionId: '123',
9
+ id: '456',
10
+ url: 'https://www.zhihu.com/question/123/answer/456',
11
+ });
12
+ });
13
+ it('parses question URLs into canonical targets', () => {
14
+ expect(__test__.parseTarget('https://www.zhihu.com/question/123456')).toEqual({
15
+ kind: 'question',
16
+ id: '123456',
17
+ url: 'https://www.zhihu.com/question/123456',
18
+ });
19
+ });
20
+ it('canonicalizes question URLs with query strings and fragments', () => {
21
+ expect(__test__.parseTarget('https://www.zhihu.com/question/123456/?utm_source=share#answer-1')).toEqual({
22
+ kind: 'question',
23
+ id: '123456',
24
+ url: 'https://www.zhihu.com/question/123456',
25
+ });
26
+ });
27
+ it('canonicalizes answer URLs with query strings and fragments', () => {
28
+ expect(__test__.parseTarget('https://www.zhihu.com/question/123456/answer/7890/?utm_psn=1#comment')).toEqual({
29
+ kind: 'answer',
30
+ questionId: '123456',
31
+ id: '7890',
32
+ url: 'https://www.zhihu.com/question/123456/answer/7890',
33
+ });
34
+ });
35
+ it('canonicalizes article URLs with query strings and fragments', () => {
36
+ expect(__test__.parseTarget('https://zhuanlan.zhihu.com/p/98765/?utm_id=1#heading')).toEqual({
37
+ kind: 'article',
38
+ id: '98765',
39
+ url: 'https://zhuanlan.zhihu.com/p/98765',
40
+ });
41
+ });
42
+ it('canonicalizes user URLs with trailing slash, query strings, and fragments', () => {
43
+ expect(__test__.parseTarget('https://www.zhihu.com/people/example-user/?utm_term=share#about')).toEqual({
44
+ kind: 'user',
45
+ slug: 'example-user',
46
+ url: 'https://www.zhihu.com/people/example-user',
47
+ });
48
+ });
49
+ it('rejects non-https Zhihu URLs', () => {
50
+ expect(() => __test__.parseTarget('http://www.zhihu.com/question/123456')).toThrowError(CliError);
51
+ });
52
+ it('rejects Zhihu URLs with embedded credentials', () => {
53
+ expect(() => __test__.parseTarget('https://user@www.zhihu.com/question/123456')).toThrowError(CliError);
54
+ });
55
+ it('rejects Zhihu URLs with explicit ports', () => {
56
+ expect(() => __test__.parseTarget('https://www.zhihu.com:8443/question/123456')).toThrowError(CliError);
57
+ });
58
+ it('rejects Zhihu URLs with empty authority usernames', () => {
59
+ expect(() => __test__.parseTarget('https://@www.zhihu.com/question/123456')).toThrowError(CliError);
60
+ });
61
+ it('rejects Zhihu URLs with empty authority username and password markers', () => {
62
+ expect(() => __test__.parseTarget('https://:@www.zhihu.com/question/123456')).toThrowError(CliError);
63
+ });
64
+ it('rejects ambiguous bare numeric IDs', () => {
65
+ expect(() => __test__.parseTarget('123456')).toThrowError(CliError);
66
+ });
67
+ it('rejects malformed typed IDs', () => {
68
+ expect(() => __test__.parseTarget('answer:123')).toThrowError(/answer:<questionId>:<answerId>/);
69
+ });
70
+ it('rejects unsupported target kinds per command', () => {
71
+ expect(() => __test__.assertAllowedKinds('follow', {
72
+ kind: 'article',
73
+ id: '1',
74
+ url: 'https://zhuanlan.zhihu.com/p/1',
75
+ })).toThrowError(/follow/);
76
+ });
77
+ });
@@ -0,0 +1,32 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import type { IPage } from '@jackwener/opencli/types';
3
+ type FileStatLike = {
4
+ isFile(): boolean;
5
+ };
6
+ type FileReaderDeps = {
7
+ readFile: typeof readFile;
8
+ stat: (path: string) => Promise<FileStatLike>;
9
+ decodeUtf8: (raw: Buffer) => string;
10
+ };
11
+ type IdentityRootLike = {
12
+ querySelectorAll(selector: string): ArrayLike<unknown>;
13
+ };
14
+ export declare function resolveCurrentUserSlugFromDom(state: unknown, documentRoot: IdentityRootLike): string | null;
15
+ export declare function requireExecute(kwargs: Record<string, unknown>): void;
16
+ export declare function resolvePayload(kwargs: Record<string, unknown>, deps?: FileReaderDeps): Promise<string>;
17
+ export declare function resolveCurrentUserIdentity(page: Pick<IPage, 'evaluate'>): Promise<string>;
18
+ export declare function buildResultRow(message: string, targetType: string, target: string, outcome: 'applied' | 'already_applied' | 'created', extra?: Record<string, unknown>): {
19
+ status: string;
20
+ outcome: "created" | "applied" | "already_applied";
21
+ message: string;
22
+ target_type: string;
23
+ target: string;
24
+ }[];
25
+ export declare const __test__: {
26
+ requireExecute: typeof requireExecute;
27
+ resolvePayload: typeof resolvePayload;
28
+ resolveCurrentUserIdentity: typeof resolveCurrentUserIdentity;
29
+ resolveCurrentUserSlugFromDom: typeof resolveCurrentUserSlugFromDom;
30
+ buildResultRow: typeof buildResultRow;
31
+ };
32
+ export {};
@@ -0,0 +1,221 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { CliError } from '@jackwener/opencli/errors';
3
+ const RESULT_ROW_RESERVED_KEYS = new Set(['status', 'outcome', 'message', 'target_type', 'target']);
4
+ const NAV_SCOPE_SELECTOR = 'header, nav, [role="banner"], [role="navigation"]';
5
+ const PROFILE_LINK_SELECTOR = 'a[href^="/people/"]';
6
+ const AVATAR_SELECTOR = 'img, [class*="Avatar"], [data-testid*="avatar" i], [aria-label*="头像"]';
7
+ const SELF_LABEL_TOKENS = ['我', '我的', '个人主页'];
8
+ const EXPLICIT_IDENTITY_META_TOKEN_GROUPS = [
9
+ ['self'],
10
+ ['current', 'user'],
11
+ ['account', 'profile'],
12
+ ['my', 'profile'],
13
+ ['my', 'account'],
14
+ ];
15
+ const IN_PAGE_EXPLICIT_IDENTITY_META_TOKEN_GROUPS = JSON.stringify(EXPLICIT_IDENTITY_META_TOKEN_GROUPS);
16
+ function defaultFileReaderDeps() {
17
+ return {
18
+ readFile,
19
+ stat: (path) => stat(path),
20
+ decodeUtf8: (raw) => new TextDecoder('utf-8', { fatal: true }).decode(raw),
21
+ };
22
+ }
23
+ function hasExplicitIdentityLabel(text) {
24
+ const normalized = text.toLowerCase();
25
+ return SELF_LABEL_TOKENS.some((token) => text.includes(token)) || normalized.includes('my profile') || normalized.includes('my account');
26
+ }
27
+ function tokenizeIdentityMeta(text) {
28
+ return text
29
+ .toLowerCase()
30
+ .split(/[^a-z0-9]+/)
31
+ .filter(Boolean);
32
+ }
33
+ function hasExplicitIdentityMeta(text) {
34
+ const tokens = new Set(tokenizeIdentityMeta(text));
35
+ return EXPLICIT_IDENTITY_META_TOKEN_GROUPS.some((group) => group.every((token) => tokens.has(token)));
36
+ }
37
+ function isIdentityRootLike(value) {
38
+ return typeof value === 'object' && value !== null && 'querySelectorAll' in value
39
+ && typeof value.querySelectorAll === 'function';
40
+ }
41
+ function isIdentityNodeLike(value) {
42
+ return typeof value === 'object' && value !== null
43
+ && 'getAttribute' in value
44
+ && 'querySelector' in value
45
+ && typeof value.getAttribute === 'function'
46
+ && typeof value.querySelector === 'function';
47
+ }
48
+ function resolveSlugFromState(state) {
49
+ const slugFromState = state?.topstory?.me?.slug
50
+ || state?.me?.slug
51
+ || state?.initialState?.me?.slug;
52
+ return typeof slugFromState === 'string' && slugFromState ? slugFromState : null;
53
+ }
54
+ function getSlugFromIdentityLink(node, allowAvatarOnly) {
55
+ const href = node.getAttribute('href') || '';
56
+ const match = href.match(/^\/people\/([A-Za-z0-9_-]+)/);
57
+ if (!match)
58
+ return null;
59
+ const aria = node.getAttribute('aria-label') || '';
60
+ const title = node.getAttribute('title') || '';
61
+ const testid = node.getAttribute('data-testid') || '';
62
+ const className = node.getAttribute('class') || '';
63
+ const rel = node.getAttribute('rel') || '';
64
+ const identityLabel = `${aria} ${title} ${node.textContent || ''}`;
65
+ const identityMeta = `${testid} ${className} ${rel}`;
66
+ const hasAvatar = Boolean(node.querySelector(AVATAR_SELECTOR));
67
+ const isExplicitIdentityLabel = hasExplicitIdentityLabel(identityLabel);
68
+ const isExplicitIdentityMeta = hasExplicitIdentityMeta(identityMeta);
69
+ if (isExplicitIdentityLabel || isExplicitIdentityMeta)
70
+ return match[1];
71
+ if (allowAvatarOnly && hasAvatar)
72
+ return match[1];
73
+ return null;
74
+ }
75
+ function findCurrentUserSlugFromRoots(roots, allowAvatarOnly) {
76
+ for (const root of roots) {
77
+ for (const node of Array.from(root.querySelectorAll(PROFILE_LINK_SELECTOR)).filter(isIdentityNodeLike)) {
78
+ const slug = getSlugFromIdentityLink(node, allowAvatarOnly);
79
+ if (slug)
80
+ return slug;
81
+ }
82
+ }
83
+ return null;
84
+ }
85
+ export function resolveCurrentUserSlugFromDom(state, documentRoot) {
86
+ const slugFromState = resolveSlugFromState(state);
87
+ if (slugFromState)
88
+ return slugFromState;
89
+ const navScopes = Array.from(documentRoot.querySelectorAll(NAV_SCOPE_SELECTOR)).filter(isIdentityRootLike);
90
+ return findCurrentUserSlugFromRoots(navScopes, true) || findCurrentUserSlugFromRoots([documentRoot], false);
91
+ }
92
+ export function requireExecute(kwargs) {
93
+ if (!kwargs.execute) {
94
+ throw new CliError('INVALID_INPUT', 'This Zhihu write command requires --execute');
95
+ }
96
+ }
97
+ export async function resolvePayload(kwargs, deps = defaultFileReaderDeps()) {
98
+ const text = typeof kwargs.text === 'string' ? kwargs.text : undefined;
99
+ const file = typeof kwargs.file === 'string' ? kwargs.file : undefined;
100
+ if (text && file) {
101
+ throw new CliError('INVALID_INPUT', 'Use either <text> or --file, not both');
102
+ }
103
+ let resolved = text ?? '';
104
+ if (file) {
105
+ let fileStat;
106
+ try {
107
+ fileStat = await deps.stat(file);
108
+ }
109
+ catch {
110
+ throw new CliError('INVALID_INPUT', `File not found: ${file}`);
111
+ }
112
+ if (!fileStat.isFile()) {
113
+ throw new CliError('INVALID_INPUT', `File must be a readable text file: ${file}`);
114
+ }
115
+ let raw;
116
+ try {
117
+ raw = await deps.readFile(file);
118
+ }
119
+ catch {
120
+ throw new CliError('INVALID_INPUT', `File could not be read: ${file}`);
121
+ }
122
+ try {
123
+ resolved = deps.decodeUtf8(raw);
124
+ }
125
+ catch {
126
+ throw new CliError('INVALID_INPUT', `File could not be decoded as UTF-8 text: ${file}`);
127
+ }
128
+ }
129
+ if (!resolved.trim()) {
130
+ throw new CliError('INVALID_INPUT', 'Payload cannot be empty or whitespace only');
131
+ }
132
+ return resolved;
133
+ }
134
+ function buildResolveCurrentUserIdentityJs() {
135
+ return `(() => {
136
+ const selfLabelTokens = ${JSON.stringify(SELF_LABEL_TOKENS)};
137
+ const explicitIdentityMetaTokenGroups = ${IN_PAGE_EXPLICIT_IDENTITY_META_TOKEN_GROUPS};
138
+ const navScopeSelector = ${JSON.stringify(NAV_SCOPE_SELECTOR)};
139
+ const profileLinkSelector = ${JSON.stringify(PROFILE_LINK_SELECTOR)};
140
+ const avatarSelector = ${JSON.stringify(AVATAR_SELECTOR)};
141
+
142
+ const hasExplicitIdentityLabel = (text) => {
143
+ const normalized = String(text || '').toLowerCase();
144
+ return selfLabelTokens.some((token) => String(text || '').includes(token))
145
+ || normalized.includes('my profile')
146
+ || normalized.includes('my account');
147
+ };
148
+
149
+ const tokenizeIdentityMeta = (text) => String(text || '')
150
+ .toLowerCase()
151
+ .split(/[^a-z0-9]+/)
152
+ .filter(Boolean);
153
+
154
+ const hasExplicitIdentityMeta = (text) => {
155
+ const tokens = new Set(tokenizeIdentityMeta(text));
156
+ return explicitIdentityMetaTokenGroups.some((group) => group.every((token) => tokens.has(token)));
157
+ };
158
+
159
+ const getSlugFromIdentityLink = (node, allowAvatarOnly) => {
160
+ const href = node.getAttribute('href') || '';
161
+ const match = href.match(/^\\/people\\/([A-Za-z0-9_-]+)/);
162
+ if (!match) return null;
163
+
164
+ const aria = node.getAttribute('aria-label') || '';
165
+ const title = node.getAttribute('title') || '';
166
+ const testid = node.getAttribute('data-testid') || '';
167
+ const className = node.getAttribute('class') || '';
168
+ const rel = node.getAttribute('rel') || '';
169
+ const identityLabel = \`\${aria} \${title} \${node.textContent || ''}\`;
170
+ const identityMeta = \`\${testid} \${className} \${rel}\`;
171
+ const hasAvatar = Boolean(node.querySelector(avatarSelector));
172
+
173
+ if (hasExplicitIdentityLabel(identityLabel) || hasExplicitIdentityMeta(identityMeta)) return match[1];
174
+ if (allowAvatarOnly && hasAvatar) return match[1];
175
+ return null;
176
+ };
177
+
178
+ const findCurrentUserSlugFromRoots = (roots, allowAvatarOnly) => {
179
+ for (const root of roots) {
180
+ for (const node of Array.from(root.querySelectorAll(profileLinkSelector))) {
181
+ const slug = getSlugFromIdentityLink(node, allowAvatarOnly);
182
+ if (slug) return slug;
183
+ }
184
+ }
185
+ return null;
186
+ };
187
+
188
+ const scopedGlobal = globalThis;
189
+ const state = scopedGlobal.__INITIAL_STATE__ || (scopedGlobal.window && scopedGlobal.window.__INITIAL_STATE__) || null;
190
+ const slugFromState = state && (state.topstory && state.topstory.me && state.topstory.me.slug)
191
+ || (state && state.me && state.me.slug)
192
+ || (state && state.initialState && state.initialState.me && state.initialState.me.slug);
193
+ if (typeof slugFromState === 'string' && slugFromState) return { slug: slugFromState };
194
+
195
+ const navScopes = Array.from(document.querySelectorAll(navScopeSelector));
196
+ const slug = findCurrentUserSlugFromRoots(navScopes, true) || findCurrentUserSlugFromRoots([document], false);
197
+ return slug ? { slug } : null;
198
+ })()`;
199
+ }
200
+ export async function resolveCurrentUserIdentity(page) {
201
+ const identity = await page.evaluate(buildResolveCurrentUserIdentityJs());
202
+ if (!identity?.slug) {
203
+ throw new CliError('ACTION_NOT_AVAILABLE', 'Could not resolve the logged-in Zhihu user identity before write');
204
+ }
205
+ return identity.slug;
206
+ }
207
+ export function buildResultRow(message, targetType, target, outcome, extra = {}) {
208
+ for (const key of Object.keys(extra)) {
209
+ if (RESULT_ROW_RESERVED_KEYS.has(key)) {
210
+ throw new CliError('INVALID_INPUT', `Result extra field cannot overwrite reserved key: ${key}`);
211
+ }
212
+ }
213
+ return [{ status: 'success', outcome, message, target_type: targetType, target, ...extra }];
214
+ }
215
+ export const __test__ = {
216
+ requireExecute,
217
+ resolvePayload,
218
+ resolveCurrentUserIdentity,
219
+ resolveCurrentUserSlugFromDom,
220
+ buildResultRow,
221
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,175 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { CliError } from '@jackwener/opencli/errors';
6
+ import { __test__ } from './write-shared.js';
7
+ class FakeNode {
8
+ attrs;
9
+ textContent;
10
+ hasAvatar;
11
+ constructor(attrs, textContent = null, hasAvatar = false) {
12
+ this.attrs = attrs;
13
+ this.textContent = textContent;
14
+ this.hasAvatar = hasAvatar;
15
+ }
16
+ getAttribute(name) {
17
+ return this.attrs[name] ?? null;
18
+ }
19
+ querySelector(selector) {
20
+ if (this.hasAvatar && selector.includes('img'))
21
+ return {};
22
+ return null;
23
+ }
24
+ }
25
+ class FakeRoot {
26
+ selectors;
27
+ constructor(selectors) {
28
+ this.selectors = selectors;
29
+ }
30
+ querySelectorAll(selector) {
31
+ return this.selectors[selector] ?? [];
32
+ }
33
+ }
34
+ function createPageForDom(documentRoot, state = undefined) {
35
+ return {
36
+ evaluate: vi.fn().mockImplementation(async (js) => {
37
+ const previousDocument = globalThis.document;
38
+ const previousWindow = globalThis.window;
39
+ const previousState = globalThis.__INITIAL_STATE__;
40
+ const windowObject = { __INITIAL_STATE__: state };
41
+ try {
42
+ Object.assign(globalThis, {
43
+ document: documentRoot,
44
+ window: windowObject,
45
+ __INITIAL_STATE__: state,
46
+ });
47
+ return globalThis.eval(js);
48
+ }
49
+ finally {
50
+ Object.assign(globalThis, {
51
+ document: previousDocument,
52
+ window: previousWindow,
53
+ __INITIAL_STATE__: previousState,
54
+ });
55
+ }
56
+ }),
57
+ };
58
+ }
59
+ describe('zhihu write shared helpers', () => {
60
+ it('rejects missing --execute', () => {
61
+ expect(() => __test__.requireExecute({})).toThrowError(CliError);
62
+ });
63
+ it('accepts a non-empty text payload', async () => {
64
+ await expect(__test__.resolvePayload({ text: 'hello' })).resolves.toBe('hello');
65
+ });
66
+ it('rejects whitespace-only payloads', async () => {
67
+ await expect(__test__.resolvePayload({ text: ' ' })).rejects.toMatchObject({ code: 'INVALID_INPUT' });
68
+ });
69
+ it('rejects missing file payloads as INVALID_INPUT', async () => {
70
+ await expect(__test__.resolvePayload({ file: join(tmpdir(), 'zhihu-write-shared-missing.txt') })).rejects.toMatchObject({
71
+ code: 'INVALID_INPUT',
72
+ message: expect.stringContaining('File not found'),
73
+ });
74
+ });
75
+ it('rejects invalid UTF-8 file payloads as INVALID_INPUT', async () => {
76
+ const dir = await mkdtemp(join(tmpdir(), 'zhihu-write-shared-'));
77
+ const file = join(dir, 'payload.txt');
78
+ await writeFile(file, Buffer.from([0xc3, 0x28]));
79
+ try {
80
+ await expect(__test__.resolvePayload({ file })).rejects.toMatchObject({
81
+ code: 'INVALID_INPUT',
82
+ message: expect.stringContaining('decoded as UTF-8'),
83
+ });
84
+ }
85
+ finally {
86
+ await rm(dir, { recursive: true, force: true });
87
+ }
88
+ });
89
+ it('rejects generic file read failures as INVALID_INPUT', async () => {
90
+ const dir = await mkdtemp(join(tmpdir(), 'zhihu-write-shared-'));
91
+ const file = join(dir, 'payload.txt');
92
+ await writeFile(file, 'hello');
93
+ try {
94
+ await expect(__test__.resolvePayload({ file }, {
95
+ stat: async () => ({ isFile: () => true }),
96
+ readFile: async () => {
97
+ throw new Error('boom');
98
+ },
99
+ decodeUtf8: (raw) => new TextDecoder('utf-8', { fatal: true }).decode(raw),
100
+ })).rejects.toMatchObject({
101
+ code: 'INVALID_INPUT',
102
+ message: expect.stringContaining('could not be read'),
103
+ });
104
+ }
105
+ finally {
106
+ await rm(dir, { recursive: true, force: true });
107
+ }
108
+ });
109
+ it('prefers the state slug before DOM fallback', async () => {
110
+ const documentRoot = new FakeRoot({
111
+ 'header, nav, [role="banner"], [role="navigation"]': [],
112
+ 'a[href^="/people/"]': [new FakeNode({ href: '/people/not-used', 'data-testid': 'profile-link' }, null, true)],
113
+ });
114
+ expect(__test__.resolveCurrentUserSlugFromDom({ me: { slug: 'alice' } }, documentRoot)).toBe('alice');
115
+ });
116
+ it('accepts nav avatar links as a conservative fallback', async () => {
117
+ const navRoot = new FakeRoot({
118
+ 'a[href^="/people/"]': [new FakeNode({ href: '/people/alice' }, null, true)],
119
+ });
120
+ const documentRoot = new FakeRoot({
121
+ 'header, nav, [role="banner"], [role="navigation"]': [navRoot],
122
+ 'a[href^="/people/"]': [],
123
+ });
124
+ expect(__test__.resolveCurrentUserSlugFromDom(undefined, documentRoot)).toBe('alice');
125
+ });
126
+ it('accepts document-wide fallback only for explicit account/profile signals', async () => {
127
+ const documentRoot = new FakeRoot({
128
+ 'header, nav, [role="banner"], [role="navigation"]': [],
129
+ 'a[href^="/people/"]': [
130
+ new FakeNode({ href: '/people/alice', 'data-testid': 'account-profile-link' }),
131
+ ],
132
+ });
133
+ expect(__test__.resolveCurrentUserSlugFromDom(undefined, documentRoot)).toBe('alice');
134
+ });
135
+ it('does not accept a document-wide author avatar link as current-user fallback', async () => {
136
+ const documentRoot = new FakeRoot({
137
+ 'header, nav, [role="banner"], [role="navigation"]': [],
138
+ 'a[href^="/people/"]': [new FakeNode({ href: '/people/author-1' }, 'Author', true)],
139
+ });
140
+ expect(__test__.resolveCurrentUserSlugFromDom(undefined, documentRoot)).toBeNull();
141
+ });
142
+ it('does not accept generic document metadata like user or dropdown alone', async () => {
143
+ const documentRoot = new FakeRoot({
144
+ 'header, nav, [role="banner"], [role="navigation"]': [],
145
+ 'a[href^="/people/"]': [
146
+ new FakeNode({ href: '/people/author-1', 'data-testid': 'user-menu-dropdown' }, 'Author'),
147
+ ],
148
+ });
149
+ expect(__test__.resolveCurrentUserSlugFromDom(undefined, documentRoot)).toBeNull();
150
+ });
151
+ it('freezes a stable current-user identity before write', async () => {
152
+ const navRoot = new FakeRoot({
153
+ 'a[href^="/people/"]': [new FakeNode({ href: '/people/alice' }, null, true)],
154
+ });
155
+ const documentRoot = new FakeRoot({
156
+ 'header, nav, [role="banner"], [role="navigation"]': [navRoot],
157
+ 'a[href^="/people/"]': [],
158
+ });
159
+ const page = createPageForDom(documentRoot);
160
+ await expect(__test__.resolveCurrentUserIdentity(page)).resolves.toBe('alice');
161
+ });
162
+ it('rejects when current-user identity cannot be resolved', async () => {
163
+ const documentRoot = new FakeRoot({
164
+ 'header, nav, [role="banner"], [role="navigation"]': [],
165
+ 'a[href^="/people/"]': [],
166
+ });
167
+ const page = createPageForDom(documentRoot);
168
+ await expect(__test__.resolveCurrentUserIdentity(page)).rejects.toMatchObject({
169
+ code: 'ACTION_NOT_AVAILABLE',
170
+ });
171
+ });
172
+ it('rejects reserved buildResultRow extra keys', () => {
173
+ expect(() => __test__.buildResultRow('done', 'question', '123', 'applied', { status: 'oops' })).toThrowError(CliError);
174
+ });
175
+ });
@@ -18,4 +18,6 @@ export declare class BrowserBridge implements IBrowserFactory {
18
18
  }): Promise<IPage>;
19
19
  close(): Promise<void>;
20
20
  private _ensureDaemon;
21
+ /** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */
22
+ private _pollUntilReady;
21
23
  }
@@ -6,8 +6,9 @@ import { fileURLToPath } from 'node:url';
6
6
  import * as path from 'node:path';
7
7
  import * as fs from 'node:fs';
8
8
  import { Page } from './page.js';
9
- import { fetchDaemonStatus, isExtensionConnected } from './daemon-client.js';
9
+ import { getDaemonHealth } from './daemon-client.js';
10
10
  import { DEFAULT_DAEMON_PORT } from '../constants.js';
11
+ import { BrowserConnectError } from '../errors.js';
11
12
  const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
12
13
  /**
13
14
  * Browser factory: manages daemon lifecycle and provides IPage instances.
@@ -52,25 +53,22 @@ export class BrowserBridge {
52
53
  async _ensureDaemon(timeoutSeconds) {
53
54
  const effectiveSeconds = (timeoutSeconds && timeoutSeconds > 0) ? timeoutSeconds : Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000);
54
55
  const timeoutMs = effectiveSeconds * 1000;
55
- // Single status check instead of two separate fetchDaemonStatus() calls
56
- const status = await fetchDaemonStatus();
57
- // Fast path: extension already connected
58
- if (status?.extensionConnected)
56
+ const health = await getDaemonHealth();
57
+ // Fast path: everything ready
58
+ if (health.state === 'ready')
59
59
  return;
60
60
  // Daemon running but no extension — wait for extension with progress
61
- if (status !== null) {
61
+ if (health.state === 'no-extension') {
62
62
  if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
63
63
  process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n');
64
64
  process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n');
65
65
  }
66
- const deadline = Date.now() + timeoutMs;
67
- while (Date.now() < deadline) {
68
- await new Promise(resolve => setTimeout(resolve, 200));
69
- if (await isExtensionConnected())
70
- return;
71
- }
72
- throw new Error('Daemon is running but the Browser Extension is not connected.\n' +
73
- 'Please install and enable the opencli Browser Bridge extension in Chrome or Chromium.');
66
+ if (await this._pollUntilReady(timeoutMs))
67
+ return;
68
+ throw new BrowserConnectError('Browser Bridge extension not connected', 'Install the Browser Bridge:\n' +
69
+ ' 1. Download: https://github.com/jackwener/opencli/releases\n' +
70
+ ' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' +
71
+ ' Then run: opencli doctor', 'extension-not-connected');
74
72
  }
75
73
  // No daemon — spawn one
76
74
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -91,19 +89,27 @@ export class BrowserBridge {
91
89
  env: { ...process.env },
92
90
  });
93
91
  this._daemonProc.unref();
94
- // Wait for daemon + extension with faster polling
92
+ // Wait for daemon + extension
93
+ if (await this._pollUntilReady(timeoutMs))
94
+ return;
95
+ const finalHealth = await getDaemonHealth();
96
+ if (finalHealth.state === 'no-extension') {
97
+ throw new BrowserConnectError('Browser Bridge extension not connected', 'Install the Browser Bridge:\n' +
98
+ ' 1. Download: https://github.com/jackwener/opencli/releases\n' +
99
+ ' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' +
100
+ ' Then run: opencli doctor', 'extension-not-connected');
101
+ }
102
+ 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');
103
+ }
104
+ /** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */
105
+ async _pollUntilReady(timeoutMs) {
95
106
  const deadline = Date.now() + timeoutMs;
96
107
  while (Date.now() < deadline) {
97
108
  await new Promise(resolve => setTimeout(resolve, 200));
98
- if (await isExtensionConnected())
99
- return;
100
- }
101
- if ((await fetchDaemonStatus()) !== null) {
102
- throw new Error('Daemon is running but the Browser Extension is not connected.\n' +
103
- 'Please install and enable the opencli Browser Bridge extension in Chrome or Chromium.');
109
+ const h = await getDaemonHealth();
110
+ if (h.state === 'ready')
111
+ return true;
104
112
  }
105
- throw new Error('Failed to start opencli daemon. Try running manually:\n' +
106
- ` node ${daemonPath}\n` +
107
- `Make sure port ${DEFAULT_DAEMON_PORT} is available.`);
113
+ return false;
108
114
  }
109
115
  }
@@ -50,17 +50,26 @@ export interface DaemonStatus {
50
50
  export declare function fetchDaemonStatus(opts?: {
51
51
  timeout?: number;
52
52
  }): Promise<DaemonStatus | null>;
53
+ export type DaemonHealth = {
54
+ state: 'stopped';
55
+ status: null;
56
+ } | {
57
+ state: 'no-extension';
58
+ status: DaemonStatus;
59
+ } | {
60
+ state: 'ready';
61
+ status: DaemonStatus;
62
+ };
63
+ /**
64
+ * Unified daemon health check — single entry point for all status queries.
65
+ * Replaces isDaemonRunning(), isExtensionConnected(), and checkDaemonStatus().
66
+ */
67
+ export declare function getDaemonHealth(opts?: {
68
+ timeout?: number;
69
+ }): Promise<DaemonHealth>;
53
70
  export declare function requestDaemonShutdown(opts?: {
54
71
  timeout?: number;
55
72
  }): Promise<boolean>;
56
- /**
57
- * Check if daemon is running.
58
- */
59
- export declare function isDaemonRunning(): Promise<boolean>;
60
- /**
61
- * Check if daemon is running AND the extension is connected.
62
- */
63
- export declare function isExtensionConnected(): Promise<boolean>;
64
73
  /**
65
74
  * Send a command to the daemon and wait for a result.
66
75
  * Retries up to 4 times: network errors retry at 500ms,