@app-public/weaverbird-debug 1.0.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/README.md ADDED
@@ -0,0 +1,271 @@
1
+ # WeaverBird CLI
2
+
3
+ WeaverBird CI 开放 CLI,通过 OAuth 2.0 登录后访问 CI 平台 API。与 CI 流水线共用同一 OAuth 应用,凭证按环境配置在 `config/profiles/` 中。
4
+
5
+ 设计说明见飞书文档:[CI 系统 2.5 版本方案设计 — CI 开放 Cli 认证](https://vesync.feishu.cn/wiki/EJRjwbQJki7ozXkJOIZcvmppngd)
6
+
7
+ ## 环境要求
8
+
9
+ - Node.js >= 16
10
+
11
+ ## 安装
12
+
13
+ ```bash
14
+ cd cli
15
+ npm install
16
+ ```
17
+
18
+ 全局联调(可选):
19
+
20
+ ```bash
21
+ npm link
22
+ ```
23
+
24
+ 或从 npm 安装(发布后,按环境选择包,scope 为 `@app-public`):
25
+
26
+ ```bash
27
+ npm install -g @app-public/weaverbird # 线上(命令仍为 weaverbird)
28
+ npm install -g @app-public/weaverbird-debug # 本地调试
29
+ npm install -g @app-public/weaverbird-test # 测试环境
30
+ ```
31
+
32
+ > scoped 包 `@app-public/weaverbird` 与公共 npm 上无 scope 的 `weaverbird`(Vue 查询构建器)是**不同包**,不会冲突。
33
+
34
+ ## CLI 命令
35
+
36
+ ```bash
37
+ weaverbird cli login # 线上(或对应环境的命令名)
38
+ weaverbird-debug cli login # 本地调试
39
+ weaverbird-test cli login # 测试环境
40
+ weaverbird cli logout # 清除当前环境本地 token
41
+ weaverbird cli status # 查询登录状态及 token 信息
42
+ weaverbird cli --version # 显示 CLI 版本号
43
+ weaverbird cli --help # 显示命令帮助
44
+ ```
45
+
46
+ ### login 流程
47
+
48
+ 1. 本地启动 `http://localhost:3333/callback` 回调服务
49
+ 2. CLI 调用 `POST /v1/api/ci/oauth/createAuthorizeSession` 创建**一次性、5 分钟有效**的授权会话
50
+ 3. 自动打开浏览器访问 `{WEAVERBIRD_URL}/weaverbird/oauth/authorize?session=...`(链接不可复制复用)
51
+ 4. 用户授权后回调带 `code`,CLI 调用 `POST /v1/api/ci/oauth/token` 换取 token
52
+ 5. token 写入 `~/.weaverbird/token.{env}.json`(如 `token.release.json`)
53
+
54
+ ### status 输出示例
55
+
56
+ 已登录:
57
+
58
+ ```json
59
+ {
60
+ "logged_in": true,
61
+ "env": "debug",
62
+ "ci_base_url": "http://10.25.72.141:16644",
63
+ "token_path": "C:\\Users\\xxx\\.weaverbird\\token.debug.json",
64
+ "token": {
65
+ "access_token": "eyJxxx",
66
+ "token_type": "Bearer",
67
+ "expires_in": 7200,
68
+ "refresh_token": "xxx"
69
+ }
70
+ }
71
+ ```
72
+
73
+ 未登录:
74
+
75
+ ```json
76
+ {
77
+ "logged_in": false,
78
+ "env": "debug",
79
+ "ci_base_url": "http://10.25.72.141:16644",
80
+ "token_path": "C:\\Users\\xxx\\.weaverbird\\token.debug.json",
81
+ "token": null
82
+ }
83
+ ```
84
+
85
+ ## 多环境配置
86
+
87
+ CLI 通过**命令名**区分环境(`weaverbird` / `weaverbird-debug` / `weaverbird-test`),配置与后端 `core/config/config.py` 对齐,位于 `config/profiles/`:
88
+
89
+ | 命令 | 环境 | `CI_BASE_URL`(SERVER_HOST) | `WEAVERBIRD_URL` |
90
+ |------|------|------------------------------|------------------|
91
+ | `weaverbird` | `release` | `https://apphost.vesync.cn` | `https://apphost.vesync.cn` |
92
+ | `weaverbird-debug` | `debug` | `http://10.25.72.141:16644` | `http://10.25.72.141:18833` |
93
+ | `weaverbird-test` | `test` | `http://192.168.10.191:16644` | `http://192.168.10.191:18833` |
94
+
95
+ ### 校验 profile
96
+
97
+ ```bash
98
+ npm run config -- --env=release
99
+ npm run config:debug
100
+ npm run config:test
101
+ npm run config:release
102
+ ```
103
+
104
+ ### 构建(profile 校验 + 语法检查 + 单元测试)
105
+
106
+ ```bash
107
+ npm run build
108
+ ```
109
+
110
+ ### 发布
111
+
112
+ 发布前自动执行 `prepublishOnly`(构建与测试)。按环境发布为 **`@app-public` scope 下的独立包**(自动 `--access public`):
113
+
114
+ | 脚本 | npm 包名 | 全局命令 |
115
+ |------|----------|----------|
116
+ | `npm run publish:release` | `@app-public/weaverbird` | `weaverbird` |
117
+ | `npm run publish:debug` | `@app-public/weaverbird-debug` | `weaverbird-debug` |
118
+ | `npm run publish:test` | `@app-public/weaverbird-test` | `weaverbird-test` |
119
+ | `npm run publish:all` | 依次发布以上三个包 | — |
120
+
121
+ ```bash
122
+ npm run publish:release # 发布线上包
123
+ npm run publish:debug # 发布调试包
124
+ npm run publish:test # 发布测试包
125
+ npm run publish:all # 一次性发布三个包(版本号需一致)
126
+
127
+ # 透传 npm publish 参数
128
+ npm run publish:release -- --dry-run
129
+ npm run publish:test -- --tag beta
130
+ ```
131
+
132
+ > 本地 `package.json` 为私有工作区包(`weaverbird-cli`,`private: true`),**不可直接 `npm publish`**。`publish:*` 脚本会临时改写为对应环境的独立包名与 bin,发布完成后自动还原。
133
+
134
+ 三个环境对应**三个不同的 scoped 包名**,互不影响、可独立发版:
135
+
136
+ | 环境 | npm 包名 |
137
+ |------|----------|
138
+ | 线上 | `@app-public/weaverbird` |
139
+ | 调试 | `@app-public/weaverbird-debug` |
140
+ | 测试 | `@app-public/weaverbird-test` |
141
+
142
+ ## 运行时环境变量覆盖
143
+
144
+ 生成 `config.js` 在运行时按命令名加载 profile;仍可通过环境变量覆盖部分配置(便于本地调试):
145
+
146
+ | 变量 | 说明 |
147
+ |------|------|
148
+ | `WEAVERBIRD_ENV` | 环境标识(debug / test / release) |
149
+ | `WEAVERBIRD_CI_URL` | CI API 根地址 |
150
+ | `WEAVERBIRD_URL` | WeaverBird 前端地址 |
151
+ | `WEAVERBIRD_CLIENT_ID` | OAuth 应用 ID |
152
+ | `WEAVERBIRD_CLIENT_SECRET` | OAuth 应用密钥 |
153
+ | `WEAVERBIRD_REDIRECT_URI` | OAuth 回调地址,默认 `http://localhost:3333/callback` |
154
+ | `WEAVERBIRD_CALLBACK_PORT` | 本地回调端口,默认 `3333` |
155
+ | `WEAVERBIRD_SCOPE` | OAuth scope,默认 `read write` |
156
+
157
+ ## OAuth 凭证配置
158
+
159
+ 凭证由服务端 **`oauth_client` 表**统一生成与管理(每个 PocketBase 库各一条应用记录)。环境不写在表里,而是通过 `core/config` 连接不同数据库(debug / test / release)自然隔离。
160
+
161
+ CLI 侧在 `config/profiles/{debug,test,release}.js` 中配置各环境 API 地址,并从**对应环境数据库**初始化脚本输出的 `CLIENT_ID` / `CLIENT_SECRET` 填入 profile。
162
+
163
+ ### 初始化 WeaverBird CLI 应用
164
+
165
+ 在目标环境的 PocketBase 建好 `oauth_client` 表后,用对应环境启动脚本执行(与后端一致,如 `python main.py test` 或 `release`):
166
+
167
+ ```bash
168
+ python util/data_server_update/maintain_260615/main.py
169
+ ```
170
+
171
+ 每个数据库只会创建一条 `name=WeaverBird CLI` 的记录;首次创建时打印明文 `clientSecret`,写入该环境对应的 `cli/config/profiles/*.js`。
172
+
173
+ **注意:** 不要将真实 `CLIENT_SECRET` 提交到公开仓库;CI/CD 发布时建议通过环境变量注入。
174
+
175
+ ## OAuth 客户端应用表(`oauth_client`)
176
+
177
+ PocketBase 集合名:`oauth_client`
178
+
179
+ | 字段 | 类型 | 必填 | 说明 |
180
+ |------|------|------|------|
181
+ | `name` | text | 是 | 应用名称,如 `WeaverBird CLI` |
182
+ | `clientId` | text | 是 | OAuth `CLIENT_ID`,格式 `cli_` + 32 位 hex,**全局唯一** |
183
+ | `clientSecret` | text | 是 | AES 加密存储的 `CLIENT_SECRET`,**不可明文回显** |
184
+ | `redirectUris` | json | 是 | 允许的回调地址列表,默认 `["http://localhost:3333/callback"]` |
185
+ | `scopes` | text | 是 | 授权范围,默认 `read write` |
186
+ | `grantTypes` | json | 是 | 默认 `["authorization_code","refresh_token"]` |
187
+ | `isInvalid` | bool | 否 | 是否禁用,默认 `false` |
188
+ | `description` | text | 否 | 备注 |
189
+
190
+ > **环境隔离**:不在表中存 `env` / `key`。debug、test、release 各自连独立 PocketBase(见 `core/config/config.py` 的 `POCKETBASE_BASE_URL`),各库维护各自的 `oauth_client` 记录。
191
+
192
+ ### 凭证生成规则
193
+
194
+ 后端 `OAuthClientExtLogic`(`logic/ext/oauth_client.py`):
195
+
196
+ | 方法 | 说明 |
197
+ |------|------|
198
+ | `generate_client_id()` | `cli_{secrets.token_hex(16)}` |
199
+ | `generate_client_secret()` | `secrets.token_urlsafe(32)` |
200
+ | `create_oauth_client(name, ...)` | 创建应用,**仅返回时展示一次**明文 secret |
201
+ | `create_or_get_weaverbird_cli()` | 若已有 `WeaverBird CLI` 则返回首条,否则新建 |
202
+ | `get_by_client_id` | 按 clientId 查询(唯一键) |
203
+ | `verify_client(client_id, secret)` | OAuth token 端校验客户端 |
204
+
205
+ `clientSecret` 使用 `Encrypt.encrypt_with_salt` 加密,`salt` 为 `clientId`。
206
+
207
+ ### 与 CLI profile 的关系
208
+
209
+ | 后端环境(core/config) | PocketBase | CLI profile |
210
+ |-------------------------|------------|-------------|
211
+ | debug | `10.25.72.141:17760` | `config/profiles/debug.js` |
212
+ | test | `192.168.10.191:17760` | `config/profiles/test.js` |
213
+ | release | `apphost.vesync.cn` | `config/profiles/release.js` |
214
+
215
+ 各环境分别在对应库执行初始化脚本,将输出的凭证填入同名 profile 即可。
216
+
217
+ ### 关联表(OAuth 完整链路,后续实现)
218
+
219
+ | 表名 | 用途 |
220
+ |------|------|
221
+ | `oauth_authorization_code` | 授权码:`code`、`clientId`、`userId`、`redirectUri`、`scope`、`expiresTime`(兑换后删除) |
222
+ | `oauth_access_token` | 访问令牌:可复用现有 `token` 表扩展,或独立存储 `accessToken`、`refreshToken`、`clientId`、`userId`、`expiresAt` |
223
+
224
+ ## 目录结构
225
+
226
+ ```
227
+ cli/
228
+ ├── index.js # CLI 入口(bin: weaverbird / weaverbird-debug / weaverbird-test)
229
+ ├── config.js # 运行时按命令名加载 profile
230
+ ├── config/
231
+ │ └── profiles/ # 各环境 profile(与 core/config 对齐)
232
+ ├── login.js # OAuth 登录逻辑
233
+ ├── tokenStore.js # ~/.weaverbird/token.json 读写
234
+ ├── api.js # Bearer 请求与 refresh_token 刷新
235
+ ├── scripts/
236
+ │ ├── build-config.js # 校验 profile
237
+ │ ├── run-build.js # build 入口
238
+ │ └── run-publish.js # 按环境发布 npm 包
239
+ ├── test/
240
+ └── package.json
241
+ ```
242
+
243
+ ## 开发
244
+
245
+ ```bash
246
+ npm run build # 完整构建
247
+ npm run publish:release # 发布线上包(示例)
248
+ ```
249
+
250
+ ## 后端依赖
251
+
252
+ CLI 依赖服务端 OAuth 接口(均为 POST JSON,响应格式与项目统一 `{ code, msg, data }`):
253
+
254
+ | 接口 | 说明 |
255
+ |------|------|
256
+ | `/v1/api/ci/oauth/createOauthClient` | 管理员创建 OAuth 应用(header `token`) |
257
+ | `/v1/api/ci/oauth/createAuthorizeSession` | CLI 创建授权页一次性会话(需 client_secret) |
258
+ | `/v1/api/ci/oauth/getAuthorizeSession` | Web 查询授权会话状态(pending/expired/invalid) |
259
+ | `/v1/api/ci/oauth/authorize` | 签发 authorization code(需 session_token + header `token`) |
260
+ | `/v1/api/ci/oauth/token` | 换取/刷新 access_token |
261
+
262
+ 授权页由前端 `{WEAVERBIRD_URL}/weaverbird/oauth/authorize?session=...` 实现;会话 **5 分钟过期、一次性使用**,过期或已用后 Web 会拦截并提示重新执行 `weaverbird cli login`。
263
+
264
+ 业务 API 请求头:`cli_token` header(OAuth 换取的 access_token,与网页 `token` 独立)。
265
+
266
+ ### PocketBase 需创建的表
267
+
268
+ - `oauth_client` — OAuth 应用
269
+ - `oauth_authorize_session` — 授权页会话(字段:sessionToken, clientId, redirectUri, responseType, scope, state, expiresTime;使用后删除)
270
+ - `oauth_authorization_code` — 授权码(字段:code, clientId, userId, redirectUri, scope, expiresTime;兑换后删除)
271
+ - `oauth_refresh_token` — 刷新令牌(字段:refreshToken, clientId, userId, scope, expiresTime, isInvalid)
package/api.js ADDED
@@ -0,0 +1,61 @@
1
+ const fetch = require('cross-fetch');
2
+ const { CI_BASE_URL } = require('./config');
3
+ const { getToken, saveToken } = require('./tokenStore');
4
+
5
+ const OAUTH_TOKEN_PATH = '/v1/api/ci/oauth/token';
6
+
7
+ async function refreshAccessToken(refreshToken) {
8
+ const { CLIENT_ID, CLIENT_SECRET } = require('./config');
9
+ const response = await fetch(`${CI_BASE_URL}${OAUTH_TOKEN_PATH}`, {
10
+ method: 'POST',
11
+ headers: { 'Content-Type': 'application/json' },
12
+ body: JSON.stringify({
13
+ grant_type: 'refresh_token',
14
+ client_id: CLIENT_ID,
15
+ client_secret: CLIENT_SECRET,
16
+ refresh_token: refreshToken,
17
+ }),
18
+ });
19
+ const result = await response.json().catch(() => ({}));
20
+ if (result.code !== 200 || !result.data?.access_token) {
21
+ throw new Error(result.msg || '刷新 token 失败');
22
+ }
23
+ saveToken(result.data);
24
+ return result.data;
25
+ }
26
+
27
+ async function getValidAccessToken() {
28
+ const token = getToken();
29
+ if (!token?.access_token) {
30
+ throw new Error('未登录,请先执行 weaverbird cli login');
31
+ }
32
+ return token.access_token;
33
+ }
34
+
35
+ async function request(path, options = {}) {
36
+ const accessToken = await getValidAccessToken();
37
+ const url = path.startsWith('http') ? path : `${CI_BASE_URL}${path}`;
38
+ const response = await fetch(url, {
39
+ ...options,
40
+ headers: {
41
+ cli_token: accessToken,
42
+ 'Content-Type': 'application/json',
43
+ ...(options.headers || {}),
44
+ },
45
+ });
46
+ const data = await response.json().catch(() => ({}));
47
+ if (response.status === 401 && getToken()?.refresh_token) {
48
+ await refreshAccessToken(getToken().refresh_token);
49
+ return request(path, options);
50
+ }
51
+ if (!response.ok) {
52
+ throw new Error(data.msg || data.message || response.statusText);
53
+ }
54
+ return data;
55
+ }
56
+
57
+ module.exports = {
58
+ request,
59
+ refreshAccessToken,
60
+ getValidAccessToken,
61
+ };
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ require('../index.js');
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ require('../index.js');
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ require('../index.js');
package/cli-env.js ADDED
@@ -0,0 +1,29 @@
1
+ const path = require('path');
2
+
3
+ const BIN_ENV_MAP = {
4
+ weaverbird: 'release',
5
+ 'weaverbird-debug': 'debug',
6
+ 'weaverbird-test': 'test',
7
+ };
8
+
9
+ function resolveEnvFromBinary() {
10
+ const binName = path.basename(process.argv[1], path.extname(process.argv[1]));
11
+ if (BIN_ENV_MAP[binName]) {
12
+ return BIN_ENV_MAP[binName];
13
+ }
14
+ return 'debug';
15
+ }
16
+
17
+ function resolveScriptName() {
18
+ const binName = path.basename(process.argv[1], path.extname(process.argv[1]));
19
+ if (BIN_ENV_MAP[binName]) {
20
+ return binName;
21
+ }
22
+ return 'weaverbird';
23
+ }
24
+
25
+ module.exports = {
26
+ BIN_ENV_MAP,
27
+ resolveEnvFromBinary,
28
+ resolveScriptName,
29
+ };
@@ -0,0 +1,11 @@
1
+ /** 与 core/config/config.py debug 环境对齐 */
2
+ module.exports = {
3
+ ENV: 'debug',
4
+ CLIENT_ID: 'cli_e5040de23559805ab9f3cc33dbcf0615',
5
+ CLIENT_SECRET: 'ZyN7V0jheHk4iixRAiqndUSt-aAT1SGl-RrRhYOi1jI',
6
+ CI_BASE_URL: 'http://10.25.72.141:16644',
7
+ WEAVERBIRD_URL: 'http://10.25.72.141:18833',
8
+ REDIRECT_URI: 'http://localhost:3333/callback',
9
+ SCOPE: 'read write',
10
+ CALLBACK_PORT: 3333,
11
+ };
@@ -0,0 +1,11 @@
1
+ /** 与 core/config/config.py release 环境对齐 */
2
+ module.exports = {
3
+ ENV: 'release',
4
+ CLIENT_ID: 'cli_weaverbird',
5
+ CLIENT_SECRET: '',
6
+ CI_BASE_URL: 'https://apphost.vesync.cn',
7
+ WEAVERBIRD_URL: 'https://apphost.vesync.cn',
8
+ REDIRECT_URI: 'http://localhost:3333/callback',
9
+ SCOPE: 'read write',
10
+ CALLBACK_PORT: 3333,
11
+ };
@@ -0,0 +1,11 @@
1
+ /** 与 core/config/config.py test 环境对齐 */
2
+ module.exports = {
3
+ ENV: 'test',
4
+ CLIENT_ID: 'cli_weaverbird_test',
5
+ CLIENT_SECRET: '',
6
+ CI_BASE_URL: 'http://192.168.10.191:16644',
7
+ WEAVERBIRD_URL: 'http://192.168.10.191:18833',
8
+ REDIRECT_URI: 'http://localhost:3333/callback',
9
+ SCOPE: 'read write',
10
+ CALLBACK_PORT: 3333,
11
+ };
package/config.js ADDED
@@ -0,0 +1,26 @@
1
+ const path = require('path');
2
+ const { resolveEnvFromBinary } = require('./cli-env');
3
+
4
+ const VALID_ENVS = ['debug', 'test', 'release'];
5
+ const env = process.env.WEAVERBIRD_ENV || resolveEnvFromBinary();
6
+
7
+ if (!VALID_ENVS.includes(env)) {
8
+ throw new Error(`无效环境 "${env}",可选:${VALID_ENVS.join(', ')}`);
9
+ }
10
+
11
+ const profile = require(path.join(__dirname, 'config', 'profiles', `${env}.js`));
12
+
13
+ function stripTrailingSlash(url) {
14
+ return typeof url === 'string' ? url.replace(/\/$/, '') : url;
15
+ }
16
+
17
+ module.exports = {
18
+ ENV: env,
19
+ CLIENT_ID: process.env.WEAVERBIRD_CLIENT_ID || profile.CLIENT_ID,
20
+ CLIENT_SECRET: process.env.WEAVERBIRD_CLIENT_SECRET || profile.CLIENT_SECRET,
21
+ CI_BASE_URL: stripTrailingSlash(process.env.WEAVERBIRD_CI_URL || profile.CI_BASE_URL),
22
+ WEAVERBIRD_URL: stripTrailingSlash(process.env.WEAVERBIRD_URL || profile.WEAVERBIRD_URL),
23
+ REDIRECT_URI: process.env.WEAVERBIRD_REDIRECT_URI || profile.REDIRECT_URI,
24
+ SCOPE: process.env.WEAVERBIRD_SCOPE || profile.SCOPE,
25
+ CALLBACK_PORT: Number(process.env.WEAVERBIRD_CALLBACK_PORT || profile.CALLBACK_PORT),
26
+ };
package/index.js ADDED
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+
3
+ const yargs = require('yargs/yargs');
4
+ const { hideBin } = require('yargs/helpers');
5
+ const { startLogin } = require('./login');
6
+ const { getToken, clearToken, getTokenPath } = require('./tokenStore');
7
+ const { CI_BASE_URL, ENV } = require('./config');
8
+ const { resolveScriptName } = require('./cli-env');
9
+ const pkg = require('./package.json');
10
+
11
+ const scriptName = resolveScriptName();
12
+
13
+ function printStatus() {
14
+ const token = getToken();
15
+ const result = {
16
+ logged_in: !!(token && token.access_token),
17
+ env: ENV,
18
+ ci_base_url: CI_BASE_URL,
19
+ token_path: getTokenPath(),
20
+ token: token || null,
21
+ };
22
+ console.log(JSON.stringify(result, null, 2));
23
+ }
24
+
25
+ async function main() {
26
+ await yargs(hideBin(process.argv))
27
+ .scriptName(scriptName)
28
+ .usage('$0 <command> [options]')
29
+ .version(false)
30
+ .command(
31
+ 'cli',
32
+ 'Weaverbird CLI 子命令',
33
+ (yargs) =>
34
+ yargs
35
+ .version(pkg.version)
36
+ .command(
37
+ 'login',
38
+ `浏览器 OAuth 登录,token 保存至 ~/.weaverbird/token.${ENV}.json`,
39
+ () => {},
40
+ async () => {
41
+ await startLogin();
42
+ }
43
+ )
44
+ .command(
45
+ 'logout',
46
+ '清除本地 token',
47
+ () => {},
48
+ () => {
49
+ clearToken();
50
+ console.log('✅ 已退出登录');
51
+ }
52
+ )
53
+ .command(
54
+ 'status',
55
+ '查询登录状态及 token 信息',
56
+ () => {},
57
+ () => {
58
+ printStatus();
59
+ }
60
+ )
61
+ .demandCommand(0)
62
+ .help()
63
+ )
64
+ .demandCommand(1, `请使用 ${scriptName} cli <command>,执行 ${scriptName} cli --help 查看帮助`)
65
+ .help()
66
+ .parseAsync();
67
+ }
68
+
69
+ main().catch((err) => {
70
+ console.error(`❌ ${err.message}`);
71
+ process.exit(1);
72
+ });
package/login.js ADDED
@@ -0,0 +1,284 @@
1
+ const express = require('express');
2
+ const open = require('open');
3
+ const fetch = require('cross-fetch');
4
+ const {saveToken} = require('./tokenStore');
5
+ const {
6
+ CLIENT_ID,
7
+ CLIENT_SECRET,
8
+ CI_BASE_URL,
9
+ WEAVERBIRD_URL,
10
+ REDIRECT_URI,
11
+ SCOPE,
12
+ CALLBACK_PORT,
13
+ } = require('./config');
14
+
15
+ const OAUTH_TOKEN_PATH = '/v1/api/ci/oauth/token';
16
+ const CREATE_AUTHORIZE_SESSION_PATH = '/v1/api/ci/oauth/createAuthorizeSession';
17
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
18
+
19
+ function escapeHtml(text) {
20
+ return String(text)
21
+ .replace(/&/g, '&amp;')
22
+ .replace(/</g, '&lt;')
23
+ .replace(/>/g, '&gt;')
24
+ .replace(/"/g, '&quot;')
25
+ .replace(/'/g, '&#39;');
26
+ }
27
+
28
+ function renderCallbackPage({ type, title, message, hint }) {
29
+ const palette = {
30
+ success: { accent: '#2C2C30', badgeBg: '#E4E9EC', icon: '✓' },
31
+ error: { accent: '#16161A', badgeBg: '#F2F2F2', icon: '!' },
32
+ };
33
+ const theme = palette[type] || palette.error;
34
+ const safeTitle = escapeHtml(title);
35
+ const safeMessage = message ? escapeHtml(message) : '';
36
+ const safeHint = hint ? escapeHtml(hint) : '';
37
+
38
+ return `<!DOCTYPE html>
39
+ <html lang="zh-CN">
40
+ <head>
41
+ <meta charset="UTF-8" />
42
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
43
+ <title>${safeTitle} · WeaverBird CLI</title>
44
+ <style>
45
+ * { box-sizing: border-box; margin: 0; padding: 0; }
46
+ body {
47
+ min-height: 100vh;
48
+ display: flex;
49
+ align-items: center;
50
+ justify-content: center;
51
+ padding: 24px;
52
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
53
+ background: #F8F9FA;
54
+ color: #16161A;
55
+ }
56
+ .card {
57
+ width: 100%;
58
+ max-width: 420px;
59
+ background: #FFFFFF;
60
+ border-radius: 8px;
61
+ padding: 40px 32px 32px;
62
+ text-align: center;
63
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
64
+ border: 1px solid #D7DDE4;
65
+ }
66
+ .badge {
67
+ width: 64px;
68
+ height: 64px;
69
+ margin: 0 auto 20px;
70
+ border-radius: 50%;
71
+ background: ${theme.badgeBg};
72
+ color: ${theme.accent};
73
+ display: flex;
74
+ align-items: center;
75
+ justify-content: center;
76
+ font-size: 28px;
77
+ font-weight: 600;
78
+ line-height: 1;
79
+ border: 1px solid #DDDDDD;
80
+ }
81
+ .brand {
82
+ font-size: 12px;
83
+ letter-spacing: 0.06em;
84
+ color: #999999;
85
+ margin-bottom: 8px;
86
+ }
87
+ h1 {
88
+ font-size: 22px;
89
+ font-weight: 600;
90
+ margin-bottom: 10px;
91
+ line-height: 1.35;
92
+ color: #16161A;
93
+ }
94
+ p {
95
+ font-size: 14px;
96
+ line-height: 1.6;
97
+ color: #555555;
98
+ word-break: break-word;
99
+ }
100
+ .hint {
101
+ margin-top: 24px;
102
+ padding-top: 20px;
103
+ border-top: 1px solid #D7DDE4;
104
+ font-size: 13px;
105
+ color: #999999;
106
+ }
107
+ </style>
108
+ </head>
109
+ <body>
110
+ <div class="card">
111
+ <div class="badge">${theme.icon}</div>
112
+ <div class="brand">WeaverBird CLI</div>
113
+ <h1>${safeTitle}</h1>
114
+ ${safeMessage ? `<p>${safeMessage}</p>` : ''}
115
+ ${safeHint ? `<div class="hint">${safeHint}</div>` : ''}
116
+ </div>
117
+ </body>
118
+ </html>`;
119
+ }
120
+
121
+ async function createAuthorizeSession(state) {
122
+ const response = await fetch(`${CI_BASE_URL}${CREATE_AUTHORIZE_SESSION_PATH}`, {
123
+ method: 'POST',
124
+ headers: {'Content-Type': 'application/json'},
125
+ body: JSON.stringify({
126
+ client_id: CLIENT_ID,
127
+ client_secret: CLIENT_SECRET,
128
+ redirect_uri: REDIRECT_URI,
129
+ response_type: 'code',
130
+ scope: SCOPE,
131
+ state,
132
+ }),
133
+ });
134
+ const result = await response.json().catch(() => ({}));
135
+ if (result.code !== 200 || !result.data?.session_token) {
136
+ throw new Error(result.msg || '创建授权会话失败');
137
+ }
138
+ return result.data.session_token;
139
+ }
140
+
141
+ function buildAuthorizeUrl(sessionToken) {
142
+ const params = new URLSearchParams({session: sessionToken});
143
+ return `${WEAVERBIRD_URL}/weaverbird/oauth/authorize?${params.toString()}`;
144
+ }
145
+
146
+ async function exchangeCodeForToken(code) {
147
+ const response = await fetch(`${CI_BASE_URL}${OAUTH_TOKEN_PATH}`, {
148
+ method: 'POST',
149
+ headers: {'Content-Type': 'application/json'},
150
+ body: JSON.stringify({
151
+ grant_type: 'authorization_code',
152
+ client_id: CLIENT_ID,
153
+ client_secret: CLIENT_SECRET,
154
+ redirect_uri: REDIRECT_URI,
155
+ code,
156
+ }),
157
+ });
158
+ const result = await response.json().catch(() => ({}));
159
+ if (result.code !== 200 || !result.data?.access_token) {
160
+ throw new Error(result.msg || '换取 token 失败');
161
+ }
162
+ return result.data;
163
+ }
164
+
165
+ async function startLogin() {
166
+ if (!CLIENT_SECRET) {
167
+ throw new Error(
168
+ '未配置 CLIENT_SECRET。请在 config.js 中填写,或设置环境变量 WEAVERBIRD_CLIENT_SECRET'
169
+ );
170
+ }
171
+
172
+ const state = Math.random().toString(36).slice(2);
173
+ const app = express();
174
+ let server;
175
+ let timer = null;
176
+ const done = new Promise((resolve, reject) => {
177
+ timer = setTimeout(() => {
178
+ reject(new Error('登录超时:未在 5 分钟内完成浏览器授权'));
179
+ }, LOGIN_TIMEOUT_MS);
180
+
181
+ app.get('/callback', async (req, res) => {
182
+ clearTimeout(timer);
183
+ const {code, error, error_description: errorDescription, state: returnedState} = req.query;
184
+
185
+ if (error) {
186
+ const msg = errorDescription || error;
187
+ res.status(400).send(renderCallbackPage({
188
+ type: 'error',
189
+ title: '授权失败',
190
+ message: msg,
191
+ hint: '请重新执行 weaverbird cli login',
192
+ }));
193
+ reject(new Error(msg));
194
+ return;
195
+ }
196
+
197
+ if (!code) {
198
+ res.status(400).send(renderCallbackPage({
199
+ type: 'error',
200
+ title: '授权失败',
201
+ message: '缺少 authorization code',
202
+ hint: '请重新执行 weaverbird cli login',
203
+ }));
204
+ reject(new Error('缺少 authorization code'));
205
+ return;
206
+ }
207
+
208
+ if (returnedState && returnedState !== state) {
209
+ res.status(400).send(renderCallbackPage({
210
+ type: 'error',
211
+ title: '授权失败',
212
+ message: 'state 校验失败',
213
+ hint: '请重新执行 weaverbird cli login',
214
+ }));
215
+ reject(new Error('OAuth state 校验失败'));
216
+ return;
217
+ }
218
+
219
+ try {
220
+ const token = await exchangeCodeForToken(code);
221
+ saveToken(token);
222
+ res.send(renderCallbackPage({
223
+ type: 'success',
224
+ title: '登录成功',
225
+ message: 'CLI 已完成授权,token 已保存到本地。',
226
+ hint: '可以关闭此页面',
227
+ }));
228
+ console.log('✅ 登录成功');
229
+ resolve(token);
230
+ } catch (err) {
231
+ res.status(500).send(renderCallbackPage({
232
+ type: 'error',
233
+ title: '登录失败',
234
+ message: err.message,
235
+ hint: '请重新执行 weaverbird cli login',
236
+ }));
237
+ reject(err);
238
+ } finally {
239
+ setTimeout(() => {
240
+ if (server) {
241
+ server.close();
242
+ }
243
+ process.exit(0);
244
+ }, 2000);
245
+ }
246
+ });
247
+ });
248
+
249
+ server = app.listen(CALLBACK_PORT, async () => {
250
+ try {
251
+ const sessionToken = await createAuthorizeSession(state);
252
+ const url = buildAuthorizeUrl(sessionToken);
253
+ console.log(`🔗 打开浏览器授权:${url}`);
254
+ console.log(`⏱ 授权链接 ${LOGIN_TIMEOUT_MS / 60000} 分钟内有效,且仅可使用一次`);
255
+ try {
256
+ await open(url);
257
+ } catch {
258
+ console.log('无法自动打开浏览器,请手动访问上述链接');
259
+ }
260
+ } catch (err) {
261
+ clearTimeout(timer);
262
+ if (server) {
263
+ server.close();
264
+ }
265
+ throw err;
266
+ }
267
+ });
268
+
269
+ server.on('error', (err) => {
270
+ if (err.code === 'EADDRINUSE') {
271
+ throw new Error(`端口 ${CALLBACK_PORT} 已被占用,请关闭占用进程或设置 WEAVERBIRD_CALLBACK_PORT`);
272
+ }
273
+ throw err;
274
+ });
275
+
276
+ return done;
277
+ }
278
+
279
+ module.exports = {
280
+ startLogin,
281
+ exchangeCodeForToken,
282
+ buildAuthorizeUrl,
283
+ createAuthorizeSession,
284
+ };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@app-public/weaverbird-debug",
3
+ "version": "1.0.0",
4
+ "description": "WeaverBird CI 开放 CLI — 本地调试环境",
5
+ "bin": {
6
+ "weaverbird-debug": "bin/weaverbird-debug.js"
7
+ },
8
+ "main": "index.js",
9
+ "scripts": {
10
+ "config": "node scripts/build-config.js",
11
+ "config:debug": "node scripts/build-config.js --env=debug",
12
+ "config:test": "node scripts/build-config.js --env=test",
13
+ "config:release": "node scripts/build-config.js --env=release",
14
+ "build": "node scripts/run-build.js",
15
+ "test": "node --test test/*.test.js",
16
+ "prepublishOnly": "node scripts/run-build.js",
17
+ "publish:release": "node scripts/run-publish.js --env=release",
18
+ "publish:debug": "node scripts/run-publish.js --env=debug",
19
+ "publish:test": "node scripts/run-publish.js --env=test",
20
+ "publish:all": "npm run publish:release && npm run publish:debug && npm run publish:test"
21
+ },
22
+ "engines": {
23
+ "node": ">=16"
24
+ },
25
+ "dependencies": {
26
+ "cross-fetch": "^4.0.0",
27
+ "express": "^4.18.2",
28
+ "open": "^8.4.2",
29
+ "yargs": "^17.7.2"
30
+ },
31
+ "keywords": [
32
+ "weaverbird",
33
+ "ci",
34
+ "vesync"
35
+ ],
36
+ "license": "UNLICENSED"
37
+ }
package/tokenStore.js ADDED
@@ -0,0 +1,46 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const { ENV } = require('./config');
5
+
6
+ const DIR = path.join(os.homedir(), '.weaverbird');
7
+ const FILE = path.join(DIR, `token.${ENV}.json`);
8
+
9
+ function ensureDir() {
10
+ if (!fs.existsSync(DIR)) {
11
+ fs.mkdirSync(DIR, { recursive: true });
12
+ }
13
+ }
14
+
15
+ function saveToken(token) {
16
+ ensureDir();
17
+ fs.writeFileSync(FILE, JSON.stringify(token, null, 2), { mode: 0o600 });
18
+ }
19
+
20
+ function getToken() {
21
+ if (!fs.existsSync(FILE)) {
22
+ return null;
23
+ }
24
+ try {
25
+ return JSON.parse(fs.readFileSync(FILE, 'utf8'));
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ function clearToken() {
32
+ if (fs.existsSync(FILE)) {
33
+ fs.unlinkSync(FILE);
34
+ }
35
+ }
36
+
37
+ function getTokenPath() {
38
+ return FILE;
39
+ }
40
+
41
+ module.exports = {
42
+ saveToken,
43
+ getToken,
44
+ clearToken,
45
+ getTokenPath,
46
+ };