@jackwener/opencli 1.5.0 → 1.5.2
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/dist/browser/cdp.js +5 -0
- package/dist/browser/discover.js +11 -7
- package/dist/browser/index.d.ts +2 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/page.d.ts +4 -0
- package/dist/browser/page.js +52 -3
- package/dist/browser.test.js +5 -0
- package/dist/cli-manifest.json +460 -1
- package/dist/cli.js +34 -3
- package/dist/clis/apple-podcasts/commands.test.js +26 -3
- package/dist/clis/apple-podcasts/top.js +4 -1
- 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/twitter/trending.js +29 -61
- package/dist/clis/weread/shelf.js +132 -9
- package/dist/clis/weread/utils.js +5 -1
- package/dist/clis/xiaohongshu/publish.js +78 -42
- package/dist/clis/xiaohongshu/publish.test.js +20 -8
- 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/daemon.js +1 -0
- package/dist/discovery.js +40 -28
- package/dist/doctor.d.ts +1 -2
- package/dist/doctor.js +9 -5
- package/dist/engine.test.js +42 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +2 -2
- package/dist/execution.js +45 -13
- package/dist/execution.test.d.ts +1 -0
- package/dist/execution.test.js +40 -0
- package/dist/extension-manifest-regression.test.d.ts +1 -0
- package/dist/extension-manifest-regression.test.js +12 -0
- package/dist/external.js +6 -1
- package/dist/main.js +1 -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 +55 -17
- package/dist/plugin.js +706 -154
- package/dist/plugin.test.js +836 -38
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.js +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/weread-private-api-regression.test.js +185 -0
- package/docs/adapters/browser/bluesky.md +53 -0
- package/docs/guide/plugins.md +10 -0
- package/extension/dist/background.js +4 -2
- package/extension/manifest.json +4 -1
- package/extension/package-lock.json +2 -2
- package/extension/package.json +1 -1
- package/extension/src/background.ts +2 -1
- package/package.json +1 -1
- package/src/browser/cdp.ts +6 -0
- package/src/browser/discover.ts +10 -7
- package/src/browser/index.ts +2 -0
- package/src/browser/page.ts +49 -3
- package/src/browser.test.ts +6 -0
- package/src/cli.ts +34 -3
- package/src/clis/apple-podcasts/commands.test.ts +30 -2
- package/src/clis/apple-podcasts/top.ts +4 -1
- 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/twitter/trending.ts +29 -77
- package/src/clis/weread/shelf.ts +169 -9
- package/src/clis/weread/utils.ts +6 -1
- package/src/clis/xiaohongshu/publish.test.ts +22 -8
- package/src/clis/xiaohongshu/publish.ts +93 -52
- package/src/clis/xiaohongshu/search.test.ts +39 -1
- package/src/clis/xiaohongshu/search.ts +19 -1
- package/src/daemon.ts +1 -0
- package/src/discovery.ts +41 -33
- package/src/doctor.ts +11 -8
- package/src/engine.test.ts +38 -0
- package/src/errors.ts +6 -2
- package/src/execution.test.ts +47 -0
- package/src/execution.ts +39 -15
- package/src/extension-manifest-regression.test.ts +17 -0
- package/src/external.ts +6 -1
- package/src/main.ts +1 -0
- package/src/plugin-scaffold.test.ts +98 -0
- package/src/plugin-scaffold.ts +170 -0
- package/src/plugin.test.ts +881 -38
- package/src/plugin.ts +871 -158
- package/src/runtime.ts +2 -2
- package/src/types.ts +2 -0
- package/src/weread-private-api-regression.test.ts +207 -0
- package/tests/e2e/browser-public.test.ts +1 -1
- package/tests/e2e/output-formats.test.ts +10 -14
- package/tests/e2e/plugin-management.test.ts +4 -1
- package/tests/e2e/public-commands.test.ts +12 -1
- package/vitest.config.ts +1 -15
package/dist/plugin.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
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"
|
|
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.
|
|
7
8
|
*/
|
|
8
9
|
import * as fs from 'node:fs';
|
|
9
10
|
import * as os from 'node:os';
|
|
@@ -15,6 +16,7 @@ import { getErrorMessage } from './errors.js';
|
|
|
15
16
|
import { log } from './logger.js';
|
|
16
17
|
import { readPluginManifest, isMonorepo, getEnabledPlugins, checkCompatibility, } from './plugin-manifest.js';
|
|
17
18
|
const isWindows = process.platform === 'win32';
|
|
19
|
+
const LOCAL_PLUGIN_SOURCE_PREFIX = 'local:';
|
|
18
20
|
/** Get home directory, respecting HOME environment variable for test isolation. */
|
|
19
21
|
function getHomeDir() {
|
|
20
22
|
return process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
@@ -27,23 +29,414 @@ export function getLockFilePath() {
|
|
|
27
29
|
export function getMonoreposDir() {
|
|
28
30
|
return path.join(getHomeDir(), '.opencli', 'monorepos');
|
|
29
31
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
+
}
|
|
33
387
|
// ── Lock file helpers ───────────────────────────────────────────────────────
|
|
34
|
-
|
|
388
|
+
function readLockFileWithWriter(writeLock = writeLockFile) {
|
|
35
389
|
try {
|
|
36
390
|
const raw = fs.readFileSync(getLockFilePath(), 'utf-8');
|
|
37
|
-
|
|
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;
|
|
38
414
|
}
|
|
39
415
|
catch {
|
|
40
416
|
return {};
|
|
41
417
|
}
|
|
42
418
|
}
|
|
43
|
-
export function
|
|
419
|
+
export function readLockFile() {
|
|
420
|
+
return readLockFileWithWriter(writeLockFile);
|
|
421
|
+
}
|
|
422
|
+
function writeLockFileWithFs(lock, fsOps = fs) {
|
|
44
423
|
const lockPath = getLockFilePath();
|
|
45
|
-
|
|
46
|
-
|
|
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);
|
|
47
440
|
}
|
|
48
441
|
/** Get the HEAD commit hash of a git repo directory. */
|
|
49
442
|
export function getCommitHash(dir) {
|
|
@@ -73,22 +466,22 @@ export function validatePluginStructure(pluginDir) {
|
|
|
73
466
|
const hasTs = files.some(f => f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('.test.ts'));
|
|
74
467
|
const hasJs = files.some(f => f.endsWith('.js') && !f.endsWith('.d.js'));
|
|
75
468
|
if (!hasYaml && !hasTs && !hasJs) {
|
|
76
|
-
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.');
|
|
77
470
|
}
|
|
78
471
|
if (hasTs) {
|
|
79
472
|
const pkgJsonPath = path.join(pluginDir, 'package.json');
|
|
80
473
|
if (!fs.existsSync(pkgJsonPath)) {
|
|
81
|
-
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.');
|
|
82
475
|
}
|
|
83
476
|
else {
|
|
84
477
|
try {
|
|
85
478
|
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
|
|
86
479
|
if (pkg.type !== 'module') {
|
|
87
|
-
errors.push(
|
|
480
|
+
errors.push('Plugin package.json must have "type": "module" for TypeScript plugins.');
|
|
88
481
|
}
|
|
89
482
|
}
|
|
90
483
|
catch {
|
|
91
|
-
errors.push(
|
|
484
|
+
errors.push('Plugin package.json is malformed or invalid JSON.');
|
|
92
485
|
}
|
|
93
486
|
}
|
|
94
487
|
}
|
|
@@ -133,12 +526,48 @@ function postInstallMonorepoLifecycle(repoDir, pluginDirs) {
|
|
|
133
526
|
finalizePluginRuntime(pluginDir);
|
|
134
527
|
}
|
|
135
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
|
+
}
|
|
136
563
|
/**
|
|
137
564
|
* Install a plugin from a source.
|
|
138
565
|
* Supports:
|
|
139
566
|
* "github:user/repo" — single plugin or full monorepo
|
|
140
567
|
* "github:user/repo/subplugin" — specific sub-plugin from a monorepo
|
|
141
568
|
* "https://github.com/user/repo"
|
|
569
|
+
* "file:///absolute/path" — local plugin directory (symlinked)
|
|
570
|
+
* "/absolute/path" — local plugin directory (symlinked)
|
|
142
571
|
*
|
|
143
572
|
* Returns the installed plugin name(s).
|
|
144
573
|
*/
|
|
@@ -149,39 +578,29 @@ export function installPlugin(source) {
|
|
|
149
578
|
`Supported formats:\n` +
|
|
150
579
|
` github:user/repo\n` +
|
|
151
580
|
` github:user/repo/subplugin\n` +
|
|
152
|
-
` https://github.com/user/repo`
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
execFileSync('git', ['clone', '--depth', '1', cloneUrl, tmpCloneDir], {
|
|
159
|
-
encoding: 'utf-8',
|
|
160
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
161
|
-
});
|
|
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`);
|
|
162
587
|
}
|
|
163
|
-
|
|
164
|
-
|
|
588
|
+
const { name: repoName, subPlugin } = parsed;
|
|
589
|
+
if (parsed.type === 'local') {
|
|
590
|
+
return installLocalPlugin(parsed.localPath, repoName);
|
|
165
591
|
}
|
|
166
|
-
|
|
592
|
+
return withTempClone(parsed.cloneUrl, (tmpCloneDir) => {
|
|
167
593
|
const manifest = readPluginManifest(tmpCloneDir);
|
|
168
594
|
// Check top-level compatibility
|
|
169
595
|
if (manifest?.opencli && !checkCompatibility(manifest.opencli)) {
|
|
170
596
|
throw new Error(`Plugin requires opencli ${manifest.opencli}, but current version is incompatible.`);
|
|
171
597
|
}
|
|
172
598
|
if (manifest && isMonorepo(manifest)) {
|
|
173
|
-
return installMonorepo(tmpCloneDir, cloneUrl, repoName, manifest, subPlugin);
|
|
599
|
+
return installMonorepo(tmpCloneDir, parsed.cloneUrl, repoName, manifest, subPlugin);
|
|
174
600
|
}
|
|
175
601
|
// 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
|
-
}
|
|
602
|
+
return installSinglePlugin(tmpCloneDir, parsed.cloneUrl, repoName, manifest);
|
|
603
|
+
});
|
|
185
604
|
}
|
|
186
605
|
/** Install a single (non-monorepo) plugin. */
|
|
187
606
|
function installSinglePlugin(cloneDir, cloneUrl, name, manifest) {
|
|
@@ -190,50 +609,100 @@ function installSinglePlugin(cloneDir, cloneUrl, name, manifest) {
|
|
|
190
609
|
if (fs.existsSync(targetDir)) {
|
|
191
610
|
throw new Error(`Plugin "${pluginName}" is already installed at ${targetDir}`);
|
|
192
611
|
}
|
|
193
|
-
|
|
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}`);
|
|
633
|
+
}
|
|
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.`);
|
|
641
|
+
}
|
|
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);
|
|
194
648
|
if (!validation.valid) {
|
|
195
649
|
throw new Error(`Invalid plugin structure:\n- ${validation.errors.join('\n- ')}`);
|
|
196
650
|
}
|
|
197
651
|
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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);
|
|
210
664
|
return pluginName;
|
|
211
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
|
+
}
|
|
212
681
|
/** Install sub-plugins from a monorepo. */
|
|
213
682
|
function installMonorepo(cloneDir, cloneUrl, repoName, manifest, subPlugin) {
|
|
214
683
|
const monoreposDir = getMonoreposDir();
|
|
215
684
|
const repoDir = path.join(monoreposDir, repoName);
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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}`);
|
|
220
690
|
}
|
|
221
|
-
let pluginsToInstall = getEnabledPlugins(
|
|
691
|
+
let pluginsToInstall = getEnabledPlugins(effectiveManifest);
|
|
222
692
|
// If a specific sub-plugin was requested, filter to just that one
|
|
223
693
|
if (subPlugin) {
|
|
224
694
|
pluginsToInstall = pluginsToInstall.filter((p) => p.name === subPlugin);
|
|
225
695
|
if (pluginsToInstall.length === 0) {
|
|
226
696
|
// Check if it exists but is disabled
|
|
227
|
-
const disabled =
|
|
697
|
+
const disabled = effectiveManifest.plugins?.[subPlugin];
|
|
228
698
|
if (disabled) {
|
|
229
699
|
throw new Error(`Sub-plugin "${subPlugin}" is disabled in the manifest.`);
|
|
230
700
|
}
|
|
231
|
-
throw new Error(`Sub-plugin "${subPlugin}" not found in monorepo. Available: ${Object.keys(
|
|
701
|
+
throw new Error(`Sub-plugin "${subPlugin}" not found in monorepo. Available: ${Object.keys(effectiveManifest.plugins ?? {}).join(', ')}`);
|
|
232
702
|
}
|
|
233
703
|
}
|
|
234
704
|
const installedNames = [];
|
|
235
705
|
const lock = readLockFile();
|
|
236
|
-
const commitHash = getCommitHash(repoDir);
|
|
237
706
|
const eligiblePlugins = [];
|
|
238
707
|
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
239
708
|
for (const { name, entry } of pluginsToInstall) {
|
|
@@ -242,7 +711,7 @@ function installMonorepo(cloneDir, cloneUrl, repoName, manifest, subPlugin) {
|
|
|
242
711
|
log.warn(`Skipping "${name}": requires opencli ${entry.opencli}`);
|
|
243
712
|
continue;
|
|
244
713
|
}
|
|
245
|
-
const subDir = path.join(
|
|
714
|
+
const subDir = path.join(repoRoot, entry.path);
|
|
246
715
|
if (!fs.existsSync(subDir)) {
|
|
247
716
|
log.warn(`Skipping "${name}": path "${entry.path}" not found in repo.`);
|
|
248
717
|
continue;
|
|
@@ -257,29 +726,85 @@ function installMonorepo(cloneDir, cloneUrl, repoName, manifest, subPlugin) {
|
|
|
257
726
|
log.warn(`Skipping "${name}": already installed at ${linkPath}`);
|
|
258
727
|
continue;
|
|
259
728
|
}
|
|
260
|
-
eligiblePlugins.push({ name, entry
|
|
729
|
+
eligiblePlugins.push({ name, entry });
|
|
261
730
|
}
|
|
262
|
-
if (eligiblePlugins.length
|
|
263
|
-
|
|
731
|
+
if (eligiblePlugins.length === 0) {
|
|
732
|
+
return installedNames;
|
|
264
733
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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);
|
|
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)));
|
|
279
737
|
}
|
|
280
|
-
|
|
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
|
+
}
|
|
756
|
+
writeLockFile(lock);
|
|
757
|
+
});
|
|
281
758
|
return installedNames;
|
|
282
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
|
+
});
|
|
796
|
+
}
|
|
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
|
+
});
|
|
807
|
+
}
|
|
283
808
|
/**
|
|
284
809
|
* Uninstall a plugin by name.
|
|
285
810
|
* For monorepo sub-plugins: removes symlink and cleans up the monorepo
|
|
@@ -302,10 +827,10 @@ export function uninstallPlugin(name) {
|
|
|
302
827
|
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
303
828
|
}
|
|
304
829
|
// Clean up monorepo directory if no more sub-plugins reference it
|
|
305
|
-
if (lockEntry?.monorepo) {
|
|
830
|
+
if (lockEntry?.source.kind === 'monorepo') {
|
|
306
831
|
delete lock[name];
|
|
307
|
-
const monoName = lockEntry.
|
|
308
|
-
const stillReferenced = Object.values(lock).some((entry) => entry.monorepo
|
|
832
|
+
const monoName = lockEntry.source.repoName;
|
|
833
|
+
const stillReferenced = Object.values(lock).some((entry) => entry.source.kind === 'monorepo' && entry.source.repoName === monoName);
|
|
309
834
|
if (!stillReferenced) {
|
|
310
835
|
const monoDir = path.join(getMonoreposDir(), monoName);
|
|
311
836
|
try {
|
|
@@ -340,77 +865,51 @@ export function updatePlugin(name) {
|
|
|
340
865
|
}
|
|
341
866
|
const lock = readLockFile();
|
|
342
867
|
const lockEntry = lock[name];
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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- ')}`);
|
|
868
|
+
const source = resolvePluginSource(lockEntry, targetDir);
|
|
869
|
+
if (source?.kind === 'local') {
|
|
870
|
+
updateLocalPlugin(name, targetDir, lock, lockEntry);
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
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.`);
|
|
367
884
|
}
|
|
368
|
-
|
|
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;
|
|
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}`);
|
|
369
901
|
}
|
|
370
|
-
if (
|
|
371
|
-
|
|
902
|
+
if (manifest?.opencli && !checkCompatibility(manifest.opencli)) {
|
|
903
|
+
throw new Error(`Plugin requires opencli ${manifest.opencli}, but current version is incompatible.`);
|
|
372
904
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
905
|
+
ensureStandalonePluginReady(tmpCloneDir);
|
|
906
|
+
publishStandalonePlugin(tmpCloneDir, targetDir, (commitHash) => {
|
|
907
|
+
updateStandaloneLockEntry(lock, name, cloneUrl, lock[name], commitHash);
|
|
376
908
|
if (commitHash) {
|
|
377
|
-
lock
|
|
378
|
-
...entry,
|
|
379
|
-
commitHash,
|
|
380
|
-
updatedAt: new Date().toISOString(),
|
|
381
|
-
};
|
|
909
|
+
writeLockFile(lock);
|
|
382
910
|
}
|
|
383
|
-
}
|
|
384
|
-
writeLockFile(lock);
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
// Standard single-plugin update
|
|
388
|
-
try {
|
|
389
|
-
execFileSync('git', ['pull', '--ff-only'], {
|
|
390
|
-
cwd: targetDir,
|
|
391
|
-
encoding: 'utf-8',
|
|
392
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
393
911
|
});
|
|
394
|
-
}
|
|
395
|
-
catch (err) {
|
|
396
|
-
throw new Error(`Failed to update plugin: ${getErrorMessage(err)}`);
|
|
397
|
-
}
|
|
398
|
-
const validation = validatePluginStructure(targetDir);
|
|
399
|
-
if (!validation.valid) {
|
|
400
|
-
log.warn(`Plugin "${name}" updated, but structure is now invalid:\n- ${validation.errors.join('\n- ')}`);
|
|
401
|
-
}
|
|
402
|
-
postInstallLifecycle(targetDir);
|
|
403
|
-
const commitHash = getCommitHash(targetDir);
|
|
404
|
-
if (commitHash) {
|
|
405
|
-
const existing = lock[name];
|
|
406
|
-
lock[name] = {
|
|
407
|
-
source: existing?.source ?? getPluginSource(targetDir) ?? '',
|
|
408
|
-
commitHash,
|
|
409
|
-
installedAt: existing?.installedAt ?? new Date().toISOString(),
|
|
410
|
-
updatedAt: new Date().toISOString(),
|
|
411
|
-
};
|
|
412
|
-
writeLockFile(lock);
|
|
413
|
-
}
|
|
912
|
+
});
|
|
414
913
|
}
|
|
415
914
|
/**
|
|
416
915
|
* Update all installed plugins.
|
|
@@ -454,8 +953,8 @@ export function listPlugins() {
|
|
|
454
953
|
// For monorepo sub-plugins, also check the monorepo root manifest
|
|
455
954
|
let description = manifest?.description;
|
|
456
955
|
let version = manifest?.version;
|
|
457
|
-
if (lockEntry?.monorepo && !description) {
|
|
458
|
-
const monoDir = path.join(getMonoreposDir(), lockEntry.
|
|
956
|
+
if (lockEntry?.source.kind === 'monorepo' && !description) {
|
|
957
|
+
const monoDir = path.join(getMonoreposDir(), lockEntry.source.repoName);
|
|
459
958
|
const monoManifest = readPluginManifest(monoDir);
|
|
460
959
|
const subEntry = monoManifest?.plugins?.[entry.name];
|
|
461
960
|
if (subEntry) {
|
|
@@ -463,9 +962,7 @@ export function listPlugins() {
|
|
|
463
962
|
version = version ?? subEntry.version;
|
|
464
963
|
}
|
|
465
964
|
}
|
|
466
|
-
const source = lockEntry
|
|
467
|
-
? lockEntry.source
|
|
468
|
-
: getPluginSource(pluginDir);
|
|
965
|
+
const source = resolveStoredPluginSource(lockEntry, pluginDir);
|
|
469
966
|
plugins.push({
|
|
470
967
|
name: entry.name,
|
|
471
968
|
path: pluginDir,
|
|
@@ -473,7 +970,7 @@ export function listPlugins() {
|
|
|
473
970
|
source,
|
|
474
971
|
version: version ?? lockEntry?.commitHash?.slice(0, 7),
|
|
475
972
|
installedAt: lockEntry?.installedAt,
|
|
476
|
-
monorepoName: lockEntry?.monorepo
|
|
973
|
+
monorepoName: lockEntry?.source.kind === 'monorepo' ? lockEntry.source.repoName : undefined,
|
|
477
974
|
description,
|
|
478
975
|
});
|
|
479
976
|
}
|
|
@@ -509,12 +1006,34 @@ function getPluginSource(dir) {
|
|
|
509
1006
|
}
|
|
510
1007
|
/** Parse a plugin source string into clone URL, repo name, and optional sub-plugin. */
|
|
511
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
|
+
}
|
|
512
1030
|
// github:user/repo/subplugin (monorepo specific sub-plugin)
|
|
513
1031
|
const githubSubMatch = source.match(/^github:([\w.-]+)\/([\w.-]+)\/([\w.-]+)$/);
|
|
514
1032
|
if (githubSubMatch) {
|
|
515
1033
|
const [, user, repo, sub] = githubSubMatch;
|
|
516
1034
|
const name = repo.replace(/^opencli-plugin-/, '');
|
|
517
1035
|
return {
|
|
1036
|
+
type: 'git',
|
|
518
1037
|
cloneUrl: `https://github.com/${user}/${repo}.git`,
|
|
519
1038
|
name,
|
|
520
1039
|
subPlugin: sub,
|
|
@@ -526,6 +1045,7 @@ function parseSource(source) {
|
|
|
526
1045
|
const [, user, repo] = githubMatch;
|
|
527
1046
|
const name = repo.replace(/^opencli-plugin-/, '');
|
|
528
1047
|
return {
|
|
1048
|
+
type: 'git',
|
|
529
1049
|
cloneUrl: `https://github.com/${user}/${repo}.git`,
|
|
530
1050
|
name,
|
|
531
1051
|
};
|
|
@@ -536,10 +1056,41 @@ function parseSource(source) {
|
|
|
536
1056
|
const [, user, repo] = urlMatch;
|
|
537
1057
|
const name = repo.replace(/^opencli-plugin-/, '');
|
|
538
1058
|
return {
|
|
1059
|
+
type: 'git',
|
|
539
1060
|
cloneUrl: `https://github.com/${user}/${repo}.git`,
|
|
540
1061
|
name,
|
|
541
1062
|
};
|
|
542
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
|
+
}
|
|
543
1094
|
return null;
|
|
544
1095
|
}
|
|
545
1096
|
/**
|
|
@@ -637,7 +1188,8 @@ function transpilePluginTs(pluginDir) {
|
|
|
637
1188
|
try {
|
|
638
1189
|
const esbuildBin = resolveEsbuildBin();
|
|
639
1190
|
if (!esbuildBin) {
|
|
640
|
-
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.');
|
|
641
1193
|
return;
|
|
642
1194
|
}
|
|
643
1195
|
const files = fs.readdirSync(pluginDir);
|
|
@@ -662,8 +1214,8 @@ function transpilePluginTs(pluginDir) {
|
|
|
662
1214
|
}
|
|
663
1215
|
}
|
|
664
1216
|
}
|
|
665
|
-
catch {
|
|
666
|
-
|
|
1217
|
+
catch (err) {
|
|
1218
|
+
log.warn(`TS transpilation setup failed: ${getErrorMessage(err)}`);
|
|
667
1219
|
}
|
|
668
1220
|
}
|
|
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, };
|
|
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, };
|