@newbeebox/newbeebox-app-engine-cli 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 +135 -0
- package/package.json +34 -0
- package/src/config.js +58 -0
- package/src/http.js +105 -0
- package/src/index.js +334 -0
- package/src/login.js +66 -0
- package/src/logs.js +56 -0
- package/src/output.js +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# nae —— NewBee App Engine CLI
|
|
2
|
+
|
|
3
|
+
平台命令行客户端。**参数直达、JSON 输出**,既可人用,也便于 CodeAgent 编排调用。
|
|
4
|
+
|
|
5
|
+
> 📖 **完整文档(含 Agent 用法范例 / 调试技巧)**:<https://workshop.newbeebox.com/app_engine/documents/nae-cli/>
|
|
6
|
+
> markdown 原文:<https://workshop.newbeebox.com/app_engine/documents/nae-cli/md/>
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 安装
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm i -g @newbeebox/newbeebox-app-engine-cli
|
|
14
|
+
# 或在仓库内:npm install && npm link
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
需要 Node ≥ 18(用到内置 `fetch`)。
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 登录
|
|
22
|
+
|
|
23
|
+
CLI 用「个人访问令牌」鉴权。密钥明文只在创建时显示一次,请注意保存。
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
nae login
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
会引导你在浏览器打开网页端 **「CLI 配置」** 页创建一枚密钥,复制后粘贴回控制台即可。也可跳过交互直接传入:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
nae login --token nae_xxxxxxxx
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
CI / Agent 场景免登录,直接用环境变量(优先级高于本地配置):
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
export NAE_TOKEN=nae_xxxxxxxx
|
|
39
|
+
export NAE_BASE_URL=https://workshop.newbeebox.com # 可选,默认即此
|
|
40
|
+
nae apps
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
配置存于 `~/.nae/config.json`。`nae logout` 清除本地密钥。
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## 输出契约(给 Agent)
|
|
48
|
+
|
|
49
|
+
- **stdout**:只放数据,成功结果一律 JSON(默认美化,`-c/--compact` 输出单行)。日志命令直出文本行。
|
|
50
|
+
- **stderr**:只放给人看的提示、进度、错误。
|
|
51
|
+
- **退出码**:`0` 成功;`1` 业务错误;`2` 未登录/密钥失效;`3` 网络不可达。
|
|
52
|
+
|
|
53
|
+
所以 Agent 可以安全地 `nae apps 2>/dev/null | jq '.[].AppID'`。
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## 命令
|
|
58
|
+
|
|
59
|
+
### 身份 / 配额
|
|
60
|
+
|
|
61
|
+
| 命令 | 说明 |
|
|
62
|
+
|------|------|
|
|
63
|
+
| `nae whoami` | 当前身份(uid/用户名/角色/命名空间) |
|
|
64
|
+
| `nae quota` | 配额用量(CPU/内存/存储/应用数 的 已用/上限) |
|
|
65
|
+
| `nae config` | 查看本地配置(密钥脱敏) |
|
|
66
|
+
|
|
67
|
+
### 应用
|
|
68
|
+
|
|
69
|
+
| 命令 | 说明 |
|
|
70
|
+
|------|------|
|
|
71
|
+
| `nae apps [--all]` | 列出应用(`--all` 仅管理员,看全平台) |
|
|
72
|
+
| `nae app <appid>` | 应用详情/状态/设置 |
|
|
73
|
+
| `nae children <appid>` | 子服务列表(如 Milvus 的 etcd/minio) |
|
|
74
|
+
| `nae create --app-id <id> [...]` | 创建应用(见下) |
|
|
75
|
+
| `nae delete <appid>` | 删除应用(模板级联清子服务/PVC/secret),幂等 |
|
|
76
|
+
|
|
77
|
+
创建普通应用:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
nae create --app-id myweb --tag latest --port 8080 \
|
|
81
|
+
--cpu 500m --mem 512Mi --env '{"FOO":"bar"}'
|
|
82
|
+
# 自带 basePath 的框架(如 Next.js)加 --keep-path-prefix
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
创建模板应用:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
nae create --kind template --app-id myredis \
|
|
89
|
+
--template redis --storage 2Gi --config '{"password":"..."}'
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### 生命周期
|
|
93
|
+
|
|
94
|
+
| 命令 | 说明 |
|
|
95
|
+
|------|------|
|
|
96
|
+
| `nae start <appid>` | 启动 |
|
|
97
|
+
| `nae stop <appid>` | 停止(缩到 0) |
|
|
98
|
+
| `nae restart <appid>` | 滚动重启 |
|
|
99
|
+
| `nae scale <appid> <replicas>` | 扩缩容 |
|
|
100
|
+
|
|
101
|
+
### 版本 / 回滚
|
|
102
|
+
|
|
103
|
+
| 命令 | 说明 |
|
|
104
|
+
|------|------|
|
|
105
|
+
| `nae versions <appid>` | 镜像版本历史 |
|
|
106
|
+
| `nae rollback <appid> [--seq N]` | 回滚(默认上一版) |
|
|
107
|
+
|
|
108
|
+
### 运行态
|
|
109
|
+
|
|
110
|
+
| 命令 | 说明 |
|
|
111
|
+
|------|------|
|
|
112
|
+
| `nae pods <appid>` | Pod 列表 |
|
|
113
|
+
| `nae events <appid> [--limit N]` | 事件/时间线 |
|
|
114
|
+
| `nae metrics <appid>` | 各 Pod 实时资源用量 |
|
|
115
|
+
| `nae logs <appid> [--pod P] [--container C] [--follow] [--tail N]` | 日志(缺省自动取首个 Pod) |
|
|
116
|
+
| `nae exec <appid> -- <cmd...>` | 在容器内执行一条命令(非交互;输出与退出码透传) |
|
|
117
|
+
|
|
118
|
+
### 模板 / 密钥
|
|
119
|
+
|
|
120
|
+
| 命令 | 说明 |
|
|
121
|
+
|------|------|
|
|
122
|
+
| `nae templates` | 可用模板目录 |
|
|
123
|
+
| `nae keys` | 我的 CLI 密钥列表 |
|
|
124
|
+
| `nae keys:create --name <名> [--days 30\|60\|90\|0]` | 自建密钥(上限 5 条,`0`=永久) |
|
|
125
|
+
| `nae keys:revoke <id>` | 吊销我的密钥 |
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## 全局选项
|
|
130
|
+
|
|
131
|
+
| 选项 | 说明 |
|
|
132
|
+
|------|------|
|
|
133
|
+
| `--base-url <url>` | 覆盖平台地址(亦可 `NAE_BASE_URL`) |
|
|
134
|
+
| `--token <token>` | 覆盖访问密钥(亦可 `NAE_TOKEN`) |
|
|
135
|
+
| `-c, --compact` | 紧凑 JSON(单行) |
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@newbeebox/newbeebox-app-engine-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "NewBee App Engine 命令行客户端(nae)——参数直达、JSON 输出,便于 CodeAgent 调用平台能力。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"nae": "src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"newbee",
|
|
18
|
+
"app-engine",
|
|
19
|
+
"cli",
|
|
20
|
+
"paas",
|
|
21
|
+
"kubernetes",
|
|
22
|
+
"deploy",
|
|
23
|
+
"codeagent",
|
|
24
|
+
"nae"
|
|
25
|
+
],
|
|
26
|
+
"homepage": "https://workshop.newbeebox.com/app_engine/documents/nae-cli/",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"commander": "^12.1.0"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"license": "MIT"
|
|
34
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// 本地配置:~/.nae/config.json,仅两项——平台地址 baseUrl + 访问密钥 token。
|
|
2
|
+
// 数据结构刻意最小:CLI 无状态机,每条命令读一次配置即可。
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs'
|
|
6
|
+
|
|
7
|
+
const DEFAULT_BASE_URL = 'https://workshop.newbeebox.com'
|
|
8
|
+
|
|
9
|
+
const CONFIG_DIR = join(homedir(), '.nae')
|
|
10
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
|
|
11
|
+
|
|
12
|
+
// load 读配置;文件不存在/损坏一律回落到默认(不抛错,让首次使用顺畅)。
|
|
13
|
+
// 环境变量优先级最高:NAE_BASE_URL / NAE_TOKEN 覆盖文件值(CI/agent 免登录直跑)。
|
|
14
|
+
export function load() {
|
|
15
|
+
let cfg = { baseUrl: DEFAULT_BASE_URL, token: '' }
|
|
16
|
+
try {
|
|
17
|
+
const raw = readFileSync(CONFIG_FILE, 'utf8')
|
|
18
|
+
const parsed = JSON.parse(raw)
|
|
19
|
+
cfg = { ...cfg, ...parsed }
|
|
20
|
+
} catch {
|
|
21
|
+
// 无配置文件即首次使用,用默认值。
|
|
22
|
+
}
|
|
23
|
+
if (process.env.NAE_BASE_URL) cfg.baseUrl = process.env.NAE_BASE_URL
|
|
24
|
+
if (process.env.NAE_TOKEN) cfg.token = process.env.NAE_TOKEN
|
|
25
|
+
cfg.baseUrl = stripTrailingSlash(cfg.baseUrl || DEFAULT_BASE_URL)
|
|
26
|
+
return cfg
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// save 持久化配置。目录不存在则建;文件权限收敛到 0600(含密钥,不给同机他人读)。
|
|
30
|
+
export function save(cfg) {
|
|
31
|
+
mkdirSync(CONFIG_DIR, { recursive: true })
|
|
32
|
+
const out = {
|
|
33
|
+
baseUrl: stripTrailingSlash(cfg.baseUrl || DEFAULT_BASE_URL),
|
|
34
|
+
token: cfg.token || '',
|
|
35
|
+
}
|
|
36
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(out, null, 2) + '\n', { mode: 0o600 })
|
|
37
|
+
try {
|
|
38
|
+
chmodSync(CONFIG_FILE, 0o600) // 已存在文件 writeFile 不改权限,显式收敛
|
|
39
|
+
} catch {
|
|
40
|
+
// Windows 不支持 POSIX 权限位,忽略。
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// apiBase 拼出 /app_engine/api/v1 前缀(单域名路径路由)。
|
|
45
|
+
export function apiBase(cfg) {
|
|
46
|
+
return `${stripTrailingSlash(cfg.baseUrl)}/app_engine/api/v1`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// cliPageUrl 网页端「CLI 配置」页地址,login 流程引导用户去这里建密钥。
|
|
50
|
+
export function cliPageUrl(cfg) {
|
|
51
|
+
return `${stripTrailingSlash(cfg.baseUrl)}/app_engine/cli`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const configPath = CONFIG_FILE
|
|
55
|
+
|
|
56
|
+
function stripTrailingSlash(s) {
|
|
57
|
+
return String(s || '').replace(/\/+$/, '')
|
|
58
|
+
}
|
package/src/http.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// HTTP 客户端:包 fetch,统一注入 Bearer 密钥、解封 {code,msg,data} 信封、归一错误。
|
|
2
|
+
// 业务命令只管调 request() 拿 data,错误分类(网络/鉴权/业务)交给这里。
|
|
3
|
+
import { apiBase } from './config.js'
|
|
4
|
+
|
|
5
|
+
// ApiError 后端返回的业务错误(HTTP>=400 且带 {code,msg} 信封)。
|
|
6
|
+
export class ApiError extends Error {
|
|
7
|
+
constructor(code, msg, status) {
|
|
8
|
+
super(msg || code)
|
|
9
|
+
this.name = 'ApiError'
|
|
10
|
+
this.code = code
|
|
11
|
+
this.status = status
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// NetworkError 连不上平台(DNS/拒绝/超时等)。
|
|
16
|
+
export class NetworkError extends Error {
|
|
17
|
+
constructor(msg) {
|
|
18
|
+
super(msg)
|
|
19
|
+
this.name = 'NetworkError'
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// AuthError 未配置密钥,或密钥已失效/过期。携带可读引导。
|
|
24
|
+
export class AuthError extends Error {
|
|
25
|
+
constructor(msg) {
|
|
26
|
+
super(msg)
|
|
27
|
+
this.name = 'AuthError'
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// request 发一次请求并返回解封后的 data。
|
|
32
|
+
// cfg : 配置(baseUrl/token)
|
|
33
|
+
// method, path : 如 'GET', '/apps'
|
|
34
|
+
// opts.query : 查询参数对象
|
|
35
|
+
// opts.body : 请求体对象(自动 JSON)
|
|
36
|
+
// opts.token : 覆盖 cfg.token(login 校验未落库的新密钥时用)
|
|
37
|
+
// opts.raw : true 时返回原始 Response(流式日志用),不解封
|
|
38
|
+
export async function request(cfg, method, path, opts = {}) {
|
|
39
|
+
const token = opts.token ?? cfg.token
|
|
40
|
+
if (!opts.allowNoToken && !token) {
|
|
41
|
+
throw new AuthError('未登录')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const url = new URL(apiBase(cfg) + path)
|
|
45
|
+
if (opts.query) {
|
|
46
|
+
for (const [k, v] of Object.entries(opts.query)) {
|
|
47
|
+
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v))
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const headers = {}
|
|
52
|
+
if (token) headers['Authorization'] = `Bearer ${token}`
|
|
53
|
+
let body
|
|
54
|
+
if (opts.body !== undefined) {
|
|
55
|
+
headers['Content-Type'] = 'application/json'
|
|
56
|
+
body = JSON.stringify(opts.body)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let resp
|
|
60
|
+
try {
|
|
61
|
+
resp = await fetch(url, { method, headers, body, signal: opts.signal })
|
|
62
|
+
} catch (e) {
|
|
63
|
+
throw new NetworkError(`无法连接到平台 ${cfg.baseUrl}(${e.message})`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (opts.raw) {
|
|
67
|
+
if (!resp.ok) throw await toError(resp)
|
|
68
|
+
return resp
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 解封 {code,msg,data}。非 JSON(理论不该发生)按状态码兜底。
|
|
72
|
+
let payload
|
|
73
|
+
try {
|
|
74
|
+
payload = await resp.json()
|
|
75
|
+
} catch {
|
|
76
|
+
if (!resp.ok) throw new ApiError('internal', `HTTP ${resp.status}`, resp.status)
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!resp.ok || (payload && payload.code && payload.code !== 'ok')) {
|
|
81
|
+
throw classify(payload, resp.status)
|
|
82
|
+
}
|
|
83
|
+
return payload ? payload.data : null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function toError(resp) {
|
|
87
|
+
let payload = null
|
|
88
|
+
try {
|
|
89
|
+
payload = await resp.json()
|
|
90
|
+
} catch {
|
|
91
|
+
/* 非 JSON 错误体 */
|
|
92
|
+
}
|
|
93
|
+
return classify(payload, resp.status)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// classify 把后端错误信封归一为对应错误类。鉴权类(forbidden + 401/403)升级为 AuthError,
|
|
97
|
+
// 让上层据此提示「重新登录」。
|
|
98
|
+
function classify(payload, status) {
|
|
99
|
+
const code = (payload && payload.code) || 'internal'
|
|
100
|
+
const msg = (payload && payload.msg) || `HTTP ${status}`
|
|
101
|
+
if (code === 'forbidden' || status === 401 || status === 403) {
|
|
102
|
+
return new AuthError(msg)
|
|
103
|
+
}
|
|
104
|
+
return new ApiError(code, msg, status)
|
|
105
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// nae —— NewBee App Engine 命令行客户端。
|
|
3
|
+
// 设计:薄壳。每条命令只做「取配置 → 调一个 HTTP 原语 → JSON 出 stdout」,
|
|
4
|
+
// 鉴权/网络/业务错误集中在 run() 归类处理,命令体里不写 try/catch。
|
|
5
|
+
import { Command } from 'commander'
|
|
6
|
+
import { load, save, configPath } from './config.js'
|
|
7
|
+
import { request, AuthError, NetworkError } from './http.js'
|
|
8
|
+
import { runLogin } from './login.js'
|
|
9
|
+
import { streamLogs } from './logs.js'
|
|
10
|
+
import * as out from './output.js'
|
|
11
|
+
|
|
12
|
+
const program = new Command()
|
|
13
|
+
|
|
14
|
+
program
|
|
15
|
+
.name('nae')
|
|
16
|
+
.description('NewBee App Engine CLI —— 参数直达、JSON 输出,便于 CodeAgent 调用')
|
|
17
|
+
.version('1.0.0')
|
|
18
|
+
.option('--base-url <url>', '覆盖平台地址(也可用环境变量 NAE_BASE_URL)')
|
|
19
|
+
.option('--token <token>', '覆盖访问密钥(也可用环境变量 NAE_TOKEN)')
|
|
20
|
+
.option('-c, --compact', '紧凑 JSON 输出(单行)')
|
|
21
|
+
|
|
22
|
+
// cfg 取合并了全局 flag 的配置。
|
|
23
|
+
function cfg() {
|
|
24
|
+
const g = program.opts()
|
|
25
|
+
const c = load()
|
|
26
|
+
if (g.baseUrl) c.baseUrl = g.baseUrl.replace(/\/+$/, '')
|
|
27
|
+
if (g.token) c.token = g.token
|
|
28
|
+
return c
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// emit 按 --compact 输出数据。
|
|
32
|
+
function emit(value) {
|
|
33
|
+
out.data(value, { pretty: !program.opts().compact })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// run 包裹命令体,集中处理三类错误:
|
|
37
|
+
// AuthError → 退出码 2,提示 nae login
|
|
38
|
+
// NetworkError → 退出码 3
|
|
39
|
+
// 其它(ApiError/未知) → 退出码 1
|
|
40
|
+
function run(fn) {
|
|
41
|
+
return (...args) => {
|
|
42
|
+
Promise.resolve(fn(...args)).catch((e) => {
|
|
43
|
+
if (e instanceof AuthError) {
|
|
44
|
+
out.info('未登录或密钥已失效。请先运行:nae login')
|
|
45
|
+
out.fail(e, 2)
|
|
46
|
+
} else if (e instanceof NetworkError) {
|
|
47
|
+
out.fail(e, 3)
|
|
48
|
+
} else {
|
|
49
|
+
out.fail(e, 1)
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 解析 JSON 字符串参数(--env / --config),失败给清晰报错。
|
|
56
|
+
function parseJSONArg(label, s) {
|
|
57
|
+
if (s === undefined) return undefined
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(s)
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error(`${label} 不是合法 JSON:${s}`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- 认证 ---
|
|
66
|
+
|
|
67
|
+
program
|
|
68
|
+
.command('login')
|
|
69
|
+
.description('登录:引导浏览器创建密钥并粘贴回来(或 --token 直接传入)')
|
|
70
|
+
.option('--token <token>', '直接使用已有密钥,跳过交互')
|
|
71
|
+
.action(
|
|
72
|
+
run(async (opts) => {
|
|
73
|
+
await runLogin(cfg(), opts.token)
|
|
74
|
+
})
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
program
|
|
78
|
+
.command('logout')
|
|
79
|
+
.description('登出:清除本地保存的密钥')
|
|
80
|
+
.action(
|
|
81
|
+
run(async () => {
|
|
82
|
+
save({ ...load(), token: '' })
|
|
83
|
+
out.info('已登出,本地密钥已清除。')
|
|
84
|
+
})
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
program
|
|
88
|
+
.command('whoami')
|
|
89
|
+
.description('显示当前身份(uid/用户名/角色/命名空间)')
|
|
90
|
+
.action(
|
|
91
|
+
run(async () => {
|
|
92
|
+
emit(await request(cfg(), 'GET', '/me'))
|
|
93
|
+
})
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
program
|
|
97
|
+
.command('config')
|
|
98
|
+
.description('显示当前 CLI 配置(密钥脱敏)')
|
|
99
|
+
.action(
|
|
100
|
+
run(async () => {
|
|
101
|
+
const c = load()
|
|
102
|
+
emit({
|
|
103
|
+
configPath,
|
|
104
|
+
baseUrl: c.baseUrl,
|
|
105
|
+
token: c.token ? c.token.slice(0, 12) + '…' : '',
|
|
106
|
+
loggedIn: !!c.token,
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
// --- 配额 ---
|
|
112
|
+
|
|
113
|
+
program
|
|
114
|
+
.command('quota')
|
|
115
|
+
.description('查询当前用户配额用量(CPU/内存/存储/应用数 的 已用/上限)')
|
|
116
|
+
.action(
|
|
117
|
+
run(async () => {
|
|
118
|
+
emit(await request(cfg(), 'GET', '/me/quota'))
|
|
119
|
+
})
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
// --- 应用 ---
|
|
123
|
+
|
|
124
|
+
program
|
|
125
|
+
.command('apps')
|
|
126
|
+
.description('列出应用')
|
|
127
|
+
.option('--all', '管理员查看全平台应用')
|
|
128
|
+
.action(
|
|
129
|
+
run(async (opts) => {
|
|
130
|
+
emit(await request(cfg(), 'GET', '/apps', { query: opts.all ? { all: 'true' } : undefined }))
|
|
131
|
+
})
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
program
|
|
135
|
+
.command('app <appid>')
|
|
136
|
+
.description('查看指定应用的详情/状态/设置')
|
|
137
|
+
.action(
|
|
138
|
+
run(async (appid) => {
|
|
139
|
+
emit(await request(cfg(), 'GET', `/apps/${appid}`))
|
|
140
|
+
})
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
program
|
|
144
|
+
.command('children <appid>')
|
|
145
|
+
.description('查看应用的子服务列表(如 Milvus 的 etcd/minio)')
|
|
146
|
+
.action(
|
|
147
|
+
run(async (appid) => {
|
|
148
|
+
emit(await request(cfg(), 'GET', `/apps/${appid}/children`))
|
|
149
|
+
})
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
program
|
|
153
|
+
.command('create')
|
|
154
|
+
.description('创建应用(普通应用或模板应用)')
|
|
155
|
+
.requiredOption('--app-id <id>', '应用 ID(地址中的 <appid>)')
|
|
156
|
+
.option('--kind <kind>', 'normal | template', 'normal')
|
|
157
|
+
.option('--tag <tag>', '普通应用:跟踪的镜像 tag')
|
|
158
|
+
.option('--port <n>', '普通应用:容器端口', (v) => parseInt(v, 10))
|
|
159
|
+
.option('--cpu <limit>', 'CPU 上限,如 1 / 500m')
|
|
160
|
+
.option('--mem <limit>', '内存上限,如 512Mi / 1Gi')
|
|
161
|
+
.option('--max-restarts <n>', '失败重启上限,0=不封顶', (v) => parseInt(v, 10))
|
|
162
|
+
.option('--keep-path-prefix', '不剥离 /apps/<appid> 前缀(自带 basePath 的框架用)')
|
|
163
|
+
.option('--template <key>', '模板应用:模板 key')
|
|
164
|
+
.option('--storage <size>', '模板应用:持久卷大小,如 10Gi')
|
|
165
|
+
.option('--replicas <n>', '副本数', (v) => parseInt(v, 10))
|
|
166
|
+
.option('--env <json>', '环境变量 JSON,如 \'{"KEY":"val"}\'')
|
|
167
|
+
.option('--config <json>', '模板参数 JSON(密码/持久化模式等)')
|
|
168
|
+
.option('--owner <uid>', '管理员代他人创建时的归属用户 ID', (v) => parseInt(v, 10))
|
|
169
|
+
.action(
|
|
170
|
+
run(async (opts) => {
|
|
171
|
+
const body = {
|
|
172
|
+
kind: opts.kind,
|
|
173
|
+
app_id: opts.appId,
|
|
174
|
+
tracked_tag: opts.tag,
|
|
175
|
+
container_port: opts.port,
|
|
176
|
+
cpu_limit: opts.cpu,
|
|
177
|
+
mem_limit: opts.mem,
|
|
178
|
+
max_restarts: opts.maxRestarts,
|
|
179
|
+
keep_path_prefix: opts.keepPathPrefix || undefined,
|
|
180
|
+
template_key: opts.template,
|
|
181
|
+
storage_size: opts.storage,
|
|
182
|
+
replicas: opts.replicas,
|
|
183
|
+
env: parseJSONArg('--env', opts.env),
|
|
184
|
+
config: parseJSONArg('--config', opts.config),
|
|
185
|
+
owner_id: opts.owner,
|
|
186
|
+
}
|
|
187
|
+
emit(await request(cfg(), 'POST', '/apps', { body }))
|
|
188
|
+
})
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
program
|
|
192
|
+
.command('delete <appid>')
|
|
193
|
+
.description('删除应用(模板级联清子服务/PVC/secret),幂等')
|
|
194
|
+
.action(
|
|
195
|
+
run(async (appid) => {
|
|
196
|
+
emit(await request(cfg(), 'DELETE', `/apps/${appid}`))
|
|
197
|
+
})
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
// --- 生命周期 ---
|
|
201
|
+
|
|
202
|
+
program
|
|
203
|
+
.command('start <appid>')
|
|
204
|
+
.description('启动应用')
|
|
205
|
+
.action(run(async (id) => emit(await request(cfg(), 'POST', `/apps/${id}/start`))))
|
|
206
|
+
|
|
207
|
+
program
|
|
208
|
+
.command('stop <appid>')
|
|
209
|
+
.description('停止应用(缩到 0)')
|
|
210
|
+
.action(run(async (id) => emit(await request(cfg(), 'POST', `/apps/${id}/stop`))))
|
|
211
|
+
|
|
212
|
+
program
|
|
213
|
+
.command('restart <appid>')
|
|
214
|
+
.description('滚动重启应用')
|
|
215
|
+
.action(run(async (id) => emit(await request(cfg(), 'POST', `/apps/${id}/restart`))))
|
|
216
|
+
|
|
217
|
+
program
|
|
218
|
+
.command('scale <appid> <replicas>')
|
|
219
|
+
.description('扩缩容到指定副本数')
|
|
220
|
+
.action(
|
|
221
|
+
run(async (id, replicas) => {
|
|
222
|
+
emit(await request(cfg(), 'POST', `/apps/${id}/scale`, { body: { replicas: parseInt(replicas, 10) } }))
|
|
223
|
+
})
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
// --- 版本 / 回滚 ---
|
|
227
|
+
|
|
228
|
+
program
|
|
229
|
+
.command('versions <appid>')
|
|
230
|
+
.description('镜像版本历史(Seq 倒序)')
|
|
231
|
+
.action(run(async (id) => emit(await request(cfg(), 'GET', `/apps/${id}/versions`))))
|
|
232
|
+
|
|
233
|
+
program
|
|
234
|
+
.command('rollback <appid>')
|
|
235
|
+
.description('回滚(默认上一版,--seq 指定)')
|
|
236
|
+
.option('--seq <n>', '回滚到的版本 Seq', (v) => parseInt(v, 10))
|
|
237
|
+
.action(
|
|
238
|
+
run(async (id, opts) => {
|
|
239
|
+
emit(await request(cfg(), 'POST', `/apps/${id}/rollback`, { body: { seq: opts.seq ?? null } }))
|
|
240
|
+
})
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
// --- 运行态:pods / events / metrics / logs ---
|
|
244
|
+
|
|
245
|
+
program
|
|
246
|
+
.command('pods <appid>')
|
|
247
|
+
.description('Pod 列表')
|
|
248
|
+
.action(run(async (id) => emit(await request(cfg(), 'GET', `/apps/${id}/pods`))))
|
|
249
|
+
|
|
250
|
+
program
|
|
251
|
+
.command('events <appid>')
|
|
252
|
+
.description('事件/时间线(倒序)')
|
|
253
|
+
.option('--limit <n>', '条数', (v) => parseInt(v, 10), 100)
|
|
254
|
+
.action(
|
|
255
|
+
run(async (id, opts) => {
|
|
256
|
+
emit(await request(cfg(), 'GET', `/apps/${id}/events`, { query: { limit: opts.limit } }))
|
|
257
|
+
})
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
program
|
|
261
|
+
.command('metrics <appid>')
|
|
262
|
+
.description('各 Pod 实时资源用量')
|
|
263
|
+
.action(run(async (id) => emit(await request(cfg(), 'GET', `/apps/${id}/metrics`))))
|
|
264
|
+
|
|
265
|
+
program
|
|
266
|
+
.command('logs <appid>')
|
|
267
|
+
.description('查看应用日志(默认取首个 Pod;--follow 持续跟随)')
|
|
268
|
+
.option('--pod <name>', '指定 Pod 名(缺省自动取首个)')
|
|
269
|
+
.option('--container <name>', '指定容器名')
|
|
270
|
+
.option('--follow', '持续跟随输出')
|
|
271
|
+
.option('--tail <n>', '末尾行数', (v) => parseInt(v, 10))
|
|
272
|
+
.action(
|
|
273
|
+
run(async (id, opts) => {
|
|
274
|
+
await streamLogs(cfg(), id, opts)
|
|
275
|
+
})
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
// --- 在容器内执行命令(一次性非交互)---
|
|
279
|
+
|
|
280
|
+
program
|
|
281
|
+
.command('exec <appid> [cmd...]')
|
|
282
|
+
.description('在应用容器内执行一条命令(非交互;命令写在 -- 之后)')
|
|
283
|
+
.option('--pod <name>', '指定 Pod(缺省取首个运行中的)')
|
|
284
|
+
.option('--container <name>', '指定容器')
|
|
285
|
+
.action(
|
|
286
|
+
run(async (appid, cmd, opts) => {
|
|
287
|
+
if (!cmd || cmd.length === 0) {
|
|
288
|
+
throw new Error('缺少命令。用法:nae exec <appid> -- <命令> [参数...]')
|
|
289
|
+
}
|
|
290
|
+
const res = await request(cfg(), 'POST', `/apps/${appid}/exec-run`, {
|
|
291
|
+
body: { command: cmd, pod: opts.pod, container: opts.container },
|
|
292
|
+
})
|
|
293
|
+
// 直出远端命令输出:stdout→stdout、stderr→stderr,进程退出码 = 远端退出码。
|
|
294
|
+
if (res.stdout) process.stdout.write(res.stdout)
|
|
295
|
+
if (res.stderr) process.stderr.write(res.stderr)
|
|
296
|
+
process.exit(res.exit_code || 0)
|
|
297
|
+
})
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
// --- 模板目录 ---
|
|
301
|
+
|
|
302
|
+
program
|
|
303
|
+
.command('templates')
|
|
304
|
+
.description('列出可用模板')
|
|
305
|
+
.action(run(async () => emit(await request(cfg(), 'GET', '/templates'))))
|
|
306
|
+
|
|
307
|
+
// --- 自己的 CLI 密钥 ---
|
|
308
|
+
|
|
309
|
+
program
|
|
310
|
+
.command('keys')
|
|
311
|
+
.description('列出我的 CLI 密钥')
|
|
312
|
+
.action(run(async () => emit(await request(cfg(), 'GET', '/me/api-keys'))))
|
|
313
|
+
|
|
314
|
+
program
|
|
315
|
+
.command('keys:create')
|
|
316
|
+
.description('创建一枚 CLI 密钥(自建上限 5 条)')
|
|
317
|
+
.requiredOption('--name <name>', '备注名')
|
|
318
|
+
.option('--days <n>', '有效期天数:30/60/90,0=永久', (v) => parseInt(v, 10), 90)
|
|
319
|
+
.action(
|
|
320
|
+
run(async (opts) => {
|
|
321
|
+
emit(await request(cfg(), 'POST', '/me/api-keys', { body: { name: opts.name, valid_days: opts.days } }))
|
|
322
|
+
})
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
program
|
|
326
|
+
.command('keys:revoke <id>')
|
|
327
|
+
.description('吊销一枚我的 CLI 密钥')
|
|
328
|
+
.action(
|
|
329
|
+
run(async (id) => {
|
|
330
|
+
emit(await request(cfg(), 'DELETE', `/me/api-keys/${parseInt(id, 10)}`))
|
|
331
|
+
})
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
program.parseAsync(process.argv)
|
package/src/login.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// 交互式登录:引导用户去网页「CLI 配置」页建密钥,粘贴回控制台,校验后落库。
|
|
2
|
+
// 全程交互走 stderr(stdout 留给数据),保证 `nae login` 不污染管道。
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { createInterface } from 'node:readline/promises'
|
|
5
|
+
import { stdin, stderr } from 'node:process'
|
|
6
|
+
import { save, cliPageUrl } from './config.js'
|
|
7
|
+
import { request, AuthError } from './http.js'
|
|
8
|
+
import * as out from './output.js'
|
|
9
|
+
|
|
10
|
+
// openBrowser 跨平台打开 URL;失败不致命(用户可手动复制)。
|
|
11
|
+
function openBrowser(url) {
|
|
12
|
+
try {
|
|
13
|
+
if (process.platform === 'win32') {
|
|
14
|
+
// start 的首参是窗口标题,留空串避免把 URL 当标题。
|
|
15
|
+
spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref()
|
|
16
|
+
} else if (process.platform === 'darwin') {
|
|
17
|
+
spawn('open', [url], { stdio: 'ignore', detached: true }).unref()
|
|
18
|
+
} else {
|
|
19
|
+
spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref()
|
|
20
|
+
}
|
|
21
|
+
return true
|
|
22
|
+
} catch {
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// runLogin 执行登录流程。tokenArg 非空则跳过交互直接用它(CI 友好)。
|
|
28
|
+
export async function runLogin(cfg, tokenArg) {
|
|
29
|
+
let token = (tokenArg || '').trim()
|
|
30
|
+
|
|
31
|
+
if (!token) {
|
|
32
|
+
const pageUrl = cliPageUrl(cfg)
|
|
33
|
+
out.info('需要登录。请在浏览器打开「CLI 配置」页创建一枚访问密钥:')
|
|
34
|
+
out.info(` ${pageUrl}`)
|
|
35
|
+
const opened = openBrowser(pageUrl)
|
|
36
|
+
out.info(opened ? '(已尝试为你打开浏览器)' : '(无法自动打开浏览器,请手动复制上面的地址)')
|
|
37
|
+
out.info('')
|
|
38
|
+
|
|
39
|
+
const rl = createInterface({ input: stdin, output: stderr })
|
|
40
|
+
try {
|
|
41
|
+
token = (await rl.question('创建后把密钥粘贴到这里,回车确认:')).trim()
|
|
42
|
+
} finally {
|
|
43
|
+
rl.close()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!token) {
|
|
48
|
+
throw new AuthError('未输入密钥')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 用新密钥校验身份(命中 /me 即有效),通过后才落库。
|
|
52
|
+
out.info('正在校验密钥…')
|
|
53
|
+
let me
|
|
54
|
+
try {
|
|
55
|
+
me = await request(cfg, 'GET', '/me', { token })
|
|
56
|
+
} catch (e) {
|
|
57
|
+
if (e instanceof AuthError) {
|
|
58
|
+
throw new AuthError('密钥无效或已过期,请重新在网页创建')
|
|
59
|
+
}
|
|
60
|
+
throw e
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
save({ ...cfg, token })
|
|
64
|
+
out.info(`登录成功:${me.username}(${me.role === 'admin' ? '管理员' : '用户'})`)
|
|
65
|
+
return me
|
|
66
|
+
}
|
package/src/logs.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// 日志:服务端是 SSE(每帧 'data: <行>\n\n')。这里用 raw 响应读流、逐行剥前缀打到 stdout。
|
|
2
|
+
// 日志本就是文本流,不走 JSON 信封——stdout 直出行,便于 `nae logs x | grep`。
|
|
3
|
+
import { request } from './http.js'
|
|
4
|
+
import * as out from './output.js'
|
|
5
|
+
|
|
6
|
+
// pickPod 未指定 --pod 时,取应用第一个 Pod 名。
|
|
7
|
+
async function pickPod(cfg, appid) {
|
|
8
|
+
const pods = await request(cfg, 'GET', `/apps/${appid}/pods`)
|
|
9
|
+
if (!Array.isArray(pods) || pods.length === 0) {
|
|
10
|
+
throw new Error('该应用当前没有运行中的 Pod')
|
|
11
|
+
}
|
|
12
|
+
return pods[0]?.metadata?.name
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// streamLogs 拉取并打印日志。follow=true 时持续跟随直到 Ctrl-C。
|
|
16
|
+
export async function streamLogs(cfg, appid, opts) {
|
|
17
|
+
const pod = opts.pod || (await pickPod(cfg, appid))
|
|
18
|
+
out.info(`日志来源:应用 ${appid} / Pod ${pod}${opts.follow ? '(跟随中,Ctrl-C 退出)' : ''}`)
|
|
19
|
+
|
|
20
|
+
const controller = new AbortController()
|
|
21
|
+
// Ctrl-C 优雅收尾。
|
|
22
|
+
process.on('SIGINT', () => controller.abort())
|
|
23
|
+
|
|
24
|
+
const resp = await request(cfg, 'GET', `/apps/${appid}/logs`, {
|
|
25
|
+
raw: true,
|
|
26
|
+
signal: controller.signal,
|
|
27
|
+
query: {
|
|
28
|
+
pod,
|
|
29
|
+
container: opts.container,
|
|
30
|
+
follow: opts.follow ? 'true' : undefined,
|
|
31
|
+
tail: opts.tail,
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const reader = resp.body.getReader()
|
|
36
|
+
const decoder = new TextDecoder()
|
|
37
|
+
let buf = ''
|
|
38
|
+
try {
|
|
39
|
+
for (;;) {
|
|
40
|
+
const { value, done } = await reader.read()
|
|
41
|
+
if (done) break
|
|
42
|
+
buf += decoder.decode(value, { stream: true })
|
|
43
|
+
let idx
|
|
44
|
+
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
|
45
|
+
const frame = buf.slice(0, idx)
|
|
46
|
+
buf = buf.slice(idx + 2)
|
|
47
|
+
for (const raw of frame.split('\n')) {
|
|
48
|
+
if (raw.startsWith('data: ')) out.line(raw.slice(6))
|
|
49
|
+
else if (raw.startsWith('data:')) out.line(raw.slice(5))
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} catch (e) {
|
|
54
|
+
if (!controller.signal.aborted) throw e
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/output.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// 输出契约(给 CodeAgent 用的硬约定):
|
|
2
|
+
// - stdout 只放「数据」:成功结果一律 JSON(默认美化),方便管道/解析。
|
|
3
|
+
// - stderr 只放「给人看的」:提示、进度、错误。
|
|
4
|
+
// - 退出码:成功 0,失败非 0。
|
|
5
|
+
// 这样 agent 可以 `nae apps 2>/dev/null | jq ...`,人也能从 stderr 读懂发生了什么。
|
|
6
|
+
|
|
7
|
+
// data 把结果写到 stdout(JSON)。pretty=false 时输出紧凑单行。
|
|
8
|
+
export function data(value, { pretty = true } = {}) {
|
|
9
|
+
process.stdout.write(JSON.stringify(value, null, pretty ? 2 : 0) + '\n')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// line 直接写一行到 stdout(用于 logs 等本就是文本流的场景)。
|
|
13
|
+
export function line(s) {
|
|
14
|
+
process.stdout.write(s + '\n')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// info 给人看的提示到 stderr。
|
|
18
|
+
export function info(s) {
|
|
19
|
+
process.stderr.write(s + '\n')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// fail 打印错误到 stderr 并以非 0 退出。err 可为 Error 或 {code,msg}。
|
|
23
|
+
export function fail(err, code = 1) {
|
|
24
|
+
const msg =
|
|
25
|
+
(err && (err.msg || err.message)) || String(err) || '未知错误'
|
|
26
|
+
process.stderr.write(`错误:${msg}\n`)
|
|
27
|
+
process.exit(code)
|
|
28
|
+
}
|