@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/dist/plugin.js
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
|
import * as fs from 'node:fs';
|
|
8
10
|
import * as os from 'node:os';
|
|
@@ -12,7 +14,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
12
14
|
import { PLUGINS_DIR } from './discovery.js';
|
|
13
15
|
import { getErrorMessage } from './errors.js';
|
|
14
16
|
import { log } from './logger.js';
|
|
17
|
+
import { readPluginManifest, isMonorepo, getEnabledPlugins, checkCompatibility, } from './plugin-manifest.js';
|
|
15
18
|
const isWindows = process.platform === 'win32';
|
|
19
|
+
const LOCAL_PLUGIN_SOURCE_PREFIX = 'local:';
|
|
16
20
|
/** Get home directory, respecting HOME environment variable for test isolation. */
|
|
17
21
|
function getHomeDir() {
|
|
18
22
|
return process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
@@ -21,22 +25,418 @@ function getHomeDir() {
|
|
|
21
25
|
export function getLockFilePath() {
|
|
22
26
|
return path.join(getHomeDir(), '.opencli', 'plugins.lock.json');
|
|
23
27
|
}
|
|
24
|
-
|
|
25
|
-
export
|
|
28
|
+
/** Monorepo clones directory: ~/.opencli/monorepos/ */
|
|
29
|
+
export function getMonoreposDir() {
|
|
30
|
+
return path.join(getHomeDir(), '.opencli', 'monorepos');
|
|
31
|
+
}
|
|
32
|
+
function parseStoredPluginSource(source) {
|
|
33
|
+
if (!source)
|
|
34
|
+
return undefined;
|
|
35
|
+
if (source.startsWith(LOCAL_PLUGIN_SOURCE_PREFIX)) {
|
|
36
|
+
return {
|
|
37
|
+
kind: 'local',
|
|
38
|
+
path: path.resolve(source.slice(LOCAL_PLUGIN_SOURCE_PREFIX.length)),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return { kind: 'git', url: source };
|
|
42
|
+
}
|
|
43
|
+
function isLocalPluginSource(source) {
|
|
44
|
+
return parseStoredPluginSource(source)?.kind === 'local';
|
|
45
|
+
}
|
|
46
|
+
function toStoredPluginSource(source) {
|
|
47
|
+
if (source.kind === 'local') {
|
|
48
|
+
return `${LOCAL_PLUGIN_SOURCE_PREFIX}${path.resolve(source.path)}`;
|
|
49
|
+
}
|
|
50
|
+
return source.url;
|
|
51
|
+
}
|
|
52
|
+
function toLocalPluginSource(pluginDir) {
|
|
53
|
+
return toStoredPluginSource({ kind: 'local', path: pluginDir });
|
|
54
|
+
}
|
|
55
|
+
function isRecord(value) {
|
|
56
|
+
return typeof value === 'object' && value !== null;
|
|
57
|
+
}
|
|
58
|
+
function normalizeLegacyMonorepo(value) {
|
|
59
|
+
if (!isRecord(value))
|
|
60
|
+
return undefined;
|
|
61
|
+
if (typeof value.name !== 'string' || typeof value.subPath !== 'string')
|
|
62
|
+
return undefined;
|
|
63
|
+
return { name: value.name, subPath: value.subPath };
|
|
64
|
+
}
|
|
65
|
+
function normalizePluginSource(source, legacyMonorepo) {
|
|
66
|
+
if (typeof source === 'string') {
|
|
67
|
+
const parsed = parseStoredPluginSource(source);
|
|
68
|
+
if (!parsed)
|
|
69
|
+
return undefined;
|
|
70
|
+
if (parsed.kind === 'git' && legacyMonorepo) {
|
|
71
|
+
return {
|
|
72
|
+
kind: 'monorepo',
|
|
73
|
+
url: parsed.url,
|
|
74
|
+
repoName: legacyMonorepo.name,
|
|
75
|
+
subPath: legacyMonorepo.subPath,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return parsed;
|
|
79
|
+
}
|
|
80
|
+
if (!isRecord(source) || typeof source.kind !== 'string')
|
|
81
|
+
return undefined;
|
|
82
|
+
switch (source.kind) {
|
|
83
|
+
case 'git':
|
|
84
|
+
return typeof source.url === 'string'
|
|
85
|
+
? { kind: 'git', url: source.url }
|
|
86
|
+
: undefined;
|
|
87
|
+
case 'local':
|
|
88
|
+
return typeof source.path === 'string'
|
|
89
|
+
? { kind: 'local', path: path.resolve(source.path) }
|
|
90
|
+
: undefined;
|
|
91
|
+
case 'monorepo':
|
|
92
|
+
return typeof source.url === 'string'
|
|
93
|
+
&& typeof source.repoName === 'string'
|
|
94
|
+
&& typeof source.subPath === 'string'
|
|
95
|
+
? {
|
|
96
|
+
kind: 'monorepo',
|
|
97
|
+
url: source.url,
|
|
98
|
+
repoName: source.repoName,
|
|
99
|
+
subPath: source.subPath,
|
|
100
|
+
}
|
|
101
|
+
: undefined;
|
|
102
|
+
default:
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function normalizeLockEntry(value) {
|
|
107
|
+
if (!isRecord(value))
|
|
108
|
+
return undefined;
|
|
109
|
+
const legacyMonorepo = normalizeLegacyMonorepo(value.monorepo);
|
|
110
|
+
const source = normalizePluginSource(value.source, legacyMonorepo);
|
|
111
|
+
if (!source)
|
|
112
|
+
return undefined;
|
|
113
|
+
if (typeof value.commitHash !== 'string' || typeof value.installedAt !== 'string') {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
const entry = {
|
|
117
|
+
source,
|
|
118
|
+
commitHash: value.commitHash,
|
|
119
|
+
installedAt: value.installedAt,
|
|
120
|
+
};
|
|
121
|
+
if (typeof value.updatedAt === 'string') {
|
|
122
|
+
entry.updatedAt = value.updatedAt;
|
|
123
|
+
}
|
|
124
|
+
return entry;
|
|
125
|
+
}
|
|
126
|
+
function resolvePluginSource(lockEntry, pluginDir) {
|
|
127
|
+
if (lockEntry) {
|
|
128
|
+
return lockEntry.source;
|
|
129
|
+
}
|
|
130
|
+
return parseStoredPluginSource(getPluginSource(pluginDir));
|
|
131
|
+
}
|
|
132
|
+
function resolveStoredPluginSource(lockEntry, pluginDir) {
|
|
133
|
+
const source = resolvePluginSource(lockEntry, pluginDir);
|
|
134
|
+
return source ? toStoredPluginSource(source) : undefined;
|
|
135
|
+
}
|
|
136
|
+
function moveDir(src, dest, fsOps = fs) {
|
|
137
|
+
try {
|
|
138
|
+
fsOps.renameSync(src, dest);
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
if (err.code === 'EXDEV') {
|
|
142
|
+
try {
|
|
143
|
+
fsOps.cpSync(src, dest, { recursive: true });
|
|
144
|
+
}
|
|
145
|
+
catch (copyErr) {
|
|
146
|
+
try {
|
|
147
|
+
fsOps.rmSync(dest, { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
catch { }
|
|
150
|
+
throw copyErr;
|
|
151
|
+
}
|
|
152
|
+
fsOps.rmSync(src, { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function createSiblingTempPath(dest, kind) {
|
|
160
|
+
const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
161
|
+
return path.join(path.dirname(dest), `.${path.basename(dest)}.${kind}-${suffix}`);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Promote a prepared staging directory into its final location.
|
|
165
|
+
* The final path is only exposed after the directory has been fully prepared.
|
|
166
|
+
*/
|
|
167
|
+
function promoteDir(stagingDir, dest, fsOps = fs) {
|
|
168
|
+
if (fsOps.existsSync(dest)) {
|
|
169
|
+
throw new Error(`Destination already exists: ${dest}`);
|
|
170
|
+
}
|
|
171
|
+
fsOps.mkdirSync(path.dirname(dest), { recursive: true });
|
|
172
|
+
const tempDest = createSiblingTempPath(dest, 'tmp');
|
|
173
|
+
try {
|
|
174
|
+
moveDir(stagingDir, tempDest, fsOps);
|
|
175
|
+
fsOps.renameSync(tempDest, dest);
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
try {
|
|
179
|
+
fsOps.rmSync(tempDest, { recursive: true, force: true });
|
|
180
|
+
}
|
|
181
|
+
catch { }
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function replaceDir(stagingDir, dest, fsOps = fs) {
|
|
186
|
+
const replacement = beginReplaceDir(stagingDir, dest, fsOps);
|
|
187
|
+
replacement.finalize();
|
|
188
|
+
}
|
|
189
|
+
function cloneRepoToTemp(cloneUrl) {
|
|
190
|
+
const tmpCloneDir = path.join(os.tmpdir(), `opencli-clone-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
|
191
|
+
try {
|
|
192
|
+
execFileSync('git', ['clone', '--depth', '1', cloneUrl, tmpCloneDir], {
|
|
193
|
+
encoding: 'utf-8',
|
|
194
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
throw new Error(`Failed to clone plugin: ${getErrorMessage(err)}`);
|
|
199
|
+
}
|
|
200
|
+
return tmpCloneDir;
|
|
201
|
+
}
|
|
202
|
+
function withTempClone(cloneUrl, work) {
|
|
203
|
+
const tmpCloneDir = cloneRepoToTemp(cloneUrl);
|
|
204
|
+
try {
|
|
205
|
+
return work(tmpCloneDir);
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
try {
|
|
209
|
+
fs.rmSync(tmpCloneDir, { recursive: true, force: true });
|
|
210
|
+
}
|
|
211
|
+
catch { }
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function resolveRemotePluginSource(lockEntry, dir) {
|
|
215
|
+
const source = resolvePluginSource(lockEntry, dir);
|
|
216
|
+
if (!source || source.kind === 'local') {
|
|
217
|
+
throw new Error(`Unable to determine remote source for plugin at ${dir}`);
|
|
218
|
+
}
|
|
219
|
+
return source.url;
|
|
220
|
+
}
|
|
221
|
+
function pathExistsSync(p) {
|
|
222
|
+
try {
|
|
223
|
+
fs.lstatSync(p);
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function removePathSync(p) {
|
|
231
|
+
try {
|
|
232
|
+
const stat = fs.lstatSync(p);
|
|
233
|
+
if (stat.isSymbolicLink()) {
|
|
234
|
+
fs.unlinkSync(p);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
238
|
+
}
|
|
239
|
+
catch { }
|
|
240
|
+
}
|
|
241
|
+
class Transaction {
|
|
242
|
+
#handles = [];
|
|
243
|
+
#settled = false;
|
|
244
|
+
track(handle) {
|
|
245
|
+
this.#handles.push(handle);
|
|
246
|
+
return handle;
|
|
247
|
+
}
|
|
248
|
+
commit() {
|
|
249
|
+
if (this.#settled)
|
|
250
|
+
return;
|
|
251
|
+
this.#settled = true;
|
|
252
|
+
for (const handle of this.#handles) {
|
|
253
|
+
handle.finalize();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
rollback() {
|
|
257
|
+
if (this.#settled)
|
|
258
|
+
return;
|
|
259
|
+
this.#settled = true;
|
|
260
|
+
for (const handle of [...this.#handles].reverse()) {
|
|
261
|
+
handle.rollback();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function runTransaction(work) {
|
|
266
|
+
const tx = new Transaction();
|
|
267
|
+
try {
|
|
268
|
+
const result = work(tx);
|
|
269
|
+
tx.commit();
|
|
270
|
+
return result;
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
tx.rollback();
|
|
274
|
+
throw err;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function beginReplaceDir(stagingDir, dest, fsOps = fs) {
|
|
278
|
+
const destExisted = fsOps.existsSync(dest);
|
|
279
|
+
fsOps.mkdirSync(path.dirname(dest), { recursive: true });
|
|
280
|
+
const tempDest = createSiblingTempPath(dest, 'tmp');
|
|
281
|
+
const backupDest = destExisted ? createSiblingTempPath(dest, 'bak') : null;
|
|
282
|
+
let settled = false;
|
|
283
|
+
try {
|
|
284
|
+
moveDir(stagingDir, tempDest, fsOps);
|
|
285
|
+
if (backupDest) {
|
|
286
|
+
fsOps.renameSync(dest, backupDest);
|
|
287
|
+
}
|
|
288
|
+
fsOps.renameSync(tempDest, dest);
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
try {
|
|
292
|
+
fsOps.rmSync(tempDest, { recursive: true, force: true });
|
|
293
|
+
}
|
|
294
|
+
catch { }
|
|
295
|
+
if (backupDest && !fsOps.existsSync(dest)) {
|
|
296
|
+
try {
|
|
297
|
+
fsOps.renameSync(backupDest, dest);
|
|
298
|
+
}
|
|
299
|
+
catch { }
|
|
300
|
+
}
|
|
301
|
+
throw err;
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
finalize() {
|
|
305
|
+
if (settled)
|
|
306
|
+
return;
|
|
307
|
+
settled = true;
|
|
308
|
+
if (backupDest) {
|
|
309
|
+
try {
|
|
310
|
+
fsOps.rmSync(backupDest, { recursive: true, force: true });
|
|
311
|
+
}
|
|
312
|
+
catch { }
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
rollback() {
|
|
316
|
+
if (settled)
|
|
317
|
+
return;
|
|
318
|
+
settled = true;
|
|
319
|
+
try {
|
|
320
|
+
fsOps.rmSync(dest, { recursive: true, force: true });
|
|
321
|
+
}
|
|
322
|
+
catch { }
|
|
323
|
+
if (backupDest) {
|
|
324
|
+
try {
|
|
325
|
+
fsOps.renameSync(backupDest, dest);
|
|
326
|
+
}
|
|
327
|
+
catch { }
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
fsOps.rmSync(tempDest, { recursive: true, force: true });
|
|
331
|
+
}
|
|
332
|
+
catch { }
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
function beginReplaceSymlink(target, linkPath) {
|
|
337
|
+
const linkExists = pathExistsSync(linkPath);
|
|
338
|
+
if (linkExists && !isSymlinkSync(linkPath)) {
|
|
339
|
+
throw new Error(`Expected monorepo plugin link at ${linkPath} to be a symlink`);
|
|
340
|
+
}
|
|
341
|
+
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
|
342
|
+
const tempLink = createSiblingTempPath(linkPath, 'tmp');
|
|
343
|
+
const backupLink = linkExists ? createSiblingTempPath(linkPath, 'bak') : null;
|
|
344
|
+
const linkType = isWindows ? 'junction' : 'dir';
|
|
345
|
+
let settled = false;
|
|
346
|
+
try {
|
|
347
|
+
fs.symlinkSync(target, tempLink, linkType);
|
|
348
|
+
if (backupLink) {
|
|
349
|
+
fs.renameSync(linkPath, backupLink);
|
|
350
|
+
}
|
|
351
|
+
fs.renameSync(tempLink, linkPath);
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
removePathSync(tempLink);
|
|
355
|
+
if (backupLink && !pathExistsSync(linkPath)) {
|
|
356
|
+
try {
|
|
357
|
+
fs.renameSync(backupLink, linkPath);
|
|
358
|
+
}
|
|
359
|
+
catch { }
|
|
360
|
+
}
|
|
361
|
+
throw err;
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
finalize() {
|
|
365
|
+
if (settled)
|
|
366
|
+
return;
|
|
367
|
+
settled = true;
|
|
368
|
+
if (backupLink) {
|
|
369
|
+
removePathSync(backupLink);
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
rollback() {
|
|
373
|
+
if (settled)
|
|
374
|
+
return;
|
|
375
|
+
settled = true;
|
|
376
|
+
removePathSync(linkPath);
|
|
377
|
+
if (backupLink && !pathExistsSync(linkPath)) {
|
|
378
|
+
try {
|
|
379
|
+
fs.renameSync(backupLink, linkPath);
|
|
380
|
+
}
|
|
381
|
+
catch { }
|
|
382
|
+
}
|
|
383
|
+
removePathSync(tempLink);
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|
|
26
387
|
// ── Lock file helpers ───────────────────────────────────────────────────────
|
|
27
|
-
|
|
388
|
+
function readLockFileWithWriter(writeLock = writeLockFile) {
|
|
28
389
|
try {
|
|
29
390
|
const raw = fs.readFileSync(getLockFilePath(), 'utf-8');
|
|
30
|
-
|
|
391
|
+
const parsed = JSON.parse(raw);
|
|
392
|
+
if (!isRecord(parsed))
|
|
393
|
+
return {};
|
|
394
|
+
const lock = {};
|
|
395
|
+
let changed = false;
|
|
396
|
+
for (const [name, entry] of Object.entries(parsed)) {
|
|
397
|
+
const normalized = normalizeLockEntry(entry);
|
|
398
|
+
if (!normalized) {
|
|
399
|
+
changed = true;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
lock[name] = normalized;
|
|
403
|
+
if (JSON.stringify(entry) !== JSON.stringify(normalized)) {
|
|
404
|
+
changed = true;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (changed) {
|
|
408
|
+
try {
|
|
409
|
+
writeLock(lock);
|
|
410
|
+
}
|
|
411
|
+
catch { }
|
|
412
|
+
}
|
|
413
|
+
return lock;
|
|
31
414
|
}
|
|
32
415
|
catch {
|
|
33
416
|
return {};
|
|
34
417
|
}
|
|
35
418
|
}
|
|
36
|
-
export function
|
|
419
|
+
export function readLockFile() {
|
|
420
|
+
return readLockFileWithWriter(writeLockFile);
|
|
421
|
+
}
|
|
422
|
+
function writeLockFileWithFs(lock, fsOps = fs) {
|
|
37
423
|
const lockPath = getLockFilePath();
|
|
38
|
-
|
|
39
|
-
|
|
424
|
+
fsOps.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
425
|
+
const tempPath = createSiblingTempPath(lockPath, 'tmp');
|
|
426
|
+
try {
|
|
427
|
+
fsOps.writeFileSync(tempPath, JSON.stringify(lock, null, 2) + '\n');
|
|
428
|
+
fsOps.renameSync(tempPath, lockPath);
|
|
429
|
+
}
|
|
430
|
+
catch (err) {
|
|
431
|
+
try {
|
|
432
|
+
fsOps.rmSync(tempPath, { force: true });
|
|
433
|
+
}
|
|
434
|
+
catch { }
|
|
435
|
+
throw err;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
export function writeLockFile(lock) {
|
|
439
|
+
writeLockFileWithFs(lock, fs);
|
|
40
440
|
}
|
|
41
441
|
/** Get the HEAD commit hash of a git repo directory. */
|
|
42
442
|
export function getCommitHash(dir) {
|
|
@@ -66,55 +466,110 @@ export function validatePluginStructure(pluginDir) {
|
|
|
66
466
|
const hasTs = files.some(f => f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('.test.ts'));
|
|
67
467
|
const hasJs = files.some(f => f.endsWith('.js') && !f.endsWith('.d.js'));
|
|
68
468
|
if (!hasYaml && !hasTs && !hasJs) {
|
|
69
|
-
errors.push(
|
|
469
|
+
errors.push('No command files found in plugin directory. A plugin must contain at least one .yaml, .ts, or .js command file.');
|
|
70
470
|
}
|
|
71
471
|
if (hasTs) {
|
|
72
472
|
const pkgJsonPath = path.join(pluginDir, 'package.json');
|
|
73
473
|
if (!fs.existsSync(pkgJsonPath)) {
|
|
74
|
-
errors.push(
|
|
474
|
+
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.');
|
|
75
475
|
}
|
|
76
476
|
else {
|
|
77
477
|
try {
|
|
78
478
|
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
|
|
79
479
|
if (pkg.type !== 'module') {
|
|
80
|
-
errors.push(
|
|
480
|
+
errors.push('Plugin package.json must have "type": "module" for TypeScript plugins.');
|
|
81
481
|
}
|
|
82
482
|
}
|
|
83
483
|
catch {
|
|
84
|
-
errors.push(
|
|
484
|
+
errors.push('Plugin package.json is malformed or invalid JSON.');
|
|
85
485
|
}
|
|
86
486
|
}
|
|
87
487
|
}
|
|
88
488
|
return { valid: errors.length === 0, errors };
|
|
89
489
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
* Called by both installPlugin() and updatePlugin().
|
|
93
|
-
*/
|
|
94
|
-
function postInstallLifecycle(pluginDir) {
|
|
95
|
-
const pkgJsonPath = path.join(pluginDir, 'package.json');
|
|
490
|
+
function installDependencies(dir) {
|
|
491
|
+
const pkgJsonPath = path.join(dir, 'package.json');
|
|
96
492
|
if (!fs.existsSync(pkgJsonPath))
|
|
97
493
|
return;
|
|
98
494
|
try {
|
|
99
495
|
execFileSync('npm', ['install', '--omit=dev'], {
|
|
100
|
-
cwd:
|
|
496
|
+
cwd: dir,
|
|
101
497
|
encoding: 'utf-8',
|
|
102
498
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
103
499
|
...(isWindows && { shell: true }),
|
|
104
500
|
});
|
|
105
501
|
}
|
|
106
502
|
catch (err) {
|
|
107
|
-
|
|
503
|
+
throw new Error(`npm install failed in ${dir}: ${getErrorMessage(err)}`);
|
|
108
504
|
}
|
|
505
|
+
}
|
|
506
|
+
function finalizePluginRuntime(pluginDir) {
|
|
109
507
|
// Symlink host opencli so TS plugins resolve '@jackwener/opencli/registry'
|
|
110
508
|
// against the running host, not a stale npm-published version.
|
|
111
509
|
linkHostOpencli(pluginDir);
|
|
112
510
|
// Transpile .ts → .js via esbuild (production node can't load .ts directly).
|
|
113
511
|
transpilePluginTs(pluginDir);
|
|
114
512
|
}
|
|
513
|
+
/**
|
|
514
|
+
* Shared post-install lifecycle for standalone plugins.
|
|
515
|
+
*/
|
|
516
|
+
function postInstallLifecycle(pluginDir) {
|
|
517
|
+
installDependencies(pluginDir);
|
|
518
|
+
finalizePluginRuntime(pluginDir);
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Monorepo lifecycle: install shared deps once at repo root, then finalize each sub-plugin.
|
|
522
|
+
*/
|
|
523
|
+
function postInstallMonorepoLifecycle(repoDir, pluginDirs) {
|
|
524
|
+
installDependencies(repoDir);
|
|
525
|
+
for (const pluginDir of pluginDirs) {
|
|
526
|
+
finalizePluginRuntime(pluginDir);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
function ensureStandalonePluginReady(pluginDir) {
|
|
530
|
+
const validation = validatePluginStructure(pluginDir);
|
|
531
|
+
if (!validation.valid) {
|
|
532
|
+
throw new Error(`Invalid plugin structure:\n- ${validation.errors.join('\n- ')}`);
|
|
533
|
+
}
|
|
534
|
+
postInstallLifecycle(pluginDir);
|
|
535
|
+
}
|
|
536
|
+
function upsertLockEntry(lock, name, entry) {
|
|
537
|
+
lock[name] = {
|
|
538
|
+
...entry,
|
|
539
|
+
installedAt: entry.installedAt ?? new Date().toISOString(),
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
function publishStandalonePlugin(stagingDir, targetDir, writeLock) {
|
|
543
|
+
runTransaction((tx) => {
|
|
544
|
+
tx.track(beginReplaceDir(stagingDir, targetDir));
|
|
545
|
+
writeLock(getCommitHash(targetDir));
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
function publishMonorepoPlugins(repoDir, pluginsDir, plugins, publishRepo, writeLock) {
|
|
549
|
+
runTransaction((tx) => {
|
|
550
|
+
if (publishRepo) {
|
|
551
|
+
fs.mkdirSync(publishRepo.parentDir, { recursive: true });
|
|
552
|
+
tx.track(beginReplaceDir(publishRepo.stagingDir, repoDir));
|
|
553
|
+
}
|
|
554
|
+
const commitHash = getCommitHash(repoDir);
|
|
555
|
+
for (const plugin of plugins) {
|
|
556
|
+
const linkPath = path.join(pluginsDir, plugin.name);
|
|
557
|
+
const subDir = path.join(repoDir, plugin.subPath);
|
|
558
|
+
tx.track(beginReplaceSymlink(subDir, linkPath));
|
|
559
|
+
}
|
|
560
|
+
writeLock?.(commitHash);
|
|
561
|
+
});
|
|
562
|
+
}
|
|
115
563
|
/**
|
|
116
564
|
* Install a plugin from a source.
|
|
117
|
-
*
|
|
565
|
+
* Supports:
|
|
566
|
+
* "github:user/repo" — single plugin or full monorepo
|
|
567
|
+
* "github:user/repo/subplugin" — specific sub-plugin from a monorepo
|
|
568
|
+
* "https://github.com/user/repo"
|
|
569
|
+
* "file:///absolute/path" — local plugin directory (symlinked)
|
|
570
|
+
* "/absolute/path" — local plugin directory (symlinked)
|
|
571
|
+
*
|
|
572
|
+
* Returns the installed plugin name(s).
|
|
118
573
|
*/
|
|
119
574
|
export function installPlugin(source) {
|
|
120
575
|
const parsed = parseSource(source);
|
|
@@ -122,93 +577,339 @@ export function installPlugin(source) {
|
|
|
122
577
|
throw new Error(`Invalid plugin source: "${source}"\n` +
|
|
123
578
|
`Supported formats:\n` +
|
|
124
579
|
` github:user/repo\n` +
|
|
125
|
-
`
|
|
580
|
+
` github:user/repo/subplugin\n` +
|
|
581
|
+
` https://github.com/user/repo\n` +
|
|
582
|
+
` https://<host>/<path>/repo.git\n` +
|
|
583
|
+
` ssh://git@<host>/<path>/repo.git\n` +
|
|
584
|
+
` git@<host>:user/repo.git\n` +
|
|
585
|
+
` file:///absolute/path\n` +
|
|
586
|
+
` /absolute/path`);
|
|
126
587
|
}
|
|
127
|
-
const {
|
|
128
|
-
|
|
588
|
+
const { name: repoName, subPlugin } = parsed;
|
|
589
|
+
if (parsed.type === 'local') {
|
|
590
|
+
return installLocalPlugin(parsed.localPath, repoName);
|
|
591
|
+
}
|
|
592
|
+
return withTempClone(parsed.cloneUrl, (tmpCloneDir) => {
|
|
593
|
+
const manifest = readPluginManifest(tmpCloneDir);
|
|
594
|
+
// Check top-level compatibility
|
|
595
|
+
if (manifest?.opencli && !checkCompatibility(manifest.opencli)) {
|
|
596
|
+
throw new Error(`Plugin requires opencli ${manifest.opencli}, but current version is incompatible.`);
|
|
597
|
+
}
|
|
598
|
+
if (manifest && isMonorepo(manifest)) {
|
|
599
|
+
return installMonorepo(tmpCloneDir, parsed.cloneUrl, repoName, manifest, subPlugin);
|
|
600
|
+
}
|
|
601
|
+
// Single plugin mode
|
|
602
|
+
return installSinglePlugin(tmpCloneDir, parsed.cloneUrl, repoName, manifest);
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
/** Install a single (non-monorepo) plugin. */
|
|
606
|
+
function installSinglePlugin(cloneDir, cloneUrl, name, manifest) {
|
|
607
|
+
const pluginName = manifest?.name ?? name;
|
|
608
|
+
const targetDir = path.join(PLUGINS_DIR, pluginName);
|
|
129
609
|
if (fs.existsSync(targetDir)) {
|
|
130
|
-
throw new Error(`Plugin "${
|
|
610
|
+
throw new Error(`Plugin "${pluginName}" is already installed at ${targetDir}`);
|
|
131
611
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
612
|
+
ensureStandalonePluginReady(cloneDir);
|
|
613
|
+
publishStandalonePlugin(cloneDir, targetDir, (commitHash) => {
|
|
614
|
+
const lock = readLockFile();
|
|
615
|
+
if (commitHash) {
|
|
616
|
+
upsertLockEntry(lock, pluginName, {
|
|
617
|
+
source: { kind: 'git', url: cloneUrl },
|
|
618
|
+
commitHash,
|
|
619
|
+
});
|
|
620
|
+
writeLockFile(lock);
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
return pluginName;
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Install a local plugin by creating a symlink.
|
|
627
|
+
* Used for plugin development: the source directory is symlinked into
|
|
628
|
+
* the plugins dir so changes are reflected immediately.
|
|
629
|
+
*/
|
|
630
|
+
function installLocalPlugin(localPath, name) {
|
|
631
|
+
if (!fs.existsSync(localPath)) {
|
|
632
|
+
throw new Error(`Local plugin path does not exist: ${localPath}`);
|
|
139
633
|
}
|
|
140
|
-
|
|
141
|
-
|
|
634
|
+
const stat = fs.statSync(localPath);
|
|
635
|
+
if (!stat.isDirectory()) {
|
|
636
|
+
throw new Error(`Local plugin path is not a directory: ${localPath}`);
|
|
637
|
+
}
|
|
638
|
+
const manifest = readPluginManifest(localPath);
|
|
639
|
+
if (manifest?.opencli && !checkCompatibility(manifest.opencli)) {
|
|
640
|
+
throw new Error(`Plugin requires opencli ${manifest.opencli}, but current version is incompatible.`);
|
|
142
641
|
}
|
|
143
|
-
const
|
|
642
|
+
const pluginName = manifest?.name ?? name;
|
|
643
|
+
const targetDir = path.join(PLUGINS_DIR, pluginName);
|
|
644
|
+
if (fs.existsSync(targetDir)) {
|
|
645
|
+
throw new Error(`Plugin "${pluginName}" is already installed at ${targetDir}`);
|
|
646
|
+
}
|
|
647
|
+
const validation = validatePluginStructure(localPath);
|
|
144
648
|
if (!validation.valid) {
|
|
145
|
-
// If validation fails, clean up the cloned directory and abort
|
|
146
|
-
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
147
649
|
throw new Error(`Invalid plugin structure:\n- ${validation.errors.join('\n- ')}`);
|
|
148
650
|
}
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
651
|
+
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
652
|
+
const resolvedPath = path.resolve(localPath);
|
|
653
|
+
const linkType = isWindows ? 'junction' : 'dir';
|
|
654
|
+
fs.symlinkSync(resolvedPath, targetDir, linkType);
|
|
655
|
+
installDependencies(localPath);
|
|
656
|
+
finalizePluginRuntime(localPath);
|
|
657
|
+
const lock = readLockFile();
|
|
658
|
+
const commitHash = getCommitHash(localPath);
|
|
659
|
+
upsertLockEntry(lock, pluginName, {
|
|
660
|
+
source: { kind: 'local', path: resolvedPath },
|
|
661
|
+
commitHash: commitHash ?? 'local',
|
|
662
|
+
});
|
|
663
|
+
writeLockFile(lock);
|
|
664
|
+
return pluginName;
|
|
665
|
+
}
|
|
666
|
+
function updateLocalPlugin(name, targetDir, lock, lockEntry) {
|
|
667
|
+
const pluginDir = fs.realpathSync(targetDir);
|
|
668
|
+
const validation = validatePluginStructure(pluginDir);
|
|
669
|
+
if (!validation.valid) {
|
|
670
|
+
log.warn(`Plugin "${name}" structure invalid:\n- ${validation.errors.join('\n- ')}`);
|
|
671
|
+
}
|
|
672
|
+
postInstallLifecycle(pluginDir);
|
|
673
|
+
upsertLockEntry(lock, name, {
|
|
674
|
+
source: lockEntry?.source ?? { kind: 'local', path: pluginDir },
|
|
675
|
+
commitHash: getCommitHash(pluginDir) ?? 'local',
|
|
676
|
+
installedAt: lockEntry?.installedAt ?? new Date().toISOString(),
|
|
677
|
+
updatedAt: new Date().toISOString(),
|
|
678
|
+
});
|
|
679
|
+
writeLockFile(lock);
|
|
680
|
+
}
|
|
681
|
+
/** Install sub-plugins from a monorepo. */
|
|
682
|
+
function installMonorepo(cloneDir, cloneUrl, repoName, manifest, subPlugin) {
|
|
683
|
+
const monoreposDir = getMonoreposDir();
|
|
684
|
+
const repoDir = path.join(monoreposDir, repoName);
|
|
685
|
+
const repoAlreadyInstalled = fs.existsSync(repoDir);
|
|
686
|
+
const repoRoot = repoAlreadyInstalled ? repoDir : cloneDir;
|
|
687
|
+
const effectiveManifest = repoAlreadyInstalled ? readPluginManifest(repoDir) : manifest;
|
|
688
|
+
if (!effectiveManifest || !isMonorepo(effectiveManifest)) {
|
|
689
|
+
throw new Error(`Monorepo manifest missing or invalid at ${repoRoot}`);
|
|
690
|
+
}
|
|
691
|
+
let pluginsToInstall = getEnabledPlugins(effectiveManifest);
|
|
692
|
+
// If a specific sub-plugin was requested, filter to just that one
|
|
693
|
+
if (subPlugin) {
|
|
694
|
+
pluginsToInstall = pluginsToInstall.filter((p) => p.name === subPlugin);
|
|
695
|
+
if (pluginsToInstall.length === 0) {
|
|
696
|
+
// Check if it exists but is disabled
|
|
697
|
+
const disabled = effectiveManifest.plugins?.[subPlugin];
|
|
698
|
+
if (disabled) {
|
|
699
|
+
throw new Error(`Sub-plugin "${subPlugin}" is disabled in the manifest.`);
|
|
700
|
+
}
|
|
701
|
+
throw new Error(`Sub-plugin "${subPlugin}" not found in monorepo. Available: ${Object.keys(effectiveManifest.plugins ?? {}).join(', ')}`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
const installedNames = [];
|
|
705
|
+
const lock = readLockFile();
|
|
706
|
+
const eligiblePlugins = [];
|
|
707
|
+
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
708
|
+
for (const { name, entry } of pluginsToInstall) {
|
|
709
|
+
// Check sub-plugin level compatibility (overrides top-level)
|
|
710
|
+
if (entry.opencli && !checkCompatibility(entry.opencli)) {
|
|
711
|
+
log.warn(`Skipping "${name}": requires opencli ${entry.opencli}`);
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
const subDir = path.join(repoRoot, entry.path);
|
|
715
|
+
if (!fs.existsSync(subDir)) {
|
|
716
|
+
log.warn(`Skipping "${name}": path "${entry.path}" not found in repo.`);
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
const validation = validatePluginStructure(subDir);
|
|
720
|
+
if (!validation.valid) {
|
|
721
|
+
log.warn(`Skipping "${name}": invalid structure — ${validation.errors.join(', ')}`);
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
const linkPath = path.join(PLUGINS_DIR, name);
|
|
725
|
+
if (fs.existsSync(linkPath)) {
|
|
726
|
+
log.warn(`Skipping "${name}": already installed at ${linkPath}`);
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
eligiblePlugins.push({ name, entry });
|
|
730
|
+
}
|
|
731
|
+
if (eligiblePlugins.length === 0) {
|
|
732
|
+
return installedNames;
|
|
733
|
+
}
|
|
734
|
+
const publishPlugins = eligiblePlugins.map(({ name, entry }) => ({ name, subPath: entry.path }));
|
|
735
|
+
if (repoAlreadyInstalled) {
|
|
736
|
+
postInstallMonorepoLifecycle(repoDir, eligiblePlugins.map((p) => path.join(repoDir, p.entry.path)));
|
|
737
|
+
}
|
|
738
|
+
else {
|
|
739
|
+
postInstallMonorepoLifecycle(cloneDir, eligiblePlugins.map((p) => path.join(cloneDir, p.entry.path)));
|
|
740
|
+
}
|
|
741
|
+
publishMonorepoPlugins(repoDir, PLUGINS_DIR, publishPlugins, repoAlreadyInstalled ? undefined : { stagingDir: cloneDir, parentDir: monoreposDir }, (commitHash) => {
|
|
742
|
+
for (const { name, entry } of eligiblePlugins) {
|
|
743
|
+
if (commitHash) {
|
|
744
|
+
upsertLockEntry(lock, name, {
|
|
745
|
+
source: {
|
|
746
|
+
kind: 'monorepo',
|
|
747
|
+
url: cloneUrl,
|
|
748
|
+
repoName,
|
|
749
|
+
subPath: entry.path,
|
|
750
|
+
},
|
|
751
|
+
commitHash,
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
installedNames.push(name);
|
|
755
|
+
}
|
|
158
756
|
writeLockFile(lock);
|
|
757
|
+
});
|
|
758
|
+
return installedNames;
|
|
759
|
+
}
|
|
760
|
+
function collectUpdatedMonorepoPlugins(monoName, lock, manifest, cloneUrl, tmpCloneDir) {
|
|
761
|
+
const updatedPlugins = [];
|
|
762
|
+
for (const [pluginName, entry] of Object.entries(lock)) {
|
|
763
|
+
if (entry.source.kind !== 'monorepo' || entry.source.repoName !== monoName)
|
|
764
|
+
continue;
|
|
765
|
+
const manifestEntry = manifest.plugins?.[pluginName];
|
|
766
|
+
if (!manifestEntry || manifestEntry.disabled) {
|
|
767
|
+
throw new Error(`Installed sub-plugin "${pluginName}" no longer exists in ${cloneUrl}`);
|
|
768
|
+
}
|
|
769
|
+
if (manifestEntry.opencli && !checkCompatibility(manifestEntry.opencli)) {
|
|
770
|
+
throw new Error(`Sub-plugin "${pluginName}" requires opencli ${manifestEntry.opencli}`);
|
|
771
|
+
}
|
|
772
|
+
const subDir = path.join(tmpCloneDir, manifestEntry.path);
|
|
773
|
+
const validation = validatePluginStructure(subDir);
|
|
774
|
+
if (!validation.valid) {
|
|
775
|
+
throw new Error(`Updated sub-plugin "${pluginName}" is invalid:\n- ${validation.errors.join('\n- ')}`);
|
|
776
|
+
}
|
|
777
|
+
updatedPlugins.push({ name: pluginName, lockEntry: entry, manifestEntry });
|
|
778
|
+
}
|
|
779
|
+
return updatedPlugins;
|
|
780
|
+
}
|
|
781
|
+
function updateMonorepoLockEntries(lock, plugins, cloneUrl, monoName, commitHash) {
|
|
782
|
+
for (const plugin of plugins) {
|
|
783
|
+
if (!commitHash)
|
|
784
|
+
continue;
|
|
785
|
+
upsertLockEntry(lock, plugin.name, {
|
|
786
|
+
...plugin.lockEntry,
|
|
787
|
+
source: {
|
|
788
|
+
kind: 'monorepo',
|
|
789
|
+
url: cloneUrl,
|
|
790
|
+
repoName: monoName,
|
|
791
|
+
subPath: plugin.manifestEntry.path,
|
|
792
|
+
},
|
|
793
|
+
commitHash,
|
|
794
|
+
updatedAt: new Date().toISOString(),
|
|
795
|
+
});
|
|
159
796
|
}
|
|
160
|
-
|
|
797
|
+
}
|
|
798
|
+
function updateStandaloneLockEntry(lock, name, cloneUrl, existing, commitHash) {
|
|
799
|
+
if (!commitHash)
|
|
800
|
+
return;
|
|
801
|
+
upsertLockEntry(lock, name, {
|
|
802
|
+
source: { kind: 'git', url: cloneUrl },
|
|
803
|
+
commitHash,
|
|
804
|
+
installedAt: existing?.installedAt ?? new Date().toISOString(),
|
|
805
|
+
updatedAt: new Date().toISOString(),
|
|
806
|
+
});
|
|
161
807
|
}
|
|
162
808
|
/**
|
|
163
809
|
* Uninstall a plugin by name.
|
|
810
|
+
* For monorepo sub-plugins: removes symlink and cleans up the monorepo
|
|
811
|
+
* directory when no more sub-plugins reference it.
|
|
164
812
|
*/
|
|
165
813
|
export function uninstallPlugin(name) {
|
|
166
814
|
const targetDir = path.join(PLUGINS_DIR, name);
|
|
167
815
|
if (!fs.existsSync(targetDir)) {
|
|
168
816
|
throw new Error(`Plugin "${name}" is not installed.`);
|
|
169
817
|
}
|
|
170
|
-
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
171
818
|
const lock = readLockFile();
|
|
172
|
-
|
|
819
|
+
const lockEntry = lock[name];
|
|
820
|
+
// Check if this is a symlink (monorepo sub-plugin)
|
|
821
|
+
const isSymlink = isSymlinkSync(targetDir);
|
|
822
|
+
if (isSymlink) {
|
|
823
|
+
// Remove symlink only (not the actual directory)
|
|
824
|
+
fs.unlinkSync(targetDir);
|
|
825
|
+
}
|
|
826
|
+
else {
|
|
827
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
828
|
+
}
|
|
829
|
+
// Clean up monorepo directory if no more sub-plugins reference it
|
|
830
|
+
if (lockEntry?.source.kind === 'monorepo') {
|
|
173
831
|
delete lock[name];
|
|
174
|
-
|
|
832
|
+
const monoName = lockEntry.source.repoName;
|
|
833
|
+
const stillReferenced = Object.values(lock).some((entry) => entry.source.kind === 'monorepo' && entry.source.repoName === monoName);
|
|
834
|
+
if (!stillReferenced) {
|
|
835
|
+
const monoDir = path.join(getMonoreposDir(), monoName);
|
|
836
|
+
try {
|
|
837
|
+
fs.rmSync(monoDir, { recursive: true, force: true });
|
|
838
|
+
}
|
|
839
|
+
catch { }
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
else if (lock[name]) {
|
|
843
|
+
delete lock[name];
|
|
844
|
+
}
|
|
845
|
+
writeLockFile(lock);
|
|
846
|
+
}
|
|
847
|
+
/** Synchronous check if a path is a symlink. */
|
|
848
|
+
function isSymlinkSync(p) {
|
|
849
|
+
try {
|
|
850
|
+
return fs.lstatSync(p).isSymbolicLink();
|
|
851
|
+
}
|
|
852
|
+
catch {
|
|
853
|
+
return false;
|
|
175
854
|
}
|
|
176
855
|
}
|
|
177
856
|
/**
|
|
178
857
|
* Update a plugin by name (git pull + re-install lifecycle).
|
|
858
|
+
* For monorepo sub-plugins: pulls the monorepo root and re-runs lifecycle
|
|
859
|
+
* for all sub-plugins from the same monorepo.
|
|
179
860
|
*/
|
|
180
861
|
export function updatePlugin(name) {
|
|
181
862
|
const targetDir = path.join(PLUGINS_DIR, name);
|
|
182
863
|
if (!fs.existsSync(targetDir)) {
|
|
183
864
|
throw new Error(`Plugin "${name}" is not installed.`);
|
|
184
865
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
catch (err) {
|
|
193
|
-
throw new Error(`Failed to update plugin: ${getErrorMessage(err)}`);
|
|
194
|
-
}
|
|
195
|
-
const validation = validatePluginStructure(targetDir);
|
|
196
|
-
if (!validation.valid) {
|
|
197
|
-
log.warn(`Plugin "${name}" updated, but structure is now invalid:\n- ${validation.errors.join('\n- ')}`);
|
|
866
|
+
const lock = readLockFile();
|
|
867
|
+
const lockEntry = lock[name];
|
|
868
|
+
const source = resolvePluginSource(lockEntry, targetDir);
|
|
869
|
+
if (source?.kind === 'local') {
|
|
870
|
+
updateLocalPlugin(name, targetDir, lock, lockEntry);
|
|
871
|
+
return;
|
|
198
872
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
873
|
+
if (source?.kind === 'monorepo') {
|
|
874
|
+
const monoDir = path.join(getMonoreposDir(), source.repoName);
|
|
875
|
+
const monoName = source.repoName;
|
|
876
|
+
const cloneUrl = source.url;
|
|
877
|
+
withTempClone(cloneUrl, (tmpCloneDir) => {
|
|
878
|
+
const manifest = readPluginManifest(tmpCloneDir);
|
|
879
|
+
if (!manifest || !isMonorepo(manifest)) {
|
|
880
|
+
throw new Error(`Updated source is no longer a monorepo: ${cloneUrl}`);
|
|
881
|
+
}
|
|
882
|
+
if (manifest.opencli && !checkCompatibility(manifest.opencli)) {
|
|
883
|
+
throw new Error(`Plugin requires opencli ${manifest.opencli}, but current version is incompatible.`);
|
|
884
|
+
}
|
|
885
|
+
const updatedPlugins = collectUpdatedMonorepoPlugins(monoName, lock, manifest, cloneUrl, tmpCloneDir);
|
|
886
|
+
if (updatedPlugins.length > 0) {
|
|
887
|
+
postInstallMonorepoLifecycle(tmpCloneDir, updatedPlugins.map((plugin) => path.join(tmpCloneDir, plugin.manifestEntry.path)));
|
|
888
|
+
}
|
|
889
|
+
publishMonorepoPlugins(monoDir, PLUGINS_DIR, updatedPlugins.map((plugin) => ({ name: plugin.name, subPath: plugin.manifestEntry.path })), { stagingDir: tmpCloneDir, parentDir: path.dirname(monoDir) }, (commitHash) => {
|
|
890
|
+
updateMonorepoLockEntries(lock, updatedPlugins, cloneUrl, monoName, commitHash);
|
|
891
|
+
writeLockFile(lock);
|
|
892
|
+
});
|
|
893
|
+
});
|
|
894
|
+
return;
|
|
211
895
|
}
|
|
896
|
+
const cloneUrl = resolveRemotePluginSource(lockEntry, targetDir);
|
|
897
|
+
withTempClone(cloneUrl, (tmpCloneDir) => {
|
|
898
|
+
const manifest = readPluginManifest(tmpCloneDir);
|
|
899
|
+
if (manifest && isMonorepo(manifest)) {
|
|
900
|
+
throw new Error(`Updated source is now a monorepo: ${cloneUrl}`);
|
|
901
|
+
}
|
|
902
|
+
if (manifest?.opencli && !checkCompatibility(manifest.opencli)) {
|
|
903
|
+
throw new Error(`Plugin requires opencli ${manifest.opencli}, but current version is incompatible.`);
|
|
904
|
+
}
|
|
905
|
+
ensureStandalonePluginReady(tmpCloneDir);
|
|
906
|
+
publishStandalonePlugin(tmpCloneDir, targetDir, (commitHash) => {
|
|
907
|
+
updateStandaloneLockEntry(lock, name, cloneUrl, lock[name], commitHash);
|
|
908
|
+
if (commitHash) {
|
|
909
|
+
writeLockFile(lock);
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
});
|
|
212
913
|
}
|
|
213
914
|
/**
|
|
214
915
|
* Update all installed plugins.
|
|
@@ -231,6 +932,7 @@ export function updateAllPlugins() {
|
|
|
231
932
|
}
|
|
232
933
|
/**
|
|
233
934
|
* List all installed plugins.
|
|
935
|
+
* Reads opencli-plugin.json for description/version when available.
|
|
234
936
|
*/
|
|
235
937
|
export function listPlugins() {
|
|
236
938
|
if (!fs.existsSync(PLUGINS_DIR))
|
|
@@ -239,19 +941,37 @@ export function listPlugins() {
|
|
|
239
941
|
const lock = readLockFile();
|
|
240
942
|
const plugins = [];
|
|
241
943
|
for (const entry of entries) {
|
|
242
|
-
|
|
243
|
-
continue;
|
|
944
|
+
// Accept both real directories and symlinks (monorepo sub-plugins)
|
|
244
945
|
const pluginDir = path.join(PLUGINS_DIR, entry.name);
|
|
946
|
+
const isDir = entry.isDirectory() || isSymlinkSync(pluginDir);
|
|
947
|
+
if (!isDir)
|
|
948
|
+
continue;
|
|
245
949
|
const commands = scanPluginCommands(pluginDir);
|
|
246
|
-
const source = getPluginSource(pluginDir);
|
|
247
950
|
const lockEntry = lock[entry.name];
|
|
951
|
+
// Try to read manifest for metadata
|
|
952
|
+
const manifest = readPluginManifest(pluginDir);
|
|
953
|
+
// For monorepo sub-plugins, also check the monorepo root manifest
|
|
954
|
+
let description = manifest?.description;
|
|
955
|
+
let version = manifest?.version;
|
|
956
|
+
if (lockEntry?.source.kind === 'monorepo' && !description) {
|
|
957
|
+
const monoDir = path.join(getMonoreposDir(), lockEntry.source.repoName);
|
|
958
|
+
const monoManifest = readPluginManifest(monoDir);
|
|
959
|
+
const subEntry = monoManifest?.plugins?.[entry.name];
|
|
960
|
+
if (subEntry) {
|
|
961
|
+
description = description ?? subEntry.description;
|
|
962
|
+
version = version ?? subEntry.version;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
const source = resolveStoredPluginSource(lockEntry, pluginDir);
|
|
248
966
|
plugins.push({
|
|
249
967
|
name: entry.name,
|
|
250
968
|
path: pluginDir,
|
|
251
969
|
commands,
|
|
252
970
|
source,
|
|
253
|
-
version: lockEntry?.commitHash?.slice(0, 7),
|
|
971
|
+
version: version ?? lockEntry?.commitHash?.slice(0, 7),
|
|
254
972
|
installedAt: lockEntry?.installedAt,
|
|
973
|
+
monorepoName: lockEntry?.source.kind === 'monorepo' ? lockEntry.source.repoName : undefined,
|
|
974
|
+
description,
|
|
255
975
|
});
|
|
256
976
|
}
|
|
257
977
|
return plugins;
|
|
@@ -284,14 +1004,48 @@ function getPluginSource(dir) {
|
|
|
284
1004
|
return undefined;
|
|
285
1005
|
}
|
|
286
1006
|
}
|
|
287
|
-
/** Parse a plugin source string into clone URL
|
|
1007
|
+
/** Parse a plugin source string into clone URL, repo name, and optional sub-plugin. */
|
|
288
1008
|
function parseSource(source) {
|
|
1009
|
+
if (source.startsWith('file://')) {
|
|
1010
|
+
try {
|
|
1011
|
+
const localPath = path.resolve(fileURLToPath(source));
|
|
1012
|
+
return {
|
|
1013
|
+
type: 'local',
|
|
1014
|
+
localPath,
|
|
1015
|
+
name: path.basename(localPath).replace(/^opencli-plugin-/, ''),
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
catch {
|
|
1019
|
+
return null;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
if (path.isAbsolute(source)) {
|
|
1023
|
+
const localPath = path.resolve(source);
|
|
1024
|
+
return {
|
|
1025
|
+
type: 'local',
|
|
1026
|
+
localPath,
|
|
1027
|
+
name: path.basename(localPath).replace(/^opencli-plugin-/, ''),
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
// github:user/repo/subplugin (monorepo specific sub-plugin)
|
|
1031
|
+
const githubSubMatch = source.match(/^github:([\w.-]+)\/([\w.-]+)\/([\w.-]+)$/);
|
|
1032
|
+
if (githubSubMatch) {
|
|
1033
|
+
const [, user, repo, sub] = githubSubMatch;
|
|
1034
|
+
const name = repo.replace(/^opencli-plugin-/, '');
|
|
1035
|
+
return {
|
|
1036
|
+
type: 'git',
|
|
1037
|
+
cloneUrl: `https://github.com/${user}/${repo}.git`,
|
|
1038
|
+
name,
|
|
1039
|
+
subPlugin: sub,
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
289
1042
|
// github:user/repo
|
|
290
1043
|
const githubMatch = source.match(/^github:([\w.-]+)\/([\w.-]+)$/);
|
|
291
1044
|
if (githubMatch) {
|
|
292
1045
|
const [, user, repo] = githubMatch;
|
|
293
1046
|
const name = repo.replace(/^opencli-plugin-/, '');
|
|
294
1047
|
return {
|
|
1048
|
+
type: 'git',
|
|
295
1049
|
cloneUrl: `https://github.com/${user}/${repo}.git`,
|
|
296
1050
|
name,
|
|
297
1051
|
};
|
|
@@ -302,10 +1056,41 @@ function parseSource(source) {
|
|
|
302
1056
|
const [, user, repo] = urlMatch;
|
|
303
1057
|
const name = repo.replace(/^opencli-plugin-/, '');
|
|
304
1058
|
return {
|
|
1059
|
+
type: 'git',
|
|
305
1060
|
cloneUrl: `https://github.com/${user}/${repo}.git`,
|
|
306
1061
|
name,
|
|
307
1062
|
};
|
|
308
1063
|
}
|
|
1064
|
+
// ── Generic git URL support ─────────────────────────────────────────────
|
|
1065
|
+
// ssh://git@host/path/to/repo.git
|
|
1066
|
+
const sshUrlMatch = source.match(/^ssh:\/\/[^/]+\/(.*?)(?:\.git)?$/);
|
|
1067
|
+
if (sshUrlMatch) {
|
|
1068
|
+
const pathPart = sshUrlMatch[1];
|
|
1069
|
+
const segments = pathPart.split('/');
|
|
1070
|
+
const repoSegment = segments.pop();
|
|
1071
|
+
const name = repoSegment.replace(/^opencli-plugin-/, '');
|
|
1072
|
+
return { type: 'git', cloneUrl: source, name };
|
|
1073
|
+
}
|
|
1074
|
+
// git@host:user/repo.git (SCP-style)
|
|
1075
|
+
const scpMatch = source.match(/^git@[^:]+:(.+?)(?:\.git)?$/);
|
|
1076
|
+
if (scpMatch) {
|
|
1077
|
+
const pathPart = scpMatch[1];
|
|
1078
|
+
const segments = pathPart.split('/');
|
|
1079
|
+
const repoSegment = segments.pop();
|
|
1080
|
+
const name = repoSegment.replace(/^opencli-plugin-/, '');
|
|
1081
|
+
return { type: 'git', cloneUrl: source, name };
|
|
1082
|
+
}
|
|
1083
|
+
// Generic https/http git URL (non-GitHub hosts)
|
|
1084
|
+
const genericHttpMatch = source.match(/^https?:\/\/[^/]+\/(.+?)(?:\.git)?$/);
|
|
1085
|
+
if (genericHttpMatch) {
|
|
1086
|
+
const pathPart = genericHttpMatch[1];
|
|
1087
|
+
const segments = pathPart.split('/');
|
|
1088
|
+
const repoSegment = segments.pop();
|
|
1089
|
+
const name = repoSegment.replace(/^opencli-plugin-/, '');
|
|
1090
|
+
// Ensure clone URL ends with .git
|
|
1091
|
+
const cloneUrl = source.endsWith('.git') ? source : `${source}.git`;
|
|
1092
|
+
return { type: 'git', cloneUrl, name };
|
|
1093
|
+
}
|
|
309
1094
|
return null;
|
|
310
1095
|
}
|
|
311
1096
|
/**
|
|
@@ -403,7 +1188,8 @@ function transpilePluginTs(pluginDir) {
|
|
|
403
1188
|
try {
|
|
404
1189
|
const esbuildBin = resolveEsbuildBin();
|
|
405
1190
|
if (!esbuildBin) {
|
|
406
|
-
log.
|
|
1191
|
+
log.warn('esbuild not found. TS plugin files will not be transpiled and may fail to load. ' +
|
|
1192
|
+
'Install esbuild (`npm i -g esbuild`) or ensure it is available in the opencli host node_modules.');
|
|
407
1193
|
return;
|
|
408
1194
|
}
|
|
409
1195
|
const files = fs.readdirSync(pluginDir);
|
|
@@ -428,8 +1214,8 @@ function transpilePluginTs(pluginDir) {
|
|
|
428
1214
|
}
|
|
429
1215
|
}
|
|
430
1216
|
}
|
|
431
|
-
catch {
|
|
432
|
-
|
|
1217
|
+
catch (err) {
|
|
1218
|
+
log.warn(`TS transpilation setup failed: ${getErrorMessage(err)}`);
|
|
433
1219
|
}
|
|
434
1220
|
}
|
|
435
|
-
export { resolveEsbuildBin as _resolveEsbuildBin, getCommitHash as _getCommitHash, parseSource as _parseSource, readLockFile as _readLockFile, updateAllPlugins as _updateAllPlugins, validatePluginStructure as _validatePluginStructure, writeLockFile as _writeLockFile, };
|
|
1221
|
+
export { resolveEsbuildBin as _resolveEsbuildBin, getCommitHash as _getCommitHash, installDependencies as _installDependencies, parseSource as _parseSource, postInstallMonorepoLifecycle as _postInstallMonorepoLifecycle, readLockFile as _readLockFile, readLockFileWithWriter as _readLockFileWithWriter, updateAllPlugins as _updateAllPlugins, validatePluginStructure as _validatePluginStructure, writeLockFile as _writeLockFile, writeLockFileWithFs as _writeLockFileWithFs, isSymlinkSync as _isSymlinkSync, getMonoreposDir as _getMonoreposDir, installLocalPlugin as _installLocalPlugin, isLocalPluginSource as _isLocalPluginSource, moveDir as _moveDir, promoteDir as _promoteDir, replaceDir as _replaceDir, resolvePluginSource as _resolvePluginSource, resolveStoredPluginSource as _resolveStoredPluginSource, toStoredPluginSource as _toStoredPluginSource, toLocalPluginSource as _toLocalPluginSource, };
|