@2en/clawly-plugins 1.29.0-beta.0 → 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.
- package/gateway/plugins.ts +16 -373
- package/gateway/presence.ts +1 -4
- package/lib/manualPluginInstall.test.ts +121 -0
- package/lib/manualPluginInstall.ts +148 -0
- package/openclaw.plugin.json +1 -0
- package/outbound.ts +12 -0
- package/package.json +3 -2
- package/skills/read-office-file/SKILL.md +41 -0
- package/gateway/plugins.test.ts +0 -472
package/gateway/plugins.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {$} from 'zx'
|
|
|
13
13
|
|
|
14
14
|
import type {PluginApi} from '../types'
|
|
15
15
|
import {LruCache} from '../lib/lruCache'
|
|
16
|
+
import {manualPluginInstall} from '../lib/manualPluginInstall'
|
|
16
17
|
import {captureEvent} from './posthog'
|
|
17
18
|
import {isUpdateAvailable} from '../lib/semver'
|
|
18
19
|
import {stripCliLogs} from '../lib/stripCliLogs'
|
|
@@ -61,301 +62,6 @@ async function fetchNpmView(npmPkgName: string): Promise<NpmViewCache | null> {
|
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
const BACKUP_DIR_SUFFIX = '.update-backup'
|
|
65
|
-
const LEGACY_BACKUP_ZIP_SUFFIX = '.update-backup.zip'
|
|
66
|
-
const STAGING_DIR_PREFIX = '.staging-'
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Back up plugin dir before an install/update operation, verify the sentinel
|
|
70
|
-
* file exists after, and rollback from backup on failure.
|
|
71
|
-
*
|
|
72
|
-
* Two modes:
|
|
73
|
-
* - **staging** (default): Install to a staging dir under extensions/, then do
|
|
74
|
-
* two fast renames to swap dirs. The plugin dir is only missing for
|
|
75
|
-
* microseconds between renames, not for the entire npm install duration.
|
|
76
|
-
* - **in-place** (`staging: false`): Copy-based backup for in-place operations
|
|
77
|
-
* like `openclaw plugins update`. The original dir stays live during the
|
|
78
|
-
* operation; rollback restores from the copy.
|
|
79
|
-
*/
|
|
80
|
-
async function withPluginBackup<T>(
|
|
81
|
-
api: PluginApi,
|
|
82
|
-
pluginId: string,
|
|
83
|
-
operation: (ctx: {stagingDir?: string}) => Promise<T>,
|
|
84
|
-
opts?: {staging?: boolean},
|
|
85
|
-
): Promise<T> {
|
|
86
|
-
const stateDir = api.runtime.state.resolveStateDir()
|
|
87
|
-
if (!stateDir) return operation({})
|
|
88
|
-
|
|
89
|
-
const extensionsBase = path.join(stateDir, 'extensions')
|
|
90
|
-
const extensionDir = path.join(extensionsBase, pluginId)
|
|
91
|
-
const backupDir = `${extensionDir}${BACKUP_DIR_SUFFIX}`
|
|
92
|
-
const useStaging = opts?.staging !== false
|
|
93
|
-
|
|
94
|
-
if (useStaging) {
|
|
95
|
-
return withStagingInstall(
|
|
96
|
-
api,
|
|
97
|
-
{stateDir, extensionsBase, extensionDir, backupDir, pluginId},
|
|
98
|
-
operation,
|
|
99
|
-
)
|
|
100
|
-
}
|
|
101
|
-
return withInPlaceBackup(api, {extensionDir, backupDir, pluginId}, operation)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
async function withStagingInstall<T>(
|
|
105
|
-
api: PluginApi,
|
|
106
|
-
dirs: {
|
|
107
|
-
stateDir: string
|
|
108
|
-
extensionsBase: string
|
|
109
|
-
extensionDir: string
|
|
110
|
-
backupDir: string
|
|
111
|
-
pluginId: string
|
|
112
|
-
},
|
|
113
|
-
operation: (ctx: {stagingDir?: string}) => Promise<T>,
|
|
114
|
-
): Promise<T> {
|
|
115
|
-
const {extensionsBase, extensionDir, backupDir, pluginId} = dirs
|
|
116
|
-
|
|
117
|
-
// Clean any orphaned staging dirs from previous interrupted attempts
|
|
118
|
-
cleanOrphanedStagingDirs(api, pluginId)
|
|
119
|
-
|
|
120
|
-
const suffix = crypto.randomBytes(4).toString('hex')
|
|
121
|
-
const stagingDir = path.join(extensionsBase, `${STAGING_DIR_PREFIX}${pluginId}-${suffix}`)
|
|
122
|
-
|
|
123
|
-
// Create staging dir (same filesystem as extensions/ → renameSync is atomic)
|
|
124
|
-
fs.mkdirSync(stagingDir, {recursive: true})
|
|
125
|
-
|
|
126
|
-
try {
|
|
127
|
-
const result = await operation({stagingDir})
|
|
128
|
-
|
|
129
|
-
// Verify sentinel in staging output
|
|
130
|
-
const stagedPluginDir = path.join(stagingDir, 'extensions', pluginId)
|
|
131
|
-
if (!fs.existsSync(path.join(stagedPluginDir, PLUGIN_SENTINEL))) {
|
|
132
|
-
throw new Error(
|
|
133
|
-
`plugin files missing after operation (${PLUGIN_SENTINEL} not found in staging)`,
|
|
134
|
-
)
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Atomic swap: rename existing → backup, then staging → final
|
|
138
|
-
const hasExisting = fs.existsSync(path.join(extensionDir, PLUGIN_SENTINEL))
|
|
139
|
-
if (hasExisting) {
|
|
140
|
-
if (fs.existsSync(backupDir)) fs.rmSync(backupDir, {recursive: true, force: true})
|
|
141
|
-
fs.renameSync(extensionDir, backupDir)
|
|
142
|
-
} else if (fs.existsSync(extensionDir)) {
|
|
143
|
-
// Broken dir without sentinel — remove it
|
|
144
|
-
fs.rmSync(extensionDir, {recursive: true, force: true})
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
fs.renameSync(stagedPluginDir, extensionDir)
|
|
148
|
-
api.logger.info(`plugins: staged install of ${pluginId} swapped in`)
|
|
149
|
-
|
|
150
|
-
// Cleanup staging dir + backup — failure must not trigger rollback
|
|
151
|
-
try {
|
|
152
|
-
fs.rmSync(stagingDir, {recursive: true, force: true})
|
|
153
|
-
} catch {}
|
|
154
|
-
try {
|
|
155
|
-
if (fs.existsSync(backupDir)) fs.rmSync(backupDir, {recursive: true, force: true})
|
|
156
|
-
} catch (cleanupErr) {
|
|
157
|
-
api.logger.warn(
|
|
158
|
-
`plugins: failed to clean backup dir — ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`,
|
|
159
|
-
)
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
return result
|
|
163
|
-
} catch (err) {
|
|
164
|
-
// Clean up staging dir on failure — original is untouched
|
|
165
|
-
try {
|
|
166
|
-
if (fs.existsSync(stagingDir)) fs.rmSync(stagingDir, {recursive: true, force: true})
|
|
167
|
-
} catch {}
|
|
168
|
-
|
|
169
|
-
// If we already renamed the original away (crash between step 3 and 4),
|
|
170
|
-
// the backup exists — restore it
|
|
171
|
-
if (fs.existsSync(backupDir) && !fs.existsSync(path.join(extensionDir, PLUGIN_SENTINEL))) {
|
|
172
|
-
api.logger.warn(`plugins: rolling back ${pluginId} from backup`)
|
|
173
|
-
try {
|
|
174
|
-
if (fs.existsSync(extensionDir)) fs.rmSync(extensionDir, {recursive: true, force: true})
|
|
175
|
-
fs.renameSync(backupDir, extensionDir)
|
|
176
|
-
api.logger.info(`plugins: rollback complete`)
|
|
177
|
-
} catch (rollbackErr) {
|
|
178
|
-
api.logger.error(
|
|
179
|
-
`plugins: rollback failed — ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
|
|
180
|
-
)
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
throw err
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
async function withInPlaceBackup<T>(
|
|
188
|
-
api: PluginApi,
|
|
189
|
-
dirs: {extensionDir: string; backupDir: string; pluginId: string},
|
|
190
|
-
operation: (ctx: {stagingDir?: string}) => Promise<T>,
|
|
191
|
-
): Promise<T> {
|
|
192
|
-
const {extensionDir, backupDir, pluginId} = dirs
|
|
193
|
-
|
|
194
|
-
const hasExisting = fs.existsSync(path.join(extensionDir, PLUGIN_SENTINEL))
|
|
195
|
-
if (hasExisting) {
|
|
196
|
-
try {
|
|
197
|
-
if (fs.existsSync(backupDir)) fs.rmSync(backupDir, {recursive: true, force: true})
|
|
198
|
-
fs.cpSync(extensionDir, backupDir, {recursive: true})
|
|
199
|
-
api.logger.info(`plugins: backed up ${pluginId} (in-place)`)
|
|
200
|
-
} catch (err) {
|
|
201
|
-
throw new Error(
|
|
202
|
-
`plugin backup failed, aborting update: ${err instanceof Error ? err.message : String(err)}`,
|
|
203
|
-
)
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
try {
|
|
208
|
-
const result = await operation({})
|
|
209
|
-
|
|
210
|
-
if (!fs.existsSync(path.join(extensionDir, PLUGIN_SENTINEL))) {
|
|
211
|
-
if (!hasExisting && fs.existsSync(extensionDir)) {
|
|
212
|
-
try {
|
|
213
|
-
fs.rmSync(extensionDir, {recursive: true, force: true})
|
|
214
|
-
} catch {}
|
|
215
|
-
}
|
|
216
|
-
throw new Error(`plugin files missing after operation (${PLUGIN_SENTINEL} not found)`)
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// Clean backup on success
|
|
220
|
-
try {
|
|
221
|
-
if (fs.existsSync(backupDir)) fs.rmSync(backupDir, {recursive: true, force: true})
|
|
222
|
-
} catch (cleanupErr) {
|
|
223
|
-
api.logger.warn(
|
|
224
|
-
`plugins: failed to clean backup dir — ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`,
|
|
225
|
-
)
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
return result
|
|
229
|
-
} catch (err) {
|
|
230
|
-
// Clean up partial install on first-time failure (no backup to restore)
|
|
231
|
-
if (!hasExisting && fs.existsSync(extensionDir)) {
|
|
232
|
-
try {
|
|
233
|
-
fs.rmSync(extensionDir, {recursive: true, force: true})
|
|
234
|
-
} catch {}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
if (fs.existsSync(backupDir)) {
|
|
238
|
-
api.logger.warn(`plugins: rolling back ${pluginId} from backup`)
|
|
239
|
-
try {
|
|
240
|
-
if (fs.existsSync(extensionDir)) fs.rmSync(extensionDir, {recursive: true, force: true})
|
|
241
|
-
fs.renameSync(backupDir, extensionDir)
|
|
242
|
-
api.logger.info(`plugins: rollback complete`)
|
|
243
|
-
} catch (rollbackErr) {
|
|
244
|
-
api.logger.error(
|
|
245
|
-
`plugins: rollback failed — ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
|
|
246
|
-
)
|
|
247
|
-
}
|
|
248
|
-
} else {
|
|
249
|
-
const legacyZip = `${extensionDir}${LEGACY_BACKUP_ZIP_SUFFIX}`
|
|
250
|
-
if (fs.existsSync(legacyZip)) {
|
|
251
|
-
api.logger.warn(`plugins: rolling back ${pluginId} from legacy zip backup`)
|
|
252
|
-
try {
|
|
253
|
-
if (fs.existsSync(extensionDir)) fs.rmSync(extensionDir, {recursive: true, force: true})
|
|
254
|
-
await $`unzip -qo ${legacyZip} -d ${path.dirname(extensionDir)}`
|
|
255
|
-
fs.unlinkSync(legacyZip)
|
|
256
|
-
api.logger.info(`plugins: rollback from legacy zip complete`)
|
|
257
|
-
} catch (rollbackErr) {
|
|
258
|
-
api.logger.error(
|
|
259
|
-
`plugins: legacy zip rollback failed — ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
|
|
260
|
-
)
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
throw err
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* Clean up orphaned staging dirs left by interrupted installs.
|
|
270
|
-
* Staging dirs are named `.staging-<pluginId>-<hex>` under extensions/.
|
|
271
|
-
*/
|
|
272
|
-
function cleanOrphanedStagingDirs(api: PluginApi, pluginId: string): void {
|
|
273
|
-
const stateDir = api.runtime.state.resolveStateDir()
|
|
274
|
-
if (!stateDir) return
|
|
275
|
-
|
|
276
|
-
const extensionsBase = path.join(stateDir, 'extensions')
|
|
277
|
-
try {
|
|
278
|
-
const entries = fs.readdirSync(extensionsBase)
|
|
279
|
-
const pattern = new RegExp(
|
|
280
|
-
`^${STAGING_DIR_PREFIX}${pluginId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-[0-9a-f]{8}$`,
|
|
281
|
-
)
|
|
282
|
-
for (const entry of entries) {
|
|
283
|
-
if (pattern.test(entry)) {
|
|
284
|
-
try {
|
|
285
|
-
fs.rmSync(path.join(extensionsBase, entry), {recursive: true, force: true})
|
|
286
|
-
api.logger.info(`plugins: cleaned up orphaned staging dir ${entry}`)
|
|
287
|
-
} catch {}
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
} catch {}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* Recover from orphaned backups left by interrupted updates.
|
|
295
|
-
* Called during plugin registration (startup).
|
|
296
|
-
*/
|
|
297
|
-
async function recoverOrphanedBackup(api: PluginApi, pluginId: string): Promise<void> {
|
|
298
|
-
const stateDir = api.runtime.state.resolveStateDir()
|
|
299
|
-
if (!stateDir) return
|
|
300
|
-
|
|
301
|
-
// Clean orphaned staging dirs first
|
|
302
|
-
cleanOrphanedStagingDirs(api, pluginId)
|
|
303
|
-
|
|
304
|
-
const extensionDir = path.join(stateDir, 'extensions', pluginId)
|
|
305
|
-
const backupDir = `${extensionDir}${BACKUP_DIR_SUFFIX}`
|
|
306
|
-
const legacyZip = `${extensionDir}${LEGACY_BACKUP_ZIP_SUFFIX}`
|
|
307
|
-
const sentinelExists = fs.existsSync(path.join(extensionDir, PLUGIN_SENTINEL))
|
|
308
|
-
|
|
309
|
-
if (sentinelExists) {
|
|
310
|
-
// Plugin dir is healthy — clean up any stale backups
|
|
311
|
-
if (fs.existsSync(backupDir)) {
|
|
312
|
-
try {
|
|
313
|
-
fs.rmSync(backupDir, {recursive: true, force: true})
|
|
314
|
-
api.logger.info(`plugins: cleaned up stale backup dir for ${pluginId}`)
|
|
315
|
-
} catch {}
|
|
316
|
-
}
|
|
317
|
-
if (fs.existsSync(legacyZip)) {
|
|
318
|
-
try {
|
|
319
|
-
fs.unlinkSync(legacyZip)
|
|
320
|
-
api.logger.info(`plugins: cleaned up stale legacy backup zip for ${pluginId}`)
|
|
321
|
-
} catch {}
|
|
322
|
-
}
|
|
323
|
-
return
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// Plugin dir is missing or broken — try to recover from backup
|
|
327
|
-
if (fs.existsSync(backupDir)) {
|
|
328
|
-
api.logger.warn(`plugins: recovering ${pluginId} from orphaned backup dir`)
|
|
329
|
-
try {
|
|
330
|
-
if (fs.existsSync(extensionDir)) fs.rmSync(extensionDir, {recursive: true, force: true})
|
|
331
|
-
fs.renameSync(backupDir, extensionDir)
|
|
332
|
-
if (!fs.existsSync(path.join(extensionDir, PLUGIN_SENTINEL))) {
|
|
333
|
-
api.logger.warn(
|
|
334
|
-
`plugins: recovered dir for ${pluginId} is missing sentinel — plugin may still be broken`,
|
|
335
|
-
)
|
|
336
|
-
} else {
|
|
337
|
-
api.logger.info(`plugins: recovery from orphaned backup complete`)
|
|
338
|
-
}
|
|
339
|
-
} catch (err) {
|
|
340
|
-
api.logger.error(
|
|
341
|
-
`plugins: orphaned backup recovery failed — ${err instanceof Error ? err.message : String(err)}`,
|
|
342
|
-
)
|
|
343
|
-
}
|
|
344
|
-
} else if (fs.existsSync(legacyZip)) {
|
|
345
|
-
api.logger.warn(`plugins: recovering ${pluginId} from orphaned legacy zip backup`)
|
|
346
|
-
try {
|
|
347
|
-
if (fs.existsSync(extensionDir)) fs.rmSync(extensionDir, {recursive: true, force: true})
|
|
348
|
-
await $`unzip -qo ${legacyZip} -d ${path.join(stateDir, 'extensions')}`
|
|
349
|
-
fs.unlinkSync(legacyZip)
|
|
350
|
-
api.logger.info(`plugins: recovery from orphaned legacy zip complete`)
|
|
351
|
-
} catch (err) {
|
|
352
|
-
api.logger.error(
|
|
353
|
-
`plugins: legacy zip recovery failed — ${err instanceof Error ? err.message : String(err)}`,
|
|
354
|
-
)
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
65
|
interface UpdateParams {
|
|
360
66
|
pluginId: string
|
|
361
67
|
npmPkgName: string
|
|
@@ -372,24 +78,7 @@ interface UpdateParams {
|
|
|
372
78
|
skipIfCurrent?: boolean
|
|
373
79
|
}
|
|
374
80
|
|
|
375
|
-
// Exported for testing
|
|
376
|
-
export {
|
|
377
|
-
withPluginBackup,
|
|
378
|
-
recoverOrphanedBackup,
|
|
379
|
-
cleanOrphanedStagingDirs,
|
|
380
|
-
BACKUP_DIR_SUFFIX,
|
|
381
|
-
LEGACY_BACKUP_ZIP_SUFFIX,
|
|
382
|
-
STAGING_DIR_PREFIX,
|
|
383
|
-
}
|
|
384
|
-
|
|
385
81
|
export function registerPlugins(api: PluginApi) {
|
|
386
|
-
// ── Startup recovery for orphaned backups ──────────────────────
|
|
387
|
-
void recoverOrphanedBackup(api, 'clawly-plugins').catch((err) => {
|
|
388
|
-
api.logger.error(
|
|
389
|
-
`plugins: orphaned backup recovery crashed — ${err instanceof Error ? err.message : String(err)}`,
|
|
390
|
-
)
|
|
391
|
-
})
|
|
392
|
-
|
|
393
82
|
// ── clawly.plugins.version ──────────────────────────────────────
|
|
394
83
|
|
|
395
84
|
api.registerGatewayMethod('clawly.plugins.version', async ({params, respond}) => {
|
|
@@ -524,69 +213,23 @@ export function registerPlugins(api: PluginApi) {
|
|
|
524
213
|
try {
|
|
525
214
|
let output = ''
|
|
526
215
|
|
|
527
|
-
if (
|
|
528
|
-
if (!stateDir) throw new Error('cannot resolve openclaw state dir')
|
|
216
|
+
if (!stateDir) throw new Error('cannot resolve openclaw state dir')
|
|
529
217
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
if (entry && typeof entry === 'object') {
|
|
538
|
-
savedEntry = entry as Record<string, unknown>
|
|
539
|
-
}
|
|
540
|
-
} catch {
|
|
541
|
-
// config unreadable — use default
|
|
542
|
-
}
|
|
543
|
-
api.logger.info(`plugins: force — saved entry config for ${pluginId}`)
|
|
544
|
-
|
|
545
|
-
// 2. Install to staging dir (OPENCLAW_STATE_DIR gives clean slate),
|
|
546
|
-
// then atomic swap — no config write needed before install.
|
|
547
|
-
output = await withPluginBackup(api, pluginId, async ({stagingDir}) => {
|
|
548
|
-
api.logger.info(`plugins: force — installing ${installTarget} to staging dir`)
|
|
549
|
-
const result = stagingDir
|
|
550
|
-
? await $`OPENCLAW_STATE_DIR=${stagingDir} openclaw plugins install ${installTarget}`
|
|
551
|
-
: await $`openclaw plugins install ${installTarget}`
|
|
552
|
-
return result.stdout.trim()
|
|
553
|
-
})
|
|
218
|
+
const targetDir = path.join(stateDir, 'extensions', pluginId)
|
|
219
|
+
const stagingDir = path.join(
|
|
220
|
+
stateDir,
|
|
221
|
+
'clawly',
|
|
222
|
+
'staging',
|
|
223
|
+
`${pluginId}-${crypto.randomBytes(4).toString('hex')}`,
|
|
224
|
+
)
|
|
554
225
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
...savedEntry,
|
|
563
|
-
}
|
|
564
|
-
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
|
|
565
|
-
api.logger.info(`plugins: force — ensured entry config for ${pluginId}`)
|
|
566
|
-
} catch (err) {
|
|
567
|
-
api.logger.warn(
|
|
568
|
-
`plugins: force — failed to write entry config: ${err instanceof Error ? err.message : String(err)}`,
|
|
569
|
-
)
|
|
570
|
-
}
|
|
571
|
-
} else if (strategy === 'update') {
|
|
572
|
-
output = await withPluginBackup(
|
|
573
|
-
api,
|
|
574
|
-
pluginId,
|
|
575
|
-
async () => {
|
|
576
|
-
const result = await $`openclaw plugins update ${pluginId}`
|
|
577
|
-
return result.stdout.trim()
|
|
578
|
-
},
|
|
579
|
-
{staging: false},
|
|
580
|
-
)
|
|
581
|
-
} else {
|
|
582
|
-
// install
|
|
583
|
-
output = await withPluginBackup(api, pluginId, async ({stagingDir}) => {
|
|
584
|
-
const result = stagingDir
|
|
585
|
-
? await $`OPENCLAW_STATE_DIR=${stagingDir} openclaw plugins install ${installTarget}`
|
|
586
|
-
: await $`openclaw plugins install ${installTarget}`
|
|
587
|
-
return result.stdout.trim()
|
|
588
|
-
})
|
|
589
|
-
}
|
|
226
|
+
api.logger.info(`plugins: ${strategy} — installing ${installTarget}`)
|
|
227
|
+
output = await manualPluginInstall({
|
|
228
|
+
spec: installTarget,
|
|
229
|
+
targetDir,
|
|
230
|
+
stagingDir,
|
|
231
|
+
logger: api.logger,
|
|
232
|
+
})
|
|
590
233
|
|
|
591
234
|
// Invalidate npm cache for this package
|
|
592
235
|
npmCache.delete(npmPkgName)
|
package/gateway/presence.ts
CHANGED
|
@@ -13,7 +13,6 @@ import {stripCliLogs} from '../lib/stripCliLogs'
|
|
|
13
13
|
$.verbose = false
|
|
14
14
|
|
|
15
15
|
const DEFAULT_HOST = 'openclaw-ios'
|
|
16
|
-
const CLAWLY_MOBILE_PREFIX = 'Clawly Mobile'
|
|
17
16
|
|
|
18
17
|
interface PresenceEntry {
|
|
19
18
|
host?: string
|
|
@@ -39,9 +38,7 @@ export async function isClientOnline(host = DEFAULT_HOST): Promise<boolean> {
|
|
|
39
38
|
const result = await $`openclaw gateway call system-presence --json`
|
|
40
39
|
const jsonStr = stripCliLogs(result.stdout)
|
|
41
40
|
const entries: PresenceEntry[] = JSON.parse(jsonStr)
|
|
42
|
-
const entry =
|
|
43
|
-
entries.find((e) => e.host === host) ||
|
|
44
|
-
entries.find((e) => e.host?.startsWith(CLAWLY_MOBILE_PREFIX))
|
|
41
|
+
const entry = entries.find((e) => e.host === host)
|
|
45
42
|
return isOnlineEntry(entry)
|
|
46
43
|
} catch {
|
|
47
44
|
return false
|
|
@@ -0,0 +1,121 @@
|
|
|
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 {manualPluginInstall} from './manualPluginInstall'
|
|
7
|
+
|
|
8
|
+
const PLUGIN_ID = 'test-plugin'
|
|
9
|
+
const SENTINEL = 'openclaw.plugin.json'
|
|
10
|
+
|
|
11
|
+
function makeTmpDir(): string {
|
|
12
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'clawly-plugins-test-'))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function setupPluginDir(stateDir: string): string {
|
|
16
|
+
const extensionDir = path.join(stateDir, 'extensions', PLUGIN_ID)
|
|
17
|
+
fs.mkdirSync(extensionDir, {recursive: true})
|
|
18
|
+
fs.writeFileSync(path.join(extensionDir, SENTINEL), '{}')
|
|
19
|
+
fs.writeFileSync(path.join(extensionDir, 'package.json'), '{"version":"1.0.0"}')
|
|
20
|
+
return extensionDir
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function setupLocalSpec(tmpDir: string, version: string, hasSentinel = true): string {
|
|
24
|
+
const specDir = path.join(tmpDir, 'local-spec')
|
|
25
|
+
fs.mkdirSync(specDir, {recursive: true})
|
|
26
|
+
if (hasSentinel) fs.writeFileSync(path.join(specDir, SENTINEL), '{}')
|
|
27
|
+
fs.writeFileSync(path.join(specDir, 'package.json'), `{"version":"${version}"}`)
|
|
28
|
+
return specDir
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let tmpDir: string
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
tmpDir = makeTmpDir()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
fs.rmSync(tmpDir, {recursive: true, force: true})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('manualPluginInstall', () => {
|
|
42
|
+
test('first install (empty targetDir): installs from local spec', async () => {
|
|
43
|
+
const targetDir = path.join(tmpDir, 'extensions', PLUGIN_ID)
|
|
44
|
+
const stagingDir = path.join(tmpDir, 'clawly', 'staging', PLUGIN_ID)
|
|
45
|
+
const specDir = setupLocalSpec(tmpDir, '1.0.0')
|
|
46
|
+
|
|
47
|
+
const result = await manualPluginInstall({
|
|
48
|
+
spec: specDir,
|
|
49
|
+
targetDir,
|
|
50
|
+
stagingDir,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
expect(result).toContain('Installed')
|
|
54
|
+
expect(fs.existsSync(path.join(targetDir, SENTINEL))).toBe(true)
|
|
55
|
+
expect(JSON.parse(fs.readFileSync(path.join(targetDir, 'package.json'), 'utf-8')).version).toBe(
|
|
56
|
+
'1.0.0',
|
|
57
|
+
)
|
|
58
|
+
expect(fs.existsSync(stagingDir)).toBe(false)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('update: syncs new version, removes stale files, cleans staging', async () => {
|
|
62
|
+
const targetDir = setupPluginDir(tmpDir)
|
|
63
|
+
fs.writeFileSync(path.join(targetDir, 'feature-v1.js'), 'old')
|
|
64
|
+
const stagingDir = path.join(tmpDir, 'clawly', 'staging', PLUGIN_ID)
|
|
65
|
+
const specDir = setupLocalSpec(tmpDir, '2.0.0')
|
|
66
|
+
|
|
67
|
+
const result = await manualPluginInstall({
|
|
68
|
+
spec: specDir,
|
|
69
|
+
targetDir,
|
|
70
|
+
stagingDir,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
expect(result).toContain('Installed')
|
|
74
|
+
expect(fs.existsSync(path.join(targetDir, 'feature-v1.js'))).toBe(false)
|
|
75
|
+
expect(fs.existsSync(path.join(targetDir, SENTINEL))).toBe(true)
|
|
76
|
+
expect(JSON.parse(fs.readFileSync(path.join(targetDir, 'package.json'), 'utf-8')).version).toBe(
|
|
77
|
+
'2.0.0',
|
|
78
|
+
)
|
|
79
|
+
expect(fs.existsSync(stagingDir)).toBe(false)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('rejects when spec lacks sentinel (targetDir unchanged)', async () => {
|
|
83
|
+
const targetDir = setupPluginDir(tmpDir)
|
|
84
|
+
const stagingDir = path.join(tmpDir, 'clawly', 'staging', PLUGIN_ID)
|
|
85
|
+
const specDir = setupLocalSpec(tmpDir, '2.0.0', false)
|
|
86
|
+
|
|
87
|
+
await expect(manualPluginInstall({spec: specDir, targetDir, stagingDir})).rejects.toThrow(
|
|
88
|
+
'plugin source missing openclaw.plugin.json',
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
expect(fs.existsSync(path.join(targetDir, SENTINEL))).toBe(true)
|
|
92
|
+
expect(JSON.parse(fs.readFileSync(path.join(targetDir, 'package.json'), 'utf-8')).version).toBe(
|
|
93
|
+
'1.0.0',
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('first install: rejects when spec lacks sentinel (targetDir left empty)', async () => {
|
|
98
|
+
const targetDir = path.join(tmpDir, 'extensions', PLUGIN_ID)
|
|
99
|
+
fs.mkdirSync(path.join(tmpDir, 'extensions'), {recursive: true})
|
|
100
|
+
const stagingDir = path.join(tmpDir, 'clawly', 'staging', PLUGIN_ID)
|
|
101
|
+
const specDir = setupLocalSpec(tmpDir, '1.0.0', false)
|
|
102
|
+
|
|
103
|
+
await expect(manualPluginInstall({spec: specDir, targetDir, stagingDir})).rejects.toThrow(
|
|
104
|
+
'plugin source missing openclaw.plugin.json',
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
expect(fs.existsSync(targetDir)).toBe(true)
|
|
108
|
+
expect(fs.readdirSync(targetDir)).toHaveLength(0)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('local spec path must exist', async () => {
|
|
112
|
+
const targetDir = path.join(tmpDir, 'extensions', PLUGIN_ID)
|
|
113
|
+
fs.mkdirSync(targetDir, {recursive: true})
|
|
114
|
+
const stagingDir = path.join(tmpDir, 'clawly', 'staging', PLUGIN_ID)
|
|
115
|
+
const specDir = path.join(tmpDir, 'nonexistent')
|
|
116
|
+
|
|
117
|
+
await expect(manualPluginInstall({spec: specDir, targetDir, stagingDir})).rejects.toThrow(
|
|
118
|
+
'local spec path does not exist',
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manual plugin install that bypasses OpenClaw's hardened extraction pipeline.
|
|
3
|
+
*
|
|
4
|
+
* OpenClaw ≥3.12 uses `copyFileWithinRoot` → `runPinnedWriteHelper` (python3)
|
|
5
|
+
* during tar extraction, which fails on sprite overlay/JuiceFS filesystems
|
|
6
|
+
* with `SafeOpenError: path is not a regular file under root`.
|
|
7
|
+
*
|
|
8
|
+
* Supports both npm spec and local absolute path. Install uses rsync:
|
|
9
|
+
* `rsync -a --delete` syncs source into targetDir — overwrites changed files,
|
|
10
|
+
* adds new files, and removes stale files not in source. The target directory
|
|
11
|
+
* is never moved or deleted, so the plugin stays functional during the sync.
|
|
12
|
+
* Staging artifacts (tgz, pkg) are cleaned up in finally.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import assert from 'node:assert'
|
|
16
|
+
import fs from 'node:fs'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import {$} from 'zx'
|
|
19
|
+
|
|
20
|
+
const PLUGIN_SENTINEL = 'openclaw.plugin.json'
|
|
21
|
+
|
|
22
|
+
interface Logger {
|
|
23
|
+
info?: (msg: string) => void
|
|
24
|
+
warn?: (msg: string) => void
|
|
25
|
+
error?: (msg: string) => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function withRetry<T>(fn: () => Promise<T>, maxAttempts: number): Promise<T> {
|
|
29
|
+
let lastErr: unknown
|
|
30
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
31
|
+
try {
|
|
32
|
+
return await fn()
|
|
33
|
+
} catch (err) {
|
|
34
|
+
lastErr = err
|
|
35
|
+
if (attempt < maxAttempts) {
|
|
36
|
+
await new Promise((r) => setTimeout(r, 500 * attempt))
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
throw lastErr
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function manualPluginInstall(params: {
|
|
44
|
+
spec: string
|
|
45
|
+
targetDir: string
|
|
46
|
+
stagingDir: string
|
|
47
|
+
logger?: Logger
|
|
48
|
+
}): Promise<string> {
|
|
49
|
+
const {spec, targetDir, stagingDir, logger} = params
|
|
50
|
+
const isNpm = !path.isAbsolute(spec)
|
|
51
|
+
const pkgDir = path.join(stagingDir, 'pkg')
|
|
52
|
+
|
|
53
|
+
// 1. Ensure targetDir exists
|
|
54
|
+
fs.mkdirSync(targetDir, {recursive: true})
|
|
55
|
+
|
|
56
|
+
// 2. Create stagingDir
|
|
57
|
+
fs.mkdirSync(stagingDir, {recursive: true})
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
let sourceDir: string
|
|
61
|
+
if (isNpm) {
|
|
62
|
+
// 3a. npm pack → stagingDir (retry 3x)
|
|
63
|
+
logger?.info?.(`downloading ${spec}...`)
|
|
64
|
+
const packResult = await withRetry(async () => {
|
|
65
|
+
const result = await $`npm pack ${spec} --pack-destination ${stagingDir}`
|
|
66
|
+
return result
|
|
67
|
+
}, 3)
|
|
68
|
+
const lastLine = packResult.stdout.trim().split('\n').pop() ?? ''
|
|
69
|
+
const tgzName = path.basename(lastLine)
|
|
70
|
+
if (!tgzName.endsWith('.tgz')) throw new Error(`unexpected npm pack output: ${lastLine}`)
|
|
71
|
+
const tgzPath = path.join(stagingDir, tgzName)
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
// 3b. Extract to stagingDir/pkg
|
|
75
|
+
fs.mkdirSync(pkgDir, {recursive: true})
|
|
76
|
+
logger?.info?.(`extracting to ${pkgDir}...`)
|
|
77
|
+
await $`tar xzf ${tgzPath} --strip-components=1 -C ${pkgDir}`
|
|
78
|
+
} finally {
|
|
79
|
+
try {
|
|
80
|
+
fs.unlinkSync(tgzPath)
|
|
81
|
+
} catch {}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 3c. npm install in pkg (retry 3x)
|
|
85
|
+
const pkgPath = path.join(pkgDir, 'package.json')
|
|
86
|
+
if (fs.existsSync(pkgPath)) {
|
|
87
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
|
|
88
|
+
if (pkg.dependencies && Object.keys(pkg.dependencies).length > 0) {
|
|
89
|
+
logger?.info?.('installing dependencies...')
|
|
90
|
+
await withRetry(
|
|
91
|
+
() => $({cwd: pkgDir})`npm install --omit=dev --omit=peer --ignore-scripts`,
|
|
92
|
+
3,
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
sourceDir = pkgDir
|
|
97
|
+
} else {
|
|
98
|
+
sourceDir = spec
|
|
99
|
+
if (!fs.existsSync(sourceDir)) {
|
|
100
|
+
throw new Error(`local spec path does not exist: ${sourceDir}`)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
assert(
|
|
105
|
+
fs.existsSync(path.join(sourceDir, 'package.json')),
|
|
106
|
+
`plugin source missing package.json: ${sourceDir}`,
|
|
107
|
+
)
|
|
108
|
+
assert(
|
|
109
|
+
fs.existsSync(path.join(sourceDir, PLUGIN_SENTINEL)),
|
|
110
|
+
`plugin source missing openclaw.plugin.json: ${sourceDir}`,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
// 4. rsync source → targetDir (overlay + delete stale files)
|
|
114
|
+
// npm pack output is already clean; local path needs dev file exclusions
|
|
115
|
+
logger?.info?.(`syncing to ${targetDir}...`)
|
|
116
|
+
if (isNpm) {
|
|
117
|
+
await $`rsync -a --delete --checksum ${sourceDir}/ ${targetDir}/`
|
|
118
|
+
} else {
|
|
119
|
+
await $`rsync -a --delete --checksum --exclude=node_modules --exclude=.git ${sourceDir}/ ${targetDir}/`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 5. Verify sentinel
|
|
123
|
+
if (!fs.existsSync(path.join(targetDir, PLUGIN_SENTINEL))) {
|
|
124
|
+
throw new Error(`plugin files missing after install (${PLUGIN_SENTINEL} not found)`)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 6. Local path: install production deps (rsync excludes node_modules)
|
|
128
|
+
if (!isNpm) {
|
|
129
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(targetDir, 'package.json'), 'utf-8'))
|
|
130
|
+
if (pkg.dependencies && Object.keys(pkg.dependencies).length > 0) {
|
|
131
|
+
logger?.info?.('installing dependencies...')
|
|
132
|
+
await withRetry(
|
|
133
|
+
() => $({cwd: targetDir})`npm install --omit=dev --omit=peer --ignore-scripts`,
|
|
134
|
+
3,
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return `Installed ${spec} to ${targetDir}`
|
|
140
|
+
} finally {
|
|
141
|
+
// 6. Remove stagingDir (tgz + pkg artifacts)
|
|
142
|
+
try {
|
|
143
|
+
if (fs.existsSync(stagingDir)) {
|
|
144
|
+
fs.rmSync(stagingDir, {recursive: true, force: true})
|
|
145
|
+
}
|
|
146
|
+
} catch {}
|
|
147
|
+
}
|
|
148
|
+
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
"name": "Clawly Plugins",
|
|
4
4
|
"description": "Clawly utility RPC methods (clawly.*): file access, presence, push notifications, agent messaging, memory browser, and ClawHub CLI bridge.",
|
|
5
5
|
"version": "0.3.0",
|
|
6
|
+
"skills": ["skills/read-office-file"],
|
|
6
7
|
"uiHints": {
|
|
7
8
|
"memoryDir": {
|
|
8
9
|
"label": "Memory directory",
|
package/outbound.ts
CHANGED
|
@@ -190,6 +190,18 @@ export function registerOutboundHttpRoute(api: PluginApi) {
|
|
|
190
190
|
path: '/clawly/file/outbound',
|
|
191
191
|
auth: 'plugin',
|
|
192
192
|
handler: async (_req: IncomingMessage, res: ServerResponse) => {
|
|
193
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
194
|
+
|
|
195
|
+
// Handle CORS preflight
|
|
196
|
+
if (_req.method === 'OPTIONS') {
|
|
197
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS')
|
|
198
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
|
199
|
+
res.setHeader('Access-Control-Max-Age', '86400')
|
|
200
|
+
res.statusCode = 204
|
|
201
|
+
res.end()
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
193
205
|
const url = new URL(_req.url ?? '/', 'http://localhost')
|
|
194
206
|
if (!guardHttpAuth(api, _req, res, url)) return
|
|
195
207
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@2en/clawly-plugins",
|
|
3
|
-
"version": "1.29.0-beta.
|
|
3
|
+
"version": "1.29.0-beta.1",
|
|
4
4
|
"module": "index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
"resolve-gateway-credentials.ts",
|
|
37
37
|
"skill-command-restore.ts",
|
|
38
38
|
"openclaw.plugin.json",
|
|
39
|
-
"internal"
|
|
39
|
+
"internal",
|
|
40
|
+
"skills"
|
|
40
41
|
],
|
|
41
42
|
"publishConfig": {
|
|
42
43
|
"access": "public"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: read-office-file
|
|
3
|
+
description: Convert Word, Excel, or PowerPoint files to Markdown using MarkItDown.
|
|
4
|
+
metadata: { "openclaw": { "emoji": "📄" } }
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Read Office File
|
|
8
|
+
|
|
9
|
+
Convert Word (.docx), Excel (.xlsx), or PowerPoint (.pptx) files to Markdown using [MarkItDown](https://github.com/microsoft/markitdown).
|
|
10
|
+
|
|
11
|
+
## When to use
|
|
12
|
+
|
|
13
|
+
- User asks to read, summarize, or extract content from a `.docx`, `.xlsx`, or `.pptx` file.
|
|
14
|
+
- User shares or references an Office file path.
|
|
15
|
+
|
|
16
|
+
## Steps
|
|
17
|
+
|
|
18
|
+
1. Convert the file to Markdown (use the extra matching the file type):
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# .docx
|
|
22
|
+
uvx 'markitdown[docx]' <file_path>
|
|
23
|
+
|
|
24
|
+
# .xlsx
|
|
25
|
+
uvx 'markitdown[xlsx]' <file_path>
|
|
26
|
+
|
|
27
|
+
# .pptx
|
|
28
|
+
uvx 'markitdown[pptx]' <file_path>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
2. Read the Markdown output and respond to the user's request.
|
|
32
|
+
|
|
33
|
+
## Supported formats
|
|
34
|
+
|
|
35
|
+
| Format | Extensions |
|
|
36
|
+
|---|---|
|
|
37
|
+
| Word | `.docx` |
|
|
38
|
+
| Excel | `.xlsx` |
|
|
39
|
+
| PowerPoint | `.pptx` |
|
|
40
|
+
|
|
41
|
+
Legacy formats (`.doc`, `.xls`, `.ppt`) are **not supported**. Ask the user to convert to the modern format first.
|
package/gateway/plugins.test.ts
DELETED
|
@@ -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
|
-
})
|