@2en/clawly-plugins 1.20.0 → 1.21.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 +0 -2
- package/gateway/plugins.ts +106 -29
- package/model-gateway-setup.ts +22 -0
- package/package.json +1 -1
- package/gateway/channels-configure.ts +0 -151
package/gateway/index.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type {PluginApi} from '../index'
|
|
2
2
|
import {registerAgentSend} from './agent'
|
|
3
|
-
import {registerChannelsConfigure} from './channels-configure'
|
|
4
3
|
import {registerClawhub2gateway} from './clawhub2gateway'
|
|
5
4
|
import {registerConfigRepair} from './config-repair'
|
|
6
5
|
import {registerPairing} from './pairing'
|
|
@@ -17,7 +16,6 @@ export function registerGateway(api: PluginApi) {
|
|
|
17
16
|
registerMemoryBrowser(api)
|
|
18
17
|
registerClawhub2gateway(api)
|
|
19
18
|
registerPlugins(api)
|
|
20
|
-
registerChannelsConfigure(api)
|
|
21
19
|
registerOfflinePush(api)
|
|
22
20
|
registerConfigRepair(api)
|
|
23
21
|
registerPairing(api)
|
package/gateway/plugins.ts
CHANGED
|
@@ -17,6 +17,8 @@ import {stripCliLogs} from '../lib/stripCliLogs'
|
|
|
17
17
|
|
|
18
18
|
$.verbose = false
|
|
19
19
|
|
|
20
|
+
const PLUGIN_SENTINEL = 'openclaw.plugin.json'
|
|
21
|
+
|
|
20
22
|
interface NpmViewCache {
|
|
21
23
|
version: string
|
|
22
24
|
versions: string[]
|
|
@@ -56,6 +58,74 @@ async function fetchNpmView(npmPkgName: string): Promise<NpmViewCache | null> {
|
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Back up plugin dir before an install/update operation, verify the sentinel
|
|
63
|
+
* file exists after, and rollback from backup on failure. Prevents the
|
|
64
|
+
* "deleted plugin dir with no recovery" scenario seen in failed auto-updates.
|
|
65
|
+
*/
|
|
66
|
+
async function withPluginBackup<T>(
|
|
67
|
+
api: PluginApi,
|
|
68
|
+
pluginId: string,
|
|
69
|
+
operation: () => Promise<T>,
|
|
70
|
+
): Promise<T> {
|
|
71
|
+
const stateDir = resolveStateDir(api)
|
|
72
|
+
if (!stateDir) return operation()
|
|
73
|
+
|
|
74
|
+
const extensionDir = path.join(stateDir, 'extensions', pluginId)
|
|
75
|
+
const backupDir = `${extensionDir}.update-backup`
|
|
76
|
+
|
|
77
|
+
// Back up only if there's a valid existing installation.
|
|
78
|
+
// If backup fails, abort — a working plugin is more valuable than a risky update.
|
|
79
|
+
const hasExisting = fs.existsSync(path.join(extensionDir, PLUGIN_SENTINEL))
|
|
80
|
+
if (hasExisting) {
|
|
81
|
+
try {
|
|
82
|
+
if (fs.existsSync(backupDir)) await $`rm -rf ${backupDir}`
|
|
83
|
+
await $`cp -a ${extensionDir} ${backupDir}`
|
|
84
|
+
api.logger.info(`plugins: backed up ${pluginId}`)
|
|
85
|
+
} catch (err) {
|
|
86
|
+
// Clean up partial backup before aborting
|
|
87
|
+
if (fs.existsSync(backupDir)) await $`rm -rf ${backupDir}`.catch(() => {})
|
|
88
|
+
throw new Error(
|
|
89
|
+
`plugin backup failed, aborting update: ${err instanceof Error ? err.message : String(err)}`,
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const result = await operation()
|
|
96
|
+
|
|
97
|
+
// Verify sentinel exists after operation
|
|
98
|
+
if (!fs.existsSync(path.join(extensionDir, PLUGIN_SENTINEL))) {
|
|
99
|
+
// Clean up partial install if no backup exists (first-time install failure)
|
|
100
|
+
if (!hasExisting && fs.existsSync(extensionDir)) {
|
|
101
|
+
await $`rm -rf ${extensionDir}`.catch(() => {})
|
|
102
|
+
}
|
|
103
|
+
throw new Error(`plugin files missing after operation (${PLUGIN_SENTINEL} not found)`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Clean backup on success
|
|
107
|
+
if (fs.existsSync(backupDir)) {
|
|
108
|
+
await $`rm -rf ${backupDir}`.catch(() => {})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return result
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if (fs.existsSync(backupDir)) {
|
|
114
|
+
api.logger.warn(`plugins: rolling back ${pluginId} from backup`)
|
|
115
|
+
try {
|
|
116
|
+
if (fs.existsSync(extensionDir)) await $`rm -rf ${extensionDir}`
|
|
117
|
+
await $`mv ${backupDir} ${extensionDir}`
|
|
118
|
+
api.logger.info(`plugins: rollback complete`)
|
|
119
|
+
} catch (rollbackErr) {
|
|
120
|
+
api.logger.error(
|
|
121
|
+
`plugins: rollback failed — ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
throw err
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
59
129
|
export function registerPlugins(api: PluginApi) {
|
|
60
130
|
// ── clawly.plugins.version ──────────────────────────────────────
|
|
61
131
|
|
|
@@ -179,41 +249,48 @@ export function registerPlugins(api: PluginApi) {
|
|
|
179
249
|
// ignore
|
|
180
250
|
}
|
|
181
251
|
|
|
182
|
-
// 3.
|
|
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>
|
|
252
|
+
// 3-4. Back up existing dir, remove, install — rollback on failure
|
|
195
253
|
try {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
254
|
+
output = await withPluginBackup(api, pluginId, async () => {
|
|
255
|
+
const extensionDir = path.join(stateDir, 'extensions', pluginId)
|
|
256
|
+
if (fs.existsSync(extensionDir)) {
|
|
257
|
+
api.logger.info(`plugins: force — removing ${extensionDir}`)
|
|
258
|
+
await $`rm -rf ${extensionDir}`
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
api.logger.info(`plugins: force — installing ${installTarget}`)
|
|
262
|
+
const result = await $`openclaw plugins install ${installTarget}`
|
|
263
|
+
return result.stdout.trim()
|
|
264
|
+
})
|
|
265
|
+
} finally {
|
|
266
|
+
// 5. Always restore config entry (both on success and after rollback)
|
|
267
|
+
try {
|
|
268
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
|
269
|
+
if (!config.plugins) config.plugins = {}
|
|
270
|
+
if (!config.plugins.entries) config.plugins.entries = {}
|
|
271
|
+
config.plugins.entries[pluginId] = {
|
|
272
|
+
...config.plugins.entries[pluginId],
|
|
273
|
+
...savedEntry,
|
|
274
|
+
}
|
|
275
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
|
|
276
|
+
api.logger.info(`plugins: force — restored entry config for ${pluginId}`)
|
|
277
|
+
} catch (err) {
|
|
278
|
+
api.logger.warn(
|
|
279
|
+
`plugins: force — failed to restore entry config: ${err instanceof Error ? err.message : String(err)}`,
|
|
280
|
+
)
|
|
202
281
|
}
|
|
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
282
|
}
|
|
210
283
|
} else if (strategy === 'update') {
|
|
211
|
-
|
|
212
|
-
|
|
284
|
+
output = await withPluginBackup(api, pluginId, async () => {
|
|
285
|
+
const result = await $`openclaw plugins update ${pluginId}`
|
|
286
|
+
return result.stdout.trim()
|
|
287
|
+
})
|
|
213
288
|
} else {
|
|
214
289
|
// install
|
|
215
|
-
|
|
216
|
-
|
|
290
|
+
output = await withPluginBackup(api, pluginId, async () => {
|
|
291
|
+
const result = await $`openclaw plugins install ${installTarget}`
|
|
292
|
+
return result.stdout.trim()
|
|
293
|
+
})
|
|
217
294
|
}
|
|
218
295
|
|
|
219
296
|
// Invalidate npm cache for this package
|
package/model-gateway-setup.ts
CHANGED
|
@@ -123,6 +123,28 @@ export function setupModelGateway(api: PluginApi): void {
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
// Backfill pluginConfig from existing provider credentials (legacy sprites
|
|
127
|
+
// provisioned before plugin config was written during configure phase).
|
|
128
|
+
const cfg = api.pluginConfig as Record<string, unknown> | undefined
|
|
129
|
+
if (
|
|
130
|
+
existingProvider.baseUrl &&
|
|
131
|
+
existingProvider.apiKey &&
|
|
132
|
+
(!cfg?.modelGatewayBaseUrl || !cfg?.modelGatewayToken)
|
|
133
|
+
) {
|
|
134
|
+
const entries = (config.plugins as any)?.entries?.['clawly-plugins']
|
|
135
|
+
if (entries) {
|
|
136
|
+
entries.config = {
|
|
137
|
+
...entries.config,
|
|
138
|
+
modelGatewayBaseUrl: existingProvider.baseUrl,
|
|
139
|
+
modelGatewayToken: existingProvider.apiKey,
|
|
140
|
+
}
|
|
141
|
+
dirty = true
|
|
142
|
+
api.logger.info(
|
|
143
|
+
'Model gateway: backfilled pluginConfig from existing provider credentials.',
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
126
148
|
if (dirty) {
|
|
127
149
|
existingProvider.models = existingModels
|
|
128
150
|
defaults.models = existingAliases
|
package/package.json
CHANGED
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Channel configuration management via openclaw.json.
|
|
3
|
-
*
|
|
4
|
-
* Methods:
|
|
5
|
-
* - clawly.channels.configure — add/update a channel account config
|
|
6
|
-
* - clawly.channels.disconnect — remove a channel account config
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import fs from 'node:fs'
|
|
10
|
-
import path from 'node:path'
|
|
11
|
-
|
|
12
|
-
import type {PluginApi} from '../index'
|
|
13
|
-
|
|
14
|
-
const TOKEN_CHANNELS = new Set(['telegram', 'discord', 'slack', 'irc', 'googlechat'])
|
|
15
|
-
|
|
16
|
-
function resolveStateDir(api: PluginApi): string {
|
|
17
|
-
return api.runtime.state?.resolveStateDir?.(process.env) ?? process.env.OPENCLAW_STATE_DIR ?? ''
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function readOpenclawConfig(configPath: string): Record<string, unknown> {
|
|
21
|
-
try {
|
|
22
|
-
return JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
|
23
|
-
} catch {
|
|
24
|
-
return {}
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function writeOpenclawConfig(configPath: string, config: Record<string, unknown>) {
|
|
29
|
-
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function registerChannelsConfigure(api: PluginApi) {
|
|
33
|
-
// ── clawly.channels.configure ──────────────────────────────────
|
|
34
|
-
|
|
35
|
-
api.registerGatewayMethod('clawly.channels.configure', async ({params, respond}) => {
|
|
36
|
-
const channel = typeof params.channel === 'string' ? params.channel.trim() : ''
|
|
37
|
-
const accountId = typeof params.accountId === 'string' ? params.accountId.trim() : 'default'
|
|
38
|
-
const config =
|
|
39
|
-
params.config && typeof params.config === 'object' && !Array.isArray(params.config)
|
|
40
|
-
? (params.config as Record<string, unknown>)
|
|
41
|
-
: null
|
|
42
|
-
|
|
43
|
-
if (!channel) {
|
|
44
|
-
respond(false, undefined, {code: 'invalid_params', message: 'channel is required'})
|
|
45
|
-
return
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
if (!TOKEN_CHANNELS.has(channel)) {
|
|
49
|
-
respond(false, undefined, {
|
|
50
|
-
code: 'invalid_params',
|
|
51
|
-
message: `unsupported channel: ${channel}. Supported: ${[...TOKEN_CHANNELS].join(', ')}`,
|
|
52
|
-
})
|
|
53
|
-
return
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (!config) {
|
|
57
|
-
respond(false, undefined, {code: 'invalid_params', message: 'config object is required'})
|
|
58
|
-
return
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const stateDir = resolveStateDir(api)
|
|
62
|
-
if (!stateDir) {
|
|
63
|
-
respond(false, undefined, {code: 'internal', message: 'cannot resolve openclaw state dir'})
|
|
64
|
-
return
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const configPath = path.join(stateDir, 'openclaw.json')
|
|
68
|
-
|
|
69
|
-
try {
|
|
70
|
-
const ocConfig = readOpenclawConfig(configPath)
|
|
71
|
-
|
|
72
|
-
// Ensure channels.<channel>.accounts.<accountId> path exists
|
|
73
|
-
if (!ocConfig.channels || typeof ocConfig.channels !== 'object') {
|
|
74
|
-
ocConfig.channels = {}
|
|
75
|
-
}
|
|
76
|
-
const channels = ocConfig.channels as Record<string, unknown>
|
|
77
|
-
|
|
78
|
-
if (!channels[channel] || typeof channels[channel] !== 'object') {
|
|
79
|
-
channels[channel] = {}
|
|
80
|
-
}
|
|
81
|
-
const channelConfig = channels[channel] as Record<string, unknown>
|
|
82
|
-
|
|
83
|
-
if (!channelConfig.accounts || typeof channelConfig.accounts !== 'object') {
|
|
84
|
-
channelConfig.accounts = {}
|
|
85
|
-
}
|
|
86
|
-
const accounts = channelConfig.accounts as Record<string, unknown>
|
|
87
|
-
|
|
88
|
-
// Merge config with enabled: true
|
|
89
|
-
accounts[accountId] = {
|
|
90
|
-
...((accounts[accountId] as Record<string, unknown>) ?? {}),
|
|
91
|
-
...config,
|
|
92
|
-
enabled: true,
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
writeOpenclawConfig(configPath, ocConfig)
|
|
96
|
-
api.logger.info(`channels.configure: wrote ${channel}/${accountId} to openclaw.json`)
|
|
97
|
-
|
|
98
|
-
respond(true, {ok: true, channel, accountId})
|
|
99
|
-
} catch (err) {
|
|
100
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
101
|
-
api.logger.error(`channels.configure: failed — ${msg}`)
|
|
102
|
-
respond(false, undefined, {code: 'internal', message: msg})
|
|
103
|
-
}
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
// ── clawly.channels.disconnect ─────────────────────────────────
|
|
107
|
-
|
|
108
|
-
api.registerGatewayMethod('clawly.channels.disconnect', async ({params, respond}) => {
|
|
109
|
-
const channel = typeof params.channel === 'string' ? params.channel.trim() : ''
|
|
110
|
-
const accountId = typeof params.accountId === 'string' ? params.accountId.trim() : 'default'
|
|
111
|
-
|
|
112
|
-
if (!channel) {
|
|
113
|
-
respond(false, undefined, {code: 'invalid_params', message: 'channel is required'})
|
|
114
|
-
return
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const stateDir = resolveStateDir(api)
|
|
118
|
-
if (!stateDir) {
|
|
119
|
-
respond(false, undefined, {code: 'internal', message: 'cannot resolve openclaw state dir'})
|
|
120
|
-
return
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const configPath = path.join(stateDir, 'openclaw.json')
|
|
124
|
-
|
|
125
|
-
try {
|
|
126
|
-
const ocConfig = readOpenclawConfig(configPath)
|
|
127
|
-
const channels = ocConfig.channels as Record<string, unknown> | undefined
|
|
128
|
-
|
|
129
|
-
if (channels && typeof channels === 'object') {
|
|
130
|
-
const channelConfig = channels[channel] as Record<string, unknown> | undefined
|
|
131
|
-
if (channelConfig && typeof channelConfig === 'object') {
|
|
132
|
-
const accounts = channelConfig.accounts as Record<string, unknown> | undefined
|
|
133
|
-
if (accounts && typeof accounts === 'object' && accountId in accounts) {
|
|
134
|
-
delete accounts[accountId]
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
writeOpenclawConfig(configPath, ocConfig)
|
|
140
|
-
api.logger.info(`channels.disconnect: removed ${channel}/${accountId} from openclaw.json`)
|
|
141
|
-
|
|
142
|
-
respond(true, {ok: true, channel, accountId})
|
|
143
|
-
} catch (err) {
|
|
144
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
145
|
-
api.logger.error(`channels.disconnect: failed — ${msg}`)
|
|
146
|
-
respond(false, undefined, {code: 'internal', message: msg})
|
|
147
|
-
}
|
|
148
|
-
})
|
|
149
|
-
|
|
150
|
-
api.logger.info('channels: registered clawly.channels.configure + clawly.channels.disconnect')
|
|
151
|
-
}
|