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