@milian/linker-cli 0.0.0-stage → 0.17.3

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
@@ -1,3 +1,118 @@
1
- # Temporary Holding Version
1
+ # @milian/linker-cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ iPaaS 集成管理平台的命令行客户端。零运行时依赖,只用 Node.js 内置模块。
4
+
5
+ 在终端里完成登录、查询连接器、创建并发布 API 资源、把接口封装成 MCP 工具等操作。
6
+
7
+ ## 环境要求
8
+
9
+ Node.js 18 或以上。
10
+
11
+ ## 安装
12
+
13
+ ```bash
14
+ npm install --global @milian/linker-cli
15
+ ```
16
+
17
+ 安装后提供 `linker-cli` 命令(Windows 为 `linker-cli.cmd`)。
18
+
19
+ ## 快速开始
20
+
21
+ ```bash
22
+ linker-cli auth login # 打开登录页
23
+ linker-cli auth status # 确认登录状态
24
+ linker-cli platforms # 列出连接器
25
+ ```
26
+
27
+ `auth login` 会在本机起一个登录页并用默认浏览器打开。首次使用需要在页面上填写**平台地址(含端口)**和账号密码;地址会被记住,下次登录自动预填。
28
+
29
+ 不想自动开浏览器时加 `--no-browser`,终端会打印地址供手动访问。
30
+
31
+ ## 命令
32
+
33
+ ### 认证
34
+
35
+ | 命令 | 说明 |
36
+ |---|---|
37
+ | `auth login` | 登录,默认拉起本地登录页 |
38
+ | `auth login --quick` | 纯终端交互输入账密(仅限本地调试) |
39
+ | `auth status` | 检查登录状态,已登录 exit 0 |
40
+ | `auth logout` | 登出并清除本地凭证(幂等) |
41
+ | `whoami` | 查看当前登录用户 |
42
+
43
+ ### 查询
44
+
45
+ | 命令 | 说明 |
46
+ |---|---|
47
+ | `platforms` | 连接器应用列表,支持 `--name` 模糊查 |
48
+ | `groups` | API 分组列表(含分组 code) |
49
+ | `api list` | 某连接器下的 API 资源 |
50
+ | `mcp sources` | 可创建 MCP 工具的来源 |
51
+ | `config show` | 当前配置与生效的环境变量 |
52
+
53
+ 查询类命令都支持 `--json` 输出完整字段。
54
+
55
+ ### 写入
56
+
57
+ | 命令 | 说明 |
58
+ |---|---|
59
+ | `platform create` | 创建连接器应用,先查分组 code 判重 |
60
+ | `api create` | 创建并发布 API 资源 |
61
+ | `api publish` | 重新发布已保存的 API 资源 |
62
+ | `mcp create` | 把来源封装成 MCP 工具,支持批量 |
63
+
64
+ 写入类命令都支持 `--dry-run` 预览将要提交的内容,建议先预览再执行。
65
+
66
+ ### 兜底
67
+
68
+ | 命令 | 说明 |
69
+ |---|---|
70
+ | `request <METHOD> <PATH>` | 携带授权发起任意请求,用于上面未覆盖的接口 |
71
+
72
+ ## 配置与凭证
73
+
74
+ | 路径 | 内容 |
75
+ |---|---|
76
+ | `~/.linker-cli/config.json` | 平台地址、网关前缀、加密方式等 |
77
+ | `~/.linker-cli/credentials.json` | 登录凭证,文件权限 0600 |
78
+
79
+ 用 `LINKER_CONFIG_DIR` 可以把这两份文件放到别处(测试时很有用):
80
+
81
+ ```bash
82
+ LINKER_CONFIG_DIR=/tmp/lk linker-cli auth status
83
+ ```
84
+
85
+ **密码不落盘。** 它只在登录时用于生成加密串,不写入任何文件、也不进日志。
86
+
87
+ ## 环境变量
88
+
89
+ 优先级高于本地配置。
90
+
91
+ | 变量 | 说明 |
92
+ |---|---|
93
+ | `LINKER_BASE_URL` | 平台地址。设置后登录页的地址栏会被锁定 |
94
+ | `LINKER_CONFIG_DIR` | 配置与凭证目录 |
95
+ | `LINKER_NO_BROWSER` | 设为 `1` 时不自动打开浏览器 |
96
+
97
+ 完整列表见 `linker-cli config env`。
98
+
99
+ ## 退出登录
100
+
101
+ ```bash
102
+ linker-cli auth logout
103
+ ```
104
+
105
+ 删除本地凭证,**保留**平台地址等配置(下次登录仍会预填)。不调用任何远端接口,因此 token 在服务端仍有效直至自然过期。
106
+
107
+ 命令是幂等的,重复执行返回 `Already logged out` 且退出码为 0。
108
+
109
+ ## 更多
110
+
111
+ ```bash
112
+ linker-cli --help # 命令总览
113
+ linker-cli api create --help # 单个命令的完整参数
114
+ ```
115
+
116
+ ## License
117
+
118
+ UNLICENSED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * 管道被提前关闭(典型场景:`linker-cli platforms | head -5`)时,stdout 会抛 EPIPE。
6
+ * 这不是错误,直接安静退出即可;否则会打一屏 Node 堆栈,把真正的输出冲掉。
7
+ */
8
+ function silenceEpipe(stream) {
9
+ stream.on('error', (err) => {
10
+ if (err && err.code === 'EPIPE') process.exit(0);
11
+ throw err;
12
+ });
13
+ }
14
+
15
+ silenceEpipe(process.stdout);
16
+ silenceEpipe(process.stderr);
17
+
18
+ require('../src/cli')
19
+ .main(process.argv.slice(2))
20
+ .catch((err) => {
21
+ const msg = err && err.message ? err.message : String(err);
22
+ process.stderr.write('[error] ' + msg + '\n');
23
+ process.exit(1);
24
+ });
package/package.json CHANGED
@@ -1,6 +1,21 @@
1
1
  {
2
2
  "name": "@milian/linker-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.17.3",
4
+ "description": "Command line client for Milian iPaaS platform. Used as the CLI backend of a WorkBuddy connector.",
5
+ "license": "UNLICENSED",
6
+ "bin": {
7
+ "linker-cli": "bin/linker-cli.js"
8
+ },
9
+ "main": "src/cli.js",
10
+ "engines": {
11
+ "node": ">=18"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "src"
16
+ ],
17
+ "scripts": {
18
+ "start": "node bin/linker-cli.js",
19
+ "test": "node test/crypto-check.js && node test/url-check.js && node test/api-check.js && node test/create-check.js && node test/schema-check.js && node test/mcp-check.js && node test/entry-check.js && node test/login-check.js && node test/keepalive-check.js"
20
+ }
21
+ }
package/src/api.js ADDED
@@ -0,0 +1,154 @@
1
+ 'use strict';
2
+
3
+ const { buildEncodeStr } = require('./crypto');
4
+
5
+ function joinUrl(baseUrl, pathname) {
6
+ const base = String(baseUrl || '').replace(/\/+$/, '');
7
+ const p = String(pathname || '');
8
+ return base + (p.startsWith('/') ? p : '/' + p);
9
+ }
10
+
11
+ /**
12
+ * 补上网关上下文前缀(默认 /index)。
13
+ * 平台所有接口都在 /index 之下;缺前缀时网关会把请求当静态资源,返回 HTTP 200 + 前端 HTML,
14
+ * 看起来像成功、实际没调到接口——这是最容易误判的一种失败。
15
+ */
16
+ function applyPrefix(cfg, pathname) {
17
+ const prefix = String(cfg.apiPrefix || '').replace(/\/+$/, '');
18
+ const p = String(pathname || '');
19
+ if (!prefix) return p;
20
+ const full = p.startsWith('/') ? p : '/' + p;
21
+ if (full === prefix || full.startsWith(prefix + '/')) return full;
22
+ return prefix + full;
23
+ }
24
+
25
+ async function readJsonResponse(res) {
26
+ const text = await res.text();
27
+ let json = null;
28
+ try {
29
+ json = text ? JSON.parse(text) : null;
30
+ } catch {
31
+ /* 非 JSON 响应,保留原文 */
32
+ }
33
+ const contentType = res.headers.get('content-type') || '';
34
+ const isHtml = /text\/html/i.test(contentType) || /^\s*<(!doctype|html)/i.test(text);
35
+ return { status: res.status, json, text, isHtml };
36
+ }
37
+
38
+ /**
39
+ * 调用登录接口,返回归一化后的授权信息。
40
+ *
41
+ * baseUrlOverride:登录页上用户填写的平台地址。没有默认地址,必须显式提供,
42
+ * 或由配置(含 LINKER_BASE_URL 环境变量)给出。
43
+ */
44
+ async function login(cfg, username, password, baseUrlOverride) {
45
+ const baseUrl = String(baseUrlOverride || cfg.baseUrl || '').replace(/\/+$/, '');
46
+ if (!baseUrl) {
47
+ throw new Error('平台地址为空:请在登录页填写平台地址,或先执行 config set baseUrl <地址>');
48
+ }
49
+ const encodeStr = buildEncodeStr({ username, password }, cfg);
50
+ const url = joinUrl(baseUrl, applyPrefix(cfg, cfg.loginPath));
51
+
52
+ let res;
53
+ try {
54
+ res = await fetch(url, {
55
+ method: 'POST',
56
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
57
+ body: JSON.stringify({ encodeStr }),
58
+ signal: AbortSignal.timeout(cfg.requestTimeoutMs)
59
+ });
60
+ } catch (err) {
61
+ throw new Error(`无法连接登录接口 ${url}: ${err.message}`);
62
+ }
63
+
64
+ const { status, json, text, isHtml } = await readJsonResponse(res);
65
+
66
+ if (!json) {
67
+ if (isHtml) {
68
+ throw new Error(
69
+ `登录接口返回了网页而不是数据:${url}\n` +
70
+ ` 请核对 baseUrl 与 apiPrefix(当前 apiPrefix="${cfg.apiPrefix || ''}")。` +
71
+ `可用 config set baseUrl / config set apiPrefix 调整。`
72
+ );
73
+ }
74
+ throw new Error(`登录接口返回非 JSON(HTTP ${status}):${String(text).slice(0, 200)}`);
75
+ }
76
+ if (json.code !== 200) {
77
+ throw new Error(`登录失败:code=${json.code} msg=${json.msg || '未知错误'}`);
78
+ }
79
+
80
+ const data = json.data || {};
81
+ if (!data.tokenValue) {
82
+ throw new Error('登录成功但响应缺少 data.tokenValue,请核对接口版本');
83
+ }
84
+
85
+ return {
86
+ tokenName: data.tokenName || cfg.tokenHeader,
87
+ tokenValue: data.tokenValue,
88
+ nickName: data.nickName || '',
89
+ userName: data.userName || username,
90
+ userId: data.userId || '',
91
+ deptId: data.deptId || '',
92
+ deptCode: data.deptCode || '',
93
+ roles: Array.isArray(data.roles) ? data.roles : [],
94
+ tenant: data.tenant || null,
95
+ // 记录签发时的平台地址,换地址后可判定旧凭证已失效
96
+ baseUrl
97
+ };
98
+ }
99
+
100
+ /**
101
+ * 携带 Authorization 的业务请求
102
+ */
103
+ async function authedRequest(cfg, cred, method, pathname, body, extraHeaders) {
104
+ const baseUrl = String(cfg.baseUrl || '').replace(/\/+$/, '');
105
+ if (!baseUrl) {
106
+ throw new Error(
107
+ '平台地址为空:请先登录并在登录页填写平台地址(auth login),或执行 config set baseUrl <地址>'
108
+ );
109
+ }
110
+
111
+ const headers = Object.assign(
112
+ {
113
+ Accept: 'application/json',
114
+ [cfg.tokenHeader]: (cfg.tokenPrefix || '') + cred.tokenValue
115
+ },
116
+ extraHeaders || {}
117
+ );
118
+
119
+ const init = {
120
+ method: String(method || 'GET').toUpperCase(),
121
+ headers,
122
+ signal: AbortSignal.timeout(cfg.requestTimeoutMs)
123
+ };
124
+
125
+ if (body !== undefined && body !== null && init.method !== 'GET' && init.method !== 'HEAD') {
126
+ headers['Content-Type'] = 'application/json';
127
+ init.body = typeof body === 'string' ? body : JSON.stringify(body);
128
+ }
129
+
130
+ const url = joinUrl(baseUrl, applyPrefix(cfg, pathname));
131
+ let res;
132
+ try {
133
+ res = await fetch(url, init);
134
+ } catch (err) {
135
+ throw new Error(`请求失败 ${url}: ${err.message}`);
136
+ }
137
+
138
+ const parsed = await readJsonResponse(res);
139
+ if (res.status === 401 || res.status === 403) {
140
+ throw new Error(
141
+ `授权失效(HTTP ${res.status}),请重新登录:wb auth login` +
142
+ (parsed.json && parsed.json.msg ? `\n 服务端:${parsed.json.msg}` : '')
143
+ );
144
+ }
145
+ if (parsed.isHtml) {
146
+ throw new Error(
147
+ `接口返回了网页而不是数据:${url}\n` +
148
+ ` 多因路径缺少网关前缀(当前 apiPrefix="${cfg.apiPrefix || ''}"),或被路由到了前端页面。`
149
+ );
150
+ }
151
+ return parsed;
152
+ }
153
+
154
+ module.exports = { login, authedRequest, joinUrl, applyPrefix };
package/src/cli.js ADDED
@@ -0,0 +1,222 @@
1
+ 'use strict';
2
+
3
+ const pkg = require('../package.json');
4
+ const { loadConfig, saveConfig, CONFIG_PATH, CRED_PATH, HOME_DIR, ENV_MAP } = require('./config');
5
+ const { describeCipher, buildEncodeStr } = require('./crypto');
6
+ const { authLogin, authLoginInteractive } = require('./commands/auth');
7
+ const { authStatus, authLogout, whoami } = require('./commands/session');
8
+ const { commandRequest } = require('./commands/request');
9
+ const { commandPlatforms } = require('./commands/platforms');
10
+ const { commandGroups } = require('./commands/groups');
11
+ const { commandPlatformCreate } = require('./commands/platform-create');
12
+ const { commandApiList } = require('./commands/api-list');
13
+ const { commandApiCreate } = require('./commands/api-create');
14
+ const { commandApiPublish } = require('./commands/api-publish');
15
+ const { commandMcpSources, commandMcpCreate } = require('./commands/mcp');
16
+ const { applyPrefix } = require('./api');
17
+
18
+ const HELP = `linker-cli ${pkg.version} — 幂链iPaaS集成管理平台命令行客户端
19
+
20
+ 用法:
21
+ linker-cli auth login [--quick|--no-browser] 登录(默认本地登录页;--no-browser 不自动开浏览器)
22
+ linker-cli auth status [--json] 检查授权状态(exit 0 = 已登录;--json 输出结构化结果)
23
+ linker-cli auth logout 登出并清除本地授权(幂等)
24
+ linker-cli whoami [--json] 查看当前登录用户
25
+ linker-cli request <METHOD> <PATH> 携带授权发起请求
26
+ [--data <json>] [--data-file <file>] [--header k:v]
27
+ linker-cli platforms [--name <kw>] 查询连接器应用列表(默认全量)
28
+ [--page N] [--size N] [--json]
29
+ linker-cli platform create 创建连接器应用(先查分组 code 判重)
30
+ --name <名称> --group <code> --host <http://ip:port>
31
+ [--remark <描述>] [--type 5] [--dry-run] [--force]
32
+ linker-cli groups [--name <kw>] 查询 API 分组(含分组 code)
33
+ linker-cli api list 查询连接器下的 API 资源(按名称模糊查)
34
+ --platform <platformId|名称> [--name <kw>] [--all] [--json]
35
+ linker-cli api create 创建并发布 API 资源(多接口组合)
36
+ --platform <id|名称> --name <名称> --url </path>
37
+ [--method POST] [--body <json|@file>] [--result <json|@file>]
38
+ [--query "a=1"] [--header "e: 2"] [--content-type 1]
39
+ [--required a,b.d] [--label "b.d=订单号"] [--labels @labels.json]
40
+ [--remark <描述>]
41
+ [--no-publish] [--dry-run] [--max-retry 3] [--json]
42
+ linker-cli api publish 已保存的 API 资源重新发布(含路径冲突重试)
43
+ --platform <id|名称> --api <outboundApiId|名称> [--dry-run] [--max-retry 3]
44
+ linker-cli mcp sources 查询可创建 MCP 工具的来源
45
+ [--type 1|2] [--name <kw>] [--platform <id|名称>] [--json]
46
+ linker-cli mcp create 把来源封装成 MCP 工具(支持批量)
47
+ --source <sourceId|名称>[,<...>] [--type 1|2] [--platform <id|名称>]
48
+ [--skip-exists] [--dry-run] [--json]
49
+ linker-cli config show 查看当前配置(含生效的环境变量)
50
+ linker-cli config set <k> <v> 修改配置项
51
+ linker-cli config env 列出可用环境变量
52
+ linker-cli config path 打印配置与凭证路径
53
+ linker-cli crypto describe 查看加密配置与密钥长度
54
+ linker-cli crypto encode <user> <pw> 生成 encodeStr(调试用,不发起登录)
55
+
56
+ 环境变量(优先级高于本地配置):
57
+ ${Object.keys(ENV_MAP)
58
+ .map((k) => ` ${k}`)
59
+ .join('\n')}
60
+ LINKER_CONFIG_DIR 配置/凭证目录,默认 ~/.linker-cli
61
+
62
+ 示例:
63
+ linker-cli auth login # 登录(平台地址在登录页填写)
64
+ linker-cli config set baseUrl http://主机:端口 # 仅脚本场景需要,交互式不必
65
+ linker-cli whoami --json
66
+ linker-cli request POST /sys/api/v1/flow/list --data '{"page":1,"size":20}'
67
+ linker-cli platforms # 全部连接器(pageSize=10000)
68
+ linker-cli platforms --name 钉钉 # 按名称模糊查
69
+ linker-cli groups # 全部分组 code
70
+ linker-cli api list --platform 泛微E10 # 某连接器下的 API 资源
71
+ linker-cli api create --platform 泛微E10 --name 查询订单 --url /order/query \\
72
+ --method POST --body '{"b":{"c":["string"],"d":7}}' --dry-run
73
+ linker-cli api publish --platform 泛微E10 --api 2118594622711623680
74
+ linker-cli mcp sources --platform 泛微E10 --name 应收
75
+ linker-cli mcp create --source 2034441472166580229 --dry-run
76
+ linker-cli platform create --name 滴滴 --group didi --host http://192.168.0.88:6001 --dry-run
77
+ `;
78
+
79
+ function printConfig(cfg) {
80
+ const shown = Object.assign({}, cfg);
81
+ if (shown.publicKey) shown.publicKey = shown.publicKey.slice(0, 64) + '... (已配置)';
82
+ if (shown.aesKey) shown.aesKey = '****** (已配置)';
83
+ if (shown.aesIv) shown.aesIv = '****** (已配置)';
84
+ process.stdout.write(JSON.stringify(shown, null, 2) + '\n');
85
+
86
+ const env = cfg._envApplied || [];
87
+ process.stdout.write(
88
+ env.length ? `\n当前生效的环境变量: ${env.join(', ')}\n` : '\n当前无环境变量覆盖\n'
89
+ );
90
+ const loginUrl = cfg.baseUrl
91
+ ? `${cfg.baseUrl}${applyPrefix(cfg, cfg.loginPath)}`
92
+ : '(未配置:首次登录时在登录页填写)';
93
+ process.stdout.write(`实际登录地址: ${loginUrl}\n`);
94
+ }
95
+
96
+ async function main(argv) {
97
+ const args = argv.slice();
98
+ const cmd = args[0];
99
+
100
+ if (!cmd || cmd === '-h' || cmd === '--help' || cmd === 'help') {
101
+ process.stdout.write(HELP);
102
+ return;
103
+ }
104
+ if (cmd === '-v' || cmd === '--version') {
105
+ process.stdout.write(pkg.version + '\n');
106
+ return;
107
+ }
108
+
109
+ const cfg = loadConfig();
110
+
111
+ if (cmd === 'auth') {
112
+ const sub = args[1] || 'status';
113
+
114
+ if (sub === 'login') {
115
+ const quick = args.includes('--quick');
116
+ const noBrowser = args.includes('--no-browser') || process.env.LINKER_NO_BROWSER === '1';
117
+ const cred = quick ? await authLoginInteractive(cfg) : await authLogin(cfg, { noBrowser });
118
+ process.stdout.write('\nLogged in as ' + (cred.nickName || cred.userName) + '\n');
119
+ process.stdout.write('平台地址: ' + (cred.baseUrl || cfg.baseUrl) + '\n');
120
+ process.exitCode = 0;
121
+ return;
122
+ }
123
+ if (sub === 'status') return authStatus(cfg, args.includes('--json'));
124
+ if (sub === 'logout') return authLogout();
125
+
126
+ throw new Error(`未知子命令: auth ${sub}`);
127
+ }
128
+
129
+ if (cmd === 'whoami') {
130
+ return whoami(cfg, args.includes('--json'));
131
+ }
132
+
133
+ if (cmd === 'request') {
134
+ return commandRequest(cfg, args.slice(1));
135
+ }
136
+
137
+ // 连接器应用 = platform;apps 为等价别名
138
+ if (cmd === 'platforms' || cmd === 'apps') {
139
+ return commandPlatforms(cfg, args.slice(1));
140
+ }
141
+
142
+ if (cmd === 'platform' || cmd === 'app') {
143
+ const sub = args[1];
144
+ if (!sub || sub === 'list') return commandPlatforms(cfg, args.slice(sub ? 2 : 1));
145
+ if (sub === 'create') return commandPlatformCreate(cfg, args.slice(2));
146
+ throw new Error(`未知子命令: ${cmd} ${sub}`);
147
+ }
148
+
149
+ if (cmd === 'groups') {
150
+ return commandGroups(cfg, args.slice(1));
151
+ }
152
+
153
+ if (cmd === 'api' || cmd === 'apis') {
154
+ const sub = args[1];
155
+ // 没有子命令、或直接跟参数(api --platform xxx)时,默认走 list
156
+ if (!sub || sub.startsWith('-')) return commandApiList(cfg, args.slice(1));
157
+ if (sub === 'list' || sub === 'ls') return commandApiList(cfg, args.slice(2));
158
+ if (sub === 'create' || sub === 'new' || sub === 'add') return commandApiCreate(cfg, args.slice(2));
159
+ if (sub === 'publish' || sub === 'pub') return commandApiPublish(cfg, args.slice(2));
160
+ throw new Error(`未知子命令: api ${sub}(可用: list / create / publish)`);
161
+ }
162
+
163
+ if (cmd === 'mcp') {
164
+ const sub = args[1];
165
+ // 没子命令、或直接跟参数(mcp --type 2)时,默认走 sources
166
+ if (!sub || sub.startsWith('-')) return commandMcpSources(cfg, args.slice(1));
167
+ if (sub === 'sources' || sub === 'source' || sub === 'list' || sub === 'ls') {
168
+ return commandMcpSources(cfg, args.slice(2));
169
+ }
170
+ if (sub === 'create' || sub === 'add' || sub === 'new') return commandMcpCreate(cfg, args.slice(2));
171
+ throw new Error(`未知子命令: mcp ${sub}(可用: sources / create)`);
172
+ }
173
+
174
+ if (cmd === 'config') {
175
+ const sub = args[1] || 'show';
176
+ if (sub === 'show') return printConfig(cfg);
177
+ if (sub === 'path') {
178
+ process.stdout.write(`home : ${HOME_DIR}\nconfig : ${CONFIG_PATH}\ncred : ${CRED_PATH}\n`);
179
+ return;
180
+ }
181
+ if (sub === 'set') {
182
+ const key = args[2];
183
+ const value = args.slice(3).join(' ');
184
+ if (!key) throw new Error('用法: wb config set <key> <value>');
185
+ saveConfig({ [key]: value });
186
+ process.stdout.write(`已设置 ${key}\n`);
187
+ if (key === 'baseUrl' || key === 'apiPrefix' || key === 'loginPath') {
188
+ process.stdout.write('注意:地址/路径变更后旧登录态失效,需要重新登录: linker-cli auth login\n');
189
+ }
190
+ return;
191
+ }
192
+ if (sub === 'env') {
193
+ process.stdout.write('可用环境变量(优先级高于本地配置):\n');
194
+ for (const [envKey, cfgKey] of Object.entries(ENV_MAP)) {
195
+ process.stdout.write(` ${envKey.padEnd(22)} -> ${cfgKey}\n`);
196
+ }
197
+ process.stdout.write(` ${'LINKER_CONFIG_DIR'.padEnd(22)} -> 配置/凭证目录(默认 ~/.linker-cli)\n`);
198
+ return;
199
+ }
200
+ throw new Error(`未知子命令: config ${sub}`);
201
+ }
202
+
203
+ if (cmd === 'crypto') {
204
+ const sub = args[1] || 'describe';
205
+ if (sub === 'describe') {
206
+ process.stdout.write(describeCipher(cfg) + '\n');
207
+ return;
208
+ }
209
+ if (sub === 'encode') {
210
+ const username = args[2];
211
+ const password = args[3];
212
+ if (!username || !password) throw new Error('用法: wb crypto encode <username> <password>');
213
+ process.stdout.write(buildEncodeStr({ username, password }, cfg) + '\n');
214
+ return;
215
+ }
216
+ throw new Error(`未知子命令: crypto ${sub}`);
217
+ }
218
+
219
+ throw new Error(`未知命令: ${cmd}\n\n${HELP}`);
220
+ }
221
+
222
+ module.exports = { main };