@newbeebox/newbeebox-app-engine-cli 1.5.0 → 1.7.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.5.0",
3
+ "version": "1.7.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,275 @@
1
+ // 转发:把内网资源转发到本地调试。两条命令同一套机制——本地 TCP server,每条连接桥到平台一条 WS 隧道、
2
+ // 双向对拷字节;都要求当前登录态有效(启动先验活),都前台常驻、Ctrl-C 即停。
3
+ // - app 端口转发:隧道到指定应用的 Service:port。
4
+ // - LLM 转发:隧道到平台为本人开的 LLM 反代;写全局 NAE_LLM_GATEWAY(=本地址)/NAE_LLM_API_KEY(=本地占位令牌),退出即清除。
5
+ // 真实网关令牌只在集群内注入、不出本机;隧道随本命令退出即断,地址是 127.0.0.1——key 拿到别处连不上集群。
6
+ import net from 'node:net'
7
+ import { randomBytes } from 'node:crypto'
8
+ import { spawnSync } from 'node:child_process'
9
+ import { homedir, platform } from 'node:os'
10
+ import { join } from 'node:path'
11
+ import { readFileSync, writeFileSync } from 'node:fs'
12
+ import WebSocket from 'ws'
13
+ import { apiBase } from './config.js'
14
+ import { CLI_VERSION } from './version.js'
15
+ import { request } from './http.js'
16
+ import * as out from './output.js'
17
+
18
+ const isWin = platform() === 'win32'
19
+
20
+ // ensureLoggedIn 启动前验活:登录态无效(无令牌/过期)直接抛 AuthError,由 run() 提示 nae login,绝不开隧道。
21
+ async function ensureLoggedIn(cfg) {
22
+ await request(cfg, 'GET', '/me')
23
+ }
24
+
25
+ // --- app 端口转发 ---
26
+
27
+ // forwardApp 在本地起 TCP server,把每条连接桥到平台隧道。前台常驻,Ctrl-C 退出。
28
+ export async function forwardApp(cfg, appid, opts) {
29
+ await ensureLoggedIn(cfg)
30
+ const remotePort = opts.port // undefined → 平台取应用容器端口
31
+ const path = `/apps/${appid}/forward` + (remotePort ? `?port=${remotePort}` : '')
32
+ let active = 0
33
+ let up = 0
34
+ let down = 0
35
+
36
+ return new Promise((resolve, reject) => {
37
+ const server = net.createServer((sock) => {
38
+ active++
39
+ bridge(cfg, path, sock, {
40
+ onUp: (n) => (up += n),
41
+ onDown: (n) => (down += n),
42
+ onClose: () => (active = Math.max(0, active - 1)),
43
+ })
44
+ })
45
+
46
+ server.on('error', reject)
47
+ server.listen(opts.local || 0, '127.0.0.1', () => {
48
+ const port = server.address().port
49
+ const dst = remotePort ? `${appid}:${remotePort}` : `${appid}(容器端口)`
50
+ out.info(`本地 127.0.0.1:${port} → 应用 ${dst}`)
51
+ out.info('前台常驻转发中,Ctrl-C 退出。')
52
+ })
53
+
54
+ const timer = liveLine(() => `活动连接 ${active} | ↑ ${humanBytes(up)} | ↓ ${humanBytes(down)}`)
55
+ onShutdown(() => {
56
+ clearInterval(timer)
57
+ process.stderr.write('\n已停止转发。\n')
58
+ server.close(() => resolve())
59
+ process.exit(0)
60
+ })
61
+ })
62
+ }
63
+
64
+ // --- LLM 转发 ---
65
+
66
+ // forwardLLM 在本地起 TCP server,每条连接桥到平台为本人开的 LLM 反代隧道。前台常驻,Ctrl-C 退出。
67
+ // 写全局 env:NAE_LLM_GATEWAY=本地址、NAE_LLM_API_KEY=本地占位令牌;退出即清除。
68
+ // 安全:真实网关令牌只在集群内注入、不出本机;隧道随本命令退出即断,地址 127.0.0.1——key 拿到别处连不上集群。
69
+ export async function forwardLLM(cfg, opts) {
70
+ await ensureLoggedIn(cfg)
71
+ const localKey = 'naelocal_' + randomBytes(18).toString('hex') // 本地占位,真实鉴权在集群内完成
72
+ let active = 0
73
+ let up = 0
74
+ let down = 0
75
+
76
+ return new Promise((resolve, reject) => {
77
+ const server = net.createServer((sock) => {
78
+ active++
79
+ bridge(cfg, '/me/llm/forward', sock, {
80
+ onUp: (n) => (up += n),
81
+ onDown: (n) => (down += n),
82
+ onClose: () => (active = Math.max(0, active - 1)),
83
+ })
84
+ })
85
+
86
+ server.on('error', reject)
87
+ server.listen(opts.local || 0, '127.0.0.1', () => {
88
+ const port = server.address().port
89
+ const gateway = `http://127.0.0.1:${port}`
90
+ applyEnv({ NAE_LLM_GATEWAY: gateway, NAE_LLM_API_KEY: localKey })
91
+ out.info('本地 LLM 转发已就绪(前台常驻,Ctrl-C 退出即停止并清除变量):')
92
+ out.info(` 本地网关 ${gateway}`)
93
+ out.info(` NAE_LLM_GATEWAY = ${gateway}`)
94
+ out.info(` NAE_LLM_API_KEY = ${localKey}`)
95
+ out.info('OpenAI 客户端 base=${NAE_LLM_GATEWAY}/v1;Anthropic SDK base=${NAE_LLM_GATEWAY}。')
96
+ out.info(reloadHint())
97
+ })
98
+
99
+ const timer = liveLine(() => `活动连接 ${active} | ↑ ${humanBytes(up)} | ↓ ${humanBytes(down)}`)
100
+ onShutdown(() => {
101
+ clearInterval(timer)
102
+ clearEnv(['NAE_LLM_GATEWAY', 'NAE_LLM_API_KEY'])
103
+ process.stderr.write('\n已停止 LLM 转发,全局环境变量已清除。\n')
104
+ server.close(() => resolve())
105
+ process.exit(0)
106
+ })
107
+ })
108
+ }
109
+
110
+ // --- 隧道 ---
111
+
112
+ // bridge 一条本地 TCP 连接 ↔ 一条平台 WS 隧道:本地数据缓冲到隧道就绪后发,回包写回本地。
113
+ function bridge(cfg, path, sock, cb) {
114
+ const ws = new WebSocket(wsBase(cfg) + path, {
115
+ headers: { Authorization: `Bearer ${cfg.token}`, 'X-NAE-CLI-Version': CLI_VERSION },
116
+ })
117
+ ws.binaryType = 'nodebuffer'
118
+
119
+ let open = false
120
+ const pending = []
121
+ sock.on('data', (d) => {
122
+ cb.onUp(d.length)
123
+ if (open) ws.send(d)
124
+ else pending.push(d)
125
+ })
126
+ ws.on('open', () => {
127
+ open = true
128
+ for (const d of pending) ws.send(d)
129
+ pending.length = 0
130
+ })
131
+ ws.on('message', (d) => {
132
+ cb.onDown(d.length)
133
+ sock.write(d)
134
+ })
135
+ ws.on('unexpected-response', (_req, res) => {
136
+ out.info(`转发连接失败:HTTP ${res.statusCode}(检查 appid / 登录态 / 应用是否在运行)`)
137
+ sock.destroy()
138
+ })
139
+ ws.on('error', () => sock.destroy())
140
+ ws.on('close', () => sock.destroy())
141
+ let closed = false
142
+ const finish = () => {
143
+ if (closed) return
144
+ closed = true
145
+ cb.onClose()
146
+ }
147
+ sock.on('close', () => {
148
+ ws.close()
149
+ finish()
150
+ })
151
+ sock.on('error', () => {
152
+ ws.close()
153
+ finish()
154
+ })
155
+ }
156
+
157
+ // onShutdown 在所有可捕获的退出信号上跑一次清理(Ctrl-C=SIGINT、Ctrl-Break=SIGBREAK、kill=SIGTERM、挂断=SIGHUP)。
158
+ // 多注册一次只跑一次(once 兜底),尽量不给用户留下指向死端口的残留环境变量。SIGKILL/强杀无法捕获,认栽。
159
+ function onShutdown(fn) {
160
+ let ran = false
161
+ const once = () => {
162
+ if (ran) return
163
+ ran = true
164
+ fn()
165
+ }
166
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGBREAK', 'SIGHUP']) {
167
+ try {
168
+ process.on(sig, once)
169
+ } catch {
170
+ // 个别平台不支持某信号,忽略。
171
+ }
172
+ }
173
+ }
174
+
175
+ // liveLine 实时流量行(覆盖式单行,写 stderr 不污染可能被重定向的 stdout)。
176
+ function liveLine(text) {
177
+ return setInterval(() => {
178
+ process.stderr.write(`\r${text()} `)
179
+ }, 1000)
180
+ }
181
+
182
+ // wsBase 把 apiBase 的 http(s):// 换成 ws(s)://。
183
+ function wsBase(cfg) {
184
+ return apiBase(cfg).replace(/^http/, 'ws')
185
+ }
186
+
187
+ // humanBytes 字节数转人类可读。
188
+ function humanBytes(n) {
189
+ const u = ['B', 'KB', 'MB', 'GB', 'TB']
190
+ let i = 0
191
+ let v = n
192
+ while (v >= 1024 && i < u.length - 1) {
193
+ v /= 1024
194
+ i++
195
+ }
196
+ return (i === 0 ? v : v.toFixed(1)) + u[i]
197
+ }
198
+
199
+ // --- 全局环境变量读写(跨平台)---
200
+
201
+ // applyEnv 写用户级全局环境变量(新开终端生效)。
202
+ // Windows 用 reg add 直写 HKCU\Environment——不用 setx:setx 会同步广播 WM_SETTINGCHANGE 给所有窗口,
203
+ // 某个窗口不响应就把本进程的事件循环卡死,转发随之假死。reg add 不广播、瞬时返回,与 clearEnv 的 reg delete 对称。
204
+ function applyEnv(pairs) {
205
+ if (isWin) {
206
+ for (const [k, v] of Object.entries(pairs)) {
207
+ const r = spawnSync('reg', ['add', 'HKCU\\Environment', '/v', k, '/t', 'REG_SZ', '/d', v, '/f'], { encoding: 'utf8' })
208
+ if (r.status !== 0) {
209
+ throw new Error(`写入环境变量 ${k} 失败:${(r.stderr || r.stdout || r.error?.message || '').trim()}`)
210
+ }
211
+ }
212
+ } else {
213
+ writeUnixBlock(pairs)
214
+ }
215
+ }
216
+
217
+ // clearEnv 清除用户级全局环境变量。Windows: reg delete;Unix: 清空 profile 托管块。
218
+ function clearEnv(names) {
219
+ if (isWin) {
220
+ for (const k of names) {
221
+ spawnSync('reg', ['delete', 'HKCU\\Environment', '/v', k, '/f'], { encoding: 'utf8' })
222
+ }
223
+ } else {
224
+ writeUnixBlock({})
225
+ }
226
+ }
227
+
228
+ const MARK_BEGIN = '# >>> nae llm forward >>>'
229
+ const MARK_END = '# <<< nae llm forward <<<'
230
+
231
+ // writeUnixBlock 把托管块(marker 之间)重写为 pairs 的 export;pairs 空即删块。幂等。
232
+ function writeUnixBlock(pairs) {
233
+ const file = unixProfile()
234
+ let content = ''
235
+ try {
236
+ content = readFileSync(file, 'utf8')
237
+ } catch {
238
+ // 无 profile,新建。
239
+ }
240
+ content = stripBlock(content)
241
+ const keys = Object.keys(pairs)
242
+ if (keys.length > 0) {
243
+ const lines = keys.map((k) => `export ${k}=${shellQuote(pairs[k])}`)
244
+ content = content.replace(/\n*$/, '\n') + MARK_BEGIN + '\n' + lines.join('\n') + '\n' + MARK_END + '\n'
245
+ }
246
+ writeFileSync(file, content)
247
+ }
248
+
249
+ // stripBlock 删掉 marker 间的旧托管块(含 marker 行)。
250
+ function stripBlock(content) {
251
+ const b = content.indexOf(MARK_BEGIN)
252
+ const e = content.indexOf(MARK_END)
253
+ if (b === -1 || e === -1 || e < b) return content
254
+ return (content.slice(0, b) + content.slice(e + MARK_END.length)).replace(/\n{3,}/g, '\n\n')
255
+ }
256
+
257
+ // unixProfile 按 $SHELL 选 profile 文件。
258
+ function unixProfile() {
259
+ const sh = process.env.SHELL || ''
260
+ if (sh.includes('zsh')) return join(homedir(), '.zshrc')
261
+ if (sh.includes('bash')) return join(homedir(), '.bashrc')
262
+ return join(homedir(), '.profile')
263
+ }
264
+
265
+ // shellQuote 单引号包裹,转义内部单引号。
266
+ function shellQuote(s) {
267
+ return `'${String(s).replace(/'/g, `'\\''`)}'`
268
+ }
269
+
270
+ // reloadHint 提示新值在新终端才生效。
271
+ function reloadHint() {
272
+ return isWin
273
+ ? '提示:已写入用户级环境变量,在另开的新终端里生效(已打开的终端不会自动刷新)。'
274
+ : `提示:已写入 ${unixProfile()},在另开的新终端或 source 后生效。`
275
+ }
package/src/index.js CHANGED
@@ -9,6 +9,7 @@ import { CLI_VERSION } from './version.js'
9
9
  import { request, AuthError, NetworkError } from './http.js'
10
10
  import { runLogin } from './login.js'
11
11
  import { streamLogs } from './logs.js'
12
+ import { forwardApp, forwardLLM } from './forward.js'
12
13
  import * as out from './output.js'
13
14
 
14
15
  const program = new Command()
@@ -426,6 +427,52 @@ program
426
427
  })
427
428
  )
428
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')
459
+ .description('把内置 LLM 网关转发到本地(前台常驻,类 forward app):写全局 env(NAE_LLM_GATEWAY/NAE_LLM_API_KEY),Ctrl-C 退出即停止并清除')
460
+ .option('--local <n>', '本地监听端口(缺省自动选空闲端口)', (v) => parseInt(v, 10))
461
+ .addHelpText(
462
+ 'after',
463
+ `
464
+ 示例:
465
+ nae forward llm # 前台常驻,另开终端里本地代码即可经环境变量调试内置网关
466
+
467
+ 说明:转发到 127.0.0.1,仅本命令运行期间有效,Ctrl-C 退出即失效;
468
+ NAE_LLM_API_KEY 是本次会话的临时本地令牌,只能连本地端点,拿到别处连不上集群;需先登录。`
469
+ )
470
+ .action(
471
+ run(async (opts) => {
472
+ await forwardLLM(cfg(), opts)
473
+ })
474
+ )
475
+
429
476
  // --- 模板目录 ---
430
477
 
431
478
  program