@coclaw/openclaw-coclaw 0.1.6 → 0.1.7

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/README.md CHANGED
@@ -5,51 +5,60 @@ CoClaw 的 OpenClaw 插件(npm: `@coclaw/openclaw-coclaw`,plugin id: `opencl
5
5
  - **transport bridge** — CoClaw server 与 OpenClaw gateway 之间的实时消息桥接
6
6
  - **session-manager** — 会话列表/读取能力(`nativeui.sessions.listAll` / `nativeui.sessions.get`)
7
7
 
8
- ## 安装
8
+ ## 安装与模式切换
9
9
 
10
- ### 从 npm 安装(生产推荐)
10
+ 插件支持两种安装模式,可随时切换(脚本会自动处理卸载→重装):
11
+
12
+ ### 本地开发(link 模式,日常开发推荐)
11
13
 
12
14
  ```bash
13
- pnpm run plugin:npm:install
14
- # 或手动:
15
- openclaw plugins install @coclaw/openclaw-coclaw
16
- openclaw gateway restart
15
+ pnpm run link
17
16
  ```
18
17
 
19
- ### 本地开发安装(--link)
18
+ link 后代码更新只需 `openclaw gateway restart`,无需重新安装。
19
+
20
+ ### 从 npm 安装
20
21
 
21
22
  ```bash
22
- pnpm run plugin:dev:link
23
- # 或手动:
24
- openclaw plugins install --link /path/to/plugins/openclaw
25
- openclaw gateway restart
23
+ pnpm run install:npm
26
24
  ```
27
25
 
28
- 安装后确认:
26
+ ### 卸载
29
27
 
30
28
  ```bash
31
- openclaw plugins doctor
32
- openclaw gateway status
29
+ pnpm run unlink # 卸载 link 模式
30
+ pnpm run uninstall:npm # 卸载 npm 模式
33
31
  ```
34
32
 
35
- ## 卸载
33
+ 卸载仅移除插件元数据和代码,不清理绑定信息(`bindings.json` 独立保留)。
34
+
35
+ ## 预发布验证与发布
36
+
37
+ ### 预发布验证
36
38
 
37
- ### npm 安装的卸载
39
+ 发布前验证 tarball 能正确安装到 OpenClaw 中:
38
40
 
39
41
  ```bash
40
- pnpm run plugin:npm:uninstall
42
+ pnpm run prerelease # 全新安装验证(交互式,含手动功能验证)
43
+ pnpm run prerelease -- --upgrade # 升级验证(先装 npm 旧版,再用本地包覆盖)
41
44
  ```
42
45
 
43
- ### 本地开发安装的卸载
46
+ ### 发布到 npm
44
47
 
45
48
  ```bash
46
- pnpm run plugin:dev:unlink
49
+ pnpm run release
47
50
  ```
48
51
 
49
- 卸载脚本会自动清理:
50
- - `plugins.entries` / `plugins.installs` 等插件元数据
51
- - `~/.openclaw/coclaw/bindings.json`(绑定信息)
52
- - `openclaw.json` 中可能残留的 `channels.coclaw` 节点(旧版兼容)
52
+ 发布流程:预发布验证(自动) → npm 凭据检查 → dry-run → 发布 → 轮询确认生效。
53
+
54
+ ### 检查发布状态
55
+
56
+ ```bash
57
+ pnpm run release:check # 显示各 registry 最新版本
58
+ pnpm run release:check -- 0.1.7 # 对比指定版本
59
+ WAIT=1 pnpm run release:check -- 0.1.7 # 轮询直到版本生效
60
+ pnpm run release:versions # 显示所有已发布版本
61
+ ```
53
62
 
54
63
  ## 绑定 / 解绑
55
64
 
@@ -102,7 +111,6 @@ node ~/.openclaw/extensions/coclaw/src/cli.js unbind --server <url>
102
111
  说明:
103
112
  - 这一设计是为了避免卸载插件后 `channels.coclaw` 节点残留导致 OpenClaw gateway schema 验证失败。
104
113
  - `config.js` 是读写绑定信息的唯一入口。
105
- - 首次读取时会自动从旧位置(`openclaw.json` 的 `channels.coclaw` / `.coclaw-tunnel.json`)迁移。
106
114
  - 绑定时不提交 bot `name`;server 通过 gateway WebSocket 获取 OpenClaw 实例名。若未设置实例名,前端回退显示 `OpenClaw`。
107
115
 
108
116
  ## 运行与排障日志
@@ -150,4 +158,4 @@ pnpm coverage # 覆盖率检查
150
158
  pnpm verify # 完整验证(check → test:standalone → test:plugin → test → coverage)
151
159
  ```
152
160
 
153
- 覆盖率阈值:100%(lines/functions/branches/statements),未通过禁止接入 gateway。
161
+ 覆盖率阈值:lines/statements/functions 100%,branches ≥ 95%。未通过禁止接入 gateway。
package/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { bindBot, unbindBot } from './src/common/bot-binding.js';
2
2
  import { registerCoclawCli } from './src/cli-registrar.js';
3
3
  import { resolveErrorMessage } from './src/common/errors.js';
4
- import { alreadyBound, notBound, bindOk, unbindOk } from './src/common/messages.js';
4
+ import { notBound, bindOk, unbindOk } from './src/common/messages.js';
5
5
  import { coclawChannelPlugin } from './src/channel-plugin.js';
6
6
  import { refreshRealtimeBridge, startRealtimeBridge, stopRealtimeBridge } from './src/realtime-bridge.js';
7
7
  import { setRuntime } from './src/runtime.js';
@@ -118,6 +118,8 @@ const plugin = {
118
118
 
119
119
  try {
120
120
  if (action === 'bind') {
121
+ // 先断开 bridge,避免 unbindWithServer 触发的 bot.unbound 竞态
122
+ await stopRealtimeBridge();
121
123
  const serverUrl = options.server ?? api.pluginConfig?.serverUrl;
122
124
  const result = await bindBot({
123
125
  code: positionals[0],
@@ -136,9 +138,6 @@ const plugin = {
136
138
  return { text: buildHelpText() };
137
139
  }
138
140
  catch (err) {
139
- if (err.code === 'ALREADY_BOUND') {
140
- return { text: alreadyBound(err) };
141
- }
142
141
  if (err.code === 'NOT_BOUND') {
143
142
  return { text: notBound() };
144
143
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw CoClaw channel plugin for remote chat",
@@ -45,23 +45,23 @@
45
45
  },
46
46
  "scripts": {
47
47
  "dev": "node src/cli.js --help",
48
- "build": "echo \"TODO: build coclaw openclaw plugin\"",
48
+ "build": "echo 'No build step needed (pure ES modules)'",
49
49
  "lint": "eslint \"**/*.{js,mjs,cjs}\" --no-error-on-unmatched-pattern",
50
50
  "typecheck": "echo \"No typecheck configured yet (coclaw openclaw plugin)\"",
51
51
  "check": "pnpm lint && pnpm typecheck",
52
52
  "test:standalone": "node --test src/standalone-mode.test.js",
53
53
  "test:plugin": "node --test src/plugin-mode.test.js",
54
54
  "test": "node --test",
55
- "coverage": "c8 --check-coverage --lines 100 --functions 100 --branches 100 --statements 100 --reporter=text --reporter=lcov node --test",
55
+ "coverage": "c8 --check-coverage --lines 100 --functions 100 --branches 95 --statements 100 --reporter=text --reporter=lcov node --test",
56
56
  "verify": "pnpm check && pnpm test:standalone && pnpm test:plugin && pnpm test && pnpm coverage",
57
- "pub:verify": "pnpm verify",
58
- "pub:check": "npm whoami --registry=https://registry.npmjs.org/ && npm ping --registry=https://registry.npmjs.org/ && npm publish --dry-run --access public --registry=https://registry.npmjs.org/",
59
- "pub:versions": "npm view @coclaw/openclaw-coclaw versions --json --registry=https://registry.npmjs.org/ && npm view @coclaw/openclaw-coclaw versions --json",
60
- "pub:release": "bash ./scripts/publish-npm.sh",
61
- "plugin:dev:link": "bash ./scripts/dev-link-install.sh",
62
- "plugin:dev:unlink": "bash ./scripts/dev-link-uninstall.sh",
63
- "plugin:npm:install": "bash ./scripts/npm-install.sh",
64
- "plugin:npm:uninstall": "bash ./scripts/npm-uninstall.sh"
57
+ "link": "bash ./scripts/link.sh",
58
+ "unlink": "bash ./scripts/unlink.sh",
59
+ "install:npm": "bash ./scripts/install-npm.sh",
60
+ "uninstall:npm": "bash ./scripts/uninstall-npm.sh",
61
+ "prerelease": "bash ./scripts/prerelease.sh",
62
+ "release": "bash ./scripts/release.sh",
63
+ "release:check": "bash ./scripts/release-check.sh",
64
+ "release:versions": "npm view @coclaw/openclaw-coclaw versions --json --registry=https://registry.npmjs.org/ && npm view @coclaw/openclaw-coclaw versions --json"
65
65
  },
66
66
  "devDependencies": {
67
67
  "c8": "^10.1.3",
package/src/api.js CHANGED
@@ -1,10 +1,14 @@
1
- async function requestJson(baseUrl, path, { method = 'GET', headers, body } = {}) {
1
+ async function requestJson(baseUrl, path, { method = 'GET', headers, body, timeout } = {}) {
2
2
  const url = new URL(path, baseUrl).toString();
3
- const res = await fetch(url, {
3
+ const fetchOpts = {
4
4
  method,
5
5
  headers,
6
6
  body: body == null ? undefined : JSON.stringify(body),
7
- });
7
+ };
8
+ if (timeout) {
9
+ fetchOpts.signal = AbortSignal.timeout(timeout);
10
+ }
11
+ const res = await fetch(url, fetchOpts);
8
12
  let data = null;
9
13
  try {
10
14
  data = await res.json();
@@ -21,37 +25,25 @@ async function requestJson(baseUrl, path, { method = 'GET', headers, body } = {}
21
25
  return data;
22
26
  }
23
27
 
28
+ const BIND_TIMEOUT = 30_000;
29
+ const UNBIND_TIMEOUT = 15_000;
30
+
24
31
  export async function bindWithServer({ baseUrl, code, name }) {
25
32
  return requestJson(baseUrl, '/api/v1/bots/bind', {
26
33
  method: 'POST',
27
34
  headers: { 'content-type': 'application/json' },
28
35
  body: { code, name },
36
+ timeout: BIND_TIMEOUT,
29
37
  });
30
38
  }
31
39
 
32
- export async function unbindWithServer({ baseUrl, token }) {
40
+ export async function unbindWithServer({ baseUrl, token, timeout = UNBIND_TIMEOUT }) {
33
41
  return requestJson(baseUrl, '/api/v1/bots/unbind', {
34
42
  method: 'POST',
35
43
  headers: {
36
44
  Authorization: `Bearer ${token}`,
37
45
  },
46
+ timeout,
38
47
  });
39
48
  }
40
49
 
41
- export async function listBotsWithServer({ baseUrl, cookie }) {
42
- return requestJson(baseUrl, '/api/v1/bots', {
43
- headers: cookie
44
- ? {
45
- Cookie: cookie,
46
- }
47
- : undefined,
48
- });
49
- }
50
-
51
- export async function getBotSelfWithServer({ baseUrl, token }) {
52
- return requestJson(baseUrl, '/api/v1/bots/self', {
53
- headers: {
54
- Authorization: `Bearer ${token}`,
55
- },
56
- });
57
- }
@@ -1,5 +1,4 @@
1
1
  import { DEFAULT_ACCOUNT_ID } from './config.js';
2
- import { createTransportAdapter } from './transport-adapter.js';
3
2
 
4
3
  function resolveAccount(_cfg, accountId) {
5
4
  const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
@@ -11,8 +10,6 @@ function resolveAccount(_cfg, accountId) {
11
10
  };
12
11
  }
13
12
 
14
- const transport = createTransportAdapter();
15
-
16
13
  export const coclawChannelPlugin = {
17
14
  id: 'coclaw',
18
15
  meta: {
@@ -44,21 +41,15 @@ export const coclawChannelPlugin = {
44
41
  },
45
42
  outbound: {
46
43
  deliveryMode: 'direct',
47
- sendText: async ({ to, text }) => {
48
- const result = await transport.safeDispatchOutbound({
49
- channel: 'coclaw',
50
- to,
51
- text,
52
- });
53
- return {
54
- channel: 'coclaw',
55
- messageId: result.messageId ?? `coclaw-local-${Date.now()}`,
56
- to,
57
- text,
58
- accepted: Boolean(result.accepted),
59
- };
60
- },
44
+ // placeholder: CoClaw 消息实际通过 realtime-bridge WebSocket 桥接发送,
45
+ // sendText 仅满足 OpenClaw channel 注册要求。
46
+ sendText: async ({ to }) => ({
47
+ channel: 'coclaw',
48
+ messageId: `coclaw-${Date.now()}`,
49
+ to,
50
+ }),
61
51
  },
52
+ // TODO: status.defaultRuntime.running 应反映 realtime-bridge 实际连接状态
62
53
  status: {
63
54
  defaultRuntime: {
64
55
  accountId: DEFAULT_ACCOUNT_ID,
@@ -2,7 +2,7 @@ import { bindBot, unbindBot } from './common/bot-binding.js';
2
2
  import { resolveErrorMessage } from './common/errors.js';
3
3
  import { callGatewayMethod } from './common/gateway-notify.js';
4
4
  import {
5
- alreadyBound, notBound, bindOk, unbindOk,
5
+ notBound, bindOk, unbindOk,
6
6
  gatewayNotified, gatewayNotifyFailed,
7
7
  } from './common/messages.js';
8
8
 
@@ -47,17 +47,14 @@ export function registerCoclawCli({ program, config, logger }, deps = {}) {
47
47
  .option('--server <url>', 'CoClaw server URL')
48
48
  .action(async (code, opts) => {
49
49
  try {
50
+ // 先断开 bridge,避免 unbindWithServer 触发的 bot.unbound 竞态
51
+ await notifyGateway('coclaw.stopBridge');
50
52
  const serverUrl = resolveServerUrl(opts, config);
51
53
  const result = await bindBot({ code, serverUrl });
52
54
  /* c8 ignore next */
53
55
  console.log(bindOk(result));
54
56
  await notifyGateway('coclaw.refreshBridge');
55
57
  } catch (err) {
56
- if (err.code === 'ALREADY_BOUND') {
57
- console.error(alreadyBound(err));
58
- process.exitCode = 1;
59
- return;
60
- }
61
58
  console.error(`Error: ${resolveErrorMessage(err)}`);
62
59
  process.exitCode = 1;
63
60
  }
package/src/cli.js CHANGED
@@ -7,7 +7,7 @@ import { bindBot, unbindBot } from './common/bot-binding.js';
7
7
  import { resolveErrorMessage } from './common/errors.js';
8
8
  import { callGatewayMethod } from './common/gateway-notify.js';
9
9
  import {
10
- alreadyBound, notBound, bindOk, unbindOk,
10
+ notBound, bindOk, unbindOk,
11
11
  gatewayNotified, gatewayNotifyFailed,
12
12
  } from './common/messages.js';
13
13
 
@@ -66,22 +66,16 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
66
66
  }
67
67
 
68
68
  if (command === 'bind') {
69
- try {
70
- const result = await bindBot({
71
- code: positionals[0],
72
- serverUrl: options.server,
73
- });
74
- /* c8 ignore next */
75
- console.log(bindOk(result));
76
- await notifyGateway('coclaw.refreshBridge', deps);
77
- return 0;
78
- } catch (err) {
79
- if (err.code === 'ALREADY_BOUND') {
80
- console.error(alreadyBound(err));
81
- return 1;
82
- }
83
- throw err;
84
- }
69
+ // 先断开 bridge,避免 unbindWithServer 触发的 bot.unbound 竞态
70
+ await notifyGateway('coclaw.stopBridge', deps);
71
+ const result = await bindBot({
72
+ code: positionals[0],
73
+ serverUrl: options.server,
74
+ });
75
+ /* c8 ignore next */
76
+ console.log(bindOk(result));
77
+ await notifyGateway('coclaw.refreshBridge', deps);
78
+ return 0;
85
79
  }
86
80
 
87
81
  if (command === 'unbind') {
@@ -9,11 +9,20 @@ export async function bindBot({ code, serverUrl }) {
9
9
  }
10
10
 
11
11
  const config = await readConfig();
12
+
13
+ // 已绑定时自动解绑再重绑(解绑尽力而为,不阻塞新绑定)
14
+ let previousBotId;
12
15
  if (config?.token) {
13
- const err = new Error('already bound, please unbind first');
14
- err.code = 'ALREADY_BOUND';
15
- err.botId = config.botId;
16
- throw err;
16
+ previousBotId = config.botId || 'unknown';
17
+ const oldBaseUrl = config.serverUrl;
18
+ if (oldBaseUrl) {
19
+ try {
20
+ await unbindWithServer({ baseUrl: oldBaseUrl, token: config.token });
21
+ } catch {
22
+ // 尽力而为,忽略解绑错误
23
+ }
24
+ }
25
+ await clearConfig();
17
26
  }
18
27
 
19
28
  /* c8 ignore next */
@@ -27,18 +36,17 @@ export async function bindBot({ code, serverUrl }) {
27
36
  throw new Error('invalid bind response');
28
37
  }
29
38
 
30
- const next = {
31
- ...config,
39
+ await writeConfig({
32
40
  serverUrl: baseUrl,
33
41
  botId: data.botId,
34
42
  token: data.token,
35
43
  boundAt: new Date().toISOString(),
36
- };
37
- await writeConfig(next);
44
+ });
38
45
 
39
46
  return {
40
47
  botId: data.botId,
41
48
  rebound: Boolean(data.rebound),
49
+ previousBotId,
42
50
  };
43
51
  }
44
52
 
@@ -1,8 +1,11 @@
1
1
  // bind/unbind CLI 及 command 的用户提示文案(统一出口)
2
2
 
3
- export function bindOk({ botId, rebound }) {
3
+ export function bindOk({ botId, rebound, previousBotId }) {
4
4
  const action = rebound ? 're-bound' : 'bound';
5
- return `OK. Bot (${botId}) ${action} to CoClaw.`;
5
+ const prev = previousBotId
6
+ ? ` (previous binding to bot ${previousBotId} was auto-removed)`
7
+ : '';
8
+ return `OK. Bot (${botId}) ${action} to CoClaw.${prev}`;
6
9
  }
7
10
 
8
11
  export function unbindOk({ botId, serverError }) {
@@ -13,11 +16,6 @@ export function unbindOk({ botId, serverError }) {
13
16
  return `OK. Bot (${id}) unbound from CoClaw.${tag}`;
14
17
  }
15
18
 
16
- export function alreadyBound({ botId }) {
17
- const id = botId ?? 'unknown';
18
- return `Already bound to CoClaw as bot (${id}).\nRun \`openclaw coclaw unbind\` to unbind first.`;
19
- }
20
-
21
19
  export function notBound() {
22
20
  return 'Not bound. Nothing to unbind.';
23
21
  }
package/src/config.js CHANGED
@@ -48,112 +48,12 @@ async function writeJson(filePath, value) {
48
48
  await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
49
49
  }
50
50
 
51
- // --- 旧位置迁移 ---
52
-
53
- function getOpenclawConfigPath() {
54
- return process.env.OPENCLAW_CONFIG_PATH
55
- ? nodePath.resolve(process.env.OPENCLAW_CONFIG_PATH)
56
- : nodePath.join(os.homedir(), '.openclaw', 'openclaw.json');
57
- }
58
-
59
- function pickFromOldConfig(rootCfg) {
60
- const channels = toRecord(rootCfg.channels);
61
- const coclaw = toRecord(channels.coclaw);
62
- const accounts = toRecord(coclaw.accounts);
63
- const account = toRecord(accounts.default);
64
- return {
65
- serverUrl: account.serverUrl ?? coclaw.serverUrl,
66
- botId: account.botId ?? coclaw.botId,
67
- token: account.token ?? coclaw.token,
68
- boundAt: account.boundAt ?? coclaw.boundAt,
69
- };
70
- }
71
-
72
- async function tryMigrateFromOldLocations() {
73
- // 1. 尝试从 openclaw.json channels.coclaw 迁移
74
- const rt = getRuntime();
75
- let oldData;
76
- if (rt?.config?.loadConfig) {
77
- oldData = pickFromOldConfig(rt.config.loadConfig());
78
- } else {
79
- const rootCfg = await readJson(getOpenclawConfigPath());
80
- oldData = pickFromOldConfig(rootCfg);
81
- }
82
- if (oldData.token) {
83
- return oldData;
84
- }
85
-
86
- // 2. 尝试从 legacy 文件迁移
87
- const legacyPaths = [
88
- nodePath.resolve(process.cwd(), '.coclaw-tunnel.json'),
89
- nodePath.join(os.homedir(), '.coclaw-tunnel.json'),
90
- ];
91
- for (const p of legacyPaths) {
92
- const legacy = await readJson(p);
93
- if (legacy?.token) {
94
- return legacy;
95
- }
96
- }
97
-
98
- return null;
99
- }
100
-
101
- async function cleanOldLocations() {
102
- // 清理 openclaw.json 中的 channels.coclaw
103
- const rt = getRuntime();
104
- if (rt?.config?.loadConfig && rt?.config?.writeConfigFile) {
105
- const rootCfg = structuredClone(rt.config.loadConfig());
106
- const channels = toRecord(rootCfg.channels);
107
- if (channels.coclaw) {
108
- delete channels.coclaw;
109
- rootCfg.channels = channels;
110
- await rt.config.writeConfigFile(rootCfg);
111
- }
112
- } else {
113
- const filePath = getOpenclawConfigPath();
114
- const rootCfg = toRecord(await readJson(filePath));
115
- const channels = toRecord(rootCfg.channels);
116
- if (channels.coclaw) {
117
- delete channels.coclaw;
118
- rootCfg.channels = channels;
119
- await writeJson(filePath, rootCfg);
120
- }
121
- }
122
-
123
- // 清理 legacy 文件
124
- const legacyPaths = [
125
- nodePath.resolve(process.cwd(), '.coclaw-tunnel.json'),
126
- nodePath.join(os.homedir(), '.coclaw-tunnel.json'),
127
- ];
128
- for (const p of legacyPaths) {
129
- const legacy = await readJson(p);
130
- if (legacy?.token) {
131
- await writeJson(p, {});
132
- }
133
- }
134
- }
135
-
136
51
  // --- 公共 API ---
137
52
 
138
53
  export async function readConfig(accountId = DEFAULT_ACCOUNT_ID) {
139
54
  const bindingsPath = getBindingsPath();
140
55
  const bindings = await readJson(bindingsPath);
141
- const entry = toRecord(bindings[accountId]);
142
-
143
- if (entry.token) {
144
- return entry;
145
- }
146
-
147
- // 首次运行:尝试从旧位置迁移
148
- const migrated = await tryMigrateFromOldLocations();
149
- if (migrated?.token) {
150
- const newBindings = { ...bindings, [accountId]: migrated };
151
- await writeJson(bindingsPath, newBindings);
152
- await cleanOldLocations();
153
- return migrated;
154
- }
155
-
156
- return entry;
56
+ return toRecord(bindings[accountId]);
157
57
  }
158
58
 
159
59
  export async function writeConfig(nextConfig, accountId = DEFAULT_ACCOUNT_ID) {
@@ -188,7 +88,4 @@ export async function clearConfig(accountId = DEFAULT_ACCOUNT_ID) {
188
88
  } else {
189
89
  await writeJson(bindingsPath, bindings);
190
90
  }
191
-
192
- // 确保清理旧位置残留
193
- await cleanOldLocations();
194
91
  }
@@ -1,3 +1,5 @@
1
+ // placeholder: 当前仅被 transport-adapter 使用,预留用于未来 channel outbound 消息规范化。
2
+
1
3
  function isNonEmptyString(value) {
2
4
  return typeof value === 'string' && value.trim() !== '';
3
5
  }