@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.
- package/.github/workflows/build-extension.yml +2 -6
- package/.github/workflows/ci.yml +21 -1
- package/README.md +35 -6
- package/README.zh-CN.md +12 -5
- package/SKILL.md +2 -0
- package/dist/browser/cdp.d.ts +2 -1
- package/dist/browser/cdp.js +5 -0
- package/dist/browser/discover.d.ts +4 -1
- package/dist/browser/discover.js +6 -2
- package/dist/browser/errors.d.ts +2 -2
- package/dist/browser/errors.js +4 -12
- package/dist/browser/mcp.d.ts +2 -1
- package/dist/browser/page.d.ts +3 -0
- package/dist/browser/page.js +24 -1
- package/dist/build-manifest.d.ts +2 -0
- package/dist/build-manifest.js +39 -14
- package/dist/build-manifest.test.js +21 -0
- package/dist/capabilityRouting.d.ts +2 -0
- package/dist/capabilityRouting.js +2 -1
- package/dist/cli-manifest.json +1567 -108
- package/dist/cli.js +68 -6
- package/dist/clis/36kr/article.d.ts +1 -0
- package/dist/clis/36kr/article.js +62 -0
- package/dist/clis/36kr/hot.d.ts +3 -0
- package/dist/clis/36kr/hot.js +80 -0
- package/dist/clis/36kr/hot.test.d.ts +1 -0
- package/dist/clis/36kr/hot.test.js +15 -0
- package/dist/clis/36kr/news.d.ts +1 -0
- package/dist/clis/36kr/news.js +51 -0
- package/dist/clis/36kr/news.test.d.ts +1 -0
- package/dist/clis/36kr/news.test.js +85 -0
- package/dist/clis/36kr/search.d.ts +1 -0
- package/dist/clis/36kr/search.js +72 -0
- package/dist/clis/bilibili/comments.d.ts +5 -0
- package/dist/clis/bilibili/comments.js +40 -0
- package/dist/clis/bilibili/comments.test.d.ts +1 -0
- package/dist/clis/bilibili/comments.test.js +82 -0
- package/dist/clis/bluesky/feeds.yaml +29 -0
- package/dist/clis/bluesky/followers.yaml +33 -0
- package/dist/clis/bluesky/following.yaml +33 -0
- package/dist/clis/bluesky/profile.yaml +27 -0
- package/dist/clis/bluesky/search.yaml +34 -0
- package/dist/clis/bluesky/starter-packs.yaml +34 -0
- package/dist/clis/bluesky/thread.yaml +32 -0
- package/dist/clis/bluesky/trending.yaml +27 -0
- package/dist/clis/bluesky/user.yaml +34 -0
- package/dist/clis/chatgpt/ask.js +29 -14
- package/dist/clis/chatgpt/ax.d.ts +6 -0
- package/dist/clis/chatgpt/ax.js +172 -1
- package/dist/clis/chatgpt/model.d.ts +1 -0
- package/dist/clis/chatgpt/model.js +24 -0
- package/dist/clis/chatgpt/send.js +12 -3
- package/dist/clis/douban/download.d.ts +1 -0
- package/dist/clis/douban/download.js +67 -0
- package/dist/clis/douban/download.test.d.ts +1 -0
- package/dist/clis/douban/download.test.js +170 -0
- package/dist/clis/douban/photos.d.ts +1 -0
- package/dist/clis/douban/photos.js +34 -0
- package/dist/clis/douban/utils.d.ts +25 -0
- package/dist/clis/douban/utils.js +190 -1
- package/dist/clis/douban/utils.test.d.ts +1 -0
- package/dist/clis/douban/utils.test.js +64 -0
- package/dist/clis/imdb/person.d.ts +1 -0
- package/dist/clis/imdb/person.js +203 -0
- package/dist/clis/imdb/reviews.d.ts +1 -0
- package/dist/clis/imdb/reviews.js +88 -0
- package/dist/clis/imdb/search.d.ts +1 -0
- package/dist/clis/imdb/search.js +161 -0
- package/dist/clis/imdb/title.d.ts +1 -0
- package/dist/clis/imdb/title.js +93 -0
- package/dist/clis/imdb/top.d.ts +1 -0
- package/dist/clis/imdb/top.js +53 -0
- package/dist/clis/imdb/trending.d.ts +1 -0
- package/dist/clis/imdb/trending.js +52 -0
- package/dist/clis/imdb/utils.d.ts +46 -0
- package/dist/clis/imdb/utils.js +285 -0
- package/dist/clis/imdb/utils.test.d.ts +1 -0
- package/dist/clis/imdb/utils.test.js +88 -0
- package/dist/clis/jd/item.d.ts +4 -0
- package/dist/clis/jd/item.js +16 -15
- package/dist/clis/jd/item.test.js +16 -1
- package/dist/clis/linux-do/categories.yaml +38 -9
- package/dist/clis/linux-do/category.d.ts +1 -0
- package/dist/clis/linux-do/category.js +36 -0
- package/dist/clis/linux-do/feed.d.ts +45 -0
- package/dist/clis/linux-do/feed.js +397 -0
- package/dist/clis/linux-do/feed.test.d.ts +1 -0
- package/dist/clis/linux-do/feed.test.js +118 -0
- package/dist/clis/linux-do/hot.d.ts +1 -0
- package/dist/clis/linux-do/hot.js +25 -0
- package/dist/clis/linux-do/latest.d.ts +1 -0
- package/dist/clis/linux-do/latest.js +18 -0
- package/dist/clis/linux-do/tags.yaml +41 -0
- package/dist/clis/linux-do/topic.yaml +41 -3
- package/dist/clis/linux-do/user-posts.yaml +67 -0
- package/dist/clis/linux-do/user-topics.yaml +54 -0
- package/dist/clis/paperreview/commands.test.d.ts +3 -0
- package/dist/clis/paperreview/commands.test.js +243 -0
- package/dist/clis/paperreview/feedback.d.ts +1 -0
- package/dist/clis/paperreview/feedback.js +52 -0
- package/dist/clis/paperreview/review.d.ts +1 -0
- package/dist/clis/paperreview/review.js +37 -0
- package/dist/clis/paperreview/submit.d.ts +1 -0
- package/dist/clis/paperreview/submit.js +85 -0
- package/dist/clis/paperreview/utils.d.ts +46 -0
- package/dist/clis/paperreview/utils.js +197 -0
- package/dist/clis/paperreview/utils.test.d.ts +1 -0
- package/dist/clis/paperreview/utils.test.js +49 -0
- package/dist/clis/producthunt/browse.d.ts +1 -0
- package/dist/clis/producthunt/browse.js +99 -0
- package/dist/clis/producthunt/hot.d.ts +1 -0
- package/dist/clis/producthunt/hot.js +110 -0
- package/dist/clis/producthunt/posts.d.ts +1 -0
- package/dist/clis/producthunt/posts.js +28 -0
- package/dist/clis/producthunt/today.d.ts +1 -0
- package/dist/clis/producthunt/today.js +35 -0
- package/dist/clis/producthunt/utils.d.ts +29 -0
- package/dist/clis/producthunt/utils.js +99 -0
- package/dist/clis/producthunt/utils.test.d.ts +1 -0
- package/dist/clis/producthunt/utils.test.js +64 -0
- package/dist/clis/twitter/article.js +4 -28
- package/dist/clis/twitter/likes.d.ts +24 -0
- package/dist/clis/twitter/likes.js +217 -0
- package/dist/clis/twitter/likes.test.d.ts +1 -0
- package/dist/clis/twitter/likes.test.js +85 -0
- package/dist/clis/twitter/profile.js +4 -28
- package/dist/clis/twitter/search.js +2 -1
- package/dist/clis/twitter/search.test.js +2 -0
- package/dist/clis/twitter/shared.d.ts +6 -0
- package/dist/clis/twitter/shared.js +35 -0
- package/dist/clis/twitter/timeline.js +2 -13
- package/dist/clis/twitter/trending.js +29 -61
- package/dist/clis/v2ex/hot.yaml +17 -3
- package/dist/clis/weixin/download.d.ts +17 -0
- package/dist/clis/weixin/download.js +88 -20
- package/dist/clis/weread/book.js +2 -2
- package/dist/clis/weread/commands.test.d.ts +3 -0
- package/dist/clis/weread/commands.test.js +43 -0
- package/dist/clis/weread/highlights.js +2 -2
- package/dist/clis/weread/notebooks.js +2 -2
- package/dist/clis/weread/notes.js +3 -3
- package/dist/clis/weread/shelf.js +2 -2
- package/dist/clis/weread/utils.d.ts +4 -4
- package/dist/clis/weread/utils.js +32 -14
- package/dist/clis/weread/utils.test.js +1 -28
- package/dist/clis/xiaohongshu/comments.d.ts +5 -0
- package/dist/clis/xiaohongshu/comments.js +74 -0
- package/dist/clis/xiaohongshu/comments.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/comments.test.js +79 -0
- package/dist/clis/xiaohongshu/publish.js +179 -47
- package/dist/clis/xiaohongshu/publish.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/publish.test.js +131 -0
- package/dist/clis/xiaohongshu/search.d.ts +8 -1
- package/dist/clis/xiaohongshu/search.js +20 -1
- package/dist/clis/xiaohongshu/search.test.d.ts +1 -1
- package/dist/clis/xiaohongshu/search.test.js +32 -1
- package/dist/commanderAdapter.d.ts +1 -0
- package/dist/commanderAdapter.js +176 -29
- package/dist/commanderAdapter.test.d.ts +1 -0
- package/dist/commanderAdapter.test.js +62 -0
- package/dist/daemon.js +17 -1
- package/dist/discovery.js +48 -42
- package/dist/doctor.d.ts +2 -2
- package/dist/doctor.js +11 -4
- package/dist/download/index.js +63 -51
- package/dist/download/index.test.js +17 -4
- package/dist/engine.test.js +42 -0
- package/dist/errors.d.ts +4 -2
- package/dist/errors.js +17 -34
- package/dist/execution.d.ts +1 -3
- package/dist/execution.js +66 -8
- package/dist/execution.test.d.ts +1 -0
- package/dist/execution.test.js +40 -0
- package/dist/external.js +6 -1
- package/dist/hooks.js +2 -0
- package/dist/main.js +6 -0
- package/dist/output.js +5 -1
- package/dist/pipeline/executor.js +3 -4
- package/dist/plugin-manifest.d.ts +70 -0
- package/dist/plugin-manifest.js +160 -0
- package/dist/plugin-manifest.test.d.ts +4 -0
- package/dist/plugin-manifest.test.js +179 -0
- package/dist/plugin-scaffold.d.ts +28 -0
- package/dist/plugin-scaffold.js +142 -0
- package/dist/plugin-scaffold.test.d.ts +4 -0
- package/dist/plugin-scaffold.test.js +83 -0
- package/dist/plugin.d.ts +82 -11
- package/dist/plugin.js +870 -84
- package/dist/plugin.test.js +1032 -17
- package/dist/registry.d.ts +4 -0
- package/dist/registry.js +2 -0
- package/dist/runtime-detect.d.ts +21 -0
- package/dist/runtime-detect.js +32 -0
- package/dist/runtime-detect.test.d.ts +1 -0
- package/dist/runtime-detect.test.js +27 -0
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.js +2 -2
- package/dist/serialization.d.ts +2 -0
- package/dist/serialization.js +6 -0
- package/dist/types.d.ts +3 -0
- package/dist/update-check.d.ts +22 -0
- package/dist/update-check.js +112 -0
- package/dist/weixin-download.test.d.ts +1 -0
- package/dist/weixin-download.test.js +30 -0
- package/dist/weread-private-api-regression.test.d.ts +1 -0
- package/dist/weread-private-api-regression.test.js +122 -0
- package/dist/yaml-schema.d.ts +3 -0
- package/dist/yaml-schema.js +18 -1
- package/docs/.vitepress/config.mts +4 -0
- package/docs/adapters/browser/36kr.md +47 -0
- package/docs/adapters/browser/bluesky.md +53 -0
- package/docs/adapters/browser/douban.md +14 -0
- package/docs/adapters/browser/imdb.md +47 -0
- package/docs/adapters/browser/jd.md +2 -2
- package/docs/adapters/browser/linux-do.md +181 -20
- package/docs/adapters/browser/paperreview.md +43 -0
- package/docs/adapters/browser/producthunt.md +49 -0
- package/docs/adapters/desktop/chatgpt.md +5 -0
- package/docs/adapters/index.md +6 -2
- package/docs/advanced/download.md +4 -0
- package/docs/advanced/rate-limiter-plugin.md +99 -0
- package/docs/guide/electron-app-cli.md +200 -0
- package/docs/guide/getting-started.md +1 -0
- package/docs/guide/plugins.md +97 -0
- package/docs/zh/guide/electron-app-cli.md +188 -0
- package/docs/zh/guide/getting-started.md +1 -0
- package/docs/zh/guide/plugins.md +65 -0
- package/extension/package.json +1 -0
- package/extension/scripts/package-release.mjs +179 -0
- package/extension/src/background.ts +2 -0
- package/package.json +4 -1
- package/scripts/postinstall.js +10 -0
- package/src/browser/cdp.ts +8 -1
- package/src/browser/discover.ts +8 -3
- package/src/browser/errors.ts +13 -14
- package/src/browser/mcp.ts +2 -1
- package/src/browser/page.ts +24 -1
- package/src/build-manifest.test.ts +23 -0
- package/src/build-manifest.ts +40 -15
- package/src/capabilityRouting.ts +2 -1
- package/src/cli.ts +69 -6
- package/src/clis/36kr/article.ts +69 -0
- package/src/clis/36kr/hot.test.ts +19 -0
- package/src/clis/36kr/hot.ts +100 -0
- package/src/clis/36kr/news.test.ts +90 -0
- package/src/clis/36kr/news.ts +54 -0
- package/src/clis/36kr/search.ts +78 -0
- package/src/clis/bilibili/comments.test.ts +102 -0
- package/src/clis/bilibili/comments.ts +44 -0
- package/src/clis/bluesky/feeds.yaml +29 -0
- package/src/clis/bluesky/followers.yaml +33 -0
- package/src/clis/bluesky/following.yaml +33 -0
- package/src/clis/bluesky/profile.yaml +27 -0
- package/src/clis/bluesky/search.yaml +34 -0
- package/src/clis/bluesky/starter-packs.yaml +34 -0
- package/src/clis/bluesky/thread.yaml +32 -0
- package/src/clis/bluesky/trending.yaml +27 -0
- package/src/clis/bluesky/user.yaml +34 -0
- package/src/clis/chatgpt/ask.ts +28 -14
- package/src/clis/chatgpt/ax.ts +180 -1
- package/src/clis/chatgpt/model.ts +27 -0
- package/src/clis/chatgpt/send.ts +16 -6
- package/src/clis/douban/download.test.ts +196 -0
- package/src/clis/douban/download.ts +78 -0
- package/src/clis/douban/photos.ts +36 -0
- package/src/clis/douban/utils.test.ts +97 -0
- package/src/clis/douban/utils.ts +232 -1
- package/src/clis/imdb/person.ts +232 -0
- package/src/clis/imdb/reviews.ts +111 -0
- package/src/clis/imdb/search.ts +179 -0
- package/src/clis/imdb/title.ts +121 -0
- package/src/clis/imdb/top.ts +67 -0
- package/src/clis/imdb/trending.ts +66 -0
- package/src/clis/imdb/utils.test.ts +117 -0
- package/src/clis/imdb/utils.ts +305 -0
- package/src/clis/jd/item.test.ts +18 -1
- package/src/clis/jd/item.ts +18 -15
- package/src/clis/linux-do/categories.yaml +38 -9
- package/src/clis/linux-do/category.ts +37 -0
- package/src/clis/linux-do/feed.test.ts +132 -0
- package/src/clis/linux-do/feed.ts +501 -0
- package/src/clis/linux-do/hot.ts +26 -0
- package/src/clis/linux-do/latest.ts +19 -0
- package/src/clis/linux-do/tags.yaml +41 -0
- package/src/clis/linux-do/topic.yaml +41 -3
- package/src/clis/linux-do/user-posts.yaml +67 -0
- package/src/clis/linux-do/user-topics.yaml +54 -0
- package/src/clis/paperreview/commands.test.ts +283 -0
- package/src/clis/paperreview/feedback.ts +64 -0
- package/src/clis/paperreview/review.ts +47 -0
- package/src/clis/paperreview/submit.ts +119 -0
- package/src/clis/paperreview/utils.test.ts +68 -0
- package/src/clis/paperreview/utils.ts +276 -0
- package/src/clis/producthunt/browse.ts +109 -0
- package/src/clis/producthunt/hot.ts +127 -0
- package/src/clis/producthunt/posts.ts +29 -0
- package/src/clis/producthunt/today.ts +37 -0
- package/src/clis/producthunt/utils.test.ts +72 -0
- package/src/clis/producthunt/utils.ts +122 -0
- package/src/clis/twitter/article.ts +5 -28
- package/src/clis/twitter/likes.test.ts +91 -0
- package/src/clis/twitter/likes.ts +256 -0
- package/src/clis/twitter/profile.ts +5 -28
- package/src/clis/twitter/search.test.ts +2 -0
- package/src/clis/twitter/search.ts +3 -1
- package/src/clis/twitter/shared.ts +45 -0
- package/src/clis/twitter/timeline.ts +2 -13
- package/src/clis/twitter/trending.ts +29 -77
- package/src/clis/v2ex/hot.yaml +17 -3
- package/src/clis/weixin/download.ts +114 -20
- package/src/clis/weread/book.ts +2 -2
- package/src/clis/weread/commands.test.ts +57 -0
- package/src/clis/weread/highlights.ts +2 -2
- package/src/clis/weread/notebooks.ts +2 -2
- package/src/clis/weread/notes.ts +3 -3
- package/src/clis/weread/shelf.ts +2 -2
- package/src/clis/weread/utils.test.ts +1 -32
- package/src/clis/weread/utils.ts +41 -16
- package/src/clis/xiaohongshu/comments.test.ts +96 -0
- package/src/clis/xiaohongshu/comments.ts +81 -0
- package/src/clis/xiaohongshu/publish.test.ts +151 -0
- package/src/clis/xiaohongshu/publish.ts +206 -54
- package/src/clis/xiaohongshu/search.test.ts +39 -1
- package/src/clis/xiaohongshu/search.ts +19 -1
- package/src/commanderAdapter.test.ts +78 -0
- package/src/commanderAdapter.ts +188 -24
- package/src/daemon.ts +19 -1
- package/src/discovery.ts +49 -48
- package/src/doctor.ts +15 -5
- package/src/download/index.test.ts +14 -4
- package/src/download/index.ts +67 -55
- package/src/engine.test.ts +38 -0
- package/src/errors.ts +26 -63
- package/src/execution.test.ts +47 -0
- package/src/execution.ts +67 -9
- package/src/external.ts +6 -1
- package/src/hooks.ts +1 -0
- package/src/main.ts +7 -0
- package/src/output.ts +3 -1
- package/src/pipeline/executor.ts +4 -6
- package/src/plugin-manifest.test.ts +223 -0
- package/src/plugin-manifest.ts +206 -0
- package/src/plugin-scaffold.test.ts +98 -0
- package/src/plugin-scaffold.ts +170 -0
- package/src/plugin.test.ts +1104 -17
- package/src/plugin.ts +1101 -86
- package/src/registry.ts +6 -1
- package/src/runtime-detect.test.ts +30 -0
- package/src/runtime-detect.ts +36 -0
- package/src/runtime.ts +3 -3
- package/src/serialization.ts +4 -0
- package/src/types.ts +3 -0
- package/src/update-check.ts +114 -0
- package/src/weixin-download.test.ts +64 -0
- package/src/weread-private-api-regression.test.ts +150 -0
- package/src/yaml-schema.ts +20 -0
- package/tests/e2e/browser-auth.test.ts +13 -9
- package/tests/e2e/browser-public-extended.test.ts +1 -1
- package/tests/e2e/browser-public.test.ts +62 -4
- package/tests/e2e/helpers.ts +2 -1
- package/tests/e2e/public-commands.test.ts +37 -3
- package/tests/smoke/api-health.test.ts +1 -1
- package/vitest.config.ts +10 -0
- package/dist/clis/linux-do/category.yaml +0 -51
- package/dist/clis/linux-do/hot.yaml +0 -50
- package/dist/clis/linux-do/latest.yaml +0 -40
- package/src/clis/linux-do/category.yaml +0 -51
- package/src/clis/linux-do/hot.yaml +0 -50
- package/src/clis/linux-do/latest.yaml +0 -40
package/src/plugin.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* Plugin management: install, uninstall, and list plugins.
|
|
3
3
|
*
|
|
4
4
|
* Plugins live in ~/.opencli/plugins/<name>/.
|
|
5
|
-
*
|
|
5
|
+
* Monorepo clones live in ~/.opencli/monorepos/<repo-name>/.
|
|
6
|
+
* Install source format: "github:user/repo", "github:user/repo/subplugin",
|
|
7
|
+
* "https://github.com/user/repo", "file:///local/plugin", or a local directory path.
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
10
|
import * as fs from 'node:fs';
|
|
@@ -13,8 +15,16 @@ import { fileURLToPath } from 'node:url';
|
|
|
13
15
|
import { PLUGINS_DIR } from './discovery.js';
|
|
14
16
|
import { getErrorMessage } from './errors.js';
|
|
15
17
|
import { log } from './logger.js';
|
|
18
|
+
import {
|
|
19
|
+
readPluginManifest,
|
|
20
|
+
isMonorepo,
|
|
21
|
+
getEnabledPlugins,
|
|
22
|
+
checkCompatibility,
|
|
23
|
+
type PluginManifest,
|
|
24
|
+
} from './plugin-manifest.js';
|
|
16
25
|
|
|
17
26
|
const isWindows = process.platform === 'win32';
|
|
27
|
+
const LOCAL_PLUGIN_SOURCE_PREFIX = 'local:';
|
|
18
28
|
|
|
19
29
|
/** Get home directory, respecting HOME environment variable for test isolation. */
|
|
20
30
|
function getHomeDir(): string {
|
|
@@ -26,11 +36,18 @@ export function getLockFilePath(): string {
|
|
|
26
36
|
return path.join(getHomeDir(), '.opencli', 'plugins.lock.json');
|
|
27
37
|
}
|
|
28
38
|
|
|
29
|
-
|
|
30
|
-
export
|
|
39
|
+
/** Monorepo clones directory: ~/.opencli/monorepos/ */
|
|
40
|
+
export function getMonoreposDir(): string {
|
|
41
|
+
return path.join(getHomeDir(), '.opencli', 'monorepos');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type PluginSourceRecord =
|
|
45
|
+
| { kind: 'git'; url: string }
|
|
46
|
+
| { kind: 'local'; path: string }
|
|
47
|
+
| { kind: 'monorepo'; url: string; repoName: string; subPath: string };
|
|
31
48
|
|
|
32
49
|
export interface LockEntry {
|
|
33
|
-
source:
|
|
50
|
+
source: PluginSourceRecord;
|
|
34
51
|
commitHash: string;
|
|
35
52
|
installedAt: string;
|
|
36
53
|
updatedAt?: string;
|
|
@@ -43,6 +60,386 @@ export interface PluginInfo {
|
|
|
43
60
|
source?: string;
|
|
44
61
|
version?: string;
|
|
45
62
|
installedAt?: string;
|
|
63
|
+
/** If from a monorepo, the monorepo name. */
|
|
64
|
+
monorepoName?: string;
|
|
65
|
+
/** Description from opencli-plugin.json. */
|
|
66
|
+
description?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface ParsedSource {
|
|
70
|
+
type: 'git' | 'local';
|
|
71
|
+
name: string;
|
|
72
|
+
subPlugin?: string;
|
|
73
|
+
cloneUrl?: string;
|
|
74
|
+
localPath?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseStoredPluginSource(source?: string): PluginSourceRecord | undefined {
|
|
78
|
+
if (!source) return undefined;
|
|
79
|
+
if (source.startsWith(LOCAL_PLUGIN_SOURCE_PREFIX)) {
|
|
80
|
+
return {
|
|
81
|
+
kind: 'local',
|
|
82
|
+
path: path.resolve(source.slice(LOCAL_PLUGIN_SOURCE_PREFIX.length)),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return { kind: 'git', url: source };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isLocalPluginSource(source?: string): boolean {
|
|
89
|
+
return parseStoredPluginSource(source)?.kind === 'local';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function toStoredPluginSource(source: PluginSourceRecord): string {
|
|
93
|
+
if (source.kind === 'local') {
|
|
94
|
+
return `${LOCAL_PLUGIN_SOURCE_PREFIX}${path.resolve(source.path)}`;
|
|
95
|
+
}
|
|
96
|
+
return source.url;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function toLocalPluginSource(pluginDir: string): string {
|
|
100
|
+
return toStoredPluginSource({ kind: 'local', path: pluginDir });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
104
|
+
return typeof value === 'object' && value !== null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalizeLegacyMonorepo(
|
|
108
|
+
value: unknown,
|
|
109
|
+
): { name: string; subPath: string } | undefined {
|
|
110
|
+
if (!isRecord(value)) return undefined;
|
|
111
|
+
if (typeof value.name !== 'string' || typeof value.subPath !== 'string') return undefined;
|
|
112
|
+
return { name: value.name, subPath: value.subPath };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizePluginSource(
|
|
116
|
+
source: unknown,
|
|
117
|
+
legacyMonorepo?: { name: string; subPath: string },
|
|
118
|
+
): PluginSourceRecord | undefined {
|
|
119
|
+
if (typeof source === 'string') {
|
|
120
|
+
const parsed = parseStoredPluginSource(source);
|
|
121
|
+
if (!parsed) return undefined;
|
|
122
|
+
if (parsed.kind === 'git' && legacyMonorepo) {
|
|
123
|
+
return {
|
|
124
|
+
kind: 'monorepo',
|
|
125
|
+
url: parsed.url,
|
|
126
|
+
repoName: legacyMonorepo.name,
|
|
127
|
+
subPath: legacyMonorepo.subPath,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return parsed;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!isRecord(source) || typeof source.kind !== 'string') return undefined;
|
|
134
|
+
switch (source.kind) {
|
|
135
|
+
case 'git':
|
|
136
|
+
return typeof source.url === 'string'
|
|
137
|
+
? { kind: 'git', url: source.url }
|
|
138
|
+
: undefined;
|
|
139
|
+
case 'local':
|
|
140
|
+
return typeof source.path === 'string'
|
|
141
|
+
? { kind: 'local', path: path.resolve(source.path) }
|
|
142
|
+
: undefined;
|
|
143
|
+
case 'monorepo':
|
|
144
|
+
return typeof source.url === 'string'
|
|
145
|
+
&& typeof source.repoName === 'string'
|
|
146
|
+
&& typeof source.subPath === 'string'
|
|
147
|
+
? {
|
|
148
|
+
kind: 'monorepo',
|
|
149
|
+
url: source.url,
|
|
150
|
+
repoName: source.repoName,
|
|
151
|
+
subPath: source.subPath,
|
|
152
|
+
}
|
|
153
|
+
: undefined;
|
|
154
|
+
default:
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizeLockEntry(value: unknown): LockEntry | undefined {
|
|
160
|
+
if (!isRecord(value)) return undefined;
|
|
161
|
+
|
|
162
|
+
const legacyMonorepo = normalizeLegacyMonorepo(value.monorepo);
|
|
163
|
+
const source = normalizePluginSource(value.source, legacyMonorepo);
|
|
164
|
+
if (!source) return undefined;
|
|
165
|
+
if (typeof value.commitHash !== 'string' || typeof value.installedAt !== 'string') {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const entry: LockEntry = {
|
|
170
|
+
source,
|
|
171
|
+
commitHash: value.commitHash,
|
|
172
|
+
installedAt: value.installedAt,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
if (typeof value.updatedAt === 'string') {
|
|
176
|
+
entry.updatedAt = value.updatedAt;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return entry;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function resolvePluginSource(lockEntry: LockEntry | undefined, pluginDir: string): PluginSourceRecord | undefined {
|
|
183
|
+
if (lockEntry) {
|
|
184
|
+
return lockEntry.source;
|
|
185
|
+
}
|
|
186
|
+
return parseStoredPluginSource(getPluginSource(pluginDir));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function resolveStoredPluginSource(lockEntry: LockEntry | undefined, pluginDir: string): string | undefined {
|
|
190
|
+
const source = resolvePluginSource(lockEntry, pluginDir);
|
|
191
|
+
return source ? toStoredPluginSource(source) : undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Filesystem helpers ──────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Move a directory, with EXDEV fallback.
|
|
198
|
+
* fs.renameSync fails when source and destination are on different
|
|
199
|
+
* filesystems (e.g. /tmp → ~/.opencli). In that case we copy then remove.
|
|
200
|
+
*/
|
|
201
|
+
type MoveDirFsOps = Pick<typeof fs, 'renameSync' | 'cpSync' | 'rmSync'>;
|
|
202
|
+
|
|
203
|
+
function moveDir(src: string, dest: string, fsOps: MoveDirFsOps = fs): void {
|
|
204
|
+
try {
|
|
205
|
+
fsOps.renameSync(src, dest);
|
|
206
|
+
} catch (err: unknown) {
|
|
207
|
+
if ((err as NodeJS.ErrnoException).code === 'EXDEV') {
|
|
208
|
+
try {
|
|
209
|
+
fsOps.cpSync(src, dest, { recursive: true });
|
|
210
|
+
} catch (copyErr) {
|
|
211
|
+
try { fsOps.rmSync(dest, { recursive: true, force: true }); } catch {}
|
|
212
|
+
throw copyErr;
|
|
213
|
+
}
|
|
214
|
+
fsOps.rmSync(src, { recursive: true, force: true });
|
|
215
|
+
} else {
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
type PromoteDirFsOps = MoveDirFsOps & Pick<typeof fs, 'existsSync' | 'mkdirSync'>;
|
|
222
|
+
|
|
223
|
+
function createSiblingTempPath(dest: string, kind: 'tmp' | 'bak'): string {
|
|
224
|
+
const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
225
|
+
return path.join(path.dirname(dest), `.${path.basename(dest)}.${kind}-${suffix}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Promote a prepared staging directory into its final location.
|
|
230
|
+
* The final path is only exposed after the directory has been fully prepared.
|
|
231
|
+
*/
|
|
232
|
+
function promoteDir(stagingDir: string, dest: string, fsOps: PromoteDirFsOps = fs): void {
|
|
233
|
+
if (fsOps.existsSync(dest)) {
|
|
234
|
+
throw new Error(`Destination already exists: ${dest}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
fsOps.mkdirSync(path.dirname(dest), { recursive: true });
|
|
238
|
+
const tempDest = createSiblingTempPath(dest, 'tmp');
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
moveDir(stagingDir, tempDest, fsOps);
|
|
242
|
+
fsOps.renameSync(tempDest, dest);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
try { fsOps.rmSync(tempDest, { recursive: true, force: true }); } catch {}
|
|
245
|
+
throw err;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function replaceDir(stagingDir: string, dest: string, fsOps: PromoteDirFsOps = fs): void {
|
|
250
|
+
const replacement = beginReplaceDir(stagingDir, dest, fsOps);
|
|
251
|
+
replacement.finalize();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function cloneRepoToTemp(cloneUrl: string): string {
|
|
255
|
+
const tmpCloneDir = path.join(
|
|
256
|
+
os.tmpdir(),
|
|
257
|
+
`opencli-clone-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
execFileSync('git', ['clone', '--depth', '1', cloneUrl, tmpCloneDir], {
|
|
262
|
+
encoding: 'utf-8',
|
|
263
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
264
|
+
});
|
|
265
|
+
} catch (err) {
|
|
266
|
+
throw new Error(`Failed to clone plugin: ${getErrorMessage(err)}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return tmpCloneDir;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function withTempClone<T>(cloneUrl: string, work: (cloneDir: string) => T): T {
|
|
273
|
+
const tmpCloneDir = cloneRepoToTemp(cloneUrl);
|
|
274
|
+
try {
|
|
275
|
+
return work(tmpCloneDir);
|
|
276
|
+
} finally {
|
|
277
|
+
try { fs.rmSync(tmpCloneDir, { recursive: true, force: true }); } catch {}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function resolveRemotePluginSource(lockEntry: LockEntry | undefined, dir: string): string {
|
|
282
|
+
const source = resolvePluginSource(lockEntry, dir);
|
|
283
|
+
if (!source || source.kind === 'local') {
|
|
284
|
+
throw new Error(`Unable to determine remote source for plugin at ${dir}`);
|
|
285
|
+
}
|
|
286
|
+
return source.url;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function pathExistsSync(p: string): boolean {
|
|
290
|
+
try {
|
|
291
|
+
fs.lstatSync(p);
|
|
292
|
+
return true;
|
|
293
|
+
} catch {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function removePathSync(p: string): void {
|
|
299
|
+
try {
|
|
300
|
+
const stat = fs.lstatSync(p);
|
|
301
|
+
if (stat.isSymbolicLink()) {
|
|
302
|
+
fs.unlinkSync(p);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
306
|
+
} catch {}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
interface TransactionHandle {
|
|
310
|
+
finalize(): void;
|
|
311
|
+
rollback(): void;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
class Transaction {
|
|
315
|
+
#handles: TransactionHandle[] = [];
|
|
316
|
+
#settled = false;
|
|
317
|
+
|
|
318
|
+
track<T extends TransactionHandle>(handle: T): T {
|
|
319
|
+
this.#handles.push(handle);
|
|
320
|
+
return handle;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
commit(): void {
|
|
324
|
+
if (this.#settled) return;
|
|
325
|
+
this.#settled = true;
|
|
326
|
+
for (const handle of this.#handles) {
|
|
327
|
+
handle.finalize();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
rollback(): void {
|
|
332
|
+
if (this.#settled) return;
|
|
333
|
+
this.#settled = true;
|
|
334
|
+
for (const handle of [...this.#handles].reverse()) {
|
|
335
|
+
handle.rollback();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function runTransaction<T>(work: (tx: Transaction) => T): T {
|
|
341
|
+
const tx = new Transaction();
|
|
342
|
+
try {
|
|
343
|
+
const result = work(tx);
|
|
344
|
+
tx.commit();
|
|
345
|
+
return result;
|
|
346
|
+
} catch (err) {
|
|
347
|
+
tx.rollback();
|
|
348
|
+
throw err;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function beginReplaceDir(
|
|
353
|
+
stagingDir: string,
|
|
354
|
+
dest: string,
|
|
355
|
+
fsOps: PromoteDirFsOps = fs,
|
|
356
|
+
): TransactionHandle {
|
|
357
|
+
const destExisted = fsOps.existsSync(dest);
|
|
358
|
+
fsOps.mkdirSync(path.dirname(dest), { recursive: true });
|
|
359
|
+
|
|
360
|
+
const tempDest = createSiblingTempPath(dest, 'tmp');
|
|
361
|
+
const backupDest = destExisted ? createSiblingTempPath(dest, 'bak') : null;
|
|
362
|
+
let settled = false;
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
moveDir(stagingDir, tempDest, fsOps);
|
|
366
|
+
if (backupDest) {
|
|
367
|
+
fsOps.renameSync(dest, backupDest);
|
|
368
|
+
}
|
|
369
|
+
fsOps.renameSync(tempDest, dest);
|
|
370
|
+
} catch (err) {
|
|
371
|
+
try { fsOps.rmSync(tempDest, { recursive: true, force: true }); } catch {}
|
|
372
|
+
if (backupDest && !fsOps.existsSync(dest)) {
|
|
373
|
+
try { fsOps.renameSync(backupDest, dest); } catch {}
|
|
374
|
+
}
|
|
375
|
+
throw err;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
finalize() {
|
|
380
|
+
if (settled) return;
|
|
381
|
+
settled = true;
|
|
382
|
+
if (backupDest) {
|
|
383
|
+
try { fsOps.rmSync(backupDest, { recursive: true, force: true }); } catch {}
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
rollback() {
|
|
387
|
+
if (settled) return;
|
|
388
|
+
settled = true;
|
|
389
|
+
try { fsOps.rmSync(dest, { recursive: true, force: true }); } catch {}
|
|
390
|
+
if (backupDest) {
|
|
391
|
+
try { fsOps.renameSync(backupDest, dest); } catch {}
|
|
392
|
+
}
|
|
393
|
+
try { fsOps.rmSync(tempDest, { recursive: true, force: true }); } catch {}
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function beginReplaceSymlink(target: string, linkPath: string): TransactionHandle {
|
|
399
|
+
const linkExists = pathExistsSync(linkPath);
|
|
400
|
+
if (linkExists && !isSymlinkSync(linkPath)) {
|
|
401
|
+
throw new Error(`Expected monorepo plugin link at ${linkPath} to be a symlink`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
|
405
|
+
|
|
406
|
+
const tempLink = createSiblingTempPath(linkPath, 'tmp');
|
|
407
|
+
const backupLink = linkExists ? createSiblingTempPath(linkPath, 'bak') : null;
|
|
408
|
+
const linkType = isWindows ? 'junction' : 'dir';
|
|
409
|
+
let settled = false;
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
fs.symlinkSync(target, tempLink, linkType);
|
|
413
|
+
if (backupLink) {
|
|
414
|
+
fs.renameSync(linkPath, backupLink);
|
|
415
|
+
}
|
|
416
|
+
fs.renameSync(tempLink, linkPath);
|
|
417
|
+
} catch (err) {
|
|
418
|
+
removePathSync(tempLink);
|
|
419
|
+
if (backupLink && !pathExistsSync(linkPath)) {
|
|
420
|
+
try { fs.renameSync(backupLink, linkPath); } catch {}
|
|
421
|
+
}
|
|
422
|
+
throw err;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
finalize() {
|
|
427
|
+
if (settled) return;
|
|
428
|
+
settled = true;
|
|
429
|
+
if (backupLink) {
|
|
430
|
+
removePathSync(backupLink);
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
rollback() {
|
|
434
|
+
if (settled) return;
|
|
435
|
+
settled = true;
|
|
436
|
+
removePathSync(linkPath);
|
|
437
|
+
if (backupLink && !pathExistsSync(linkPath)) {
|
|
438
|
+
try { fs.renameSync(backupLink, linkPath); } catch {}
|
|
439
|
+
}
|
|
440
|
+
removePathSync(tempLink);
|
|
441
|
+
},
|
|
442
|
+
};
|
|
46
443
|
}
|
|
47
444
|
|
|
48
445
|
// ── Validation helpers ──────────────────────────────────────────────────────
|
|
@@ -54,19 +451,67 @@ export interface ValidationResult {
|
|
|
54
451
|
|
|
55
452
|
// ── Lock file helpers ───────────────────────────────────────────────────────
|
|
56
453
|
|
|
57
|
-
|
|
454
|
+
function readLockFileWithWriter(
|
|
455
|
+
writeLock: (lock: Record<string, LockEntry>) => void = writeLockFile,
|
|
456
|
+
): Record<string, LockEntry> {
|
|
58
457
|
try {
|
|
59
458
|
const raw = fs.readFileSync(getLockFilePath(), 'utf-8');
|
|
60
|
-
|
|
459
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
460
|
+
if (!isRecord(parsed)) return {};
|
|
461
|
+
|
|
462
|
+
const lock: Record<string, LockEntry> = {};
|
|
463
|
+
let changed = false;
|
|
464
|
+
|
|
465
|
+
for (const [name, entry] of Object.entries(parsed)) {
|
|
466
|
+
const normalized = normalizeLockEntry(entry);
|
|
467
|
+
if (!normalized) {
|
|
468
|
+
changed = true;
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
lock[name] = normalized;
|
|
473
|
+
if (JSON.stringify(entry) !== JSON.stringify(normalized)) {
|
|
474
|
+
changed = true;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (changed) {
|
|
479
|
+
try {
|
|
480
|
+
writeLock(lock);
|
|
481
|
+
} catch {}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return lock;
|
|
61
485
|
} catch {
|
|
62
486
|
return {};
|
|
63
487
|
}
|
|
64
488
|
}
|
|
65
489
|
|
|
66
|
-
export function
|
|
490
|
+
export function readLockFile(): Record<string, LockEntry> {
|
|
491
|
+
return readLockFileWithWriter(writeLockFile);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
type WriteLockFileFsOps = Pick<typeof fs, 'mkdirSync' | 'writeFileSync' | 'renameSync' | 'rmSync'>;
|
|
495
|
+
|
|
496
|
+
function writeLockFileWithFs(
|
|
497
|
+
lock: Record<string, LockEntry>,
|
|
498
|
+
fsOps: WriteLockFileFsOps = fs,
|
|
499
|
+
): void {
|
|
67
500
|
const lockPath = getLockFilePath();
|
|
68
|
-
|
|
69
|
-
|
|
501
|
+
fsOps.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
502
|
+
const tempPath = createSiblingTempPath(lockPath, 'tmp');
|
|
503
|
+
|
|
504
|
+
try {
|
|
505
|
+
fsOps.writeFileSync(tempPath, JSON.stringify(lock, null, 2) + '\n');
|
|
506
|
+
fsOps.renameSync(tempPath, lockPath);
|
|
507
|
+
} catch (err) {
|
|
508
|
+
try { fsOps.rmSync(tempPath, { force: true }); } catch {}
|
|
509
|
+
throw err;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function writeLockFile(lock: Record<string, LockEntry>): void {
|
|
514
|
+
writeLockFileWithFs(lock, fs);
|
|
70
515
|
}
|
|
71
516
|
|
|
72
517
|
/** Get the HEAD commit hash of a git repo directory. */
|
|
@@ -89,7 +534,7 @@ export function getCommitHash(dir: string): string | undefined {
|
|
|
89
534
|
*/
|
|
90
535
|
export function validatePluginStructure(pluginDir: string): ValidationResult {
|
|
91
536
|
const errors: string[] = [];
|
|
92
|
-
|
|
537
|
+
|
|
93
538
|
if (!fs.existsSync(pluginDir)) {
|
|
94
539
|
return { valid: false, errors: ['Plugin directory does not exist'] };
|
|
95
540
|
}
|
|
@@ -100,21 +545,21 @@ export function validatePluginStructure(pluginDir: string): ValidationResult {
|
|
|
100
545
|
const hasJs = files.some(f => f.endsWith('.js') && !f.endsWith('.d.js'));
|
|
101
546
|
|
|
102
547
|
if (!hasYaml && !hasTs && !hasJs) {
|
|
103
|
-
errors.push(
|
|
548
|
+
errors.push('No command files found in plugin directory. A plugin must contain at least one .yaml, .ts, or .js command file.');
|
|
104
549
|
}
|
|
105
550
|
|
|
106
551
|
if (hasTs) {
|
|
107
552
|
const pkgJsonPath = path.join(pluginDir, 'package.json');
|
|
108
553
|
if (!fs.existsSync(pkgJsonPath)) {
|
|
109
|
-
errors.push(
|
|
554
|
+
errors.push('Plugin contains .ts files but no package.json. A package.json with "type": "module" and "@jackwener/opencli" peer dependency is required for TS plugins.');
|
|
110
555
|
} else {
|
|
111
556
|
try {
|
|
112
557
|
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
|
|
113
558
|
if (pkg.type !== 'module') {
|
|
114
|
-
errors.push(
|
|
559
|
+
errors.push('Plugin package.json must have "type": "module" for TypeScript plugins.');
|
|
115
560
|
}
|
|
116
561
|
} catch {
|
|
117
|
-
errors.push(
|
|
562
|
+
errors.push('Plugin package.json is malformed or invalid JSON.');
|
|
118
563
|
}
|
|
119
564
|
}
|
|
120
565
|
}
|
|
@@ -122,25 +567,23 @@ export function validatePluginStructure(pluginDir: string): ValidationResult {
|
|
|
122
567
|
return { valid: errors.length === 0, errors };
|
|
123
568
|
}
|
|
124
569
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
* Called by both installPlugin() and updatePlugin().
|
|
128
|
-
*/
|
|
129
|
-
function postInstallLifecycle(pluginDir: string): void {
|
|
130
|
-
const pkgJsonPath = path.join(pluginDir, 'package.json');
|
|
570
|
+
function installDependencies(dir: string): void {
|
|
571
|
+
const pkgJsonPath = path.join(dir, 'package.json');
|
|
131
572
|
if (!fs.existsSync(pkgJsonPath)) return;
|
|
132
573
|
|
|
133
574
|
try {
|
|
134
575
|
execFileSync('npm', ['install', '--omit=dev'], {
|
|
135
|
-
cwd:
|
|
576
|
+
cwd: dir,
|
|
136
577
|
encoding: 'utf-8',
|
|
137
578
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
138
579
|
...(isWindows && { shell: true }),
|
|
139
580
|
});
|
|
140
581
|
} catch (err) {
|
|
141
|
-
|
|
582
|
+
throw new Error(`npm install failed in ${dir}: ${getErrorMessage(err)}`);
|
|
142
583
|
}
|
|
584
|
+
}
|
|
143
585
|
|
|
586
|
+
function finalizePluginRuntime(pluginDir: string): void {
|
|
144
587
|
// Symlink host opencli so TS plugins resolve '@jackwener/opencli/registry'
|
|
145
588
|
// against the running host, not a stale npm-published version.
|
|
146
589
|
linkHostOpencli(pluginDir);
|
|
@@ -149,82 +592,492 @@ function postInstallLifecycle(pluginDir: string): void {
|
|
|
149
592
|
transpilePluginTs(pluginDir);
|
|
150
593
|
}
|
|
151
594
|
|
|
595
|
+
/**
|
|
596
|
+
* Shared post-install lifecycle for standalone plugins.
|
|
597
|
+
*/
|
|
598
|
+
function postInstallLifecycle(pluginDir: string): void {
|
|
599
|
+
installDependencies(pluginDir);
|
|
600
|
+
finalizePluginRuntime(pluginDir);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Monorepo lifecycle: install shared deps once at repo root, then finalize each sub-plugin.
|
|
605
|
+
*/
|
|
606
|
+
function postInstallMonorepoLifecycle(repoDir: string, pluginDirs: string[]): void {
|
|
607
|
+
installDependencies(repoDir);
|
|
608
|
+
for (const pluginDir of pluginDirs) {
|
|
609
|
+
finalizePluginRuntime(pluginDir);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function ensureStandalonePluginReady(pluginDir: string): void {
|
|
614
|
+
const validation = validatePluginStructure(pluginDir);
|
|
615
|
+
if (!validation.valid) {
|
|
616
|
+
throw new Error(`Invalid plugin structure:\n- ${validation.errors.join('\n- ')}`);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
postInstallLifecycle(pluginDir);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
type LockEntryInput = Omit<LockEntry, 'installedAt'> & Partial<Pick<LockEntry, 'installedAt'>>;
|
|
623
|
+
|
|
624
|
+
function upsertLockEntry(
|
|
625
|
+
lock: Record<string, LockEntry>,
|
|
626
|
+
name: string,
|
|
627
|
+
entry: LockEntryInput,
|
|
628
|
+
): void {
|
|
629
|
+
lock[name] = {
|
|
630
|
+
...entry,
|
|
631
|
+
installedAt: entry.installedAt ?? new Date().toISOString(),
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function publishStandalonePlugin(
|
|
636
|
+
stagingDir: string,
|
|
637
|
+
targetDir: string,
|
|
638
|
+
writeLock: (commitHash: string | undefined) => void,
|
|
639
|
+
): void {
|
|
640
|
+
runTransaction((tx) => {
|
|
641
|
+
tx.track(beginReplaceDir(stagingDir, targetDir));
|
|
642
|
+
writeLock(getCommitHash(targetDir));
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
interface MonorepoPublishPlugin {
|
|
647
|
+
name: string;
|
|
648
|
+
subPath: string;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function publishMonorepoPlugins(
|
|
652
|
+
repoDir: string,
|
|
653
|
+
pluginsDir: string,
|
|
654
|
+
plugins: MonorepoPublishPlugin[],
|
|
655
|
+
publishRepo?: { stagingDir: string; parentDir: string },
|
|
656
|
+
writeLock?: (commitHash: string | undefined) => void,
|
|
657
|
+
): void {
|
|
658
|
+
runTransaction((tx) => {
|
|
659
|
+
if (publishRepo) {
|
|
660
|
+
fs.mkdirSync(publishRepo.parentDir, { recursive: true });
|
|
661
|
+
tx.track(beginReplaceDir(publishRepo.stagingDir, repoDir));
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const commitHash = getCommitHash(repoDir);
|
|
665
|
+
for (const plugin of plugins) {
|
|
666
|
+
const linkPath = path.join(pluginsDir, plugin.name);
|
|
667
|
+
const subDir = path.join(repoDir, plugin.subPath);
|
|
668
|
+
tx.track(beginReplaceSymlink(subDir, linkPath));
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
writeLock?.(commitHash);
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
152
675
|
/**
|
|
153
676
|
* Install a plugin from a source.
|
|
154
|
-
*
|
|
677
|
+
* Supports:
|
|
678
|
+
* "github:user/repo" — single plugin or full monorepo
|
|
679
|
+
* "github:user/repo/subplugin" — specific sub-plugin from a monorepo
|
|
680
|
+
* "https://github.com/user/repo"
|
|
681
|
+
* "file:///absolute/path" — local plugin directory (symlinked)
|
|
682
|
+
* "/absolute/path" — local plugin directory (symlinked)
|
|
683
|
+
*
|
|
684
|
+
* Returns the installed plugin name(s).
|
|
155
685
|
*/
|
|
156
|
-
export function installPlugin(source: string): string {
|
|
686
|
+
export function installPlugin(source: string): string | string[] {
|
|
157
687
|
const parsed = parseSource(source);
|
|
158
688
|
if (!parsed) {
|
|
159
689
|
throw new Error(
|
|
160
690
|
`Invalid plugin source: "${source}"\n` +
|
|
161
691
|
`Supported formats:\n` +
|
|
162
692
|
` github:user/repo\n` +
|
|
163
|
-
`
|
|
693
|
+
` github:user/repo/subplugin\n` +
|
|
694
|
+
` https://github.com/user/repo\n` +
|
|
695
|
+
` https://<host>/<path>/repo.git\n` +
|
|
696
|
+
` ssh://git@<host>/<path>/repo.git\n` +
|
|
697
|
+
` git@<host>:user/repo.git\n` +
|
|
698
|
+
` file:///absolute/path\n` +
|
|
699
|
+
` /absolute/path`
|
|
164
700
|
);
|
|
165
701
|
}
|
|
166
702
|
|
|
167
|
-
const {
|
|
168
|
-
|
|
703
|
+
const { name: repoName, subPlugin } = parsed;
|
|
704
|
+
|
|
705
|
+
if (parsed.type === 'local') {
|
|
706
|
+
return installLocalPlugin(parsed.localPath!, repoName);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return withTempClone(parsed.cloneUrl!, (tmpCloneDir) => {
|
|
710
|
+
const manifest = readPluginManifest(tmpCloneDir);
|
|
711
|
+
|
|
712
|
+
// Check top-level compatibility
|
|
713
|
+
if (manifest?.opencli && !checkCompatibility(manifest.opencli)) {
|
|
714
|
+
throw new Error(
|
|
715
|
+
`Plugin requires opencli ${manifest.opencli}, but current version is incompatible.`
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (manifest && isMonorepo(manifest)) {
|
|
720
|
+
return installMonorepo(tmpCloneDir, parsed.cloneUrl!, repoName, manifest, subPlugin);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Single plugin mode
|
|
724
|
+
return installSinglePlugin(tmpCloneDir, parsed.cloneUrl!, repoName, manifest);
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Install a single (non-monorepo) plugin. */
|
|
729
|
+
function installSinglePlugin(
|
|
730
|
+
cloneDir: string,
|
|
731
|
+
cloneUrl: string,
|
|
732
|
+
name: string,
|
|
733
|
+
manifest: PluginManifest | null,
|
|
734
|
+
): string {
|
|
735
|
+
const pluginName = manifest?.name ?? name;
|
|
736
|
+
const targetDir = path.join(PLUGINS_DIR, pluginName);
|
|
169
737
|
|
|
170
738
|
if (fs.existsSync(targetDir)) {
|
|
171
|
-
throw new Error(`Plugin "${
|
|
739
|
+
throw new Error(`Plugin "${pluginName}" is already installed at ${targetDir}`);
|
|
172
740
|
}
|
|
173
741
|
|
|
174
|
-
|
|
175
|
-
|
|
742
|
+
ensureStandalonePluginReady(cloneDir);
|
|
743
|
+
publishStandalonePlugin(cloneDir, targetDir, (commitHash) => {
|
|
744
|
+
const lock = readLockFile();
|
|
745
|
+
if (commitHash) {
|
|
746
|
+
upsertLockEntry(lock, pluginName, {
|
|
747
|
+
source: { kind: 'git', url: cloneUrl },
|
|
748
|
+
commitHash,
|
|
749
|
+
});
|
|
750
|
+
writeLockFile(lock);
|
|
751
|
+
}
|
|
752
|
+
});
|
|
176
753
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
754
|
+
return pluginName;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Install a local plugin by creating a symlink.
|
|
759
|
+
* Used for plugin development: the source directory is symlinked into
|
|
760
|
+
* the plugins dir so changes are reflected immediately.
|
|
761
|
+
*/
|
|
762
|
+
function installLocalPlugin(localPath: string, name: string): string {
|
|
763
|
+
if (!fs.existsSync(localPath)) {
|
|
764
|
+
throw new Error(`Local plugin path does not exist: ${localPath}`);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const stat = fs.statSync(localPath);
|
|
768
|
+
if (!stat.isDirectory()) {
|
|
769
|
+
throw new Error(`Local plugin path is not a directory: ${localPath}`);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const manifest = readPluginManifest(localPath);
|
|
773
|
+
|
|
774
|
+
if (manifest?.opencli && !checkCompatibility(manifest.opencli)) {
|
|
775
|
+
throw new Error(
|
|
776
|
+
`Plugin requires opencli ${manifest.opencli}, but current version is incompatible.`
|
|
777
|
+
);
|
|
184
778
|
}
|
|
185
779
|
|
|
186
|
-
const
|
|
780
|
+
const pluginName = manifest?.name ?? name;
|
|
781
|
+
const targetDir = path.join(PLUGINS_DIR, pluginName);
|
|
782
|
+
|
|
783
|
+
if (fs.existsSync(targetDir)) {
|
|
784
|
+
throw new Error(`Plugin "${pluginName}" is already installed at ${targetDir}`);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const validation = validatePluginStructure(localPath);
|
|
187
788
|
if (!validation.valid) {
|
|
188
|
-
// If validation fails, clean up the cloned directory and abort
|
|
189
|
-
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
190
789
|
throw new Error(`Invalid plugin structure:\n- ${validation.errors.join('\n- ')}`);
|
|
191
790
|
}
|
|
192
791
|
|
|
193
|
-
|
|
792
|
+
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
793
|
+
|
|
794
|
+
const resolvedPath = path.resolve(localPath);
|
|
795
|
+
const linkType = isWindows ? 'junction' : 'dir';
|
|
796
|
+
fs.symlinkSync(resolvedPath, targetDir, linkType);
|
|
194
797
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
798
|
+
installDependencies(localPath);
|
|
799
|
+
finalizePluginRuntime(localPath);
|
|
800
|
+
|
|
801
|
+
const lock = readLockFile();
|
|
802
|
+
const commitHash = getCommitHash(localPath);
|
|
803
|
+
upsertLockEntry(lock, pluginName, {
|
|
804
|
+
source: { kind: 'local', path: resolvedPath },
|
|
805
|
+
commitHash: commitHash ?? 'local',
|
|
806
|
+
});
|
|
807
|
+
writeLockFile(lock);
|
|
808
|
+
|
|
809
|
+
return pluginName;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function updateLocalPlugin(
|
|
813
|
+
name: string,
|
|
814
|
+
targetDir: string,
|
|
815
|
+
lock: Record<string, LockEntry>,
|
|
816
|
+
lockEntry?: LockEntry,
|
|
817
|
+
): void {
|
|
818
|
+
const pluginDir = fs.realpathSync(targetDir);
|
|
819
|
+
|
|
820
|
+
const validation = validatePluginStructure(pluginDir);
|
|
821
|
+
if (!validation.valid) {
|
|
822
|
+
log.warn(`Plugin "${name}" structure invalid:\n- ${validation.errors.join('\n- ')}`);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
postInstallLifecycle(pluginDir);
|
|
826
|
+
|
|
827
|
+
upsertLockEntry(lock, name, {
|
|
828
|
+
source: lockEntry?.source ?? { kind: 'local', path: pluginDir },
|
|
829
|
+
commitHash: getCommitHash(pluginDir) ?? 'local',
|
|
830
|
+
installedAt: lockEntry?.installedAt ?? new Date().toISOString(),
|
|
831
|
+
updatedAt: new Date().toISOString(),
|
|
832
|
+
});
|
|
833
|
+
writeLockFile(lock);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** Install sub-plugins from a monorepo. */
|
|
837
|
+
function installMonorepo(
|
|
838
|
+
cloneDir: string,
|
|
839
|
+
cloneUrl: string,
|
|
840
|
+
repoName: string,
|
|
841
|
+
manifest: PluginManifest,
|
|
842
|
+
subPlugin?: string,
|
|
843
|
+
): string[] {
|
|
844
|
+
const monoreposDir = getMonoreposDir();
|
|
845
|
+
const repoDir = path.join(monoreposDir, repoName);
|
|
846
|
+
const repoAlreadyInstalled = fs.existsSync(repoDir);
|
|
847
|
+
const repoRoot = repoAlreadyInstalled ? repoDir : cloneDir;
|
|
848
|
+
const effectiveManifest = repoAlreadyInstalled ? readPluginManifest(repoDir) : manifest;
|
|
849
|
+
|
|
850
|
+
if (!effectiveManifest || !isMonorepo(effectiveManifest)) {
|
|
851
|
+
throw new Error(`Monorepo manifest missing or invalid at ${repoRoot}`);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
let pluginsToInstall = getEnabledPlugins(effectiveManifest);
|
|
855
|
+
|
|
856
|
+
// If a specific sub-plugin was requested, filter to just that one
|
|
857
|
+
if (subPlugin) {
|
|
858
|
+
pluginsToInstall = pluginsToInstall.filter((p) => p.name === subPlugin);
|
|
859
|
+
if (pluginsToInstall.length === 0) {
|
|
860
|
+
// Check if it exists but is disabled
|
|
861
|
+
const disabled = effectiveManifest.plugins?.[subPlugin];
|
|
862
|
+
if (disabled) {
|
|
863
|
+
throw new Error(`Sub-plugin "${subPlugin}" is disabled in the manifest.`);
|
|
864
|
+
}
|
|
865
|
+
throw new Error(
|
|
866
|
+
`Sub-plugin "${subPlugin}" not found in monorepo. Available: ${Object.keys(effectiveManifest.plugins ?? {}).join(', ')}`
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const installedNames: string[] = [];
|
|
872
|
+
const lock = readLockFile();
|
|
873
|
+
const eligiblePlugins: Array<{ name: string; entry: typeof pluginsToInstall[number]['entry'] }> = [];
|
|
874
|
+
|
|
875
|
+
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
876
|
+
|
|
877
|
+
for (const { name, entry } of pluginsToInstall) {
|
|
878
|
+
// Check sub-plugin level compatibility (overrides top-level)
|
|
879
|
+
if (entry.opencli && !checkCompatibility(entry.opencli)) {
|
|
880
|
+
log.warn(`Skipping "${name}": requires opencli ${entry.opencli}`);
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const subDir = path.join(repoRoot, entry.path);
|
|
885
|
+
if (!fs.existsSync(subDir)) {
|
|
886
|
+
log.warn(`Skipping "${name}": path "${entry.path}" not found in repo.`);
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const validation = validatePluginStructure(subDir);
|
|
891
|
+
if (!validation.valid) {
|
|
892
|
+
log.warn(`Skipping "${name}": invalid structure — ${validation.errors.join(', ')}`);
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const linkPath = path.join(PLUGINS_DIR, name);
|
|
897
|
+
if (fs.existsSync(linkPath)) {
|
|
898
|
+
log.warn(`Skipping "${name}": already installed at ${linkPath}`);
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
eligiblePlugins.push({ name, entry });
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (eligiblePlugins.length === 0) {
|
|
906
|
+
return installedNames;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const publishPlugins = eligiblePlugins.map(({ name, entry }) => ({ name, subPath: entry.path }));
|
|
910
|
+
|
|
911
|
+
if (repoAlreadyInstalled) {
|
|
912
|
+
postInstallMonorepoLifecycle(repoDir, eligiblePlugins.map((p) => path.join(repoDir, p.entry.path)));
|
|
913
|
+
} else {
|
|
914
|
+
postInstallMonorepoLifecycle(cloneDir, eligiblePlugins.map((p) => path.join(cloneDir, p.entry.path)));
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
publishMonorepoPlugins(
|
|
918
|
+
repoDir,
|
|
919
|
+
PLUGINS_DIR,
|
|
920
|
+
publishPlugins,
|
|
921
|
+
repoAlreadyInstalled ? undefined : { stagingDir: cloneDir, parentDir: monoreposDir },
|
|
922
|
+
(commitHash) => {
|
|
923
|
+
for (const { name, entry } of eligiblePlugins) {
|
|
924
|
+
if (commitHash) {
|
|
925
|
+
upsertLockEntry(lock, name, {
|
|
926
|
+
source: {
|
|
927
|
+
kind: 'monorepo',
|
|
928
|
+
url: cloneUrl,
|
|
929
|
+
repoName,
|
|
930
|
+
subPath: entry.path,
|
|
931
|
+
},
|
|
932
|
+
commitHash,
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
installedNames.push(name);
|
|
936
|
+
}
|
|
937
|
+
writeLockFile(lock);
|
|
938
|
+
},
|
|
939
|
+
);
|
|
940
|
+
|
|
941
|
+
return installedNames;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function collectUpdatedMonorepoPlugins(
|
|
945
|
+
monoName: string,
|
|
946
|
+
lock: Record<string, LockEntry>,
|
|
947
|
+
manifest: PluginManifest,
|
|
948
|
+
cloneUrl: string,
|
|
949
|
+
tmpCloneDir: string,
|
|
950
|
+
): Array<{
|
|
951
|
+
name: string;
|
|
952
|
+
lockEntry: LockEntry;
|
|
953
|
+
manifestEntry: NonNullable<PluginManifest['plugins']>[string];
|
|
954
|
+
}> {
|
|
955
|
+
const updatedPlugins: Array<{
|
|
956
|
+
name: string;
|
|
957
|
+
lockEntry: LockEntry;
|
|
958
|
+
manifestEntry: NonNullable<PluginManifest['plugins']>[string];
|
|
959
|
+
}> = [];
|
|
960
|
+
|
|
961
|
+
for (const [pluginName, entry] of Object.entries(lock)) {
|
|
962
|
+
if (entry.source.kind !== 'monorepo' || entry.source.repoName !== monoName) continue;
|
|
963
|
+
const manifestEntry = manifest.plugins?.[pluginName];
|
|
964
|
+
if (!manifestEntry || manifestEntry.disabled) {
|
|
965
|
+
throw new Error(`Installed sub-plugin "${pluginName}" no longer exists in ${cloneUrl}`);
|
|
966
|
+
}
|
|
967
|
+
if (manifestEntry.opencli && !checkCompatibility(manifestEntry.opencli)) {
|
|
968
|
+
throw new Error(`Sub-plugin "${pluginName}" requires opencli ${manifestEntry.opencli}`);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const subDir = path.join(tmpCloneDir, manifestEntry.path);
|
|
972
|
+
const validation = validatePluginStructure(subDir);
|
|
973
|
+
if (!validation.valid) {
|
|
974
|
+
throw new Error(`Updated sub-plugin "${pluginName}" is invalid:\n- ${validation.errors.join('\n- ')}`);
|
|
975
|
+
}
|
|
976
|
+
updatedPlugins.push({ name: pluginName, lockEntry: entry, manifestEntry });
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
return updatedPlugins;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function updateMonorepoLockEntries(
|
|
983
|
+
lock: Record<string, LockEntry>,
|
|
984
|
+
plugins: Array<{
|
|
985
|
+
name: string;
|
|
986
|
+
lockEntry: LockEntry;
|
|
987
|
+
manifestEntry: NonNullable<PluginManifest['plugins']>[string];
|
|
988
|
+
}>,
|
|
989
|
+
cloneUrl: string,
|
|
990
|
+
monoName: string,
|
|
991
|
+
commitHash: string | undefined,
|
|
992
|
+
): void {
|
|
993
|
+
for (const plugin of plugins) {
|
|
994
|
+
if (!commitHash) continue;
|
|
995
|
+
upsertLockEntry(lock, plugin.name, {
|
|
996
|
+
...plugin.lockEntry,
|
|
997
|
+
source: {
|
|
998
|
+
kind: 'monorepo',
|
|
999
|
+
url: cloneUrl,
|
|
1000
|
+
repoName: monoName,
|
|
1001
|
+
subPath: plugin.manifestEntry.path,
|
|
1002
|
+
},
|
|
200
1003
|
commitHash,
|
|
201
|
-
|
|
202
|
-
};
|
|
203
|
-
writeLockFile(lock);
|
|
1004
|
+
updatedAt: new Date().toISOString(),
|
|
1005
|
+
});
|
|
204
1006
|
}
|
|
1007
|
+
}
|
|
205
1008
|
|
|
206
|
-
|
|
1009
|
+
function updateStandaloneLockEntry(
|
|
1010
|
+
lock: Record<string, LockEntry>,
|
|
1011
|
+
name: string,
|
|
1012
|
+
cloneUrl: string,
|
|
1013
|
+
existing: LockEntry | undefined,
|
|
1014
|
+
commitHash: string | undefined,
|
|
1015
|
+
): void {
|
|
1016
|
+
if (!commitHash) return;
|
|
1017
|
+
|
|
1018
|
+
upsertLockEntry(lock, name, {
|
|
1019
|
+
source: { kind: 'git', url: cloneUrl },
|
|
1020
|
+
commitHash,
|
|
1021
|
+
installedAt: existing?.installedAt ?? new Date().toISOString(),
|
|
1022
|
+
updatedAt: new Date().toISOString(),
|
|
1023
|
+
});
|
|
207
1024
|
}
|
|
208
1025
|
|
|
209
1026
|
/**
|
|
210
1027
|
* Uninstall a plugin by name.
|
|
1028
|
+
* For monorepo sub-plugins: removes symlink and cleans up the monorepo
|
|
1029
|
+
* directory when no more sub-plugins reference it.
|
|
211
1030
|
*/
|
|
212
1031
|
export function uninstallPlugin(name: string): void {
|
|
213
1032
|
const targetDir = path.join(PLUGINS_DIR, name);
|
|
214
1033
|
if (!fs.existsSync(targetDir)) {
|
|
215
1034
|
throw new Error(`Plugin "${name}" is not installed.`);
|
|
216
1035
|
}
|
|
217
|
-
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
218
1036
|
|
|
219
1037
|
const lock = readLockFile();
|
|
220
|
-
|
|
1038
|
+
const lockEntry = lock[name];
|
|
1039
|
+
|
|
1040
|
+
// Check if this is a symlink (monorepo sub-plugin)
|
|
1041
|
+
const isSymlink = isSymlinkSync(targetDir);
|
|
1042
|
+
|
|
1043
|
+
if (isSymlink) {
|
|
1044
|
+
// Remove symlink only (not the actual directory)
|
|
1045
|
+
fs.unlinkSync(targetDir);
|
|
1046
|
+
} else {
|
|
1047
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Clean up monorepo directory if no more sub-plugins reference it
|
|
1051
|
+
if (lockEntry?.source.kind === 'monorepo') {
|
|
1052
|
+
delete lock[name];
|
|
1053
|
+
const monoName = lockEntry.source.repoName;
|
|
1054
|
+
const stillReferenced = Object.values(lock).some(
|
|
1055
|
+
(entry) => entry.source.kind === 'monorepo' && entry.source.repoName === monoName,
|
|
1056
|
+
);
|
|
1057
|
+
if (!stillReferenced) {
|
|
1058
|
+
const monoDir = path.join(getMonoreposDir(), monoName);
|
|
1059
|
+
try { fs.rmSync(monoDir, { recursive: true, force: true }); } catch {}
|
|
1060
|
+
}
|
|
1061
|
+
} else if (lock[name]) {
|
|
221
1062
|
delete lock[name];
|
|
222
|
-
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
writeLockFile(lock);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/** Synchronous check if a path is a symlink. */
|
|
1069
|
+
function isSymlinkSync(p: string): boolean {
|
|
1070
|
+
try {
|
|
1071
|
+
return fs.lstatSync(p).isSymbolicLink();
|
|
1072
|
+
} catch {
|
|
1073
|
+
return false;
|
|
223
1074
|
}
|
|
224
1075
|
}
|
|
225
1076
|
|
|
226
1077
|
/**
|
|
227
1078
|
* Update a plugin by name (git pull + re-install lifecycle).
|
|
1079
|
+
* For monorepo sub-plugins: pulls the monorepo root and re-runs lifecycle
|
|
1080
|
+
* for all sub-plugins from the same monorepo.
|
|
228
1081
|
*/
|
|
229
1082
|
export function updatePlugin(name: string): void {
|
|
230
1083
|
const targetDir = path.join(PLUGINS_DIR, name);
|
|
@@ -232,35 +1085,78 @@ export function updatePlugin(name: string): void {
|
|
|
232
1085
|
throw new Error(`Plugin "${name}" is not installed.`);
|
|
233
1086
|
}
|
|
234
1087
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
encoding: 'utf-8',
|
|
239
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
240
|
-
});
|
|
241
|
-
} catch (err) {
|
|
242
|
-
throw new Error(`Failed to update plugin: ${getErrorMessage(err)}`);
|
|
243
|
-
}
|
|
1088
|
+
const lock = readLockFile();
|
|
1089
|
+
const lockEntry = lock[name];
|
|
1090
|
+
const source = resolvePluginSource(lockEntry, targetDir);
|
|
244
1091
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
1092
|
+
if (source?.kind === 'local') {
|
|
1093
|
+
updateLocalPlugin(name, targetDir, lock, lockEntry);
|
|
1094
|
+
return;
|
|
248
1095
|
}
|
|
249
1096
|
|
|
250
|
-
|
|
1097
|
+
if (source?.kind === 'monorepo') {
|
|
1098
|
+
const monoDir = path.join(getMonoreposDir(), source.repoName);
|
|
1099
|
+
const monoName = source.repoName;
|
|
1100
|
+
const cloneUrl = source.url;
|
|
1101
|
+
withTempClone(cloneUrl, (tmpCloneDir) => {
|
|
1102
|
+
const manifest = readPluginManifest(tmpCloneDir);
|
|
1103
|
+
if (!manifest || !isMonorepo(manifest)) {
|
|
1104
|
+
throw new Error(`Updated source is no longer a monorepo: ${cloneUrl}`);
|
|
1105
|
+
}
|
|
251
1106
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
1107
|
+
if (manifest.opencli && !checkCompatibility(manifest.opencli)) {
|
|
1108
|
+
throw new Error(
|
|
1109
|
+
`Plugin requires opencli ${manifest.opencli}, but current version is incompatible.`
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const updatedPlugins = collectUpdatedMonorepoPlugins(
|
|
1114
|
+
monoName,
|
|
1115
|
+
lock,
|
|
1116
|
+
manifest,
|
|
1117
|
+
cloneUrl,
|
|
1118
|
+
tmpCloneDir,
|
|
1119
|
+
);
|
|
1120
|
+
|
|
1121
|
+
if (updatedPlugins.length > 0) {
|
|
1122
|
+
postInstallMonorepoLifecycle(tmpCloneDir, updatedPlugins.map((plugin) => path.join(tmpCloneDir, plugin.manifestEntry.path)));
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
publishMonorepoPlugins(
|
|
1126
|
+
monoDir,
|
|
1127
|
+
PLUGINS_DIR,
|
|
1128
|
+
updatedPlugins.map((plugin) => ({ name: plugin.name, subPath: plugin.manifestEntry.path })),
|
|
1129
|
+
{ stagingDir: tmpCloneDir, parentDir: path.dirname(monoDir) },
|
|
1130
|
+
(commitHash) => {
|
|
1131
|
+
updateMonorepoLockEntries(lock, updatedPlugins, cloneUrl, monoName, commitHash);
|
|
1132
|
+
writeLockFile(lock);
|
|
1133
|
+
},
|
|
1134
|
+
);
|
|
1135
|
+
});
|
|
1136
|
+
return;
|
|
263
1137
|
}
|
|
1138
|
+
|
|
1139
|
+
const cloneUrl = resolveRemotePluginSource(lockEntry, targetDir);
|
|
1140
|
+
withTempClone(cloneUrl, (tmpCloneDir) => {
|
|
1141
|
+
const manifest = readPluginManifest(tmpCloneDir);
|
|
1142
|
+
if (manifest && isMonorepo(manifest)) {
|
|
1143
|
+
throw new Error(`Updated source is now a monorepo: ${cloneUrl}`);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
if (manifest?.opencli && !checkCompatibility(manifest.opencli)) {
|
|
1147
|
+
throw new Error(
|
|
1148
|
+
`Plugin requires opencli ${manifest.opencli}, but current version is incompatible.`
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
ensureStandalonePluginReady(tmpCloneDir);
|
|
1153
|
+
publishStandalonePlugin(tmpCloneDir, targetDir, (commitHash) => {
|
|
1154
|
+
updateStandaloneLockEntry(lock, name, cloneUrl, lock[name], commitHash);
|
|
1155
|
+
if (commitHash) {
|
|
1156
|
+
writeLockFile(lock);
|
|
1157
|
+
}
|
|
1158
|
+
});
|
|
1159
|
+
});
|
|
264
1160
|
}
|
|
265
1161
|
|
|
266
1162
|
export interface UpdateResult {
|
|
@@ -290,6 +1186,7 @@ export function updateAllPlugins(): UpdateResult[] {
|
|
|
290
1186
|
|
|
291
1187
|
/**
|
|
292
1188
|
* List all installed plugins.
|
|
1189
|
+
* Reads opencli-plugin.json for description/version when available.
|
|
293
1190
|
*/
|
|
294
1191
|
export function listPlugins(): PluginInfo[] {
|
|
295
1192
|
if (!fs.existsSync(PLUGINS_DIR)) return [];
|
|
@@ -299,19 +1196,40 @@ export function listPlugins(): PluginInfo[] {
|
|
|
299
1196
|
const plugins: PluginInfo[] = [];
|
|
300
1197
|
|
|
301
1198
|
for (const entry of entries) {
|
|
302
|
-
|
|
1199
|
+
// Accept both real directories and symlinks (monorepo sub-plugins)
|
|
303
1200
|
const pluginDir = path.join(PLUGINS_DIR, entry.name);
|
|
1201
|
+
const isDir = entry.isDirectory() || isSymlinkSync(pluginDir);
|
|
1202
|
+
if (!isDir) continue;
|
|
1203
|
+
|
|
304
1204
|
const commands = scanPluginCommands(pluginDir);
|
|
305
|
-
const source = getPluginSource(pluginDir);
|
|
306
1205
|
const lockEntry = lock[entry.name];
|
|
307
1206
|
|
|
1207
|
+
// Try to read manifest for metadata
|
|
1208
|
+
const manifest = readPluginManifest(pluginDir);
|
|
1209
|
+
// For monorepo sub-plugins, also check the monorepo root manifest
|
|
1210
|
+
let description = manifest?.description;
|
|
1211
|
+
let version = manifest?.version;
|
|
1212
|
+
if (lockEntry?.source.kind === 'monorepo' && !description) {
|
|
1213
|
+
const monoDir = path.join(getMonoreposDir(), lockEntry.source.repoName);
|
|
1214
|
+
const monoManifest = readPluginManifest(monoDir);
|
|
1215
|
+
const subEntry = monoManifest?.plugins?.[entry.name];
|
|
1216
|
+
if (subEntry) {
|
|
1217
|
+
description = description ?? subEntry.description;
|
|
1218
|
+
version = version ?? subEntry.version;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const source = resolveStoredPluginSource(lockEntry, pluginDir);
|
|
1223
|
+
|
|
308
1224
|
plugins.push({
|
|
309
1225
|
name: entry.name,
|
|
310
1226
|
path: pluginDir,
|
|
311
1227
|
commands,
|
|
312
1228
|
source,
|
|
313
|
-
version: lockEntry?.commitHash?.slice(0, 7),
|
|
1229
|
+
version: version ?? lockEntry?.commitHash?.slice(0, 7),
|
|
314
1230
|
installedAt: lockEntry?.installedAt,
|
|
1231
|
+
monorepoName: lockEntry?.source.kind === 'monorepo' ? lockEntry.source.repoName : undefined,
|
|
1232
|
+
description,
|
|
315
1233
|
});
|
|
316
1234
|
}
|
|
317
1235
|
|
|
@@ -350,30 +1268,109 @@ function getPluginSource(dir: string): string | undefined {
|
|
|
350
1268
|
}
|
|
351
1269
|
}
|
|
352
1270
|
|
|
353
|
-
/** Parse a plugin source string into clone URL
|
|
354
|
-
function parseSource(
|
|
1271
|
+
/** Parse a plugin source string into clone URL, repo name, and optional sub-plugin. */
|
|
1272
|
+
function parseSource(
|
|
1273
|
+
source: string,
|
|
1274
|
+
): ParsedSource | null {
|
|
1275
|
+
if (source.startsWith('file://')) {
|
|
1276
|
+
try {
|
|
1277
|
+
const localPath = path.resolve(fileURLToPath(source));
|
|
1278
|
+
return {
|
|
1279
|
+
type: 'local',
|
|
1280
|
+
localPath,
|
|
1281
|
+
name: path.basename(localPath).replace(/^opencli-plugin-/, ''),
|
|
1282
|
+
};
|
|
1283
|
+
} catch {
|
|
1284
|
+
return null;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
if (path.isAbsolute(source)) {
|
|
1289
|
+
const localPath = path.resolve(source);
|
|
1290
|
+
return {
|
|
1291
|
+
type: 'local',
|
|
1292
|
+
localPath,
|
|
1293
|
+
name: path.basename(localPath).replace(/^opencli-plugin-/, ''),
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// github:user/repo/subplugin (monorepo specific sub-plugin)
|
|
1298
|
+
const githubSubMatch = source.match(
|
|
1299
|
+
/^github:([\w.-]+)\/([\w.-]+)\/([\w.-]+)$/,
|
|
1300
|
+
);
|
|
1301
|
+
if (githubSubMatch) {
|
|
1302
|
+
const [, user, repo, sub] = githubSubMatch;
|
|
1303
|
+
const name = repo.replace(/^opencli-plugin-/, '');
|
|
1304
|
+
return {
|
|
1305
|
+
type: 'git',
|
|
1306
|
+
cloneUrl: `https://github.com/${user}/${repo}.git`,
|
|
1307
|
+
name,
|
|
1308
|
+
subPlugin: sub,
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
|
|
355
1312
|
// github:user/repo
|
|
356
1313
|
const githubMatch = source.match(/^github:([\w.-]+)\/([\w.-]+)$/);
|
|
357
1314
|
if (githubMatch) {
|
|
358
1315
|
const [, user, repo] = githubMatch;
|
|
359
1316
|
const name = repo.replace(/^opencli-plugin-/, '');
|
|
360
1317
|
return {
|
|
1318
|
+
type: 'git',
|
|
361
1319
|
cloneUrl: `https://github.com/${user}/${repo}.git`,
|
|
362
1320
|
name,
|
|
363
1321
|
};
|
|
364
1322
|
}
|
|
365
1323
|
|
|
366
1324
|
// https://github.com/user/repo (or .git)
|
|
367
|
-
const urlMatch = source.match(
|
|
1325
|
+
const urlMatch = source.match(
|
|
1326
|
+
/^https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?$/,
|
|
1327
|
+
);
|
|
368
1328
|
if (urlMatch) {
|
|
369
1329
|
const [, user, repo] = urlMatch;
|
|
370
1330
|
const name = repo.replace(/^opencli-plugin-/, '');
|
|
371
1331
|
return {
|
|
1332
|
+
type: 'git',
|
|
372
1333
|
cloneUrl: `https://github.com/${user}/${repo}.git`,
|
|
373
1334
|
name,
|
|
374
1335
|
};
|
|
375
1336
|
}
|
|
376
1337
|
|
|
1338
|
+
// ── Generic git URL support ─────────────────────────────────────────────
|
|
1339
|
+
|
|
1340
|
+
// ssh://git@host/path/to/repo.git
|
|
1341
|
+
const sshUrlMatch = source.match(/^ssh:\/\/[^/]+\/(.*?)(?:\.git)?$/);
|
|
1342
|
+
if (sshUrlMatch) {
|
|
1343
|
+
const pathPart = sshUrlMatch[1];
|
|
1344
|
+
const segments = pathPart.split('/');
|
|
1345
|
+
const repoSegment = segments.pop()!;
|
|
1346
|
+
const name = repoSegment.replace(/^opencli-plugin-/, '');
|
|
1347
|
+
return { type: 'git', cloneUrl: source, name };
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// git@host:user/repo.git (SCP-style)
|
|
1351
|
+
const scpMatch = source.match(/^git@[^:]+:(.+?)(?:\.git)?$/);
|
|
1352
|
+
if (scpMatch) {
|
|
1353
|
+
const pathPart = scpMatch[1];
|
|
1354
|
+
const segments = pathPart.split('/');
|
|
1355
|
+
const repoSegment = segments.pop()!;
|
|
1356
|
+
const name = repoSegment.replace(/^opencli-plugin-/, '');
|
|
1357
|
+
return { type: 'git', cloneUrl: source, name };
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// Generic https/http git URL (non-GitHub hosts)
|
|
1361
|
+
const genericHttpMatch = source.match(
|
|
1362
|
+
/^https?:\/\/[^/]+\/(.+?)(?:\.git)?$/,
|
|
1363
|
+
);
|
|
1364
|
+
if (genericHttpMatch) {
|
|
1365
|
+
const pathPart = genericHttpMatch[1];
|
|
1366
|
+
const segments = pathPart.split('/');
|
|
1367
|
+
const repoSegment = segments.pop()!;
|
|
1368
|
+
const name = repoSegment.replace(/^opencli-plugin-/, '');
|
|
1369
|
+
// Ensure clone URL ends with .git
|
|
1370
|
+
const cloneUrl = source.endsWith('.git') ? source : `${source}.git`;
|
|
1371
|
+
return { type: 'git', cloneUrl, name };
|
|
1372
|
+
}
|
|
1373
|
+
|
|
377
1374
|
return null;
|
|
378
1375
|
}
|
|
379
1376
|
|
|
@@ -478,7 +1475,10 @@ function transpilePluginTs(pluginDir: string): void {
|
|
|
478
1475
|
const esbuildBin = resolveEsbuildBin();
|
|
479
1476
|
|
|
480
1477
|
if (!esbuildBin) {
|
|
481
|
-
log.
|
|
1478
|
+
log.warn(
|
|
1479
|
+
'esbuild not found. TS plugin files will not be transpiled and may fail to load. ' +
|
|
1480
|
+
'Install esbuild (`npm i -g esbuild`) or ensure it is available in the opencli host node_modules.'
|
|
1481
|
+
);
|
|
482
1482
|
return;
|
|
483
1483
|
}
|
|
484
1484
|
|
|
@@ -506,17 +1506,32 @@ function transpilePluginTs(pluginDir: string): void {
|
|
|
506
1506
|
log.warn(`Failed to transpile ${tsFile}: ${getErrorMessage(err)}`);
|
|
507
1507
|
}
|
|
508
1508
|
}
|
|
509
|
-
} catch {
|
|
510
|
-
|
|
1509
|
+
} catch (err) {
|
|
1510
|
+
log.warn(`TS transpilation setup failed: ${getErrorMessage(err)}`);
|
|
511
1511
|
}
|
|
512
1512
|
}
|
|
513
1513
|
|
|
514
1514
|
export {
|
|
515
1515
|
resolveEsbuildBin as _resolveEsbuildBin,
|
|
516
1516
|
getCommitHash as _getCommitHash,
|
|
1517
|
+
installDependencies as _installDependencies,
|
|
517
1518
|
parseSource as _parseSource,
|
|
1519
|
+
postInstallMonorepoLifecycle as _postInstallMonorepoLifecycle,
|
|
518
1520
|
readLockFile as _readLockFile,
|
|
1521
|
+
readLockFileWithWriter as _readLockFileWithWriter,
|
|
519
1522
|
updateAllPlugins as _updateAllPlugins,
|
|
520
1523
|
validatePluginStructure as _validatePluginStructure,
|
|
521
1524
|
writeLockFile as _writeLockFile,
|
|
1525
|
+
writeLockFileWithFs as _writeLockFileWithFs,
|
|
1526
|
+
isSymlinkSync as _isSymlinkSync,
|
|
1527
|
+
getMonoreposDir as _getMonoreposDir,
|
|
1528
|
+
installLocalPlugin as _installLocalPlugin,
|
|
1529
|
+
isLocalPluginSource as _isLocalPluginSource,
|
|
1530
|
+
moveDir as _moveDir,
|
|
1531
|
+
promoteDir as _promoteDir,
|
|
1532
|
+
replaceDir as _replaceDir,
|
|
1533
|
+
resolvePluginSource as _resolvePluginSource,
|
|
1534
|
+
resolveStoredPluginSource as _resolveStoredPluginSource,
|
|
1535
|
+
toStoredPluginSource as _toStoredPluginSource,
|
|
1536
|
+
toLocalPluginSource as _toLocalPluginSource,
|
|
522
1537
|
};
|