@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.
@@ -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 (strategy === 'force') {
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
- const configPath = path.join(stateDir, 'openclaw.json')
531
-
532
- // 1. Save current plugins.entries.<id> config (preserve user settings)
533
- let savedEntry: Record<string, unknown> = {enabled: true}
534
- try {
535
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
536
- const entry = config?.plugins?.entries?.[pluginId]
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
- // 3. Ensure config entry is present (triggers config watcher restart)
556
- try {
557
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
558
- if (!config.plugins) config.plugins = {}
559
- if (!config.plugins.entries) config.plugins.entries = {}
560
- config.plugins.entries[pluginId] = {
561
- ...config.plugins.entries[pluginId],
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)
@@ -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
@@ -17,6 +17,7 @@ describe('readTelemetryPluginConfig', () => {
17
17
  otelDataset: 'clawly-otel-logs-dev',
18
18
  posthogApiKey: 'ph-key',
19
19
  posthogHost: 'https://us.i.posthog.com',
20
+ messageLogEnabled: false,
20
21
  })
21
22
  })
22
23
 
@@ -35,6 +36,7 @@ describe('readTelemetryPluginConfig', () => {
35
36
  otelDataset: 'clawly-otel-logs-dev',
36
37
  posthogApiKey: 'ph-key',
37
38
  posthogHost: 'https://us.i.posthog.com',
39
+ messageLogEnabled: false,
38
40
  })
39
41
  })
40
42
 
@@ -53,6 +55,7 @@ describe('readTelemetryPluginConfig', () => {
53
55
  otelDataset: undefined,
54
56
  posthogApiKey: undefined,
55
57
  posthogHost: undefined,
58
+ messageLogEnabled: false,
56
59
  })
57
60
  })
58
61
  })
@@ -4,6 +4,7 @@ export interface TelemetryPluginConfig {
4
4
  otelDataset?: string
5
5
  posthogApiKey?: string
6
6
  posthogHost?: string
7
+ messageLogEnabled?: boolean
7
8
  }
8
9
 
9
10
  function readString(value: unknown): string | undefined {
@@ -23,5 +24,6 @@ export function readTelemetryPluginConfig(
23
24
  otelDataset: readString(cfg.otelDataset),
24
25
  posthogApiKey: readString(cfg.posthogApiKey),
25
26
  posthogHost: readString(cfg.posthogHost),
27
+ messageLogEnabled: cfg.messageLogEnabled === true || cfg.messageLogEnabled === 'true',
26
28
  }
27
29
  }
package/index.ts CHANGED
@@ -34,6 +34,9 @@
34
34
  * - agent_end — sends push notification when client is offline; injects cron results into main session
35
35
  * - after_tool_call — cron telemetry: captures cron job creation/deletion
36
36
  * - agent_end (pri 100) — cron telemetry: captures cron execution outcomes with delivery/push flags
37
+ * - llm_input — message-log (dev-only): logs model, provider, history size, images count (no content)
38
+ * - llm_output — message-log (dev-only): logs model, provider, content length (no content)
39
+ * - message_sent — message-log (dev-only): logs channel delivery outcome (no content)
37
40
  * - gateway_start — auto-approves device pairing for Clawly mobile clients (clientId: openclaw-ios)
38
41
  * - gateway_start — registers auto-update cron job (0 3 * * *) for clawly-plugins
39
42
  */
@@ -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
+ })