@jackwener/opencli 1.4.1 → 1.5.0
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/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/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 +1111 -112
- package/dist/cli.js +34 -3
- 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/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/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 +114 -18
- package/dist/clis/xiaohongshu/publish.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/publish.test.js +119 -0
- 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 +8 -14
- package/dist/doctor.d.ts +1 -0
- package/dist/doctor.js +9 -2
- package/dist/download/index.js +63 -51
- package/dist/download/index.test.js +17 -4
- package/dist/errors.d.ts +3 -1
- package/dist/errors.js +15 -32
- package/dist/execution.d.ts +1 -3
- package/dist/execution.js +21 -1
- package/dist/hooks.js +2 -0
- package/dist/main.js +5 -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.d.ts +38 -5
- package/dist/plugin.js +267 -33
- package/dist/plugin.test.js +220 -3
- 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.js +1 -1
- package/dist/serialization.d.ts +2 -0
- package/dist/serialization.js +6 -0
- package/dist/types.d.ts +1 -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/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 +87 -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 +2 -1
- package/src/browser/discover.ts +8 -3
- package/src/browser/errors.ts +13 -14
- package/src/browser/mcp.ts +2 -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 +35 -3
- 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/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/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 +137 -0
- package/src/clis/xiaohongshu/publish.ts +129 -18
- package/src/commanderAdapter.test.ts +78 -0
- package/src/commanderAdapter.ts +188 -24
- package/src/daemon.ts +19 -1
- package/src/discovery.ts +8 -15
- package/src/doctor.ts +13 -2
- package/src/download/index.test.ts +14 -4
- package/src/download/index.ts +67 -55
- package/src/errors.ts +25 -66
- package/src/execution.ts +28 -3
- package/src/hooks.ts +1 -0
- package/src/main.ts +6 -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.test.ts +246 -2
- package/src/plugin.ts +338 -36
- 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 +1 -1
- package/src/serialization.ts +4 -0
- package/src/types.ts +1 -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,8 @@
|
|
|
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" or "github:user/repo/subplugin"
|
|
6
7
|
*/
|
|
7
8
|
import * as fs from 'node:fs';
|
|
8
9
|
import * as os from 'node:os';
|
|
@@ -12,6 +13,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
12
13
|
import { PLUGINS_DIR } from './discovery.js';
|
|
13
14
|
import { getErrorMessage } from './errors.js';
|
|
14
15
|
import { log } from './logger.js';
|
|
16
|
+
import { readPluginManifest, isMonorepo, getEnabledPlugins, checkCompatibility, } from './plugin-manifest.js';
|
|
15
17
|
const isWindows = process.platform === 'win32';
|
|
16
18
|
/** Get home directory, respecting HOME environment variable for test isolation. */
|
|
17
19
|
function getHomeDir() {
|
|
@@ -21,8 +23,13 @@ function getHomeDir() {
|
|
|
21
23
|
export function getLockFilePath() {
|
|
22
24
|
return path.join(getHomeDir(), '.opencli', 'plugins.lock.json');
|
|
23
25
|
}
|
|
26
|
+
/** Monorepo clones directory: ~/.opencli/monorepos/ */
|
|
27
|
+
export function getMonoreposDir() {
|
|
28
|
+
return path.join(getHomeDir(), '.opencli', 'monorepos');
|
|
29
|
+
}
|
|
24
30
|
// Legacy const for backward compatibility (computed at load time)
|
|
25
31
|
export const LOCK_FILE = path.join(os.homedir(), '.opencli', 'plugins.lock.json');
|
|
32
|
+
export const MONOREPOS_DIR = path.join(os.homedir(), '.opencli', 'monorepos');
|
|
26
33
|
// ── Lock file helpers ───────────────────────────────────────────────────────
|
|
27
34
|
export function readLockFile() {
|
|
28
35
|
try {
|
|
@@ -87,34 +94,53 @@ export function validatePluginStructure(pluginDir) {
|
|
|
87
94
|
}
|
|
88
95
|
return { valid: errors.length === 0, errors };
|
|
89
96
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
* Called by both installPlugin() and updatePlugin().
|
|
93
|
-
*/
|
|
94
|
-
function postInstallLifecycle(pluginDir) {
|
|
95
|
-
const pkgJsonPath = path.join(pluginDir, 'package.json');
|
|
97
|
+
function installDependencies(dir) {
|
|
98
|
+
const pkgJsonPath = path.join(dir, 'package.json');
|
|
96
99
|
if (!fs.existsSync(pkgJsonPath))
|
|
97
100
|
return;
|
|
98
101
|
try {
|
|
99
102
|
execFileSync('npm', ['install', '--omit=dev'], {
|
|
100
|
-
cwd:
|
|
103
|
+
cwd: dir,
|
|
101
104
|
encoding: 'utf-8',
|
|
102
105
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
103
106
|
...(isWindows && { shell: true }),
|
|
104
107
|
});
|
|
105
108
|
}
|
|
106
109
|
catch (err) {
|
|
107
|
-
|
|
110
|
+
throw new Error(`npm install failed in ${dir}: ${getErrorMessage(err)}`);
|
|
108
111
|
}
|
|
112
|
+
}
|
|
113
|
+
function finalizePluginRuntime(pluginDir) {
|
|
109
114
|
// Symlink host opencli so TS plugins resolve '@jackwener/opencli/registry'
|
|
110
115
|
// against the running host, not a stale npm-published version.
|
|
111
116
|
linkHostOpencli(pluginDir);
|
|
112
117
|
// Transpile .ts → .js via esbuild (production node can't load .ts directly).
|
|
113
118
|
transpilePluginTs(pluginDir);
|
|
114
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Shared post-install lifecycle for standalone plugins.
|
|
122
|
+
*/
|
|
123
|
+
function postInstallLifecycle(pluginDir) {
|
|
124
|
+
installDependencies(pluginDir);
|
|
125
|
+
finalizePluginRuntime(pluginDir);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Monorepo lifecycle: install shared deps once at repo root, then finalize each sub-plugin.
|
|
129
|
+
*/
|
|
130
|
+
function postInstallMonorepoLifecycle(repoDir, pluginDirs) {
|
|
131
|
+
installDependencies(repoDir);
|
|
132
|
+
for (const pluginDir of pluginDirs) {
|
|
133
|
+
finalizePluginRuntime(pluginDir);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
115
136
|
/**
|
|
116
137
|
* Install a plugin from a source.
|
|
117
|
-
*
|
|
138
|
+
* Supports:
|
|
139
|
+
* "github:user/repo" — single plugin or full monorepo
|
|
140
|
+
* "github:user/repo/subplugin" — specific sub-plugin from a monorepo
|
|
141
|
+
* "https://github.com/user/repo"
|
|
142
|
+
*
|
|
143
|
+
* Returns the installed plugin name(s).
|
|
118
144
|
*/
|
|
119
145
|
export function installPlugin(source) {
|
|
120
146
|
const parsed = parseSource(source);
|
|
@@ -122,17 +148,14 @@ export function installPlugin(source) {
|
|
|
122
148
|
throw new Error(`Invalid plugin source: "${source}"\n` +
|
|
123
149
|
`Supported formats:\n` +
|
|
124
150
|
` github:user/repo\n` +
|
|
151
|
+
` github:user/repo/subplugin\n` +
|
|
125
152
|
` https://github.com/user/repo`);
|
|
126
153
|
}
|
|
127
|
-
const { cloneUrl, name } = parsed;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
throw new Error(`Plugin "${name}" is already installed at ${targetDir}`);
|
|
131
|
-
}
|
|
132
|
-
// Ensure plugins directory exists
|
|
133
|
-
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
154
|
+
const { cloneUrl, name: repoName, subPlugin } = parsed;
|
|
155
|
+
// Clone to a temporary location first so we can inspect the manifest
|
|
156
|
+
const tmpCloneDir = path.join(os.tmpdir(), `opencli-clone-${Date.now()}`);
|
|
134
157
|
try {
|
|
135
|
-
execFileSync('git', ['clone', '--depth', '1', cloneUrl,
|
|
158
|
+
execFileSync('git', ['clone', '--depth', '1', cloneUrl, tmpCloneDir], {
|
|
136
159
|
encoding: 'utf-8',
|
|
137
160
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
138
161
|
});
|
|
@@ -140,48 +163,228 @@ export function installPlugin(source) {
|
|
|
140
163
|
catch (err) {
|
|
141
164
|
throw new Error(`Failed to clone plugin: ${getErrorMessage(err)}`);
|
|
142
165
|
}
|
|
143
|
-
|
|
166
|
+
try {
|
|
167
|
+
const manifest = readPluginManifest(tmpCloneDir);
|
|
168
|
+
// Check top-level compatibility
|
|
169
|
+
if (manifest?.opencli && !checkCompatibility(manifest.opencli)) {
|
|
170
|
+
throw new Error(`Plugin requires opencli ${manifest.opencli}, but current version is incompatible.`);
|
|
171
|
+
}
|
|
172
|
+
if (manifest && isMonorepo(manifest)) {
|
|
173
|
+
return installMonorepo(tmpCloneDir, cloneUrl, repoName, manifest, subPlugin);
|
|
174
|
+
}
|
|
175
|
+
// Single plugin mode
|
|
176
|
+
return installSinglePlugin(tmpCloneDir, cloneUrl, repoName, manifest);
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
// Clean up temp clone (may already have been moved)
|
|
180
|
+
try {
|
|
181
|
+
fs.rmSync(tmpCloneDir, { recursive: true, force: true });
|
|
182
|
+
}
|
|
183
|
+
catch { }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** Install a single (non-monorepo) plugin. */
|
|
187
|
+
function installSinglePlugin(cloneDir, cloneUrl, name, manifest) {
|
|
188
|
+
const pluginName = manifest?.name ?? name;
|
|
189
|
+
const targetDir = path.join(PLUGINS_DIR, pluginName);
|
|
190
|
+
if (fs.existsSync(targetDir)) {
|
|
191
|
+
throw new Error(`Plugin "${pluginName}" is already installed at ${targetDir}`);
|
|
192
|
+
}
|
|
193
|
+
const validation = validatePluginStructure(cloneDir);
|
|
144
194
|
if (!validation.valid) {
|
|
145
|
-
// If validation fails, clean up the cloned directory and abort
|
|
146
|
-
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
147
195
|
throw new Error(`Invalid plugin structure:\n- ${validation.errors.join('\n- ')}`);
|
|
148
196
|
}
|
|
197
|
+
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
198
|
+
fs.renameSync(cloneDir, targetDir);
|
|
149
199
|
postInstallLifecycle(targetDir);
|
|
150
200
|
const commitHash = getCommitHash(targetDir);
|
|
151
201
|
if (commitHash) {
|
|
152
202
|
const lock = readLockFile();
|
|
153
|
-
lock[
|
|
203
|
+
lock[pluginName] = {
|
|
154
204
|
source: cloneUrl,
|
|
155
205
|
commitHash,
|
|
156
206
|
installedAt: new Date().toISOString(),
|
|
157
207
|
};
|
|
158
208
|
writeLockFile(lock);
|
|
159
209
|
}
|
|
160
|
-
return
|
|
210
|
+
return pluginName;
|
|
211
|
+
}
|
|
212
|
+
/** Install sub-plugins from a monorepo. */
|
|
213
|
+
function installMonorepo(cloneDir, cloneUrl, repoName, manifest, subPlugin) {
|
|
214
|
+
const monoreposDir = getMonoreposDir();
|
|
215
|
+
const repoDir = path.join(monoreposDir, repoName);
|
|
216
|
+
// Move clone to permanent monorepos location (if not already there)
|
|
217
|
+
if (!fs.existsSync(repoDir)) {
|
|
218
|
+
fs.mkdirSync(monoreposDir, { recursive: true });
|
|
219
|
+
fs.renameSync(cloneDir, repoDir);
|
|
220
|
+
}
|
|
221
|
+
let pluginsToInstall = getEnabledPlugins(manifest);
|
|
222
|
+
// If a specific sub-plugin was requested, filter to just that one
|
|
223
|
+
if (subPlugin) {
|
|
224
|
+
pluginsToInstall = pluginsToInstall.filter((p) => p.name === subPlugin);
|
|
225
|
+
if (pluginsToInstall.length === 0) {
|
|
226
|
+
// Check if it exists but is disabled
|
|
227
|
+
const disabled = manifest.plugins?.[subPlugin];
|
|
228
|
+
if (disabled) {
|
|
229
|
+
throw new Error(`Sub-plugin "${subPlugin}" is disabled in the manifest.`);
|
|
230
|
+
}
|
|
231
|
+
throw new Error(`Sub-plugin "${subPlugin}" not found in monorepo. Available: ${Object.keys(manifest.plugins ?? {}).join(', ')}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const installedNames = [];
|
|
235
|
+
const lock = readLockFile();
|
|
236
|
+
const commitHash = getCommitHash(repoDir);
|
|
237
|
+
const eligiblePlugins = [];
|
|
238
|
+
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
239
|
+
for (const { name, entry } of pluginsToInstall) {
|
|
240
|
+
// Check sub-plugin level compatibility (overrides top-level)
|
|
241
|
+
if (entry.opencli && !checkCompatibility(entry.opencli)) {
|
|
242
|
+
log.warn(`Skipping "${name}": requires opencli ${entry.opencli}`);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const subDir = path.join(repoDir, entry.path);
|
|
246
|
+
if (!fs.existsSync(subDir)) {
|
|
247
|
+
log.warn(`Skipping "${name}": path "${entry.path}" not found in repo.`);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const validation = validatePluginStructure(subDir);
|
|
251
|
+
if (!validation.valid) {
|
|
252
|
+
log.warn(`Skipping "${name}": invalid structure — ${validation.errors.join(', ')}`);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const linkPath = path.join(PLUGINS_DIR, name);
|
|
256
|
+
if (fs.existsSync(linkPath)) {
|
|
257
|
+
log.warn(`Skipping "${name}": already installed at ${linkPath}`);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
eligiblePlugins.push({ name, entry, subDir });
|
|
261
|
+
}
|
|
262
|
+
if (eligiblePlugins.length > 0) {
|
|
263
|
+
postInstallMonorepoLifecycle(repoDir, eligiblePlugins.map((p) => p.subDir));
|
|
264
|
+
}
|
|
265
|
+
for (const { name, entry, subDir } of eligiblePlugins) {
|
|
266
|
+
const linkPath = path.join(PLUGINS_DIR, name);
|
|
267
|
+
// Create symlink (junction on Windows)
|
|
268
|
+
const linkType = isWindows ? 'junction' : 'dir';
|
|
269
|
+
fs.symlinkSync(subDir, linkPath, linkType);
|
|
270
|
+
if (commitHash) {
|
|
271
|
+
lock[name] = {
|
|
272
|
+
source: cloneUrl,
|
|
273
|
+
commitHash,
|
|
274
|
+
installedAt: new Date().toISOString(),
|
|
275
|
+
monorepo: { name: repoName, subPath: entry.path },
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
installedNames.push(name);
|
|
279
|
+
}
|
|
280
|
+
writeLockFile(lock);
|
|
281
|
+
return installedNames;
|
|
161
282
|
}
|
|
162
283
|
/**
|
|
163
284
|
* Uninstall a plugin by name.
|
|
285
|
+
* For monorepo sub-plugins: removes symlink and cleans up the monorepo
|
|
286
|
+
* directory when no more sub-plugins reference it.
|
|
164
287
|
*/
|
|
165
288
|
export function uninstallPlugin(name) {
|
|
166
289
|
const targetDir = path.join(PLUGINS_DIR, name);
|
|
167
290
|
if (!fs.existsSync(targetDir)) {
|
|
168
291
|
throw new Error(`Plugin "${name}" is not installed.`);
|
|
169
292
|
}
|
|
170
|
-
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
171
293
|
const lock = readLockFile();
|
|
172
|
-
|
|
294
|
+
const lockEntry = lock[name];
|
|
295
|
+
// Check if this is a symlink (monorepo sub-plugin)
|
|
296
|
+
const isSymlink = isSymlinkSync(targetDir);
|
|
297
|
+
if (isSymlink) {
|
|
298
|
+
// Remove symlink only (not the actual directory)
|
|
299
|
+
fs.unlinkSync(targetDir);
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
303
|
+
}
|
|
304
|
+
// Clean up monorepo directory if no more sub-plugins reference it
|
|
305
|
+
if (lockEntry?.monorepo) {
|
|
306
|
+
delete lock[name];
|
|
307
|
+
const monoName = lockEntry.monorepo.name;
|
|
308
|
+
const stillReferenced = Object.values(lock).some((entry) => entry.monorepo?.name === monoName);
|
|
309
|
+
if (!stillReferenced) {
|
|
310
|
+
const monoDir = path.join(getMonoreposDir(), monoName);
|
|
311
|
+
try {
|
|
312
|
+
fs.rmSync(monoDir, { recursive: true, force: true });
|
|
313
|
+
}
|
|
314
|
+
catch { }
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
else if (lock[name]) {
|
|
173
318
|
delete lock[name];
|
|
174
|
-
|
|
319
|
+
}
|
|
320
|
+
writeLockFile(lock);
|
|
321
|
+
}
|
|
322
|
+
/** Synchronous check if a path is a symlink. */
|
|
323
|
+
function isSymlinkSync(p) {
|
|
324
|
+
try {
|
|
325
|
+
return fs.lstatSync(p).isSymbolicLink();
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
return false;
|
|
175
329
|
}
|
|
176
330
|
}
|
|
177
331
|
/**
|
|
178
332
|
* Update a plugin by name (git pull + re-install lifecycle).
|
|
333
|
+
* For monorepo sub-plugins: pulls the monorepo root and re-runs lifecycle
|
|
334
|
+
* for all sub-plugins from the same monorepo.
|
|
179
335
|
*/
|
|
180
336
|
export function updatePlugin(name) {
|
|
181
337
|
const targetDir = path.join(PLUGINS_DIR, name);
|
|
182
338
|
if (!fs.existsSync(targetDir)) {
|
|
183
339
|
throw new Error(`Plugin "${name}" is not installed.`);
|
|
184
340
|
}
|
|
341
|
+
const lock = readLockFile();
|
|
342
|
+
const lockEntry = lock[name];
|
|
343
|
+
if (lockEntry?.monorepo) {
|
|
344
|
+
// Monorepo update: pull the repo root
|
|
345
|
+
const monoDir = path.join(getMonoreposDir(), lockEntry.monorepo.name);
|
|
346
|
+
try {
|
|
347
|
+
execFileSync('git', ['pull', '--ff-only'], {
|
|
348
|
+
cwd: monoDir,
|
|
349
|
+
encoding: 'utf-8',
|
|
350
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
throw new Error(`Failed to update monorepo: ${getErrorMessage(err)}`);
|
|
355
|
+
}
|
|
356
|
+
// Re-run lifecycle for ALL sub-plugins from this monorepo
|
|
357
|
+
const monoName = lockEntry.monorepo.name;
|
|
358
|
+
const commitHash = getCommitHash(monoDir);
|
|
359
|
+
const pluginDirs = [];
|
|
360
|
+
for (const [pluginName, entry] of Object.entries(lock)) {
|
|
361
|
+
if (entry.monorepo?.name !== monoName)
|
|
362
|
+
continue;
|
|
363
|
+
const subDir = path.join(monoDir, entry.monorepo.subPath);
|
|
364
|
+
const validation = validatePluginStructure(subDir);
|
|
365
|
+
if (!validation.valid) {
|
|
366
|
+
log.warn(`Plugin "${pluginName}" structure invalid after update:\n- ${validation.errors.join('\n- ')}`);
|
|
367
|
+
}
|
|
368
|
+
pluginDirs.push(subDir);
|
|
369
|
+
}
|
|
370
|
+
if (pluginDirs.length > 0) {
|
|
371
|
+
postInstallMonorepoLifecycle(monoDir, pluginDirs);
|
|
372
|
+
}
|
|
373
|
+
for (const [pluginName, entry] of Object.entries(lock)) {
|
|
374
|
+
if (entry.monorepo?.name !== monoName)
|
|
375
|
+
continue;
|
|
376
|
+
if (commitHash) {
|
|
377
|
+
lock[pluginName] = {
|
|
378
|
+
...entry,
|
|
379
|
+
commitHash,
|
|
380
|
+
updatedAt: new Date().toISOString(),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
writeLockFile(lock);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
// Standard single-plugin update
|
|
185
388
|
try {
|
|
186
389
|
execFileSync('git', ['pull', '--ff-only'], {
|
|
187
390
|
cwd: targetDir,
|
|
@@ -199,7 +402,6 @@ export function updatePlugin(name) {
|
|
|
199
402
|
postInstallLifecycle(targetDir);
|
|
200
403
|
const commitHash = getCommitHash(targetDir);
|
|
201
404
|
if (commitHash) {
|
|
202
|
-
const lock = readLockFile();
|
|
203
405
|
const existing = lock[name];
|
|
204
406
|
lock[name] = {
|
|
205
407
|
source: existing?.source ?? getPluginSource(targetDir) ?? '',
|
|
@@ -231,6 +433,7 @@ export function updateAllPlugins() {
|
|
|
231
433
|
}
|
|
232
434
|
/**
|
|
233
435
|
* List all installed plugins.
|
|
436
|
+
* Reads opencli-plugin.json for description/version when available.
|
|
234
437
|
*/
|
|
235
438
|
export function listPlugins() {
|
|
236
439
|
if (!fs.existsSync(PLUGINS_DIR))
|
|
@@ -239,19 +442,39 @@ export function listPlugins() {
|
|
|
239
442
|
const lock = readLockFile();
|
|
240
443
|
const plugins = [];
|
|
241
444
|
for (const entry of entries) {
|
|
242
|
-
|
|
243
|
-
continue;
|
|
445
|
+
// Accept both real directories and symlinks (monorepo sub-plugins)
|
|
244
446
|
const pluginDir = path.join(PLUGINS_DIR, entry.name);
|
|
447
|
+
const isDir = entry.isDirectory() || isSymlinkSync(pluginDir);
|
|
448
|
+
if (!isDir)
|
|
449
|
+
continue;
|
|
245
450
|
const commands = scanPluginCommands(pluginDir);
|
|
246
|
-
const source = getPluginSource(pluginDir);
|
|
247
451
|
const lockEntry = lock[entry.name];
|
|
452
|
+
// Try to read manifest for metadata
|
|
453
|
+
const manifest = readPluginManifest(pluginDir);
|
|
454
|
+
// For monorepo sub-plugins, also check the monorepo root manifest
|
|
455
|
+
let description = manifest?.description;
|
|
456
|
+
let version = manifest?.version;
|
|
457
|
+
if (lockEntry?.monorepo && !description) {
|
|
458
|
+
const monoDir = path.join(getMonoreposDir(), lockEntry.monorepo.name);
|
|
459
|
+
const monoManifest = readPluginManifest(monoDir);
|
|
460
|
+
const subEntry = monoManifest?.plugins?.[entry.name];
|
|
461
|
+
if (subEntry) {
|
|
462
|
+
description = description ?? subEntry.description;
|
|
463
|
+
version = version ?? subEntry.version;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const source = lockEntry?.monorepo
|
|
467
|
+
? lockEntry.source
|
|
468
|
+
: getPluginSource(pluginDir);
|
|
248
469
|
plugins.push({
|
|
249
470
|
name: entry.name,
|
|
250
471
|
path: pluginDir,
|
|
251
472
|
commands,
|
|
252
473
|
source,
|
|
253
|
-
version: lockEntry?.commitHash?.slice(0, 7),
|
|
474
|
+
version: version ?? lockEntry?.commitHash?.slice(0, 7),
|
|
254
475
|
installedAt: lockEntry?.installedAt,
|
|
476
|
+
monorepoName: lockEntry?.monorepo?.name,
|
|
477
|
+
description,
|
|
255
478
|
});
|
|
256
479
|
}
|
|
257
480
|
return plugins;
|
|
@@ -284,8 +507,19 @@ function getPluginSource(dir) {
|
|
|
284
507
|
return undefined;
|
|
285
508
|
}
|
|
286
509
|
}
|
|
287
|
-
/** Parse a plugin source string into clone URL
|
|
510
|
+
/** Parse a plugin source string into clone URL, repo name, and optional sub-plugin. */
|
|
288
511
|
function parseSource(source) {
|
|
512
|
+
// github:user/repo/subplugin (monorepo specific sub-plugin)
|
|
513
|
+
const githubSubMatch = source.match(/^github:([\w.-]+)\/([\w.-]+)\/([\w.-]+)$/);
|
|
514
|
+
if (githubSubMatch) {
|
|
515
|
+
const [, user, repo, sub] = githubSubMatch;
|
|
516
|
+
const name = repo.replace(/^opencli-plugin-/, '');
|
|
517
|
+
return {
|
|
518
|
+
cloneUrl: `https://github.com/${user}/${repo}.git`,
|
|
519
|
+
name,
|
|
520
|
+
subPlugin: sub,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
289
523
|
// github:user/repo
|
|
290
524
|
const githubMatch = source.match(/^github:([\w.-]+)\/([\w.-]+)$/);
|
|
291
525
|
if (githubMatch) {
|
|
@@ -432,4 +666,4 @@ function transpilePluginTs(pluginDir) {
|
|
|
432
666
|
// Non-fatal: skip transpilation if anything goes wrong
|
|
433
667
|
}
|
|
434
668
|
}
|
|
435
|
-
export { resolveEsbuildBin as _resolveEsbuildBin, getCommitHash as _getCommitHash, parseSource as _parseSource, readLockFile as _readLockFile, updateAllPlugins as _updateAllPlugins, validatePluginStructure as _validatePluginStructure, writeLockFile as _writeLockFile, };
|
|
669
|
+
export { resolveEsbuildBin as _resolveEsbuildBin, getCommitHash as _getCommitHash, installDependencies as _installDependencies, parseSource as _parseSource, postInstallMonorepoLifecycle as _postInstallMonorepoLifecycle, readLockFile as _readLockFile, updateAllPlugins as _updateAllPlugins, validatePluginStructure as _validatePluginStructure, writeLockFile as _writeLockFile, isSymlinkSync as _isSymlinkSync, getMonoreposDir as _getMonoreposDir, };
|
package/dist/plugin.test.js
CHANGED
|
@@ -7,7 +7,11 @@ import * as os from 'node:os';
|
|
|
7
7
|
import * as path from 'node:path';
|
|
8
8
|
import { PLUGINS_DIR } from './discovery.js';
|
|
9
9
|
import * as pluginModule from './plugin.js';
|
|
10
|
-
const {
|
|
10
|
+
const { mockExecFileSync, mockExecSync } = vi.hoisted(() => ({
|
|
11
|
+
mockExecFileSync: vi.fn(),
|
|
12
|
+
mockExecSync: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
const { LOCK_FILE, _getCommitHash, _installDependencies, _postInstallMonorepoLifecycle, listPlugins, _readLockFile, _resolveEsbuildBin, uninstallPlugin, updatePlugin, _parseSource, _updateAllPlugins, _validatePluginStructure, _writeLockFile, _isSymlinkSync, _getMonoreposDir, } = pluginModule;
|
|
11
15
|
describe('parseSource', () => {
|
|
12
16
|
it('parses github:user/repo format', () => {
|
|
13
17
|
const result = _parseSource('github:ByteYue/opencli-plugin-github-trending');
|
|
@@ -239,7 +243,7 @@ describe('updatePlugin', () => {
|
|
|
239
243
|
});
|
|
240
244
|
vi.mock('node:child_process', () => {
|
|
241
245
|
return {
|
|
242
|
-
execFileSync:
|
|
246
|
+
execFileSync: mockExecFileSync.mockImplementation((_cmd, args, opts) => {
|
|
243
247
|
if (Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
|
|
244
248
|
if (opts?.cwd === os.tmpdir()) {
|
|
245
249
|
throw new Error('not a git repository');
|
|
@@ -251,9 +255,50 @@ vi.mock('node:child_process', () => {
|
|
|
251
255
|
}
|
|
252
256
|
return '';
|
|
253
257
|
}),
|
|
254
|
-
execSync:
|
|
258
|
+
execSync: mockExecSync.mockImplementation(() => ''),
|
|
255
259
|
};
|
|
256
260
|
});
|
|
261
|
+
describe('installDependencies', () => {
|
|
262
|
+
beforeEach(() => {
|
|
263
|
+
mockExecFileSync.mockClear();
|
|
264
|
+
mockExecSync.mockClear();
|
|
265
|
+
});
|
|
266
|
+
it('throws when npm install fails', () => {
|
|
267
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-plugin-b-'));
|
|
268
|
+
const failingDir = path.join(tmpDir, 'plugin-b');
|
|
269
|
+
fs.mkdirSync(failingDir, { recursive: true });
|
|
270
|
+
fs.writeFileSync(path.join(failingDir, 'package.json'), JSON.stringify({ name: 'plugin-b' }));
|
|
271
|
+
expect(() => _installDependencies(failingDir)).toThrow('npm install failed');
|
|
272
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
describe('postInstallMonorepoLifecycle', () => {
|
|
276
|
+
let repoDir;
|
|
277
|
+
let subDir;
|
|
278
|
+
beforeEach(() => {
|
|
279
|
+
mockExecFileSync.mockClear();
|
|
280
|
+
mockExecSync.mockClear();
|
|
281
|
+
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-monorepo-'));
|
|
282
|
+
subDir = path.join(repoDir, 'packages', 'alpha');
|
|
283
|
+
fs.mkdirSync(subDir, { recursive: true });
|
|
284
|
+
fs.writeFileSync(path.join(repoDir, 'package.json'), JSON.stringify({
|
|
285
|
+
name: 'opencli-plugins',
|
|
286
|
+
private: true,
|
|
287
|
+
workspaces: ['packages/*'],
|
|
288
|
+
}));
|
|
289
|
+
fs.writeFileSync(path.join(subDir, 'hello.yaml'), 'site: test\nname: hello\n');
|
|
290
|
+
});
|
|
291
|
+
afterEach(() => {
|
|
292
|
+
fs.rmSync(repoDir, { recursive: true, force: true });
|
|
293
|
+
});
|
|
294
|
+
it('installs dependencies once at the monorepo root, not in each sub-plugin', () => {
|
|
295
|
+
_postInstallMonorepoLifecycle(repoDir, [subDir]);
|
|
296
|
+
const npmCalls = mockExecFileSync.mock.calls.filter(([cmd, args]) => cmd === 'npm' && Array.isArray(args) && args[0] === 'install');
|
|
297
|
+
expect(npmCalls).toHaveLength(1);
|
|
298
|
+
expect(npmCalls[0][2]).toMatchObject({ cwd: repoDir });
|
|
299
|
+
expect(npmCalls.some(([, , opts]) => opts?.cwd === subDir)).toBe(false);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
257
302
|
describe('updateAllPlugins', () => {
|
|
258
303
|
const testDirA = path.join(PLUGINS_DIR, 'plugin-a');
|
|
259
304
|
const testDirB = path.join(PLUGINS_DIR, 'plugin-b');
|
|
@@ -295,3 +340,175 @@ describe('updateAllPlugins', () => {
|
|
|
295
340
|
expect(resC.success).toBe(true);
|
|
296
341
|
});
|
|
297
342
|
});
|
|
343
|
+
// ── Monorepo-specific tests ─────────────────────────────────────────────────
|
|
344
|
+
describe('parseSource with monorepo subplugin', () => {
|
|
345
|
+
it('parses github:user/repo/subplugin format', () => {
|
|
346
|
+
const result = _parseSource('github:ByteYue/opencli-plugins/polymarket');
|
|
347
|
+
expect(result).toEqual({
|
|
348
|
+
cloneUrl: 'https://github.com/ByteYue/opencli-plugins.git',
|
|
349
|
+
name: 'opencli-plugins',
|
|
350
|
+
subPlugin: 'polymarket',
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
it('strips opencli-plugin- prefix from repo name in subplugin format', () => {
|
|
354
|
+
const result = _parseSource('github:user/opencli-plugin-collection/defi');
|
|
355
|
+
expect(result.name).toBe('collection');
|
|
356
|
+
expect(result.subPlugin).toBe('defi');
|
|
357
|
+
});
|
|
358
|
+
it('still parses github:user/repo without subplugin', () => {
|
|
359
|
+
const result = _parseSource('github:user/my-repo');
|
|
360
|
+
expect(result).toEqual({
|
|
361
|
+
cloneUrl: 'https://github.com/user/my-repo.git',
|
|
362
|
+
name: 'my-repo',
|
|
363
|
+
});
|
|
364
|
+
expect(result.subPlugin).toBeUndefined();
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
describe('isSymlinkSync', () => {
|
|
368
|
+
let tmpDir;
|
|
369
|
+
beforeEach(() => {
|
|
370
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-symlink-test-'));
|
|
371
|
+
});
|
|
372
|
+
afterEach(() => {
|
|
373
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
374
|
+
});
|
|
375
|
+
it('returns false for a regular directory', () => {
|
|
376
|
+
const dir = path.join(tmpDir, 'regular');
|
|
377
|
+
fs.mkdirSync(dir);
|
|
378
|
+
expect(_isSymlinkSync(dir)).toBe(false);
|
|
379
|
+
});
|
|
380
|
+
it('returns true for a symlink', () => {
|
|
381
|
+
const target = path.join(tmpDir, 'target');
|
|
382
|
+
const link = path.join(tmpDir, 'link');
|
|
383
|
+
fs.mkdirSync(target);
|
|
384
|
+
fs.symlinkSync(target, link, 'dir');
|
|
385
|
+
expect(_isSymlinkSync(link)).toBe(true);
|
|
386
|
+
});
|
|
387
|
+
it('returns false for non-existent path', () => {
|
|
388
|
+
expect(_isSymlinkSync(path.join(tmpDir, 'nope'))).toBe(false);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
describe('monorepo uninstall with symlink', () => {
|
|
392
|
+
let tmpDir;
|
|
393
|
+
let pluginDir;
|
|
394
|
+
let monoDir;
|
|
395
|
+
beforeEach(() => {
|
|
396
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-mono-uninstall-'));
|
|
397
|
+
// We need to use the real PLUGINS_DIR for uninstallPlugin() to work
|
|
398
|
+
pluginDir = path.join(PLUGINS_DIR, '__test-mono-sub__');
|
|
399
|
+
monoDir = path.join(_getMonoreposDir(), '__test-mono__');
|
|
400
|
+
// Set up monorepo structure
|
|
401
|
+
const subDir = path.join(monoDir, 'packages', 'sub');
|
|
402
|
+
fs.mkdirSync(subDir, { recursive: true });
|
|
403
|
+
fs.writeFileSync(path.join(subDir, 'cmd.yaml'), 'site: test');
|
|
404
|
+
// Create symlink in plugins dir
|
|
405
|
+
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
406
|
+
fs.symlinkSync(subDir, pluginDir, 'dir');
|
|
407
|
+
// Set up lock file with monorepo entry
|
|
408
|
+
const lock = _readLockFile();
|
|
409
|
+
lock['__test-mono-sub__'] = {
|
|
410
|
+
source: 'https://github.com/user/test.git',
|
|
411
|
+
commitHash: 'abc123',
|
|
412
|
+
installedAt: '2025-01-01T00:00:00.000Z',
|
|
413
|
+
monorepo: { name: '__test-mono__', subPath: 'packages/sub' },
|
|
414
|
+
};
|
|
415
|
+
_writeLockFile(lock);
|
|
416
|
+
});
|
|
417
|
+
afterEach(() => {
|
|
418
|
+
try {
|
|
419
|
+
fs.unlinkSync(pluginDir);
|
|
420
|
+
}
|
|
421
|
+
catch { }
|
|
422
|
+
try {
|
|
423
|
+
fs.rmSync(pluginDir, { recursive: true, force: true });
|
|
424
|
+
}
|
|
425
|
+
catch { }
|
|
426
|
+
try {
|
|
427
|
+
fs.rmSync(monoDir, { recursive: true, force: true });
|
|
428
|
+
}
|
|
429
|
+
catch { }
|
|
430
|
+
// Clean up lock entry
|
|
431
|
+
const lock = _readLockFile();
|
|
432
|
+
delete lock['__test-mono-sub__'];
|
|
433
|
+
_writeLockFile(lock);
|
|
434
|
+
});
|
|
435
|
+
it('removes symlink but keeps monorepo if other sub-plugins reference it', () => {
|
|
436
|
+
// Add another sub-plugin referencing the same monorepo
|
|
437
|
+
const lock = _readLockFile();
|
|
438
|
+
lock['__test-mono-other__'] = {
|
|
439
|
+
source: 'https://github.com/user/test.git',
|
|
440
|
+
commitHash: 'abc123',
|
|
441
|
+
installedAt: '2025-01-01T00:00:00.000Z',
|
|
442
|
+
monorepo: { name: '__test-mono__', subPath: 'packages/other' },
|
|
443
|
+
};
|
|
444
|
+
_writeLockFile(lock);
|
|
445
|
+
uninstallPlugin('__test-mono-sub__');
|
|
446
|
+
// Symlink removed
|
|
447
|
+
expect(fs.existsSync(pluginDir)).toBe(false);
|
|
448
|
+
// Monorepo dir still exists (other sub-plugin references it)
|
|
449
|
+
expect(fs.existsSync(monoDir)).toBe(true);
|
|
450
|
+
// Lock entry removed
|
|
451
|
+
expect(_readLockFile()['__test-mono-sub__']).toBeUndefined();
|
|
452
|
+
// Other lock entry still present
|
|
453
|
+
expect(_readLockFile()['__test-mono-other__']).toBeDefined();
|
|
454
|
+
// Clean up the other entry
|
|
455
|
+
const finalLock = _readLockFile();
|
|
456
|
+
delete finalLock['__test-mono-other__'];
|
|
457
|
+
_writeLockFile(finalLock);
|
|
458
|
+
});
|
|
459
|
+
it('removes symlink AND monorepo dir when last sub-plugin is uninstalled', () => {
|
|
460
|
+
uninstallPlugin('__test-mono-sub__');
|
|
461
|
+
// Symlink removed
|
|
462
|
+
expect(fs.existsSync(pluginDir)).toBe(false);
|
|
463
|
+
// Monorepo dir also removed (no more references)
|
|
464
|
+
expect(fs.existsSync(monoDir)).toBe(false);
|
|
465
|
+
// Lock entry removed
|
|
466
|
+
expect(_readLockFile()['__test-mono-sub__']).toBeUndefined();
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
describe('listPlugins with monorepo metadata', () => {
|
|
470
|
+
const testSymlinkTarget = path.join(os.tmpdir(), 'opencli-list-mono-target');
|
|
471
|
+
const testLink = path.join(PLUGINS_DIR, '__test-mono-list__');
|
|
472
|
+
beforeEach(() => {
|
|
473
|
+
// Create a target dir with a command file
|
|
474
|
+
fs.mkdirSync(testSymlinkTarget, { recursive: true });
|
|
475
|
+
fs.writeFileSync(path.join(testSymlinkTarget, 'hello.yaml'), 'site: test\nname: hello\n');
|
|
476
|
+
// Create symlink
|
|
477
|
+
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
478
|
+
try {
|
|
479
|
+
fs.unlinkSync(testLink);
|
|
480
|
+
}
|
|
481
|
+
catch { }
|
|
482
|
+
fs.symlinkSync(testSymlinkTarget, testLink, 'dir');
|
|
483
|
+
// Set up lock file with monorepo entry
|
|
484
|
+
const lock = _readLockFile();
|
|
485
|
+
lock['__test-mono-list__'] = {
|
|
486
|
+
source: 'https://github.com/user/test-mono.git',
|
|
487
|
+
commitHash: 'def456def456def456def456def456def456def4',
|
|
488
|
+
installedAt: '2025-01-01T00:00:00.000Z',
|
|
489
|
+
monorepo: { name: 'test-mono', subPath: 'packages/list' },
|
|
490
|
+
};
|
|
491
|
+
_writeLockFile(lock);
|
|
492
|
+
});
|
|
493
|
+
afterEach(() => {
|
|
494
|
+
try {
|
|
495
|
+
fs.unlinkSync(testLink);
|
|
496
|
+
}
|
|
497
|
+
catch { }
|
|
498
|
+
try {
|
|
499
|
+
fs.rmSync(testSymlinkTarget, { recursive: true, force: true });
|
|
500
|
+
}
|
|
501
|
+
catch { }
|
|
502
|
+
const lock = _readLockFile();
|
|
503
|
+
delete lock['__test-mono-list__'];
|
|
504
|
+
_writeLockFile(lock);
|
|
505
|
+
});
|
|
506
|
+
it('lists symlinked plugins with monorepoName', () => {
|
|
507
|
+
const plugins = listPlugins();
|
|
508
|
+
const found = plugins.find(p => p.name === '__test-mono-list__');
|
|
509
|
+
expect(found).toBeDefined();
|
|
510
|
+
expect(found.monorepoName).toBe('test-mono');
|
|
511
|
+
expect(found.commands).toContain('hello');
|
|
512
|
+
expect(found.source).toBe('https://github.com/user/test-mono.git');
|
|
513
|
+
});
|
|
514
|
+
});
|