@2en/clawly-plugins 1.29.0-beta.0 → 1.29.0-beta.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/gateway/audit.ts +2 -2
- package/gateway/cron-delivery.test.ts +385 -0
- package/gateway/cron-delivery.ts +8 -4
- package/gateway/plugins.ts +16 -373
- package/gateway/presence.ts +1 -4
- package/internal/hooks/auto-update.ts +3 -3
- package/lib/manualPluginInstall.test.ts +121 -0
- package/lib/manualPluginInstall.ts +148 -0
- package/lib/stripCliLogs.test.ts +5 -17
- package/lib/stripCliLogs.ts +7 -22
- 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
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import {$} from 'zx'
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {stripCliLogs} from '../../lib/stripCliLogs'
|
|
8
8
|
import type {PluginApi} from '../../types'
|
|
9
9
|
|
|
10
10
|
$.verbose = false
|
|
@@ -90,7 +90,7 @@ function needsUpdate(job: CronJobEntry, config: JobConfig): boolean {
|
|
|
90
90
|
async function findJobs(): Promise<[unknown, CronJobEntry[]]> {
|
|
91
91
|
try {
|
|
92
92
|
const {stdout} = await $`openclaw cron list --json`
|
|
93
|
-
const parsed = JSON.parse(
|
|
93
|
+
const parsed = JSON.parse(stripCliLogs(stdout))
|
|
94
94
|
const jobs: unknown[] = parsed?.jobs ?? parsed ?? []
|
|
95
95
|
if (!Array.isArray(jobs)) return [null, []]
|
|
96
96
|
return [null, jobs.filter((j: any) => j.name === JOB_NAME) as CronJobEntry[]]
|
|
@@ -136,7 +136,7 @@ export function registerAutoUpdate(api: PluginApi) {
|
|
|
136
136
|
const {stdout} =
|
|
137
137
|
await $`openclaw cron add ${['--name', JOB_NAME, '--json', ...configToArgs(JOB_CONFIG)]}`
|
|
138
138
|
try {
|
|
139
|
-
const result = JSON.parse(
|
|
139
|
+
const result = JSON.parse(stripCliLogs(stdout))
|
|
140
140
|
autoUpdateJobId = result?.id ?? null
|
|
141
141
|
} catch {
|
|
142
142
|
// ID extraction failed — job was still created
|
|
@@ -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
|
+
})
|