@2en/clawly-plugins 1.28.1 → 1.29.0-beta.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.
@@ -1,472 +0,0 @@
1
- import {afterEach, beforeEach, describe, expect, test} from 'bun:test'
2
- import fs from 'node:fs'
3
- import os from 'node:os'
4
- import path from 'node:path'
5
-
6
- import {
7
- withPluginBackup,
8
- recoverOrphanedBackup,
9
- cleanOrphanedStagingDirs,
10
- BACKUP_DIR_SUFFIX,
11
- LEGACY_BACKUP_ZIP_SUFFIX,
12
- STAGING_DIR_PREFIX,
13
- } from './plugins'
14
- import type {PluginApi} from '../types'
15
-
16
- const PLUGIN_ID = 'test-plugin'
17
- const SENTINEL = 'openclaw.plugin.json'
18
-
19
- function makeTmpDir(): string {
20
- return fs.mkdtempSync(path.join(os.tmpdir(), 'clawly-plugins-test-'))
21
- }
22
-
23
- function makeApi(stateDir: string): PluginApi {
24
- const logs: string[] = []
25
- return {
26
- id: 'test',
27
- name: 'test',
28
- runtime: {state: {resolveStateDir: () => stateDir}},
29
- logger: {
30
- info: (msg: string) => logs.push(`info: ${msg}`),
31
- warn: (msg: string) => logs.push(`warn: ${msg}`),
32
- error: (msg: string) => logs.push(`error: ${msg}`),
33
- },
34
- registerGatewayMethod: () => {},
35
- registerTool: () => {},
36
- registerCommand: () => {},
37
- registerChannel: () => {},
38
- registerHttpRoute: () => {},
39
- on: () => {},
40
- _logs: logs,
41
- } as unknown as PluginApi & {_logs: string[]}
42
- }
43
-
44
- function setupPluginDir(stateDir: string): string {
45
- const extensionDir = path.join(stateDir, 'extensions', PLUGIN_ID)
46
- fs.mkdirSync(extensionDir, {recursive: true})
47
- fs.writeFileSync(path.join(extensionDir, SENTINEL), '{}')
48
- fs.writeFileSync(path.join(extensionDir, 'package.json'), '{"version":"1.0.0"}')
49
- return extensionDir
50
- }
51
-
52
- /** Simulate openclaw plugins install writing to a staging dir */
53
- function simulateStagedInstall(stagingDir: string, pluginId: string, version: string) {
54
- const stagedPluginDir = path.join(stagingDir, 'extensions', pluginId)
55
- fs.mkdirSync(stagedPluginDir, {recursive: true})
56
- fs.writeFileSync(path.join(stagedPluginDir, SENTINEL), '{}')
57
- fs.writeFileSync(path.join(stagedPluginDir, 'package.json'), JSON.stringify({version}))
58
- }
59
-
60
- let tmpDir: string
61
-
62
- beforeEach(() => {
63
- tmpDir = makeTmpDir()
64
- })
65
-
66
- afterEach(() => {
67
- fs.rmSync(tmpDir, {recursive: true, force: true})
68
- })
69
-
70
- describe('withPluginBackup (staging mode — default)', () => {
71
- test('successful install: original stays live during operation, atomic swap at end', async () => {
72
- const api = makeApi(tmpDir)
73
- const extensionDir = setupPluginDir(tmpDir)
74
- const backupDir = `${extensionDir}${BACKUP_DIR_SUFFIX}`
75
-
76
- const result = await withPluginBackup(api, PLUGIN_ID, async ({stagingDir}) => {
77
- // Original dir should still exist during the operation
78
- expect(fs.existsSync(extensionDir)).toBe(true)
79
- expect(fs.existsSync(path.join(extensionDir, SENTINEL))).toBe(true)
80
- // Staging dir should be provided and exist
81
- expect(stagingDir).toBeDefined()
82
- expect(fs.existsSync(stagingDir!)).toBe(true)
83
- // Staging dir should be under extensions/
84
- expect(stagingDir!.startsWith(path.join(tmpDir, 'extensions'))).toBe(true)
85
-
86
- simulateStagedInstall(stagingDir!, PLUGIN_ID, '2.0.0')
87
- return 'installed'
88
- })
89
-
90
- expect(result).toBe('installed')
91
- expect(fs.existsSync(extensionDir)).toBe(true)
92
- expect(fs.existsSync(backupDir)).toBe(false)
93
- expect(
94
- JSON.parse(fs.readFileSync(path.join(extensionDir, 'package.json'), 'utf-8')).version,
95
- ).toBe('2.0.0')
96
- // Staging dir should be cleaned up
97
- const remainingStaging = fs
98
- .readdirSync(path.join(tmpDir, 'extensions'))
99
- .filter((f) => f.startsWith(STAGING_DIR_PREFIX))
100
- expect(remainingStaging).toHaveLength(0)
101
- })
102
-
103
- test('failed install: original dir untouched, staging dir cleaned up', async () => {
104
- const api = makeApi(tmpDir)
105
- const extensionDir = setupPluginDir(tmpDir)
106
-
107
- await expect(
108
- withPluginBackup(api, PLUGIN_ID, async ({stagingDir}) => {
109
- expect(fs.existsSync(extensionDir)).toBe(true)
110
- expect(stagingDir).toBeDefined()
111
- throw new Error('npm install failed')
112
- }),
113
- ).rejects.toThrow('npm install failed')
114
-
115
- // Original dir should be untouched
116
- expect(fs.existsSync(extensionDir)).toBe(true)
117
- expect(fs.existsSync(path.join(extensionDir, SENTINEL))).toBe(true)
118
- expect(
119
- JSON.parse(fs.readFileSync(path.join(extensionDir, 'package.json'), 'utf-8')).version,
120
- ).toBe('1.0.0')
121
- // No staging dirs left
122
- const remainingStaging = fs
123
- .readdirSync(path.join(tmpDir, 'extensions'))
124
- .filter((f) => f.startsWith(STAGING_DIR_PREFIX))
125
- expect(remainingStaging).toHaveLength(0)
126
- })
127
-
128
- test('missing sentinel in staging: throws, original untouched', async () => {
129
- const api = makeApi(tmpDir)
130
- const extensionDir = setupPluginDir(tmpDir)
131
-
132
- await expect(
133
- withPluginBackup(api, PLUGIN_ID, async ({stagingDir}) => {
134
- // Install to staging but forget sentinel
135
- const stagedPluginDir = path.join(stagingDir!, 'extensions', PLUGIN_ID)
136
- fs.mkdirSync(stagedPluginDir, {recursive: true})
137
- fs.writeFileSync(path.join(stagedPluginDir, 'package.json'), '{"version":"2.0.0"}')
138
- return 'done'
139
- }),
140
- ).rejects.toThrow('plugin files missing after operation')
141
-
142
- expect(fs.existsSync(extensionDir)).toBe(true)
143
- expect(fs.existsSync(path.join(extensionDir, SENTINEL))).toBe(true)
144
- })
145
-
146
- test('first-time install (no existing dir): staging works without backup', async () => {
147
- const api = makeApi(tmpDir)
148
- const extensionDir = path.join(tmpDir, 'extensions', PLUGIN_ID)
149
- fs.mkdirSync(path.join(tmpDir, 'extensions'), {recursive: true})
150
-
151
- const result = await withPluginBackup(api, PLUGIN_ID, async ({stagingDir}) => {
152
- expect(fs.existsSync(extensionDir)).toBe(false)
153
- simulateStagedInstall(stagingDir!, PLUGIN_ID, '1.0.0')
154
- return 'first install'
155
- })
156
-
157
- expect(result).toBe('first install')
158
- expect(fs.existsSync(path.join(extensionDir, SENTINEL))).toBe(true)
159
- })
160
-
161
- test('first-time install failure: no leftover dirs', async () => {
162
- const api = makeApi(tmpDir)
163
- fs.mkdirSync(path.join(tmpDir, 'extensions'), {recursive: true})
164
-
165
- await expect(
166
- withPluginBackup(api, PLUGIN_ID, async () => {
167
- throw new Error('install failed')
168
- }),
169
- ).rejects.toThrow('install failed')
170
-
171
- const extensionDir = path.join(tmpDir, 'extensions', PLUGIN_ID)
172
- expect(fs.existsSync(extensionDir)).toBe(false)
173
- const remainingStaging = fs
174
- .readdirSync(path.join(tmpDir, 'extensions'))
175
- .filter((f) => f.startsWith(STAGING_DIR_PREFIX))
176
- expect(remainingStaging).toHaveLength(0)
177
- })
178
-
179
- test('staging dir is on same filesystem as extensions/', async () => {
180
- const api = makeApi(tmpDir)
181
- setupPluginDir(tmpDir)
182
- const extensionsBase = path.join(tmpDir, 'extensions')
183
-
184
- await withPluginBackup(api, PLUGIN_ID, async ({stagingDir}) => {
185
- // Verify staging dir is a sibling inside extensions/
186
- expect(path.dirname(stagingDir!)).toBe(extensionsBase)
187
- simulateStagedInstall(stagingDir!, PLUGIN_ID, '2.0.0')
188
- return 'ok'
189
- })
190
- })
191
- })
192
-
193
- describe('withPluginBackup (in-place mode — staging: false)', () => {
194
- test('preserves original dir for in-place update', async () => {
195
- const api = makeApi(tmpDir)
196
- const extensionDir = setupPluginDir(tmpDir)
197
- const backupDir = `${extensionDir}${BACKUP_DIR_SUFFIX}`
198
-
199
- const result = await withPluginBackup(
200
- api,
201
- PLUGIN_ID,
202
- async () => {
203
- // Original dir should still exist (copied, not renamed)
204
- expect(fs.existsSync(extensionDir)).toBe(true)
205
- expect(fs.existsSync(backupDir)).toBe(true)
206
- // Simulate in-place update
207
- fs.writeFileSync(path.join(extensionDir, 'package.json'), '{"version":"2.0.0"}')
208
- return 'updated'
209
- },
210
- {staging: false},
211
- )
212
-
213
- expect(result).toBe('updated')
214
- expect(fs.existsSync(backupDir)).toBe(false)
215
- expect(
216
- JSON.parse(fs.readFileSync(path.join(extensionDir, 'package.json'), 'utf-8')).version,
217
- ).toBe('2.0.0')
218
- })
219
-
220
- test('first install failure: cleans up partial extensionDir', async () => {
221
- const api = makeApi(tmpDir)
222
- const extensionDir = path.join(tmpDir, 'extensions', PLUGIN_ID)
223
- fs.mkdirSync(path.join(tmpDir, 'extensions'), {recursive: true})
224
-
225
- await expect(
226
- withPluginBackup(
227
- api,
228
- PLUGIN_ID,
229
- async () => {
230
- // Simulate partial install — dir created but operation fails
231
- fs.mkdirSync(extensionDir, {recursive: true})
232
- fs.writeFileSync(path.join(extensionDir, 'package.json'), '{"version":"1.0.0"}')
233
- throw new Error('install failed')
234
- },
235
- {staging: false},
236
- ),
237
- ).rejects.toThrow('install failed')
238
-
239
- // Partial dir should be cleaned up
240
- expect(fs.existsSync(extensionDir)).toBe(false)
241
- })
242
-
243
- test('rollback on success-without-sentinel', async () => {
244
- const api = makeApi(tmpDir)
245
- const extensionDir = setupPluginDir(tmpDir)
246
- const backupDir = `${extensionDir}${BACKUP_DIR_SUFFIX}`
247
-
248
- await expect(
249
- withPluginBackup(
250
- api,
251
- PLUGIN_ID,
252
- async () => {
253
- // Operation succeeds but removes sentinel
254
- fs.unlinkSync(path.join(extensionDir, SENTINEL))
255
- return 'done'
256
- },
257
- {staging: false},
258
- ),
259
- ).rejects.toThrow('plugin files missing after operation')
260
-
261
- // Should be rolled back to original (backup had sentinel)
262
- expect(fs.existsSync(path.join(extensionDir, SENTINEL))).toBe(true)
263
- expect(fs.existsSync(backupDir)).toBe(false)
264
- })
265
-
266
- test('rollback restores original on failure', async () => {
267
- const api = makeApi(tmpDir)
268
- const extensionDir = setupPluginDir(tmpDir)
269
-
270
- await expect(
271
- withPluginBackup(
272
- api,
273
- PLUGIN_ID,
274
- async () => {
275
- // Corrupt the dir
276
- fs.unlinkSync(path.join(extensionDir, SENTINEL))
277
- throw new Error('update failed')
278
- },
279
- {staging: false},
280
- ),
281
- ).rejects.toThrow('update failed')
282
-
283
- // Should be rolled back to original
284
- expect(fs.existsSync(path.join(extensionDir, SENTINEL))).toBe(true)
285
- expect(
286
- JSON.parse(fs.readFileSync(path.join(extensionDir, 'package.json'), 'utf-8')).version,
287
- ).toBe('1.0.0')
288
- })
289
- })
290
-
291
- describe('withPluginBackup (no stateDir)', () => {
292
- test('passes through to operation', async () => {
293
- const api = makeApi(tmpDir)
294
- ;(api.runtime.state as any).resolveStateDir = () => ''
295
-
296
- const result = await withPluginBackup(api, PLUGIN_ID, async () => 'passthrough')
297
- expect(result).toBe('passthrough')
298
- })
299
- })
300
-
301
- describe('recoverOrphanedBackup', () => {
302
- test('recovers from orphaned backup dir when plugin dir is missing', async () => {
303
- const api = makeApi(tmpDir)
304
- const extensionDir = path.join(tmpDir, 'extensions', PLUGIN_ID)
305
- const backupDir = `${extensionDir}${BACKUP_DIR_SUFFIX}`
306
-
307
- // Create orphaned backup (as if update was interrupted)
308
- fs.mkdirSync(backupDir, {recursive: true})
309
- fs.writeFileSync(path.join(backupDir, SENTINEL), '{}')
310
- fs.writeFileSync(path.join(backupDir, 'package.json'), '{"version":"1.0.0"}')
311
-
312
- await recoverOrphanedBackup(api, PLUGIN_ID)
313
-
314
- expect(fs.existsSync(extensionDir)).toBe(true)
315
- expect(fs.existsSync(path.join(extensionDir, SENTINEL))).toBe(true)
316
- expect(fs.existsSync(backupDir)).toBe(false)
317
- })
318
-
319
- test('warns when recovered dir is missing sentinel', async () => {
320
- const api = makeApi(tmpDir) as PluginApi & {_logs: string[]}
321
- const extensionDir = path.join(tmpDir, 'extensions', PLUGIN_ID)
322
- const backupDir = `${extensionDir}${BACKUP_DIR_SUFFIX}`
323
-
324
- // Create orphaned backup without sentinel
325
- fs.mkdirSync(backupDir, {recursive: true})
326
- fs.writeFileSync(path.join(backupDir, 'package.json'), '{"version":"1.0.0"}')
327
-
328
- await recoverOrphanedBackup(api, PLUGIN_ID)
329
-
330
- expect(fs.existsSync(extensionDir)).toBe(true)
331
- expect(fs.existsSync(backupDir)).toBe(false)
332
- expect(api._logs.some((l: string) => l.includes('missing sentinel'))).toBe(true)
333
- })
334
-
335
- test('cleans up stale backup when plugin dir is healthy', async () => {
336
- const api = makeApi(tmpDir)
337
- const extensionDir = setupPluginDir(tmpDir)
338
- const backupDir = `${extensionDir}${BACKUP_DIR_SUFFIX}`
339
-
340
- // Create stale backup alongside healthy plugin
341
- fs.mkdirSync(backupDir, {recursive: true})
342
- fs.writeFileSync(path.join(backupDir, SENTINEL), '{}')
343
-
344
- await recoverOrphanedBackup(api, PLUGIN_ID)
345
-
346
- expect(fs.existsSync(extensionDir)).toBe(true)
347
- expect(fs.existsSync(backupDir)).toBe(false)
348
- })
349
-
350
- test('cleans up stale legacy zip when plugin dir is healthy', async () => {
351
- const api = makeApi(tmpDir)
352
- const extensionDir = setupPluginDir(tmpDir)
353
- const legacyZip = `${extensionDir}${LEGACY_BACKUP_ZIP_SUFFIX}`
354
-
355
- fs.writeFileSync(legacyZip, 'fake-zip-data')
356
-
357
- await recoverOrphanedBackup(api, PLUGIN_ID)
358
-
359
- expect(fs.existsSync(extensionDir)).toBe(true)
360
- expect(fs.existsSync(legacyZip)).toBe(false)
361
- })
362
-
363
- test('no-op when no backups and plugin is healthy', async () => {
364
- const api = makeApi(tmpDir) as PluginApi & {_logs: string[]}
365
- setupPluginDir(tmpDir)
366
-
367
- await recoverOrphanedBackup(api, PLUGIN_ID)
368
-
369
- // No recovery logs
370
- expect((api as any)._logs.filter((l: string) => l.includes('recover'))).toHaveLength(0)
371
- })
372
-
373
- test('no-op when plugin dir missing and no backups exist', async () => {
374
- const api = makeApi(tmpDir)
375
- fs.mkdirSync(path.join(tmpDir, 'extensions'), {recursive: true})
376
-
377
- await recoverOrphanedBackup(api, PLUGIN_ID)
378
-
379
- const extensionDir = path.join(tmpDir, 'extensions', PLUGIN_ID)
380
- expect(fs.existsSync(extensionDir)).toBe(false)
381
- })
382
-
383
- test('cleans orphaned staging dirs at startup', async () => {
384
- const api = makeApi(tmpDir)
385
- setupPluginDir(tmpDir)
386
- const extensionsBase = path.join(tmpDir, 'extensions')
387
-
388
- // Create orphaned staging dirs
389
- const staging1 = path.join(extensionsBase, `${STAGING_DIR_PREFIX}${PLUGIN_ID}-aabbccdd`)
390
- const staging2 = path.join(extensionsBase, `${STAGING_DIR_PREFIX}${PLUGIN_ID}-11223344`)
391
- fs.mkdirSync(path.join(staging1, 'extensions', PLUGIN_ID), {recursive: true})
392
- fs.writeFileSync(path.join(staging1, 'openclaw.json'), '{}')
393
- fs.mkdirSync(staging2, {recursive: true})
394
-
395
- await recoverOrphanedBackup(api, PLUGIN_ID)
396
-
397
- expect(fs.existsSync(staging1)).toBe(false)
398
- expect(fs.existsSync(staging2)).toBe(false)
399
- })
400
-
401
- test('no-op when no stateDir', async () => {
402
- const api = makeApi(tmpDir)
403
- ;(api.runtime.state as any).resolveStateDir = () => ''
404
-
405
- // Should not throw
406
- await recoverOrphanedBackup(api, PLUGIN_ID)
407
- })
408
- })
409
-
410
- describe('cleanOrphanedStagingDirs', () => {
411
- test('removes staging dirs for the specified plugin only', () => {
412
- const api = makeApi(tmpDir)
413
- const extensionsBase = path.join(tmpDir, 'extensions')
414
- fs.mkdirSync(extensionsBase, {recursive: true})
415
-
416
- // Create staging dirs for our plugin and another plugin
417
- const ours = path.join(extensionsBase, `${STAGING_DIR_PREFIX}${PLUGIN_ID}-aabb1122`)
418
- const other = path.join(extensionsBase, `${STAGING_DIR_PREFIX}other-plugin-ccdd3344`)
419
- fs.mkdirSync(ours, {recursive: true})
420
- fs.mkdirSync(other, {recursive: true})
421
-
422
- cleanOrphanedStagingDirs(api, PLUGIN_ID)
423
-
424
- expect(fs.existsSync(ours)).toBe(false)
425
- expect(fs.existsSync(other)).toBe(true)
426
- })
427
-
428
- test('does not delete staging dirs of plugin whose ID is a superstring', () => {
429
- const api = makeApi(tmpDir)
430
- const extensionsBase = path.join(tmpDir, 'extensions')
431
- fs.mkdirSync(extensionsBase, {recursive: true})
432
-
433
- // "test-plugin" should NOT match "test-plugin-extra"
434
- const ours = path.join(extensionsBase, `${STAGING_DIR_PREFIX}${PLUGIN_ID}-aabb1122`)
435
- const longerPlugin = path.join(
436
- extensionsBase,
437
- `${STAGING_DIR_PREFIX}${PLUGIN_ID}-extra-ccdd3344`,
438
- )
439
- fs.mkdirSync(ours, {recursive: true})
440
- fs.mkdirSync(longerPlugin, {recursive: true})
441
-
442
- cleanOrphanedStagingDirs(api, PLUGIN_ID)
443
-
444
- expect(fs.existsSync(ours)).toBe(false)
445
- // longer plugin name must survive — it's a different plugin
446
- expect(fs.existsSync(longerPlugin)).toBe(true)
447
- })
448
-
449
- test('staging install cleans previous orphaned staging dirs', async () => {
450
- const api = makeApi(tmpDir)
451
- setupPluginDir(tmpDir)
452
- const extensionsBase = path.join(tmpDir, 'extensions')
453
-
454
- // Create an orphaned staging dir from a previous interrupted attempt
455
- const orphaned = path.join(extensionsBase, `${STAGING_DIR_PREFIX}${PLUGIN_ID}-deadbeef`)
456
- fs.mkdirSync(path.join(orphaned, 'extensions', PLUGIN_ID), {recursive: true})
457
- fs.writeFileSync(path.join(orphaned, 'openclaw.json'), '{}')
458
-
459
- await withPluginBackup(api, PLUGIN_ID, async ({stagingDir}) => {
460
- // Orphaned dir should already be cleaned up
461
- expect(fs.existsSync(orphaned)).toBe(false)
462
- // But our new staging dir should exist
463
- expect(fs.existsSync(stagingDir!)).toBe(true)
464
- simulateStagedInstall(stagingDir!, PLUGIN_ID, '2.0.0')
465
- return 'ok'
466
- })
467
-
468
- // No staging dirs left
469
- const remaining = fs.readdirSync(extensionsBase).filter((f) => f.startsWith(STAGING_DIR_PREFIX))
470
- expect(remaining).toHaveLength(0)
471
- })
472
- })