@jackwener/opencli 1.4.1 → 1.5.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.
Files changed (369) hide show
  1. package/.github/workflows/build-extension.yml +2 -6
  2. package/.github/workflows/ci.yml +21 -1
  3. package/README.md +35 -6
  4. package/README.zh-CN.md +12 -5
  5. package/SKILL.md +2 -0
  6. package/dist/browser/cdp.d.ts +2 -1
  7. package/dist/browser/cdp.js +5 -0
  8. package/dist/browser/discover.d.ts +4 -1
  9. package/dist/browser/discover.js +6 -2
  10. package/dist/browser/errors.d.ts +2 -2
  11. package/dist/browser/errors.js +4 -12
  12. package/dist/browser/mcp.d.ts +2 -1
  13. package/dist/browser/page.d.ts +3 -0
  14. package/dist/browser/page.js +24 -1
  15. package/dist/build-manifest.d.ts +2 -0
  16. package/dist/build-manifest.js +39 -14
  17. package/dist/build-manifest.test.js +21 -0
  18. package/dist/capabilityRouting.d.ts +2 -0
  19. package/dist/capabilityRouting.js +2 -1
  20. package/dist/cli-manifest.json +1567 -108
  21. package/dist/cli.js +68 -6
  22. package/dist/clis/36kr/article.d.ts +1 -0
  23. package/dist/clis/36kr/article.js +62 -0
  24. package/dist/clis/36kr/hot.d.ts +3 -0
  25. package/dist/clis/36kr/hot.js +80 -0
  26. package/dist/clis/36kr/hot.test.d.ts +1 -0
  27. package/dist/clis/36kr/hot.test.js +15 -0
  28. package/dist/clis/36kr/news.d.ts +1 -0
  29. package/dist/clis/36kr/news.js +51 -0
  30. package/dist/clis/36kr/news.test.d.ts +1 -0
  31. package/dist/clis/36kr/news.test.js +85 -0
  32. package/dist/clis/36kr/search.d.ts +1 -0
  33. package/dist/clis/36kr/search.js +72 -0
  34. package/dist/clis/bilibili/comments.d.ts +5 -0
  35. package/dist/clis/bilibili/comments.js +40 -0
  36. package/dist/clis/bilibili/comments.test.d.ts +1 -0
  37. package/dist/clis/bilibili/comments.test.js +82 -0
  38. package/dist/clis/bluesky/feeds.yaml +29 -0
  39. package/dist/clis/bluesky/followers.yaml +33 -0
  40. package/dist/clis/bluesky/following.yaml +33 -0
  41. package/dist/clis/bluesky/profile.yaml +27 -0
  42. package/dist/clis/bluesky/search.yaml +34 -0
  43. package/dist/clis/bluesky/starter-packs.yaml +34 -0
  44. package/dist/clis/bluesky/thread.yaml +32 -0
  45. package/dist/clis/bluesky/trending.yaml +27 -0
  46. package/dist/clis/bluesky/user.yaml +34 -0
  47. package/dist/clis/chatgpt/ask.js +29 -14
  48. package/dist/clis/chatgpt/ax.d.ts +6 -0
  49. package/dist/clis/chatgpt/ax.js +172 -1
  50. package/dist/clis/chatgpt/model.d.ts +1 -0
  51. package/dist/clis/chatgpt/model.js +24 -0
  52. package/dist/clis/chatgpt/send.js +12 -3
  53. package/dist/clis/douban/download.d.ts +1 -0
  54. package/dist/clis/douban/download.js +67 -0
  55. package/dist/clis/douban/download.test.d.ts +1 -0
  56. package/dist/clis/douban/download.test.js +170 -0
  57. package/dist/clis/douban/photos.d.ts +1 -0
  58. package/dist/clis/douban/photos.js +34 -0
  59. package/dist/clis/douban/utils.d.ts +25 -0
  60. package/dist/clis/douban/utils.js +190 -1
  61. package/dist/clis/douban/utils.test.d.ts +1 -0
  62. package/dist/clis/douban/utils.test.js +64 -0
  63. package/dist/clis/imdb/person.d.ts +1 -0
  64. package/dist/clis/imdb/person.js +203 -0
  65. package/dist/clis/imdb/reviews.d.ts +1 -0
  66. package/dist/clis/imdb/reviews.js +88 -0
  67. package/dist/clis/imdb/search.d.ts +1 -0
  68. package/dist/clis/imdb/search.js +161 -0
  69. package/dist/clis/imdb/title.d.ts +1 -0
  70. package/dist/clis/imdb/title.js +93 -0
  71. package/dist/clis/imdb/top.d.ts +1 -0
  72. package/dist/clis/imdb/top.js +53 -0
  73. package/dist/clis/imdb/trending.d.ts +1 -0
  74. package/dist/clis/imdb/trending.js +52 -0
  75. package/dist/clis/imdb/utils.d.ts +46 -0
  76. package/dist/clis/imdb/utils.js +285 -0
  77. package/dist/clis/imdb/utils.test.d.ts +1 -0
  78. package/dist/clis/imdb/utils.test.js +88 -0
  79. package/dist/clis/jd/item.d.ts +4 -0
  80. package/dist/clis/jd/item.js +16 -15
  81. package/dist/clis/jd/item.test.js +16 -1
  82. package/dist/clis/linux-do/categories.yaml +38 -9
  83. package/dist/clis/linux-do/category.d.ts +1 -0
  84. package/dist/clis/linux-do/category.js +36 -0
  85. package/dist/clis/linux-do/feed.d.ts +45 -0
  86. package/dist/clis/linux-do/feed.js +397 -0
  87. package/dist/clis/linux-do/feed.test.d.ts +1 -0
  88. package/dist/clis/linux-do/feed.test.js +118 -0
  89. package/dist/clis/linux-do/hot.d.ts +1 -0
  90. package/dist/clis/linux-do/hot.js +25 -0
  91. package/dist/clis/linux-do/latest.d.ts +1 -0
  92. package/dist/clis/linux-do/latest.js +18 -0
  93. package/dist/clis/linux-do/tags.yaml +41 -0
  94. package/dist/clis/linux-do/topic.yaml +41 -3
  95. package/dist/clis/linux-do/user-posts.yaml +67 -0
  96. package/dist/clis/linux-do/user-topics.yaml +54 -0
  97. package/dist/clis/paperreview/commands.test.d.ts +3 -0
  98. package/dist/clis/paperreview/commands.test.js +243 -0
  99. package/dist/clis/paperreview/feedback.d.ts +1 -0
  100. package/dist/clis/paperreview/feedback.js +52 -0
  101. package/dist/clis/paperreview/review.d.ts +1 -0
  102. package/dist/clis/paperreview/review.js +37 -0
  103. package/dist/clis/paperreview/submit.d.ts +1 -0
  104. package/dist/clis/paperreview/submit.js +85 -0
  105. package/dist/clis/paperreview/utils.d.ts +46 -0
  106. package/dist/clis/paperreview/utils.js +197 -0
  107. package/dist/clis/paperreview/utils.test.d.ts +1 -0
  108. package/dist/clis/paperreview/utils.test.js +49 -0
  109. package/dist/clis/producthunt/browse.d.ts +1 -0
  110. package/dist/clis/producthunt/browse.js +99 -0
  111. package/dist/clis/producthunt/hot.d.ts +1 -0
  112. package/dist/clis/producthunt/hot.js +110 -0
  113. package/dist/clis/producthunt/posts.d.ts +1 -0
  114. package/dist/clis/producthunt/posts.js +28 -0
  115. package/dist/clis/producthunt/today.d.ts +1 -0
  116. package/dist/clis/producthunt/today.js +35 -0
  117. package/dist/clis/producthunt/utils.d.ts +29 -0
  118. package/dist/clis/producthunt/utils.js +99 -0
  119. package/dist/clis/producthunt/utils.test.d.ts +1 -0
  120. package/dist/clis/producthunt/utils.test.js +64 -0
  121. package/dist/clis/twitter/article.js +4 -28
  122. package/dist/clis/twitter/likes.d.ts +24 -0
  123. package/dist/clis/twitter/likes.js +217 -0
  124. package/dist/clis/twitter/likes.test.d.ts +1 -0
  125. package/dist/clis/twitter/likes.test.js +85 -0
  126. package/dist/clis/twitter/profile.js +4 -28
  127. package/dist/clis/twitter/search.js +2 -1
  128. package/dist/clis/twitter/search.test.js +2 -0
  129. package/dist/clis/twitter/shared.d.ts +6 -0
  130. package/dist/clis/twitter/shared.js +35 -0
  131. package/dist/clis/twitter/timeline.js +2 -13
  132. package/dist/clis/twitter/trending.js +29 -61
  133. package/dist/clis/v2ex/hot.yaml +17 -3
  134. package/dist/clis/weixin/download.d.ts +17 -0
  135. package/dist/clis/weixin/download.js +88 -20
  136. package/dist/clis/weread/book.js +2 -2
  137. package/dist/clis/weread/commands.test.d.ts +3 -0
  138. package/dist/clis/weread/commands.test.js +43 -0
  139. package/dist/clis/weread/highlights.js +2 -2
  140. package/dist/clis/weread/notebooks.js +2 -2
  141. package/dist/clis/weread/notes.js +3 -3
  142. package/dist/clis/weread/shelf.js +2 -2
  143. package/dist/clis/weread/utils.d.ts +4 -4
  144. package/dist/clis/weread/utils.js +32 -14
  145. package/dist/clis/weread/utils.test.js +1 -28
  146. package/dist/clis/xiaohongshu/comments.d.ts +5 -0
  147. package/dist/clis/xiaohongshu/comments.js +74 -0
  148. package/dist/clis/xiaohongshu/comments.test.d.ts +1 -0
  149. package/dist/clis/xiaohongshu/comments.test.js +79 -0
  150. package/dist/clis/xiaohongshu/publish.js +179 -47
  151. package/dist/clis/xiaohongshu/publish.test.d.ts +1 -0
  152. package/dist/clis/xiaohongshu/publish.test.js +131 -0
  153. package/dist/clis/xiaohongshu/search.d.ts +8 -1
  154. package/dist/clis/xiaohongshu/search.js +20 -1
  155. package/dist/clis/xiaohongshu/search.test.d.ts +1 -1
  156. package/dist/clis/xiaohongshu/search.test.js +32 -1
  157. package/dist/commanderAdapter.d.ts +1 -0
  158. package/dist/commanderAdapter.js +176 -29
  159. package/dist/commanderAdapter.test.d.ts +1 -0
  160. package/dist/commanderAdapter.test.js +62 -0
  161. package/dist/daemon.js +17 -1
  162. package/dist/discovery.js +48 -42
  163. package/dist/doctor.d.ts +2 -2
  164. package/dist/doctor.js +11 -4
  165. package/dist/download/index.js +63 -51
  166. package/dist/download/index.test.js +17 -4
  167. package/dist/engine.test.js +42 -0
  168. package/dist/errors.d.ts +4 -2
  169. package/dist/errors.js +17 -34
  170. package/dist/execution.d.ts +1 -3
  171. package/dist/execution.js +66 -8
  172. package/dist/execution.test.d.ts +1 -0
  173. package/dist/execution.test.js +40 -0
  174. package/dist/external.js +6 -1
  175. package/dist/hooks.js +2 -0
  176. package/dist/main.js +6 -0
  177. package/dist/output.js +5 -1
  178. package/dist/pipeline/executor.js +3 -4
  179. package/dist/plugin-manifest.d.ts +70 -0
  180. package/dist/plugin-manifest.js +160 -0
  181. package/dist/plugin-manifest.test.d.ts +4 -0
  182. package/dist/plugin-manifest.test.js +179 -0
  183. package/dist/plugin-scaffold.d.ts +28 -0
  184. package/dist/plugin-scaffold.js +142 -0
  185. package/dist/plugin-scaffold.test.d.ts +4 -0
  186. package/dist/plugin-scaffold.test.js +83 -0
  187. package/dist/plugin.d.ts +82 -11
  188. package/dist/plugin.js +870 -84
  189. package/dist/plugin.test.js +1032 -17
  190. package/dist/registry.d.ts +4 -0
  191. package/dist/registry.js +2 -0
  192. package/dist/runtime-detect.d.ts +21 -0
  193. package/dist/runtime-detect.js +32 -0
  194. package/dist/runtime-detect.test.d.ts +1 -0
  195. package/dist/runtime-detect.test.js +27 -0
  196. package/dist/runtime.d.ts +1 -0
  197. package/dist/runtime.js +2 -2
  198. package/dist/serialization.d.ts +2 -0
  199. package/dist/serialization.js +6 -0
  200. package/dist/types.d.ts +3 -0
  201. package/dist/update-check.d.ts +22 -0
  202. package/dist/update-check.js +112 -0
  203. package/dist/weixin-download.test.d.ts +1 -0
  204. package/dist/weixin-download.test.js +30 -0
  205. package/dist/weread-private-api-regression.test.d.ts +1 -0
  206. package/dist/weread-private-api-regression.test.js +122 -0
  207. package/dist/yaml-schema.d.ts +3 -0
  208. package/dist/yaml-schema.js +18 -1
  209. package/docs/.vitepress/config.mts +4 -0
  210. package/docs/adapters/browser/36kr.md +47 -0
  211. package/docs/adapters/browser/bluesky.md +53 -0
  212. package/docs/adapters/browser/douban.md +14 -0
  213. package/docs/adapters/browser/imdb.md +47 -0
  214. package/docs/adapters/browser/jd.md +2 -2
  215. package/docs/adapters/browser/linux-do.md +181 -20
  216. package/docs/adapters/browser/paperreview.md +43 -0
  217. package/docs/adapters/browser/producthunt.md +49 -0
  218. package/docs/adapters/desktop/chatgpt.md +5 -0
  219. package/docs/adapters/index.md +6 -2
  220. package/docs/advanced/download.md +4 -0
  221. package/docs/advanced/rate-limiter-plugin.md +99 -0
  222. package/docs/guide/electron-app-cli.md +200 -0
  223. package/docs/guide/getting-started.md +1 -0
  224. package/docs/guide/plugins.md +97 -0
  225. package/docs/zh/guide/electron-app-cli.md +188 -0
  226. package/docs/zh/guide/getting-started.md +1 -0
  227. package/docs/zh/guide/plugins.md +65 -0
  228. package/extension/package.json +1 -0
  229. package/extension/scripts/package-release.mjs +179 -0
  230. package/extension/src/background.ts +2 -0
  231. package/package.json +4 -1
  232. package/scripts/postinstall.js +10 -0
  233. package/src/browser/cdp.ts +8 -1
  234. package/src/browser/discover.ts +8 -3
  235. package/src/browser/errors.ts +13 -14
  236. package/src/browser/mcp.ts +2 -1
  237. package/src/browser/page.ts +24 -1
  238. package/src/build-manifest.test.ts +23 -0
  239. package/src/build-manifest.ts +40 -15
  240. package/src/capabilityRouting.ts +2 -1
  241. package/src/cli.ts +69 -6
  242. package/src/clis/36kr/article.ts +69 -0
  243. package/src/clis/36kr/hot.test.ts +19 -0
  244. package/src/clis/36kr/hot.ts +100 -0
  245. package/src/clis/36kr/news.test.ts +90 -0
  246. package/src/clis/36kr/news.ts +54 -0
  247. package/src/clis/36kr/search.ts +78 -0
  248. package/src/clis/bilibili/comments.test.ts +102 -0
  249. package/src/clis/bilibili/comments.ts +44 -0
  250. package/src/clis/bluesky/feeds.yaml +29 -0
  251. package/src/clis/bluesky/followers.yaml +33 -0
  252. package/src/clis/bluesky/following.yaml +33 -0
  253. package/src/clis/bluesky/profile.yaml +27 -0
  254. package/src/clis/bluesky/search.yaml +34 -0
  255. package/src/clis/bluesky/starter-packs.yaml +34 -0
  256. package/src/clis/bluesky/thread.yaml +32 -0
  257. package/src/clis/bluesky/trending.yaml +27 -0
  258. package/src/clis/bluesky/user.yaml +34 -0
  259. package/src/clis/chatgpt/ask.ts +28 -14
  260. package/src/clis/chatgpt/ax.ts +180 -1
  261. package/src/clis/chatgpt/model.ts +27 -0
  262. package/src/clis/chatgpt/send.ts +16 -6
  263. package/src/clis/douban/download.test.ts +196 -0
  264. package/src/clis/douban/download.ts +78 -0
  265. package/src/clis/douban/photos.ts +36 -0
  266. package/src/clis/douban/utils.test.ts +97 -0
  267. package/src/clis/douban/utils.ts +232 -1
  268. package/src/clis/imdb/person.ts +232 -0
  269. package/src/clis/imdb/reviews.ts +111 -0
  270. package/src/clis/imdb/search.ts +179 -0
  271. package/src/clis/imdb/title.ts +121 -0
  272. package/src/clis/imdb/top.ts +67 -0
  273. package/src/clis/imdb/trending.ts +66 -0
  274. package/src/clis/imdb/utils.test.ts +117 -0
  275. package/src/clis/imdb/utils.ts +305 -0
  276. package/src/clis/jd/item.test.ts +18 -1
  277. package/src/clis/jd/item.ts +18 -15
  278. package/src/clis/linux-do/categories.yaml +38 -9
  279. package/src/clis/linux-do/category.ts +37 -0
  280. package/src/clis/linux-do/feed.test.ts +132 -0
  281. package/src/clis/linux-do/feed.ts +501 -0
  282. package/src/clis/linux-do/hot.ts +26 -0
  283. package/src/clis/linux-do/latest.ts +19 -0
  284. package/src/clis/linux-do/tags.yaml +41 -0
  285. package/src/clis/linux-do/topic.yaml +41 -3
  286. package/src/clis/linux-do/user-posts.yaml +67 -0
  287. package/src/clis/linux-do/user-topics.yaml +54 -0
  288. package/src/clis/paperreview/commands.test.ts +283 -0
  289. package/src/clis/paperreview/feedback.ts +64 -0
  290. package/src/clis/paperreview/review.ts +47 -0
  291. package/src/clis/paperreview/submit.ts +119 -0
  292. package/src/clis/paperreview/utils.test.ts +68 -0
  293. package/src/clis/paperreview/utils.ts +276 -0
  294. package/src/clis/producthunt/browse.ts +109 -0
  295. package/src/clis/producthunt/hot.ts +127 -0
  296. package/src/clis/producthunt/posts.ts +29 -0
  297. package/src/clis/producthunt/today.ts +37 -0
  298. package/src/clis/producthunt/utils.test.ts +72 -0
  299. package/src/clis/producthunt/utils.ts +122 -0
  300. package/src/clis/twitter/article.ts +5 -28
  301. package/src/clis/twitter/likes.test.ts +91 -0
  302. package/src/clis/twitter/likes.ts +256 -0
  303. package/src/clis/twitter/profile.ts +5 -28
  304. package/src/clis/twitter/search.test.ts +2 -0
  305. package/src/clis/twitter/search.ts +3 -1
  306. package/src/clis/twitter/shared.ts +45 -0
  307. package/src/clis/twitter/timeline.ts +2 -13
  308. package/src/clis/twitter/trending.ts +29 -77
  309. package/src/clis/v2ex/hot.yaml +17 -3
  310. package/src/clis/weixin/download.ts +114 -20
  311. package/src/clis/weread/book.ts +2 -2
  312. package/src/clis/weread/commands.test.ts +57 -0
  313. package/src/clis/weread/highlights.ts +2 -2
  314. package/src/clis/weread/notebooks.ts +2 -2
  315. package/src/clis/weread/notes.ts +3 -3
  316. package/src/clis/weread/shelf.ts +2 -2
  317. package/src/clis/weread/utils.test.ts +1 -32
  318. package/src/clis/weread/utils.ts +41 -16
  319. package/src/clis/xiaohongshu/comments.test.ts +96 -0
  320. package/src/clis/xiaohongshu/comments.ts +81 -0
  321. package/src/clis/xiaohongshu/publish.test.ts +151 -0
  322. package/src/clis/xiaohongshu/publish.ts +206 -54
  323. package/src/clis/xiaohongshu/search.test.ts +39 -1
  324. package/src/clis/xiaohongshu/search.ts +19 -1
  325. package/src/commanderAdapter.test.ts +78 -0
  326. package/src/commanderAdapter.ts +188 -24
  327. package/src/daemon.ts +19 -1
  328. package/src/discovery.ts +49 -48
  329. package/src/doctor.ts +15 -5
  330. package/src/download/index.test.ts +14 -4
  331. package/src/download/index.ts +67 -55
  332. package/src/engine.test.ts +38 -0
  333. package/src/errors.ts +26 -63
  334. package/src/execution.test.ts +47 -0
  335. package/src/execution.ts +67 -9
  336. package/src/external.ts +6 -1
  337. package/src/hooks.ts +1 -0
  338. package/src/main.ts +7 -0
  339. package/src/output.ts +3 -1
  340. package/src/pipeline/executor.ts +4 -6
  341. package/src/plugin-manifest.test.ts +223 -0
  342. package/src/plugin-manifest.ts +206 -0
  343. package/src/plugin-scaffold.test.ts +98 -0
  344. package/src/plugin-scaffold.ts +170 -0
  345. package/src/plugin.test.ts +1104 -17
  346. package/src/plugin.ts +1101 -86
  347. package/src/registry.ts +6 -1
  348. package/src/runtime-detect.test.ts +30 -0
  349. package/src/runtime-detect.ts +36 -0
  350. package/src/runtime.ts +3 -3
  351. package/src/serialization.ts +4 -0
  352. package/src/types.ts +3 -0
  353. package/src/update-check.ts +114 -0
  354. package/src/weixin-download.test.ts +64 -0
  355. package/src/weread-private-api-regression.test.ts +150 -0
  356. package/src/yaml-schema.ts +20 -0
  357. package/tests/e2e/browser-auth.test.ts +13 -9
  358. package/tests/e2e/browser-public-extended.test.ts +1 -1
  359. package/tests/e2e/browser-public.test.ts +62 -4
  360. package/tests/e2e/helpers.ts +2 -1
  361. package/tests/e2e/public-commands.test.ts +37 -3
  362. package/tests/smoke/api-health.test.ts +1 -1
  363. package/vitest.config.ts +10 -0
  364. package/dist/clis/linux-do/category.yaml +0 -51
  365. package/dist/clis/linux-do/hot.yaml +0 -50
  366. package/dist/clis/linux-do/latest.yaml +0 -40
  367. package/src/clis/linux-do/category.yaml +0 -51
  368. package/src/clis/linux-do/hot.yaml +0 -50
  369. package/src/clis/linux-do/latest.yaml +0 -40
@@ -6,15 +6,26 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
6
6
  import * as fs from 'node:fs';
7
7
  import * as os from 'node:os';
8
8
  import * as path from 'node:path';
9
+ import { pathToFileURL } from 'node:url';
9
10
  import { PLUGINS_DIR } from './discovery.js';
10
11
  import type { LockEntry } from './plugin.js';
11
12
  import * as pluginModule from './plugin.js';
12
13
 
14
+ const { mockExecFileSync, mockExecSync } = vi.hoisted(() => ({
15
+ mockExecFileSync: vi.fn(),
16
+ mockExecSync: vi.fn(),
17
+ }));
18
+
13
19
  const {
14
- LOCK_FILE,
15
20
  _getCommitHash,
21
+ _installDependencies,
22
+ _postInstallMonorepoLifecycle,
23
+ _promoteDir,
24
+ _replaceDir,
25
+ installPlugin,
16
26
  listPlugins,
17
27
  _readLockFile,
28
+ _readLockFileWithWriter,
18
29
  _resolveEsbuildBin,
19
30
  uninstallPlugin,
20
31
  updatePlugin,
@@ -22,12 +33,24 @@ const {
22
33
  _updateAllPlugins,
23
34
  _validatePluginStructure,
24
35
  _writeLockFile,
36
+ _writeLockFileWithFs,
37
+ _isSymlinkSync,
38
+ _getMonoreposDir,
39
+ getLockFilePath,
40
+ _installLocalPlugin,
41
+ _isLocalPluginSource,
42
+ _moveDir,
43
+ _resolvePluginSource,
44
+ _resolveStoredPluginSource,
45
+ _toStoredPluginSource,
46
+ _toLocalPluginSource,
25
47
  } = pluginModule;
26
48
 
27
49
  describe('parseSource', () => {
28
50
  it('parses github:user/repo format', () => {
29
51
  const result = _parseSource('github:ByteYue/opencli-plugin-github-trending');
30
52
  expect(result).toEqual({
53
+ type: 'git',
31
54
  cloneUrl: 'https://github.com/ByteYue/opencli-plugin-github-trending.git',
32
55
  name: 'github-trending',
33
56
  });
@@ -36,6 +59,7 @@ describe('parseSource', () => {
36
59
  it('parses https URL format', () => {
37
60
  const result = _parseSource('https://github.com/ByteYue/opencli-plugin-hot-digest');
38
61
  expect(result).toEqual({
62
+ type: 'git',
39
63
  cloneUrl: 'https://github.com/ByteYue/opencli-plugin-hot-digest.git',
40
64
  name: 'hot-digest',
41
65
  });
@@ -55,6 +79,98 @@ describe('parseSource', () => {
55
79
  expect(_parseSource('invalid')).toBeNull();
56
80
  expect(_parseSource('npm:some-package')).toBeNull();
57
81
  });
82
+
83
+ it('parses file:// local plugin directories', () => {
84
+ const localDir = path.join(os.tmpdir(), 'opencli-plugin-test');
85
+ const fileUrl = pathToFileURL(localDir).href;
86
+ const result = _parseSource(fileUrl);
87
+ expect(result).toEqual({
88
+ type: 'local',
89
+ localPath: localDir,
90
+ name: 'test',
91
+ });
92
+ });
93
+
94
+ it('parses plain absolute local plugin directories', () => {
95
+ const localDir = path.join(os.tmpdir(), 'my-plugin');
96
+ const result = _parseSource(localDir);
97
+ expect(result).toEqual({
98
+ type: 'local',
99
+ localPath: localDir,
100
+ name: 'my-plugin',
101
+ });
102
+ });
103
+
104
+ it('strips opencli-plugin- prefix for local paths', () => {
105
+ const localDir = path.join(os.tmpdir(), 'opencli-plugin-foo');
106
+ const result = _parseSource(localDir);
107
+ expect(result!.name).toBe('foo');
108
+ });
109
+
110
+ // ── Generic git URL support ──
111
+ it('parses ssh:// URLs', () => {
112
+ const result = _parseSource('ssh://git@gitlab.com/team/opencli-plugin-tools.git');
113
+ expect(result).toEqual({
114
+ type: 'git',
115
+ cloneUrl: 'ssh://git@gitlab.com/team/opencli-plugin-tools.git',
116
+ name: 'tools',
117
+ });
118
+ });
119
+
120
+ it('parses ssh:// URLs without .git suffix', () => {
121
+ const result = _parseSource('ssh://git@gitlab.com/team/my-plugin');
122
+ expect(result).toEqual({
123
+ type: 'git',
124
+ cloneUrl: 'ssh://git@gitlab.com/team/my-plugin',
125
+ name: 'my-plugin',
126
+ });
127
+ });
128
+
129
+ it('parses git@ SCP-style URLs', () => {
130
+ const result = _parseSource('git@gitlab.com:team/my-plugin.git');
131
+ expect(result).toEqual({
132
+ type: 'git',
133
+ cloneUrl: 'git@gitlab.com:team/my-plugin.git',
134
+ name: 'my-plugin',
135
+ });
136
+ });
137
+
138
+ it('parses git@ SCP-style URLs and strips opencli-plugin- prefix', () => {
139
+ const result = _parseSource('git@github.com:user/opencli-plugin-awesome.git');
140
+ expect(result).toEqual({
141
+ type: 'git',
142
+ cloneUrl: 'git@github.com:user/opencli-plugin-awesome.git',
143
+ name: 'awesome',
144
+ });
145
+ });
146
+
147
+ it('parses generic HTTPS git URLs (non-GitHub)', () => {
148
+ const result = _parseSource('https://codehub.example.com/Team/App/opencli-plugins-app.git');
149
+ expect(result).toEqual({
150
+ type: 'git',
151
+ cloneUrl: 'https://codehub.example.com/Team/App/opencli-plugins-app.git',
152
+ name: 'opencli-plugins-app',
153
+ });
154
+ });
155
+
156
+ it('parses generic HTTPS git URLs without .git suffix', () => {
157
+ const result = _parseSource('https://gitlab.example.com/org/my-plugin');
158
+ expect(result).toEqual({
159
+ type: 'git',
160
+ cloneUrl: 'https://gitlab.example.com/org/my-plugin.git',
161
+ name: 'my-plugin',
162
+ });
163
+ });
164
+
165
+ it('still prefers GitHub shorthand over generic HTTPS for github.com', () => {
166
+ const result = _parseSource('https://github.com/user/repo');
167
+ // Should be handled by the GitHub-specific matcher (normalizes URL)
168
+ expect(result).toEqual({
169
+ type: 'git',
170
+ cloneUrl: 'https://github.com/user/repo.git',
171
+ name: 'repo',
172
+ });
173
+ });
58
174
  });
59
175
 
60
176
  describe('validatePluginStructure', () => {
@@ -119,40 +235,40 @@ describe('validatePluginStructure', () => {
119
235
  });
120
236
 
121
237
  describe('lock file', () => {
122
- const backupPath = `${LOCK_FILE}.test-backup`;
238
+ const backupPath = `${getLockFilePath()}.test-backup`;
123
239
  let hadOriginal = false;
124
240
 
125
241
  beforeEach(() => {
126
- hadOriginal = fs.existsSync(LOCK_FILE);
242
+ hadOriginal = fs.existsSync(getLockFilePath());
127
243
  if (hadOriginal) {
128
244
  fs.mkdirSync(path.dirname(backupPath), { recursive: true });
129
- fs.copyFileSync(LOCK_FILE, backupPath);
245
+ fs.copyFileSync(getLockFilePath(), backupPath);
130
246
  }
131
247
  });
132
248
 
133
249
  afterEach(() => {
134
250
  if (hadOriginal) {
135
- fs.copyFileSync(backupPath, LOCK_FILE);
251
+ fs.copyFileSync(backupPath, getLockFilePath());
136
252
  fs.unlinkSync(backupPath);
137
253
  return;
138
254
  }
139
- try { fs.unlinkSync(LOCK_FILE); } catch {}
255
+ try { fs.unlinkSync(getLockFilePath()); } catch {}
140
256
  });
141
257
 
142
258
  it('reads empty lock when file does not exist', () => {
143
- try { fs.unlinkSync(LOCK_FILE); } catch {}
259
+ try { fs.unlinkSync(getLockFilePath()); } catch {}
144
260
  expect(_readLockFile()).toEqual({});
145
261
  });
146
262
 
147
263
  it('round-trips lock entries', () => {
148
264
  const entries: Record<string, LockEntry> = {
149
265
  'test-plugin': {
150
- source: 'https://github.com/user/repo.git',
266
+ source: { kind: 'git', url: 'https://github.com/user/repo.git' },
151
267
  commitHash: 'abc1234567890def',
152
268
  installedAt: '2025-01-01T00:00:00.000Z',
153
269
  },
154
270
  'another-plugin': {
155
- source: 'https://github.com/user/another.git',
271
+ source: { kind: 'git', url: 'https://github.com/user/another.git' },
156
272
  commitHash: 'def4567890123abc',
157
273
  installedAt: '2025-02-01T00:00:00.000Z',
158
274
  updatedAt: '2025-03-01T00:00:00.000Z',
@@ -164,10 +280,105 @@ describe('lock file', () => {
164
280
  });
165
281
 
166
282
  it('handles malformed lock file gracefully', () => {
167
- fs.mkdirSync(path.dirname(LOCK_FILE), { recursive: true });
168
- fs.writeFileSync(LOCK_FILE, 'not valid json');
283
+ fs.mkdirSync(path.dirname(getLockFilePath()), { recursive: true });
284
+ fs.writeFileSync(getLockFilePath(), 'not valid json');
169
285
  expect(_readLockFile()).toEqual({});
170
286
  });
287
+
288
+ it('keeps the previous lockfile contents when atomic rewrite fails', () => {
289
+ const existing: Record<string, LockEntry> = {
290
+ stable: {
291
+ source: { kind: 'git', url: 'https://github.com/user/stable.git' },
292
+ commitHash: 'stable1234567890',
293
+ installedAt: '2025-01-01T00:00:00.000Z',
294
+ },
295
+ };
296
+ _writeLockFile(existing);
297
+
298
+ const renameSync = vi.fn(() => {
299
+ throw new Error('rename failed');
300
+ });
301
+ const rmSync = vi.fn(() => undefined);
302
+
303
+ expect(() => _writeLockFileWithFs({
304
+ broken: {
305
+ source: { kind: 'git', url: 'https://github.com/user/broken.git' },
306
+ commitHash: 'broken1234567890',
307
+ installedAt: '2025-02-01T00:00:00.000Z',
308
+ },
309
+ }, {
310
+ mkdirSync: fs.mkdirSync,
311
+ writeFileSync: fs.writeFileSync,
312
+ renameSync,
313
+ rmSync,
314
+ })).toThrow('rename failed');
315
+
316
+ expect(_readLockFile()).toEqual(existing);
317
+ expect(rmSync).toHaveBeenCalledTimes(1);
318
+ });
319
+
320
+ it('migrates legacy string sources to structured sources on read', () => {
321
+ const legacyLocalPath = path.resolve(path.join(os.tmpdir(), 'opencli-legacy-local-plugin'));
322
+ fs.mkdirSync(path.dirname(getLockFilePath()), { recursive: true });
323
+ fs.writeFileSync(getLockFilePath(), JSON.stringify({
324
+ alpha: {
325
+ source: 'https://github.com/user/opencli-plugins.git',
326
+ commitHash: 'abc1234567890def',
327
+ installedAt: '2025-01-01T00:00:00.000Z',
328
+ monorepo: { name: 'opencli-plugins', subPath: 'packages/alpha' },
329
+ },
330
+ beta: {
331
+ source: `local:${legacyLocalPath}`,
332
+ commitHash: 'local',
333
+ installedAt: '2025-01-01T00:00:00.000Z',
334
+ },
335
+ }, null, 2));
336
+
337
+ expect(_readLockFile()).toEqual({
338
+ alpha: {
339
+ source: {
340
+ kind: 'monorepo',
341
+ url: 'https://github.com/user/opencli-plugins.git',
342
+ repoName: 'opencli-plugins',
343
+ subPath: 'packages/alpha',
344
+ },
345
+ commitHash: 'abc1234567890def',
346
+ installedAt: '2025-01-01T00:00:00.000Z',
347
+ },
348
+ beta: {
349
+ source: { kind: 'local', path: legacyLocalPath },
350
+ commitHash: 'local',
351
+ installedAt: '2025-01-01T00:00:00.000Z',
352
+ },
353
+ });
354
+ });
355
+
356
+ it('returns normalized entries even when migration rewrite fails', () => {
357
+ fs.mkdirSync(path.dirname(getLockFilePath()), { recursive: true });
358
+ fs.writeFileSync(getLockFilePath(), JSON.stringify({
359
+ alpha: {
360
+ source: 'https://github.com/user/opencli-plugins.git',
361
+ commitHash: 'abc1234567890def',
362
+ installedAt: '2025-01-01T00:00:00.000Z',
363
+ monorepo: { name: 'opencli-plugins', subPath: 'packages/alpha' },
364
+ },
365
+ }, null, 2));
366
+
367
+ expect(_readLockFileWithWriter(() => {
368
+ throw new Error('disk full');
369
+ })).toEqual({
370
+ alpha: {
371
+ source: {
372
+ kind: 'monorepo',
373
+ url: 'https://github.com/user/opencli-plugins.git',
374
+ repoName: 'opencli-plugins',
375
+ subPath: 'packages/alpha',
376
+ },
377
+ commitHash: 'abc1234567890def',
378
+ installedAt: '2025-01-01T00:00:00.000Z',
379
+ },
380
+ });
381
+ });
171
382
  });
172
383
 
173
384
  describe('getCommitHash', () => {
@@ -188,7 +399,6 @@ describe('resolveEsbuildBin', () => {
188
399
  expect(binPath).not.toBeNull();
189
400
  expect(typeof binPath).toBe('string');
190
401
  expect(fs.existsSync(binPath!)).toBe(true);
191
- // On Windows the resolved path ends with 'esbuild.cmd', on Unix 'esbuild'
192
402
  expect(binPath).toMatch(/esbuild(\.cmd)?$/);
193
403
  });
194
404
  });
@@ -216,7 +426,7 @@ describe('listPlugins', () => {
216
426
 
217
427
  const lock = _readLockFile();
218
428
  lock['__test-list-plugin__'] = {
219
- source: 'https://github.com/user/repo.git',
429
+ source: { kind: 'git', url: 'https://github.com/user/repo.git' },
220
430
  commitHash: 'abcdef1234567890abcdef1234567890abcdef12',
221
431
  installedAt: '2025-01-01T00:00:00.000Z',
222
432
  };
@@ -233,10 +443,37 @@ describe('listPlugins', () => {
233
443
  });
234
444
 
235
445
  it('returns empty array when no plugins dir', () => {
236
- // listPlugins should handle missing dir gracefully
237
446
  const plugins = listPlugins();
238
447
  expect(Array.isArray(plugins)).toBe(true);
239
448
  });
449
+
450
+ it('prefers lockfile source for local symlink plugins', () => {
451
+ const localTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-local-list-'));
452
+ const linkPath = path.join(PLUGINS_DIR, '__test-list-plugin__');
453
+
454
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
455
+ fs.writeFileSync(path.join(localTarget, 'hello.yaml'), 'site: test\nname: hello\n');
456
+ try { fs.unlinkSync(linkPath); } catch {}
457
+ try { fs.rmSync(linkPath, { recursive: true, force: true }); } catch {}
458
+ fs.symlinkSync(localTarget, linkPath, 'dir');
459
+
460
+ const lock = _readLockFile();
461
+ lock['__test-list-plugin__'] = {
462
+ source: { kind: 'local', path: localTarget },
463
+ commitHash: 'local',
464
+ installedAt: '2025-01-01T00:00:00.000Z',
465
+ };
466
+ _writeLockFile(lock);
467
+
468
+ const plugins = listPlugins();
469
+ const found = plugins.find(p => p.name === '__test-list-plugin__');
470
+ expect(found?.source).toBe(`local:${localTarget}`);
471
+
472
+ try { fs.unlinkSync(linkPath); } catch {}
473
+ try { fs.rmSync(localTarget, { recursive: true, force: true }); } catch {}
474
+ delete lock['__test-list-plugin__'];
475
+ _writeLockFile(lock);
476
+ });
240
477
  });
241
478
 
242
479
  describe('uninstallPlugin', () => {
@@ -260,7 +497,7 @@ describe('uninstallPlugin', () => {
260
497
 
261
498
  const lock = _readLockFile();
262
499
  lock['__test-uninstall__'] = {
263
- source: 'https://github.com/user/repo.git',
500
+ source: { kind: 'git', url: 'https://github.com/user/repo.git' },
264
501
  commitHash: 'abc123',
265
502
  installedAt: '2025-01-01T00:00:00.000Z',
266
503
  };
@@ -279,11 +516,50 @@ describe('updatePlugin', () => {
279
516
  it('throws for non-existent plugin', () => {
280
517
  expect(() => updatePlugin('__nonexistent__')).toThrow('not installed');
281
518
  });
519
+
520
+ it('refreshes local plugins without running git pull', () => {
521
+ const localTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-local-update-'));
522
+ const linkPath = path.join(PLUGINS_DIR, '__test-local-update__');
523
+
524
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
525
+ fs.writeFileSync(path.join(localTarget, 'hello.yaml'), 'site: test\nname: hello\n');
526
+ fs.symlinkSync(localTarget, linkPath, 'dir');
527
+
528
+ const lock = _readLockFile();
529
+ lock['__test-local-update__'] = {
530
+ source: { kind: 'local', path: localTarget },
531
+ commitHash: 'local',
532
+ installedAt: '2025-01-01T00:00:00.000Z',
533
+ };
534
+ _writeLockFile(lock);
535
+
536
+ mockExecFileSync.mockClear();
537
+ updatePlugin('__test-local-update__');
538
+
539
+ expect(
540
+ mockExecFileSync.mock.calls.some(
541
+ ([cmd, args, opts]) => cmd === 'git'
542
+ && Array.isArray(args)
543
+ && args[0] === 'pull'
544
+ && opts?.cwd === linkPath,
545
+ ),
546
+ ).toBe(false);
547
+
548
+ const updated = _readLockFile()['__test-local-update__'];
549
+ expect(updated?.source).toEqual({ kind: 'local', path: path.resolve(localTarget) });
550
+ expect(updated?.updatedAt).toBeDefined();
551
+
552
+ try { fs.unlinkSync(linkPath); } catch {}
553
+ try { fs.rmSync(localTarget, { recursive: true, force: true }); } catch {}
554
+ const finalLock = _readLockFile();
555
+ delete finalLock['__test-local-update__'];
556
+ _writeLockFile(finalLock);
557
+ });
282
558
  });
283
559
 
284
560
  vi.mock('node:child_process', () => {
285
561
  return {
286
- execFileSync: vi.fn((_cmd, args, opts) => {
562
+ execFileSync: mockExecFileSync.mockImplementation((_cmd, args, opts) => {
287
563
  if (Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
288
564
  if (opts?.cwd === os.tmpdir()) {
289
565
  throw new Error('not a git repository');
@@ -295,10 +571,63 @@ vi.mock('node:child_process', () => {
295
571
  }
296
572
  return '';
297
573
  }),
298
- execSync: vi.fn(() => ''),
574
+ execSync: mockExecSync.mockImplementation(() => ''),
299
575
  };
300
576
  });
301
577
 
578
+ describe('installDependencies', () => {
579
+ beforeEach(() => {
580
+ mockExecFileSync.mockClear();
581
+ mockExecSync.mockClear();
582
+ });
583
+
584
+ it('throws when npm install fails', () => {
585
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-plugin-b-'));
586
+ const failingDir = path.join(tmpDir, 'plugin-b');
587
+ fs.mkdirSync(failingDir, { recursive: true });
588
+ fs.writeFileSync(path.join(failingDir, 'package.json'), JSON.stringify({ name: 'plugin-b' }));
589
+
590
+ expect(() => _installDependencies(failingDir)).toThrow('npm install failed');
591
+
592
+ fs.rmSync(tmpDir, { recursive: true, force: true });
593
+ });
594
+ });
595
+
596
+ describe('postInstallMonorepoLifecycle', () => {
597
+ let repoDir: string;
598
+ let subDir: string;
599
+
600
+ beforeEach(() => {
601
+ mockExecFileSync.mockClear();
602
+ mockExecSync.mockClear();
603
+ repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-monorepo-'));
604
+ subDir = path.join(repoDir, 'packages', 'alpha');
605
+ fs.mkdirSync(subDir, { recursive: true });
606
+ fs.writeFileSync(path.join(repoDir, 'package.json'), JSON.stringify({
607
+ name: 'opencli-plugins',
608
+ private: true,
609
+ workspaces: ['packages/*'],
610
+ }));
611
+ fs.writeFileSync(path.join(subDir, 'hello.yaml'), 'site: test\nname: hello\n');
612
+ });
613
+
614
+ afterEach(() => {
615
+ fs.rmSync(repoDir, { recursive: true, force: true });
616
+ });
617
+
618
+ it('installs dependencies once at the monorepo root, not in each sub-plugin', () => {
619
+ _postInstallMonorepoLifecycle(repoDir, [subDir]);
620
+
621
+ const npmCalls = mockExecFileSync.mock.calls.filter(
622
+ ([cmd, args]) => cmd === 'npm' && Array.isArray(args) && args[0] === 'install',
623
+ );
624
+
625
+ expect(npmCalls).toHaveLength(1);
626
+ expect(npmCalls[0][2]).toMatchObject({ cwd: repoDir });
627
+ expect(npmCalls.some(([, , opts]) => opts?.cwd === subDir)).toBe(false);
628
+ });
629
+ });
630
+
302
631
  describe('updateAllPlugins', () => {
303
632
  const testDirA = path.join(PLUGINS_DIR, 'plugin-a');
304
633
  const testDirB = path.join(PLUGINS_DIR, 'plugin-b');
@@ -311,16 +640,59 @@ describe('updateAllPlugins', () => {
311
640
  fs.writeFileSync(path.join(testDirA, 'cmd.yaml'), 'site: a');
312
641
  fs.writeFileSync(path.join(testDirB, 'cmd.yaml'), 'site: b');
313
642
  fs.writeFileSync(path.join(testDirC, 'cmd.yaml'), 'site: c');
643
+
644
+ const lock = _readLockFile();
645
+ lock['plugin-a'] = {
646
+ source: { kind: 'git', url: 'https://github.com/user/plugin-a.git' },
647
+ commitHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
648
+ installedAt: '2025-01-01T00:00:00.000Z',
649
+ };
650
+ lock['plugin-b'] = {
651
+ source: { kind: 'git', url: 'https://github.com/user/plugin-b.git' },
652
+ commitHash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
653
+ installedAt: '2025-01-01T00:00:00.000Z',
654
+ };
655
+ lock['plugin-c'] = {
656
+ source: { kind: 'git', url: 'https://github.com/user/plugin-c.git' },
657
+ commitHash: 'cccccccccccccccccccccccccccccccccccccccc',
658
+ installedAt: '2025-01-01T00:00:00.000Z',
659
+ };
660
+ _writeLockFile(lock);
314
661
  });
315
662
 
316
663
  afterEach(() => {
317
664
  try { fs.rmSync(testDirA, { recursive: true }); } catch {}
318
665
  try { fs.rmSync(testDirB, { recursive: true }); } catch {}
319
666
  try { fs.rmSync(testDirC, { recursive: true }); } catch {}
667
+ const lock = _readLockFile();
668
+ delete lock['plugin-a'];
669
+ delete lock['plugin-b'];
670
+ delete lock['plugin-c'];
671
+ _writeLockFile(lock);
320
672
  vi.clearAllMocks();
321
673
  });
322
674
 
323
675
  it('collects successes and failures without throwing', () => {
676
+ mockExecFileSync.mockImplementation((cmd, args) => {
677
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
678
+ const cloneUrl = String(args[3]);
679
+ const cloneDir = String(args[4]);
680
+ fs.mkdirSync(cloneDir, { recursive: true });
681
+ fs.writeFileSync(path.join(cloneDir, 'cmd.yaml'), 'site: test\nname: hello\n');
682
+ if (cloneUrl.includes('plugin-b')) {
683
+ fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ name: 'plugin-b' }));
684
+ }
685
+ return '';
686
+ }
687
+ if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
688
+ throw new Error('Network error');
689
+ }
690
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
691
+ return '1234567890abcdef1234567890abcdef12345678\n';
692
+ }
693
+ return '';
694
+ });
695
+
324
696
  const results = _updateAllPlugins();
325
697
 
326
698
  const resA = results.find(r => r.name === 'plugin-a');
@@ -338,3 +710,718 @@ describe('updateAllPlugins', () => {
338
710
  expect(resC!.success).toBe(true);
339
711
  });
340
712
  });
713
+
714
+ // ── Monorepo-specific tests ─────────────────────────────────────────────────
715
+
716
+ describe('parseSource with monorepo subplugin', () => {
717
+ it('parses github:user/repo/subplugin format', () => {
718
+ const result = _parseSource('github:ByteYue/opencli-plugins/polymarket');
719
+ expect(result).toEqual({
720
+ type: 'git',
721
+ cloneUrl: 'https://github.com/ByteYue/opencli-plugins.git',
722
+ name: 'opencli-plugins',
723
+ subPlugin: 'polymarket',
724
+ });
725
+ });
726
+
727
+ it('strips opencli-plugin- prefix from repo name in subplugin format', () => {
728
+ const result = _parseSource('github:user/opencli-plugin-collection/defi');
729
+ expect(result!.name).toBe('collection');
730
+ expect(result!.subPlugin).toBe('defi');
731
+ });
732
+
733
+ it('still parses github:user/repo without subplugin', () => {
734
+ const result = _parseSource('github:user/my-repo');
735
+ expect(result).toEqual({
736
+ type: 'git',
737
+ cloneUrl: 'https://github.com/user/my-repo.git',
738
+ name: 'my-repo',
739
+ });
740
+ expect(result!.subPlugin).toBeUndefined();
741
+ });
742
+ });
743
+
744
+ describe('isSymlinkSync', () => {
745
+ let tmpDir: string;
746
+
747
+ beforeEach(() => {
748
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-symlink-test-'));
749
+ });
750
+
751
+ afterEach(() => {
752
+ fs.rmSync(tmpDir, { recursive: true, force: true });
753
+ });
754
+
755
+ it('returns false for a regular directory', () => {
756
+ const dir = path.join(tmpDir, 'regular');
757
+ fs.mkdirSync(dir);
758
+ expect(_isSymlinkSync(dir)).toBe(false);
759
+ });
760
+
761
+ it('returns true for a symlink', () => {
762
+ const target = path.join(tmpDir, 'target');
763
+ const link = path.join(tmpDir, 'link');
764
+ fs.mkdirSync(target);
765
+ fs.symlinkSync(target, link, 'dir');
766
+ expect(_isSymlinkSync(link)).toBe(true);
767
+ });
768
+
769
+ it('returns false for non-existent path', () => {
770
+ expect(_isSymlinkSync(path.join(tmpDir, 'nope'))).toBe(false);
771
+ });
772
+ });
773
+
774
+ describe('monorepo uninstall with symlink', () => {
775
+ let tmpDir: string;
776
+ let pluginDir: string;
777
+ let monoDir: string;
778
+
779
+ beforeEach(() => {
780
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-mono-uninstall-'));
781
+ pluginDir = path.join(PLUGINS_DIR, '__test-mono-sub__');
782
+ monoDir = path.join(_getMonoreposDir(), '__test-mono__');
783
+
784
+ const subDir = path.join(monoDir, 'packages', 'sub');
785
+ fs.mkdirSync(subDir, { recursive: true });
786
+ fs.writeFileSync(path.join(subDir, 'cmd.yaml'), 'site: test');
787
+
788
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
789
+ fs.symlinkSync(subDir, pluginDir, 'dir');
790
+
791
+ const lock = _readLockFile();
792
+ lock['__test-mono-sub__'] = {
793
+ source: {
794
+ kind: 'monorepo',
795
+ url: 'https://github.com/user/test.git',
796
+ repoName: '__test-mono__',
797
+ subPath: 'packages/sub',
798
+ },
799
+ commitHash: 'abc123',
800
+ installedAt: '2025-01-01T00:00:00.000Z',
801
+ };
802
+ _writeLockFile(lock);
803
+ });
804
+
805
+ afterEach(() => {
806
+ try { fs.unlinkSync(pluginDir); } catch {}
807
+ try { fs.rmSync(pluginDir, { recursive: true, force: true }); } catch {}
808
+ try { fs.rmSync(monoDir, { recursive: true, force: true }); } catch {}
809
+ const lock = _readLockFile();
810
+ delete lock['__test-mono-sub__'];
811
+ _writeLockFile(lock);
812
+ });
813
+
814
+ it('removes symlink but keeps monorepo if other sub-plugins reference it', () => {
815
+ const lock = _readLockFile();
816
+ lock['__test-mono-other__'] = {
817
+ source: {
818
+ kind: 'monorepo',
819
+ url: 'https://github.com/user/test.git',
820
+ repoName: '__test-mono__',
821
+ subPath: 'packages/other',
822
+ },
823
+ commitHash: 'abc123',
824
+ installedAt: '2025-01-01T00:00:00.000Z',
825
+ };
826
+ _writeLockFile(lock);
827
+
828
+ uninstallPlugin('__test-mono-sub__');
829
+
830
+ expect(fs.existsSync(pluginDir)).toBe(false);
831
+ expect(fs.existsSync(monoDir)).toBe(true);
832
+ expect(_readLockFile()['__test-mono-sub__']).toBeUndefined();
833
+ expect(_readLockFile()['__test-mono-other__']).toBeDefined();
834
+
835
+ const finalLock = _readLockFile();
836
+ delete finalLock['__test-mono-other__'];
837
+ _writeLockFile(finalLock);
838
+ });
839
+
840
+ it('removes symlink AND monorepo dir when last sub-plugin is uninstalled', () => {
841
+ uninstallPlugin('__test-mono-sub__');
842
+
843
+ expect(fs.existsSync(pluginDir)).toBe(false);
844
+ expect(fs.existsSync(monoDir)).toBe(false);
845
+ expect(_readLockFile()['__test-mono-sub__']).toBeUndefined();
846
+ });
847
+ });
848
+
849
+ describe('listPlugins with monorepo metadata', () => {
850
+ const testSymlinkTarget = path.join(os.tmpdir(), 'opencli-list-mono-target');
851
+ const testLink = path.join(PLUGINS_DIR, '__test-mono-list__');
852
+
853
+ beforeEach(() => {
854
+ fs.mkdirSync(testSymlinkTarget, { recursive: true });
855
+ fs.writeFileSync(path.join(testSymlinkTarget, 'hello.yaml'), 'site: test\nname: hello\n');
856
+
857
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
858
+ try { fs.unlinkSync(testLink); } catch {}
859
+ fs.symlinkSync(testSymlinkTarget, testLink, 'dir');
860
+
861
+ const lock = _readLockFile();
862
+ lock['__test-mono-list__'] = {
863
+ source: {
864
+ kind: 'monorepo',
865
+ url: 'https://github.com/user/test-mono.git',
866
+ repoName: 'test-mono',
867
+ subPath: 'packages/list',
868
+ },
869
+ commitHash: 'def456def456def456def456def456def456def4',
870
+ installedAt: '2025-01-01T00:00:00.000Z',
871
+ };
872
+ _writeLockFile(lock);
873
+ });
874
+
875
+ afterEach(() => {
876
+ try { fs.unlinkSync(testLink); } catch {}
877
+ try { fs.rmSync(testSymlinkTarget, { recursive: true, force: true }); } catch {}
878
+ const lock = _readLockFile();
879
+ delete lock['__test-mono-list__'];
880
+ _writeLockFile(lock);
881
+ });
882
+
883
+ it('lists symlinked plugins with monorepoName', () => {
884
+ const plugins = listPlugins();
885
+ const found = plugins.find(p => p.name === '__test-mono-list__');
886
+ expect(found).toBeDefined();
887
+ expect(found!.monorepoName).toBe('test-mono');
888
+ expect(found!.commands).toContain('hello');
889
+ expect(found!.source).toBe('https://github.com/user/test-mono.git');
890
+ });
891
+ });
892
+
893
+ describe('installLocalPlugin', () => {
894
+ let tmpDir: string;
895
+ const pluginName = '__test-local-plugin__';
896
+
897
+ beforeEach(() => {
898
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-local-install-'));
899
+ fs.writeFileSync(path.join(tmpDir, 'hello.yaml'), 'site: test\nname: hello\n');
900
+ });
901
+
902
+ afterEach(() => {
903
+ const linkPath = path.join(PLUGINS_DIR, pluginName);
904
+ try { fs.unlinkSync(linkPath); } catch {}
905
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
906
+ const lock = _readLockFile();
907
+ delete lock[pluginName];
908
+ _writeLockFile(lock);
909
+ });
910
+
911
+ it('creates a symlink to the local directory', () => {
912
+ const result = _installLocalPlugin(tmpDir, pluginName);
913
+ expect(result).toBe(pluginName);
914
+ const linkPath = path.join(PLUGINS_DIR, pluginName);
915
+ expect(fs.existsSync(linkPath)).toBe(true);
916
+ expect(_isSymlinkSync(linkPath)).toBe(true);
917
+ });
918
+
919
+ it('records local: source in lockfile', () => {
920
+ _installLocalPlugin(tmpDir, pluginName);
921
+ const lock = _readLockFile();
922
+ expect(lock[pluginName]).toBeDefined();
923
+ expect(lock[pluginName].source).toEqual({ kind: 'local', path: path.resolve(tmpDir) });
924
+ });
925
+
926
+ it('lists the recorded local source', () => {
927
+ _installLocalPlugin(tmpDir, pluginName);
928
+ const plugins = listPlugins();
929
+ const found = plugins.find(p => p.name === pluginName);
930
+ expect(found).toBeDefined();
931
+ expect(found!.source).toBe(`local:${path.resolve(tmpDir)}`);
932
+ });
933
+
934
+ it('throws for non-existent path', () => {
935
+ expect(() => _installLocalPlugin('/does/not/exist', 'x')).toThrow('does not exist');
936
+ });
937
+ });
938
+
939
+ describe('isLocalPluginSource', () => {
940
+ it('detects lockfile local sources', () => {
941
+ expect(_isLocalPluginSource('local:/tmp/plugin')).toBe(true);
942
+ expect(_isLocalPluginSource('https://github.com/user/repo.git')).toBe(false);
943
+ expect(_isLocalPluginSource(undefined)).toBe(false);
944
+ });
945
+ });
946
+
947
+ describe('plugin source helpers', () => {
948
+ it('formats local plugin sources consistently', () => {
949
+ const dir = path.join(os.tmpdir(), 'opencli-plugin-source');
950
+ expect(_toLocalPluginSource(dir)).toBe(`local:${path.resolve(dir)}`);
951
+ });
952
+
953
+ it('serializes structured local sources consistently', () => {
954
+ const dir = path.join(os.tmpdir(), 'opencli-plugin-source');
955
+ expect(_toStoredPluginSource({ kind: 'local', path: dir })).toBe(`local:${path.resolve(dir)}`);
956
+ });
957
+
958
+ it('prefers lockfile source over git remote lookup', () => {
959
+ const dir = path.join(os.tmpdir(), 'opencli-plugin-source');
960
+ const localPath = path.resolve(path.join(os.tmpdir(), 'opencli-plugin-source-local'));
961
+ const source = _resolveStoredPluginSource({
962
+ source: { kind: 'local', path: localPath },
963
+ commitHash: 'local',
964
+ installedAt: '2025-01-01T00:00:00.000Z',
965
+ }, dir);
966
+ expect(source).toBe(`local:${localPath}`);
967
+ });
968
+
969
+ it('returns structured monorepo sources unchanged', () => {
970
+ const dir = path.join(os.tmpdir(), 'opencli-plugin-source');
971
+ const source = _resolvePluginSource({
972
+ source: {
973
+ kind: 'monorepo',
974
+ url: 'https://github.com/user/opencli-plugins.git',
975
+ repoName: 'opencli-plugins',
976
+ subPath: 'packages/alpha',
977
+ },
978
+ commitHash: 'abcdef1234567890abcdef1234567890abcdef12',
979
+ installedAt: '2025-01-01T00:00:00.000Z',
980
+ }, dir);
981
+ expect(source).toEqual({
982
+ kind: 'monorepo',
983
+ url: 'https://github.com/user/opencli-plugins.git',
984
+ repoName: 'opencli-plugins',
985
+ subPath: 'packages/alpha',
986
+ });
987
+ });
988
+ });
989
+
990
+ describe('moveDir', () => {
991
+ it('cleans up destination when EXDEV fallback copy fails', () => {
992
+ const src = path.join(os.tmpdir(), 'opencli-move-src');
993
+ const dest = path.join(os.tmpdir(), 'opencli-move-dest');
994
+ const renameErr = Object.assign(new Error('cross-device link not permitted'), { code: 'EXDEV' });
995
+ const copyErr = new Error('copy failed');
996
+ const renameSync = vi.fn(() => { throw renameErr; });
997
+ const cpSync = vi.fn(() => { throw copyErr; });
998
+ const rmSync = vi.fn(() => undefined);
999
+
1000
+ expect(() => _moveDir(src, dest, { renameSync, cpSync, rmSync })).toThrow(copyErr);
1001
+ expect(renameSync).toHaveBeenCalledWith(src, dest);
1002
+ expect(cpSync).toHaveBeenCalledWith(src, dest, { recursive: true });
1003
+ expect(rmSync).toHaveBeenCalledWith(dest, { recursive: true, force: true });
1004
+ });
1005
+ });
1006
+
1007
+ describe('promoteDir', () => {
1008
+ it('cleans up temporary publish dir when final rename fails', () => {
1009
+ const staging = path.join(os.tmpdir(), 'opencli-promote-stage');
1010
+ const dest = path.join(os.tmpdir(), 'opencli-promote-dest');
1011
+ const publishErr = new Error('publish failed');
1012
+ const existsSync = vi.fn(() => false);
1013
+ const mkdirSync = vi.fn(() => undefined);
1014
+ const cpSync = vi.fn(() => undefined);
1015
+ const rmSync = vi.fn(() => undefined);
1016
+ const renameSync = vi.fn((src, _target) => {
1017
+ if (String(src) === staging) return;
1018
+ throw publishErr;
1019
+ });
1020
+
1021
+ expect(() => _promoteDir(staging, dest, { existsSync, mkdirSync, renameSync, cpSync, rmSync })).toThrow(publishErr);
1022
+
1023
+ const tempDest = renameSync.mock.calls[0][1];
1024
+ expect(renameSync).toHaveBeenNthCalledWith(1, staging, tempDest);
1025
+ expect(renameSync).toHaveBeenNthCalledWith(2, tempDest, dest);
1026
+ expect(rmSync).toHaveBeenCalledWith(tempDest, { recursive: true, force: true });
1027
+ });
1028
+ });
1029
+
1030
+ describe('replaceDir', () => {
1031
+ it('rolls back the original destination when swap fails', () => {
1032
+ const staging = path.join(os.tmpdir(), 'opencli-replace-stage');
1033
+ const dest = path.join(os.tmpdir(), 'opencli-replace-dest');
1034
+ const publishErr = new Error('swap failed');
1035
+ const existingPaths = new Set([dest]);
1036
+ const existsSync = vi.fn((p) => existingPaths.has(String(p)));
1037
+ const mkdirSync = vi.fn(() => undefined);
1038
+ const cpSync = vi.fn(() => undefined);
1039
+ const rmSync = vi.fn(() => undefined);
1040
+ const renameSync = vi.fn((src, target) => {
1041
+ if (String(src) === staging) {
1042
+ existingPaths.add(String(target));
1043
+ return;
1044
+ }
1045
+ if (String(src) === dest) {
1046
+ existingPaths.delete(dest);
1047
+ existingPaths.add(String(target));
1048
+ return;
1049
+ }
1050
+ if (String(target) === dest) throw publishErr;
1051
+ if (existingPaths.has(String(src))) {
1052
+ existingPaths.delete(String(src));
1053
+ existingPaths.add(String(target));
1054
+ }
1055
+ });
1056
+
1057
+ expect(() => _replaceDir(staging, dest, { existsSync, mkdirSync, renameSync, cpSync, rmSync })).toThrow(publishErr);
1058
+
1059
+ const tempDest = renameSync.mock.calls[0][1];
1060
+ const backupDest = renameSync.mock.calls[1][1];
1061
+ expect(renameSync).toHaveBeenNthCalledWith(1, staging, tempDest);
1062
+ expect(renameSync).toHaveBeenNthCalledWith(2, dest, backupDest);
1063
+ expect(renameSync).toHaveBeenNthCalledWith(3, tempDest, dest);
1064
+ expect(renameSync).toHaveBeenNthCalledWith(4, backupDest, dest);
1065
+ expect(rmSync).toHaveBeenCalledWith(tempDest, { recursive: true, force: true });
1066
+ });
1067
+ });
1068
+
1069
+ describe('installPlugin transactional staging', () => {
1070
+ const standaloneSource = 'github:user/opencli-plugin-__test-transactional-standalone__';
1071
+ const standaloneName = '__test-transactional-standalone__';
1072
+ const standaloneDir = path.join(PLUGINS_DIR, standaloneName);
1073
+ const monorepoSource = 'github:user/opencli-plugins-__test-transactional__';
1074
+ const monorepoRepoDir = path.join(_getMonoreposDir(), 'opencli-plugins-__test-transactional__');
1075
+ const monorepoLink = path.join(PLUGINS_DIR, 'alpha');
1076
+
1077
+ beforeEach(() => {
1078
+ mockExecFileSync.mockClear();
1079
+ mockExecSync.mockClear();
1080
+ });
1081
+
1082
+ afterEach(() => {
1083
+ try { fs.unlinkSync(monorepoLink); } catch {}
1084
+ try { fs.rmSync(monorepoLink, { recursive: true, force: true }); } catch {}
1085
+ try { fs.rmSync(monorepoRepoDir, { recursive: true, force: true }); } catch {}
1086
+ try { fs.rmSync(standaloneDir, { recursive: true, force: true }); } catch {}
1087
+ const lock = _readLockFile();
1088
+ delete lock[standaloneName];
1089
+ delete lock.alpha;
1090
+ _writeLockFile(lock);
1091
+ vi.clearAllMocks();
1092
+ });
1093
+
1094
+ it('does not expose the final standalone plugin dir when lifecycle fails in staging', () => {
1095
+ mockExecFileSync.mockImplementation((cmd, args) => {
1096
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1097
+ const cloneDir = String(args[args.length - 1]);
1098
+ fs.mkdirSync(cloneDir, { recursive: true });
1099
+ fs.writeFileSync(path.join(cloneDir, 'hello.yaml'), 'site: test\nname: hello\n');
1100
+ fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ name: standaloneName }));
1101
+ return '';
1102
+ }
1103
+ if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
1104
+ throw new Error('boom');
1105
+ }
1106
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1107
+ return '1234567890abcdef1234567890abcdef12345678\n';
1108
+ }
1109
+ return '';
1110
+ });
1111
+
1112
+ expect(() => installPlugin(standaloneSource)).toThrow(`npm install failed`);
1113
+ expect(fs.existsSync(standaloneDir)).toBe(false);
1114
+ expect(_readLockFile()[standaloneName]).toBeUndefined();
1115
+ });
1116
+
1117
+ it('does not expose monorepo links or repo dir when lifecycle fails in staging', () => {
1118
+ mockExecFileSync.mockImplementation((cmd, args) => {
1119
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1120
+ const cloneDir = String(args[args.length - 1]);
1121
+ const alphaDir = path.join(cloneDir, 'packages', 'alpha');
1122
+ fs.mkdirSync(alphaDir, { recursive: true });
1123
+ fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({
1124
+ name: 'opencli-plugins-__test-transactional__',
1125
+ private: true,
1126
+ }));
1127
+ fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
1128
+ plugins: {
1129
+ alpha: { path: 'packages/alpha' },
1130
+ },
1131
+ }));
1132
+ fs.writeFileSync(path.join(alphaDir, 'hello.yaml'), 'site: test\nname: hello\n');
1133
+ return '';
1134
+ }
1135
+ if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
1136
+ throw new Error('boom');
1137
+ }
1138
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1139
+ return '1234567890abcdef1234567890abcdef12345678\n';
1140
+ }
1141
+ return '';
1142
+ });
1143
+
1144
+ expect(() => installPlugin(monorepoSource)).toThrow(`npm install failed`);
1145
+ expect(fs.existsSync(monorepoRepoDir)).toBe(false);
1146
+ expect(fs.existsSync(monorepoLink)).toBe(false);
1147
+ expect(_readLockFile().alpha).toBeUndefined();
1148
+ });
1149
+ });
1150
+
1151
+ describe('installPlugin with existing monorepo', () => {
1152
+ const repoName = '__test-existing-monorepo__';
1153
+ const repoDir = path.join(_getMonoreposDir(), repoName);
1154
+ const pluginName = 'beta';
1155
+ const pluginLink = path.join(PLUGINS_DIR, pluginName);
1156
+
1157
+ beforeEach(() => {
1158
+ mockExecFileSync.mockClear();
1159
+ mockExecSync.mockClear();
1160
+ });
1161
+
1162
+ afterEach(() => {
1163
+ try { fs.unlinkSync(pluginLink); } catch {}
1164
+ try { fs.rmSync(pluginLink, { recursive: true, force: true }); } catch {}
1165
+ try { fs.rmSync(repoDir, { recursive: true, force: true }); } catch {}
1166
+ const lock = _readLockFile();
1167
+ delete lock[pluginName];
1168
+ _writeLockFile(lock);
1169
+ vi.clearAllMocks();
1170
+ });
1171
+
1172
+ it('reinstalls root dependencies when adding a sub-plugin from an existing monorepo', () => {
1173
+ const subDir = path.join(repoDir, 'packages', pluginName);
1174
+ fs.mkdirSync(subDir, { recursive: true });
1175
+ fs.writeFileSync(path.join(repoDir, 'package.json'), JSON.stringify({
1176
+ name: repoName,
1177
+ private: true,
1178
+ workspaces: ['packages/*'],
1179
+ }));
1180
+ fs.writeFileSync(path.join(repoDir, 'opencli-plugin.json'), JSON.stringify({
1181
+ plugins: {
1182
+ [pluginName]: { path: `packages/${pluginName}` },
1183
+ },
1184
+ }));
1185
+ fs.writeFileSync(path.join(subDir, 'hello.yaml'), 'site: test\nname: hello\n');
1186
+
1187
+ mockExecFileSync.mockImplementation((cmd, args) => {
1188
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1189
+ const cloneDir = String(args[4]);
1190
+ fs.mkdirSync(cloneDir, { recursive: true });
1191
+ fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
1192
+ plugins: {
1193
+ [pluginName]: { path: `packages/${pluginName}` },
1194
+ },
1195
+ }));
1196
+ return '';
1197
+ }
1198
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1199
+ return '1234567890abcdef1234567890abcdef12345678\n';
1200
+ }
1201
+ return '';
1202
+ });
1203
+
1204
+ installPlugin(`github:user/${repoName}/${pluginName}`);
1205
+
1206
+ const npmCalls = mockExecFileSync.mock.calls.filter(
1207
+ ([cmd, args]) => cmd === 'npm' && Array.isArray(args) && args[0] === 'install',
1208
+ );
1209
+ expect(npmCalls.some(([, , opts]) => opts?.cwd === repoDir)).toBe(true);
1210
+ expect(fs.realpathSync(pluginLink)).toBe(fs.realpathSync(subDir));
1211
+ });
1212
+ });
1213
+
1214
+ describe('updatePlugin transactional staging', () => {
1215
+ const standaloneName = '__test-transactional-update__';
1216
+ const standaloneDir = path.join(PLUGINS_DIR, standaloneName);
1217
+ const monorepoName = '__test-transactional-mono-update__';
1218
+ const monorepoRepoDir = path.join(_getMonoreposDir(), monorepoName);
1219
+ const monorepoPluginName = 'alpha-update';
1220
+ const monorepoLink = path.join(PLUGINS_DIR, monorepoPluginName);
1221
+
1222
+ beforeEach(() => {
1223
+ mockExecFileSync.mockClear();
1224
+ mockExecSync.mockClear();
1225
+ });
1226
+
1227
+ afterEach(() => {
1228
+ try { fs.unlinkSync(monorepoLink); } catch {}
1229
+ try { fs.rmSync(monorepoLink, { recursive: true, force: true }); } catch {}
1230
+ try { fs.rmSync(monorepoRepoDir, { recursive: true, force: true }); } catch {}
1231
+ try { fs.rmSync(standaloneDir, { recursive: true, force: true }); } catch {}
1232
+ const lock = _readLockFile();
1233
+ delete lock[standaloneName];
1234
+ delete lock[monorepoPluginName];
1235
+ _writeLockFile(lock);
1236
+ vi.clearAllMocks();
1237
+ });
1238
+
1239
+ it('keeps the existing standalone plugin when staged update preparation fails', () => {
1240
+ fs.mkdirSync(standaloneDir, { recursive: true });
1241
+ fs.writeFileSync(path.join(standaloneDir, 'old.yaml'), 'site: old\nname: old\n');
1242
+
1243
+ const lock = _readLockFile();
1244
+ lock[standaloneName] = {
1245
+ source: {
1246
+ kind: 'git',
1247
+ url: 'https://github.com/user/opencli-plugin-__test-transactional-update__.git',
1248
+ },
1249
+ commitHash: 'oldhasholdhasholdhasholdhasholdhasholdh',
1250
+ installedAt: '2025-01-01T00:00:00.000Z',
1251
+ };
1252
+ _writeLockFile(lock);
1253
+
1254
+ mockExecFileSync.mockImplementation((cmd, args) => {
1255
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1256
+ const cloneDir = String(args[4]);
1257
+ fs.mkdirSync(cloneDir, { recursive: true });
1258
+ fs.writeFileSync(path.join(cloneDir, 'hello.yaml'), 'site: test\nname: hello\n');
1259
+ fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ name: standaloneName }));
1260
+ return '';
1261
+ }
1262
+ if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
1263
+ throw new Error('boom');
1264
+ }
1265
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1266
+ return '1234567890abcdef1234567890abcdef12345678\n';
1267
+ }
1268
+ return '';
1269
+ });
1270
+
1271
+ expect(() => updatePlugin(standaloneName)).toThrow('npm install failed');
1272
+ expect(fs.existsSync(standaloneDir)).toBe(true);
1273
+ expect(fs.readFileSync(path.join(standaloneDir, 'old.yaml'), 'utf-8')).toContain('site: old');
1274
+ expect(_readLockFile()[standaloneName]?.commitHash).toBe('oldhasholdhasholdhasholdhasholdhasholdh');
1275
+ });
1276
+
1277
+ it('keeps the existing monorepo repo and link when staged update preparation fails', () => {
1278
+ const subDir = path.join(monorepoRepoDir, 'packages', monorepoPluginName);
1279
+ fs.mkdirSync(subDir, { recursive: true });
1280
+ fs.writeFileSync(path.join(subDir, 'old.yaml'), 'site: old\nname: old\n');
1281
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
1282
+ fs.symlinkSync(subDir, monorepoLink, 'dir');
1283
+
1284
+ const lock = _readLockFile();
1285
+ lock[monorepoPluginName] = {
1286
+ source: {
1287
+ kind: 'monorepo',
1288
+ url: 'https://github.com/user/opencli-plugins-__test-transactional-mono-update__.git',
1289
+ repoName: monorepoName,
1290
+ subPath: `packages/${monorepoPluginName}`,
1291
+ },
1292
+ commitHash: 'oldmonooldmonooldmonooldmonooldmonoold',
1293
+ installedAt: '2025-01-01T00:00:00.000Z',
1294
+ };
1295
+ _writeLockFile(lock);
1296
+
1297
+ mockExecFileSync.mockImplementation((cmd, args) => {
1298
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1299
+ const cloneDir = String(args[4]);
1300
+ const alphaDir = path.join(cloneDir, 'packages', monorepoPluginName);
1301
+ fs.mkdirSync(alphaDir, { recursive: true });
1302
+ fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({
1303
+ name: 'opencli-plugins-__test-transactional-mono-update__',
1304
+ private: true,
1305
+ }));
1306
+ fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
1307
+ plugins: {
1308
+ [monorepoPluginName]: { path: `packages/${monorepoPluginName}` },
1309
+ },
1310
+ }));
1311
+ fs.writeFileSync(path.join(alphaDir, 'hello.yaml'), 'site: test\nname: hello\n');
1312
+ return '';
1313
+ }
1314
+ if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
1315
+ throw new Error('boom');
1316
+ }
1317
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1318
+ return '1234567890abcdef1234567890abcdef12345678\n';
1319
+ }
1320
+ return '';
1321
+ });
1322
+
1323
+ expect(() => updatePlugin(monorepoPluginName)).toThrow('npm install failed');
1324
+ expect(fs.existsSync(monorepoRepoDir)).toBe(true);
1325
+ expect(fs.existsSync(monorepoLink)).toBe(true);
1326
+ expect(fs.readFileSync(path.join(subDir, 'old.yaml'), 'utf-8')).toContain('site: old');
1327
+ expect(_readLockFile()[monorepoPluginName]?.commitHash).toBe('oldmonooldmonooldmonooldmonooldmonoold');
1328
+ });
1329
+
1330
+ it('relinks monorepo plugins when the updated manifest moves their subPath', () => {
1331
+ const oldSubDir = path.join(monorepoRepoDir, 'packages', 'old-alpha');
1332
+ fs.mkdirSync(oldSubDir, { recursive: true });
1333
+ fs.writeFileSync(path.join(oldSubDir, 'old.yaml'), 'site: old\nname: old\n');
1334
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
1335
+ fs.symlinkSync(oldSubDir, monorepoLink, 'dir');
1336
+
1337
+ const lock = _readLockFile();
1338
+ lock[monorepoPluginName] = {
1339
+ source: {
1340
+ kind: 'monorepo',
1341
+ url: 'https://github.com/user/opencli-plugins-__test-transactional-mono-update__.git',
1342
+ repoName: monorepoName,
1343
+ subPath: 'packages/old-alpha',
1344
+ },
1345
+ commitHash: 'oldmonooldmonooldmonooldmonooldmonoold',
1346
+ installedAt: '2025-01-01T00:00:00.000Z',
1347
+ };
1348
+ _writeLockFile(lock);
1349
+
1350
+ mockExecFileSync.mockImplementation((cmd, args) => {
1351
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1352
+ const cloneDir = String(args[4]);
1353
+ const movedDir = path.join(cloneDir, 'packages', 'moved-alpha');
1354
+ fs.mkdirSync(movedDir, { recursive: true });
1355
+ fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
1356
+ plugins: {
1357
+ [monorepoPluginName]: { path: 'packages/moved-alpha' },
1358
+ },
1359
+ }));
1360
+ fs.writeFileSync(path.join(movedDir, 'hello.yaml'), 'site: test\nname: hello\n');
1361
+ return '';
1362
+ }
1363
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1364
+ return '1234567890abcdef1234567890abcdef12345678\n';
1365
+ }
1366
+ return '';
1367
+ });
1368
+
1369
+ updatePlugin(monorepoPluginName);
1370
+
1371
+ const expectedTarget = path.join(monorepoRepoDir, 'packages', 'moved-alpha');
1372
+ expect(fs.realpathSync(monorepoLink)).toBe(fs.realpathSync(expectedTarget));
1373
+ expect(_readLockFile()[monorepoPluginName]?.source).toMatchObject({
1374
+ kind: 'monorepo',
1375
+ subPath: 'packages/moved-alpha',
1376
+ });
1377
+ });
1378
+
1379
+ it('rolls back the monorepo repo swap when relinking fails', () => {
1380
+ const oldSubDir = path.join(monorepoRepoDir, 'packages', 'old-alpha');
1381
+ fs.mkdirSync(oldSubDir, { recursive: true });
1382
+ fs.writeFileSync(path.join(oldSubDir, 'old.yaml'), 'site: old\nname: old\n');
1383
+ fs.mkdirSync(monorepoLink, { recursive: true });
1384
+ fs.writeFileSync(path.join(monorepoLink, 'blocker.txt'), 'not a symlink');
1385
+
1386
+ const lock = _readLockFile();
1387
+ lock[monorepoPluginName] = {
1388
+ source: {
1389
+ kind: 'monorepo',
1390
+ url: 'https://github.com/user/opencli-plugins-__test-transactional-mono-update__.git',
1391
+ repoName: monorepoName,
1392
+ subPath: 'packages/old-alpha',
1393
+ },
1394
+ commitHash: 'oldmonooldmonooldmonooldmonooldmonoold',
1395
+ installedAt: '2025-01-01T00:00:00.000Z',
1396
+ };
1397
+ _writeLockFile(lock);
1398
+
1399
+ mockExecFileSync.mockImplementation((cmd, args) => {
1400
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1401
+ const cloneDir = String(args[4]);
1402
+ const movedDir = path.join(cloneDir, 'packages', 'moved-alpha');
1403
+ fs.mkdirSync(movedDir, { recursive: true });
1404
+ fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
1405
+ plugins: {
1406
+ [monorepoPluginName]: { path: 'packages/moved-alpha' },
1407
+ },
1408
+ }));
1409
+ fs.writeFileSync(path.join(movedDir, 'hello.yaml'), 'site: test\nname: hello\n');
1410
+ return '';
1411
+ }
1412
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1413
+ return '1234567890abcdef1234567890abcdef12345678\n';
1414
+ }
1415
+ return '';
1416
+ });
1417
+
1418
+ expect(() => updatePlugin(monorepoPluginName)).toThrow('to be a symlink');
1419
+ expect(fs.existsSync(path.join(monorepoRepoDir, 'packages', 'old-alpha', 'old.yaml'))).toBe(true);
1420
+ expect(fs.existsSync(path.join(monorepoRepoDir, 'packages', 'moved-alpha'))).toBe(false);
1421
+ expect(fs.readFileSync(path.join(monorepoLink, 'blocker.txt'), 'utf-8')).toBe('not a symlink');
1422
+ expect(_readLockFile()[monorepoPluginName]?.source).toMatchObject({
1423
+ kind: 'monorepo',
1424
+ subPath: 'packages/old-alpha',
1425
+ });
1426
+ });
1427
+ });