@2en/clawly-plugins 1.5.0 → 1.6.0
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/index.ts +2 -0
- package/gateway/plugins.ts +243 -0
- package/gateway/presence.ts +2 -6
- package/lib/lruCache.test.ts +85 -0
- package/lib/lruCache.ts +50 -0
- package/lib/semver.test.ts +88 -0
- package/lib/semver.ts +46 -0
- package/lib/stripCliLogs.test.ts +71 -0
- package/lib/stripCliLogs.ts +25 -0
- package/package.json +2 -1
package/gateway/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {registerAgentSend} from './agent'
|
|
|
3
3
|
import {registerClawhub2gateway} from './clawhub2gateway'
|
|
4
4
|
import {registerMemoryBrowser} from './memory'
|
|
5
5
|
import {registerNotification} from './notification'
|
|
6
|
+
import {registerPlugins} from './plugins'
|
|
6
7
|
import {registerPresence} from './presence'
|
|
7
8
|
|
|
8
9
|
export function registerGateway(api: PluginApi) {
|
|
@@ -11,4 +12,5 @@ export function registerGateway(api: PluginApi) {
|
|
|
11
12
|
registerAgentSend(api)
|
|
12
13
|
registerMemoryBrowser(api)
|
|
13
14
|
registerClawhub2gateway(api)
|
|
15
|
+
registerPlugins(api)
|
|
14
16
|
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin version check and update management.
|
|
3
|
+
*
|
|
4
|
+
* Methods:
|
|
5
|
+
* - clawly.plugins.version({ pluginId, npmPkgName }) → VersionResult
|
|
6
|
+
* - clawly.plugins.update({ pluginId, npmPkgName, strategy, targetVersion?, restart? }) → UpdateResult
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import {$} from 'zx'
|
|
12
|
+
|
|
13
|
+
import type {PluginApi} from '../index'
|
|
14
|
+
import {LruCache} from '../lib/lruCache'
|
|
15
|
+
import {isUpdateAvailable} from '../lib/semver'
|
|
16
|
+
import {stripCliLogs} from '../lib/stripCliLogs'
|
|
17
|
+
|
|
18
|
+
$.verbose = false
|
|
19
|
+
|
|
20
|
+
interface NpmViewCache {
|
|
21
|
+
version: string
|
|
22
|
+
versions: string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const npmCache = new LruCache<NpmViewCache>({maxSize: 5, ttlMs: 5 * 60 * 1000})
|
|
26
|
+
|
|
27
|
+
function resolveStateDir(api: PluginApi): string {
|
|
28
|
+
return api.runtime.state?.resolveStateDir?.(process.env) ?? process.env.OPENCLAW_STATE_DIR ?? ''
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readExtensionVersion(stateDir: string, pluginId: string): string | null {
|
|
32
|
+
try {
|
|
33
|
+
const pkgPath = path.join(stateDir, 'extensions', pluginId, 'package.json')
|
|
34
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
|
|
35
|
+
return typeof pkg.version === 'string' ? pkg.version : null
|
|
36
|
+
} catch {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function fetchNpmView(npmPkgName: string): Promise<NpmViewCache | null> {
|
|
42
|
+
const cached = npmCache.get(npmPkgName)
|
|
43
|
+
if (cached) return cached
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const result = await $`npm view ${npmPkgName} --json`
|
|
47
|
+
const parsed = JSON.parse(stripCliLogs(result.stdout))
|
|
48
|
+
const entry: NpmViewCache = {
|
|
49
|
+
version: typeof parsed.version === 'string' ? parsed.version : '',
|
|
50
|
+
versions: Array.isArray(parsed.versions) ? parsed.versions : [],
|
|
51
|
+
}
|
|
52
|
+
npmCache.set(npmPkgName, entry)
|
|
53
|
+
return entry
|
|
54
|
+
} catch {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function registerPlugins(api: PluginApi) {
|
|
60
|
+
// ── clawly.plugins.version ──────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
api.registerGatewayMethod('clawly.plugins.version', async ({params, respond}) => {
|
|
63
|
+
const pluginId = typeof params.pluginId === 'string' ? params.pluginId.trim() : ''
|
|
64
|
+
const npmPkgName = typeof params.npmPkgName === 'string' ? params.npmPkgName.trim() : ''
|
|
65
|
+
|
|
66
|
+
if (!pluginId || !npmPkgName) {
|
|
67
|
+
respond(false, undefined, {
|
|
68
|
+
code: 'invalid_params',
|
|
69
|
+
message: 'pluginId and npmPkgName are required',
|
|
70
|
+
})
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const stateDir = resolveStateDir(api)
|
|
75
|
+
api.logger.info(`plugins.version: checking ${pluginId} (${npmPkgName})`)
|
|
76
|
+
let latestNpmVersion: string | null = null
|
|
77
|
+
let allNpmVersions: string[] = []
|
|
78
|
+
let updateAvailable = false
|
|
79
|
+
const errors: string[] = []
|
|
80
|
+
|
|
81
|
+
// Step 1: Read installed version from extensions/<id>/package.json
|
|
82
|
+
const npmPackageVersion = stateDir ? readExtensionVersion(stateDir, pluginId) : null
|
|
83
|
+
api.logger.info(`plugins.version: installed version = ${npmPackageVersion ?? 'not found'}`)
|
|
84
|
+
|
|
85
|
+
// Step 2: Fetch npm registry info
|
|
86
|
+
try {
|
|
87
|
+
const npm = await fetchNpmView(npmPkgName)
|
|
88
|
+
if (npm) {
|
|
89
|
+
latestNpmVersion = npm.version
|
|
90
|
+
allNpmVersions = npm.versions
|
|
91
|
+
api.logger.info(
|
|
92
|
+
`plugins.version: npm registry latest=${latestNpmVersion}, ${allNpmVersions.length} versions available`,
|
|
93
|
+
)
|
|
94
|
+
} else {
|
|
95
|
+
api.logger.warn(`plugins.version: npm view returned null for ${npmPkgName}`)
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
errors.push(`npm view: ${err instanceof Error ? err.message : String(err)}`)
|
|
99
|
+
api.logger.error(`plugins.version: npm view failed — ${errors[errors.length - 1]}`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Step 3: Compute update availability
|
|
103
|
+
if (npmPackageVersion && latestNpmVersion) {
|
|
104
|
+
updateAvailable = isUpdateAvailable(npmPackageVersion, latestNpmVersion)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
api.logger.info(
|
|
108
|
+
`plugins.version: result current=${npmPackageVersion} latest=${latestNpmVersion} updateAvailable=${updateAvailable}`,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
respond(true, {
|
|
112
|
+
pluginVersion: npmPackageVersion,
|
|
113
|
+
npmPackageVersion,
|
|
114
|
+
latestNpmVersion,
|
|
115
|
+
allNpmVersions,
|
|
116
|
+
updateAvailable,
|
|
117
|
+
...(errors.length ? {error: errors.join('; ')} : {}),
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// ── clawly.plugins.update ──────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
api.registerGatewayMethod('clawly.plugins.update', async ({params, respond}) => {
|
|
124
|
+
const pluginId = typeof params.pluginId === 'string' ? params.pluginId.trim() : ''
|
|
125
|
+
const npmPkgName = typeof params.npmPkgName === 'string' ? params.npmPkgName.trim() : ''
|
|
126
|
+
const strategy = typeof params.strategy === 'string' ? params.strategy.trim() : ''
|
|
127
|
+
const targetVersion =
|
|
128
|
+
typeof params.targetVersion === 'string' ? params.targetVersion.trim() : undefined
|
|
129
|
+
const restart = params.restart === true
|
|
130
|
+
|
|
131
|
+
if (!pluginId || !npmPkgName || !strategy) {
|
|
132
|
+
respond(false, undefined, {
|
|
133
|
+
code: 'invalid_params',
|
|
134
|
+
message: 'pluginId, npmPkgName, and strategy are required',
|
|
135
|
+
})
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!['force', 'update', 'install'].includes(strategy)) {
|
|
140
|
+
respond(false, undefined, {
|
|
141
|
+
code: 'invalid_params',
|
|
142
|
+
message: `invalid strategy: ${strategy}`,
|
|
143
|
+
})
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const installTarget = targetVersion ? `${npmPkgName}@${targetVersion}` : npmPkgName
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
let output = ''
|
|
151
|
+
|
|
152
|
+
if (strategy === 'force') {
|
|
153
|
+
const stateDir = resolveStateDir(api)
|
|
154
|
+
if (!stateDir) throw new Error('cannot resolve openclaw state dir')
|
|
155
|
+
|
|
156
|
+
const configPath = path.join(stateDir, 'openclaw.json')
|
|
157
|
+
|
|
158
|
+
// 1. Save current plugins.entries.<id> config (preserve user settings)
|
|
159
|
+
let savedEntry: Record<string, unknown> = {enabled: true}
|
|
160
|
+
try {
|
|
161
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
|
162
|
+
const entry = config?.plugins?.entries?.[pluginId]
|
|
163
|
+
if (entry && typeof entry === 'object') {
|
|
164
|
+
savedEntry = entry as Record<string, unknown>
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
// config unreadable — use default
|
|
168
|
+
}
|
|
169
|
+
api.logger.info(`plugins: force — saved entry config for ${pluginId}`)
|
|
170
|
+
|
|
171
|
+
// 2. Delete plugins.entries.<id> from config so install starts clean
|
|
172
|
+
try {
|
|
173
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
|
174
|
+
if (config?.plugins?.entries?.[pluginId]) {
|
|
175
|
+
delete config.plugins.entries[pluginId]
|
|
176
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
// ignore
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 3. Remove extension dir to force clean install
|
|
183
|
+
const extensionDir = path.join(stateDir, 'extensions', pluginId)
|
|
184
|
+
if (fs.existsSync(extensionDir)) {
|
|
185
|
+
api.logger.info(`plugins: force — removing ${extensionDir}`)
|
|
186
|
+
await $`rm -rf ${extensionDir}`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 4. Install fresh
|
|
190
|
+
api.logger.info(`plugins: force — installing ${installTarget}`)
|
|
191
|
+
const result = await $`openclaw plugins install ${installTarget}`
|
|
192
|
+
output = result.stdout.trim()
|
|
193
|
+
|
|
194
|
+
// 5. Merge saved config back into plugins.entries.<id>
|
|
195
|
+
try {
|
|
196
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
|
197
|
+
if (!config.plugins) config.plugins = {}
|
|
198
|
+
if (!config.plugins.entries) config.plugins.entries = {}
|
|
199
|
+
config.plugins.entries[pluginId] = {
|
|
200
|
+
...config.plugins.entries[pluginId],
|
|
201
|
+
...savedEntry,
|
|
202
|
+
}
|
|
203
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
|
|
204
|
+
api.logger.info(`plugins: force — restored entry config for ${pluginId}`)
|
|
205
|
+
} catch (err) {
|
|
206
|
+
api.logger.warn(
|
|
207
|
+
`plugins: force — failed to restore entry config: ${err instanceof Error ? err.message : String(err)}`,
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
} else if (strategy === 'update') {
|
|
211
|
+
const result = await $`openclaw plugins update ${pluginId}`
|
|
212
|
+
output = result.stdout.trim()
|
|
213
|
+
} else {
|
|
214
|
+
// install
|
|
215
|
+
const result = await $`openclaw plugins install ${installTarget}`
|
|
216
|
+
output = result.stdout.trim()
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Invalidate npm cache for this package
|
|
220
|
+
npmCache.delete(npmPkgName)
|
|
221
|
+
|
|
222
|
+
let restarted = false
|
|
223
|
+
if (restart) {
|
|
224
|
+
try {
|
|
225
|
+
await $`openclaw gateway restart`
|
|
226
|
+
restarted = true
|
|
227
|
+
} catch (err) {
|
|
228
|
+
api.logger.warn(
|
|
229
|
+
`plugins: gateway restart failed — ${err instanceof Error ? err.message : String(err)}`,
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
respond(true, {ok: true, strategy, output, restarted})
|
|
235
|
+
} catch (err) {
|
|
236
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
237
|
+
api.logger.error(`plugins: update failed — ${msg}`)
|
|
238
|
+
respond(false, {ok: false, strategy, error: msg})
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
api.logger.info('plugins: registered clawly.plugins.version + clawly.plugins.update')
|
|
243
|
+
}
|
package/gateway/presence.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import {$} from 'zx'
|
|
9
9
|
|
|
10
10
|
import type {PluginApi} from '../index'
|
|
11
|
+
import {stripCliLogs} from '../lib/stripCliLogs'
|
|
11
12
|
|
|
12
13
|
$.verbose = false
|
|
13
14
|
|
|
@@ -25,12 +26,7 @@ interface PresenceEntry {
|
|
|
25
26
|
export async function isClientOnline(host = DEFAULT_HOST): Promise<boolean> {
|
|
26
27
|
try {
|
|
27
28
|
const result = await $`openclaw gateway call system-presence --json`
|
|
28
|
-
const
|
|
29
|
-
let jsonStr = output
|
|
30
|
-
if (!output.startsWith('[')) {
|
|
31
|
-
jsonStr = output.slice(output.indexOf('\n['))
|
|
32
|
-
}
|
|
33
|
-
console.log({jsonStr})
|
|
29
|
+
const jsonStr = stripCliLogs(result.stdout)
|
|
34
30
|
const entries: PresenceEntry[] = JSON.parse(jsonStr)
|
|
35
31
|
const entry = entries.find((e) => e.host === host)
|
|
36
32
|
if (!entry) return false
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, jest, test} from 'bun:test'
|
|
2
|
+
import {LruCache} from './lruCache'
|
|
3
|
+
|
|
4
|
+
describe('LruCache', () => {
|
|
5
|
+
test('get returns undefined for missing key', () => {
|
|
6
|
+
const cache = new LruCache<string>({maxSize: 3, ttlMs: 60_000})
|
|
7
|
+
expect(cache.get('missing')).toBeUndefined()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('set and get', () => {
|
|
11
|
+
const cache = new LruCache<number>({maxSize: 3, ttlMs: 60_000})
|
|
12
|
+
cache.set('a', 1)
|
|
13
|
+
expect(cache.get('a')).toBe(1)
|
|
14
|
+
expect(cache.size).toBe(1)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('evicts oldest when over maxSize', () => {
|
|
18
|
+
const cache = new LruCache<number>({maxSize: 2, ttlMs: 60_000})
|
|
19
|
+
cache.set('a', 1)
|
|
20
|
+
cache.set('b', 2)
|
|
21
|
+
cache.set('c', 3)
|
|
22
|
+
expect(cache.get('a')).toBeUndefined()
|
|
23
|
+
expect(cache.get('b')).toBe(2)
|
|
24
|
+
expect(cache.get('c')).toBe(3)
|
|
25
|
+
expect(cache.size).toBe(2)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('get refreshes position (prevents eviction)', () => {
|
|
29
|
+
const cache = new LruCache<number>({maxSize: 2, ttlMs: 60_000})
|
|
30
|
+
cache.set('a', 1)
|
|
31
|
+
cache.set('b', 2)
|
|
32
|
+
// Access 'a' to refresh its position
|
|
33
|
+
cache.get('a')
|
|
34
|
+
// Insert 'c' — should evict 'b' (now oldest) instead of 'a'
|
|
35
|
+
cache.set('c', 3)
|
|
36
|
+
expect(cache.get('a')).toBe(1)
|
|
37
|
+
expect(cache.get('b')).toBeUndefined()
|
|
38
|
+
expect(cache.get('c')).toBe(3)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('expired entries return undefined', () => {
|
|
42
|
+
const now = Date.now()
|
|
43
|
+
jest.setSystemTime(new Date(now))
|
|
44
|
+
|
|
45
|
+
const cache = new LruCache<string>({maxSize: 5, ttlMs: 1000})
|
|
46
|
+
cache.set('x', 'val')
|
|
47
|
+
expect(cache.get('x')).toBe('val')
|
|
48
|
+
|
|
49
|
+
// Advance past TTL
|
|
50
|
+
jest.setSystemTime(new Date(now + 1001))
|
|
51
|
+
expect(cache.get('x')).toBeUndefined()
|
|
52
|
+
expect(cache.size).toBe(0)
|
|
53
|
+
|
|
54
|
+
jest.restoreAllMocks()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('delete removes entry', () => {
|
|
58
|
+
const cache = new LruCache<number>({maxSize: 5, ttlMs: 60_000})
|
|
59
|
+
cache.set('a', 1)
|
|
60
|
+
expect(cache.delete('a')).toBe(true)
|
|
61
|
+
expect(cache.get('a')).toBeUndefined()
|
|
62
|
+
expect(cache.delete('a')).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('clear removes all entries', () => {
|
|
66
|
+
const cache = new LruCache<number>({maxSize: 5, ttlMs: 60_000})
|
|
67
|
+
cache.set('a', 1)
|
|
68
|
+
cache.set('b', 2)
|
|
69
|
+
cache.clear()
|
|
70
|
+
expect(cache.size).toBe(0)
|
|
71
|
+
expect(cache.get('a')).toBeUndefined()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('overwriting a key updates value and refreshes position', () => {
|
|
75
|
+
const cache = new LruCache<number>({maxSize: 2, ttlMs: 60_000})
|
|
76
|
+
cache.set('a', 1)
|
|
77
|
+
cache.set('b', 2)
|
|
78
|
+
cache.set('a', 10)
|
|
79
|
+
// 'a' is refreshed, 'b' is oldest
|
|
80
|
+
cache.set('c', 3)
|
|
81
|
+
expect(cache.get('a')).toBe(10)
|
|
82
|
+
expect(cache.get('b')).toBeUndefined()
|
|
83
|
+
expect(cache.get('c')).toBe(3)
|
|
84
|
+
})
|
|
85
|
+
})
|
package/lib/lruCache.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
interface CacheEntry<V> {
|
|
2
|
+
value: V
|
|
3
|
+
expiresAt: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export class LruCache<V> {
|
|
7
|
+
private map = new Map<string, CacheEntry<V>>()
|
|
8
|
+
private maxSize: number
|
|
9
|
+
private ttlMs: number
|
|
10
|
+
|
|
11
|
+
constructor(opts: {maxSize: number; ttlMs: number}) {
|
|
12
|
+
this.maxSize = opts.maxSize
|
|
13
|
+
this.ttlMs = opts.ttlMs
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get(key: string): V | undefined {
|
|
17
|
+
const entry = this.map.get(key)
|
|
18
|
+
if (!entry) return undefined
|
|
19
|
+
if (Date.now() > entry.expiresAt) {
|
|
20
|
+
this.map.delete(key)
|
|
21
|
+
return undefined
|
|
22
|
+
}
|
|
23
|
+
// Refresh position: delete and re-insert (Map preserves insertion order)
|
|
24
|
+
this.map.delete(key)
|
|
25
|
+
this.map.set(key, entry)
|
|
26
|
+
return entry.value
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
set(key: string, value: V): void {
|
|
30
|
+
this.map.delete(key)
|
|
31
|
+
if (this.map.size >= this.maxSize) {
|
|
32
|
+
// Evict oldest (first entry)
|
|
33
|
+
const oldest = this.map.keys().next().value
|
|
34
|
+
if (oldest !== undefined) this.map.delete(oldest)
|
|
35
|
+
}
|
|
36
|
+
this.map.set(key, {value, expiresAt: Date.now() + this.ttlMs})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
delete(key: string): boolean {
|
|
40
|
+
return this.map.delete(key)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
clear(): void {
|
|
44
|
+
this.map.clear()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get size(): number {
|
|
48
|
+
return this.map.size
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {describe, expect, test} from 'bun:test'
|
|
2
|
+
import {compareSemVer, isUpdateAvailable, parseSemVer} from './semver'
|
|
3
|
+
|
|
4
|
+
describe('parseSemVer', () => {
|
|
5
|
+
test('parses simple version', () => {
|
|
6
|
+
const v = parseSemVer('1.2.3')
|
|
7
|
+
expect(v).toEqual({major: 1, minor: 2, patch: 3, prerelease: undefined, raw: '1.2.3'})
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('parses version with v prefix', () => {
|
|
11
|
+
const v = parseSemVer('v1.2.3')
|
|
12
|
+
expect(v).toEqual({major: 1, minor: 2, patch: 3, prerelease: undefined, raw: 'v1.2.3'})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('parses version with prerelease', () => {
|
|
16
|
+
const v = parseSemVer('1.2.3-beta.1')
|
|
17
|
+
expect(v).toEqual({major: 1, minor: 2, patch: 3, prerelease: 'beta.1', raw: '1.2.3-beta.1'})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('returns null for invalid input', () => {
|
|
21
|
+
expect(parseSemVer('')).toBeNull()
|
|
22
|
+
expect(parseSemVer('abc')).toBeNull()
|
|
23
|
+
expect(parseSemVer('1.2')).toBeNull()
|
|
24
|
+
expect(parseSemVer('1.2.3.4')).toBeNull()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('trims whitespace', () => {
|
|
28
|
+
const v = parseSemVer(' 1.0.0 ')
|
|
29
|
+
expect(v?.major).toBe(1)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('compareSemVer', () => {
|
|
34
|
+
test('equal versions return 0', () => {
|
|
35
|
+
expect(compareSemVer('1.0.0', '1.0.0')).toBe(0)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('major difference', () => {
|
|
39
|
+
expect(compareSemVer('2.0.0', '1.0.0')).toBe(1)
|
|
40
|
+
expect(compareSemVer('1.0.0', '2.0.0')).toBe(-1)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('minor difference', () => {
|
|
44
|
+
expect(compareSemVer('1.1.0', '1.0.0')).toBe(1)
|
|
45
|
+
expect(compareSemVer('1.0.0', '1.1.0')).toBe(-1)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('patch difference', () => {
|
|
49
|
+
expect(compareSemVer('1.0.1', '1.0.0')).toBe(1)
|
|
50
|
+
expect(compareSemVer('1.0.0', '1.0.1')).toBe(-1)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('release > prerelease at same version', () => {
|
|
54
|
+
expect(compareSemVer('1.0.0', '1.0.0-beta.1')).toBe(1)
|
|
55
|
+
expect(compareSemVer('1.0.0-beta.1', '1.0.0')).toBe(-1)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('two prereleases at same version return 0', () => {
|
|
59
|
+
expect(compareSemVer('1.0.0-alpha', '1.0.0-beta')).toBe(0)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('returns null for invalid input', () => {
|
|
63
|
+
expect(compareSemVer('abc', '1.0.0')).toBeNull()
|
|
64
|
+
expect(compareSemVer('1.0.0', 'abc')).toBeNull()
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('isUpdateAvailable', () => {
|
|
69
|
+
test('returns true when latest > current', () => {
|
|
70
|
+
expect(isUpdateAvailable('1.0.0', '1.1.0')).toBe(true)
|
|
71
|
+
expect(isUpdateAvailable('1.0.0', '2.0.0')).toBe(true)
|
|
72
|
+
expect(isUpdateAvailable('1.5.0', '1.5.1')).toBe(true)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('returns false when latest <= current', () => {
|
|
76
|
+
expect(isUpdateAvailable('1.0.0', '1.0.0')).toBe(false)
|
|
77
|
+
expect(isUpdateAvailable('2.0.0', '1.0.0')).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('returns false when latest is prerelease', () => {
|
|
81
|
+
expect(isUpdateAvailable('1.0.0', '2.0.0-beta.1')).toBe(false)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('returns false for invalid input', () => {
|
|
85
|
+
expect(isUpdateAvailable('abc', '1.0.0')).toBe(false)
|
|
86
|
+
expect(isUpdateAvailable('1.0.0', 'abc')).toBe(false)
|
|
87
|
+
})
|
|
88
|
+
})
|
package/lib/semver.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface SemVer {
|
|
2
|
+
major: number
|
|
3
|
+
minor: number
|
|
4
|
+
patch: number
|
|
5
|
+
prerelease: string | undefined
|
|
6
|
+
raw: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/
|
|
10
|
+
|
|
11
|
+
export function parseSemVer(version: string): SemVer | null {
|
|
12
|
+
const m = version.trim().match(SEMVER_RE)
|
|
13
|
+
if (!m) return null
|
|
14
|
+
return {
|
|
15
|
+
major: Number(m[1]),
|
|
16
|
+
minor: Number(m[2]),
|
|
17
|
+
patch: Number(m[3]),
|
|
18
|
+
prerelease: m[4] ?? undefined,
|
|
19
|
+
raw: version.trim(),
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function compareSemVer(a: string, b: string): -1 | 0 | 1 | null {
|
|
24
|
+
const pa = parseSemVer(a)
|
|
25
|
+
const pb = parseSemVer(b)
|
|
26
|
+
if (!pa || !pb) return null
|
|
27
|
+
|
|
28
|
+
for (const key of ['major', 'minor', 'patch'] as const) {
|
|
29
|
+
if (pa[key] > pb[key]) return 1
|
|
30
|
+
if (pa[key] < pb[key]) return -1
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Both equal in major.minor.patch — compare prerelease
|
|
34
|
+
// A release (no prerelease) is greater than a prerelease
|
|
35
|
+
if (!pa.prerelease && pb.prerelease) return 1
|
|
36
|
+
if (pa.prerelease && !pb.prerelease) return -1
|
|
37
|
+
|
|
38
|
+
return 0
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isUpdateAvailable(current: string, latest: string): boolean {
|
|
42
|
+
const pl = parseSemVer(latest)
|
|
43
|
+
if (!pl || pl.prerelease) return false // latest must be stable
|
|
44
|
+
const cmp = compareSemVer(latest, current)
|
|
45
|
+
return cmp === 1
|
|
46
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {describe, expect, test} from 'bun:test'
|
|
2
|
+
import {stripCliLogs} from './stripCliLogs'
|
|
3
|
+
|
|
4
|
+
describe('stripCliLogs', () => {
|
|
5
|
+
test('strips leading log lines', () => {
|
|
6
|
+
const input = [
|
|
7
|
+
'11:40:09 [plugins] echo: registered /clawly_echo command',
|
|
8
|
+
'11:40:09 [plugins] tool: registered clawly_is_user_online agent tool',
|
|
9
|
+
'11:40:09 [plugins] tool: registered clawly_send_app_push agent tool',
|
|
10
|
+
'[{"id":"clawly-plugins","version":"1.5.0"}]',
|
|
11
|
+
].join('\n')
|
|
12
|
+
expect(stripCliLogs(input)).toBe('[{"id":"clawly-plugins","version":"1.5.0"}]')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('returns unchanged when no log lines', () => {
|
|
16
|
+
const input = '{"ok":true}'
|
|
17
|
+
expect(stripCliLogs(input)).toBe('{"ok":true}')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('handles single-digit hour', () => {
|
|
21
|
+
const input = '9:05:01 [core] init\n{"data":1}'
|
|
22
|
+
expect(stripCliLogs(input)).toBe('{"data":1}')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('returns empty string when all lines are logs', () => {
|
|
26
|
+
const input = '11:00:00 [a] foo\n12:00:00 [b] bar'
|
|
27
|
+
expect(stripCliLogs(input)).toBe('')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('preserves multiline JSON after logs', () => {
|
|
31
|
+
const input = ['11:40:09 [plugins] loading', '{', ' "version": "1.0.0"', '}'].join('\n')
|
|
32
|
+
expect(stripCliLogs(input)).toBe('{\n "version": "1.0.0"\n}')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('handles empty input', () => {
|
|
36
|
+
expect(stripCliLogs('')).toBe('')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('does not strip lines that merely contain a timestamp mid-line', () => {
|
|
40
|
+
const input = 'some prefix 11:40:09 [x] y\n{"ok":true}'
|
|
41
|
+
expect(stripCliLogs(input)).toBe('some prefix 11:40:09 [x] y\n{"ok":true}')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('strips ANSI color codes from log lines', () => {
|
|
45
|
+
const input = [
|
|
46
|
+
'\x1b[35m12:46:24 [plugins] presence: registered clawly.isOnline method',
|
|
47
|
+
'\x1b[35m12:46:24 [plugins] registered clawly.plugins.version',
|
|
48
|
+
'[{"id":"clawly-plugins","version":"1.5.0"}]',
|
|
49
|
+
].join('\n')
|
|
50
|
+
expect(stripCliLogs(input)).toBe('[{"id":"clawly-plugins","version":"1.5.0"}]')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('strips log lines without timestamps (after ANSI stripping)', () => {
|
|
54
|
+
const input = [
|
|
55
|
+
'\x1b[35m[plugins] echo: registered /clawly_echo command',
|
|
56
|
+
'\x1b[35m[plugins] tool: registered clawly_send_app_push agent tool',
|
|
57
|
+
'[{"id":"clawly-plugins"}]',
|
|
58
|
+
].join('\n')
|
|
59
|
+
expect(stripCliLogs(input)).toBe('[{"id":"clawly-plugins"}]')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('strips plain log lines without timestamps', () => {
|
|
63
|
+
const input = '[plugins] Loaded clawly-plugins plugin.\n{"ok":true}'
|
|
64
|
+
expect(stripCliLogs(input)).toBe('{"ok":true}')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('preserves JSON arrays (not confused with log prefixes)', () => {
|
|
68
|
+
const input = '[{"id":"test"}]'
|
|
69
|
+
expect(stripCliLogs(input)).toBe('[{"id":"test"}]')
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip leading log lines from `openclaw ... --json` output.
|
|
3
|
+
*
|
|
4
|
+
* OpenClaw CLI may emit log lines (with or without ANSI colors / timestamps)
|
|
5
|
+
* before the actual JSON payload:
|
|
6
|
+
*
|
|
7
|
+
* 11:40:09 [plugins] echo: registered /clawly_echo command
|
|
8
|
+
* \x1b[35m[plugins] tool: registered clawly_send_app_push agent tool
|
|
9
|
+
* [{"id":"clawly-plugins", ...}]
|
|
10
|
+
*
|
|
11
|
+
* This function strips ANSI escape sequences, drops every line that looks
|
|
12
|
+
* like a log prefix (`HH:MM:SS [tag] ...` or `[tag] ...`), and returns the
|
|
13
|
+
* remaining text (the JSON body).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g
|
|
17
|
+
const LOG_LINE_RE = /^(\d{1,2}:\d{2}:\d{2} )?\[\w[\w-]*\] /
|
|
18
|
+
|
|
19
|
+
export function stripCliLogs(output: string): string {
|
|
20
|
+
const cleaned = output.replace(ANSI_RE, '')
|
|
21
|
+
const lines = cleaned.split('\n')
|
|
22
|
+
const firstNonLog = lines.findIndex((line) => !LOG_LINE_RE.test(line))
|
|
23
|
+
if (firstNonLog === -1) return ''
|
|
24
|
+
return lines.slice(firstNonLog).join('\n').trim()
|
|
25
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@2en/clawly-plugins",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"module": "index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"files": [
|
|
15
15
|
"command",
|
|
16
16
|
"gateway",
|
|
17
|
+
"lib",
|
|
17
18
|
"tools",
|
|
18
19
|
"index.ts",
|
|
19
20
|
"calendar.ts",
|