@jackwener/opencli 1.7.22 → 1.8.1
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/README.md +35 -194
- package/README.zh-CN.md +42 -260
- package/cli-manifest.json +8160 -4392
- package/clis/12306/me.js +73 -0
- package/clis/12306/orders.js +96 -0
- package/clis/12306/passengers.js +90 -0
- package/clis/12306/price.js +166 -0
- package/clis/12306/stations.js +66 -0
- package/clis/12306/train.js +91 -0
- package/clis/12306/trains.js +119 -0
- package/clis/12306/utils.js +272 -0
- package/clis/12306/utils.test.js +331 -0
- package/clis/36kr/article.js +6 -3
- package/clis/36kr/article.test.js +46 -0
- package/clis/_atlassian/shared.js +577 -0
- package/clis/_atlassian/shared.test.js +170 -0
- package/clis/apple-podcasts/commands.test.js +20 -0
- package/clis/apple-podcasts/search.js +2 -2
- package/clis/barchart/greeks.js +144 -56
- package/clis/barchart/greeks.test.js +138 -0
- package/clis/bilibili/comment.js +125 -0
- package/clis/bilibili/comment.test.js +153 -0
- package/clis/bilibili/comments.js +116 -21
- package/clis/bilibili/comments.test.js +77 -18
- package/clis/bilibili/subtitle.js +76 -31
- package/clis/bilibili/subtitle.test.js +156 -9
- package/clis/bilibili/summary.js +167 -0
- package/clis/bilibili/summary.test.js +210 -0
- package/clis/bilibili/utils.js +63 -5
- package/clis/bilibili/utils.test.js +45 -1
- package/clis/booking/booking.test.js +356 -0
- package/clis/booking/search.js +351 -0
- package/clis/chatgpt/envelope.test.js +108 -0
- package/clis/chatgpt/image.js +2 -2
- package/clis/chatgpt/image.test.js +6 -0
- package/clis/chatgpt/utils.js +148 -41
- package/clis/chatgpt/utils.test.js +92 -2
- package/clis/chess/analyze.js +35 -0
- package/clis/chess/analyze.test.js +79 -0
- package/clis/chess/game.js +114 -0
- package/clis/chess/game.test.js +178 -0
- package/clis/chess/games.js +67 -0
- package/clis/chess/games.test.js +164 -0
- package/clis/chess/stats.js +32 -0
- package/clis/chess/stats.test.js +79 -0
- package/clis/chess/utils.js +170 -0
- package/clis/chess/utils.test.js +230 -0
- package/clis/confluence/commands.test.js +195 -0
- package/clis/confluence/create.js +39 -0
- package/clis/confluence/page.js +23 -0
- package/clis/confluence/search.js +34 -0
- package/clis/confluence/shared.js +173 -0
- package/clis/confluence/update.js +38 -0
- package/clis/douyin/_shared/browser-fetch.js +44 -20
- package/clis/douyin/_shared/browser-fetch.test.js +22 -1
- package/clis/douyin/_shared/evaluate-result.js +16 -0
- package/clis/douyin/_shared/tos-upload.js +105 -69
- package/clis/douyin/_shared/vod-upload.js +212 -0
- package/clis/douyin/_shared/vod-upload.test.js +38 -0
- package/clis/douyin/delete.js +137 -4
- package/clis/douyin/delete.test.js +90 -1
- package/clis/douyin/hashtag.js +84 -23
- package/clis/douyin/hashtag.test.js +113 -0
- package/clis/douyin/publish-upload-id.test.js +170 -0
- package/clis/douyin/publish.js +88 -42
- package/clis/douyin/user-videos.js +9 -2
- package/clis/douyin/user-videos.test.js +43 -0
- package/clis/flomo/memos.js +228 -0
- package/clis/flomo/memos.test.js +144 -0
- package/clis/geogebra/add-circle.js +46 -0
- package/clis/geogebra/add-line.js +35 -0
- package/clis/geogebra/add-point.js +27 -0
- package/clis/geogebra/add-polygon.js +25 -0
- package/clis/geogebra/eval.js +35 -0
- package/clis/geogebra/geogebra.test.js +175 -0
- package/clis/geogebra/hexagon.js +62 -0
- package/clis/geogebra/info.js +72 -0
- package/clis/geogebra/list.js +35 -0
- package/clis/geogebra/triangle.js +60 -0
- package/clis/geogebra/utils.js +271 -0
- package/clis/gitee/search.js +2 -2
- package/clis/gitee/search.test.js +65 -0
- package/clis/jike/post.js +27 -17
- package/clis/jike/read.test.js +86 -0
- package/clis/jike/topic.js +32 -19
- package/clis/jike/user.js +33 -20
- package/clis/jira/attachments.js +28 -0
- package/clis/jira/commands.test.js +287 -0
- package/clis/jira/comments.js +28 -0
- package/clis/jira/issue.js +28 -0
- package/clis/jira/links.js +28 -0
- package/clis/jira/search.js +47 -0
- package/clis/jira/shared.js +256 -0
- package/clis/lesswrong/comments.js +1 -1
- package/clis/lesswrong/curated.js +1 -1
- package/clis/lesswrong/frontpage.js +1 -1
- package/clis/lesswrong/frontpage.test.js +37 -0
- package/clis/lesswrong/new.js +1 -1
- package/clis/lesswrong/read.js +1 -1
- package/clis/lesswrong/sequences.js +1 -1
- package/clis/lesswrong/shortform.js +1 -1
- package/clis/lesswrong/tag.js +1 -1
- package/clis/lesswrong/top-month.js +1 -1
- package/clis/lesswrong/top-week.js +1 -1
- package/clis/lesswrong/top-year.js +1 -1
- package/clis/lesswrong/top.js +1 -1
- package/clis/linkedin/connect.js +401 -0
- package/clis/linkedin/connect.test.js +213 -0
- package/clis/linkedin/inbox.js +234 -0
- package/clis/linkedin/inbox.test.js +152 -0
- package/clis/linkedin/job-detail.js +167 -0
- package/clis/linkedin/job-detail.test.js +38 -0
- package/clis/linkedin/jobs-preferences.js +113 -0
- package/clis/linkedin/jobs-preferences.test.js +43 -0
- package/clis/linkedin/people-search.js +262 -0
- package/clis/linkedin/people-search.test.js +216 -0
- package/clis/linkedin/post-analytics.js +74 -0
- package/clis/linkedin/post-analytics.test.js +40 -0
- package/clis/linkedin/posts-core.js +241 -0
- package/clis/linkedin/posts.js +22 -0
- package/clis/linkedin/posts.test.js +40 -0
- package/clis/linkedin/profile-analytics.js +104 -0
- package/clis/linkedin/profile-analytics.test.js +67 -0
- package/clis/linkedin/profile-experience.js +671 -0
- package/clis/linkedin/profile-experience.test.js +152 -0
- package/clis/linkedin/profile-projects.js +311 -0
- package/clis/linkedin/profile-projects.test.js +111 -0
- package/clis/linkedin/profile-read.js +148 -0
- package/clis/linkedin/profile-read.test.js +77 -0
- package/clis/linkedin/safe-send.js +357 -0
- package/clis/linkedin/safe-send.test.js +204 -0
- package/clis/linkedin/salesnav-inbox.js +210 -0
- package/clis/linkedin/salesnav-inbox.test.js +113 -0
- package/clis/linkedin/salesnav-message.js +360 -0
- package/clis/linkedin/salesnav-message.test.js +172 -0
- package/clis/linkedin/salesnav-search.js +186 -0
- package/clis/linkedin/salesnav-search.test.js +76 -0
- package/clis/linkedin/salesnav-thread.js +212 -0
- package/clis/linkedin/salesnav-thread.test.js +79 -0
- package/clis/linkedin/sent-invitations.js +92 -0
- package/clis/linkedin/sent-invitations.test.js +62 -0
- package/clis/linkedin/services-read.js +213 -0
- package/clis/linkedin/services-read.test.js +105 -0
- package/clis/linkedin/shared.js +124 -0
- package/clis/linkedin/thread-snapshot.js +214 -0
- package/clis/linkedin/thread-snapshot.test.js +89 -0
- package/clis/linkedin/timeline.js +14 -7
- package/clis/linkedin-learning/course.js +138 -0
- package/clis/linkedin-learning/course.test.js +114 -0
- package/clis/linkedin-learning/search.js +155 -0
- package/clis/linkedin-learning/search.test.js +144 -0
- package/clis/linkedin-learning/trending.js +133 -0
- package/clis/linkedin-learning/trending.test.js +123 -0
- package/clis/notebooklm/add-source.js +269 -0
- package/clis/notebooklm/add-source.test.js +97 -0
- package/clis/notebooklm/create.js +76 -0
- package/clis/notebooklm/create.test.js +58 -0
- package/clis/notebooklm/generate-audio.js +91 -0
- package/clis/notebooklm/generate-audio.test.js +63 -0
- package/clis/notebooklm/generate-slides.js +106 -0
- package/clis/notebooklm/generate-slides.test.js +75 -0
- package/clis/notebooklm/open.test.js +10 -10
- package/clis/notebooklm/rpc.js +20 -6
- package/clis/notebooklm/rpc.test.js +27 -1
- package/clis/notebooklm/utils.js +100 -24
- package/clis/notebooklm/utils.test.js +60 -1
- package/clis/notebooklm/write-note.js +103 -0
- package/clis/notebooklm/write-note.test.js +70 -0
- package/clis/pixiv/detail.js +41 -34
- package/clis/pixiv/detail.test.js +93 -0
- package/clis/pixiv/user.js +36 -31
- package/clis/pixiv/user.test.js +100 -0
- package/clis/pixiv/utils.js +56 -7
- package/clis/powerchina/search.js +3 -3
- package/clis/powerchina/search.test.js +27 -1
- package/clis/reddit/extract-media.test.js +149 -0
- package/clis/reddit/frontpage.js +47 -9
- package/clis/reddit/frontpage.test.js +34 -0
- package/clis/reddit/home.js +31 -1
- package/clis/reddit/home.test.js +46 -3
- package/clis/reddit/hot.js +32 -1
- package/clis/reddit/hot.test.js +15 -1
- package/clis/reddit/popular.js +39 -1
- package/clis/reddit/popular.test.js +26 -0
- package/clis/reddit/saved.js +1 -1
- package/clis/reddit/search.js +38 -1
- package/clis/reddit/search.test.js +26 -0
- package/clis/reddit/subreddit.js +52 -7
- package/clis/reddit/subreddit.test.js +31 -0
- package/clis/reddit/subscribed.js +165 -0
- package/clis/reddit/subscribed.test.js +168 -0
- package/clis/reddit/upvoted.js +1 -1
- package/clis/suno/commands.test.js +188 -0
- package/clis/suno/download.js +140 -0
- package/clis/suno/download.test.js +151 -0
- package/clis/suno/generate.js +231 -0
- package/clis/suno/generate.test.js +252 -0
- package/clis/suno/list.js +79 -0
- package/clis/suno/status.js +63 -0
- package/clis/suno/utils.js +549 -0
- package/clis/suno/utils.test.js +329 -0
- package/clis/twitter/device-follow.js +193 -0
- package/clis/twitter/device-follow.test.js +287 -0
- package/clis/twitter/download.js +443 -73
- package/clis/twitter/download.test.js +457 -0
- package/clis/twitter/followers.js +6 -2
- package/clis/twitter/followers.test.js +19 -1
- package/clis/twitter/following.js +14 -5
- package/clis/twitter/following.test.js +29 -0
- package/clis/twitter/likes.js +12 -4
- package/clis/twitter/likes.test.js +26 -1
- package/clis/twitter/list-add.js +1 -1
- package/clis/twitter/list-create.js +155 -0
- package/clis/twitter/list-create.test.js +169 -0
- package/clis/twitter/list-remove.js +13 -6
- package/clis/twitter/list-remove.test.js +74 -0
- package/clis/twitter/list-tweets.js +6 -2
- package/clis/twitter/list-tweets.test.js +41 -1
- package/clis/twitter/lists.js +31 -4
- package/clis/twitter/lists.test.js +152 -16
- package/clis/twitter/notifications.js +4 -4
- package/clis/twitter/post.js +62 -4
- package/clis/twitter/post.test.js +35 -3
- package/clis/twitter/profile.js +81 -28
- package/clis/twitter/profile.test.js +113 -2
- package/clis/twitter/quote.js +9 -4
- package/clis/twitter/reply.js +13 -10
- package/clis/twitter/reply.test.js +41 -0
- package/clis/twitter/search.js +7 -3
- package/clis/twitter/search.test.js +41 -0
- package/clis/twitter/shared.js +155 -0
- package/clis/twitter/shared.test.js +465 -1
- package/clis/twitter/thread.js +10 -2
- package/clis/twitter/thread.test.js +58 -0
- package/clis/twitter/timeline.js +6 -2
- package/clis/twitter/timeline.test.js +2 -0
- package/clis/twitter/tweets.js +3 -2
- package/clis/twitter/tweets.test.js +1 -1
- package/clis/twitter/utils.js +53 -16
- package/clis/upwork/detail.js +132 -0
- package/clis/upwork/feed.js +109 -0
- package/clis/upwork/search.js +115 -0
- package/clis/upwork/upwork.test.js +566 -0
- package/clis/upwork/utils.js +323 -0
- package/clis/weibo/delete.js +172 -0
- package/clis/weibo/delete.test.js +94 -0
- package/clis/weibo/publish.js +37 -14
- package/clis/weibo/publish.test.js +14 -5
- package/clis/weibo/user-posts.js +234 -0
- package/clis/weibo/user-posts.test.js +92 -0
- package/clis/weread/book-search.js +438 -0
- package/clis/weread/book-search.test.js +242 -0
- package/clis/weread/search-regression.test.js +98 -11
- package/clis/weread/search.js +32 -9
- package/clis/weread-official/book.js +135 -0
- package/clis/weread-official/commands.test.js +385 -0
- package/clis/weread-official/discover.js +107 -0
- package/clis/weread-official/list-apis.js +95 -0
- package/clis/weread-official/notes.js +171 -0
- package/clis/weread-official/readdata.js +158 -0
- package/clis/weread-official/review.js +93 -0
- package/clis/weread-official/search.js +106 -0
- package/clis/weread-official/shelf.js +97 -0
- package/clis/weread-official/utils.js +293 -0
- package/clis/weread-official/utils.test.js +242 -0
- package/clis/wikipedia/trending.js +7 -3
- package/clis/wikipedia/trending.test.js +57 -0
- package/clis/xianyu/chat.js +24 -109
- package/clis/xianyu/chat.test.js +5 -0
- package/clis/xianyu/im.js +322 -0
- package/clis/xianyu/im.test.js +253 -0
- package/clis/xianyu/inbox.js +96 -0
- package/clis/xianyu/messages.js +91 -0
- package/clis/xianyu/reply.js +82 -0
- package/clis/xiaohongshu/creator-note-detail.js +166 -28
- package/clis/xiaohongshu/creator-note-detail.test.js +196 -36
- package/clis/xiaohongshu/creator-notes-summary.js +2 -1
- package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
- package/clis/xiaohongshu/creator-notes.js +252 -2
- package/clis/xiaohongshu/creator-notes.test.js +90 -1
- package/clis/xiaohongshu/creator-stats.js +2 -1
- package/clis/xiaohongshu/creator-stats.test.js +24 -0
- package/clis/xiaohongshu/delete-note.js +260 -0
- package/clis/xiaohongshu/delete-note.test.js +172 -0
- package/clis/xiaohongshu/download.js +97 -39
- package/clis/xiaohongshu/download.test.js +201 -0
- package/clis/xiaohongshu/publish.js +48 -8
- package/clis/xiaohongshu/publish.test.js +65 -10
- package/clis/xiaohongshu/user-helpers.test.js +41 -0
- package/clis/xiaohongshu/user.js +27 -4
- package/clis/xiaoyuzhou/download.js +1 -1
- package/clis/xiaoyuzhou/transcript.js +1 -1
- package/clis/youdao/note.js +258 -0
- package/clis/youdao/note.test.js +99 -0
- package/clis/youtube/transcript.js +397 -24
- package/clis/youtube/transcript.test.js +196 -6
- package/clis/zhihu/answer-comments.js +280 -0
- package/clis/zhihu/answer-comments.test.js +287 -0
- package/clis/zhihu/answer-detail.js +2 -19
- package/clis/zhihu/answer-detail.test.js +8 -0
- package/clis/zhihu/collection.js +17 -16
- package/clis/zhihu/collection.test.js +50 -3
- package/clis/zhihu/download.js +1 -1
- package/clis/zhihu/question.js +42 -17
- package/clis/zhihu/question.test.js +113 -11
- package/clis/zhihu/search.js +195 -43
- package/clis/zhihu/search.test.js +198 -0
- package/clis/zhihu/text.js +29 -0
- package/clis/zhihu/text.test.js +24 -0
- package/dist/src/browser/errors.js +4 -2
- package/dist/src/browser/errors.test.js +6 -0
- package/dist/src/browser/network-cache.js +13 -1
- package/dist/src/browser/network-cache.test.js +17 -0
- package/dist/src/browser/page.js +30 -4
- package/dist/src/browser/page.test.js +42 -0
- package/dist/src/browser/utils.d.ts +1 -1
- package/dist/src/cli-argv-preprocess.d.ts +26 -0
- package/dist/src/cli-argv-preprocess.js +138 -0
- package/dist/src/cli-argv-preprocess.test.js +79 -0
- package/dist/src/convention-audit.js +15 -8
- package/dist/src/convention-audit.test.js +21 -0
- package/dist/src/download/index.js +13 -1
- package/dist/src/download/index.test.js +23 -1
- package/dist/src/download/media-download.js +15 -2
- package/dist/src/download/media-download.test.d.ts +1 -0
- package/dist/src/download/media-download.test.js +112 -0
- package/dist/src/download/progress.js +2 -2
- package/dist/src/download/progress.test.js +12 -1
- package/dist/src/electron-apps.js +1 -1
- package/dist/src/electron-apps.test.js +7 -2
- package/dist/src/errors.d.ts +17 -0
- package/dist/src/errors.js +22 -0
- package/dist/src/external-clis.yaml +8 -0
- package/dist/src/main.js +14 -2
- package/dist/src/output.js +11 -1
- package/dist/src/output.test.js +6 -0
- package/dist/src/registry.js +1 -0
- package/dist/src/registry.test.js +11 -0
- package/dist/src/utils.d.ts +43 -0
- package/dist/src/utils.js +97 -0
- package/dist/src/utils.test.d.ts +1 -0
- package/dist/src/utils.test.js +155 -0
- package/package.json +8 -2
- package/scripts/silent-column-drop-baseline.json +0 -52
- package/scripts/typed-error-lint-baseline.json +28 -380
- package/clis/slock/_utils.js +0 -12
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { stripHtml } from './text.js';
|
|
3
|
+
|
|
4
|
+
describe('zhihu text helpers', () => {
|
|
5
|
+
it('strips tags and decodes named entities in flat mode', () => {
|
|
6
|
+
expect(stripHtml('<em>Codex</em> & <CLI>')).toBe('Codex & <CLI>');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('decodes decimal and hexadecimal numeric entities', () => {
|
|
10
|
+
expect(stripHtml('"中文" & 'test'')).toBe('"中文" & \'test\'');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('keeps invalid numeric entities unchanged', () => {
|
|
14
|
+
expect(stripHtml('bad � entity')).toBe('bad � entity');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('keeps list excerpts flat by default', () => {
|
|
18
|
+
expect(stripHtml('<p>first</p><br><p>second</p>')).toBe('firstsecond');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('preserves block breaks when requested', () => {
|
|
22
|
+
expect(stripHtml('<p>first</p><br><p>second</p>', { preserveBlocks: true })).toBe('first\n\nsecond');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -49,8 +49,10 @@ export function classifyBrowserError(err) {
|
|
|
49
49
|
if (TARGET_NAVIGATION_PATTERNS.some(p => msg.includes(p))) {
|
|
50
50
|
return { kind: 'target-navigation', retryable: true, delayMs: 200 };
|
|
51
51
|
}
|
|
52
|
-
// CDP protocol error with target
|
|
53
|
-
|
|
52
|
+
// CDP protocol error with target/context invalidation (e.g., -32000 "target closed" or
|
|
53
|
+
// -32000 "Cannot find default execution context" — both indicate the inspected target
|
|
54
|
+
// went away and a fresh attach should recover).
|
|
55
|
+
if (msg.includes('-32000') && /target|context/i.test(msg)) {
|
|
54
56
|
return { kind: 'target-navigation', retryable: true, delayMs: 200 };
|
|
55
57
|
}
|
|
56
58
|
return { kind: 'non-retryable', retryable: false, delayMs: 0 };
|
|
@@ -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));
|
|
@@ -29,7 +29,19 @@ export function saveNetworkCache(session, entries, baseDir) {
|
|
|
29
29
|
savedAt: new Date().toISOString(),
|
|
30
30
|
entries,
|
|
31
31
|
};
|
|
32
|
-
|
|
32
|
+
// 0o600: entries can include auth tokens and PII from captured response
|
|
33
|
+
// bodies. fchmod before writing also tightens a pre-existing broad file.
|
|
34
|
+
let fd;
|
|
35
|
+
try {
|
|
36
|
+
fd = fs.openSync(target, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC, 0o600);
|
|
37
|
+
fs.fchmodSync(fd, 0o600);
|
|
38
|
+
fs.writeFileSync(fd, JSON.stringify(payload), 'utf8');
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
if (fd !== undefined) {
|
|
42
|
+
fs.closeSync(fd);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
33
45
|
}
|
|
34
46
|
export function loadNetworkCache(session, opts = {}) {
|
|
35
47
|
const target = getCachePath(session, opts.baseDir);
|
|
@@ -55,4 +55,21 @@ describe('network-cache', () => {
|
|
|
55
55
|
expect(findEntry(file, 'B')?.key).toBe('B');
|
|
56
56
|
expect(findEntry(file, 'missing')).toBeNull();
|
|
57
57
|
});
|
|
58
|
+
it.skipIf(process.platform === 'win32')('writes the cache file with 0o600 owner-only permissions', () => {
|
|
59
|
+
saveNetworkCache('ws', [makeEntry('UserTweets')], baseDir);
|
|
60
|
+
const target = getCachePath('ws', baseDir);
|
|
61
|
+
const mode = fs.statSync(target).mode & 0o777;
|
|
62
|
+
expect(mode).toBe(0o600);
|
|
63
|
+
});
|
|
64
|
+
it.skipIf(process.platform === 'win32')('tightens an existing cache file before rewriting it', () => {
|
|
65
|
+
const target = getCachePath('ws', baseDir);
|
|
66
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
67
|
+
fs.writeFileSync(target, '{"version":1,"session":"ws","savedAt":"old","entries":[]}', { mode: 0o644 });
|
|
68
|
+
saveNetworkCache('ws', [makeEntry('UserTweets')], baseDir);
|
|
69
|
+
const mode = fs.statSync(target).mode & 0o777;
|
|
70
|
+
expect(mode).toBe(0o600);
|
|
71
|
+
const reloaded = loadNetworkCache('ws', { baseDir });
|
|
72
|
+
expect(reloaded.status).toBe('ok');
|
|
73
|
+
expect(reloaded.file?.entries[0].key).toBe('UserTweets');
|
|
74
|
+
});
|
|
58
75
|
});
|
package/dist/src/browser/page.js
CHANGED
|
@@ -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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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' })
|
|
@@ -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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
});
|
|
@@ -165,7 +165,19 @@ export function exportCookiesToNetscape(cookies, filePath) {
|
|
|
165
165
|
lines.push(`${domain}\t${includeSubdomains}\t${cookiePath}\t${secure}\t${expiry}\t${safeName}\t${safeValue}`);
|
|
166
166
|
}
|
|
167
167
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
168
|
-
|
|
168
|
+
// 0o600: file holds live session cookies, must be owner-only. fchmod before
|
|
169
|
+
// writing also tightens a pre-existing broad file before new secrets land.
|
|
170
|
+
let fd;
|
|
171
|
+
try {
|
|
172
|
+
fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC, 0o600);
|
|
173
|
+
fs.fchmodSync(fd, 0o600);
|
|
174
|
+
fs.writeFileSync(fd, lines.join('\n'), 'utf8');
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
if (fd !== undefined) {
|
|
178
|
+
fs.closeSync(fd);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
169
181
|
}
|
|
170
182
|
export function formatCookieHeader(cookies) {
|
|
171
183
|
return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ');
|
|
@@ -3,7 +3,7 @@ import * as http from 'node:http';
|
|
|
3
3
|
import * as os from 'node:os';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
-
import { formatCookieHeader, httpDownload, resolveRedirectUrl } from './index.js';
|
|
6
|
+
import { exportCookiesToNetscape, formatCookieHeader, httpDownload, resolveRedirectUrl } from './index.js';
|
|
7
7
|
const servers = [];
|
|
8
8
|
const tempDirs = [];
|
|
9
9
|
afterEach(async () => {
|
|
@@ -115,4 +115,26 @@ describe('download helpers', { retry: process.platform === 'win32' ? 2 : 0 }, ()
|
|
|
115
115
|
expect(result).toEqual({ success: true, size: 2 });
|
|
116
116
|
expect(fs.readFileSync(destPath, 'utf8')).toBe('ok');
|
|
117
117
|
});
|
|
118
|
+
it.skipIf(process.platform === 'win32')('writes the Netscape cookie file with 0o600 owner-only permissions', async () => {
|
|
119
|
+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-dl-'));
|
|
120
|
+
tempDirs.push(tempDir);
|
|
121
|
+
const cookiesPath = path.join(tempDir, 'cookies.txt');
|
|
122
|
+
exportCookiesToNetscape([
|
|
123
|
+
{ name: 'sid', value: 'secret', domain: 'example.com' },
|
|
124
|
+
], cookiesPath);
|
|
125
|
+
const mode = fs.statSync(cookiesPath).mode & 0o777;
|
|
126
|
+
expect(mode).toBe(0o600);
|
|
127
|
+
});
|
|
128
|
+
it.skipIf(process.platform === 'win32')('tightens an existing Netscape cookie file before rewriting it', async () => {
|
|
129
|
+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-dl-'));
|
|
130
|
+
tempDirs.push(tempDir);
|
|
131
|
+
const cookiesPath = path.join(tempDir, 'cookies.txt');
|
|
132
|
+
fs.writeFileSync(cookiesPath, 'stale-cookie', { mode: 0o644 });
|
|
133
|
+
exportCookiesToNetscape([
|
|
134
|
+
{ name: 'sid', value: 'secret', domain: 'example.com' },
|
|
135
|
+
], cookiesPath);
|
|
136
|
+
const mode = fs.statSync(cookiesPath).mode & 0o777;
|
|
137
|
+
expect(mode).toBe(0o600);
|
|
138
|
+
expect(fs.readFileSync(cookiesPath, 'utf8')).toContain('sid\tsecret');
|
|
139
|
+
});
|
|
118
140
|
});
|
|
@@ -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
|
|
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 {};
|