@jackwener/opencli 1.7.22 → 1.8.0

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 (222) hide show
  1. package/README.md +30 -148
  2. package/README.zh-CN.md +37 -211
  3. package/cli-manifest.json +6423 -4260
  4. package/clis/12306/me.js +73 -0
  5. package/clis/12306/orders.js +96 -0
  6. package/clis/12306/passengers.js +90 -0
  7. package/clis/12306/price.js +166 -0
  8. package/clis/12306/stations.js +66 -0
  9. package/clis/12306/train.js +91 -0
  10. package/clis/12306/trains.js +119 -0
  11. package/clis/12306/utils.js +272 -0
  12. package/clis/12306/utils.test.js +331 -0
  13. package/clis/36kr/article.js +6 -3
  14. package/clis/36kr/article.test.js +46 -0
  15. package/clis/apple-podcasts/commands.test.js +20 -0
  16. package/clis/apple-podcasts/search.js +2 -2
  17. package/clis/barchart/greeks.js +144 -56
  18. package/clis/barchart/greeks.test.js +138 -0
  19. package/clis/bilibili/summary.js +167 -0
  20. package/clis/bilibili/summary.test.js +210 -0
  21. package/clis/booking/booking.test.js +356 -0
  22. package/clis/booking/search.js +351 -0
  23. package/clis/chatgpt/envelope.test.js +108 -0
  24. package/clis/chatgpt/image.js +2 -2
  25. package/clis/chatgpt/image.test.js +6 -0
  26. package/clis/chatgpt/utils.js +148 -41
  27. package/clis/chatgpt/utils.test.js +92 -2
  28. package/clis/douyin/_shared/browser-fetch.js +44 -20
  29. package/clis/douyin/_shared/browser-fetch.test.js +22 -1
  30. package/clis/douyin/_shared/evaluate-result.js +16 -0
  31. package/clis/douyin/_shared/tos-upload.js +105 -69
  32. package/clis/douyin/_shared/vod-upload.js +212 -0
  33. package/clis/douyin/_shared/vod-upload.test.js +38 -0
  34. package/clis/douyin/delete.js +137 -4
  35. package/clis/douyin/delete.test.js +90 -1
  36. package/clis/douyin/publish-upload-id.test.js +170 -0
  37. package/clis/douyin/publish.js +88 -42
  38. package/clis/douyin/user-videos.js +9 -2
  39. package/clis/douyin/user-videos.test.js +43 -0
  40. package/clis/flomo/memos.js +228 -0
  41. package/clis/flomo/memos.test.js +144 -0
  42. package/clis/gitee/search.js +2 -2
  43. package/clis/gitee/search.test.js +65 -0
  44. package/clis/jike/post.js +27 -17
  45. package/clis/jike/read.test.js +86 -0
  46. package/clis/jike/topic.js +32 -19
  47. package/clis/jike/user.js +33 -20
  48. package/clis/lesswrong/comments.js +1 -1
  49. package/clis/lesswrong/curated.js +1 -1
  50. package/clis/lesswrong/frontpage.js +1 -1
  51. package/clis/lesswrong/frontpage.test.js +37 -0
  52. package/clis/lesswrong/new.js +1 -1
  53. package/clis/lesswrong/read.js +1 -1
  54. package/clis/lesswrong/sequences.js +1 -1
  55. package/clis/lesswrong/shortform.js +1 -1
  56. package/clis/lesswrong/tag.js +1 -1
  57. package/clis/lesswrong/top-month.js +1 -1
  58. package/clis/lesswrong/top-week.js +1 -1
  59. package/clis/lesswrong/top-year.js +1 -1
  60. package/clis/lesswrong/top.js +1 -1
  61. package/clis/linkedin/connect.js +401 -0
  62. package/clis/linkedin/connect.test.js +213 -0
  63. package/clis/linkedin/inbox.js +234 -0
  64. package/clis/linkedin/inbox.test.js +152 -0
  65. package/clis/linkedin/people-search.js +262 -0
  66. package/clis/linkedin/people-search.test.js +216 -0
  67. package/clis/linkedin/safe-send.js +357 -0
  68. package/clis/linkedin/safe-send.test.js +204 -0
  69. package/clis/linkedin/salesnav-inbox.js +210 -0
  70. package/clis/linkedin/salesnav-inbox.test.js +113 -0
  71. package/clis/linkedin/salesnav-message.js +360 -0
  72. package/clis/linkedin/salesnav-message.test.js +172 -0
  73. package/clis/linkedin/salesnav-search.js +186 -0
  74. package/clis/linkedin/salesnav-search.test.js +76 -0
  75. package/clis/linkedin/salesnav-thread.js +212 -0
  76. package/clis/linkedin/salesnav-thread.test.js +79 -0
  77. package/clis/linkedin/sent-invitations.js +92 -0
  78. package/clis/linkedin/sent-invitations.test.js +62 -0
  79. package/clis/linkedin/thread-snapshot.js +214 -0
  80. package/clis/linkedin/thread-snapshot.test.js +89 -0
  81. package/clis/linkedin-learning/course.js +138 -0
  82. package/clis/linkedin-learning/course.test.js +114 -0
  83. package/clis/linkedin-learning/search.js +155 -0
  84. package/clis/linkedin-learning/search.test.js +144 -0
  85. package/clis/linkedin-learning/trending.js +133 -0
  86. package/clis/linkedin-learning/trending.test.js +123 -0
  87. package/clis/powerchina/search.js +3 -3
  88. package/clis/powerchina/search.test.js +27 -1
  89. package/clis/reddit/extract-media.test.js +149 -0
  90. package/clis/reddit/frontpage.js +47 -9
  91. package/clis/reddit/frontpage.test.js +34 -0
  92. package/clis/reddit/home.js +31 -1
  93. package/clis/reddit/home.test.js +46 -3
  94. package/clis/reddit/hot.js +32 -1
  95. package/clis/reddit/hot.test.js +15 -1
  96. package/clis/reddit/popular.js +39 -1
  97. package/clis/reddit/popular.test.js +26 -0
  98. package/clis/reddit/saved.js +1 -1
  99. package/clis/reddit/search.js +38 -1
  100. package/clis/reddit/search.test.js +26 -0
  101. package/clis/reddit/subreddit.js +52 -7
  102. package/clis/reddit/subreddit.test.js +31 -0
  103. package/clis/reddit/subscribed.js +165 -0
  104. package/clis/reddit/subscribed.test.js +168 -0
  105. package/clis/reddit/upvoted.js +1 -1
  106. package/clis/suno/commands.test.js +188 -0
  107. package/clis/suno/download.js +140 -0
  108. package/clis/suno/download.test.js +151 -0
  109. package/clis/suno/generate.js +226 -0
  110. package/clis/suno/generate.test.js +243 -0
  111. package/clis/suno/list.js +79 -0
  112. package/clis/suno/status.js +62 -0
  113. package/clis/suno/utils.js +540 -0
  114. package/clis/suno/utils.test.js +223 -0
  115. package/clis/twitter/device-follow.js +193 -0
  116. package/clis/twitter/device-follow.test.js +287 -0
  117. package/clis/twitter/download.js +443 -73
  118. package/clis/twitter/download.test.js +457 -0
  119. package/clis/twitter/list-create.js +155 -0
  120. package/clis/twitter/list-create.test.js +169 -0
  121. package/clis/twitter/list-remove.js +12 -5
  122. package/clis/twitter/list-remove.test.js +74 -0
  123. package/clis/twitter/list-tweets.js +6 -2
  124. package/clis/twitter/list-tweets.test.js +41 -1
  125. package/clis/twitter/lists.js +31 -4
  126. package/clis/twitter/lists.test.js +152 -16
  127. package/clis/twitter/search.js +6 -2
  128. package/clis/twitter/search.test.js +6 -0
  129. package/clis/twitter/shared.js +144 -0
  130. package/clis/twitter/shared.test.js +429 -1
  131. package/clis/twitter/thread.js +10 -2
  132. package/clis/twitter/thread.test.js +58 -0
  133. package/clis/twitter/timeline.js +6 -2
  134. package/clis/twitter/timeline.test.js +2 -0
  135. package/clis/twitter/tweets.js +3 -2
  136. package/clis/twitter/tweets.test.js +1 -1
  137. package/clis/weibo/delete.js +172 -0
  138. package/clis/weibo/delete.test.js +94 -0
  139. package/clis/weibo/publish.js +37 -14
  140. package/clis/weibo/publish.test.js +14 -5
  141. package/clis/weibo/user-posts.js +234 -0
  142. package/clis/weibo/user-posts.test.js +92 -0
  143. package/clis/weread/search-regression.test.js +18 -11
  144. package/clis/weread/search.js +15 -7
  145. package/clis/weread-official/book.js +135 -0
  146. package/clis/weread-official/commands.test.js +385 -0
  147. package/clis/weread-official/discover.js +107 -0
  148. package/clis/weread-official/list-apis.js +95 -0
  149. package/clis/weread-official/notes.js +171 -0
  150. package/clis/weread-official/readdata.js +158 -0
  151. package/clis/weread-official/review.js +93 -0
  152. package/clis/weread-official/search.js +106 -0
  153. package/clis/weread-official/shelf.js +97 -0
  154. package/clis/weread-official/utils.js +293 -0
  155. package/clis/weread-official/utils.test.js +242 -0
  156. package/clis/wikipedia/trending.js +7 -3
  157. package/clis/wikipedia/trending.test.js +57 -0
  158. package/clis/xianyu/chat.js +24 -109
  159. package/clis/xianyu/chat.test.js +5 -0
  160. package/clis/xianyu/im.js +322 -0
  161. package/clis/xianyu/im.test.js +253 -0
  162. package/clis/xianyu/inbox.js +96 -0
  163. package/clis/xianyu/messages.js +91 -0
  164. package/clis/xianyu/reply.js +82 -0
  165. package/clis/xiaohongshu/creator-note-detail.js +2 -1
  166. package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
  167. package/clis/xiaohongshu/creator-notes-summary.js +2 -1
  168. package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
  169. package/clis/xiaohongshu/creator-notes.js +2 -1
  170. package/clis/xiaohongshu/creator-notes.test.js +12 -0
  171. package/clis/xiaohongshu/creator-stats.js +2 -1
  172. package/clis/xiaohongshu/creator-stats.test.js +24 -0
  173. package/clis/xiaohongshu/delete-note.js +260 -0
  174. package/clis/xiaohongshu/delete-note.test.js +172 -0
  175. package/clis/xiaohongshu/publish.js +48 -8
  176. package/clis/xiaohongshu/publish.test.js +65 -10
  177. package/clis/xiaohongshu/user-helpers.test.js +41 -0
  178. package/clis/xiaohongshu/user.js +27 -4
  179. package/clis/xiaoyuzhou/download.js +1 -1
  180. package/clis/xiaoyuzhou/transcript.js +1 -1
  181. package/clis/youdao/note.js +258 -0
  182. package/clis/youdao/note.test.js +99 -0
  183. package/clis/youtube/transcript.js +397 -24
  184. package/clis/youtube/transcript.test.js +196 -6
  185. package/clis/zhihu/answer-comments.js +299 -0
  186. package/clis/zhihu/answer-comments.test.js +287 -0
  187. package/clis/zhihu/answer-detail.js +12 -0
  188. package/clis/zhihu/answer-detail.test.js +8 -0
  189. package/clis/zhihu/collection.js +15 -2
  190. package/clis/zhihu/collection.test.js +46 -0
  191. package/clis/zhihu/download.js +1 -1
  192. package/clis/zhihu/question.js +42 -9
  193. package/clis/zhihu/question.test.js +111 -9
  194. package/clis/zhihu/search.js +206 -43
  195. package/clis/zhihu/search.test.js +198 -0
  196. package/dist/src/browser/errors.js +4 -2
  197. package/dist/src/browser/errors.test.js +6 -0
  198. package/dist/src/browser/page.js +30 -4
  199. package/dist/src/browser/page.test.js +42 -0
  200. package/dist/src/browser/utils.d.ts +1 -1
  201. package/dist/src/cli-argv-preprocess.d.ts +26 -0
  202. package/dist/src/cli-argv-preprocess.js +138 -0
  203. package/dist/src/cli-argv-preprocess.test.js +79 -0
  204. package/dist/src/convention-audit.js +15 -8
  205. package/dist/src/convention-audit.test.js +21 -0
  206. package/dist/src/download/media-download.js +15 -2
  207. package/dist/src/download/media-download.test.d.ts +1 -0
  208. package/dist/src/download/media-download.test.js +110 -0
  209. package/dist/src/electron-apps.js +1 -1
  210. package/dist/src/electron-apps.test.js +7 -2
  211. package/dist/src/errors.d.ts +17 -0
  212. package/dist/src/errors.js +22 -0
  213. package/dist/src/external-clis.yaml +8 -0
  214. package/dist/src/main.js +14 -2
  215. package/dist/src/utils.d.ts +43 -0
  216. package/dist/src/utils.js +97 -0
  217. package/dist/src/utils.test.d.ts +1 -0
  218. package/dist/src/utils.test.js +155 -0
  219. package/package.json +8 -2
  220. package/scripts/silent-column-drop-baseline.json +0 -52
  221. package/scripts/typed-error-lint-baseline.json +28 -380
  222. package/clis/slock/_utils.js +0 -12
@@ -32,6 +32,12 @@ describe('classifyBrowserError', () => {
32
32
  expect(advice.retryable).toBe(true);
33
33
  expect(advice.delayMs).toBe(200);
34
34
  });
35
+ it('classifies CDP -32000 execution-context errors with 200ms delay', () => {
36
+ const advice = classifyBrowserError(new Error('{"code":-32000,"message":"Cannot find default execution context"}'));
37
+ expect(advice.kind).toBe('target-navigation');
38
+ expect(advice.retryable).toBe(true);
39
+ expect(advice.delayMs).toBe(200);
40
+ });
35
41
  it('returns non-retryable for unrelated errors', () => {
36
42
  for (const msg of ['Permission denied', 'malformed exec payload', 'SyntaxError']) {
37
43
  const advice = classifyBrowserError(new Error(msg));
@@ -22,6 +22,15 @@ function isUnsupportedNetworkCaptureError(err) {
22
22
  return (normalized.includes('unknown action') && normalized.includes('network-capture'))
23
23
  || (normalized.includes('network capture') && normalized.includes('not supported'));
24
24
  }
25
+ // The extension throws "Page not found: <id> — stale page identity" when our cached
26
+ // `_page` targetId no longer maps to a live tab — e.g. the user closed the automation
27
+ // window, or a long-running script left the cache pointing at an evicted target.
28
+ // Detect that signature so goto() can drop the stale id and let resolveTab fall back
29
+ // to the session lease (or create a fresh tab).
30
+ function isStalePageIdentityError(err) {
31
+ const message = err instanceof Error ? err.message : String(err);
32
+ return message.includes('stale page identity');
33
+ }
25
34
  /**
26
35
  * Page — implements IPage by talking to the daemon via HTTP.
27
36
  */
@@ -69,10 +78,27 @@ export class Page extends BasePage {
69
78
  };
70
79
  }
71
80
  async goto(url, options) {
72
- const result = await sendCommandFull('navigate', {
73
- url,
74
- ...this._cmdOpts(),
75
- });
81
+ let result;
82
+ try {
83
+ result = await sendCommandFull('navigate', {
84
+ url,
85
+ ...this._cmdOpts(),
86
+ });
87
+ }
88
+ catch (err) {
89
+ // If our cached targetId went stale (tab closed externally, identity evicted),
90
+ // drop the dead id and retry without it — the extension will resolve through the
91
+ // session lease or open a fresh automation tab. Without this, subsequent
92
+ // navigations in the same Page instance keep re-sending the same dead targetId
93
+ // and cascade into "Page not found:" failures.
94
+ if (!isStalePageIdentityError(err) || this._page === undefined)
95
+ throw err;
96
+ this._page = undefined;
97
+ result = await sendCommandFull('navigate', {
98
+ url,
99
+ ...this._cmdOpts(),
100
+ });
101
+ }
76
102
  // Remember the page identity (targetId) for subsequent calls
77
103
  if (result.page) {
78
104
  this._page = result.page;
@@ -235,6 +235,48 @@ describe('Page active target tracking', () => {
235
235
  page: 'page-explicit',
236
236
  }));
237
237
  });
238
+ // Regression: a Page instance can keep re-sending a cached targetId after the tab
239
+ // has been closed externally, so the extension throws
240
+ // "Page not found: <id> — stale page identity" on follow-up navigation.
241
+ // goto() now drops the stale identity and retries once without it so the extension's
242
+ // session lease can resolve through to a live tab.
243
+ it('drops a stale page identity and retries navigate once', async () => {
244
+ sendCommandFullMock
245
+ .mockResolvedValueOnce({ data: { url: 'https://example.com/first' }, page: 'page-1' })
246
+ .mockRejectedValueOnce(new Error('Page not found: deadbeef — stale page identity'))
247
+ .mockResolvedValueOnce({ data: { url: 'https://example.com/second' }, page: 'page-2' });
248
+ const page = new Page('site:youtube', undefined, undefined, undefined, 'adapter', 'persistent');
249
+ await page.goto('https://example.com/first', { waitUntil: 'none' });
250
+ expect(page.getActivePage()).toBe('page-1');
251
+ await page.goto('https://example.com/second', { waitUntil: 'none' });
252
+ expect(page.getActivePage()).toBe('page-2');
253
+ expect(sendCommandFullMock).toHaveBeenCalledTimes(3);
254
+ // First retry attempt carried the stale page; the recovery call must drop it.
255
+ const retryCall = sendCommandFullMock.mock.calls[2];
256
+ expect(retryCall[0]).toBe('navigate');
257
+ expect(retryCall[1]).not.toHaveProperty('page');
258
+ });
259
+ it('does not retry stale page errors when no identity was cached', async () => {
260
+ // _page is undefined on a fresh Page — there's nothing to drop, so propagate the
261
+ // error instead of silently retrying with the same params.
262
+ sendCommandFullMock
263
+ .mockRejectedValueOnce(new Error('Page not found: deadbeef — stale page identity'));
264
+ const page = new Page('site:youtube', undefined, undefined, undefined, 'adapter', 'persistent');
265
+ await expect(page.goto('https://example.com', { waitUntil: 'none' }))
266
+ .rejects.toThrow('stale page identity');
267
+ expect(sendCommandFullMock).toHaveBeenCalledTimes(1);
268
+ });
269
+ it('propagates non-stale navigate errors unchanged', async () => {
270
+ sendCommandFullMock
271
+ .mockResolvedValueOnce({ data: { url: 'https://example.com/first' }, page: 'page-1' })
272
+ .mockRejectedValueOnce(new Error('Extension disconnected'));
273
+ const page = new Page('site:youtube', undefined, undefined, undefined, 'adapter', 'persistent');
274
+ await page.goto('https://example.com/first', { waitUntil: 'none' });
275
+ await expect(page.goto('https://example.com/second', { waitUntil: 'none' }))
276
+ .rejects.toThrow('Extension disconnected');
277
+ // No retry for unrelated errors — exactly two navigate calls total.
278
+ expect(sendCommandFullMock).toHaveBeenCalledTimes(2);
279
+ });
238
280
  it('creates a new tab without changing the current active page binding', async () => {
239
281
  sendCommandFullMock
240
282
  .mockResolvedValueOnce({ data: { url: 'https://first.example' }, page: 'page-1' })
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Utility functions for browser operations
3
3
  */
4
- type EvaluateFunction = (...args: any[]) => unknown;
4
+ type EvaluateFunction = (...args: never[]) => unknown;
5
5
  /**
6
6
  * Serialize a function-form page.evaluate call for CDP Runtime.evaluate.
7
7
  *
@@ -35,3 +35,29 @@ export declare function rewriteBrowserArgv(argv: readonly string[]): string[];
35
35
  export declare class BrowserSessionArgvError extends Error {
36
36
  constructor(message: string);
37
37
  }
38
+ /**
39
+ * Minimal manifest shape consumed by escapeLeadingDashPositional. Imported
40
+ * lazily by main.ts so this module stays dependency-free.
41
+ */
42
+ export interface DashPositionalManifestEntry {
43
+ site: string;
44
+ name: string;
45
+ args?: Array<{
46
+ name: string;
47
+ positional?: boolean;
48
+ required?: boolean;
49
+ valueRequired?: boolean;
50
+ default?: unknown;
51
+ }>;
52
+ browser?: boolean;
53
+ }
54
+ /**
55
+ * `opencli boss detail -abc123def` fails because commander parses
56
+ * `-abc123def` as an unknown option rather than the required
57
+ * `<security-id>` positional. BOSS 直聘 securityId tokens are opaque
58
+ * strings that can legitimately start with `-` (issue #1160), and the
59
+ * same shape can show up in any adapter that takes an opaque-id
60
+ * positional. Insert a `--` separator so commander treats the next
61
+ * token as the positional value.
62
+ */
63
+ export declare function escapeLeadingDashPositional(argv: readonly string[], manifest: readonly DashPositionalManifestEntry[]): string[];
@@ -129,3 +129,141 @@ export class BrowserSessionArgvError extends Error {
129
129
  this.name = 'BrowserSessionArgvError';
130
130
  }
131
131
  }
132
+ function knownCommandOptions(cmd) {
133
+ const options = new Map([
134
+ ['-h', 'none'],
135
+ ['--help', 'none'],
136
+ ['-v', 'none'],
137
+ ['--verbose', 'none'],
138
+ ['-f', 'required'],
139
+ ['--format', 'required'],
140
+ ['--trace', 'required'],
141
+ ]);
142
+ if (cmd.browser) {
143
+ options.set('--window', 'required');
144
+ options.set('--site-session', 'required');
145
+ options.set('--keep-tab', 'required');
146
+ }
147
+ for (const arg of cmd.args ?? []) {
148
+ if (arg.positional)
149
+ continue;
150
+ // Keep in sync with commanderAdapter.ts:
151
+ // required/valueRequired -> `<value>`; otherwise -> `[value]`.
152
+ options.set(`--${arg.name}`, arg.required || arg.valueRequired ? 'required' : 'optional');
153
+ }
154
+ return options;
155
+ }
156
+ function consumeKnownOption(argv, index, options) {
157
+ const token = argv[index];
158
+ if (!token || token === '--')
159
+ return null;
160
+ const eq = token.indexOf('=');
161
+ const key = eq === -1 ? token : token.slice(0, eq);
162
+ const mode = options.get(key);
163
+ if (!mode && eq === -1 && token.startsWith('-') && !token.startsWith('--') && token.length > 2) {
164
+ const shortKey = token.slice(0, 2);
165
+ const shortMode = options.get(shortKey);
166
+ if (shortMode === 'required') {
167
+ return { values: [token], nextIndex: index + 1 };
168
+ }
169
+ }
170
+ if (!mode)
171
+ return null;
172
+ if (eq !== -1 || mode === 'none')
173
+ return { values: [token], nextIndex: index + 1 };
174
+ const next = argv[index + 1];
175
+ if (mode === 'required') {
176
+ return next === undefined
177
+ ? { values: [token], nextIndex: index + 1 }
178
+ : { values: [token, next], nextIndex: index + 2 };
179
+ }
180
+ if (next !== undefined && !next.startsWith('-')) {
181
+ return { values: [token, next], nextIndex: index + 2 };
182
+ }
183
+ return { values: [token], nextIndex: index + 1 };
184
+ }
185
+ /**
186
+ * `opencli boss detail -abc123def` fails because commander parses
187
+ * `-abc123def` as an unknown option rather than the required
188
+ * `<security-id>` positional. BOSS 直聘 securityId tokens are opaque
189
+ * strings that can legitimately start with `-` (issue #1160), and the
190
+ * same shape can show up in any adapter that takes an opaque-id
191
+ * positional. Insert a `--` separator so commander treats the next
192
+ * token as the positional value.
193
+ */
194
+ export function escapeLeadingDashPositional(argv, manifest) {
195
+ const result = [...argv];
196
+ const requiredFirstPositional = new Map();
197
+ for (const cmd of manifest) {
198
+ const first = cmd.args?.find((a) => a.positional);
199
+ if (first?.required)
200
+ requiredFirstPositional.set(cmd.site + '/' + cmd.name, cmd);
201
+ }
202
+ let i = 0;
203
+ while (i < result.length) {
204
+ const tok = result[i];
205
+ if (!tok.startsWith('-'))
206
+ break;
207
+ if (tok.includes('=')) {
208
+ i += 1;
209
+ continue;
210
+ }
211
+ if (ROOT_VALUE_FLAGS.has(tok) && i + 1 < result.length) {
212
+ i += 2;
213
+ }
214
+ else {
215
+ i += 1;
216
+ }
217
+ }
218
+ const site = result[i];
219
+ const cmd = result[i + 1];
220
+ const positionalIdx = i + 2;
221
+ if (!site || !cmd || positionalIdx >= result.length)
222
+ return result;
223
+ const entry = requiredFirstPositional.get(site + '/' + cmd);
224
+ if (!entry)
225
+ return result;
226
+ const options = knownCommandOptions(entry);
227
+ const beforePositional = [];
228
+ let j = positionalIdx;
229
+ while (j < result.length) {
230
+ const token = result[j];
231
+ if (token === '--')
232
+ return result;
233
+ const consumed = consumeKnownOption(result, j, options);
234
+ if (consumed) {
235
+ beforePositional.push(...consumed.values);
236
+ j = consumed.nextIndex;
237
+ continue;
238
+ }
239
+ if (!token.startsWith('-'))
240
+ return result;
241
+ if (token.startsWith('--'))
242
+ return result;
243
+ break;
244
+ }
245
+ if (j >= result.length)
246
+ return result;
247
+ const positional = result[j];
248
+ const trailingOptions = [];
249
+ const trailingRest = [];
250
+ j += 1;
251
+ while (j < result.length) {
252
+ const consumed = consumeKnownOption(result, j, options);
253
+ if (consumed) {
254
+ trailingOptions.push(...consumed.values);
255
+ j = consumed.nextIndex;
256
+ continue;
257
+ }
258
+ trailingRest.push(result[j]);
259
+ j += 1;
260
+ }
261
+ return [
262
+ ...result.slice(0, positionalIdx),
263
+ ...beforePositional,
264
+ ...trailingOptions,
265
+ '--',
266
+ positional,
267
+ ...trailingRest,
268
+ ];
269
+ }
@@ -128,3 +128,82 @@ describe('rewriteBrowserArgv', () => {
128
128
  }
129
129
  });
130
130
  });
131
+ import { escapeLeadingDashPositional } from './cli-argv-preprocess.js';
132
+ describe('escapeLeadingDashPositional', () => {
133
+ const manifest = [
134
+ { site: 'boss', name: 'detail', browser: true, args: [{ name: 'security-id', positional: true, required: true }, { name: 'retry', positional: false, valueRequired: true }] },
135
+ { site: 'boss', name: 'search', args: [{ name: 'query', positional: true, required: false }, { name: 'limit', positional: false }] },
136
+ { site: 'twitter', name: 'follow', args: [{ name: 'username', positional: true, required: true }] },
137
+ { site: 'twitter', name: 'lists', args: [{ name: 'limit', positional: false }] },
138
+ ];
139
+ it('inserts -- before a required positional starting with `-`', () => {
140
+ expect(escapeLeadingDashPositional(['boss', 'detail', '-abc123def'], manifest))
141
+ .toEqual(['boss', 'detail', '--', '-abc123def']);
142
+ });
143
+ it('preserves trailing flags after the dash-leading positional', () => {
144
+ expect(escapeLeadingDashPositional(['boss', 'detail', '-xyz', '-f', 'json'], manifest))
145
+ .toEqual(['boss', 'detail', '-f', 'json', '--', '-xyz']);
146
+ });
147
+ it('preserves attached short option values like commander does', () => {
148
+ expect(escapeLeadingDashPositional(['boss', 'detail', '-fjson', '-xyz'], manifest))
149
+ .toEqual(['boss', 'detail', '-fjson', '--', '-xyz']);
150
+ expect(escapeLeadingDashPositional(['boss', 'detail', '-xyz', '-fjson'], manifest))
151
+ .toEqual(['boss', 'detail', '-fjson', '--', '-xyz']);
152
+ });
153
+ it('handles known options before a dash-leading positional', () => {
154
+ expect(escapeLeadingDashPositional(['boss', 'detail', '--format', 'json', '--trace=on', '-xyz'], manifest))
155
+ .toEqual(['boss', 'detail', '--format', 'json', '--trace=on', '--', '-xyz']);
156
+ });
157
+ it('keeps adapter and browser options parseable when they follow the positional', () => {
158
+ expect(escapeLeadingDashPositional(['boss', 'detail', '-xyz', '--retry', '2', '--window', 'foreground'], manifest))
159
+ .toEqual(['boss', 'detail', '--retry', '2', '--window', 'foreground', '--', '-xyz']);
160
+ });
161
+ it('protects negative numeric positionals too', () => {
162
+ expect(escapeLeadingDashPositional(['boss', 'detail', '-42'], manifest))
163
+ .toEqual(['boss', 'detail', '--', '-42']);
164
+ });
165
+ it('leaves unknown dash options untouched instead of hiding them behind --', () => {
166
+ expect(escapeLeadingDashPositional(['boss', 'detail', '--unknown', 'value'], manifest))
167
+ .toEqual(['boss', 'detail', '--unknown', 'value']);
168
+ });
169
+ it('does not touch positional values that do not start with -', () => {
170
+ expect(escapeLeadingDashPositional(['boss', 'detail', 'normal-id'], manifest))
171
+ .toEqual(['boss', 'detail', 'normal-id']);
172
+ });
173
+ it('does not touch the recognised short flags -f / -v / -h', () => {
174
+ expect(escapeLeadingDashPositional(['boss', 'detail', '-f', 'json'], manifest))
175
+ .toEqual(['boss', 'detail', '-f', 'json']);
176
+ expect(escapeLeadingDashPositional(['boss', 'detail', '-v'], manifest))
177
+ .toEqual(['boss', 'detail', '-v']);
178
+ });
179
+ it('does not touch long flags (--*)', () => {
180
+ expect(escapeLeadingDashPositional(['boss', 'detail', '--format', 'json'], manifest))
181
+ .toEqual(['boss', 'detail', '--format', 'json']);
182
+ });
183
+ it('does not touch already-escaped --', () => {
184
+ expect(escapeLeadingDashPositional(['boss', 'detail', '--', '-already-escaped'], manifest))
185
+ .toEqual(['boss', 'detail', '--', '-already-escaped']);
186
+ });
187
+ it('does not touch commands without a required positional', () => {
188
+ expect(escapeLeadingDashPositional(['boss', 'search', '-something'], manifest))
189
+ .toEqual(['boss', 'search', '-something']);
190
+ expect(escapeLeadingDashPositional(['twitter', 'lists', '-something'], manifest))
191
+ .toEqual(['twitter', 'lists', '-something']);
192
+ });
193
+ it('works when --profile or another root flag precedes the site', () => {
194
+ expect(escapeLeadingDashPositional(['--profile', 'work', 'boss', 'detail', '-abc'], manifest))
195
+ .toEqual(['--profile', 'work', 'boss', 'detail', '--', '-abc']);
196
+ });
197
+ it('works for any adapter, not just boss', () => {
198
+ expect(escapeLeadingDashPositional(['twitter', 'follow', '-someuser'], manifest))
199
+ .toEqual(['twitter', 'follow', '--', '-someuser']);
200
+ });
201
+ it('returns argv unchanged when the command is unknown', () => {
202
+ expect(escapeLeadingDashPositional(['unknown', 'cmd', '-arg'], manifest))
203
+ .toEqual(['unknown', 'cmd', '-arg']);
204
+ });
205
+ it('returns argv unchanged when argv is too short', () => {
206
+ expect(escapeLeadingDashPositional(['boss'], manifest)).toEqual(['boss']);
207
+ expect(escapeLeadingDashPositional(['boss', 'detail'], manifest)).toEqual(['boss', 'detail']);
208
+ });
209
+ });
@@ -246,19 +246,26 @@ function auditTypedErrorPatterns(command, source, sourcePath, projectRoot) {
246
246
  }
247
247
  const sentinel = /(?:\?\?|\|\|)\s*(['"])(unknown|Unknown|UNKNOWN|N\/A|n\/a|NA|未知|-)\1/.exec(line);
248
248
  if (sentinel) {
249
- violations.push({
250
- rule: 'silent-sentinel',
251
- ...command,
252
- file: relative,
253
- line: index + 1,
254
- message: `sentinel fallback ${sentinel[0].trim()} can turn missing data into fake data; prefer dropping the field or throwing a typed error`,
255
- details: { text: line.trim() },
256
- });
249
+ if (!isThrowMessageLine(line)) {
250
+ violations.push({
251
+ rule: 'silent-sentinel',
252
+ ...command,
253
+ file: relative,
254
+ line: index + 1,
255
+ message: `sentinel fallback ${sentinel[0].trim()} can turn missing data into fake data; prefer dropping the field or throwing a typed error`,
256
+ details: { text: line.trim() },
257
+ });
258
+ }
257
259
  }
258
260
  offset += line.length + 1;
259
261
  });
260
262
  return dedupeViolations(violations);
261
263
  }
264
+ function isThrowMessageLine(line) {
265
+ // Only single-line `throw new X(...)` diagnostics are ignored. Multi-line
266
+ // throw expressions with row-like sentinel fallbacks still stay visible.
267
+ return /\bthrow\s+new\b/.test(line);
268
+ }
262
269
  function auditWriteDeletePair(entries) {
263
270
  const bySite = new Map();
264
271
  for (const entry of entries) {
@@ -223,4 +223,25 @@ describe('convention audit', () => {
223
223
  const violations = report.categories.find((item) => item.rule === 'silent-empty-fallback').violations;
224
224
  expect(violations.map((violation) => violation.command)).toEqual(['demo/catch']);
225
225
  });
226
+ it('does not report sentinel fallbacks inside thrown error messages', () => {
227
+ const root = makeProject([
228
+ { site: 'demo', name: 'error', access: 'read', columns: ['id'], sourceFile: 'demo/error.js' },
229
+ { site: 'demo', name: 'row', access: 'read', columns: ['id', 'title'], sourceFile: 'demo/row.js' },
230
+ ], {
231
+ 'demo/error.js': `
232
+ export async function run(data) {
233
+ if (!data.ok) throw new Error(\`demo failed: \${data.message ?? 'unknown'}\`);
234
+ return [{ id: 1 }];
235
+ }
236
+ `,
237
+ 'demo/row.js': `
238
+ export async function run(item) {
239
+ return [{ id: item.id, title: item.title ?? 'unknown' }];
240
+ }
241
+ `,
242
+ });
243
+ const report = runConventionAudit({ projectRoot: root });
244
+ const violations = report.categories.find((item) => item.rule === 'silent-sentinel').violations;
245
+ expect(violations.map((violation) => violation.command)).toEqual(['demo/row']);
246
+ });
226
247
  });
@@ -9,7 +9,7 @@
9
9
  import * as fs from 'node:fs';
10
10
  import * as path from 'node:path';
11
11
  import { getErrorMessage } from '../errors.js';
12
- import { httpDownload, ytdlpDownload, checkYtdlp, getTempDir, exportCookiesToNetscape, } from './index.js';
12
+ import { httpDownload, ytdlpDownload, checkYtdlp, getTempDir, exportCookiesToNetscape, sanitizeFilename, } from './index.js';
13
13
  import { DownloadProgressTracker, formatBytes } from './progress.js';
14
14
  // ============================================================
15
15
  // Main API
@@ -50,7 +50,7 @@ export async function downloadMedia(items, options) {
50
50
  const media = items[i];
51
51
  const isVideo = media.type !== 'image';
52
52
  const ext = isVideo ? 'mp4' : 'jpg';
53
- const filename = media.filename || `${filenamePrefix}_${i + 1}.${ext}`;
53
+ const filename = resolveMediaFilename(media.filename, filenamePrefix, i + 1, ext);
54
54
  const destPath = path.join(outputDir, filename);
55
55
  const progressBar = tracker.onFileStart(filename, i);
56
56
  try {
@@ -112,3 +112,16 @@ export async function downloadMedia(items, options) {
112
112
  }
113
113
  return results;
114
114
  }
115
+ function resolveMediaFilename(filename, prefix, index, ext) {
116
+ const safePrefix = sanitizePathSegment(path.basename(path.win32.basename(prefix))) || 'download';
117
+ const fallback = `${safePrefix}_${index}.${ext}`;
118
+ if (!filename)
119
+ return fallback;
120
+ const basename = path.basename(path.win32.basename(filename));
121
+ const safeName = sanitizePathSegment(basename);
122
+ return safeName || fallback;
123
+ }
124
+ function sanitizePathSegment(value) {
125
+ const sanitized = sanitizeFilename(value);
126
+ return sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : '';
127
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,110 @@
1
+ import * as fs from 'node:fs';
2
+ import * as http from 'node:http';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { afterEach, describe, expect, it } from 'vitest';
6
+ import { downloadMedia } from './media-download.js';
7
+ const servers = [];
8
+ const tempDirs = [];
9
+ afterEach(async () => {
10
+ await Promise.all(servers.map((server) => new Promise((resolve, reject) => {
11
+ server.close((err) => (err ? reject(err) : resolve()));
12
+ })));
13
+ servers.length = 0;
14
+ for (const dir of tempDirs) {
15
+ try {
16
+ fs.rmSync(dir, { recursive: true, force: true });
17
+ }
18
+ catch { /* ignore */ }
19
+ }
20
+ tempDirs.length = 0;
21
+ });
22
+ async function startServer(handler) {
23
+ const server = http.createServer(handler);
24
+ servers.push(server);
25
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
26
+ const address = server.address();
27
+ if (!address || typeof address === 'string') {
28
+ throw new Error('Failed to start test server');
29
+ }
30
+ return `http://127.0.0.1:${address.port}`;
31
+ }
32
+ describe('media downloads', () => {
33
+ it('keeps custom filenames inside the output directory', async () => {
34
+ const baseUrl = await startServer((_req, res) => {
35
+ res.statusCode = 200;
36
+ res.end('image');
37
+ });
38
+ const parentDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-media-parent-'));
39
+ tempDirs.push(parentDir);
40
+ const outputDir = path.join(parentDir, 'downloads');
41
+ const results = await downloadMedia([
42
+ { type: 'image', url: `${baseUrl}/image.jpg`, filename: '../escape.jpg' },
43
+ ], {
44
+ output: outputDir,
45
+ verbose: false,
46
+ });
47
+ expect(results).toEqual([
48
+ { index: 1, type: 'image', status: 'success', size: '5.0 B' },
49
+ ]);
50
+ expect(fs.readFileSync(path.join(outputDir, 'escape.jpg'), 'utf8')).toBe('image');
51
+ expect(fs.existsSync(path.join(parentDir, 'escape.jpg'))).toBe(false);
52
+ });
53
+ it('strips nested and absolute path components from custom filenames', async () => {
54
+ const baseUrl = await startServer((_req, res) => {
55
+ res.statusCode = 200;
56
+ res.end('image');
57
+ });
58
+ const parentDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-media-parent-'));
59
+ tempDirs.push(parentDir);
60
+ const outputDir = path.join(parentDir, 'downloads');
61
+ const results = await downloadMedia([
62
+ { type: 'image', url: `${baseUrl}/one.jpg`, filename: '../../etc/passwd' },
63
+ { type: 'image', url: `${baseUrl}/two.jpg`, filename: '/tmp/evil.jpg' },
64
+ { type: 'image', url: `${baseUrl}/three.jpg`, filename: 'nested/path/photo.png' },
65
+ { type: 'image', url: `${baseUrl}/four.jpg`, filename: '..\\windows\\escape.jpg' },
66
+ ], {
67
+ output: outputDir,
68
+ verbose: false,
69
+ });
70
+ expect(results).toEqual([
71
+ { index: 1, type: 'image', status: 'success', size: '5.0 B' },
72
+ { index: 2, type: 'image', status: 'success', size: '5.0 B' },
73
+ { index: 3, type: 'image', status: 'success', size: '5.0 B' },
74
+ { index: 4, type: 'image', status: 'success', size: '5.0 B' },
75
+ ]);
76
+ expect(fs.readFileSync(path.join(outputDir, 'passwd'), 'utf8')).toBe('image');
77
+ expect(fs.readFileSync(path.join(outputDir, 'evil.jpg'), 'utf8')).toBe('image');
78
+ expect(fs.readFileSync(path.join(outputDir, 'photo.png'), 'utf8')).toBe('image');
79
+ expect(fs.readFileSync(path.join(outputDir, 'escape.jpg'), 'utf8')).toBe('image');
80
+ expect(fs.existsSync(path.join(parentDir, 'etc', 'passwd'))).toBe(false);
81
+ expect(fs.existsSync(path.join(parentDir, 'tmp', 'evil.jpg'))).toBe(false);
82
+ });
83
+ it('falls back to generated names for empty dot-directory filenames', async () => {
84
+ const baseUrl = await startServer((_req, res) => {
85
+ res.statusCode = 200;
86
+ res.end('image');
87
+ });
88
+ const parentDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-media-parent-'));
89
+ tempDirs.push(parentDir);
90
+ const outputDir = path.join(parentDir, 'downloads');
91
+ const results = await downloadMedia([
92
+ { type: 'image', url: `${baseUrl}/dot.jpg`, filename: '.' },
93
+ { type: 'image', url: `${baseUrl}/dotdot.jpg`, filename: '..' },
94
+ { type: 'image', url: `${baseUrl}/blank.jpg`, filename: ' ' },
95
+ ], {
96
+ output: outputDir,
97
+ filenamePrefix: '../unsafe prefix',
98
+ verbose: false,
99
+ });
100
+ expect(results).toEqual([
101
+ { index: 1, type: 'image', status: 'success', size: '5.0 B' },
102
+ { index: 2, type: 'image', status: 'success', size: '5.0 B' },
103
+ { index: 3, type: 'image', status: 'success', size: '5.0 B' },
104
+ ]);
105
+ expect(fs.readFileSync(path.join(outputDir, 'unsafe_prefix_1.jpg'), 'utf8')).toBe('image');
106
+ expect(fs.readFileSync(path.join(outputDir, 'unsafe_prefix_2.jpg'), 'utf8')).toBe('image');
107
+ expect(fs.readFileSync(path.join(outputDir, 'unsafe_prefix_3.jpg'), 'utf8')).toBe('image');
108
+ expect(fs.existsSync(path.join(parentDir, 'unsafe prefix_1.jpg'))).toBe(false);
109
+ });
110
+ });
@@ -10,7 +10,7 @@ import * as os from 'node:os';
10
10
  import yaml from 'js-yaml';
11
11
  export const builtinApps = {
12
12
  cursor: { port: 9226, processName: 'Cursor', bundleId: 'com.todesktop.runtime.Cursor', displayName: 'Cursor' },
13
- codex: { port: 9222, processName: 'Codex', bundleId: 'com.openai.codex', displayName: 'Codex' },
13
+ codex: { port: 9238, processName: 'Codex', bundleId: 'com.openai.codex', displayName: 'Codex' },
14
14
  chatwise: { port: 9228, processName: 'ChatWise', bundleId: 'com.chatwise.app', displayName: 'ChatWise' },
15
15
  'discord-app': { port: 9232, processName: 'Discord', bundleId: 'com.discord.app', displayName: 'Discord' },
16
16
  'doubao-app': { port: 9225, processName: 'Doubao', bundleId: 'com.volcengine.doubao', displayName: 'Doubao' },
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { getElectronApp, isElectronApp, loadApps } from './electron-apps.js';
2
+ import { builtinApps, getElectronApp, isElectronApp, loadApps } from './electron-apps.js';
3
3
  describe('electron-apps registry', () => {
4
4
  it('returns builtin app entry for cursor', () => {
5
5
  const app = getElectronApp('cursor');
@@ -10,7 +10,12 @@ describe('electron-apps registry', () => {
10
10
  it('returns builtin app entry for codex', () => {
11
11
  const app = getElectronApp('codex');
12
12
  expect(app).toBeDefined();
13
- expect(app.port).toBe(9222);
13
+ expect(app.port).toBe(9238);
14
+ });
15
+ it('keeps builtin Electron app CDP ports unique and off the browser-bridge port', () => {
16
+ const ports = Object.values(builtinApps).map((app) => app.port);
17
+ expect(new Set(ports).size).toBe(ports.length);
18
+ expect(ports).not.toContain(9222);
14
19
  });
15
20
  it('returns undefined for non-Electron sites', () => {
16
21
  expect(getElectronApp('bilibili')).toBeUndefined();
@@ -72,6 +72,23 @@ export declare function selectorError(selector: string, hint?: string): CliError
72
72
  export declare class PluginError extends CliError {
73
73
  constructor(message: string, hint?: string);
74
74
  }
75
+ /**
76
+ * Thrown when a JSON endpoint returns HTML instead of JSON — typically a login
77
+ * wall, rate-limit page, or WAF challenge. Surfaced as a structured error so
78
+ * callers can show "re-login or wait out the rate limit" guidance instead of
79
+ * the cryptic `SyntaxError: Unexpected token '<', "<!DOCTYPE "...` that a naive
80
+ * JSON.parse on an HTML body produces.
81
+ *
82
+ * `bodyPreview` is the first 100 chars of the response body (after trimming
83
+ * leading whitespace) — useful for logs / debugging without dumping the full
84
+ * page.
85
+ */
86
+ export declare class LoginWallError extends CliError {
87
+ readonly status: number;
88
+ readonly url: string;
89
+ readonly bodyPreview: string;
90
+ constructor(message: string, status: number, url: string, bodyPreview: string, hint?: string);
91
+ }
75
92
  /** Structured error output — unified contract for all consumers (AI agents, scripts, humans). */
76
93
  export interface ErrorEnvelope {
77
94
  ok: false;