@newbeebox/newbeebox-app-engine-cli 1.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newbeebox/newbeebox-app-engine-cli",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "NewBee App Engine 命令行客户端(nae)——参数直达、默认人类可读(加 -o json 出 JSON),便于在终端使用与脚本/管道里解析。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,8 @@
24
24
  ],
25
25
  "homepage": "https://workshop.newbeebox.com/app_engine/documents/nae-cli/",
26
26
  "dependencies": {
27
- "commander": "^12.1.0"
27
+ "commander": "^12.1.0",
28
+ "ws": "^8.21.0"
28
29
  },
29
30
  "publishConfig": {
30
31
  "access": "public"
package/src/forward.js ADDED
@@ -0,0 +1,220 @@
1
+ // 转发:把内网资源转发到本地调试。
2
+ // - app 端口转发:本地 TCP server,每条连接开一条 WS 到平台 /apps/<id>/forward,双向对拷字节。
3
+ // 平台 Pod 是唯一外网可达的桥;笔记本够不到 ClusterIP,故经平台中转(类 kubectl port-forward)。
4
+ // - LLM 转发:把内置网关经平台 PAT 代理端点暴露回本地,写全局环境变量 NAE_LLM_GATEWAY/NAE_LLM_API_KEY,
5
+ // 本地代码用同款变量直接调试;off 清除。真实网关令牌不出平台——本地填的是 PAT。
6
+ import net from 'node:net'
7
+ import { spawnSync } from 'node:child_process'
8
+ import { homedir, platform } from 'node:os'
9
+ import { join } from 'node:path'
10
+ import { readFileSync, writeFileSync } from 'node:fs'
11
+ import WebSocket from 'ws'
12
+ import { apiBase } from './config.js'
13
+ import { CLI_VERSION } from './version.js'
14
+ import { AuthError } from './http.js'
15
+ import * as out from './output.js'
16
+
17
+ const isWin = platform() === 'win32'
18
+
19
+ // --- app 端口转发 ---
20
+
21
+ // forwardApp 在本地起 TCP server,把每条连接桥到平台 WS 隧道。前台常驻,Ctrl-C 退出。
22
+ export function forwardApp(cfg, appid, opts) {
23
+ if (!cfg.token) throw new AuthError('未登录')
24
+ const remotePort = opts.port // undefined → 平台取应用容器端口
25
+ let active = 0
26
+ let up = 0
27
+ let down = 0
28
+
29
+ return new Promise((resolve, reject) => {
30
+ const server = net.createServer((sock) => {
31
+ active++
32
+ bridge(cfg, appid, remotePort, sock, {
33
+ onUp: (n) => (up += n),
34
+ onDown: (n) => (down += n),
35
+ onClose: () => (active = Math.max(0, active - 1)),
36
+ })
37
+ })
38
+
39
+ server.on('error', reject)
40
+ server.listen(opts.local || 0, '127.0.0.1', () => {
41
+ const port = server.address().port
42
+ const dst = remotePort ? `${appid}:${remotePort}` : `${appid}(容器端口)`
43
+ out.info(`本地 127.0.0.1:${port} → 应用 ${dst}`)
44
+ out.info('前台常驻转发中,Ctrl-C 退出。')
45
+ })
46
+
47
+ // 实时流量行(覆盖式单行,写 stderr 不污染可能被重定向的 stdout)。
48
+ const timer = setInterval(() => {
49
+ process.stderr.write(`\r活动连接 ${active} | ↑ ${humanBytes(up)} | ↓ ${humanBytes(down)} `)
50
+ }, 1000)
51
+
52
+ process.on('SIGINT', () => {
53
+ clearInterval(timer)
54
+ process.stderr.write('\n已停止转发。\n')
55
+ server.close(() => resolve())
56
+ // server.close 只停新连接;已建连接由进程退出收尾。
57
+ process.exit(0)
58
+ })
59
+ })
60
+ }
61
+
62
+ // bridge 一条本地 TCP 连接 ↔ 一条平台 WS 隧道:本地数据缓冲到 WS open 后发,WS 二进制帧写回本地。
63
+ function bridge(cfg, appid, remotePort, sock, cb) {
64
+ const url = wsBase(cfg) + `/apps/${appid}/forward` + (remotePort ? `?port=${remotePort}` : '')
65
+ const ws = new WebSocket(url, {
66
+ headers: { Authorization: `Bearer ${cfg.token}`, 'X-NAE-CLI-Version': CLI_VERSION },
67
+ })
68
+ ws.binaryType = 'nodebuffer'
69
+
70
+ let open = false
71
+ const pending = []
72
+ sock.on('data', (d) => {
73
+ cb.onUp(d.length)
74
+ if (open) ws.send(d)
75
+ else pending.push(d)
76
+ })
77
+ ws.on('open', () => {
78
+ open = true
79
+ for (const d of pending) ws.send(d)
80
+ pending.length = 0
81
+ })
82
+ ws.on('message', (d) => {
83
+ cb.onDown(d.length)
84
+ sock.write(d)
85
+ })
86
+ ws.on('unexpected-response', (_req, res) => {
87
+ out.info(`转发连接失败:HTTP ${res.statusCode}(检查 appid / 登录态 / 应用是否在运行)`)
88
+ sock.destroy()
89
+ })
90
+ ws.on('error', () => sock.destroy())
91
+ ws.on('close', () => sock.destroy())
92
+ let closed = false
93
+ const finish = () => {
94
+ if (closed) return
95
+ closed = true
96
+ cb.onClose()
97
+ }
98
+ sock.on('close', () => {
99
+ ws.close()
100
+ finish()
101
+ })
102
+ sock.on('error', () => {
103
+ ws.close()
104
+ finish()
105
+ })
106
+ }
107
+
108
+ // wsBase 把 apiBase 的 http(s):// 换成 ws(s)://。
109
+ function wsBase(cfg) {
110
+ return apiBase(cfg).replace(/^http/, 'ws')
111
+ }
112
+
113
+ // humanBytes 字节数转人类可读。
114
+ function humanBytes(n) {
115
+ const u = ['B', 'KB', 'MB', 'GB', 'TB']
116
+ let i = 0
117
+ let v = n
118
+ while (v >= 1024 && i < u.length - 1) {
119
+ v /= 1024
120
+ i++
121
+ }
122
+ return (i === 0 ? v : v.toFixed(1)) + u[i]
123
+ }
124
+
125
+ // --- LLM 转发(全局环境变量)---
126
+
127
+ // forwardLLM on/off:把内置 LLM 网关经平台 PAT 代理端点暴露回本地,写/清全局环境变量。
128
+ export function forwardLLM(cfg, action) {
129
+ if (action !== 'on' && action !== 'off') {
130
+ throw new Error('用法:nae forward llm on|off')
131
+ }
132
+ const gateway = `${apiBase(cfg)}/me/llm/proxy`
133
+ if (action === 'on') {
134
+ if (!cfg.token) throw new AuthError('未登录:先 nae login 再开启 LLM 转发')
135
+ applyEnv({ NAE_LLM_GATEWAY: gateway, NAE_LLM_API_KEY: cfg.token })
136
+ out.info('已开启本地 LLM 转发,全局环境变量已写入:')
137
+ out.info(` NAE_LLM_GATEWAY = ${gateway}`)
138
+ out.info(` NAE_LLM_API_KEY = ${cfg.token.slice(0, 12)}…`)
139
+ out.info(reloadHint())
140
+ out.info('本地用 OpenAI 客户端 base=${NAE_LLM_GATEWAY}/v1;Anthropic SDK base=${NAE_LLM_GATEWAY}。')
141
+ } else {
142
+ clearEnv(['NAE_LLM_GATEWAY', 'NAE_LLM_API_KEY'])
143
+ out.info('已关闭本地 LLM 转发,全局环境变量已清除。')
144
+ out.info(reloadHint())
145
+ }
146
+ }
147
+
148
+ // applyEnv 写用户级全局环境变量。Windows: setx;Unix: 重写 profile 内的托管块。
149
+ function applyEnv(pairs) {
150
+ if (isWin) {
151
+ for (const [k, v] of Object.entries(pairs)) {
152
+ const r = spawnSync('setx', [k, v], { encoding: 'utf8' })
153
+ if (r.status !== 0) {
154
+ throw new Error(`setx ${k} 失败:${(r.stderr || r.stdout || r.error?.message || '').trim()}`)
155
+ }
156
+ }
157
+ } else {
158
+ writeUnixBlock(pairs)
159
+ }
160
+ }
161
+
162
+ // clearEnv 清除用户级全局环境变量。Windows: reg delete;Unix: 清空 profile 托管块。
163
+ function clearEnv(names) {
164
+ if (isWin) {
165
+ for (const k of names) {
166
+ spawnSync('reg', ['delete', 'HKCU\\Environment', '/v', k, '/f'], { encoding: 'utf8' })
167
+ }
168
+ } else {
169
+ writeUnixBlock({})
170
+ }
171
+ }
172
+
173
+ const MARK_BEGIN = '# >>> nae llm forward >>>'
174
+ const MARK_END = '# <<< nae llm forward <<<'
175
+
176
+ // writeUnixBlock 把托管块(marker 之间)重写为 pairs 的 export;pairs 空即删块。幂等。
177
+ function writeUnixBlock(pairs) {
178
+ const file = unixProfile()
179
+ let content = ''
180
+ try {
181
+ content = readFileSync(file, 'utf8')
182
+ } catch {
183
+ // 无 profile,新建。
184
+ }
185
+ content = stripBlock(content)
186
+ const keys = Object.keys(pairs)
187
+ if (keys.length > 0) {
188
+ const lines = keys.map((k) => `export ${k}=${shellQuote(pairs[k])}`)
189
+ content = content.replace(/\n*$/, '\n') + MARK_BEGIN + '\n' + lines.join('\n') + '\n' + MARK_END + '\n'
190
+ }
191
+ writeFileSync(file, content)
192
+ }
193
+
194
+ // stripBlock 删掉 marker 间的旧托管块(含 marker 行)。
195
+ function stripBlock(content) {
196
+ const b = content.indexOf(MARK_BEGIN)
197
+ const e = content.indexOf(MARK_END)
198
+ if (b === -1 || e === -1 || e < b) return content
199
+ return (content.slice(0, b) + content.slice(e + MARK_END.length)).replace(/\n{3,}/g, '\n\n')
200
+ }
201
+
202
+ // unixProfile 按 $SHELL 选 profile 文件。
203
+ function unixProfile() {
204
+ const sh = process.env.SHELL || ''
205
+ if (sh.includes('zsh')) return join(homedir(), '.zshrc')
206
+ if (sh.includes('bash')) return join(homedir(), '.bashrc')
207
+ return join(homedir(), '.profile')
208
+ }
209
+
210
+ // shellQuote 单引号包裹,转义内部单引号。
211
+ function shellQuote(s) {
212
+ return `'${String(s).replace(/'/g, `'\\''`)}'`
213
+ }
214
+
215
+ // reloadHint 提示新值在新终端才生效。
216
+ function reloadHint() {
217
+ return isWin
218
+ ? '提示:已写入用户级环境变量(setx),新开一个终端窗口生效。'
219
+ : `提示:已写入 ${unixProfile()},执行 source 它或新开终端生效。`
220
+ }
package/src/http.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // HTTP 客户端:包 fetch,统一注入 Bearer 密钥、解封 {code,msg,data} 信封、归一错误。
2
2
  // 业务命令只管调 request() 拿 data,错误分类(网络/鉴权/业务)交给这里。
3
3
  import { apiBase } from './config.js'
4
+ import { CLI_VERSION } from './version.js'
4
5
 
5
6
  // ApiError 后端返回的业务错误(HTTP>=400 且带 {code,msg} 信封)。
6
7
  export class ApiError extends Error {
@@ -48,7 +49,7 @@ export async function request(cfg, method, path, opts = {}) {
48
49
  }
49
50
  }
50
51
 
51
- const headers = {}
52
+ const headers = { 'X-NAE-CLI-Version': CLI_VERSION }
52
53
  if (token) headers['Authorization'] = `Bearer ${token}`
53
54
  let body
54
55
  if (opts.body !== undefined) {
package/src/index.js CHANGED
@@ -5,9 +5,11 @@
5
5
  import { readFileSync } from 'fs'
6
6
  import { Command } from 'commander'
7
7
  import { load, save, configPath } from './config.js'
8
+ import { CLI_VERSION } from './version.js'
8
9
  import { request, AuthError, NetworkError } from './http.js'
9
10
  import { runLogin } from './login.js'
10
11
  import { streamLogs } from './logs.js'
12
+ import { forwardApp, forwardLLM } from './forward.js'
11
13
  import * as out from './output.js'
12
14
 
13
15
  const program = new Command()
@@ -15,7 +17,7 @@ const program = new Command()
15
17
  program
16
18
  .name('nae')
17
19
  .description('NewBee App Engine CLI —— 默认人类可读,-o json 输出 JSON 供脚本解析')
18
- .version('1.4.0')
20
+ .version(CLI_VERSION)
19
21
  .option('--base-url <url>', '覆盖平台地址(也可用环境变量 NAE_BASE_URL)')
20
22
  .option('--token <token>', '覆盖访问密钥(也可用环境变量 NAE_TOKEN)')
21
23
  .option('-o, --output <format>', '输出格式:text(默认,人类可读)| json', 'text')
@@ -425,6 +427,52 @@ program
425
427
  })
426
428
  )
427
429
 
430
+ // --- 转发:把内网资源转发到本地调试 ---
431
+
432
+ const forward = program
433
+ .command('forward')
434
+ .description('把内网资源转发到本地调试:app 端口转发 / 内置 LLM 网关转发')
435
+
436
+ forward
437
+ .command('app <appid>')
438
+ .description('把应用监听端口转发到本地可用端口(前台常驻,类 kubectl port-forward,Ctrl-C 退出,实时显示流量)')
439
+ .option('--port <n>', '应用侧端口(缺省取应用容器端口)', (v) => parseInt(v, 10))
440
+ .option('--local <n>', '本地监听端口(缺省自动选空闲端口)', (v) => parseInt(v, 10))
441
+ .addHelpText(
442
+ 'after',
443
+ `
444
+ 示例:
445
+ # 把应用容器端口转发到本地随机空闲端口
446
+ nae forward app myredis
447
+
448
+ # 指定应用侧端口 6379、本地固定 16379
449
+ nae forward app myredis --port 6379 --local 16379`
450
+ )
451
+ .action(
452
+ run(async (appid, opts) => {
453
+ await forwardApp(cfg(), appid, opts)
454
+ })
455
+ )
456
+
457
+ forward
458
+ .command('llm <action>')
459
+ .description('本地 LLM 转发开关:on 写全局 env(NAE_LLM_GATEWAY/NAE_LLM_API_KEY) 把内置网关转发回本地,off 清除')
460
+ .addHelpText(
461
+ 'after',
462
+ `
463
+ 示例:
464
+ nae forward llm on # 开启:写全局环境变量,新终端里本地代码即可直连内置网关调试
465
+ nae forward llm off # 关闭:清除这两个全局环境变量
466
+
467
+ 说明:本地 NAE_LLM_API_KEY 填的是你的 CLI 访问密钥(PAT),真实网关令牌不出平台;
468
+ 请求经平台 PAT 代理端点转到内网网关,配额/计量与应用内调用完全一致。`
469
+ )
470
+ .action(
471
+ run(async (action) => {
472
+ forwardLLM(cfg(), action)
473
+ })
474
+ )
475
+
428
476
  // --- 模板目录 ---
429
477
 
430
478
  program
package/src/version.js ADDED
@@ -0,0 +1,16 @@
1
+ // CLI 版本单一真相:读包自身的 package.json。
2
+ // 之前 package.json 与 index.js 的 .version() 双写易漂移;此处归一,命令行 --version 与
3
+ // 请求头 X-NAE-CLI-Version 共用,永远与发布版本一致。
4
+ import { readFileSync } from 'node:fs'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { dirname, join } from 'node:path'
7
+
8
+ let v = '0.0.0'
9
+ try {
10
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
11
+ v = JSON.parse(readFileSync(pkgPath, 'utf8')).version || v
12
+ } catch {
13
+ // 读不到包元数据(极端打包场景)时回落,不影响功能。
14
+ }
15
+
16
+ export const CLI_VERSION = v