@aiform/cli-core 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 +13 -0
- package/package.json +30 -0
- package/src/http.mjs +121 -0
- package/src/index.d.ts +46 -0
- package/src/index.mjs +3 -0
- package/src/options.mjs +50 -0
- package/src/prompt.mjs +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# @aiform/cli-core
|
|
2
|
+
|
|
3
|
+
Hive CLI 共用底座:登录/cookie、JSON fetch(结构化错误)、隐藏密码输入、commander action 包装。被 [`@aiform/cli-workspace`](https://www.npmjs.com/package/@aiform/cli-workspace) 和 [`@aiform/cli-platform`](https://www.npmjs.com/package/@aiform/cli-platform) 依赖。一般不直接用。
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
import { ApiClient, resolveBaseUrl, resolveCreds, runAction, promptHidden, die } from '@aiform/cli-core';
|
|
7
|
+
|
|
8
|
+
const c = new ApiClient(resolveBaseUrl(opts.url, 'WORKSPACE_URL', 'http://localhost:3000'));
|
|
9
|
+
await c.login(await resolveCreds(opts, { tag: 'my-cli', emailEnv: 'X_EMAIL', passwordEnv: 'X_PW' }));
|
|
10
|
+
const { members } = await c.get('/api/members');
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
ESM only;要求 Node ≥ 20(用内置 `fetch` + `Headers.getSetCookie()`)。
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aiform/cli-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared HTTP-client plumbing for the hive CLIs (login/cookie, JSON fetch, hidden prompt)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.mjs",
|
|
8
|
+
"types": "./src/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./src/index.d.ts",
|
|
12
|
+
"default": "./src/index.mjs"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"vitest": "^2.1.8"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "vitest run"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/http.mjs
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// 极简 HTTP 客户端:把 hive workspace / platform 的 Next.js API routes 当远端服务调。
|
|
2
|
+
//
|
|
3
|
+
// 设计:
|
|
4
|
+
// - 所有 API route 失败返回 `{ error: 'CODE', message?: '...' }` + 非 2xx 状态;
|
|
5
|
+
// 这里把它包成 ApiError,CLI 直接 console.error + 退码。
|
|
6
|
+
// - 登录走 `POST /api/auth/login` `{email,password}` → `Set-Cookie`;不关心 cookie 名字,
|
|
7
|
+
// 把所有 set-cookie 的 `name=value` 段拼起来当 `Cookie` 头回放即可。
|
|
8
|
+
// - bootstrap-token 这类非 cookie 鉴权由调用方传 `headers: { Authorization: 'Bearer ...' }`。
|
|
9
|
+
|
|
10
|
+
export class ApiError extends Error {
|
|
11
|
+
/** @param {number} status @param {string} code @param {string} [detail] @param {string} [path] */
|
|
12
|
+
constructor(status, code, detail, path) {
|
|
13
|
+
const where = path ? ` (${path})` : '';
|
|
14
|
+
super(`${status} ${code}${detail ? `: ${detail}` : ''}${where}`);
|
|
15
|
+
this.name = 'ApiError';
|
|
16
|
+
this.status = status;
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.detail = detail ?? null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 去掉 baseUrl 尾斜杠,确保 path 以 `/` 开头。 */
|
|
23
|
+
function joinUrl(baseUrl, path) {
|
|
24
|
+
const b = String(baseUrl).replace(/\/+$/, '');
|
|
25
|
+
const p = path.startsWith('/') ? path : `/${path}`;
|
|
26
|
+
return `${b}${p}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function parseBody(res) {
|
|
30
|
+
const text = await res.text();
|
|
31
|
+
if (!text) return null;
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(text);
|
|
34
|
+
} catch {
|
|
35
|
+
return { _raw: text };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class ApiClient {
|
|
40
|
+
/** @param {string} baseUrl */
|
|
41
|
+
constructor(baseUrl) {
|
|
42
|
+
this.baseUrl = baseUrl;
|
|
43
|
+
/** @type {string|null} */
|
|
44
|
+
this.cookie = null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `POST /api/auth/login`;成功后把 Set-Cookie 存到 this.cookie。
|
|
49
|
+
* @param {{email: string, password: string}} creds
|
|
50
|
+
*/
|
|
51
|
+
async login({ email, password }) {
|
|
52
|
+
const res = await fetch(joinUrl(this.baseUrl, '/api/auth/login'), {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'Content-Type': 'application/json' },
|
|
55
|
+
body: JSON.stringify({ email, password }),
|
|
56
|
+
});
|
|
57
|
+
if (res.status !== 200) {
|
|
58
|
+
const body = await parseBody(res);
|
|
59
|
+
throw new ApiError(
|
|
60
|
+
res.status,
|
|
61
|
+
body?.error ?? 'LOGIN_FAILED',
|
|
62
|
+
body?.message ?? (typeof body?._raw === 'string' ? body._raw.slice(0, 200) : undefined),
|
|
63
|
+
'/api/auth/login',
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
const setCookies = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [];
|
|
67
|
+
const jar = setCookies.map((c) => c.split(';')[0].trim()).filter(Boolean);
|
|
68
|
+
if (jar.length === 0) throw new ApiError(res.status, 'NO_SET_COOKIE', 'login response carried no Set-Cookie', '/api/auth/login');
|
|
69
|
+
this.cookie = jar.join('; ');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 通用请求。2xx → 解析 JSON 返回;否则抛 ApiError。
|
|
74
|
+
* @param {string} method
|
|
75
|
+
* @param {string} path
|
|
76
|
+
* @param {{body?: unknown, headers?: Record<string,string>, query?: Record<string, string|number|boolean|undefined>}} [opts]
|
|
77
|
+
*/
|
|
78
|
+
async request(method, path, opts = {}) {
|
|
79
|
+
let url = joinUrl(this.baseUrl, path);
|
|
80
|
+
if (opts.query) {
|
|
81
|
+
const qs = new URLSearchParams();
|
|
82
|
+
for (const [k, v] of Object.entries(opts.query)) {
|
|
83
|
+
if (v === undefined || v === null || v === false) continue;
|
|
84
|
+
qs.set(k, String(v === true ? 1 : v));
|
|
85
|
+
}
|
|
86
|
+
const s = qs.toString();
|
|
87
|
+
if (s) url += `?${s}`;
|
|
88
|
+
}
|
|
89
|
+
const headers = { ...(opts.headers ?? {}) };
|
|
90
|
+
if (this.cookie && !headers.Cookie) headers.Cookie = this.cookie;
|
|
91
|
+
let body;
|
|
92
|
+
if (opts.body !== undefined) {
|
|
93
|
+
headers['Content-Type'] = 'application/json';
|
|
94
|
+
body = JSON.stringify(opts.body);
|
|
95
|
+
}
|
|
96
|
+
const res = await fetch(url, { method, headers, body });
|
|
97
|
+
const parsed = await parseBody(res);
|
|
98
|
+
if (res.status < 200 || res.status >= 300) {
|
|
99
|
+
throw new ApiError(
|
|
100
|
+
res.status,
|
|
101
|
+
parsed?.error ?? 'HTTP_ERROR',
|
|
102
|
+
parsed?.message ?? (typeof parsed?._raw === 'string' ? parsed._raw.slice(0, 200) : undefined),
|
|
103
|
+
path,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return parsed;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
get(path, opts) {
|
|
110
|
+
return this.request('GET', path, opts);
|
|
111
|
+
}
|
|
112
|
+
post(path, body, opts) {
|
|
113
|
+
return this.request('POST', path, { ...opts, body });
|
|
114
|
+
}
|
|
115
|
+
put(path, body, opts) {
|
|
116
|
+
return this.request('PUT', path, { ...opts, body });
|
|
117
|
+
}
|
|
118
|
+
del(path, opts) {
|
|
119
|
+
return this.request('DELETE', path, opts);
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// 手写类型声明(包体是纯 .mjs;这份 .d.ts 仅给 TS 消费者 / 编辑器用)。
|
|
2
|
+
|
|
3
|
+
export class ApiError extends Error {
|
|
4
|
+
status: number;
|
|
5
|
+
code: string;
|
|
6
|
+
detail: string | null;
|
|
7
|
+
constructor(status: number, code: string, detail?: string, path?: string);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ApiRequestOptions {
|
|
11
|
+
body?: unknown;
|
|
12
|
+
headers?: Record<string, string>;
|
|
13
|
+
query?: Record<string, string | number | boolean | undefined>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class ApiClient {
|
|
17
|
+
baseUrl: string;
|
|
18
|
+
cookie: string | null;
|
|
19
|
+
constructor(baseUrl: string);
|
|
20
|
+
login(creds: { email: string; password: string }): Promise<void>;
|
|
21
|
+
request(method: string, path: string, opts?: ApiRequestOptions): Promise<any>;
|
|
22
|
+
get(path: string, opts?: ApiRequestOptions): Promise<any>;
|
|
23
|
+
post(path: string, body?: unknown, opts?: ApiRequestOptions): Promise<any>;
|
|
24
|
+
put(path: string, body?: unknown, opts?: ApiRequestOptions): Promise<any>;
|
|
25
|
+
del(path: string, opts?: ApiRequestOptions): Promise<any>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function promptHidden(label: string): Promise<string>;
|
|
29
|
+
|
|
30
|
+
export function die(tag: string, msg: string, code?: number): never;
|
|
31
|
+
|
|
32
|
+
export function resolveBaseUrl(
|
|
33
|
+
optValue: string | undefined,
|
|
34
|
+
envName: string,
|
|
35
|
+
fallback: string,
|
|
36
|
+
): string;
|
|
37
|
+
|
|
38
|
+
export function resolveCreds(
|
|
39
|
+
opts: { email?: string; password?: string },
|
|
40
|
+
cfg: { tag: string; emailEnv: string; passwordEnv: string },
|
|
41
|
+
): Promise<{ email: string; password: string }>;
|
|
42
|
+
|
|
43
|
+
export function runAction<A extends unknown[]>(
|
|
44
|
+
tag: string,
|
|
45
|
+
fn: (...args: A) => Promise<void> | void,
|
|
46
|
+
): (...args: A) => Promise<void>;
|
package/src/index.mjs
ADDED
package/src/options.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { promptHidden } from './prompt.mjs';
|
|
2
|
+
import { ApiError } from './http.mjs';
|
|
3
|
+
|
|
4
|
+
/** console.error(`[tag] msg`) + process.exit(code). */
|
|
5
|
+
export function die(tag, msg, code = 1) {
|
|
6
|
+
console.error(`[${tag}] ${msg}`);
|
|
7
|
+
process.exit(code);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** flag 优先,其次 env,最后 fallback。 */
|
|
11
|
+
export function resolveBaseUrl(optValue, envName, fallback) {
|
|
12
|
+
return optValue || process.env[envName] || fallback;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 解析登录凭据:email 必须来自 --email / env;password 来自 --password / env / 隐藏输入。
|
|
17
|
+
* 缺 email → 退码 2;缺 password → 退码 2;Ctrl-C 取消 → 退码 130。
|
|
18
|
+
* @param {{email?: string, password?: string}} opts
|
|
19
|
+
* @param {{tag: string, emailEnv: string, passwordEnv: string}} cfg
|
|
20
|
+
* @returns {Promise<{email: string, password: string}>}
|
|
21
|
+
*/
|
|
22
|
+
export async function resolveCreds(opts, { tag, emailEnv, passwordEnv }) {
|
|
23
|
+
const email = opts.email || process.env[emailEnv];
|
|
24
|
+
if (!email) die(tag, `--email 或环境变量 ${emailEnv} 必填`, 2);
|
|
25
|
+
let password = opts.password || process.env[passwordEnv];
|
|
26
|
+
if (!password) {
|
|
27
|
+
try {
|
|
28
|
+
password = await promptHidden(`password for ${email}: `);
|
|
29
|
+
} catch {
|
|
30
|
+
die(tag, 'aborted', 130);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!password) die(tag, 'password 不能为空', 2);
|
|
34
|
+
return { email, password };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 包 commander action:捕获 ApiError / 普通 Error → `[tag] <msg>` + exit 1。
|
|
39
|
+
* 用法:`.action(runAction('hive-workspace', async (email, opts) => { ... }))`
|
|
40
|
+
*/
|
|
41
|
+
export function runAction(tag, fn) {
|
|
42
|
+
return async (...args) => {
|
|
43
|
+
try {
|
|
44
|
+
await fn(...args);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err instanceof ApiError) die(tag, err.message);
|
|
47
|
+
die(tag, err instanceof Error ? err.message : String(err));
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
package/src/prompt.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
|
|
3
|
+
const CTRL_C = '\x03';
|
|
4
|
+
const DEL = '\x7f';
|
|
5
|
+
const BS = '\x08';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 隐藏字符输入(密码等),逐字回显 '*'。
|
|
9
|
+
* Ctrl-C / 非 TTY 关 EOF 时 reject(new Error('aborted'))。
|
|
10
|
+
* @param {string} label
|
|
11
|
+
* @returns {Promise<string>}
|
|
12
|
+
*/
|
|
13
|
+
export function promptHidden(label) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const rl = createInterface({
|
|
16
|
+
input: process.stdin,
|
|
17
|
+
output: process.stdout,
|
|
18
|
+
terminal: true,
|
|
19
|
+
});
|
|
20
|
+
process.stdout.write(label);
|
|
21
|
+
let buf = '';
|
|
22
|
+
const onData = (chunk) => {
|
|
23
|
+
const s = chunk.toString();
|
|
24
|
+
for (const ch of s) {
|
|
25
|
+
if (ch === '\n' || ch === '\r') {
|
|
26
|
+
process.stdout.write('\n');
|
|
27
|
+
process.stdin.removeListener('data', onData);
|
|
28
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
29
|
+
rl.close();
|
|
30
|
+
return resolve(buf);
|
|
31
|
+
}
|
|
32
|
+
if (ch === CTRL_C) {
|
|
33
|
+
process.stdin.removeListener('data', onData);
|
|
34
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
35
|
+
rl.close();
|
|
36
|
+
return reject(new Error('aborted'));
|
|
37
|
+
}
|
|
38
|
+
if (ch === DEL || ch === BS) {
|
|
39
|
+
if (buf.length > 0) {
|
|
40
|
+
buf = buf.slice(0, -1);
|
|
41
|
+
process.stdout.write('\b \b');
|
|
42
|
+
}
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
buf += ch;
|
|
46
|
+
process.stdout.write('*');
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
50
|
+
process.stdin.on('data', onData);
|
|
51
|
+
});
|
|
52
|
+
}
|