@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
@@ -5,13 +5,19 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
5
5
  import * as fs from 'node:fs';
6
6
  import * as os from 'node:os';
7
7
  import * as path from 'node:path';
8
+ import { pathToFileURL } from 'node:url';
8
9
  import { PLUGINS_DIR } from './discovery.js';
9
10
  import * as pluginModule from './plugin.js';
10
- const { LOCK_FILE, _getCommitHash, listPlugins, _readLockFile, _resolveEsbuildBin, uninstallPlugin, updatePlugin, _parseSource, _updateAllPlugins, _validatePluginStructure, _writeLockFile, } = pluginModule;
11
+ const { mockExecFileSync, mockExecSync } = vi.hoisted(() => ({
12
+ mockExecFileSync: vi.fn(),
13
+ mockExecSync: vi.fn(),
14
+ }));
15
+ const { _getCommitHash, _installDependencies, _postInstallMonorepoLifecycle, _promoteDir, _replaceDir, installPlugin, listPlugins, _readLockFile, _readLockFileWithWriter, _resolveEsbuildBin, uninstallPlugin, updatePlugin, _parseSource, _updateAllPlugins, _validatePluginStructure, _writeLockFile, _writeLockFileWithFs, _isSymlinkSync, _getMonoreposDir, getLockFilePath, _installLocalPlugin, _isLocalPluginSource, _moveDir, _resolvePluginSource, _resolveStoredPluginSource, _toStoredPluginSource, _toLocalPluginSource, } = pluginModule;
11
16
  describe('parseSource', () => {
12
17
  it('parses github:user/repo format', () => {
13
18
  const result = _parseSource('github:ByteYue/opencli-plugin-github-trending');
14
19
  expect(result).toEqual({
20
+ type: 'git',
15
21
  cloneUrl: 'https://github.com/ByteYue/opencli-plugin-github-trending.git',
16
22
  name: 'github-trending',
17
23
  });
@@ -19,6 +25,7 @@ describe('parseSource', () => {
19
25
  it('parses https URL format', () => {
20
26
  const result = _parseSource('https://github.com/ByteYue/opencli-plugin-hot-digest');
21
27
  expect(result).toEqual({
28
+ type: 'git',
22
29
  cloneUrl: 'https://github.com/ByteYue/opencli-plugin-hot-digest.git',
23
30
  name: 'hot-digest',
24
31
  });
@@ -35,6 +42,88 @@ describe('parseSource', () => {
35
42
  expect(_parseSource('invalid')).toBeNull();
36
43
  expect(_parseSource('npm:some-package')).toBeNull();
37
44
  });
45
+ it('parses file:// local plugin directories', () => {
46
+ const localDir = path.join(os.tmpdir(), 'opencli-plugin-test');
47
+ const fileUrl = pathToFileURL(localDir).href;
48
+ const result = _parseSource(fileUrl);
49
+ expect(result).toEqual({
50
+ type: 'local',
51
+ localPath: localDir,
52
+ name: 'test',
53
+ });
54
+ });
55
+ it('parses plain absolute local plugin directories', () => {
56
+ const localDir = path.join(os.tmpdir(), 'my-plugin');
57
+ const result = _parseSource(localDir);
58
+ expect(result).toEqual({
59
+ type: 'local',
60
+ localPath: localDir,
61
+ name: 'my-plugin',
62
+ });
63
+ });
64
+ it('strips opencli-plugin- prefix for local paths', () => {
65
+ const localDir = path.join(os.tmpdir(), 'opencli-plugin-foo');
66
+ const result = _parseSource(localDir);
67
+ expect(result.name).toBe('foo');
68
+ });
69
+ // ── Generic git URL support ──
70
+ it('parses ssh:// URLs', () => {
71
+ const result = _parseSource('ssh://git@gitlab.com/team/opencli-plugin-tools.git');
72
+ expect(result).toEqual({
73
+ type: 'git',
74
+ cloneUrl: 'ssh://git@gitlab.com/team/opencli-plugin-tools.git',
75
+ name: 'tools',
76
+ });
77
+ });
78
+ it('parses ssh:// URLs without .git suffix', () => {
79
+ const result = _parseSource('ssh://git@gitlab.com/team/my-plugin');
80
+ expect(result).toEqual({
81
+ type: 'git',
82
+ cloneUrl: 'ssh://git@gitlab.com/team/my-plugin',
83
+ name: 'my-plugin',
84
+ });
85
+ });
86
+ it('parses git@ SCP-style URLs', () => {
87
+ const result = _parseSource('git@gitlab.com:team/my-plugin.git');
88
+ expect(result).toEqual({
89
+ type: 'git',
90
+ cloneUrl: 'git@gitlab.com:team/my-plugin.git',
91
+ name: 'my-plugin',
92
+ });
93
+ });
94
+ it('parses git@ SCP-style URLs and strips opencli-plugin- prefix', () => {
95
+ const result = _parseSource('git@github.com:user/opencli-plugin-awesome.git');
96
+ expect(result).toEqual({
97
+ type: 'git',
98
+ cloneUrl: 'git@github.com:user/opencli-plugin-awesome.git',
99
+ name: 'awesome',
100
+ });
101
+ });
102
+ it('parses generic HTTPS git URLs (non-GitHub)', () => {
103
+ const result = _parseSource('https://codehub.example.com/Team/App/opencli-plugins-app.git');
104
+ expect(result).toEqual({
105
+ type: 'git',
106
+ cloneUrl: 'https://codehub.example.com/Team/App/opencli-plugins-app.git',
107
+ name: 'opencli-plugins-app',
108
+ });
109
+ });
110
+ it('parses generic HTTPS git URLs without .git suffix', () => {
111
+ const result = _parseSource('https://gitlab.example.com/org/my-plugin');
112
+ expect(result).toEqual({
113
+ type: 'git',
114
+ cloneUrl: 'https://gitlab.example.com/org/my-plugin.git',
115
+ name: 'my-plugin',
116
+ });
117
+ });
118
+ it('still prefers GitHub shorthand over generic HTTPS for github.com', () => {
119
+ const result = _parseSource('https://github.com/user/repo');
120
+ // Should be handled by the GitHub-specific matcher (normalizes URL)
121
+ expect(result).toEqual({
122
+ type: 'git',
123
+ cloneUrl: 'https://github.com/user/repo.git',
124
+ name: 'repo',
125
+ });
126
+ });
38
127
  });
39
128
  describe('validatePluginStructure', () => {
40
129
  const testDir = path.join(PLUGINS_DIR, '__test-validate__');
@@ -91,29 +180,29 @@ describe('validatePluginStructure', () => {
91
180
  });
92
181
  });
93
182
  describe('lock file', () => {
94
- const backupPath = `${LOCK_FILE}.test-backup`;
183
+ const backupPath = `${getLockFilePath()}.test-backup`;
95
184
  let hadOriginal = false;
96
185
  beforeEach(() => {
97
- hadOriginal = fs.existsSync(LOCK_FILE);
186
+ hadOriginal = fs.existsSync(getLockFilePath());
98
187
  if (hadOriginal) {
99
188
  fs.mkdirSync(path.dirname(backupPath), { recursive: true });
100
- fs.copyFileSync(LOCK_FILE, backupPath);
189
+ fs.copyFileSync(getLockFilePath(), backupPath);
101
190
  }
102
191
  });
103
192
  afterEach(() => {
104
193
  if (hadOriginal) {
105
- fs.copyFileSync(backupPath, LOCK_FILE);
194
+ fs.copyFileSync(backupPath, getLockFilePath());
106
195
  fs.unlinkSync(backupPath);
107
196
  return;
108
197
  }
109
198
  try {
110
- fs.unlinkSync(LOCK_FILE);
199
+ fs.unlinkSync(getLockFilePath());
111
200
  }
112
201
  catch { }
113
202
  });
114
203
  it('reads empty lock when file does not exist', () => {
115
204
  try {
116
- fs.unlinkSync(LOCK_FILE);
205
+ fs.unlinkSync(getLockFilePath());
117
206
  }
118
207
  catch { }
119
208
  expect(_readLockFile()).toEqual({});
@@ -121,12 +210,12 @@ describe('lock file', () => {
121
210
  it('round-trips lock entries', () => {
122
211
  const entries = {
123
212
  'test-plugin': {
124
- source: 'https://github.com/user/repo.git',
213
+ source: { kind: 'git', url: 'https://github.com/user/repo.git' },
125
214
  commitHash: 'abc1234567890def',
126
215
  installedAt: '2025-01-01T00:00:00.000Z',
127
216
  },
128
217
  'another-plugin': {
129
- source: 'https://github.com/user/another.git',
218
+ source: { kind: 'git', url: 'https://github.com/user/another.git' },
130
219
  commitHash: 'def4567890123abc',
131
220
  installedAt: '2025-02-01T00:00:00.000Z',
132
221
  updatedAt: '2025-03-01T00:00:00.000Z',
@@ -136,10 +225,97 @@ describe('lock file', () => {
136
225
  expect(_readLockFile()).toEqual(entries);
137
226
  });
138
227
  it('handles malformed lock file gracefully', () => {
139
- fs.mkdirSync(path.dirname(LOCK_FILE), { recursive: true });
140
- fs.writeFileSync(LOCK_FILE, 'not valid json');
228
+ fs.mkdirSync(path.dirname(getLockFilePath()), { recursive: true });
229
+ fs.writeFileSync(getLockFilePath(), 'not valid json');
141
230
  expect(_readLockFile()).toEqual({});
142
231
  });
232
+ it('keeps the previous lockfile contents when atomic rewrite fails', () => {
233
+ const existing = {
234
+ stable: {
235
+ source: { kind: 'git', url: 'https://github.com/user/stable.git' },
236
+ commitHash: 'stable1234567890',
237
+ installedAt: '2025-01-01T00:00:00.000Z',
238
+ },
239
+ };
240
+ _writeLockFile(existing);
241
+ const renameSync = vi.fn(() => {
242
+ throw new Error('rename failed');
243
+ });
244
+ const rmSync = vi.fn(() => undefined);
245
+ expect(() => _writeLockFileWithFs({
246
+ broken: {
247
+ source: { kind: 'git', url: 'https://github.com/user/broken.git' },
248
+ commitHash: 'broken1234567890',
249
+ installedAt: '2025-02-01T00:00:00.000Z',
250
+ },
251
+ }, {
252
+ mkdirSync: fs.mkdirSync,
253
+ writeFileSync: fs.writeFileSync,
254
+ renameSync,
255
+ rmSync,
256
+ })).toThrow('rename failed');
257
+ expect(_readLockFile()).toEqual(existing);
258
+ expect(rmSync).toHaveBeenCalledTimes(1);
259
+ });
260
+ it('migrates legacy string sources to structured sources on read', () => {
261
+ const legacyLocalPath = path.resolve(path.join(os.tmpdir(), 'opencli-legacy-local-plugin'));
262
+ fs.mkdirSync(path.dirname(getLockFilePath()), { recursive: true });
263
+ fs.writeFileSync(getLockFilePath(), JSON.stringify({
264
+ alpha: {
265
+ source: 'https://github.com/user/opencli-plugins.git',
266
+ commitHash: 'abc1234567890def',
267
+ installedAt: '2025-01-01T00:00:00.000Z',
268
+ monorepo: { name: 'opencli-plugins', subPath: 'packages/alpha' },
269
+ },
270
+ beta: {
271
+ source: `local:${legacyLocalPath}`,
272
+ commitHash: 'local',
273
+ installedAt: '2025-01-01T00:00:00.000Z',
274
+ },
275
+ }, null, 2));
276
+ expect(_readLockFile()).toEqual({
277
+ alpha: {
278
+ source: {
279
+ kind: 'monorepo',
280
+ url: 'https://github.com/user/opencli-plugins.git',
281
+ repoName: 'opencli-plugins',
282
+ subPath: 'packages/alpha',
283
+ },
284
+ commitHash: 'abc1234567890def',
285
+ installedAt: '2025-01-01T00:00:00.000Z',
286
+ },
287
+ beta: {
288
+ source: { kind: 'local', path: legacyLocalPath },
289
+ commitHash: 'local',
290
+ installedAt: '2025-01-01T00:00:00.000Z',
291
+ },
292
+ });
293
+ });
294
+ it('returns normalized entries even when migration rewrite fails', () => {
295
+ fs.mkdirSync(path.dirname(getLockFilePath()), { recursive: true });
296
+ fs.writeFileSync(getLockFilePath(), JSON.stringify({
297
+ alpha: {
298
+ source: 'https://github.com/user/opencli-plugins.git',
299
+ commitHash: 'abc1234567890def',
300
+ installedAt: '2025-01-01T00:00:00.000Z',
301
+ monorepo: { name: 'opencli-plugins', subPath: 'packages/alpha' },
302
+ },
303
+ }, null, 2));
304
+ expect(_readLockFileWithWriter(() => {
305
+ throw new Error('disk full');
306
+ })).toEqual({
307
+ alpha: {
308
+ source: {
309
+ kind: 'monorepo',
310
+ url: 'https://github.com/user/opencli-plugins.git',
311
+ repoName: 'opencli-plugins',
312
+ subPath: 'packages/alpha',
313
+ },
314
+ commitHash: 'abc1234567890def',
315
+ installedAt: '2025-01-01T00:00:00.000Z',
316
+ },
317
+ });
318
+ });
143
319
  });
144
320
  describe('getCommitHash', () => {
145
321
  it('returns a hash for a git repo', () => {
@@ -157,7 +333,6 @@ describe('resolveEsbuildBin', () => {
157
333
  expect(binPath).not.toBeNull();
158
334
  expect(typeof binPath).toBe('string');
159
335
  expect(fs.existsSync(binPath)).toBe(true);
160
- // On Windows the resolved path ends with 'esbuild.cmd', on Unix 'esbuild'
161
336
  expect(binPath).toMatch(/esbuild(\.cmd)?$/);
162
337
  });
163
338
  });
@@ -182,7 +357,7 @@ describe('listPlugins', () => {
182
357
  fs.writeFileSync(path.join(testDir, 'hello.yaml'), 'site: test\nname: hello\n');
183
358
  const lock = _readLockFile();
184
359
  lock['__test-list-plugin__'] = {
185
- source: 'https://github.com/user/repo.git',
360
+ source: { kind: 'git', url: 'https://github.com/user/repo.git' },
186
361
  commitHash: 'abcdef1234567890abcdef1234567890abcdef12',
187
362
  installedAt: '2025-01-01T00:00:00.000Z',
188
363
  };
@@ -196,10 +371,44 @@ describe('listPlugins', () => {
196
371
  _writeLockFile(lock);
197
372
  });
198
373
  it('returns empty array when no plugins dir', () => {
199
- // listPlugins should handle missing dir gracefully
200
374
  const plugins = listPlugins();
201
375
  expect(Array.isArray(plugins)).toBe(true);
202
376
  });
377
+ it('prefers lockfile source for local symlink plugins', () => {
378
+ const localTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-local-list-'));
379
+ const linkPath = path.join(PLUGINS_DIR, '__test-list-plugin__');
380
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
381
+ fs.writeFileSync(path.join(localTarget, 'hello.yaml'), 'site: test\nname: hello\n');
382
+ try {
383
+ fs.unlinkSync(linkPath);
384
+ }
385
+ catch { }
386
+ try {
387
+ fs.rmSync(linkPath, { recursive: true, force: true });
388
+ }
389
+ catch { }
390
+ fs.symlinkSync(localTarget, linkPath, 'dir');
391
+ const lock = _readLockFile();
392
+ lock['__test-list-plugin__'] = {
393
+ source: { kind: 'local', path: localTarget },
394
+ commitHash: 'local',
395
+ installedAt: '2025-01-01T00:00:00.000Z',
396
+ };
397
+ _writeLockFile(lock);
398
+ const plugins = listPlugins();
399
+ const found = plugins.find(p => p.name === '__test-list-plugin__');
400
+ expect(found?.source).toBe(`local:${localTarget}`);
401
+ try {
402
+ fs.unlinkSync(linkPath);
403
+ }
404
+ catch { }
405
+ try {
406
+ fs.rmSync(localTarget, { recursive: true, force: true });
407
+ }
408
+ catch { }
409
+ delete lock['__test-list-plugin__'];
410
+ _writeLockFile(lock);
411
+ });
203
412
  });
204
413
  describe('uninstallPlugin', () => {
205
414
  const testDir = path.join(PLUGINS_DIR, '__test-uninstall__');
@@ -220,7 +429,7 @@ describe('uninstallPlugin', () => {
220
429
  fs.writeFileSync(path.join(testDir, 'test.yaml'), 'site: test');
221
430
  const lock = _readLockFile();
222
431
  lock['__test-uninstall__'] = {
223
- source: 'https://github.com/user/repo.git',
432
+ source: { kind: 'git', url: 'https://github.com/user/repo.git' },
224
433
  commitHash: 'abc123',
225
434
  installedAt: '2025-01-01T00:00:00.000Z',
226
435
  };
@@ -236,10 +445,44 @@ describe('updatePlugin', () => {
236
445
  it('throws for non-existent plugin', () => {
237
446
  expect(() => updatePlugin('__nonexistent__')).toThrow('not installed');
238
447
  });
448
+ it('refreshes local plugins without running git pull', () => {
449
+ const localTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-local-update-'));
450
+ const linkPath = path.join(PLUGINS_DIR, '__test-local-update__');
451
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
452
+ fs.writeFileSync(path.join(localTarget, 'hello.yaml'), 'site: test\nname: hello\n');
453
+ fs.symlinkSync(localTarget, linkPath, 'dir');
454
+ const lock = _readLockFile();
455
+ lock['__test-local-update__'] = {
456
+ source: { kind: 'local', path: localTarget },
457
+ commitHash: 'local',
458
+ installedAt: '2025-01-01T00:00:00.000Z',
459
+ };
460
+ _writeLockFile(lock);
461
+ mockExecFileSync.mockClear();
462
+ updatePlugin('__test-local-update__');
463
+ expect(mockExecFileSync.mock.calls.some(([cmd, args, opts]) => cmd === 'git'
464
+ && Array.isArray(args)
465
+ && args[0] === 'pull'
466
+ && opts?.cwd === linkPath)).toBe(false);
467
+ const updated = _readLockFile()['__test-local-update__'];
468
+ expect(updated?.source).toEqual({ kind: 'local', path: path.resolve(localTarget) });
469
+ expect(updated?.updatedAt).toBeDefined();
470
+ try {
471
+ fs.unlinkSync(linkPath);
472
+ }
473
+ catch { }
474
+ try {
475
+ fs.rmSync(localTarget, { recursive: true, force: true });
476
+ }
477
+ catch { }
478
+ const finalLock = _readLockFile();
479
+ delete finalLock['__test-local-update__'];
480
+ _writeLockFile(finalLock);
481
+ });
239
482
  });
240
483
  vi.mock('node:child_process', () => {
241
484
  return {
242
- execFileSync: vi.fn((_cmd, args, opts) => {
485
+ execFileSync: mockExecFileSync.mockImplementation((_cmd, args, opts) => {
243
486
  if (Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
244
487
  if (opts?.cwd === os.tmpdir()) {
245
488
  throw new Error('not a git repository');
@@ -251,9 +494,50 @@ vi.mock('node:child_process', () => {
251
494
  }
252
495
  return '';
253
496
  }),
254
- execSync: vi.fn(() => ''),
497
+ execSync: mockExecSync.mockImplementation(() => ''),
255
498
  };
256
499
  });
500
+ describe('installDependencies', () => {
501
+ beforeEach(() => {
502
+ mockExecFileSync.mockClear();
503
+ mockExecSync.mockClear();
504
+ });
505
+ it('throws when npm install fails', () => {
506
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-plugin-b-'));
507
+ const failingDir = path.join(tmpDir, 'plugin-b');
508
+ fs.mkdirSync(failingDir, { recursive: true });
509
+ fs.writeFileSync(path.join(failingDir, 'package.json'), JSON.stringify({ name: 'plugin-b' }));
510
+ expect(() => _installDependencies(failingDir)).toThrow('npm install failed');
511
+ fs.rmSync(tmpDir, { recursive: true, force: true });
512
+ });
513
+ });
514
+ describe('postInstallMonorepoLifecycle', () => {
515
+ let repoDir;
516
+ let subDir;
517
+ beforeEach(() => {
518
+ mockExecFileSync.mockClear();
519
+ mockExecSync.mockClear();
520
+ repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-monorepo-'));
521
+ subDir = path.join(repoDir, 'packages', 'alpha');
522
+ fs.mkdirSync(subDir, { recursive: true });
523
+ fs.writeFileSync(path.join(repoDir, 'package.json'), JSON.stringify({
524
+ name: 'opencli-plugins',
525
+ private: true,
526
+ workspaces: ['packages/*'],
527
+ }));
528
+ fs.writeFileSync(path.join(subDir, 'hello.yaml'), 'site: test\nname: hello\n');
529
+ });
530
+ afterEach(() => {
531
+ fs.rmSync(repoDir, { recursive: true, force: true });
532
+ });
533
+ it('installs dependencies once at the monorepo root, not in each sub-plugin', () => {
534
+ _postInstallMonorepoLifecycle(repoDir, [subDir]);
535
+ const npmCalls = mockExecFileSync.mock.calls.filter(([cmd, args]) => cmd === 'npm' && Array.isArray(args) && args[0] === 'install');
536
+ expect(npmCalls).toHaveLength(1);
537
+ expect(npmCalls[0][2]).toMatchObject({ cwd: repoDir });
538
+ expect(npmCalls.some(([, , opts]) => opts?.cwd === subDir)).toBe(false);
539
+ });
540
+ });
257
541
  describe('updateAllPlugins', () => {
258
542
  const testDirA = path.join(PLUGINS_DIR, 'plugin-a');
259
543
  const testDirB = path.join(PLUGINS_DIR, 'plugin-b');
@@ -265,6 +549,23 @@ describe('updateAllPlugins', () => {
265
549
  fs.writeFileSync(path.join(testDirA, 'cmd.yaml'), 'site: a');
266
550
  fs.writeFileSync(path.join(testDirB, 'cmd.yaml'), 'site: b');
267
551
  fs.writeFileSync(path.join(testDirC, 'cmd.yaml'), 'site: c');
552
+ const lock = _readLockFile();
553
+ lock['plugin-a'] = {
554
+ source: { kind: 'git', url: 'https://github.com/user/plugin-a.git' },
555
+ commitHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
556
+ installedAt: '2025-01-01T00:00:00.000Z',
557
+ };
558
+ lock['plugin-b'] = {
559
+ source: { kind: 'git', url: 'https://github.com/user/plugin-b.git' },
560
+ commitHash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
561
+ installedAt: '2025-01-01T00:00:00.000Z',
562
+ };
563
+ lock['plugin-c'] = {
564
+ source: { kind: 'git', url: 'https://github.com/user/plugin-c.git' },
565
+ commitHash: 'cccccccccccccccccccccccccccccccccccccccc',
566
+ installedAt: '2025-01-01T00:00:00.000Z',
567
+ };
568
+ _writeLockFile(lock);
268
569
  });
269
570
  afterEach(() => {
270
571
  try {
@@ -279,9 +580,33 @@ describe('updateAllPlugins', () => {
279
580
  fs.rmSync(testDirC, { recursive: true });
280
581
  }
281
582
  catch { }
583
+ const lock = _readLockFile();
584
+ delete lock['plugin-a'];
585
+ delete lock['plugin-b'];
586
+ delete lock['plugin-c'];
587
+ _writeLockFile(lock);
282
588
  vi.clearAllMocks();
283
589
  });
284
590
  it('collects successes and failures without throwing', () => {
591
+ mockExecFileSync.mockImplementation((cmd, args) => {
592
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
593
+ const cloneUrl = String(args[3]);
594
+ const cloneDir = String(args[4]);
595
+ fs.mkdirSync(cloneDir, { recursive: true });
596
+ fs.writeFileSync(path.join(cloneDir, 'cmd.yaml'), 'site: test\nname: hello\n');
597
+ if (cloneUrl.includes('plugin-b')) {
598
+ fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ name: 'plugin-b' }));
599
+ }
600
+ return '';
601
+ }
602
+ if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
603
+ throw new Error('Network error');
604
+ }
605
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
606
+ return '1234567890abcdef1234567890abcdef12345678\n';
607
+ }
608
+ return '';
609
+ });
285
610
  const results = _updateAllPlugins();
286
611
  const resA = results.find(r => r.name === 'plugin-a');
287
612
  const resB = results.find(r => r.name === 'plugin-b');
@@ -295,3 +620,693 @@ describe('updateAllPlugins', () => {
295
620
  expect(resC.success).toBe(true);
296
621
  });
297
622
  });
623
+ // ── Monorepo-specific tests ─────────────────────────────────────────────────
624
+ describe('parseSource with monorepo subplugin', () => {
625
+ it('parses github:user/repo/subplugin format', () => {
626
+ const result = _parseSource('github:ByteYue/opencli-plugins/polymarket');
627
+ expect(result).toEqual({
628
+ type: 'git',
629
+ cloneUrl: 'https://github.com/ByteYue/opencli-plugins.git',
630
+ name: 'opencli-plugins',
631
+ subPlugin: 'polymarket',
632
+ });
633
+ });
634
+ it('strips opencli-plugin- prefix from repo name in subplugin format', () => {
635
+ const result = _parseSource('github:user/opencli-plugin-collection/defi');
636
+ expect(result.name).toBe('collection');
637
+ expect(result.subPlugin).toBe('defi');
638
+ });
639
+ it('still parses github:user/repo without subplugin', () => {
640
+ const result = _parseSource('github:user/my-repo');
641
+ expect(result).toEqual({
642
+ type: 'git',
643
+ cloneUrl: 'https://github.com/user/my-repo.git',
644
+ name: 'my-repo',
645
+ });
646
+ expect(result.subPlugin).toBeUndefined();
647
+ });
648
+ });
649
+ describe('isSymlinkSync', () => {
650
+ let tmpDir;
651
+ beforeEach(() => {
652
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-symlink-test-'));
653
+ });
654
+ afterEach(() => {
655
+ fs.rmSync(tmpDir, { recursive: true, force: true });
656
+ });
657
+ it('returns false for a regular directory', () => {
658
+ const dir = path.join(tmpDir, 'regular');
659
+ fs.mkdirSync(dir);
660
+ expect(_isSymlinkSync(dir)).toBe(false);
661
+ });
662
+ it('returns true for a symlink', () => {
663
+ const target = path.join(tmpDir, 'target');
664
+ const link = path.join(tmpDir, 'link');
665
+ fs.mkdirSync(target);
666
+ fs.symlinkSync(target, link, 'dir');
667
+ expect(_isSymlinkSync(link)).toBe(true);
668
+ });
669
+ it('returns false for non-existent path', () => {
670
+ expect(_isSymlinkSync(path.join(tmpDir, 'nope'))).toBe(false);
671
+ });
672
+ });
673
+ describe('monorepo uninstall with symlink', () => {
674
+ let tmpDir;
675
+ let pluginDir;
676
+ let monoDir;
677
+ beforeEach(() => {
678
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-mono-uninstall-'));
679
+ pluginDir = path.join(PLUGINS_DIR, '__test-mono-sub__');
680
+ monoDir = path.join(_getMonoreposDir(), '__test-mono__');
681
+ const subDir = path.join(monoDir, 'packages', 'sub');
682
+ fs.mkdirSync(subDir, { recursive: true });
683
+ fs.writeFileSync(path.join(subDir, 'cmd.yaml'), 'site: test');
684
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
685
+ fs.symlinkSync(subDir, pluginDir, 'dir');
686
+ const lock = _readLockFile();
687
+ lock['__test-mono-sub__'] = {
688
+ source: {
689
+ kind: 'monorepo',
690
+ url: 'https://github.com/user/test.git',
691
+ repoName: '__test-mono__',
692
+ subPath: 'packages/sub',
693
+ },
694
+ commitHash: 'abc123',
695
+ installedAt: '2025-01-01T00:00:00.000Z',
696
+ };
697
+ _writeLockFile(lock);
698
+ });
699
+ afterEach(() => {
700
+ try {
701
+ fs.unlinkSync(pluginDir);
702
+ }
703
+ catch { }
704
+ try {
705
+ fs.rmSync(pluginDir, { recursive: true, force: true });
706
+ }
707
+ catch { }
708
+ try {
709
+ fs.rmSync(monoDir, { recursive: true, force: true });
710
+ }
711
+ catch { }
712
+ const lock = _readLockFile();
713
+ delete lock['__test-mono-sub__'];
714
+ _writeLockFile(lock);
715
+ });
716
+ it('removes symlink but keeps monorepo if other sub-plugins reference it', () => {
717
+ const lock = _readLockFile();
718
+ lock['__test-mono-other__'] = {
719
+ source: {
720
+ kind: 'monorepo',
721
+ url: 'https://github.com/user/test.git',
722
+ repoName: '__test-mono__',
723
+ subPath: 'packages/other',
724
+ },
725
+ commitHash: 'abc123',
726
+ installedAt: '2025-01-01T00:00:00.000Z',
727
+ };
728
+ _writeLockFile(lock);
729
+ uninstallPlugin('__test-mono-sub__');
730
+ expect(fs.existsSync(pluginDir)).toBe(false);
731
+ expect(fs.existsSync(monoDir)).toBe(true);
732
+ expect(_readLockFile()['__test-mono-sub__']).toBeUndefined();
733
+ expect(_readLockFile()['__test-mono-other__']).toBeDefined();
734
+ const finalLock = _readLockFile();
735
+ delete finalLock['__test-mono-other__'];
736
+ _writeLockFile(finalLock);
737
+ });
738
+ it('removes symlink AND monorepo dir when last sub-plugin is uninstalled', () => {
739
+ uninstallPlugin('__test-mono-sub__');
740
+ expect(fs.existsSync(pluginDir)).toBe(false);
741
+ expect(fs.existsSync(monoDir)).toBe(false);
742
+ expect(_readLockFile()['__test-mono-sub__']).toBeUndefined();
743
+ });
744
+ });
745
+ describe('listPlugins with monorepo metadata', () => {
746
+ const testSymlinkTarget = path.join(os.tmpdir(), 'opencli-list-mono-target');
747
+ const testLink = path.join(PLUGINS_DIR, '__test-mono-list__');
748
+ beforeEach(() => {
749
+ fs.mkdirSync(testSymlinkTarget, { recursive: true });
750
+ fs.writeFileSync(path.join(testSymlinkTarget, 'hello.yaml'), 'site: test\nname: hello\n');
751
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
752
+ try {
753
+ fs.unlinkSync(testLink);
754
+ }
755
+ catch { }
756
+ fs.symlinkSync(testSymlinkTarget, testLink, 'dir');
757
+ const lock = _readLockFile();
758
+ lock['__test-mono-list__'] = {
759
+ source: {
760
+ kind: 'monorepo',
761
+ url: 'https://github.com/user/test-mono.git',
762
+ repoName: 'test-mono',
763
+ subPath: 'packages/list',
764
+ },
765
+ commitHash: 'def456def456def456def456def456def456def4',
766
+ installedAt: '2025-01-01T00:00:00.000Z',
767
+ };
768
+ _writeLockFile(lock);
769
+ });
770
+ afterEach(() => {
771
+ try {
772
+ fs.unlinkSync(testLink);
773
+ }
774
+ catch { }
775
+ try {
776
+ fs.rmSync(testSymlinkTarget, { recursive: true, force: true });
777
+ }
778
+ catch { }
779
+ const lock = _readLockFile();
780
+ delete lock['__test-mono-list__'];
781
+ _writeLockFile(lock);
782
+ });
783
+ it('lists symlinked plugins with monorepoName', () => {
784
+ const plugins = listPlugins();
785
+ const found = plugins.find(p => p.name === '__test-mono-list__');
786
+ expect(found).toBeDefined();
787
+ expect(found.monorepoName).toBe('test-mono');
788
+ expect(found.commands).toContain('hello');
789
+ expect(found.source).toBe('https://github.com/user/test-mono.git');
790
+ });
791
+ });
792
+ describe('installLocalPlugin', () => {
793
+ let tmpDir;
794
+ const pluginName = '__test-local-plugin__';
795
+ beforeEach(() => {
796
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-local-install-'));
797
+ fs.writeFileSync(path.join(tmpDir, 'hello.yaml'), 'site: test\nname: hello\n');
798
+ });
799
+ afterEach(() => {
800
+ const linkPath = path.join(PLUGINS_DIR, pluginName);
801
+ try {
802
+ fs.unlinkSync(linkPath);
803
+ }
804
+ catch { }
805
+ try {
806
+ fs.rmSync(tmpDir, { recursive: true, force: true });
807
+ }
808
+ catch { }
809
+ const lock = _readLockFile();
810
+ delete lock[pluginName];
811
+ _writeLockFile(lock);
812
+ });
813
+ it('creates a symlink to the local directory', () => {
814
+ const result = _installLocalPlugin(tmpDir, pluginName);
815
+ expect(result).toBe(pluginName);
816
+ const linkPath = path.join(PLUGINS_DIR, pluginName);
817
+ expect(fs.existsSync(linkPath)).toBe(true);
818
+ expect(_isSymlinkSync(linkPath)).toBe(true);
819
+ });
820
+ it('records local: source in lockfile', () => {
821
+ _installLocalPlugin(tmpDir, pluginName);
822
+ const lock = _readLockFile();
823
+ expect(lock[pluginName]).toBeDefined();
824
+ expect(lock[pluginName].source).toEqual({ kind: 'local', path: path.resolve(tmpDir) });
825
+ });
826
+ it('lists the recorded local source', () => {
827
+ _installLocalPlugin(tmpDir, pluginName);
828
+ const plugins = listPlugins();
829
+ const found = plugins.find(p => p.name === pluginName);
830
+ expect(found).toBeDefined();
831
+ expect(found.source).toBe(`local:${path.resolve(tmpDir)}`);
832
+ });
833
+ it('throws for non-existent path', () => {
834
+ expect(() => _installLocalPlugin('/does/not/exist', 'x')).toThrow('does not exist');
835
+ });
836
+ });
837
+ describe('isLocalPluginSource', () => {
838
+ it('detects lockfile local sources', () => {
839
+ expect(_isLocalPluginSource('local:/tmp/plugin')).toBe(true);
840
+ expect(_isLocalPluginSource('https://github.com/user/repo.git')).toBe(false);
841
+ expect(_isLocalPluginSource(undefined)).toBe(false);
842
+ });
843
+ });
844
+ describe('plugin source helpers', () => {
845
+ it('formats local plugin sources consistently', () => {
846
+ const dir = path.join(os.tmpdir(), 'opencli-plugin-source');
847
+ expect(_toLocalPluginSource(dir)).toBe(`local:${path.resolve(dir)}`);
848
+ });
849
+ it('serializes structured local sources consistently', () => {
850
+ const dir = path.join(os.tmpdir(), 'opencli-plugin-source');
851
+ expect(_toStoredPluginSource({ kind: 'local', path: dir })).toBe(`local:${path.resolve(dir)}`);
852
+ });
853
+ it('prefers lockfile source over git remote lookup', () => {
854
+ const dir = path.join(os.tmpdir(), 'opencli-plugin-source');
855
+ const localPath = path.resolve(path.join(os.tmpdir(), 'opencli-plugin-source-local'));
856
+ const source = _resolveStoredPluginSource({
857
+ source: { kind: 'local', path: localPath },
858
+ commitHash: 'local',
859
+ installedAt: '2025-01-01T00:00:00.000Z',
860
+ }, dir);
861
+ expect(source).toBe(`local:${localPath}`);
862
+ });
863
+ it('returns structured monorepo sources unchanged', () => {
864
+ const dir = path.join(os.tmpdir(), 'opencli-plugin-source');
865
+ const source = _resolvePluginSource({
866
+ source: {
867
+ kind: 'monorepo',
868
+ url: 'https://github.com/user/opencli-plugins.git',
869
+ repoName: 'opencli-plugins',
870
+ subPath: 'packages/alpha',
871
+ },
872
+ commitHash: 'abcdef1234567890abcdef1234567890abcdef12',
873
+ installedAt: '2025-01-01T00:00:00.000Z',
874
+ }, dir);
875
+ expect(source).toEqual({
876
+ kind: 'monorepo',
877
+ url: 'https://github.com/user/opencli-plugins.git',
878
+ repoName: 'opencli-plugins',
879
+ subPath: 'packages/alpha',
880
+ });
881
+ });
882
+ });
883
+ describe('moveDir', () => {
884
+ it('cleans up destination when EXDEV fallback copy fails', () => {
885
+ const src = path.join(os.tmpdir(), 'opencli-move-src');
886
+ const dest = path.join(os.tmpdir(), 'opencli-move-dest');
887
+ const renameErr = Object.assign(new Error('cross-device link not permitted'), { code: 'EXDEV' });
888
+ const copyErr = new Error('copy failed');
889
+ const renameSync = vi.fn(() => { throw renameErr; });
890
+ const cpSync = vi.fn(() => { throw copyErr; });
891
+ const rmSync = vi.fn(() => undefined);
892
+ expect(() => _moveDir(src, dest, { renameSync, cpSync, rmSync })).toThrow(copyErr);
893
+ expect(renameSync).toHaveBeenCalledWith(src, dest);
894
+ expect(cpSync).toHaveBeenCalledWith(src, dest, { recursive: true });
895
+ expect(rmSync).toHaveBeenCalledWith(dest, { recursive: true, force: true });
896
+ });
897
+ });
898
+ describe('promoteDir', () => {
899
+ it('cleans up temporary publish dir when final rename fails', () => {
900
+ const staging = path.join(os.tmpdir(), 'opencli-promote-stage');
901
+ const dest = path.join(os.tmpdir(), 'opencli-promote-dest');
902
+ const publishErr = new Error('publish failed');
903
+ const existsSync = vi.fn(() => false);
904
+ const mkdirSync = vi.fn(() => undefined);
905
+ const cpSync = vi.fn(() => undefined);
906
+ const rmSync = vi.fn(() => undefined);
907
+ const renameSync = vi.fn((src, _target) => {
908
+ if (String(src) === staging)
909
+ return;
910
+ throw publishErr;
911
+ });
912
+ expect(() => _promoteDir(staging, dest, { existsSync, mkdirSync, renameSync, cpSync, rmSync })).toThrow(publishErr);
913
+ const tempDest = renameSync.mock.calls[0][1];
914
+ expect(renameSync).toHaveBeenNthCalledWith(1, staging, tempDest);
915
+ expect(renameSync).toHaveBeenNthCalledWith(2, tempDest, dest);
916
+ expect(rmSync).toHaveBeenCalledWith(tempDest, { recursive: true, force: true });
917
+ });
918
+ });
919
+ describe('replaceDir', () => {
920
+ it('rolls back the original destination when swap fails', () => {
921
+ const staging = path.join(os.tmpdir(), 'opencli-replace-stage');
922
+ const dest = path.join(os.tmpdir(), 'opencli-replace-dest');
923
+ const publishErr = new Error('swap failed');
924
+ const existingPaths = new Set([dest]);
925
+ const existsSync = vi.fn((p) => existingPaths.has(String(p)));
926
+ const mkdirSync = vi.fn(() => undefined);
927
+ const cpSync = vi.fn(() => undefined);
928
+ const rmSync = vi.fn(() => undefined);
929
+ const renameSync = vi.fn((src, target) => {
930
+ if (String(src) === staging) {
931
+ existingPaths.add(String(target));
932
+ return;
933
+ }
934
+ if (String(src) === dest) {
935
+ existingPaths.delete(dest);
936
+ existingPaths.add(String(target));
937
+ return;
938
+ }
939
+ if (String(target) === dest)
940
+ throw publishErr;
941
+ if (existingPaths.has(String(src))) {
942
+ existingPaths.delete(String(src));
943
+ existingPaths.add(String(target));
944
+ }
945
+ });
946
+ expect(() => _replaceDir(staging, dest, { existsSync, mkdirSync, renameSync, cpSync, rmSync })).toThrow(publishErr);
947
+ const tempDest = renameSync.mock.calls[0][1];
948
+ const backupDest = renameSync.mock.calls[1][1];
949
+ expect(renameSync).toHaveBeenNthCalledWith(1, staging, tempDest);
950
+ expect(renameSync).toHaveBeenNthCalledWith(2, dest, backupDest);
951
+ expect(renameSync).toHaveBeenNthCalledWith(3, tempDest, dest);
952
+ expect(renameSync).toHaveBeenNthCalledWith(4, backupDest, dest);
953
+ expect(rmSync).toHaveBeenCalledWith(tempDest, { recursive: true, force: true });
954
+ });
955
+ });
956
+ describe('installPlugin transactional staging', () => {
957
+ const standaloneSource = 'github:user/opencli-plugin-__test-transactional-standalone__';
958
+ const standaloneName = '__test-transactional-standalone__';
959
+ const standaloneDir = path.join(PLUGINS_DIR, standaloneName);
960
+ const monorepoSource = 'github:user/opencli-plugins-__test-transactional__';
961
+ const monorepoRepoDir = path.join(_getMonoreposDir(), 'opencli-plugins-__test-transactional__');
962
+ const monorepoLink = path.join(PLUGINS_DIR, 'alpha');
963
+ beforeEach(() => {
964
+ mockExecFileSync.mockClear();
965
+ mockExecSync.mockClear();
966
+ });
967
+ afterEach(() => {
968
+ try {
969
+ fs.unlinkSync(monorepoLink);
970
+ }
971
+ catch { }
972
+ try {
973
+ fs.rmSync(monorepoLink, { recursive: true, force: true });
974
+ }
975
+ catch { }
976
+ try {
977
+ fs.rmSync(monorepoRepoDir, { recursive: true, force: true });
978
+ }
979
+ catch { }
980
+ try {
981
+ fs.rmSync(standaloneDir, { recursive: true, force: true });
982
+ }
983
+ catch { }
984
+ const lock = _readLockFile();
985
+ delete lock[standaloneName];
986
+ delete lock.alpha;
987
+ _writeLockFile(lock);
988
+ vi.clearAllMocks();
989
+ });
990
+ it('does not expose the final standalone plugin dir when lifecycle fails in staging', () => {
991
+ mockExecFileSync.mockImplementation((cmd, args) => {
992
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
993
+ const cloneDir = String(args[args.length - 1]);
994
+ fs.mkdirSync(cloneDir, { recursive: true });
995
+ fs.writeFileSync(path.join(cloneDir, 'hello.yaml'), 'site: test\nname: hello\n');
996
+ fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ name: standaloneName }));
997
+ return '';
998
+ }
999
+ if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
1000
+ throw new Error('boom');
1001
+ }
1002
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1003
+ return '1234567890abcdef1234567890abcdef12345678\n';
1004
+ }
1005
+ return '';
1006
+ });
1007
+ expect(() => installPlugin(standaloneSource)).toThrow(`npm install failed`);
1008
+ expect(fs.existsSync(standaloneDir)).toBe(false);
1009
+ expect(_readLockFile()[standaloneName]).toBeUndefined();
1010
+ });
1011
+ it('does not expose monorepo links or repo dir when lifecycle fails in staging', () => {
1012
+ mockExecFileSync.mockImplementation((cmd, args) => {
1013
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1014
+ const cloneDir = String(args[args.length - 1]);
1015
+ const alphaDir = path.join(cloneDir, 'packages', 'alpha');
1016
+ fs.mkdirSync(alphaDir, { recursive: true });
1017
+ fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({
1018
+ name: 'opencli-plugins-__test-transactional__',
1019
+ private: true,
1020
+ }));
1021
+ fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
1022
+ plugins: {
1023
+ alpha: { path: 'packages/alpha' },
1024
+ },
1025
+ }));
1026
+ fs.writeFileSync(path.join(alphaDir, 'hello.yaml'), 'site: test\nname: hello\n');
1027
+ return '';
1028
+ }
1029
+ if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
1030
+ throw new Error('boom');
1031
+ }
1032
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1033
+ return '1234567890abcdef1234567890abcdef12345678\n';
1034
+ }
1035
+ return '';
1036
+ });
1037
+ expect(() => installPlugin(monorepoSource)).toThrow(`npm install failed`);
1038
+ expect(fs.existsSync(monorepoRepoDir)).toBe(false);
1039
+ expect(fs.existsSync(monorepoLink)).toBe(false);
1040
+ expect(_readLockFile().alpha).toBeUndefined();
1041
+ });
1042
+ });
1043
+ describe('installPlugin with existing monorepo', () => {
1044
+ const repoName = '__test-existing-monorepo__';
1045
+ const repoDir = path.join(_getMonoreposDir(), repoName);
1046
+ const pluginName = 'beta';
1047
+ const pluginLink = path.join(PLUGINS_DIR, pluginName);
1048
+ beforeEach(() => {
1049
+ mockExecFileSync.mockClear();
1050
+ mockExecSync.mockClear();
1051
+ });
1052
+ afterEach(() => {
1053
+ try {
1054
+ fs.unlinkSync(pluginLink);
1055
+ }
1056
+ catch { }
1057
+ try {
1058
+ fs.rmSync(pluginLink, { recursive: true, force: true });
1059
+ }
1060
+ catch { }
1061
+ try {
1062
+ fs.rmSync(repoDir, { recursive: true, force: true });
1063
+ }
1064
+ catch { }
1065
+ const lock = _readLockFile();
1066
+ delete lock[pluginName];
1067
+ _writeLockFile(lock);
1068
+ vi.clearAllMocks();
1069
+ });
1070
+ it('reinstalls root dependencies when adding a sub-plugin from an existing monorepo', () => {
1071
+ const subDir = path.join(repoDir, 'packages', pluginName);
1072
+ fs.mkdirSync(subDir, { recursive: true });
1073
+ fs.writeFileSync(path.join(repoDir, 'package.json'), JSON.stringify({
1074
+ name: repoName,
1075
+ private: true,
1076
+ workspaces: ['packages/*'],
1077
+ }));
1078
+ fs.writeFileSync(path.join(repoDir, 'opencli-plugin.json'), JSON.stringify({
1079
+ plugins: {
1080
+ [pluginName]: { path: `packages/${pluginName}` },
1081
+ },
1082
+ }));
1083
+ fs.writeFileSync(path.join(subDir, 'hello.yaml'), 'site: test\nname: hello\n');
1084
+ mockExecFileSync.mockImplementation((cmd, args) => {
1085
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1086
+ const cloneDir = String(args[4]);
1087
+ fs.mkdirSync(cloneDir, { recursive: true });
1088
+ fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
1089
+ plugins: {
1090
+ [pluginName]: { path: `packages/${pluginName}` },
1091
+ },
1092
+ }));
1093
+ return '';
1094
+ }
1095
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1096
+ return '1234567890abcdef1234567890abcdef12345678\n';
1097
+ }
1098
+ return '';
1099
+ });
1100
+ installPlugin(`github:user/${repoName}/${pluginName}`);
1101
+ const npmCalls = mockExecFileSync.mock.calls.filter(([cmd, args]) => cmd === 'npm' && Array.isArray(args) && args[0] === 'install');
1102
+ expect(npmCalls.some(([, , opts]) => opts?.cwd === repoDir)).toBe(true);
1103
+ expect(fs.realpathSync(pluginLink)).toBe(fs.realpathSync(subDir));
1104
+ });
1105
+ });
1106
+ describe('updatePlugin transactional staging', () => {
1107
+ const standaloneName = '__test-transactional-update__';
1108
+ const standaloneDir = path.join(PLUGINS_DIR, standaloneName);
1109
+ const monorepoName = '__test-transactional-mono-update__';
1110
+ const monorepoRepoDir = path.join(_getMonoreposDir(), monorepoName);
1111
+ const monorepoPluginName = 'alpha-update';
1112
+ const monorepoLink = path.join(PLUGINS_DIR, monorepoPluginName);
1113
+ beforeEach(() => {
1114
+ mockExecFileSync.mockClear();
1115
+ mockExecSync.mockClear();
1116
+ });
1117
+ afterEach(() => {
1118
+ try {
1119
+ fs.unlinkSync(monorepoLink);
1120
+ }
1121
+ catch { }
1122
+ try {
1123
+ fs.rmSync(monorepoLink, { recursive: true, force: true });
1124
+ }
1125
+ catch { }
1126
+ try {
1127
+ fs.rmSync(monorepoRepoDir, { recursive: true, force: true });
1128
+ }
1129
+ catch { }
1130
+ try {
1131
+ fs.rmSync(standaloneDir, { recursive: true, force: true });
1132
+ }
1133
+ catch { }
1134
+ const lock = _readLockFile();
1135
+ delete lock[standaloneName];
1136
+ delete lock[monorepoPluginName];
1137
+ _writeLockFile(lock);
1138
+ vi.clearAllMocks();
1139
+ });
1140
+ it('keeps the existing standalone plugin when staged update preparation fails', () => {
1141
+ fs.mkdirSync(standaloneDir, { recursive: true });
1142
+ fs.writeFileSync(path.join(standaloneDir, 'old.yaml'), 'site: old\nname: old\n');
1143
+ const lock = _readLockFile();
1144
+ lock[standaloneName] = {
1145
+ source: {
1146
+ kind: 'git',
1147
+ url: 'https://github.com/user/opencli-plugin-__test-transactional-update__.git',
1148
+ },
1149
+ commitHash: 'oldhasholdhasholdhasholdhasholdhasholdh',
1150
+ installedAt: '2025-01-01T00:00:00.000Z',
1151
+ };
1152
+ _writeLockFile(lock);
1153
+ mockExecFileSync.mockImplementation((cmd, args) => {
1154
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1155
+ const cloneDir = String(args[4]);
1156
+ fs.mkdirSync(cloneDir, { recursive: true });
1157
+ fs.writeFileSync(path.join(cloneDir, 'hello.yaml'), 'site: test\nname: hello\n');
1158
+ fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ name: standaloneName }));
1159
+ return '';
1160
+ }
1161
+ if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
1162
+ throw new Error('boom');
1163
+ }
1164
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1165
+ return '1234567890abcdef1234567890abcdef12345678\n';
1166
+ }
1167
+ return '';
1168
+ });
1169
+ expect(() => updatePlugin(standaloneName)).toThrow('npm install failed');
1170
+ expect(fs.existsSync(standaloneDir)).toBe(true);
1171
+ expect(fs.readFileSync(path.join(standaloneDir, 'old.yaml'), 'utf-8')).toContain('site: old');
1172
+ expect(_readLockFile()[standaloneName]?.commitHash).toBe('oldhasholdhasholdhasholdhasholdhasholdh');
1173
+ });
1174
+ it('keeps the existing monorepo repo and link when staged update preparation fails', () => {
1175
+ const subDir = path.join(monorepoRepoDir, 'packages', monorepoPluginName);
1176
+ fs.mkdirSync(subDir, { recursive: true });
1177
+ fs.writeFileSync(path.join(subDir, 'old.yaml'), 'site: old\nname: old\n');
1178
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
1179
+ fs.symlinkSync(subDir, monorepoLink, 'dir');
1180
+ const lock = _readLockFile();
1181
+ lock[monorepoPluginName] = {
1182
+ source: {
1183
+ kind: 'monorepo',
1184
+ url: 'https://github.com/user/opencli-plugins-__test-transactional-mono-update__.git',
1185
+ repoName: monorepoName,
1186
+ subPath: `packages/${monorepoPluginName}`,
1187
+ },
1188
+ commitHash: 'oldmonooldmonooldmonooldmonooldmonoold',
1189
+ installedAt: '2025-01-01T00:00:00.000Z',
1190
+ };
1191
+ _writeLockFile(lock);
1192
+ mockExecFileSync.mockImplementation((cmd, args) => {
1193
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1194
+ const cloneDir = String(args[4]);
1195
+ const alphaDir = path.join(cloneDir, 'packages', monorepoPluginName);
1196
+ fs.mkdirSync(alphaDir, { recursive: true });
1197
+ fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({
1198
+ name: 'opencli-plugins-__test-transactional-mono-update__',
1199
+ private: true,
1200
+ }));
1201
+ fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
1202
+ plugins: {
1203
+ [monorepoPluginName]: { path: `packages/${monorepoPluginName}` },
1204
+ },
1205
+ }));
1206
+ fs.writeFileSync(path.join(alphaDir, 'hello.yaml'), 'site: test\nname: hello\n');
1207
+ return '';
1208
+ }
1209
+ if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
1210
+ throw new Error('boom');
1211
+ }
1212
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1213
+ return '1234567890abcdef1234567890abcdef12345678\n';
1214
+ }
1215
+ return '';
1216
+ });
1217
+ expect(() => updatePlugin(monorepoPluginName)).toThrow('npm install failed');
1218
+ expect(fs.existsSync(monorepoRepoDir)).toBe(true);
1219
+ expect(fs.existsSync(monorepoLink)).toBe(true);
1220
+ expect(fs.readFileSync(path.join(subDir, 'old.yaml'), 'utf-8')).toContain('site: old');
1221
+ expect(_readLockFile()[monorepoPluginName]?.commitHash).toBe('oldmonooldmonooldmonooldmonooldmonoold');
1222
+ });
1223
+ it('relinks monorepo plugins when the updated manifest moves their subPath', () => {
1224
+ const oldSubDir = path.join(monorepoRepoDir, 'packages', 'old-alpha');
1225
+ fs.mkdirSync(oldSubDir, { recursive: true });
1226
+ fs.writeFileSync(path.join(oldSubDir, 'old.yaml'), 'site: old\nname: old\n');
1227
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
1228
+ fs.symlinkSync(oldSubDir, monorepoLink, 'dir');
1229
+ const lock = _readLockFile();
1230
+ lock[monorepoPluginName] = {
1231
+ source: {
1232
+ kind: 'monorepo',
1233
+ url: 'https://github.com/user/opencli-plugins-__test-transactional-mono-update__.git',
1234
+ repoName: monorepoName,
1235
+ subPath: 'packages/old-alpha',
1236
+ },
1237
+ commitHash: 'oldmonooldmonooldmonooldmonooldmonoold',
1238
+ installedAt: '2025-01-01T00:00:00.000Z',
1239
+ };
1240
+ _writeLockFile(lock);
1241
+ mockExecFileSync.mockImplementation((cmd, args) => {
1242
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1243
+ const cloneDir = String(args[4]);
1244
+ const movedDir = path.join(cloneDir, 'packages', 'moved-alpha');
1245
+ fs.mkdirSync(movedDir, { recursive: true });
1246
+ fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
1247
+ plugins: {
1248
+ [monorepoPluginName]: { path: 'packages/moved-alpha' },
1249
+ },
1250
+ }));
1251
+ fs.writeFileSync(path.join(movedDir, 'hello.yaml'), 'site: test\nname: hello\n');
1252
+ return '';
1253
+ }
1254
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1255
+ return '1234567890abcdef1234567890abcdef12345678\n';
1256
+ }
1257
+ return '';
1258
+ });
1259
+ updatePlugin(monorepoPluginName);
1260
+ const expectedTarget = path.join(monorepoRepoDir, 'packages', 'moved-alpha');
1261
+ expect(fs.realpathSync(monorepoLink)).toBe(fs.realpathSync(expectedTarget));
1262
+ expect(_readLockFile()[monorepoPluginName]?.source).toMatchObject({
1263
+ kind: 'monorepo',
1264
+ subPath: 'packages/moved-alpha',
1265
+ });
1266
+ });
1267
+ it('rolls back the monorepo repo swap when relinking fails', () => {
1268
+ const oldSubDir = path.join(monorepoRepoDir, 'packages', 'old-alpha');
1269
+ fs.mkdirSync(oldSubDir, { recursive: true });
1270
+ fs.writeFileSync(path.join(oldSubDir, 'old.yaml'), 'site: old\nname: old\n');
1271
+ fs.mkdirSync(monorepoLink, { recursive: true });
1272
+ fs.writeFileSync(path.join(monorepoLink, 'blocker.txt'), 'not a symlink');
1273
+ const lock = _readLockFile();
1274
+ lock[monorepoPluginName] = {
1275
+ source: {
1276
+ kind: 'monorepo',
1277
+ url: 'https://github.com/user/opencli-plugins-__test-transactional-mono-update__.git',
1278
+ repoName: monorepoName,
1279
+ subPath: 'packages/old-alpha',
1280
+ },
1281
+ commitHash: 'oldmonooldmonooldmonooldmonooldmonoold',
1282
+ installedAt: '2025-01-01T00:00:00.000Z',
1283
+ };
1284
+ _writeLockFile(lock);
1285
+ mockExecFileSync.mockImplementation((cmd, args) => {
1286
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
1287
+ const cloneDir = String(args[4]);
1288
+ const movedDir = path.join(cloneDir, 'packages', 'moved-alpha');
1289
+ fs.mkdirSync(movedDir, { recursive: true });
1290
+ fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
1291
+ plugins: {
1292
+ [monorepoPluginName]: { path: 'packages/moved-alpha' },
1293
+ },
1294
+ }));
1295
+ fs.writeFileSync(path.join(movedDir, 'hello.yaml'), 'site: test\nname: hello\n');
1296
+ return '';
1297
+ }
1298
+ if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
1299
+ return '1234567890abcdef1234567890abcdef12345678\n';
1300
+ }
1301
+ return '';
1302
+ });
1303
+ expect(() => updatePlugin(monorepoPluginName)).toThrow('to be a symlink');
1304
+ expect(fs.existsSync(path.join(monorepoRepoDir, 'packages', 'old-alpha', 'old.yaml'))).toBe(true);
1305
+ expect(fs.existsSync(path.join(monorepoRepoDir, 'packages', 'moved-alpha'))).toBe(false);
1306
+ expect(fs.readFileSync(path.join(monorepoLink, 'blocker.txt'), 'utf-8')).toBe('not a symlink');
1307
+ expect(_readLockFile()[monorepoPluginName]?.source).toMatchObject({
1308
+ kind: 'monorepo',
1309
+ subPath: 'packages/old-alpha',
1310
+ });
1311
+ });
1312
+ });