@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/src/plugin.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
|
6
6
|
import * as fs from 'node:fs';
|
|
7
7
|
import * as os from 'node:os';
|
|
8
8
|
import * as path from 'node:path';
|
|
9
|
+
import { pathToFileURL } from 'node:url';
|
|
9
10
|
import { PLUGINS_DIR } from './discovery.js';
|
|
10
11
|
import type { LockEntry } from './plugin.js';
|
|
11
12
|
import * as pluginModule from './plugin.js';
|
|
@@ -16,12 +17,15 @@ const { mockExecFileSync, mockExecSync } = vi.hoisted(() => ({
|
|
|
16
17
|
}));
|
|
17
18
|
|
|
18
19
|
const {
|
|
19
|
-
LOCK_FILE,
|
|
20
20
|
_getCommitHash,
|
|
21
21
|
_installDependencies,
|
|
22
22
|
_postInstallMonorepoLifecycle,
|
|
23
|
+
_promoteDir,
|
|
24
|
+
_replaceDir,
|
|
25
|
+
installPlugin,
|
|
23
26
|
listPlugins,
|
|
24
27
|
_readLockFile,
|
|
28
|
+
_readLockFileWithWriter,
|
|
25
29
|
_resolveEsbuildBin,
|
|
26
30
|
uninstallPlugin,
|
|
27
31
|
updatePlugin,
|
|
@@ -29,14 +33,24 @@ const {
|
|
|
29
33
|
_updateAllPlugins,
|
|
30
34
|
_validatePluginStructure,
|
|
31
35
|
_writeLockFile,
|
|
36
|
+
_writeLockFileWithFs,
|
|
32
37
|
_isSymlinkSync,
|
|
33
38
|
_getMonoreposDir,
|
|
39
|
+
getLockFilePath,
|
|
40
|
+
_installLocalPlugin,
|
|
41
|
+
_isLocalPluginSource,
|
|
42
|
+
_moveDir,
|
|
43
|
+
_resolvePluginSource,
|
|
44
|
+
_resolveStoredPluginSource,
|
|
45
|
+
_toStoredPluginSource,
|
|
46
|
+
_toLocalPluginSource,
|
|
34
47
|
} = pluginModule;
|
|
35
48
|
|
|
36
49
|
describe('parseSource', () => {
|
|
37
50
|
it('parses github:user/repo format', () => {
|
|
38
51
|
const result = _parseSource('github:ByteYue/opencli-plugin-github-trending');
|
|
39
52
|
expect(result).toEqual({
|
|
53
|
+
type: 'git',
|
|
40
54
|
cloneUrl: 'https://github.com/ByteYue/opencli-plugin-github-trending.git',
|
|
41
55
|
name: 'github-trending',
|
|
42
56
|
});
|
|
@@ -45,6 +59,7 @@ describe('parseSource', () => {
|
|
|
45
59
|
it('parses https URL format', () => {
|
|
46
60
|
const result = _parseSource('https://github.com/ByteYue/opencli-plugin-hot-digest');
|
|
47
61
|
expect(result).toEqual({
|
|
62
|
+
type: 'git',
|
|
48
63
|
cloneUrl: 'https://github.com/ByteYue/opencli-plugin-hot-digest.git',
|
|
49
64
|
name: 'hot-digest',
|
|
50
65
|
});
|
|
@@ -64,6 +79,98 @@ describe('parseSource', () => {
|
|
|
64
79
|
expect(_parseSource('invalid')).toBeNull();
|
|
65
80
|
expect(_parseSource('npm:some-package')).toBeNull();
|
|
66
81
|
});
|
|
82
|
+
|
|
83
|
+
it('parses file:// local plugin directories', () => {
|
|
84
|
+
const localDir = path.join(os.tmpdir(), 'opencli-plugin-test');
|
|
85
|
+
const fileUrl = pathToFileURL(localDir).href;
|
|
86
|
+
const result = _parseSource(fileUrl);
|
|
87
|
+
expect(result).toEqual({
|
|
88
|
+
type: 'local',
|
|
89
|
+
localPath: localDir,
|
|
90
|
+
name: 'test',
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('parses plain absolute local plugin directories', () => {
|
|
95
|
+
const localDir = path.join(os.tmpdir(), 'my-plugin');
|
|
96
|
+
const result = _parseSource(localDir);
|
|
97
|
+
expect(result).toEqual({
|
|
98
|
+
type: 'local',
|
|
99
|
+
localPath: localDir,
|
|
100
|
+
name: 'my-plugin',
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('strips opencli-plugin- prefix for local paths', () => {
|
|
105
|
+
const localDir = path.join(os.tmpdir(), 'opencli-plugin-foo');
|
|
106
|
+
const result = _parseSource(localDir);
|
|
107
|
+
expect(result!.name).toBe('foo');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ── Generic git URL support ──
|
|
111
|
+
it('parses ssh:// URLs', () => {
|
|
112
|
+
const result = _parseSource('ssh://git@gitlab.com/team/opencli-plugin-tools.git');
|
|
113
|
+
expect(result).toEqual({
|
|
114
|
+
type: 'git',
|
|
115
|
+
cloneUrl: 'ssh://git@gitlab.com/team/opencli-plugin-tools.git',
|
|
116
|
+
name: 'tools',
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('parses ssh:// URLs without .git suffix', () => {
|
|
121
|
+
const result = _parseSource('ssh://git@gitlab.com/team/my-plugin');
|
|
122
|
+
expect(result).toEqual({
|
|
123
|
+
type: 'git',
|
|
124
|
+
cloneUrl: 'ssh://git@gitlab.com/team/my-plugin',
|
|
125
|
+
name: 'my-plugin',
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('parses git@ SCP-style URLs', () => {
|
|
130
|
+
const result = _parseSource('git@gitlab.com:team/my-plugin.git');
|
|
131
|
+
expect(result).toEqual({
|
|
132
|
+
type: 'git',
|
|
133
|
+
cloneUrl: 'git@gitlab.com:team/my-plugin.git',
|
|
134
|
+
name: 'my-plugin',
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('parses git@ SCP-style URLs and strips opencli-plugin- prefix', () => {
|
|
139
|
+
const result = _parseSource('git@github.com:user/opencli-plugin-awesome.git');
|
|
140
|
+
expect(result).toEqual({
|
|
141
|
+
type: 'git',
|
|
142
|
+
cloneUrl: 'git@github.com:user/opencli-plugin-awesome.git',
|
|
143
|
+
name: 'awesome',
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('parses generic HTTPS git URLs (non-GitHub)', () => {
|
|
148
|
+
const result = _parseSource('https://codehub.example.com/Team/App/opencli-plugins-app.git');
|
|
149
|
+
expect(result).toEqual({
|
|
150
|
+
type: 'git',
|
|
151
|
+
cloneUrl: 'https://codehub.example.com/Team/App/opencli-plugins-app.git',
|
|
152
|
+
name: 'opencli-plugins-app',
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('parses generic HTTPS git URLs without .git suffix', () => {
|
|
157
|
+
const result = _parseSource('https://gitlab.example.com/org/my-plugin');
|
|
158
|
+
expect(result).toEqual({
|
|
159
|
+
type: 'git',
|
|
160
|
+
cloneUrl: 'https://gitlab.example.com/org/my-plugin.git',
|
|
161
|
+
name: 'my-plugin',
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('still prefers GitHub shorthand over generic HTTPS for github.com', () => {
|
|
166
|
+
const result = _parseSource('https://github.com/user/repo');
|
|
167
|
+
// Should be handled by the GitHub-specific matcher (normalizes URL)
|
|
168
|
+
expect(result).toEqual({
|
|
169
|
+
type: 'git',
|
|
170
|
+
cloneUrl: 'https://github.com/user/repo.git',
|
|
171
|
+
name: 'repo',
|
|
172
|
+
});
|
|
173
|
+
});
|
|
67
174
|
});
|
|
68
175
|
|
|
69
176
|
describe('validatePluginStructure', () => {
|
|
@@ -128,40 +235,40 @@ describe('validatePluginStructure', () => {
|
|
|
128
235
|
});
|
|
129
236
|
|
|
130
237
|
describe('lock file', () => {
|
|
131
|
-
const backupPath = `${
|
|
238
|
+
const backupPath = `${getLockFilePath()}.test-backup`;
|
|
132
239
|
let hadOriginal = false;
|
|
133
240
|
|
|
134
241
|
beforeEach(() => {
|
|
135
|
-
hadOriginal = fs.existsSync(
|
|
242
|
+
hadOriginal = fs.existsSync(getLockFilePath());
|
|
136
243
|
if (hadOriginal) {
|
|
137
244
|
fs.mkdirSync(path.dirname(backupPath), { recursive: true });
|
|
138
|
-
fs.copyFileSync(
|
|
245
|
+
fs.copyFileSync(getLockFilePath(), backupPath);
|
|
139
246
|
}
|
|
140
247
|
});
|
|
141
248
|
|
|
142
249
|
afterEach(() => {
|
|
143
250
|
if (hadOriginal) {
|
|
144
|
-
fs.copyFileSync(backupPath,
|
|
251
|
+
fs.copyFileSync(backupPath, getLockFilePath());
|
|
145
252
|
fs.unlinkSync(backupPath);
|
|
146
253
|
return;
|
|
147
254
|
}
|
|
148
|
-
try { fs.unlinkSync(
|
|
255
|
+
try { fs.unlinkSync(getLockFilePath()); } catch {}
|
|
149
256
|
});
|
|
150
257
|
|
|
151
258
|
it('reads empty lock when file does not exist', () => {
|
|
152
|
-
try { fs.unlinkSync(
|
|
259
|
+
try { fs.unlinkSync(getLockFilePath()); } catch {}
|
|
153
260
|
expect(_readLockFile()).toEqual({});
|
|
154
261
|
});
|
|
155
262
|
|
|
156
263
|
it('round-trips lock entries', () => {
|
|
157
264
|
const entries: Record<string, LockEntry> = {
|
|
158
265
|
'test-plugin': {
|
|
159
|
-
source: 'https://github.com/user/repo.git',
|
|
266
|
+
source: { kind: 'git', url: 'https://github.com/user/repo.git' },
|
|
160
267
|
commitHash: 'abc1234567890def',
|
|
161
268
|
installedAt: '2025-01-01T00:00:00.000Z',
|
|
162
269
|
},
|
|
163
270
|
'another-plugin': {
|
|
164
|
-
source: 'https://github.com/user/another.git',
|
|
271
|
+
source: { kind: 'git', url: 'https://github.com/user/another.git' },
|
|
165
272
|
commitHash: 'def4567890123abc',
|
|
166
273
|
installedAt: '2025-02-01T00:00:00.000Z',
|
|
167
274
|
updatedAt: '2025-03-01T00:00:00.000Z',
|
|
@@ -173,10 +280,105 @@ describe('lock file', () => {
|
|
|
173
280
|
});
|
|
174
281
|
|
|
175
282
|
it('handles malformed lock file gracefully', () => {
|
|
176
|
-
fs.mkdirSync(path.dirname(
|
|
177
|
-
fs.writeFileSync(
|
|
283
|
+
fs.mkdirSync(path.dirname(getLockFilePath()), { recursive: true });
|
|
284
|
+
fs.writeFileSync(getLockFilePath(), 'not valid json');
|
|
178
285
|
expect(_readLockFile()).toEqual({});
|
|
179
286
|
});
|
|
287
|
+
|
|
288
|
+
it('keeps the previous lockfile contents when atomic rewrite fails', () => {
|
|
289
|
+
const existing: Record<string, LockEntry> = {
|
|
290
|
+
stable: {
|
|
291
|
+
source: { kind: 'git', url: 'https://github.com/user/stable.git' },
|
|
292
|
+
commitHash: 'stable1234567890',
|
|
293
|
+
installedAt: '2025-01-01T00:00:00.000Z',
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
_writeLockFile(existing);
|
|
297
|
+
|
|
298
|
+
const renameSync = vi.fn(() => {
|
|
299
|
+
throw new Error('rename failed');
|
|
300
|
+
});
|
|
301
|
+
const rmSync = vi.fn(() => undefined);
|
|
302
|
+
|
|
303
|
+
expect(() => _writeLockFileWithFs({
|
|
304
|
+
broken: {
|
|
305
|
+
source: { kind: 'git', url: 'https://github.com/user/broken.git' },
|
|
306
|
+
commitHash: 'broken1234567890',
|
|
307
|
+
installedAt: '2025-02-01T00:00:00.000Z',
|
|
308
|
+
},
|
|
309
|
+
}, {
|
|
310
|
+
mkdirSync: fs.mkdirSync,
|
|
311
|
+
writeFileSync: fs.writeFileSync,
|
|
312
|
+
renameSync,
|
|
313
|
+
rmSync,
|
|
314
|
+
})).toThrow('rename failed');
|
|
315
|
+
|
|
316
|
+
expect(_readLockFile()).toEqual(existing);
|
|
317
|
+
expect(rmSync).toHaveBeenCalledTimes(1);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('migrates legacy string sources to structured sources on read', () => {
|
|
321
|
+
const legacyLocalPath = path.resolve(path.join(os.tmpdir(), 'opencli-legacy-local-plugin'));
|
|
322
|
+
fs.mkdirSync(path.dirname(getLockFilePath()), { recursive: true });
|
|
323
|
+
fs.writeFileSync(getLockFilePath(), JSON.stringify({
|
|
324
|
+
alpha: {
|
|
325
|
+
source: 'https://github.com/user/opencli-plugins.git',
|
|
326
|
+
commitHash: 'abc1234567890def',
|
|
327
|
+
installedAt: '2025-01-01T00:00:00.000Z',
|
|
328
|
+
monorepo: { name: 'opencli-plugins', subPath: 'packages/alpha' },
|
|
329
|
+
},
|
|
330
|
+
beta: {
|
|
331
|
+
source: `local:${legacyLocalPath}`,
|
|
332
|
+
commitHash: 'local',
|
|
333
|
+
installedAt: '2025-01-01T00:00:00.000Z',
|
|
334
|
+
},
|
|
335
|
+
}, null, 2));
|
|
336
|
+
|
|
337
|
+
expect(_readLockFile()).toEqual({
|
|
338
|
+
alpha: {
|
|
339
|
+
source: {
|
|
340
|
+
kind: 'monorepo',
|
|
341
|
+
url: 'https://github.com/user/opencli-plugins.git',
|
|
342
|
+
repoName: 'opencli-plugins',
|
|
343
|
+
subPath: 'packages/alpha',
|
|
344
|
+
},
|
|
345
|
+
commitHash: 'abc1234567890def',
|
|
346
|
+
installedAt: '2025-01-01T00:00:00.000Z',
|
|
347
|
+
},
|
|
348
|
+
beta: {
|
|
349
|
+
source: { kind: 'local', path: legacyLocalPath },
|
|
350
|
+
commitHash: 'local',
|
|
351
|
+
installedAt: '2025-01-01T00:00:00.000Z',
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('returns normalized entries even when migration rewrite fails', () => {
|
|
357
|
+
fs.mkdirSync(path.dirname(getLockFilePath()), { recursive: true });
|
|
358
|
+
fs.writeFileSync(getLockFilePath(), JSON.stringify({
|
|
359
|
+
alpha: {
|
|
360
|
+
source: 'https://github.com/user/opencli-plugins.git',
|
|
361
|
+
commitHash: 'abc1234567890def',
|
|
362
|
+
installedAt: '2025-01-01T00:00:00.000Z',
|
|
363
|
+
monorepo: { name: 'opencli-plugins', subPath: 'packages/alpha' },
|
|
364
|
+
},
|
|
365
|
+
}, null, 2));
|
|
366
|
+
|
|
367
|
+
expect(_readLockFileWithWriter(() => {
|
|
368
|
+
throw new Error('disk full');
|
|
369
|
+
})).toEqual({
|
|
370
|
+
alpha: {
|
|
371
|
+
source: {
|
|
372
|
+
kind: 'monorepo',
|
|
373
|
+
url: 'https://github.com/user/opencli-plugins.git',
|
|
374
|
+
repoName: 'opencli-plugins',
|
|
375
|
+
subPath: 'packages/alpha',
|
|
376
|
+
},
|
|
377
|
+
commitHash: 'abc1234567890def',
|
|
378
|
+
installedAt: '2025-01-01T00:00:00.000Z',
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
});
|
|
180
382
|
});
|
|
181
383
|
|
|
182
384
|
describe('getCommitHash', () => {
|
|
@@ -197,7 +399,6 @@ describe('resolveEsbuildBin', () => {
|
|
|
197
399
|
expect(binPath).not.toBeNull();
|
|
198
400
|
expect(typeof binPath).toBe('string');
|
|
199
401
|
expect(fs.existsSync(binPath!)).toBe(true);
|
|
200
|
-
// On Windows the resolved path ends with 'esbuild.cmd', on Unix 'esbuild'
|
|
201
402
|
expect(binPath).toMatch(/esbuild(\.cmd)?$/);
|
|
202
403
|
});
|
|
203
404
|
});
|
|
@@ -225,7 +426,7 @@ describe('listPlugins', () => {
|
|
|
225
426
|
|
|
226
427
|
const lock = _readLockFile();
|
|
227
428
|
lock['__test-list-plugin__'] = {
|
|
228
|
-
source: 'https://github.com/user/repo.git',
|
|
429
|
+
source: { kind: 'git', url: 'https://github.com/user/repo.git' },
|
|
229
430
|
commitHash: 'abcdef1234567890abcdef1234567890abcdef12',
|
|
230
431
|
installedAt: '2025-01-01T00:00:00.000Z',
|
|
231
432
|
};
|
|
@@ -242,10 +443,37 @@ describe('listPlugins', () => {
|
|
|
242
443
|
});
|
|
243
444
|
|
|
244
445
|
it('returns empty array when no plugins dir', () => {
|
|
245
|
-
// listPlugins should handle missing dir gracefully
|
|
246
446
|
const plugins = listPlugins();
|
|
247
447
|
expect(Array.isArray(plugins)).toBe(true);
|
|
248
448
|
});
|
|
449
|
+
|
|
450
|
+
it('prefers lockfile source for local symlink plugins', () => {
|
|
451
|
+
const localTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-local-list-'));
|
|
452
|
+
const linkPath = path.join(PLUGINS_DIR, '__test-list-plugin__');
|
|
453
|
+
|
|
454
|
+
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
455
|
+
fs.writeFileSync(path.join(localTarget, 'hello.yaml'), 'site: test\nname: hello\n');
|
|
456
|
+
try { fs.unlinkSync(linkPath); } catch {}
|
|
457
|
+
try { fs.rmSync(linkPath, { recursive: true, force: true }); } catch {}
|
|
458
|
+
fs.symlinkSync(localTarget, linkPath, 'dir');
|
|
459
|
+
|
|
460
|
+
const lock = _readLockFile();
|
|
461
|
+
lock['__test-list-plugin__'] = {
|
|
462
|
+
source: { kind: 'local', path: localTarget },
|
|
463
|
+
commitHash: 'local',
|
|
464
|
+
installedAt: '2025-01-01T00:00:00.000Z',
|
|
465
|
+
};
|
|
466
|
+
_writeLockFile(lock);
|
|
467
|
+
|
|
468
|
+
const plugins = listPlugins();
|
|
469
|
+
const found = plugins.find(p => p.name === '__test-list-plugin__');
|
|
470
|
+
expect(found?.source).toBe(`local:${localTarget}`);
|
|
471
|
+
|
|
472
|
+
try { fs.unlinkSync(linkPath); } catch {}
|
|
473
|
+
try { fs.rmSync(localTarget, { recursive: true, force: true }); } catch {}
|
|
474
|
+
delete lock['__test-list-plugin__'];
|
|
475
|
+
_writeLockFile(lock);
|
|
476
|
+
});
|
|
249
477
|
});
|
|
250
478
|
|
|
251
479
|
describe('uninstallPlugin', () => {
|
|
@@ -269,7 +497,7 @@ describe('uninstallPlugin', () => {
|
|
|
269
497
|
|
|
270
498
|
const lock = _readLockFile();
|
|
271
499
|
lock['__test-uninstall__'] = {
|
|
272
|
-
source: 'https://github.com/user/repo.git',
|
|
500
|
+
source: { kind: 'git', url: 'https://github.com/user/repo.git' },
|
|
273
501
|
commitHash: 'abc123',
|
|
274
502
|
installedAt: '2025-01-01T00:00:00.000Z',
|
|
275
503
|
};
|
|
@@ -288,6 +516,45 @@ describe('updatePlugin', () => {
|
|
|
288
516
|
it('throws for non-existent plugin', () => {
|
|
289
517
|
expect(() => updatePlugin('__nonexistent__')).toThrow('not installed');
|
|
290
518
|
});
|
|
519
|
+
|
|
520
|
+
it('refreshes local plugins without running git pull', () => {
|
|
521
|
+
const localTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-local-update-'));
|
|
522
|
+
const linkPath = path.join(PLUGINS_DIR, '__test-local-update__');
|
|
523
|
+
|
|
524
|
+
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
525
|
+
fs.writeFileSync(path.join(localTarget, 'hello.yaml'), 'site: test\nname: hello\n');
|
|
526
|
+
fs.symlinkSync(localTarget, linkPath, 'dir');
|
|
527
|
+
|
|
528
|
+
const lock = _readLockFile();
|
|
529
|
+
lock['__test-local-update__'] = {
|
|
530
|
+
source: { kind: 'local', path: localTarget },
|
|
531
|
+
commitHash: 'local',
|
|
532
|
+
installedAt: '2025-01-01T00:00:00.000Z',
|
|
533
|
+
};
|
|
534
|
+
_writeLockFile(lock);
|
|
535
|
+
|
|
536
|
+
mockExecFileSync.mockClear();
|
|
537
|
+
updatePlugin('__test-local-update__');
|
|
538
|
+
|
|
539
|
+
expect(
|
|
540
|
+
mockExecFileSync.mock.calls.some(
|
|
541
|
+
([cmd, args, opts]) => cmd === 'git'
|
|
542
|
+
&& Array.isArray(args)
|
|
543
|
+
&& args[0] === 'pull'
|
|
544
|
+
&& opts?.cwd === linkPath,
|
|
545
|
+
),
|
|
546
|
+
).toBe(false);
|
|
547
|
+
|
|
548
|
+
const updated = _readLockFile()['__test-local-update__'];
|
|
549
|
+
expect(updated?.source).toEqual({ kind: 'local', path: path.resolve(localTarget) });
|
|
550
|
+
expect(updated?.updatedAt).toBeDefined();
|
|
551
|
+
|
|
552
|
+
try { fs.unlinkSync(linkPath); } catch {}
|
|
553
|
+
try { fs.rmSync(localTarget, { recursive: true, force: true }); } catch {}
|
|
554
|
+
const finalLock = _readLockFile();
|
|
555
|
+
delete finalLock['__test-local-update__'];
|
|
556
|
+
_writeLockFile(finalLock);
|
|
557
|
+
});
|
|
291
558
|
});
|
|
292
559
|
|
|
293
560
|
vi.mock('node:child_process', () => {
|
|
@@ -373,16 +640,59 @@ describe('updateAllPlugins', () => {
|
|
|
373
640
|
fs.writeFileSync(path.join(testDirA, 'cmd.yaml'), 'site: a');
|
|
374
641
|
fs.writeFileSync(path.join(testDirB, 'cmd.yaml'), 'site: b');
|
|
375
642
|
fs.writeFileSync(path.join(testDirC, 'cmd.yaml'), 'site: c');
|
|
643
|
+
|
|
644
|
+
const lock = _readLockFile();
|
|
645
|
+
lock['plugin-a'] = {
|
|
646
|
+
source: { kind: 'git', url: 'https://github.com/user/plugin-a.git' },
|
|
647
|
+
commitHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
648
|
+
installedAt: '2025-01-01T00:00:00.000Z',
|
|
649
|
+
};
|
|
650
|
+
lock['plugin-b'] = {
|
|
651
|
+
source: { kind: 'git', url: 'https://github.com/user/plugin-b.git' },
|
|
652
|
+
commitHash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
653
|
+
installedAt: '2025-01-01T00:00:00.000Z',
|
|
654
|
+
};
|
|
655
|
+
lock['plugin-c'] = {
|
|
656
|
+
source: { kind: 'git', url: 'https://github.com/user/plugin-c.git' },
|
|
657
|
+
commitHash: 'cccccccccccccccccccccccccccccccccccccccc',
|
|
658
|
+
installedAt: '2025-01-01T00:00:00.000Z',
|
|
659
|
+
};
|
|
660
|
+
_writeLockFile(lock);
|
|
376
661
|
});
|
|
377
662
|
|
|
378
663
|
afterEach(() => {
|
|
379
664
|
try { fs.rmSync(testDirA, { recursive: true }); } catch {}
|
|
380
665
|
try { fs.rmSync(testDirB, { recursive: true }); } catch {}
|
|
381
666
|
try { fs.rmSync(testDirC, { recursive: true }); } catch {}
|
|
667
|
+
const lock = _readLockFile();
|
|
668
|
+
delete lock['plugin-a'];
|
|
669
|
+
delete lock['plugin-b'];
|
|
670
|
+
delete lock['plugin-c'];
|
|
671
|
+
_writeLockFile(lock);
|
|
382
672
|
vi.clearAllMocks();
|
|
383
673
|
});
|
|
384
674
|
|
|
385
675
|
it('collects successes and failures without throwing', () => {
|
|
676
|
+
mockExecFileSync.mockImplementation((cmd, args) => {
|
|
677
|
+
if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
|
|
678
|
+
const cloneUrl = String(args[3]);
|
|
679
|
+
const cloneDir = String(args[4]);
|
|
680
|
+
fs.mkdirSync(cloneDir, { recursive: true });
|
|
681
|
+
fs.writeFileSync(path.join(cloneDir, 'cmd.yaml'), 'site: test\nname: hello\n');
|
|
682
|
+
if (cloneUrl.includes('plugin-b')) {
|
|
683
|
+
fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ name: 'plugin-b' }));
|
|
684
|
+
}
|
|
685
|
+
return '';
|
|
686
|
+
}
|
|
687
|
+
if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
|
|
688
|
+
throw new Error('Network error');
|
|
689
|
+
}
|
|
690
|
+
if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
|
|
691
|
+
return '1234567890abcdef1234567890abcdef12345678\n';
|
|
692
|
+
}
|
|
693
|
+
return '';
|
|
694
|
+
});
|
|
695
|
+
|
|
386
696
|
const results = _updateAllPlugins();
|
|
387
697
|
|
|
388
698
|
const resA = results.find(r => r.name === 'plugin-a');
|
|
@@ -407,6 +717,7 @@ describe('parseSource with monorepo subplugin', () => {
|
|
|
407
717
|
it('parses github:user/repo/subplugin format', () => {
|
|
408
718
|
const result = _parseSource('github:ByteYue/opencli-plugins/polymarket');
|
|
409
719
|
expect(result).toEqual({
|
|
720
|
+
type: 'git',
|
|
410
721
|
cloneUrl: 'https://github.com/ByteYue/opencli-plugins.git',
|
|
411
722
|
name: 'opencli-plugins',
|
|
412
723
|
subPlugin: 'polymarket',
|
|
@@ -422,6 +733,7 @@ describe('parseSource with monorepo subplugin', () => {
|
|
|
422
733
|
it('still parses github:user/repo without subplugin', () => {
|
|
423
734
|
const result = _parseSource('github:user/my-repo');
|
|
424
735
|
expect(result).toEqual({
|
|
736
|
+
type: 'git',
|
|
425
737
|
cloneUrl: 'https://github.com/user/my-repo.git',
|
|
426
738
|
name: 'my-repo',
|
|
427
739
|
});
|
|
@@ -466,26 +778,26 @@ describe('monorepo uninstall with symlink', () => {
|
|
|
466
778
|
|
|
467
779
|
beforeEach(() => {
|
|
468
780
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-mono-uninstall-'));
|
|
469
|
-
// We need to use the real PLUGINS_DIR for uninstallPlugin() to work
|
|
470
781
|
pluginDir = path.join(PLUGINS_DIR, '__test-mono-sub__');
|
|
471
782
|
monoDir = path.join(_getMonoreposDir(), '__test-mono__');
|
|
472
783
|
|
|
473
|
-
// Set up monorepo structure
|
|
474
784
|
const subDir = path.join(monoDir, 'packages', 'sub');
|
|
475
785
|
fs.mkdirSync(subDir, { recursive: true });
|
|
476
786
|
fs.writeFileSync(path.join(subDir, 'cmd.yaml'), 'site: test');
|
|
477
787
|
|
|
478
|
-
// Create symlink in plugins dir
|
|
479
788
|
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
480
789
|
fs.symlinkSync(subDir, pluginDir, 'dir');
|
|
481
790
|
|
|
482
|
-
// Set up lock file with monorepo entry
|
|
483
791
|
const lock = _readLockFile();
|
|
484
792
|
lock['__test-mono-sub__'] = {
|
|
485
|
-
source:
|
|
793
|
+
source: {
|
|
794
|
+
kind: 'monorepo',
|
|
795
|
+
url: 'https://github.com/user/test.git',
|
|
796
|
+
repoName: '__test-mono__',
|
|
797
|
+
subPath: 'packages/sub',
|
|
798
|
+
},
|
|
486
799
|
commitHash: 'abc123',
|
|
487
800
|
installedAt: '2025-01-01T00:00:00.000Z',
|
|
488
|
-
monorepo: { name: '__test-mono__', subPath: 'packages/sub' },
|
|
489
801
|
};
|
|
490
802
|
_writeLockFile(lock);
|
|
491
803
|
});
|
|
@@ -494,35 +806,32 @@ describe('monorepo uninstall with symlink', () => {
|
|
|
494
806
|
try { fs.unlinkSync(pluginDir); } catch {}
|
|
495
807
|
try { fs.rmSync(pluginDir, { recursive: true, force: true }); } catch {}
|
|
496
808
|
try { fs.rmSync(monoDir, { recursive: true, force: true }); } catch {}
|
|
497
|
-
// Clean up lock entry
|
|
498
809
|
const lock = _readLockFile();
|
|
499
810
|
delete lock['__test-mono-sub__'];
|
|
500
811
|
_writeLockFile(lock);
|
|
501
812
|
});
|
|
502
813
|
|
|
503
814
|
it('removes symlink but keeps monorepo if other sub-plugins reference it', () => {
|
|
504
|
-
// Add another sub-plugin referencing the same monorepo
|
|
505
815
|
const lock = _readLockFile();
|
|
506
816
|
lock['__test-mono-other__'] = {
|
|
507
|
-
source:
|
|
817
|
+
source: {
|
|
818
|
+
kind: 'monorepo',
|
|
819
|
+
url: 'https://github.com/user/test.git',
|
|
820
|
+
repoName: '__test-mono__',
|
|
821
|
+
subPath: 'packages/other',
|
|
822
|
+
},
|
|
508
823
|
commitHash: 'abc123',
|
|
509
824
|
installedAt: '2025-01-01T00:00:00.000Z',
|
|
510
|
-
monorepo: { name: '__test-mono__', subPath: 'packages/other' },
|
|
511
825
|
};
|
|
512
826
|
_writeLockFile(lock);
|
|
513
827
|
|
|
514
828
|
uninstallPlugin('__test-mono-sub__');
|
|
515
829
|
|
|
516
|
-
// Symlink removed
|
|
517
830
|
expect(fs.existsSync(pluginDir)).toBe(false);
|
|
518
|
-
// Monorepo dir still exists (other sub-plugin references it)
|
|
519
831
|
expect(fs.existsSync(monoDir)).toBe(true);
|
|
520
|
-
// Lock entry removed
|
|
521
832
|
expect(_readLockFile()['__test-mono-sub__']).toBeUndefined();
|
|
522
|
-
// Other lock entry still present
|
|
523
833
|
expect(_readLockFile()['__test-mono-other__']).toBeDefined();
|
|
524
834
|
|
|
525
|
-
// Clean up the other entry
|
|
526
835
|
const finalLock = _readLockFile();
|
|
527
836
|
delete finalLock['__test-mono-other__'];
|
|
528
837
|
_writeLockFile(finalLock);
|
|
@@ -531,11 +840,8 @@ describe('monorepo uninstall with symlink', () => {
|
|
|
531
840
|
it('removes symlink AND monorepo dir when last sub-plugin is uninstalled', () => {
|
|
532
841
|
uninstallPlugin('__test-mono-sub__');
|
|
533
842
|
|
|
534
|
-
// Symlink removed
|
|
535
843
|
expect(fs.existsSync(pluginDir)).toBe(false);
|
|
536
|
-
// Monorepo dir also removed (no more references)
|
|
537
844
|
expect(fs.existsSync(monoDir)).toBe(false);
|
|
538
|
-
// Lock entry removed
|
|
539
845
|
expect(_readLockFile()['__test-mono-sub__']).toBeUndefined();
|
|
540
846
|
});
|
|
541
847
|
});
|
|
@@ -545,22 +851,23 @@ describe('listPlugins with monorepo metadata', () => {
|
|
|
545
851
|
const testLink = path.join(PLUGINS_DIR, '__test-mono-list__');
|
|
546
852
|
|
|
547
853
|
beforeEach(() => {
|
|
548
|
-
// Create a target dir with a command file
|
|
549
854
|
fs.mkdirSync(testSymlinkTarget, { recursive: true });
|
|
550
855
|
fs.writeFileSync(path.join(testSymlinkTarget, 'hello.yaml'), 'site: test\nname: hello\n');
|
|
551
856
|
|
|
552
|
-
// Create symlink
|
|
553
857
|
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
554
858
|
try { fs.unlinkSync(testLink); } catch {}
|
|
555
859
|
fs.symlinkSync(testSymlinkTarget, testLink, 'dir');
|
|
556
860
|
|
|
557
|
-
// Set up lock file with monorepo entry
|
|
558
861
|
const lock = _readLockFile();
|
|
559
862
|
lock['__test-mono-list__'] = {
|
|
560
|
-
source:
|
|
863
|
+
source: {
|
|
864
|
+
kind: 'monorepo',
|
|
865
|
+
url: 'https://github.com/user/test-mono.git',
|
|
866
|
+
repoName: 'test-mono',
|
|
867
|
+
subPath: 'packages/list',
|
|
868
|
+
},
|
|
561
869
|
commitHash: 'def456def456def456def456def456def456def4',
|
|
562
870
|
installedAt: '2025-01-01T00:00:00.000Z',
|
|
563
|
-
monorepo: { name: 'test-mono', subPath: 'packages/list' },
|
|
564
871
|
};
|
|
565
872
|
_writeLockFile(lock);
|
|
566
873
|
});
|
|
@@ -582,3 +889,539 @@ describe('listPlugins with monorepo metadata', () => {
|
|
|
582
889
|
expect(found!.source).toBe('https://github.com/user/test-mono.git');
|
|
583
890
|
});
|
|
584
891
|
});
|
|
892
|
+
|
|
893
|
+
describe('installLocalPlugin', () => {
|
|
894
|
+
let tmpDir: string;
|
|
895
|
+
const pluginName = '__test-local-plugin__';
|
|
896
|
+
|
|
897
|
+
beforeEach(() => {
|
|
898
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-local-install-'));
|
|
899
|
+
fs.writeFileSync(path.join(tmpDir, 'hello.yaml'), 'site: test\nname: hello\n');
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
afterEach(() => {
|
|
903
|
+
const linkPath = path.join(PLUGINS_DIR, pluginName);
|
|
904
|
+
try { fs.unlinkSync(linkPath); } catch {}
|
|
905
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
906
|
+
const lock = _readLockFile();
|
|
907
|
+
delete lock[pluginName];
|
|
908
|
+
_writeLockFile(lock);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
it('creates a symlink to the local directory', () => {
|
|
912
|
+
const result = _installLocalPlugin(tmpDir, pluginName);
|
|
913
|
+
expect(result).toBe(pluginName);
|
|
914
|
+
const linkPath = path.join(PLUGINS_DIR, pluginName);
|
|
915
|
+
expect(fs.existsSync(linkPath)).toBe(true);
|
|
916
|
+
expect(_isSymlinkSync(linkPath)).toBe(true);
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
it('records local: source in lockfile', () => {
|
|
920
|
+
_installLocalPlugin(tmpDir, pluginName);
|
|
921
|
+
const lock = _readLockFile();
|
|
922
|
+
expect(lock[pluginName]).toBeDefined();
|
|
923
|
+
expect(lock[pluginName].source).toEqual({ kind: 'local', path: path.resolve(tmpDir) });
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
it('lists the recorded local source', () => {
|
|
927
|
+
_installLocalPlugin(tmpDir, pluginName);
|
|
928
|
+
const plugins = listPlugins();
|
|
929
|
+
const found = plugins.find(p => p.name === pluginName);
|
|
930
|
+
expect(found).toBeDefined();
|
|
931
|
+
expect(found!.source).toBe(`local:${path.resolve(tmpDir)}`);
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
it('throws for non-existent path', () => {
|
|
935
|
+
expect(() => _installLocalPlugin('/does/not/exist', 'x')).toThrow('does not exist');
|
|
936
|
+
});
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
describe('isLocalPluginSource', () => {
|
|
940
|
+
it('detects lockfile local sources', () => {
|
|
941
|
+
expect(_isLocalPluginSource('local:/tmp/plugin')).toBe(true);
|
|
942
|
+
expect(_isLocalPluginSource('https://github.com/user/repo.git')).toBe(false);
|
|
943
|
+
expect(_isLocalPluginSource(undefined)).toBe(false);
|
|
944
|
+
});
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
describe('plugin source helpers', () => {
|
|
948
|
+
it('formats local plugin sources consistently', () => {
|
|
949
|
+
const dir = path.join(os.tmpdir(), 'opencli-plugin-source');
|
|
950
|
+
expect(_toLocalPluginSource(dir)).toBe(`local:${path.resolve(dir)}`);
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
it('serializes structured local sources consistently', () => {
|
|
954
|
+
const dir = path.join(os.tmpdir(), 'opencli-plugin-source');
|
|
955
|
+
expect(_toStoredPluginSource({ kind: 'local', path: dir })).toBe(`local:${path.resolve(dir)}`);
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
it('prefers lockfile source over git remote lookup', () => {
|
|
959
|
+
const dir = path.join(os.tmpdir(), 'opencli-plugin-source');
|
|
960
|
+
const localPath = path.resolve(path.join(os.tmpdir(), 'opencli-plugin-source-local'));
|
|
961
|
+
const source = _resolveStoredPluginSource({
|
|
962
|
+
source: { kind: 'local', path: localPath },
|
|
963
|
+
commitHash: 'local',
|
|
964
|
+
installedAt: '2025-01-01T00:00:00.000Z',
|
|
965
|
+
}, dir);
|
|
966
|
+
expect(source).toBe(`local:${localPath}`);
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
it('returns structured monorepo sources unchanged', () => {
|
|
970
|
+
const dir = path.join(os.tmpdir(), 'opencli-plugin-source');
|
|
971
|
+
const source = _resolvePluginSource({
|
|
972
|
+
source: {
|
|
973
|
+
kind: 'monorepo',
|
|
974
|
+
url: 'https://github.com/user/opencli-plugins.git',
|
|
975
|
+
repoName: 'opencli-plugins',
|
|
976
|
+
subPath: 'packages/alpha',
|
|
977
|
+
},
|
|
978
|
+
commitHash: 'abcdef1234567890abcdef1234567890abcdef12',
|
|
979
|
+
installedAt: '2025-01-01T00:00:00.000Z',
|
|
980
|
+
}, dir);
|
|
981
|
+
expect(source).toEqual({
|
|
982
|
+
kind: 'monorepo',
|
|
983
|
+
url: 'https://github.com/user/opencli-plugins.git',
|
|
984
|
+
repoName: 'opencli-plugins',
|
|
985
|
+
subPath: 'packages/alpha',
|
|
986
|
+
});
|
|
987
|
+
});
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
describe('moveDir', () => {
|
|
991
|
+
it('cleans up destination when EXDEV fallback copy fails', () => {
|
|
992
|
+
const src = path.join(os.tmpdir(), 'opencli-move-src');
|
|
993
|
+
const dest = path.join(os.tmpdir(), 'opencli-move-dest');
|
|
994
|
+
const renameErr = Object.assign(new Error('cross-device link not permitted'), { code: 'EXDEV' });
|
|
995
|
+
const copyErr = new Error('copy failed');
|
|
996
|
+
const renameSync = vi.fn(() => { throw renameErr; });
|
|
997
|
+
const cpSync = vi.fn(() => { throw copyErr; });
|
|
998
|
+
const rmSync = vi.fn(() => undefined);
|
|
999
|
+
|
|
1000
|
+
expect(() => _moveDir(src, dest, { renameSync, cpSync, rmSync })).toThrow(copyErr);
|
|
1001
|
+
expect(renameSync).toHaveBeenCalledWith(src, dest);
|
|
1002
|
+
expect(cpSync).toHaveBeenCalledWith(src, dest, { recursive: true });
|
|
1003
|
+
expect(rmSync).toHaveBeenCalledWith(dest, { recursive: true, force: true });
|
|
1004
|
+
});
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
describe('promoteDir', () => {
|
|
1008
|
+
it('cleans up temporary publish dir when final rename fails', () => {
|
|
1009
|
+
const staging = path.join(os.tmpdir(), 'opencli-promote-stage');
|
|
1010
|
+
const dest = path.join(os.tmpdir(), 'opencli-promote-dest');
|
|
1011
|
+
const publishErr = new Error('publish failed');
|
|
1012
|
+
const existsSync = vi.fn(() => false);
|
|
1013
|
+
const mkdirSync = vi.fn(() => undefined);
|
|
1014
|
+
const cpSync = vi.fn(() => undefined);
|
|
1015
|
+
const rmSync = vi.fn(() => undefined);
|
|
1016
|
+
const renameSync = vi.fn((src, _target) => {
|
|
1017
|
+
if (String(src) === staging) return;
|
|
1018
|
+
throw publishErr;
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
expect(() => _promoteDir(staging, dest, { existsSync, mkdirSync, renameSync, cpSync, rmSync })).toThrow(publishErr);
|
|
1022
|
+
|
|
1023
|
+
const tempDest = renameSync.mock.calls[0][1];
|
|
1024
|
+
expect(renameSync).toHaveBeenNthCalledWith(1, staging, tempDest);
|
|
1025
|
+
expect(renameSync).toHaveBeenNthCalledWith(2, tempDest, dest);
|
|
1026
|
+
expect(rmSync).toHaveBeenCalledWith(tempDest, { recursive: true, force: true });
|
|
1027
|
+
});
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
describe('replaceDir', () => {
|
|
1031
|
+
it('rolls back the original destination when swap fails', () => {
|
|
1032
|
+
const staging = path.join(os.tmpdir(), 'opencli-replace-stage');
|
|
1033
|
+
const dest = path.join(os.tmpdir(), 'opencli-replace-dest');
|
|
1034
|
+
const publishErr = new Error('swap failed');
|
|
1035
|
+
const existingPaths = new Set([dest]);
|
|
1036
|
+
const existsSync = vi.fn((p) => existingPaths.has(String(p)));
|
|
1037
|
+
const mkdirSync = vi.fn(() => undefined);
|
|
1038
|
+
const cpSync = vi.fn(() => undefined);
|
|
1039
|
+
const rmSync = vi.fn(() => undefined);
|
|
1040
|
+
const renameSync = vi.fn((src, target) => {
|
|
1041
|
+
if (String(src) === staging) {
|
|
1042
|
+
existingPaths.add(String(target));
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
if (String(src) === dest) {
|
|
1046
|
+
existingPaths.delete(dest);
|
|
1047
|
+
existingPaths.add(String(target));
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
if (String(target) === dest) throw publishErr;
|
|
1051
|
+
if (existingPaths.has(String(src))) {
|
|
1052
|
+
existingPaths.delete(String(src));
|
|
1053
|
+
existingPaths.add(String(target));
|
|
1054
|
+
}
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
expect(() => _replaceDir(staging, dest, { existsSync, mkdirSync, renameSync, cpSync, rmSync })).toThrow(publishErr);
|
|
1058
|
+
|
|
1059
|
+
const tempDest = renameSync.mock.calls[0][1];
|
|
1060
|
+
const backupDest = renameSync.mock.calls[1][1];
|
|
1061
|
+
expect(renameSync).toHaveBeenNthCalledWith(1, staging, tempDest);
|
|
1062
|
+
expect(renameSync).toHaveBeenNthCalledWith(2, dest, backupDest);
|
|
1063
|
+
expect(renameSync).toHaveBeenNthCalledWith(3, tempDest, dest);
|
|
1064
|
+
expect(renameSync).toHaveBeenNthCalledWith(4, backupDest, dest);
|
|
1065
|
+
expect(rmSync).toHaveBeenCalledWith(tempDest, { recursive: true, force: true });
|
|
1066
|
+
});
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
describe('installPlugin transactional staging', () => {
|
|
1070
|
+
const standaloneSource = 'github:user/opencli-plugin-__test-transactional-standalone__';
|
|
1071
|
+
const standaloneName = '__test-transactional-standalone__';
|
|
1072
|
+
const standaloneDir = path.join(PLUGINS_DIR, standaloneName);
|
|
1073
|
+
const monorepoSource = 'github:user/opencli-plugins-__test-transactional__';
|
|
1074
|
+
const monorepoRepoDir = path.join(_getMonoreposDir(), 'opencli-plugins-__test-transactional__');
|
|
1075
|
+
const monorepoLink = path.join(PLUGINS_DIR, 'alpha');
|
|
1076
|
+
|
|
1077
|
+
beforeEach(() => {
|
|
1078
|
+
mockExecFileSync.mockClear();
|
|
1079
|
+
mockExecSync.mockClear();
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
afterEach(() => {
|
|
1083
|
+
try { fs.unlinkSync(monorepoLink); } catch {}
|
|
1084
|
+
try { fs.rmSync(monorepoLink, { recursive: true, force: true }); } catch {}
|
|
1085
|
+
try { fs.rmSync(monorepoRepoDir, { recursive: true, force: true }); } catch {}
|
|
1086
|
+
try { fs.rmSync(standaloneDir, { recursive: true, force: true }); } catch {}
|
|
1087
|
+
const lock = _readLockFile();
|
|
1088
|
+
delete lock[standaloneName];
|
|
1089
|
+
delete lock.alpha;
|
|
1090
|
+
_writeLockFile(lock);
|
|
1091
|
+
vi.clearAllMocks();
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
it('does not expose the final standalone plugin dir when lifecycle fails in staging', () => {
|
|
1095
|
+
mockExecFileSync.mockImplementation((cmd, args) => {
|
|
1096
|
+
if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
|
|
1097
|
+
const cloneDir = String(args[args.length - 1]);
|
|
1098
|
+
fs.mkdirSync(cloneDir, { recursive: true });
|
|
1099
|
+
fs.writeFileSync(path.join(cloneDir, 'hello.yaml'), 'site: test\nname: hello\n');
|
|
1100
|
+
fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ name: standaloneName }));
|
|
1101
|
+
return '';
|
|
1102
|
+
}
|
|
1103
|
+
if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
|
|
1104
|
+
throw new Error('boom');
|
|
1105
|
+
}
|
|
1106
|
+
if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
|
|
1107
|
+
return '1234567890abcdef1234567890abcdef12345678\n';
|
|
1108
|
+
}
|
|
1109
|
+
return '';
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
expect(() => installPlugin(standaloneSource)).toThrow(`npm install failed`);
|
|
1113
|
+
expect(fs.existsSync(standaloneDir)).toBe(false);
|
|
1114
|
+
expect(_readLockFile()[standaloneName]).toBeUndefined();
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
it('does not expose monorepo links or repo dir when lifecycle fails in staging', () => {
|
|
1118
|
+
mockExecFileSync.mockImplementation((cmd, args) => {
|
|
1119
|
+
if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
|
|
1120
|
+
const cloneDir = String(args[args.length - 1]);
|
|
1121
|
+
const alphaDir = path.join(cloneDir, 'packages', 'alpha');
|
|
1122
|
+
fs.mkdirSync(alphaDir, { recursive: true });
|
|
1123
|
+
fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({
|
|
1124
|
+
name: 'opencli-plugins-__test-transactional__',
|
|
1125
|
+
private: true,
|
|
1126
|
+
}));
|
|
1127
|
+
fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
|
|
1128
|
+
plugins: {
|
|
1129
|
+
alpha: { path: 'packages/alpha' },
|
|
1130
|
+
},
|
|
1131
|
+
}));
|
|
1132
|
+
fs.writeFileSync(path.join(alphaDir, 'hello.yaml'), 'site: test\nname: hello\n');
|
|
1133
|
+
return '';
|
|
1134
|
+
}
|
|
1135
|
+
if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
|
|
1136
|
+
throw new Error('boom');
|
|
1137
|
+
}
|
|
1138
|
+
if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
|
|
1139
|
+
return '1234567890abcdef1234567890abcdef12345678\n';
|
|
1140
|
+
}
|
|
1141
|
+
return '';
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
expect(() => installPlugin(monorepoSource)).toThrow(`npm install failed`);
|
|
1145
|
+
expect(fs.existsSync(monorepoRepoDir)).toBe(false);
|
|
1146
|
+
expect(fs.existsSync(monorepoLink)).toBe(false);
|
|
1147
|
+
expect(_readLockFile().alpha).toBeUndefined();
|
|
1148
|
+
});
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
describe('installPlugin with existing monorepo', () => {
|
|
1152
|
+
const repoName = '__test-existing-monorepo__';
|
|
1153
|
+
const repoDir = path.join(_getMonoreposDir(), repoName);
|
|
1154
|
+
const pluginName = 'beta';
|
|
1155
|
+
const pluginLink = path.join(PLUGINS_DIR, pluginName);
|
|
1156
|
+
|
|
1157
|
+
beforeEach(() => {
|
|
1158
|
+
mockExecFileSync.mockClear();
|
|
1159
|
+
mockExecSync.mockClear();
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
afterEach(() => {
|
|
1163
|
+
try { fs.unlinkSync(pluginLink); } catch {}
|
|
1164
|
+
try { fs.rmSync(pluginLink, { recursive: true, force: true }); } catch {}
|
|
1165
|
+
try { fs.rmSync(repoDir, { recursive: true, force: true }); } catch {}
|
|
1166
|
+
const lock = _readLockFile();
|
|
1167
|
+
delete lock[pluginName];
|
|
1168
|
+
_writeLockFile(lock);
|
|
1169
|
+
vi.clearAllMocks();
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
it('reinstalls root dependencies when adding a sub-plugin from an existing monorepo', () => {
|
|
1173
|
+
const subDir = path.join(repoDir, 'packages', pluginName);
|
|
1174
|
+
fs.mkdirSync(subDir, { recursive: true });
|
|
1175
|
+
fs.writeFileSync(path.join(repoDir, 'package.json'), JSON.stringify({
|
|
1176
|
+
name: repoName,
|
|
1177
|
+
private: true,
|
|
1178
|
+
workspaces: ['packages/*'],
|
|
1179
|
+
}));
|
|
1180
|
+
fs.writeFileSync(path.join(repoDir, 'opencli-plugin.json'), JSON.stringify({
|
|
1181
|
+
plugins: {
|
|
1182
|
+
[pluginName]: { path: `packages/${pluginName}` },
|
|
1183
|
+
},
|
|
1184
|
+
}));
|
|
1185
|
+
fs.writeFileSync(path.join(subDir, 'hello.yaml'), 'site: test\nname: hello\n');
|
|
1186
|
+
|
|
1187
|
+
mockExecFileSync.mockImplementation((cmd, args) => {
|
|
1188
|
+
if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
|
|
1189
|
+
const cloneDir = String(args[4]);
|
|
1190
|
+
fs.mkdirSync(cloneDir, { recursive: true });
|
|
1191
|
+
fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
|
|
1192
|
+
plugins: {
|
|
1193
|
+
[pluginName]: { path: `packages/${pluginName}` },
|
|
1194
|
+
},
|
|
1195
|
+
}));
|
|
1196
|
+
return '';
|
|
1197
|
+
}
|
|
1198
|
+
if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
|
|
1199
|
+
return '1234567890abcdef1234567890abcdef12345678\n';
|
|
1200
|
+
}
|
|
1201
|
+
return '';
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
installPlugin(`github:user/${repoName}/${pluginName}`);
|
|
1205
|
+
|
|
1206
|
+
const npmCalls = mockExecFileSync.mock.calls.filter(
|
|
1207
|
+
([cmd, args]) => cmd === 'npm' && Array.isArray(args) && args[0] === 'install',
|
|
1208
|
+
);
|
|
1209
|
+
expect(npmCalls.some(([, , opts]) => opts?.cwd === repoDir)).toBe(true);
|
|
1210
|
+
expect(fs.realpathSync(pluginLink)).toBe(fs.realpathSync(subDir));
|
|
1211
|
+
});
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
describe('updatePlugin transactional staging', () => {
|
|
1215
|
+
const standaloneName = '__test-transactional-update__';
|
|
1216
|
+
const standaloneDir = path.join(PLUGINS_DIR, standaloneName);
|
|
1217
|
+
const monorepoName = '__test-transactional-mono-update__';
|
|
1218
|
+
const monorepoRepoDir = path.join(_getMonoreposDir(), monorepoName);
|
|
1219
|
+
const monorepoPluginName = 'alpha-update';
|
|
1220
|
+
const monorepoLink = path.join(PLUGINS_DIR, monorepoPluginName);
|
|
1221
|
+
|
|
1222
|
+
beforeEach(() => {
|
|
1223
|
+
mockExecFileSync.mockClear();
|
|
1224
|
+
mockExecSync.mockClear();
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
afterEach(() => {
|
|
1228
|
+
try { fs.unlinkSync(monorepoLink); } catch {}
|
|
1229
|
+
try { fs.rmSync(monorepoLink, { recursive: true, force: true }); } catch {}
|
|
1230
|
+
try { fs.rmSync(monorepoRepoDir, { recursive: true, force: true }); } catch {}
|
|
1231
|
+
try { fs.rmSync(standaloneDir, { recursive: true, force: true }); } catch {}
|
|
1232
|
+
const lock = _readLockFile();
|
|
1233
|
+
delete lock[standaloneName];
|
|
1234
|
+
delete lock[monorepoPluginName];
|
|
1235
|
+
_writeLockFile(lock);
|
|
1236
|
+
vi.clearAllMocks();
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
it('keeps the existing standalone plugin when staged update preparation fails', () => {
|
|
1240
|
+
fs.mkdirSync(standaloneDir, { recursive: true });
|
|
1241
|
+
fs.writeFileSync(path.join(standaloneDir, 'old.yaml'), 'site: old\nname: old\n');
|
|
1242
|
+
|
|
1243
|
+
const lock = _readLockFile();
|
|
1244
|
+
lock[standaloneName] = {
|
|
1245
|
+
source: {
|
|
1246
|
+
kind: 'git',
|
|
1247
|
+
url: 'https://github.com/user/opencli-plugin-__test-transactional-update__.git',
|
|
1248
|
+
},
|
|
1249
|
+
commitHash: 'oldhasholdhasholdhasholdhasholdhasholdh',
|
|
1250
|
+
installedAt: '2025-01-01T00:00:00.000Z',
|
|
1251
|
+
};
|
|
1252
|
+
_writeLockFile(lock);
|
|
1253
|
+
|
|
1254
|
+
mockExecFileSync.mockImplementation((cmd, args) => {
|
|
1255
|
+
if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
|
|
1256
|
+
const cloneDir = String(args[4]);
|
|
1257
|
+
fs.mkdirSync(cloneDir, { recursive: true });
|
|
1258
|
+
fs.writeFileSync(path.join(cloneDir, 'hello.yaml'), 'site: test\nname: hello\n');
|
|
1259
|
+
fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ name: standaloneName }));
|
|
1260
|
+
return '';
|
|
1261
|
+
}
|
|
1262
|
+
if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
|
|
1263
|
+
throw new Error('boom');
|
|
1264
|
+
}
|
|
1265
|
+
if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
|
|
1266
|
+
return '1234567890abcdef1234567890abcdef12345678\n';
|
|
1267
|
+
}
|
|
1268
|
+
return '';
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
expect(() => updatePlugin(standaloneName)).toThrow('npm install failed');
|
|
1272
|
+
expect(fs.existsSync(standaloneDir)).toBe(true);
|
|
1273
|
+
expect(fs.readFileSync(path.join(standaloneDir, 'old.yaml'), 'utf-8')).toContain('site: old');
|
|
1274
|
+
expect(_readLockFile()[standaloneName]?.commitHash).toBe('oldhasholdhasholdhasholdhasholdhasholdh');
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
it('keeps the existing monorepo repo and link when staged update preparation fails', () => {
|
|
1278
|
+
const subDir = path.join(monorepoRepoDir, 'packages', monorepoPluginName);
|
|
1279
|
+
fs.mkdirSync(subDir, { recursive: true });
|
|
1280
|
+
fs.writeFileSync(path.join(subDir, 'old.yaml'), 'site: old\nname: old\n');
|
|
1281
|
+
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
1282
|
+
fs.symlinkSync(subDir, monorepoLink, 'dir');
|
|
1283
|
+
|
|
1284
|
+
const lock = _readLockFile();
|
|
1285
|
+
lock[monorepoPluginName] = {
|
|
1286
|
+
source: {
|
|
1287
|
+
kind: 'monorepo',
|
|
1288
|
+
url: 'https://github.com/user/opencli-plugins-__test-transactional-mono-update__.git',
|
|
1289
|
+
repoName: monorepoName,
|
|
1290
|
+
subPath: `packages/${monorepoPluginName}`,
|
|
1291
|
+
},
|
|
1292
|
+
commitHash: 'oldmonooldmonooldmonooldmonooldmonoold',
|
|
1293
|
+
installedAt: '2025-01-01T00:00:00.000Z',
|
|
1294
|
+
};
|
|
1295
|
+
_writeLockFile(lock);
|
|
1296
|
+
|
|
1297
|
+
mockExecFileSync.mockImplementation((cmd, args) => {
|
|
1298
|
+
if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
|
|
1299
|
+
const cloneDir = String(args[4]);
|
|
1300
|
+
const alphaDir = path.join(cloneDir, 'packages', monorepoPluginName);
|
|
1301
|
+
fs.mkdirSync(alphaDir, { recursive: true });
|
|
1302
|
+
fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({
|
|
1303
|
+
name: 'opencli-plugins-__test-transactional-mono-update__',
|
|
1304
|
+
private: true,
|
|
1305
|
+
}));
|
|
1306
|
+
fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
|
|
1307
|
+
plugins: {
|
|
1308
|
+
[monorepoPluginName]: { path: `packages/${monorepoPluginName}` },
|
|
1309
|
+
},
|
|
1310
|
+
}));
|
|
1311
|
+
fs.writeFileSync(path.join(alphaDir, 'hello.yaml'), 'site: test\nname: hello\n');
|
|
1312
|
+
return '';
|
|
1313
|
+
}
|
|
1314
|
+
if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') {
|
|
1315
|
+
throw new Error('boom');
|
|
1316
|
+
}
|
|
1317
|
+
if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
|
|
1318
|
+
return '1234567890abcdef1234567890abcdef12345678\n';
|
|
1319
|
+
}
|
|
1320
|
+
return '';
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
expect(() => updatePlugin(monorepoPluginName)).toThrow('npm install failed');
|
|
1324
|
+
expect(fs.existsSync(monorepoRepoDir)).toBe(true);
|
|
1325
|
+
expect(fs.existsSync(monorepoLink)).toBe(true);
|
|
1326
|
+
expect(fs.readFileSync(path.join(subDir, 'old.yaml'), 'utf-8')).toContain('site: old');
|
|
1327
|
+
expect(_readLockFile()[monorepoPluginName]?.commitHash).toBe('oldmonooldmonooldmonooldmonooldmonoold');
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
it('relinks monorepo plugins when the updated manifest moves their subPath', () => {
|
|
1331
|
+
const oldSubDir = path.join(monorepoRepoDir, 'packages', 'old-alpha');
|
|
1332
|
+
fs.mkdirSync(oldSubDir, { recursive: true });
|
|
1333
|
+
fs.writeFileSync(path.join(oldSubDir, 'old.yaml'), 'site: old\nname: old\n');
|
|
1334
|
+
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
1335
|
+
fs.symlinkSync(oldSubDir, monorepoLink, 'dir');
|
|
1336
|
+
|
|
1337
|
+
const lock = _readLockFile();
|
|
1338
|
+
lock[monorepoPluginName] = {
|
|
1339
|
+
source: {
|
|
1340
|
+
kind: 'monorepo',
|
|
1341
|
+
url: 'https://github.com/user/opencli-plugins-__test-transactional-mono-update__.git',
|
|
1342
|
+
repoName: monorepoName,
|
|
1343
|
+
subPath: 'packages/old-alpha',
|
|
1344
|
+
},
|
|
1345
|
+
commitHash: 'oldmonooldmonooldmonooldmonooldmonoold',
|
|
1346
|
+
installedAt: '2025-01-01T00:00:00.000Z',
|
|
1347
|
+
};
|
|
1348
|
+
_writeLockFile(lock);
|
|
1349
|
+
|
|
1350
|
+
mockExecFileSync.mockImplementation((cmd, args) => {
|
|
1351
|
+
if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
|
|
1352
|
+
const cloneDir = String(args[4]);
|
|
1353
|
+
const movedDir = path.join(cloneDir, 'packages', 'moved-alpha');
|
|
1354
|
+
fs.mkdirSync(movedDir, { recursive: true });
|
|
1355
|
+
fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
|
|
1356
|
+
plugins: {
|
|
1357
|
+
[monorepoPluginName]: { path: 'packages/moved-alpha' },
|
|
1358
|
+
},
|
|
1359
|
+
}));
|
|
1360
|
+
fs.writeFileSync(path.join(movedDir, 'hello.yaml'), 'site: test\nname: hello\n');
|
|
1361
|
+
return '';
|
|
1362
|
+
}
|
|
1363
|
+
if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
|
|
1364
|
+
return '1234567890abcdef1234567890abcdef12345678\n';
|
|
1365
|
+
}
|
|
1366
|
+
return '';
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
updatePlugin(monorepoPluginName);
|
|
1370
|
+
|
|
1371
|
+
const expectedTarget = path.join(monorepoRepoDir, 'packages', 'moved-alpha');
|
|
1372
|
+
expect(fs.realpathSync(monorepoLink)).toBe(fs.realpathSync(expectedTarget));
|
|
1373
|
+
expect(_readLockFile()[monorepoPluginName]?.source).toMatchObject({
|
|
1374
|
+
kind: 'monorepo',
|
|
1375
|
+
subPath: 'packages/moved-alpha',
|
|
1376
|
+
});
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
it('rolls back the monorepo repo swap when relinking fails', () => {
|
|
1380
|
+
const oldSubDir = path.join(monorepoRepoDir, 'packages', 'old-alpha');
|
|
1381
|
+
fs.mkdirSync(oldSubDir, { recursive: true });
|
|
1382
|
+
fs.writeFileSync(path.join(oldSubDir, 'old.yaml'), 'site: old\nname: old\n');
|
|
1383
|
+
fs.mkdirSync(monorepoLink, { recursive: true });
|
|
1384
|
+
fs.writeFileSync(path.join(monorepoLink, 'blocker.txt'), 'not a symlink');
|
|
1385
|
+
|
|
1386
|
+
const lock = _readLockFile();
|
|
1387
|
+
lock[monorepoPluginName] = {
|
|
1388
|
+
source: {
|
|
1389
|
+
kind: 'monorepo',
|
|
1390
|
+
url: 'https://github.com/user/opencli-plugins-__test-transactional-mono-update__.git',
|
|
1391
|
+
repoName: monorepoName,
|
|
1392
|
+
subPath: 'packages/old-alpha',
|
|
1393
|
+
},
|
|
1394
|
+
commitHash: 'oldmonooldmonooldmonooldmonooldmonoold',
|
|
1395
|
+
installedAt: '2025-01-01T00:00:00.000Z',
|
|
1396
|
+
};
|
|
1397
|
+
_writeLockFile(lock);
|
|
1398
|
+
|
|
1399
|
+
mockExecFileSync.mockImplementation((cmd, args) => {
|
|
1400
|
+
if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') {
|
|
1401
|
+
const cloneDir = String(args[4]);
|
|
1402
|
+
const movedDir = path.join(cloneDir, 'packages', 'moved-alpha');
|
|
1403
|
+
fs.mkdirSync(movedDir, { recursive: true });
|
|
1404
|
+
fs.writeFileSync(path.join(cloneDir, 'opencli-plugin.json'), JSON.stringify({
|
|
1405
|
+
plugins: {
|
|
1406
|
+
[monorepoPluginName]: { path: 'packages/moved-alpha' },
|
|
1407
|
+
},
|
|
1408
|
+
}));
|
|
1409
|
+
fs.writeFileSync(path.join(movedDir, 'hello.yaml'), 'site: test\nname: hello\n');
|
|
1410
|
+
return '';
|
|
1411
|
+
}
|
|
1412
|
+
if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') {
|
|
1413
|
+
return '1234567890abcdef1234567890abcdef12345678\n';
|
|
1414
|
+
}
|
|
1415
|
+
return '';
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
expect(() => updatePlugin(monorepoPluginName)).toThrow('to be a symlink');
|
|
1419
|
+
expect(fs.existsSync(path.join(monorepoRepoDir, 'packages', 'old-alpha', 'old.yaml'))).toBe(true);
|
|
1420
|
+
expect(fs.existsSync(path.join(monorepoRepoDir, 'packages', 'moved-alpha'))).toBe(false);
|
|
1421
|
+
expect(fs.readFileSync(path.join(monorepoLink, 'blocker.txt'), 'utf-8')).toBe('not a symlink');
|
|
1422
|
+
expect(_readLockFile()[monorepoPluginName]?.source).toMatchObject({
|
|
1423
|
+
kind: 'monorepo',
|
|
1424
|
+
subPath: 'packages/old-alpha',
|
|
1425
|
+
});
|
|
1426
|
+
});
|
|
1427
|
+
});
|