@habitaxx/cli 0.1.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 +134 -0
- package/bin/habitaxx.js +12 -0
- package/package.json +41 -0
- package/src/auth-store.js +165 -0
- package/src/browser.js +14 -0
- package/src/cli.js +453 -0
- package/src/http.js +281 -0
package/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# 栖界开放平台 CLI
|
|
2
|
+
|
|
3
|
+
`@habitaxx/cli` 使用设备授权流程连接栖界开放平台。用户在浏览器中登录并选择项目、权限和能力,CLI 随后将该域的独立 API Key 安全保存到本机。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
全局安装(或本地运行):
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @habitaxx/cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
本地仓库调试可直接执行:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
node sdk/cli/bin/habitaxx.js --help
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
运行环境要求 Node.js 18.17 或更高版本。
|
|
20
|
+
|
|
21
|
+
## 登录与设备授权
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
habitaxx auth init
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
命令执行步骤:
|
|
28
|
+
|
|
29
|
+
1. 创建一个 10 分钟有效的设备授权请求;
|
|
30
|
+
2. 自动打开浏览器引导授权:`https://platform.habitaxx.com/auth?code=XXXX-XXXX`;
|
|
31
|
+
3. 等待用户在开放平台页面审批;
|
|
32
|
+
4. 将该域的授权凭据安全写入本地 `~/.habitaxx/auth.json`。
|
|
33
|
+
|
|
34
|
+
API Key 绝不会显示在浏览器或终端输出中。本地凭据目录权限严格限定为 `0700`,文件权限为 `0600`。
|
|
35
|
+
|
|
36
|
+
精细化限制授权范围示例(按能力标识申请):
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
habitaxx auth init \
|
|
40
|
+
--scope capabilities:read,tasks:write \
|
|
41
|
+
--ability <capability-key>
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## 查看与管理授权状态
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# 查看当前域授权信息
|
|
48
|
+
habitaxx auth status
|
|
49
|
+
|
|
50
|
+
# 在线向网关验证凭据是否有效
|
|
51
|
+
habitaxx auth status --check
|
|
52
|
+
|
|
53
|
+
# 以安全脱敏 JSON 输出
|
|
54
|
+
habitaxx auth status --json
|
|
55
|
+
|
|
56
|
+
# 退出当前域登录(删除本地凭据)
|
|
57
|
+
habitaxx auth logout
|
|
58
|
+
|
|
59
|
+
# 清空全部域的本地登录信息
|
|
60
|
+
habitaxx auth logout --all
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`auth logout` 仅清理本地保存的凭据,不会直接撤销开放平台远端的 API Key。若凭据遗失或弃用,请前往开放平台控制台进行吊销。
|
|
64
|
+
|
|
65
|
+
## 调用平台能力
|
|
66
|
+
|
|
67
|
+
CLI 自动在内存中完成 API Key 到短效 Access Token 的置换,请求网关时统一携带 Bearer Token 并注入 `X-Client-Type: open_platform` 请求头。
|
|
68
|
+
|
|
69
|
+
### 1. 通用接口请求 (`habitaxx request`)
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# 获取开放平台可用能力清单
|
|
73
|
+
habitaxx request GET /capabilities
|
|
74
|
+
|
|
75
|
+
# 发起智能问诊会话
|
|
76
|
+
habitaxx request POST /ms-ai-fast/session-records/sessions \
|
|
77
|
+
--data '{"module_type":1,"pet_profile_id":78,"content":"狗狗食欲不振并且嗜睡"}'
|
|
78
|
+
|
|
79
|
+
# 鸟类多模态识别 (Multipart 表单)
|
|
80
|
+
habitaxx request POST /bird/detect \
|
|
81
|
+
--form file=@/path/to/bird.jpg
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 2. 鸟类识别快捷命令 (`habitaxx bird detect`)
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
habitaxx bird detect --file /path/to/bird.jpg
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### 3. 智能项圈 IMU 行为预测 (`habitaxx imu predict`)
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
# 传入样本数据进行预测
|
|
94
|
+
habitaxx imu predict --data @samples.json --top-k 5
|
|
95
|
+
|
|
96
|
+
# 指定设备标识进行预测
|
|
97
|
+
habitaxx imu predict --data @samples.json --device 001
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`samples.json` 数据结构示例:
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"samples": [
|
|
104
|
+
[-0.32, -0.49, -0.65, -32.21, -21.96, 1.33],
|
|
105
|
+
[-0.31, -0.48, -0.64, -31.50, -21.20, 1.25]
|
|
106
|
+
],
|
|
107
|
+
"top_k": 5
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## 环境变量说明
|
|
112
|
+
|
|
113
|
+
| 环境变量 | 默认值 | 说明 |
|
|
114
|
+
| :--- | :--- | :--- |
|
|
115
|
+
| `HABITAXX_PLATFORM_URL` | `https://platform.habitaxx.com` | 控制台前端页面地址 |
|
|
116
|
+
| `HABITAXX_PLATFORM_API_URL` | `<platform-url>/api/v1` | 控制面后端 API 接口地址 |
|
|
117
|
+
| `HABITAXX_API_BASE_URL` | `https://open-api.habitaxx.com/v1` | 开放能力网关基础路径 |
|
|
118
|
+
| `HABITAXX_AUTH_FILE` | `~/.habitaxx/auth.json` | 本地凭据存储路径 |
|
|
119
|
+
|
|
120
|
+
## Agent Skill 集成
|
|
121
|
+
|
|
122
|
+
官方配套的 Agent Skill 提供了公网和本地两种安装接入方式:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
# 方式一:公网在线安装(推荐)
|
|
126
|
+
npx skills add habitaxx/skills --skill habitaxx-skill
|
|
127
|
+
# 或通过 npm 在线引入
|
|
128
|
+
npx skills add @habitaxx/skill
|
|
129
|
+
|
|
130
|
+
# 方式二:本地仓库目录安装
|
|
131
|
+
npx skills add ./sdk/habitaxx-skill
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
该 Skill 借助 CLI 统一调度平台能力,无需让 AI Agent 直接接触原始密钥凭据,确保运行安全。
|
package/bin/habitaxx.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { main } from '../src/cli.js'
|
|
4
|
+
|
|
5
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
6
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
7
|
+
const safe = message.replace(/qj_live_[A-Za-z0-9_-]+/g, '[REDACTED_API_KEY]')
|
|
8
|
+
.replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[REDACTED_TOKEN]')
|
|
9
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
|
10
|
+
console.error(`错误:${safe}`)
|
|
11
|
+
process.exitCode = 1
|
|
12
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@habitaxx/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "栖界开放平台官方命令行工具",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"habitaxx": "./bin/habitaxx.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18.17"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"src",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"check": "node --check bin/habitaxx.js && node --check src/auth-store.js && node --check src/browser.js && node --check src/cli.js && node --check src/http.js"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"habitaxx",
|
|
22
|
+
"qijie",
|
|
23
|
+
"cli",
|
|
24
|
+
"open-platform",
|
|
25
|
+
"ai",
|
|
26
|
+
"device-authorization"
|
|
27
|
+
],
|
|
28
|
+
"author": "Habitaxx",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/habitaxx/cli.git"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/habitaxx/cli/issues"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/habitaxx/cli#readme",
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { constants as fsConstants } from 'node:fs'
|
|
3
|
+
import { access, chmod, lstat, mkdir, readFile, rename, rm, open } from 'node:fs/promises'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
const FILE_VERSION = 1
|
|
8
|
+
|
|
9
|
+
export function authFilePath() {
|
|
10
|
+
const override = process.env.HABITAXX_AUTH_FILE?.trim()
|
|
11
|
+
const file = override ? path.resolve(override) : path.join(os.homedir(), '.habitaxx', 'auth.json')
|
|
12
|
+
if (path.basename(path.dirname(file)) !== '.habitaxx') {
|
|
13
|
+
throw new Error('HABITAXX_AUTH_FILE 必须位于独立的 .habitaxx 目录内,避免修改其他目录权限')
|
|
14
|
+
}
|
|
15
|
+
return file
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function domainKey(apiBaseUrl) {
|
|
19
|
+
const url = new URL(apiBaseUrl)
|
|
20
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
21
|
+
throw new Error('API 地址只支持 http 或 https')
|
|
22
|
+
}
|
|
23
|
+
return url.origin
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function emptyAuthStore(deviceId = randomUUID()) {
|
|
27
|
+
return {
|
|
28
|
+
version: FILE_VERSION,
|
|
29
|
+
device_id: deviceId,
|
|
30
|
+
current: null,
|
|
31
|
+
domains: {},
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function rejectSymlink(target, kind) {
|
|
36
|
+
try {
|
|
37
|
+
const stat = await lstat(target)
|
|
38
|
+
if (stat.isSymbolicLink()) throw new Error(`${kind}不能是符号链接:${target}`)
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error?.code !== 'ENOENT') throw error
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function loadAuthStore({ allowMissing = true } = {}) {
|
|
45
|
+
const file = authFilePath()
|
|
46
|
+
await rejectSymlink(path.dirname(file), '授权目录')
|
|
47
|
+
await rejectSymlink(file, '授权文件')
|
|
48
|
+
|
|
49
|
+
let raw
|
|
50
|
+
try {
|
|
51
|
+
raw = await readFile(file, 'utf8')
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (allowMissing && error?.code === 'ENOENT') return emptyAuthStore()
|
|
54
|
+
throw error
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let parsed
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(raw)
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error(`授权文件格式损坏,请备份后删除并重新授权:${file}`)
|
|
62
|
+
}
|
|
63
|
+
if (parsed?.version !== FILE_VERSION || typeof parsed?.domains !== 'object' || !parsed.domains || Array.isArray(parsed.domains)) {
|
|
64
|
+
throw new Error(`不支持的授权文件格式,请升级 CLI 或重新授权:${file}`)
|
|
65
|
+
}
|
|
66
|
+
for (const [domain, item] of Object.entries(parsed.domains)) {
|
|
67
|
+
if (!item || typeof item.api_key !== 'string' || !item.api_key
|
|
68
|
+
|| typeof item.project_no !== 'string' || typeof item.user_id !== 'string'
|
|
69
|
+
|| !Array.isArray(item.capabilities) || !Array.isArray(item.ability_keys)
|
|
70
|
+
|| domainKey(item.api_base_url) !== domain) {
|
|
71
|
+
throw new Error('授权文件包含无效的域信息,请备份后重新授权')
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (typeof parsed.device_id !== 'string' || !parsed.device_id) parsed.device_id = randomUUID()
|
|
75
|
+
return parsed
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function saveAuthStore(store) {
|
|
79
|
+
const file = authFilePath()
|
|
80
|
+
const directory = path.dirname(file)
|
|
81
|
+
await rejectSymlink(directory, '授权目录')
|
|
82
|
+
await mkdir(directory, { recursive: true, mode: 0o700 })
|
|
83
|
+
await chmod(directory, 0o700)
|
|
84
|
+
await rejectSymlink(file, '授权文件')
|
|
85
|
+
|
|
86
|
+
const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`
|
|
87
|
+
let handle
|
|
88
|
+
try {
|
|
89
|
+
handle = await open(temporary, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600)
|
|
90
|
+
await handle.writeFile(`${JSON.stringify(store, null, 2)}\n`, 'utf8')
|
|
91
|
+
await handle.sync()
|
|
92
|
+
await handle.close()
|
|
93
|
+
handle = undefined
|
|
94
|
+
await rename(temporary, file)
|
|
95
|
+
await chmod(file, 0o600)
|
|
96
|
+
} finally {
|
|
97
|
+
await handle?.close().catch(() => {})
|
|
98
|
+
await rm(temporary, { force: true }).catch(() => {})
|
|
99
|
+
}
|
|
100
|
+
return file
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 短期文件锁只覆盖读-改-写,不在等待浏览器授权期间持有。
|
|
104
|
+
export async function updateAuthStore(update) {
|
|
105
|
+
const file = authFilePath()
|
|
106
|
+
const directory = path.dirname(file)
|
|
107
|
+
await rejectSymlink(directory, '授权目录')
|
|
108
|
+
await mkdir(directory, { recursive: true, mode: 0o700 })
|
|
109
|
+
await chmod(directory, 0o700)
|
|
110
|
+
const lockPath = `${file}.lock`
|
|
111
|
+
let lock
|
|
112
|
+
try {
|
|
113
|
+
lock = await open(lockPath, 'wx', 0o600)
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (error?.code === 'EEXIST') {
|
|
116
|
+
throw new Error(`其他 CLI 正在更新授权文件;稍后重试。若进程异常退出,确认没有 CLI 运行后删除 ${lockPath}`)
|
|
117
|
+
}
|
|
118
|
+
throw error
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const store = await loadAuthStore()
|
|
122
|
+
await update(store)
|
|
123
|
+
await saveAuthStore(store)
|
|
124
|
+
return store
|
|
125
|
+
} finally {
|
|
126
|
+
await lock.close()
|
|
127
|
+
await rm(lockPath, { force: true })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function currentAuthorization(store, requestedDomain) {
|
|
132
|
+
const key = requestedDomain || store.current
|
|
133
|
+
if (!key || !store.domains[key]) {
|
|
134
|
+
throw new Error('当前域尚未授权,请先运行 habitaxx auth init')
|
|
135
|
+
}
|
|
136
|
+
return { domain: key, authorization: store.domains[key] }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function publicAuthorization(domain, authorization) {
|
|
140
|
+
return {
|
|
141
|
+
domain,
|
|
142
|
+
platform_url: authorization.platform_url,
|
|
143
|
+
platform_api_url: authorization.platform_api_url,
|
|
144
|
+
api_base_url: authorization.api_base_url,
|
|
145
|
+
project_id: authorization.project_id,
|
|
146
|
+
project_no: authorization.project_no,
|
|
147
|
+
project_name: authorization.project_name,
|
|
148
|
+
user_id: authorization.user_id,
|
|
149
|
+
api_key_id: authorization.api_key_id,
|
|
150
|
+
api_key_name: authorization.api_key_name,
|
|
151
|
+
api_key_prefix: authorization.api_key_prefix,
|
|
152
|
+
capabilities: authorization.capabilities,
|
|
153
|
+
ability_keys: authorization.ability_keys,
|
|
154
|
+
authorized_at: authorization.authorized_at,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function authFileExists() {
|
|
159
|
+
try {
|
|
160
|
+
await access(authFilePath(), fsConstants.F_OK)
|
|
161
|
+
return true
|
|
162
|
+
} catch {
|
|
163
|
+
return false
|
|
164
|
+
}
|
|
165
|
+
}
|
package/src/browser.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
export async function openBrowser(url) {
|
|
4
|
+
const [command, args] = process.platform === 'darwin'
|
|
5
|
+
? ['open', [url]]
|
|
6
|
+
: process.platform === 'win32'
|
|
7
|
+
? ['rundll32.exe', ['url.dll,FileProtocolHandler', url]]
|
|
8
|
+
: ['xdg-open', [url]]
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true })
|
|
11
|
+
child.once('error', () => resolve(false))
|
|
12
|
+
child.once('spawn', () => { child.unref(); resolve(true) })
|
|
13
|
+
})
|
|
14
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import os from 'node:os'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
authFileExists,
|
|
5
|
+
authFilePath,
|
|
6
|
+
currentAuthorization,
|
|
7
|
+
domainKey,
|
|
8
|
+
loadAuthStore,
|
|
9
|
+
publicAuthorization,
|
|
10
|
+
updateAuthStore,
|
|
11
|
+
} from './auth-store.js'
|
|
12
|
+
import { openBrowser } from './browser.js'
|
|
13
|
+
import {
|
|
14
|
+
HabitaxxHttpError,
|
|
15
|
+
buildMultipart,
|
|
16
|
+
callOpenApi,
|
|
17
|
+
exchangeRuntimeToken,
|
|
18
|
+
parseDataArgument,
|
|
19
|
+
requestJson,
|
|
20
|
+
validateBaseUrl,
|
|
21
|
+
} from './http.js'
|
|
22
|
+
|
|
23
|
+
const VERSION = '0.1.0'
|
|
24
|
+
const DEFAULT_PLATFORM_URL = 'https://platform.habitaxx.com'
|
|
25
|
+
const DEFAULT_API_BASE_URL = 'https://open-api.habitaxx.com/v1'
|
|
26
|
+
const DEFAULT_SCOPES = ['capabilities:read', 'tasks:write']
|
|
27
|
+
|
|
28
|
+
const HELP = `栖界开放平台 CLI
|
|
29
|
+
|
|
30
|
+
用法:
|
|
31
|
+
habitaxx auth init [选项] 登录并授权当前设备
|
|
32
|
+
habitaxx auth status [选项] 查看本地授权状态
|
|
33
|
+
habitaxx auth logout [选项] 删除本地授权
|
|
34
|
+
habitaxx request <方法> <路径> [选项] 通过 Runtime 调用开放 API
|
|
35
|
+
habitaxx bird detect --file <图片> 调用鸟类识别能力
|
|
36
|
+
habitaxx imu predict [选项] 调用智能项圈 IMU 行为预测能力
|
|
37
|
+
|
|
38
|
+
授权选项:
|
|
39
|
+
--platform-url <url> 授权页面地址(默认 ${DEFAULT_PLATFORM_URL})
|
|
40
|
+
--platform-api-url <url> 控制面 API 地址(默认 <platform-url>/api/v1)
|
|
41
|
+
--api-base-url <url> 开放 API 地址(默认 ${DEFAULT_API_BASE_URL})
|
|
42
|
+
--client-name <name> 授权页显示的客户端名称
|
|
43
|
+
--device-name <name> 授权页显示的设备名称
|
|
44
|
+
--scope <scope[,scope]> 申请权限,可重复
|
|
45
|
+
--ability <key[,key]> 申请能力,可重复;不传表示全部能力
|
|
46
|
+
--no-open 不自动打开浏览器
|
|
47
|
+
|
|
48
|
+
通用授权选择:
|
|
49
|
+
--domain <origin> 使用 auth.json 中指定域,例如 https://open-api.habitaxx.com
|
|
50
|
+
--json 输出不含秘密的 JSON
|
|
51
|
+
--check auth status 在线校验凭据(默认仅检查本地文件)
|
|
52
|
+
--all auth logout 删除全部域的本地凭据
|
|
53
|
+
|
|
54
|
+
request 选项:
|
|
55
|
+
--data '<json>' JSON 请求体;也支持 --data @payload.json
|
|
56
|
+
--form 'name=value' multipart 字段,可重复;文件使用 name=@/path/file
|
|
57
|
+
--header 'Name: value' 自定义请求头,可重复
|
|
58
|
+
--output <file> 将响应体保存到文件
|
|
59
|
+
|
|
60
|
+
imu predict 选项:
|
|
61
|
+
--data '<json>' 包含 samples 数组及可选 top_k 的数据;支持 @file.json
|
|
62
|
+
--device <device_id> 可选,按设备标识预测
|
|
63
|
+
--top-k <num> 可选,返回置信度前 K 项(默认 5)
|
|
64
|
+
|
|
65
|
+
安全说明:
|
|
66
|
+
API Key 仅保存在 ~/.habitaxx/auth.json(目录 0700,文件 0600)。
|
|
67
|
+
Runtime Access Token 仅在单次命令内存中使用,不写入磁盘,也不会放进 URL。
|
|
68
|
+
`
|
|
69
|
+
|
|
70
|
+
function parseArgs(args, { values = [], booleans = [] } = {}) {
|
|
71
|
+
const valueOptions = new Set(values)
|
|
72
|
+
const booleanOptions = new Set(booleans)
|
|
73
|
+
const options = new Map()
|
|
74
|
+
const positionals = []
|
|
75
|
+
|
|
76
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
77
|
+
const item = args[index]
|
|
78
|
+
if (item === '--') {
|
|
79
|
+
positionals.push(...args.slice(index + 1))
|
|
80
|
+
break
|
|
81
|
+
}
|
|
82
|
+
if (!item.startsWith('--')) {
|
|
83
|
+
positionals.push(item)
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const equals = item.indexOf('=')
|
|
88
|
+
const name = equals > 0 ? item.slice(0, equals) : item
|
|
89
|
+
if (booleanOptions.has(name)) {
|
|
90
|
+
if (equals > 0) throw new Error(`${name} 不接受参数值`)
|
|
91
|
+
options.set(name, true)
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if (!valueOptions.has(name)) throw new Error(`未知选项:${name}`)
|
|
95
|
+
|
|
96
|
+
const value = equals > 0 ? item.slice(equals + 1) : args[++index]
|
|
97
|
+
if (value === undefined || value.startsWith('--')) throw new Error(`${name} 缺少参数值`)
|
|
98
|
+
const existing = options.get(name)
|
|
99
|
+
options.set(name, existing === undefined ? value : [...asArray(existing), value])
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { options, positionals }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function asArray(value) {
|
|
106
|
+
return Array.isArray(value) ? value : [value]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function listOption(options, name, fallback = []) {
|
|
110
|
+
if (!options.has(name)) return fallback
|
|
111
|
+
const values = asArray(options.get(name))
|
|
112
|
+
.flatMap((value) => String(value).split(','))
|
|
113
|
+
.map((value) => value.trim())
|
|
114
|
+
if (values.some((value) => !value)) throw new Error(`${name} 不能包含空权限或空能力标识`)
|
|
115
|
+
return [...new Set(values)]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function stringOption(options, name, fallback) {
|
|
119
|
+
const value = options.get(name)
|
|
120
|
+
if (Array.isArray(value)) throw new Error(`${name} 只能指定一次`)
|
|
121
|
+
return value === undefined ? fallback : String(value).trim()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeHttpUrl(value, optionName) {
|
|
125
|
+
try {
|
|
126
|
+
return validateBaseUrl(value)
|
|
127
|
+
} catch {
|
|
128
|
+
throw new Error(`${optionName} 必须是无凭据、query、fragment 的 HTTPS 地址(仅 loopback 允许 HTTP)`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function selectedDomain(options) {
|
|
133
|
+
const explicitDomain = stringOption(options, '--domain', '')
|
|
134
|
+
if (explicitDomain) return domainKey(normalizeHttpUrl(explicitDomain, '--domain'))
|
|
135
|
+
const apiBaseUrl = stringOption(options, '--api-base-url', '')
|
|
136
|
+
return apiBaseUrl ? domainKey(normalizeHttpUrl(apiBaseUrl, '--api-base-url')) : undefined
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function sleep(milliseconds, signal) {
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
if (signal?.aborted) return reject(signal.reason)
|
|
142
|
+
const done = () => { signal?.removeEventListener('abort', abort); resolve() }
|
|
143
|
+
const timer = setTimeout(done, milliseconds)
|
|
144
|
+
const abort = () => { clearTimeout(timer); reject(signal.reason) }
|
|
145
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function authInit(args) {
|
|
150
|
+
const { options, positionals } = parseArgs(args, {
|
|
151
|
+
values: [
|
|
152
|
+
'--platform-url',
|
|
153
|
+
'--platform-api-url',
|
|
154
|
+
'--api-base-url',
|
|
155
|
+
'--client-name',
|
|
156
|
+
'--device-name',
|
|
157
|
+
'--scope',
|
|
158
|
+
'--ability',
|
|
159
|
+
],
|
|
160
|
+
booleans: ['--no-open'],
|
|
161
|
+
})
|
|
162
|
+
if (positionals.length) throw new Error(`无法识别的参数:${positionals.join(' ')}`)
|
|
163
|
+
|
|
164
|
+
const platformUrl = normalizeHttpUrl(
|
|
165
|
+
stringOption(options, '--platform-url', process.env.HABITAXX_PLATFORM_URL || DEFAULT_PLATFORM_URL),
|
|
166
|
+
'--platform-url',
|
|
167
|
+
)
|
|
168
|
+
const defaultApiUrl = `${platformUrl}/api/v1`
|
|
169
|
+
const platformApiUrl = normalizeHttpUrl(
|
|
170
|
+
stringOption(options, '--platform-api-url', process.env.HABITAXX_PLATFORM_API_URL || defaultApiUrl),
|
|
171
|
+
'--platform-api-url',
|
|
172
|
+
)
|
|
173
|
+
const apiBaseUrl = normalizeHttpUrl(
|
|
174
|
+
stringOption(options, '--api-base-url', process.env.HABITAXX_API_BASE_URL || DEFAULT_API_BASE_URL),
|
|
175
|
+
'--api-base-url',
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
const requestedDomain = domainKey(apiBaseUrl)
|
|
179
|
+
const existingStore = await loadAuthStore()
|
|
180
|
+
if (existingStore.domains[requestedDomain]) {
|
|
181
|
+
const existing = existingStore.domains[requestedDomain]
|
|
182
|
+
throw new Error(
|
|
183
|
+
`域 ${requestedDomain} 已存在授权(项目:${existing.project_name || existing.project_no})。`
|
|
184
|
+
+ `若需重新授权,请先运行 habitaxx auth logout --domain ${requestedDomain}。`,
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const clientName = stringOption(options, '--client-name', 'Habitaxx CLI')
|
|
189
|
+
const deviceName = stringOption(options, '--device-name', os.hostname() || 'developer-device')
|
|
190
|
+
const scopes = listOption(options, '--scope', DEFAULT_SCOPES)
|
|
191
|
+
const abilities = listOption(options, '--ability', [])
|
|
192
|
+
|
|
193
|
+
const initResult = await requestJson(`${platformApiUrl}/device-auth/requests`, {
|
|
194
|
+
method: 'POST',
|
|
195
|
+
body: {
|
|
196
|
+
client_name: clientName,
|
|
197
|
+
device_name: deviceName,
|
|
198
|
+
requested_scopes: scopes,
|
|
199
|
+
requested_abilities: abilities,
|
|
200
|
+
api_base_url: apiBaseUrl,
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
const { device_code, user_code, verification_uri_complete, expires_in, interval = 5 } = initResult
|
|
205
|
+
console.log(`\n请在浏览器中完成设备授权:\n ${verification_uri_complete}\n`)
|
|
206
|
+
console.log(`用户代码:${user_code}`)
|
|
207
|
+
console.log(`有效时间:${Math.floor(expires_in / 60)} 分钟\n`)
|
|
208
|
+
|
|
209
|
+
if (!options.has('--no-open')) {
|
|
210
|
+
try {
|
|
211
|
+
await openBrowser(verification_uri_complete)
|
|
212
|
+
console.log('已尝试打开默认浏览器。如未打开,请手动复制上述链接。')
|
|
213
|
+
} catch {
|
|
214
|
+
console.log('未能自动打开浏览器,请手动复制上述链接完成授权。')
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
console.log('等待授权中(按 Ctrl+C 可取消)...')
|
|
219
|
+
const pollUrl = `${platformApiUrl}/device-auth/tokens`
|
|
220
|
+
const pollIntervalMs = Math.max(interval, 2) * 1000
|
|
221
|
+
const deadline = Date.now() + expires_in * 1000
|
|
222
|
+
|
|
223
|
+
while (Date.now() < deadline) {
|
|
224
|
+
await sleep(pollIntervalMs)
|
|
225
|
+
try {
|
|
226
|
+
const pollResult = await requestJson(pollUrl, {
|
|
227
|
+
method: 'POST',
|
|
228
|
+
body: { device_code },
|
|
229
|
+
})
|
|
230
|
+
if (pollResult && pollResult.api_key) {
|
|
231
|
+
const stored = {
|
|
232
|
+
domain: requestedDomain,
|
|
233
|
+
api_base_url: apiBaseUrl,
|
|
234
|
+
api_key: pollResult.api_key,
|
|
235
|
+
api_key_prefix: pollResult.api_key_prefix || `${pollResult.api_key.slice(0, 12)}...`,
|
|
236
|
+
project_id: pollResult.project_id,
|
|
237
|
+
project_no: pollResult.project_no,
|
|
238
|
+
project_name: pollResult.project_name,
|
|
239
|
+
user_id: pollResult.user_id,
|
|
240
|
+
capabilities: pollResult.capabilities || scopes,
|
|
241
|
+
ability_keys: pollResult.ability_keys || abilities,
|
|
242
|
+
authorized_at: new Date().toISOString(),
|
|
243
|
+
}
|
|
244
|
+
await updateAuthStore((store) => {
|
|
245
|
+
store.domains[requestedDomain] = stored
|
|
246
|
+
store.current = requestedDomain
|
|
247
|
+
})
|
|
248
|
+
console.log(`\n授权成功!项目:${stored.project_name}(${stored.project_no})`)
|
|
249
|
+
console.log(`凭据已安全保存至:${authFilePath()}`)
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
} catch (err) {
|
|
253
|
+
if (err instanceof HabitaxxHttpError) {
|
|
254
|
+
if (err.code === 'AUTHORIZATION_PENDING') continue
|
|
255
|
+
if (err.code === 'SLOW_DOWN') {
|
|
256
|
+
await sleep(pollIntervalMs)
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
throw err
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
throw new Error('授权超时,请重新执行 habitaxx auth init')
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function authStatus(args) {
|
|
267
|
+
const { options, positionals } = parseArgs(args, {
|
|
268
|
+
values: ['--domain', '--api-base-url'],
|
|
269
|
+
booleans: ['--json', '--check'],
|
|
270
|
+
})
|
|
271
|
+
if (positionals.length) throw new Error(`无法识别的参数:${positionals.join(' ')}`)
|
|
272
|
+
const store = await loadAuthStore()
|
|
273
|
+
const resolved = currentAuthorization(store, selectedDomain(options))
|
|
274
|
+
|
|
275
|
+
let valid = null
|
|
276
|
+
if (options.has('--check')) {
|
|
277
|
+
try {
|
|
278
|
+
await exchangeRuntimeToken(resolved.authorization)
|
|
279
|
+
valid = true
|
|
280
|
+
} catch (err) {
|
|
281
|
+
valid = false
|
|
282
|
+
if (!options.has('--json')) {
|
|
283
|
+
console.warn(`警告:远程校验失败:${err.message}`)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const safe = publicAuthorization(resolved.authorization)
|
|
289
|
+
if (options.has('--json')) {
|
|
290
|
+
console.log(JSON.stringify({ ...safe, valid }, null, 2))
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
console.log(`当前域:${safe.domain}`)
|
|
295
|
+
console.log(`API 地址:${safe.api_base_url}`)
|
|
296
|
+
console.log(`项目:${safe.project_name} (${safe.project_no})`)
|
|
297
|
+
console.log(`API Key:${safe.api_key_prefix}`)
|
|
298
|
+
console.log(`权限:${safe.capabilities.length ? safe.capabilities.join(', ') : '无'}`)
|
|
299
|
+
console.log(`能力:${safe.ability_keys.length ? safe.ability_keys.join(', ') : '全部'}`)
|
|
300
|
+
console.log(`授权时间:${safe.authorized_at}`)
|
|
301
|
+
console.log(`凭据文件:${authFilePath()}`)
|
|
302
|
+
if (valid !== null) console.log(`远程校验:${valid ? '有效' : '失败'}`)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function authLogout(args) {
|
|
306
|
+
const { options, positionals } = parseArgs(args, {
|
|
307
|
+
values: ['--domain', '--api-base-url'],
|
|
308
|
+
booleans: ['--all'],
|
|
309
|
+
})
|
|
310
|
+
if (positionals.length) throw new Error(`无法识别的参数:${positionals.join(' ')}`)
|
|
311
|
+
if (!(await authFileExists())) {
|
|
312
|
+
console.log('本地没有授权信息。')
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
let removedDomain
|
|
316
|
+
await updateAuthStore((store) => {
|
|
317
|
+
if (options.has('--all')) {
|
|
318
|
+
store.domains = {}
|
|
319
|
+
store.current = null
|
|
320
|
+
} else {
|
|
321
|
+
const selected = currentAuthorization(store, selectedDomain(options))
|
|
322
|
+
removedDomain = selected.domain
|
|
323
|
+
delete store.domains[selected.domain]
|
|
324
|
+
if (store.current === selected.domain) store.current = Object.keys(store.domains)[0] || null
|
|
325
|
+
}
|
|
326
|
+
})
|
|
327
|
+
console.log(`已删除${removedDomain || '全部域'}的本地凭据。远程 API Key 未被撤销,请在开放平台中撤销。`)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function parseHeaders(values) {
|
|
331
|
+
const headers = {}
|
|
332
|
+
for (const value of values) {
|
|
333
|
+
const separator = value.indexOf(':')
|
|
334
|
+
if (separator <= 0) throw new Error(`无效的 --header 参数:${value}`)
|
|
335
|
+
const name = value.slice(0, separator).trim()
|
|
336
|
+
const headerValue = value.slice(separator + 1).trim()
|
|
337
|
+
if (/^(authorization|x-api-key|host|proxy-authorization|cookie)$/i.test(name)) {
|
|
338
|
+
throw new Error(`不允许通过 --header 覆盖 ${name},认证由 CLI 安全管理`)
|
|
339
|
+
}
|
|
340
|
+
headers[name] = headerValue
|
|
341
|
+
}
|
|
342
|
+
return headers
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function resolveAuthorization(options) {
|
|
346
|
+
const store = await loadAuthStore()
|
|
347
|
+
return currentAuthorization(store, selectedDomain(options)).authorization
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function printResponse(result) {
|
|
351
|
+
if (result.output) {
|
|
352
|
+
console.log(`响应已保存:${result.output}`)
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
if (typeof result.body === 'string') {
|
|
356
|
+
process.stdout.write(result.body.endsWith('\n') ? result.body : `${result.body}\n`)
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
console.log(JSON.stringify(result.body, null, 2))
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function requestCommand(args) {
|
|
363
|
+
const { options, positionals } = parseArgs(args, {
|
|
364
|
+
values: ['--domain', '--api-base-url', '--data', '--form', '--header', '--output'],
|
|
365
|
+
})
|
|
366
|
+
if (positionals.length !== 2) throw new Error('用法:habitaxx request <方法> <路径> [选项]')
|
|
367
|
+
if (options.has('--data') && options.has('--form')) throw new Error('--data 与 --form 不能同时使用')
|
|
368
|
+
|
|
369
|
+
const [method, requestPath] = positionals
|
|
370
|
+
const headers = parseHeaders(options.has('--header') ? asArray(options.get('--header')) : [])
|
|
371
|
+
let body
|
|
372
|
+
if (options.has('--data')) {
|
|
373
|
+
const data = await parseDataArgument(stringOption(options, '--data', ''))
|
|
374
|
+
headers['Content-Type'] = 'application/json'
|
|
375
|
+
body = JSON.stringify(data)
|
|
376
|
+
} else if (options.has('--form')) {
|
|
377
|
+
body = await buildMultipart(asArray(options.get('--form')).map(String))
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const authorization = await resolveAuthorization(options)
|
|
381
|
+
const result = await callOpenApi(authorization, requestPath, {
|
|
382
|
+
method,
|
|
383
|
+
headers,
|
|
384
|
+
body,
|
|
385
|
+
output: stringOption(options, '--output', ''),
|
|
386
|
+
})
|
|
387
|
+
printResponse(result)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function birdDetect(args) {
|
|
391
|
+
const { options, positionals } = parseArgs(args, {
|
|
392
|
+
values: ['--file', '--domain', '--api-base-url', '--output'],
|
|
393
|
+
})
|
|
394
|
+
if (positionals.length) throw new Error(`无法识别的参数:${positionals.join(' ')}`)
|
|
395
|
+
const filePath = stringOption(options, '--file', '')
|
|
396
|
+
if (!filePath) throw new Error('缺少 --file <图片路径>')
|
|
397
|
+
|
|
398
|
+
const form = await buildMultipart([`file=@${filePath}`])
|
|
399
|
+
const authorization = await resolveAuthorization(options)
|
|
400
|
+
const result = await callOpenApi(authorization, '/bird/detect', {
|
|
401
|
+
method: 'POST',
|
|
402
|
+
body: form,
|
|
403
|
+
output: stringOption(options, '--output', ''),
|
|
404
|
+
})
|
|
405
|
+
printResponse(result)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function imuPredict(args) {
|
|
409
|
+
const { options, positionals } = parseArgs(args, {
|
|
410
|
+
values: ['--data', '--device', '--top-k', '--domain', '--api-base-url', '--output'],
|
|
411
|
+
})
|
|
412
|
+
if (positionals.length) throw new Error(`无法识别的参数:${positionals.join(' ')}`)
|
|
413
|
+
const rawData = stringOption(options, '--data', '')
|
|
414
|
+
if (!rawData) throw new Error('缺少 --data 参数(支持 JSON 字符串或 @文件路径)')
|
|
415
|
+
|
|
416
|
+
const payload = await parseDataArgument(rawData)
|
|
417
|
+
if (options.has('--top-k')) {
|
|
418
|
+
payload.top_k = Number(stringOption(options, '--top-k', '5'))
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const deviceId = stringOption(options, '--device', '')
|
|
422
|
+
const path = deviceId ? `/imu/predict/${encodeURIComponent(deviceId)}` : '/imu/predict'
|
|
423
|
+
|
|
424
|
+
const authorization = await resolveAuthorization(options)
|
|
425
|
+
const result = await callOpenApi(authorization, path, {
|
|
426
|
+
method: 'POST',
|
|
427
|
+
headers: { 'Content-Type': 'application/json' },
|
|
428
|
+
body: JSON.stringify(payload),
|
|
429
|
+
output: stringOption(options, '--output', ''),
|
|
430
|
+
})
|
|
431
|
+
printResponse(result)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export async function main(args) {
|
|
435
|
+
if (args.includes('--help') || args.includes('-h') || !args.length || args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
|
|
436
|
+
console.log(HELP)
|
|
437
|
+
return
|
|
438
|
+
}
|
|
439
|
+
if (args[0] === '--version' || args[0] === '-v') {
|
|
440
|
+
console.log(VERSION)
|
|
441
|
+
return
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const [command, subcommand, ...rest] = args
|
|
445
|
+
if (command === 'auth' && subcommand === 'init') return authInit(rest)
|
|
446
|
+
if (command === 'auth' && subcommand === 'status') return authStatus(rest)
|
|
447
|
+
if (command === 'auth' && subcommand === 'logout') return authLogout(rest)
|
|
448
|
+
if (command === 'request') return requestCommand([subcommand, ...rest].filter((value) => value !== undefined))
|
|
449
|
+
if (command === 'bird' && subcommand === 'detect') return birdDetect(rest)
|
|
450
|
+
if (command === 'imu' && subcommand === 'predict') return imuPredict(rest)
|
|
451
|
+
|
|
452
|
+
throw new Error(`未知命令:${args.join(' ')}\n\n${HELP}`)
|
|
453
|
+
}
|
package/src/http.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
export function validateBaseUrl(value) {
|
|
5
|
+
const url = new URL(value)
|
|
6
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
|
|
7
|
+
if (url.username || url.password || url.search || url.hash
|
|
8
|
+
|| (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback))) {
|
|
9
|
+
throw new Error('地址必须使用 HTTPS(本地 loopback 可用 HTTP),且不能含凭据、query 或 fragment')
|
|
10
|
+
}
|
|
11
|
+
return trimTrailingSlash(url.href)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function connectionError(error, url, method) {
|
|
15
|
+
// 只展示公开地址和已知错误码,不输出请求头、请求体或 query 中的凭据。
|
|
16
|
+
const target = new URL(url)
|
|
17
|
+
const codes = [error?.code, error?.cause?.code,
|
|
18
|
+
...(error?.cause?.errors || []).map((item) => item?.code)]
|
|
19
|
+
const reasons = {
|
|
20
|
+
ECONNREFUSED: '连接被拒绝,请确认目标服务已启动,并检查监听地址和端口',
|
|
21
|
+
ENOTFOUND: '域名解析失败,请检查地址和 DNS',
|
|
22
|
+
EAI_AGAIN: '域名解析暂时失败,请检查网络和 DNS',
|
|
23
|
+
ETIMEDOUT: '连接超时,请检查网络、防火墙和服务状态',
|
|
24
|
+
UND_ERR_CONNECT_TIMEOUT: '连接超时,请检查网络、防火墙和服务状态',
|
|
25
|
+
UND_ERR_HEADERS_TIMEOUT: '等待响应头超时',
|
|
26
|
+
UND_ERR_BODY_TIMEOUT: '读取响应体超时',
|
|
27
|
+
ECONNRESET: '连接被重置,请检查服务或代理日志',
|
|
28
|
+
UND_ERR_SOCKET: '连接意外关闭,请检查服务或代理日志',
|
|
29
|
+
CERT_HAS_EXPIRED: 'TLS 证书已过期,请检查服务器证书',
|
|
30
|
+
DEPTH_ZERO_SELF_SIGNED_CERT: 'TLS 证书不受信任,请检查服务器证书',
|
|
31
|
+
EPERM: '当前进程无权访问目标地址,请检查系统或沙箱网络权限',
|
|
32
|
+
EACCES: '当前进程无权访问目标地址,请检查系统或沙箱网络权限',
|
|
33
|
+
}
|
|
34
|
+
const code = codes.find((value) => Object.hasOwn(reasons, value))
|
|
35
|
+
const reason = code ? `${reasons[code]}(${code})`
|
|
36
|
+
: '连接失败,请检查网络、TLS 证书或代理;CLI 不接受 HTTP 重定向'
|
|
37
|
+
return new Error(`${method || 'GET'} ${target.origin}${target.pathname}:${reason}`, { cause: error })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function boundedFetch(url, options, timeoutMs = 30000) {
|
|
41
|
+
const controller = new AbortController()
|
|
42
|
+
const abort = () => controller.abort(options.signal?.reason)
|
|
43
|
+
if (options.signal?.aborted) abort()
|
|
44
|
+
options.signal?.addEventListener('abort', abort, { once: true })
|
|
45
|
+
const timer = setTimeout(() => controller.abort(new Error('请求超时')), timeoutMs)
|
|
46
|
+
try {
|
|
47
|
+
// 禁止重定向,尤其防止 X-API-Key 或设备凭证被带到其他域。
|
|
48
|
+
const response = await fetch(url, { ...options, redirect: 'error', signal: controller.signal })
|
|
49
|
+
// 在超时范围内读取响应体,而非只等待响应头。
|
|
50
|
+
const bytes = await response.arrayBuffer()
|
|
51
|
+
return new Response([204, 205, 304].includes(response.status) ? null : bytes, {
|
|
52
|
+
status: response.status, headers: response.headers,
|
|
53
|
+
})
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (controller.signal.aborted) {
|
|
56
|
+
if (options.signal?.aborted) throw options.signal.reason || new Error('请求已取消')
|
|
57
|
+
const target = new URL(url)
|
|
58
|
+
throw new Error(`${options.method || 'GET'} ${target.origin}${target.pathname}:请求超时(${timeoutMs / 1000} 秒)`)
|
|
59
|
+
}
|
|
60
|
+
throw connectionError(error, url, options.method)
|
|
61
|
+
} finally {
|
|
62
|
+
clearTimeout(timer)
|
|
63
|
+
options.signal?.removeEventListener('abort', abort)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class HabitaxxHttpError extends Error {
|
|
68
|
+
constructor(message, { status, code, errorCode, callId, body } = {}) {
|
|
69
|
+
super(message)
|
|
70
|
+
this.name = 'HabitaxxHttpError'
|
|
71
|
+
this.status = status
|
|
72
|
+
this.code = code
|
|
73
|
+
this.errorCode = errorCode
|
|
74
|
+
this.callId = callId
|
|
75
|
+
this.body = body
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function trimTrailingSlash(value) {
|
|
80
|
+
return value.trim().replace(/\/+$/, '')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function unwrapPayload(value) {
|
|
84
|
+
if (
|
|
85
|
+
value
|
|
86
|
+
&& typeof value === 'object'
|
|
87
|
+
&& Object.hasOwn(value, 'code')
|
|
88
|
+
&& Object.hasOwn(value, 'message')
|
|
89
|
+
&& Object.hasOwn(value, 'data')
|
|
90
|
+
) {
|
|
91
|
+
if (value.code !== 200 && value.code !== 0) {
|
|
92
|
+
const msg = value.message || `业务请求失败(代码 ${value.code})`
|
|
93
|
+
throw new HabitaxxHttpError(msg, {
|
|
94
|
+
status: 200,
|
|
95
|
+
code: value.code,
|
|
96
|
+
errorCode: value.error_code,
|
|
97
|
+
callId: value.call_id,
|
|
98
|
+
body: value,
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
return value.data
|
|
102
|
+
}
|
|
103
|
+
return value
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function responseBody(response) {
|
|
107
|
+
const contentType = response.headers.get('content-type') || ''
|
|
108
|
+
if (contentType.includes('json')) {
|
|
109
|
+
try {
|
|
110
|
+
return await response.json()
|
|
111
|
+
} catch {
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return response.text()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function errorMessage(response, body) {
|
|
119
|
+
if (body && typeof body === 'object') {
|
|
120
|
+
const message = body.message || body.detail || body.error
|
|
121
|
+
if (typeof message === 'string' && message.trim()) return message.trim()
|
|
122
|
+
}
|
|
123
|
+
if (typeof body === 'string' && body.trim()) return body.trim().slice(0, 300)
|
|
124
|
+
return `请求失败(HTTP ${response.status})`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function requestJson(url, { method = 'GET', headers = {}, body, signal } = {}) {
|
|
128
|
+
const response = await boundedFetch(url, {
|
|
129
|
+
method,
|
|
130
|
+
headers: {
|
|
131
|
+
Accept: 'application/json',
|
|
132
|
+
'X-Client-Type': 'open_platform',
|
|
133
|
+
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
134
|
+
...headers,
|
|
135
|
+
},
|
|
136
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
137
|
+
signal,
|
|
138
|
+
})
|
|
139
|
+
const parsed = await responseBody(response)
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
throw new HabitaxxHttpError(errorMessage(response, parsed), {
|
|
142
|
+
status: response.status,
|
|
143
|
+
code: parsed && typeof parsed === 'object' ? parsed.code : undefined,
|
|
144
|
+
errorCode: parsed && typeof parsed === 'object' ? parsed.error_code : undefined,
|
|
145
|
+
callId: parsed && typeof parsed === 'object' ? parsed.call_id : undefined,
|
|
146
|
+
body: parsed,
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
return unwrapPayload(parsed)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function exchangeRuntimeToken(authorization, { signal } = {}) {
|
|
153
|
+
const result = await requestJson(`${validateBaseUrl(authorization.api_base_url)}/auth/token`, {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers: { 'X-API-Key': authorization.api_key },
|
|
156
|
+
body: {
|
|
157
|
+
project_no: authorization.project_no,
|
|
158
|
+
user_id: authorization.user_id,
|
|
159
|
+
},
|
|
160
|
+
signal,
|
|
161
|
+
})
|
|
162
|
+
if (!result?.access_token) throw new Error('平台未返回有效的 Runtime Access Token')
|
|
163
|
+
return result
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function safeApiUrl(apiBaseUrl, requestPath) {
|
|
167
|
+
if (!requestPath || /^[a-z][a-z\d+.-]*:/i.test(requestPath) || requestPath.startsWith('//')) {
|
|
168
|
+
throw new Error('请求路径必须是相对开放 API 根地址的路径,例如 /bird/detect')
|
|
169
|
+
}
|
|
170
|
+
const base = new URL(`${validateBaseUrl(apiBaseUrl)}/`)
|
|
171
|
+
const target = new URL(requestPath.replace(/^\/+/, ''), base)
|
|
172
|
+
if (target.origin !== base.origin || !target.pathname.startsWith(base.pathname) || target.hash) {
|
|
173
|
+
throw new Error('请求路径不能跳出开放 API 根地址')
|
|
174
|
+
}
|
|
175
|
+
return target.href
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function callOpenApi(
|
|
179
|
+
authorization,
|
|
180
|
+
requestPath,
|
|
181
|
+
{ method = 'GET', headers = {}, body, output, signal } = {},
|
|
182
|
+
) {
|
|
183
|
+
const url = safeApiUrl(authorization.api_base_url, requestPath)
|
|
184
|
+
const token = await exchangeRuntimeToken(authorization, { signal })
|
|
185
|
+
const response = await boundedFetch(url, {
|
|
186
|
+
method: method.toUpperCase(),
|
|
187
|
+
headers: {
|
|
188
|
+
Accept: 'application/json',
|
|
189
|
+
'X-Client-Type': 'open_platform',
|
|
190
|
+
...headers,
|
|
191
|
+
Authorization: `Bearer ${token.access_token}`,
|
|
192
|
+
},
|
|
193
|
+
body,
|
|
194
|
+
signal,
|
|
195
|
+
}, 120000)
|
|
196
|
+
|
|
197
|
+
const parsed = await responseBody(response)
|
|
198
|
+
if (!response.ok) {
|
|
199
|
+
throw new HabitaxxHttpError(errorMessage(response, parsed), {
|
|
200
|
+
status: response.status,
|
|
201
|
+
code: parsed && typeof parsed === 'object' ? parsed.code : undefined,
|
|
202
|
+
errorCode: parsed && typeof parsed === 'object' ? parsed.error_code : undefined,
|
|
203
|
+
callId: parsed && typeof parsed === 'object' ? parsed.call_id : undefined,
|
|
204
|
+
body: parsed,
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 检查业务层错误(统一 200 返回中 code != 200)
|
|
209
|
+
if (parsed && typeof parsed === 'object' && Object.hasOwn(parsed, 'code') && parsed.code !== 200 && parsed.code !== 0) {
|
|
210
|
+
const msg = parsed.message || `业务请求失败(代码 ${parsed.code})`
|
|
211
|
+
throw new HabitaxxHttpError(msg, {
|
|
212
|
+
status: response.status,
|
|
213
|
+
code: parsed.code,
|
|
214
|
+
errorCode: parsed.error_code,
|
|
215
|
+
callId: parsed.call_id,
|
|
216
|
+
body: parsed,
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (output) {
|
|
221
|
+
const target = path.resolve(output)
|
|
222
|
+
if (Buffer.isBuffer(parsed) || typeof parsed === 'string') {
|
|
223
|
+
await writeFile(target, parsed)
|
|
224
|
+
} else {
|
|
225
|
+
await writeFile(target, JSON.stringify(parsed, null, 2))
|
|
226
|
+
}
|
|
227
|
+
return { output: target, status: response.status }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
status: response.status,
|
|
232
|
+
contentType: response.headers.get('content-type') || '',
|
|
233
|
+
body: parsed,
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function parseDataArgument(value) {
|
|
238
|
+
const raw = value.startsWith('@')
|
|
239
|
+
? await readFile(path.resolve(value.slice(1)), 'utf8')
|
|
240
|
+
: value
|
|
241
|
+
try {
|
|
242
|
+
return JSON.parse(raw)
|
|
243
|
+
} catch {
|
|
244
|
+
throw new Error('--data 必须是有效 JSON,或使用 @文件路径')
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const FILE_CONTENT_TYPES = {
|
|
249
|
+
'.png': 'image/png',
|
|
250
|
+
'.jpg': 'image/jpeg',
|
|
251
|
+
'.jpeg': 'image/jpeg',
|
|
252
|
+
'.webp': 'image/webp',
|
|
253
|
+
'.gif': 'image/gif',
|
|
254
|
+
'.bmp': 'image/bmp',
|
|
255
|
+
'.tif': 'image/tiff',
|
|
256
|
+
'.tiff': 'image/tiff',
|
|
257
|
+
'.avif': 'image/avif',
|
|
258
|
+
'.svg': 'image/svg+xml',
|
|
259
|
+
'.ico': 'image/vnd.microsoft.icon',
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function buildMultipart(entries) {
|
|
263
|
+
const form = new FormData()
|
|
264
|
+
for (const entry of entries) {
|
|
265
|
+
const separator = entry.indexOf('=')
|
|
266
|
+
if (separator <= 0) throw new Error(`无效的 --form 参数:${entry}`)
|
|
267
|
+
const name = entry.slice(0, separator).trim()
|
|
268
|
+
const value = entry.slice(separator + 1)
|
|
269
|
+
if (!name) throw new Error(`无效的 --form 参数:${entry}`)
|
|
270
|
+
if (value.startsWith('@')) {
|
|
271
|
+
const filePath = path.resolve(value.slice(1))
|
|
272
|
+
const bytes = await readFile(filePath)
|
|
273
|
+
// 文件部分需要自己的 MIME 类型;外层 multipart boundary 仍由 fetch 生成。
|
|
274
|
+
const type = FILE_CONTENT_TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream'
|
|
275
|
+
form.append(name, new Blob([bytes], { type }), path.basename(filePath))
|
|
276
|
+
} else {
|
|
277
|
+
form.append(name, value)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return form
|
|
281
|
+
}
|