@shubao-ai/shubao-cli 0.1.1
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/LICENSE +3 -0
- package/README.md +28 -0
- package/dist/src/api/account.js +2 -0
- package/dist/src/api/client.js +21 -0
- package/dist/src/api/execution.js +12 -0
- package/dist/src/api/runtime.js +3 -0
- package/dist/src/app.js +181 -0
- package/dist/src/auth/key-format.js +10 -0
- package/dist/src/auth/redaction.js +7 -0
- package/dist/src/auth/runtime-token.js +14 -0
- package/dist/src/commands/account.js +16 -0
- package/dist/src/commands/auth.js +84 -0
- package/dist/src/commands/credentials.js +20 -0
- package/dist/src/commands/doctor.js +40 -0
- package/dist/src/commands/init.js +3 -0
- package/dist/src/commands/run.js +41 -0
- package/dist/src/config/config.js +47 -0
- package/dist/src/config/server-url.js +29 -0
- package/dist/src/index.js +11 -0
- package/dist/src/storage/credential-store.js +31 -0
- package/dist/src/storage/env-store.js +96 -0
- package/dist/src/ui/banner.js +45 -0
- package/dist/src/ui/main-menu.js +26 -0
- package/package.json +34 -0
package/LICENSE
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# shubao
|
|
2
|
+
|
|
3
|
+
数宝小助手
|
|
4
|
+
|
|
5
|
+
## 使用
|
|
6
|
+
|
|
7
|
+
### 方式一(推荐:npx 即用)
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx @shubao-ai/shubao-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### 方式二(全局安装重复使用)
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g @shubao-ai/shubao-cli
|
|
17
|
+
shubao
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## 命令
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
shubao init # 初始化服务地址
|
|
24
|
+
shubao auth # 登录认证
|
|
25
|
+
shubao account status # 查看账户与积分
|
|
26
|
+
shubao doctor # 环境诊断
|
|
27
|
+
shubao --help # 查看完整帮助
|
|
28
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export class APIError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
status;
|
|
4
|
+
constructor(code, message, status) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.status = status;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
import { normalizeServerURL } from "../config/server-url.js";
|
|
11
|
+
export class Client {
|
|
12
|
+
server;
|
|
13
|
+
constructor(server) { this.server = normalizeServerURL(server); }
|
|
14
|
+
async request(path, token, init = {}) {
|
|
15
|
+
const res = await fetch(new URL(path, this.server), { ...init, headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(init.headers ?? {}) } });
|
|
16
|
+
const body = await res.json().catch(() => ({}));
|
|
17
|
+
if (!res.ok || (body.code !== undefined && body.code !== 0))
|
|
18
|
+
throw new APIError(String(body.code ?? res.status), body.message ?? `HTTP ${res.status}`, res.status);
|
|
19
|
+
return (body.data ?? body);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export async function getMarketAsset(client, key, assetID) {
|
|
2
|
+
return client.request(`/api/v1/market/assets/${encodeURIComponent(assetID)}`, key);
|
|
3
|
+
}
|
|
4
|
+
export async function createExecutionQuote(client, runtimeToken, assetID, snapshotID, input) {
|
|
5
|
+
return client.request(`/api/v1/market/assets/${encodeURIComponent(assetID)}/execution-quotes`, runtimeToken, { method: "POST", body: JSON.stringify({ snapshot_id: snapshotID, input }) });
|
|
6
|
+
}
|
|
7
|
+
export async function createExecution(client, runtimeToken, assetID, quoteID, idempotencyKey, input) {
|
|
8
|
+
return client.request(`/api/v1/market/assets/${encodeURIComponent(assetID)}/executions`, runtimeToken, { method: "POST", body: JSON.stringify({ quote_id: quoteID, idempotency_key: idempotencyKey, input }) });
|
|
9
|
+
}
|
|
10
|
+
export async function getExecution(client, runtimeToken, runID) {
|
|
11
|
+
return client.request(`/api/v1/market/executions/${encodeURIComponent(runID)}`, runtimeToken);
|
|
12
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export async function bootstrapDevice(client, key) { return client.request("/api/v1/runtime/devices/bootstrap", key, { method: "POST", body: JSON.stringify({ device_name: `${process.platform} shubao-cli`, platform: process.platform, arch: process.arch, client_name: "shubao-cli", client_version: "0.1.1" }) }); }
|
|
2
|
+
export async function revokeBootstrapDevice(client, key, deviceID) { await client.request(`/api/v1/runtime/devices/bootstrap/${encodeURIComponent(deviceID)}`, key, { method: "DELETE" }); }
|
|
3
|
+
export async function exchangeRuntimeToken(client, refresh) { return client.request("/api/v1/runtime/access-tokens", undefined, { method: "POST", headers: { Authorization: `Device ${refresh}` } }); }
|
package/dist/src/app.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { banner } from "./ui/banner.js";
|
|
2
|
+
import { mainMenu } from "./ui/main-menu.js";
|
|
3
|
+
import { DEFAULT_SERVER, loadConfig } from "./config/config.js";
|
|
4
|
+
import { FileCredentialStore } from "./storage/credential-store.js";
|
|
5
|
+
import { authInteractive, authStatus, revokeLocal, authJSONMessage, authSuccessMessage } from "./commands/auth.js";
|
|
6
|
+
import { doctor } from "./commands/doctor.js";
|
|
7
|
+
import { formatAccountStatus, getAccountStatus } from "./commands/account.js";
|
|
8
|
+
import { runAsset } from "./commands/run.js";
|
|
9
|
+
import { init } from "./commands/init.js";
|
|
10
|
+
import { normalizeServerURL } from "./config/server-url.js";
|
|
11
|
+
import { ShellProfileStore } from "./storage/env-store.js";
|
|
12
|
+
import { redact } from "./auth/key-format.js";
|
|
13
|
+
import { confirm, input } from "@inquirer/prompts";
|
|
14
|
+
const dim = "\u001B[2m";
|
|
15
|
+
const reset = "\u001B[0m";
|
|
16
|
+
const clear = "\u001B[2J";
|
|
17
|
+
const home = "\u001B[H";
|
|
18
|
+
const help = `数宝小助手
|
|
19
|
+
|
|
20
|
+
命令:
|
|
21
|
+
shubao init 初始化服务地址
|
|
22
|
+
shubao auth [--stdin] 登录认证(--stdin 从标准输入读 API Key)
|
|
23
|
+
shubao auth status 查看登录状态
|
|
24
|
+
shubao auth revoke 清除本地凭证(不影响服务端 API Key)
|
|
25
|
+
shubao account status [--json] 查看账户与积分余额
|
|
26
|
+
shubao run <asset-id> --input <json> [--yes] [--idempotency-key <key>] [--json]
|
|
27
|
+
执行指定资产(按冻结价格扣点)
|
|
28
|
+
shubao doctor 环境与健康检查
|
|
29
|
+
shubao --server <url> <command> 临时指定服务端地址
|
|
30
|
+
|
|
31
|
+
常用选项:
|
|
32
|
+
--json 以 JSON 输出(account status / run)
|
|
33
|
+
--yes 跳过价格确认直接执行(run)
|
|
34
|
+
--storage <类型> 凭证存储位置:config(默认)| env
|
|
35
|
+
-v, --version 显示版本号
|
|
36
|
+
-h, --help 显示本帮助
|
|
37
|
+
|
|
38
|
+
用法:
|
|
39
|
+
npx @shubao-ai/shubao-cli 一次性执行(推荐)
|
|
40
|
+
npm install -g @shubao-ai/shubao-cli 全局安装后直接用 shubao`;
|
|
41
|
+
export async function run(argv) {
|
|
42
|
+
const version = "0.1.1";
|
|
43
|
+
let args = [...argv];
|
|
44
|
+
let serverOverride;
|
|
45
|
+
const pos = args.indexOf("--server");
|
|
46
|
+
if (pos >= 0) {
|
|
47
|
+
serverOverride = args[pos + 1];
|
|
48
|
+
args.splice(pos, 2);
|
|
49
|
+
}
|
|
50
|
+
if (args.includes("--version") || args.includes("-v"))
|
|
51
|
+
return void console.log(version);
|
|
52
|
+
if (args.includes("--help") || args.includes("-h"))
|
|
53
|
+
return void console.log(help);
|
|
54
|
+
const config = await loadConfig();
|
|
55
|
+
const server = normalizeServerURL(serverOverride || process.env.SHUBAO_API_BASE_URL || config.server || DEFAULT_SERVER);
|
|
56
|
+
const store = new FileCredentialStore();
|
|
57
|
+
if (args[0] === "init")
|
|
58
|
+
return void console.log(await init());
|
|
59
|
+
if (args[0] === "account" && (!args[1] || args[1] === "status")) {
|
|
60
|
+
const value = await getAccountStatus(server, store);
|
|
61
|
+
return void console.log(args.includes("--json") ? JSON.stringify(value) : formatAccountStatus(value));
|
|
62
|
+
}
|
|
63
|
+
if (args[0] === "run") {
|
|
64
|
+
const assetID = args[1];
|
|
65
|
+
const rawInput = readOption(args, "--input");
|
|
66
|
+
if (!assetID || !rawInput)
|
|
67
|
+
throw new Error("用法:shubao run <asset-id> --input <json>");
|
|
68
|
+
let input;
|
|
69
|
+
try {
|
|
70
|
+
input = JSON.parse(rawInput);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
throw new Error("--input 必须是 JSON 对象");
|
|
74
|
+
}
|
|
75
|
+
if (!input || Array.isArray(input) || typeof input !== "object")
|
|
76
|
+
throw new Error("--input 必须是 JSON 对象");
|
|
77
|
+
const result = await runAsset(server, store, assetID, input, { yes: args.includes("--yes"), idempotencyKey: readOption(args, "--idempotency-key"), confirm: async (price) => confirm({ message: `本次执行将按冻结价格扣除 ${price} 点,是否继续?`, default: false }) });
|
|
78
|
+
if (!result)
|
|
79
|
+
return void console.log("已取消,未创建 Execution。");
|
|
80
|
+
return void console.log(args.includes("--json") ? JSON.stringify(result) : `Execution:${result.execution.id}\n状态:${result.execution.status}\n冻结价格:${result.price_points} 点\n结果:${JSON.stringify(result.execution.output_json ?? {})}`);
|
|
81
|
+
}
|
|
82
|
+
if (args[0] === "auth" && args[1] === "status")
|
|
83
|
+
return void console.log(await authStatus(server, store));
|
|
84
|
+
if (args[0] === "auth" && args[1] === "revoke") {
|
|
85
|
+
const storage = readOption(args, "--storage") ?? "all";
|
|
86
|
+
if (storage !== "config" && storage !== "env" && storage !== "all")
|
|
87
|
+
throw new Error("--storage 仅支持 config、env 或 all");
|
|
88
|
+
await revokeLocal(server, store, storage);
|
|
89
|
+
return void console.log("已删除指定本地凭证;未撤销服务端 API Key。");
|
|
90
|
+
}
|
|
91
|
+
if (args[0] === "auth") {
|
|
92
|
+
const storage = readOption(args, "--storage") ?? "config";
|
|
93
|
+
if (storage !== "config" && storage !== "env")
|
|
94
|
+
throw new Error("--storage 仅支持 config 或 env");
|
|
95
|
+
const stdin = args.includes("--stdin");
|
|
96
|
+
if (storage === "env") {
|
|
97
|
+
const allowed = stdin ? args.includes("--allow-plaintext-env-storage") : await confirm({ message: "警告:API Key 将以明文保存在 Shell 配置中。是否继续?", default: false });
|
|
98
|
+
if (!allowed)
|
|
99
|
+
throw Object.assign(new Error("已取消明文环境变量存储"), { code: "PLAINTEXT_ENV_STORAGE_NOT_CONFIRMED" });
|
|
100
|
+
}
|
|
101
|
+
const key = stdin ? (await readStdin()).trim() : "";
|
|
102
|
+
const json = args.includes("--json");
|
|
103
|
+
return void console.log(key ? (await authFromKey(server, key, store, storage, json)) : await authInteractive(server, store, storage, new ShellProfileStore()));
|
|
104
|
+
}
|
|
105
|
+
if (args[0] === "doctor")
|
|
106
|
+
return void (await doctor(server, store)).forEach((line) => console.log(line));
|
|
107
|
+
await interactiveLoop(server, store);
|
|
108
|
+
}
|
|
109
|
+
// 交互式主菜单:只有用户选择「退出」才会结束。
|
|
110
|
+
// 每轮操作结果展示后,等用户按回车再清屏回到干净的主菜单——像终端本身一样,
|
|
111
|
+
// 屏幕只显示当前这一轮,不堆积历史。
|
|
112
|
+
async function interactiveLoop(server, store) {
|
|
113
|
+
while (true) {
|
|
114
|
+
// 清屏 + 打印 banner,让每轮都从干净屏幕开始。
|
|
115
|
+
process.stdout.write(`${clear}${home}`);
|
|
116
|
+
console.log(banner());
|
|
117
|
+
const status = await readLocalLoginStatus(server, store);
|
|
118
|
+
const choice = await mainMenu(status);
|
|
119
|
+
if (choice === "exit") {
|
|
120
|
+
console.log("👋 再见!");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
console.log();
|
|
124
|
+
try {
|
|
125
|
+
if (choice === "auth") {
|
|
126
|
+
if (status && !(await confirm({ message: `已登录 ${status.account}(${status.apiKeyPrefix}),重新认证将覆盖现有凭证。是否继续?`, default: false }))) {
|
|
127
|
+
console.log(`${dim}已取消,保留现有凭证。${reset}`);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
console.log(await authInteractive(server, store));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
else if (choice === "doctor") {
|
|
134
|
+
(await doctor(server, store)).forEach((line) => console.log(line));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
const code = error.code ?? "";
|
|
139
|
+
if (code === "USER_CANCELLED" || code === "PLAINTEXT_ENV_STORAGE_NOT_CONFIRMED") {
|
|
140
|
+
console.log(`${dim}已取消。${reset}`);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
console.log(`${dim}操作失败:${error.message ?? "未知错误"}${reset}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// 结果展示完,等用户确认后再清屏回菜单——避免还没看完就被刷掉。
|
|
147
|
+
await waitForReturn(`${dim}按回车返回主菜单…${reset}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// 读本地凭证并查一次账户名,用于在主菜单展示当前登录状态。
|
|
151
|
+
// 网络失败时退化为只显示脱敏 Key 前缀,不影响进菜单。
|
|
152
|
+
async function readLocalLoginStatus(server, store) {
|
|
153
|
+
try {
|
|
154
|
+
const envKey = process.env.SHUBAO_API_KEY?.trim() || null;
|
|
155
|
+
const configKey = await store.getApiKey(server);
|
|
156
|
+
const key = envKey ?? configKey;
|
|
157
|
+
if (!key)
|
|
158
|
+
return null;
|
|
159
|
+
const apiKeyPrefix = redact(key);
|
|
160
|
+
try {
|
|
161
|
+
const account = await getAccountStatus(server, store);
|
|
162
|
+
const name = account.account.display_name ?? account.account.nickname ?? account.account.name ?? "已认证账户";
|
|
163
|
+
return { account: name, apiKeyPrefix };
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return { account: envKey ? "环境变量凭证" : "已认证账户", apiKeyPrefix };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function readOption(args, name) { const index = args.indexOf(name); return index >= 0 ? args[index + 1] : undefined; }
|
|
174
|
+
async function authFromKey(server, key, store, storage, json = false) { const { authenticate } = await import("./commands/auth.js"); const envStore = new ShellProfileStore(); const result = await authenticate(server, key, store, storage, envStore); if (json)
|
|
175
|
+
return authJSONMessage(storage, envStore); return authSuccessMessage(result.account, result.deviceId, key, storage, envStore); }
|
|
176
|
+
async function readStdin() { const chunks = []; for await (const chunk of process.stdin)
|
|
177
|
+
chunks.push(Buffer.from(chunk)); return Buffer.concat(chunks).toString("utf8"); }
|
|
178
|
+
// 等用户按一次回车再继续。用 inquirer 的 input 接管 stdin,避免与菜单抢输入。
|
|
179
|
+
async function waitForReturn(hint) {
|
|
180
|
+
await input({ message: hint, default: "" });
|
|
181
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function parseHelperKey(value) {
|
|
2
|
+
const match = /^sbk_([A-Za-z0-9]{4,32})_([A-Za-z0-9]{24,128})$/.exec(value.trim());
|
|
3
|
+
return match ? { publicId: match[1], secret: match[2] } : null;
|
|
4
|
+
}
|
|
5
|
+
export function redact(value) {
|
|
6
|
+
const clean = value.trim();
|
|
7
|
+
if (clean.length < 8)
|
|
8
|
+
return "[REDACTED]";
|
|
9
|
+
return `${clean.slice(0, 4)}_****${clean.slice(-4)}`;
|
|
10
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function redactText(value) {
|
|
2
|
+
return value
|
|
3
|
+
.replace(/sbk_[A-Za-z0-9]+_[A-Za-z0-9]+/g, "sbk_****")
|
|
4
|
+
.replace(/rt_[A-Za-z0-9]+/g, "rt_****")
|
|
5
|
+
.replace(/rr_[A-Za-z0-9]+/g, "rr_****")
|
|
6
|
+
.replace(/(Authorization|Bearer|Cookie)\s*[: ]\s*[^\s]+/gi, "$1: [REDACTED]");
|
|
7
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { exchangeRuntimeToken } from "../api/runtime.js";
|
|
2
|
+
// Runtime tokens live in memory only. They are refreshed once near expiry and
|
|
3
|
+
// never written to disk or a target application's configuration.
|
|
4
|
+
export class RuntimeTokenCache {
|
|
5
|
+
cached;
|
|
6
|
+
async get(client, refreshSecret) {
|
|
7
|
+
if (this.cached && this.cached.expiresAt - Date.now() > 60_000)
|
|
8
|
+
return this.cached.token;
|
|
9
|
+
const next = await exchangeRuntimeToken(client, refreshSecret);
|
|
10
|
+
this.cached = { token: next.access_token, expiresAt: Date.parse(next.expires_at) };
|
|
11
|
+
return next.access_token;
|
|
12
|
+
}
|
|
13
|
+
clear() { this.cached = undefined; }
|
|
14
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Client } from "../api/client.js";
|
|
2
|
+
import { getAccount, getBalance } from "../api/account.js";
|
|
3
|
+
import { RuntimeTokenCache } from "../auth/runtime-token.js";
|
|
4
|
+
import { loadConfig } from "../config/config.js";
|
|
5
|
+
import { loadRuntimeCredentials } from "./credentials.js";
|
|
6
|
+
export async function getAccountStatus(server, store, cache = new RuntimeTokenCache()) {
|
|
7
|
+
const client = new Client(server);
|
|
8
|
+
const credentials = await loadRuntimeCredentials(server, store);
|
|
9
|
+
const [account, balance, config] = await Promise.all([getAccount(client, credentials.apiKey), getBalance(client, credentials.apiKey), loadConfig()]);
|
|
10
|
+
await cache.get(client, credentials.refreshSecret);
|
|
11
|
+
return { account, balance, credential_source: credentials.source, api_key_prefix: credentials.apiKeyPrefix, device_id: config.deviceId, runtime_token_exchange: "available" };
|
|
12
|
+
}
|
|
13
|
+
export function formatAccountStatus(value) {
|
|
14
|
+
const name = value.account.display_name ?? value.account.nickname ?? value.account.name ?? "已验证用户";
|
|
15
|
+
return `账户:${name}\n账户状态:${value.account.status ?? "unknown"}\n凭证来源:${value.credential_source}\nAPI Key:${value.api_key_prefix}\nRuntime Device:${value.device_id ? `已登记(${value.device_id})` : "未登记"}\npoints:${value.balance.points}\nRuntime Token Exchange:可用`;
|
|
16
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { password } from "@inquirer/prompts";
|
|
2
|
+
import { Client } from "../api/client.js";
|
|
3
|
+
import { getAccount } from "../api/account.js";
|
|
4
|
+
import { bootstrapDevice, revokeBootstrapDevice } from "../api/runtime.js";
|
|
5
|
+
import { parseHelperKey, redact } from "../auth/key-format.js";
|
|
6
|
+
import { clearDeviceConfig, loadConfig, saveConfig } from "../config/config.js";
|
|
7
|
+
import { ShellProfileStore } from "../storage/env-store.js";
|
|
8
|
+
const mint = "\u001B[96m";
|
|
9
|
+
const bold = "\u001B[1m";
|
|
10
|
+
const reset = "\u001B[0m";
|
|
11
|
+
export async function authenticate(server, key, store, source = "config", envStore = new ShellProfileStore(), deps = {}) {
|
|
12
|
+
if (!parseHelperKey(key))
|
|
13
|
+
throw Object.assign(new Error("API Key 格式无效"), { code: "KEY_INVALID" });
|
|
14
|
+
const client = new Client(server);
|
|
15
|
+
const account = await getAccount(client, key);
|
|
16
|
+
const bootstrap = await bootstrapDevice(client, key);
|
|
17
|
+
try {
|
|
18
|
+
if (source === "config")
|
|
19
|
+
await store.setApiKey(server, key);
|
|
20
|
+
await store.setRefreshSecret(server, bootstrap.refresh_secret);
|
|
21
|
+
if (source === "env")
|
|
22
|
+
await envStore.write(key, server);
|
|
23
|
+
const config = { ...(await (deps.loadConfig ?? loadConfig)()), server, deviceId: bootstrap.device.id };
|
|
24
|
+
await (deps.saveConfig ?? saveConfig)(config);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
await Promise.allSettled([store.deleteApiKey(server), store.deleteRefreshSecret(server)]);
|
|
28
|
+
await (deps.clearDeviceConfig ?? clearDeviceConfig)(server, bootstrap.device.id).catch(() => undefined);
|
|
29
|
+
if (source === "env")
|
|
30
|
+
await envStore.revoke().catch(() => undefined);
|
|
31
|
+
try {
|
|
32
|
+
await (deps.revokeDevice ?? revokeBootstrapDevice)(client, key, bootstrap.device.id);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
(deps.onRollbackWarning ?? (() => console.warn("警告:运行设备撤销未确认,请在账户安全设置中检查。")))();
|
|
36
|
+
}
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
return { account: account.display_name ?? account.nickname ?? account.name ?? "已验证用户", deviceId: bootstrap.device.id };
|
|
40
|
+
}
|
|
41
|
+
export async function authInteractive(server, store, source = "config", envStore = new ShellProfileStore(), deps = {}) {
|
|
42
|
+
const readSecret = deps.readSecret ?? (() => password({
|
|
43
|
+
message: "请输入数宝 API 密钥(输入内容将隐藏)",
|
|
44
|
+
mask: "*",
|
|
45
|
+
theme: {
|
|
46
|
+
prefix: { idle: `${mint} ?${reset}`, done: `${mint} ✓${reset}` },
|
|
47
|
+
style: { message: (text) => `${bold}${mint}${text}${reset}` },
|
|
48
|
+
},
|
|
49
|
+
}));
|
|
50
|
+
const key = await readSecret();
|
|
51
|
+
const result = await authenticate(server, key, store, source, envStore);
|
|
52
|
+
return authSuccessMessage(result.account, result.deviceId, key, source, envStore);
|
|
53
|
+
}
|
|
54
|
+
export function authSuccessMessage(account, deviceID, key, source, envStore) {
|
|
55
|
+
const message = `认证成功:${account},API Key ${redact(key)},设备 ${deviceID.slice(0, 10)}…`;
|
|
56
|
+
if (source !== "env" || !envStore)
|
|
57
|
+
return message;
|
|
58
|
+
const profile = envStore.path();
|
|
59
|
+
return `${message}\n已写入 ${profile}。\n请执行:\n${envStore.reloadCommand()}\n或重新打开终端使配置生效。`;
|
|
60
|
+
}
|
|
61
|
+
export function authJSONMessage(source, envStore) {
|
|
62
|
+
if (source !== "env" || !envStore)
|
|
63
|
+
return JSON.stringify({});
|
|
64
|
+
return JSON.stringify({ profile_path: envStore.path(), reload_command: envStore.reloadCommand() });
|
|
65
|
+
}
|
|
66
|
+
export async function authStatus(server, store, deps = {}) {
|
|
67
|
+
const client = new Client(server);
|
|
68
|
+
const envKey = process.env.SHUBAO_API_KEY?.trim() || null;
|
|
69
|
+
const configKey = await store.getApiKey(server);
|
|
70
|
+
if (envKey && configKey && envKey !== configKey)
|
|
71
|
+
throw Object.assign(new Error("环境变量与配置文件中的 API 密钥不一致"), { code: "CREDENTIAL_SOURCE_CONFLICT" });
|
|
72
|
+
const key = envKey ?? configKey;
|
|
73
|
+
if (!key)
|
|
74
|
+
return "未配置本地 API 密钥";
|
|
75
|
+
const account = await getAccount(client, key);
|
|
76
|
+
const config = await (deps.loadConfig ?? loadConfig)();
|
|
77
|
+
return `凭证来源:${envKey ? "环境变量" : "配置文件"}\n服务:${client.server}\nAPI Key:${redact(key)}\n账户:${account.display_name ?? account.nickname ?? account.name ?? "已验证"}\n设备:${config.deviceId ? `${config.deviceId.slice(0, 10)}…` : "按需创建"}`;
|
|
78
|
+
}
|
|
79
|
+
export async function revokeLocal(server, store, source = "all", envStore = new ShellProfileStore()) {
|
|
80
|
+
if (source === "config" || source === "all")
|
|
81
|
+
await Promise.all([store.deleteApiKey(server), store.deleteRefreshSecret(server)]);
|
|
82
|
+
if (source === "env" || source === "all")
|
|
83
|
+
await envStore.revoke();
|
|
84
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { parseHelperKey, redact } from "../auth/key-format.js";
|
|
2
|
+
import { Client } from "../api/client.js";
|
|
3
|
+
import { bootstrapDevice } from "../api/runtime.js";
|
|
4
|
+
export async function loadRuntimeCredentials(server, store) {
|
|
5
|
+
const envKey = process.env.SHUBAO_API_KEY?.trim() || null;
|
|
6
|
+
const configKey = await store.getApiKey(server);
|
|
7
|
+
if (envKey && configKey && envKey !== configKey)
|
|
8
|
+
throw Object.assign(new Error("环境变量与配置文件中的 API 密钥不一致"), { code: "CREDENTIAL_SOURCE_CONFLICT" });
|
|
9
|
+
const apiKey = envKey ?? configKey;
|
|
10
|
+
if (!apiKey)
|
|
11
|
+
throw Object.assign(new Error("未配置 API 密钥"), { code: "NOT_AUTHENTICATED" });
|
|
12
|
+
if (!parseHelperKey(apiKey))
|
|
13
|
+
throw Object.assign(new Error("API Key 格式无效"), { code: "KEY_INVALID" });
|
|
14
|
+
let refreshSecret = await store.getRefreshSecret(server);
|
|
15
|
+
if (!refreshSecret) {
|
|
16
|
+
refreshSecret = (await bootstrapDevice(new Client(server), apiKey)).refresh_secret;
|
|
17
|
+
await store.setRefreshSecret(server, refreshSecret);
|
|
18
|
+
}
|
|
19
|
+
return { apiKey, refreshSecret, source: envKey ? "env" : "config", apiKeyPrefix: redact(apiKey) };
|
|
20
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Client } from "../api/client.js";
|
|
2
|
+
import { getAccount } from "../api/account.js";
|
|
3
|
+
import { redact } from "../auth/key-format.js";
|
|
4
|
+
import { loadConfig } from "../config/config.js";
|
|
5
|
+
// 诊断只关心两件事:配置文件是否已生成、本地 API Key 是否能通过服务端校验。
|
|
6
|
+
// 服务端地址属于实现细节,不在此暴露给用户。
|
|
7
|
+
export async function doctor(server, store, deps = {}) {
|
|
8
|
+
const lines = [];
|
|
9
|
+
// ① 配置文件:init 是否执行过(config.json 是否存在并含服务地址)
|
|
10
|
+
let configured = false;
|
|
11
|
+
try {
|
|
12
|
+
const config = await (deps.loadConfig ?? loadConfig)();
|
|
13
|
+
configured = Boolean(config.server);
|
|
14
|
+
}
|
|
15
|
+
catch { /* 配置文件缺失或损坏,视为未配置 */ }
|
|
16
|
+
lines.push(configured ? "✓ 已初始化配置" : "✗ 未初始化:请先执行 shubao init");
|
|
17
|
+
// ② API Key 有效性:合并 env/config 后,调一次服务端验证
|
|
18
|
+
const envKey = process.env.SHUBAO_API_KEY?.trim() || null;
|
|
19
|
+
const configKey = await store.getApiKey(server).catch(() => null);
|
|
20
|
+
if (envKey && configKey && envKey !== configKey) {
|
|
21
|
+
lines.push("✗ 凭证来源冲突:环境变量与配置文件中的 API Key 不一致");
|
|
22
|
+
return lines;
|
|
23
|
+
}
|
|
24
|
+
const key = envKey ?? configKey;
|
|
25
|
+
if (!key) {
|
|
26
|
+
lines.push("✗ 未配置 API Key");
|
|
27
|
+
return lines;
|
|
28
|
+
}
|
|
29
|
+
const client = new Client(server);
|
|
30
|
+
try {
|
|
31
|
+
const account = await getAccount(client, key);
|
|
32
|
+
const name = account.display_name ?? account.nickname ?? account.name ?? "已验证用户";
|
|
33
|
+
lines.push(`✓ API Key 有效(账户:${name},${redact(key)})`);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
const message = error.message ?? "未知错误";
|
|
37
|
+
lines.push(`✗ API Key 校验失败:${message}`);
|
|
38
|
+
}
|
|
39
|
+
return lines;
|
|
40
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { input } from "@inquirer/prompts";
|
|
2
|
+
import { DEFAULT_SERVER, loadConfig, saveConfig } from "../config/config.js";
|
|
3
|
+
export async function init() { const current = await loadConfig(); const server = await input({ message: "服务地址", default: current.server || DEFAULT_SERVER }); await saveConfig({ ...current, server: server.replace(/\/$/, "") }); return `已保存服务地址:${server}`; }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { APIError, Client } from "../api/client.js";
|
|
3
|
+
import { createExecution, createExecutionQuote, getExecution, getMarketAsset } from "../api/execution.js";
|
|
4
|
+
import { RuntimeTokenCache } from "../auth/runtime-token.js";
|
|
5
|
+
import { loadRuntimeCredentials } from "./credentials.js";
|
|
6
|
+
async function withRuntimeToken(cache, client, refresh, action) {
|
|
7
|
+
try {
|
|
8
|
+
return await action(await cache.get(client, refresh));
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
if (!(error instanceof APIError) || error.status !== 401)
|
|
12
|
+
throw error;
|
|
13
|
+
cache.clear();
|
|
14
|
+
return action(await cache.get(client, refresh));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function runAsset(server, store, assetID, input, options) {
|
|
18
|
+
const client = new Client(server);
|
|
19
|
+
const credentials = await loadRuntimeCredentials(server, store);
|
|
20
|
+
const cache = options.cache ?? new RuntimeTokenCache();
|
|
21
|
+
const asset = await getMarketAsset(client, credentials.apiKey, assetID);
|
|
22
|
+
const snapshotID = asset.package_snapshot_id ?? asset.snapshot_id;
|
|
23
|
+
if (!snapshotID)
|
|
24
|
+
throw Object.assign(new Error("资产没有可执行的已发布 Snapshot"), { code: "ASSET_SNAPSHOT_NOT_FOUND" });
|
|
25
|
+
const quote = await withRuntimeToken(cache, client, credentials.refreshSecret, (token) => createExecutionQuote(client, token, assetID, snapshotID, input));
|
|
26
|
+
if (!options.yes && !(await options.confirm?.(quote.price_points)))
|
|
27
|
+
return null;
|
|
28
|
+
const idempotencyKey = options.idempotencyKey ?? randomUUID();
|
|
29
|
+
const created = await withRuntimeToken(cache, client, credentials.refreshSecret, (token) => createExecution(client, token, assetID, quote.quote.id, idempotencyKey, input));
|
|
30
|
+
let current = created.execution;
|
|
31
|
+
let steps = [];
|
|
32
|
+
for (let i = 0; i < 20 && !["succeeded", "failed"].includes(current.status); i++) {
|
|
33
|
+
await (options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))))(250);
|
|
34
|
+
const status = await withRuntimeToken(cache, client, credentials.refreshSecret, (token) => getExecution(client, token, current.id));
|
|
35
|
+
current = status.execution;
|
|
36
|
+
steps = status.steps;
|
|
37
|
+
}
|
|
38
|
+
if (!["succeeded", "failed"].includes(current.status))
|
|
39
|
+
throw Object.assign(new Error("执行轮询超时"), { code: "EXECUTION_TIMEOUT" });
|
|
40
|
+
return { quote_id: quote.quote.id, price_points: quote.price_points, execution: current, steps };
|
|
41
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { normalizeServerURL } from "./server-url.js";
|
|
5
|
+
export const DEFAULT_SERVER = "https://shubao.wuhuangwansui.cn";
|
|
6
|
+
export class ConfigStore {
|
|
7
|
+
dir;
|
|
8
|
+
file;
|
|
9
|
+
constructor(home = homedir()) { this.dir = join(home, ".config", "shubao-cli"); this.file = join(this.dir, "config.json"); }
|
|
10
|
+
async load() {
|
|
11
|
+
try {
|
|
12
|
+
const parsed = JSON.parse(await readFile(this.file, "utf8"));
|
|
13
|
+
return { ...parsed, server: normalizeServerURL(parsed.server || DEFAULT_SERVER) };
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return { server: DEFAULT_SERVER };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
async save(config) {
|
|
20
|
+
await mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
21
|
+
await chmod(this.dir, 0o700);
|
|
22
|
+
const tmp = `${this.file}.tmp`;
|
|
23
|
+
try {
|
|
24
|
+
await writeFile(tmp, JSON.stringify({ ...config, server: normalizeServerURL(config.server) }), { mode: 0o600 });
|
|
25
|
+
await rename(tmp, this.file);
|
|
26
|
+
await chmod(this.file, 0o600);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
await unlink(tmp).catch(() => undefined);
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async clearDevice(server, deviceID) {
|
|
34
|
+
const current = await this.load();
|
|
35
|
+
if (current.server !== normalizeServerURL(server) || current.deviceId !== deviceID)
|
|
36
|
+
return;
|
|
37
|
+
await this.save({ server: current.server });
|
|
38
|
+
}
|
|
39
|
+
async permissions() {
|
|
40
|
+
const [dir, file] = await Promise.all([stat(this.dir), stat(this.file)]);
|
|
41
|
+
return { dir: dir.mode & 0o777, file: file.mode & 0o777 };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const defaultStore = new ConfigStore();
|
|
45
|
+
export async function loadConfig() { return defaultStore.load(); }
|
|
46
|
+
export async function saveConfig(config) { return defaultStore.save(config); }
|
|
47
|
+
export async function clearDeviceConfig(server, deviceID) { return defaultStore.clearDevice(server, deviceID); }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
export class ServerURLError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function normalizeServerURL(value) {
|
|
10
|
+
if (/[\x00-\x1F\x7F]/.test(value))
|
|
11
|
+
throw new ServerURLError("INVALID_SERVER_URL", "服务地址不能包含控制字符");
|
|
12
|
+
let url;
|
|
13
|
+
try {
|
|
14
|
+
url = new URL(value.trim());
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new ServerURLError("INVALID_SERVER_URL", "服务地址无效");
|
|
18
|
+
}
|
|
19
|
+
if (!url.hostname || url.username || url.password || url.search || url.hash || !/^\/*$/.test(url.pathname)) {
|
|
20
|
+
throw new ServerURLError("INVALID_SERVER_URL", "服务地址只能是无路径的 HTTP(S) 根地址");
|
|
21
|
+
}
|
|
22
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
23
|
+
throw new ServerURLError("INVALID_SERVER_URL", "服务地址必须使用 HTTP(S)");
|
|
24
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
25
|
+
const local = host === "localhost" || host === "::1" || isIP(host) === 4 && host === "127.0.0.1";
|
|
26
|
+
if (url.protocol === "http:" && !local)
|
|
27
|
+
throw new ServerURLError("INSECURE_SERVER_URL", "远程服务地址必须使用 HTTPS");
|
|
28
|
+
return `${url.protocol}//${url.host}`;
|
|
29
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { run } from "./app.js";
|
|
3
|
+
run(process.argv.slice(2)).catch((error) => {
|
|
4
|
+
if (error.name === "ExitPromptError" || error.message?.includes("force closed the prompt")) {
|
|
5
|
+
console.log("\n已取消操作。");
|
|
6
|
+
process.exitCode = 130;
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
console.error(`数宝小助手错误:${error.message ?? "发生未知错误"}`);
|
|
10
|
+
process.exitCode = 1;
|
|
11
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ConfigStore } from "../config/config.js";
|
|
2
|
+
import { normalizeServerURL } from "../config/server-url.js";
|
|
3
|
+
export class MemoryCredentialStore {
|
|
4
|
+
values = new Map();
|
|
5
|
+
key(server, type) { return `${type}:${server}`; }
|
|
6
|
+
async getApiKey(server) { return this.values.get(this.key(server, "api")) ?? null; }
|
|
7
|
+
async setApiKey(server, value) { this.values.set(this.key(server, "api"), value); }
|
|
8
|
+
async deleteApiKey(server) { this.values.delete(this.key(server, "api")); }
|
|
9
|
+
async getRefreshSecret(server) { return this.values.get(this.key(server, "refresh")) ?? null; }
|
|
10
|
+
async setRefreshSecret(server, value) { this.values.set(this.key(server, "refresh"), value); }
|
|
11
|
+
async deleteRefreshSecret(server) { this.values.delete(this.key(server, "refresh")); }
|
|
12
|
+
}
|
|
13
|
+
export class FileCredentialStore {
|
|
14
|
+
configStore;
|
|
15
|
+
refreshSecrets = new Map();
|
|
16
|
+
constructor(configStore = new ConfigStore()) {
|
|
17
|
+
this.configStore = configStore;
|
|
18
|
+
}
|
|
19
|
+
async matchingConfig(server) {
|
|
20
|
+
const normalizedServer = normalizeServerURL(server);
|
|
21
|
+
const current = await this.configStore.load();
|
|
22
|
+
return current.server === normalizedServer ? current : null;
|
|
23
|
+
}
|
|
24
|
+
async getApiKey(server) { return (await this.matchingConfig(server))?.apiKey ?? null; }
|
|
25
|
+
async setApiKey(server, value) { await this.configStore.save({ ...(await this.matchingConfig(server) ?? { server: normalizeServerURL(server) }), apiKey: value }); }
|
|
26
|
+
async deleteApiKey(server) { const config = await this.matchingConfig(server); if (!config)
|
|
27
|
+
return; const { apiKey: _, ...rest } = config; await this.configStore.save(rest); }
|
|
28
|
+
async getRefreshSecret(server) { return this.refreshSecrets.get(normalizeServerURL(server)) ?? null; }
|
|
29
|
+
async setRefreshSecret(server, value) { this.refreshSecrets.set(normalizeServerURL(server), value); }
|
|
30
|
+
async deleteRefreshSecret(server) { this.refreshSecrets.delete(normalizeServerURL(server)); }
|
|
31
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { chmod, copyFile, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { parseHelperKey } from "../auth/key-format.js";
|
|
5
|
+
import { normalizeServerURL } from "../config/server-url.js";
|
|
6
|
+
const START = "# >>> shubao helper >>>";
|
|
7
|
+
const END = "# <<< shubao helper <<<";
|
|
8
|
+
const MANAGED = new RegExp(`${escapeRegExp(START)}[\\s\\S]*?${escapeRegExp(END)}\\n?`, "g");
|
|
9
|
+
export class EnvStorageError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
constructor(code, message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.code = code;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class ShellProfileStore {
|
|
17
|
+
home;
|
|
18
|
+
shell;
|
|
19
|
+
platform;
|
|
20
|
+
profilePath;
|
|
21
|
+
constructor(options = {}) {
|
|
22
|
+
this.home = options.home ?? homedir();
|
|
23
|
+
this.shell = options.shell ?? process.env.SHELL ?? "";
|
|
24
|
+
this.platform = options.platform ?? process.platform;
|
|
25
|
+
this.profilePath = options.profilePath;
|
|
26
|
+
}
|
|
27
|
+
path() {
|
|
28
|
+
if (this.profilePath)
|
|
29
|
+
return this.profilePath;
|
|
30
|
+
if (this.platform === "win32")
|
|
31
|
+
throw new EnvStorageError("ENV_STORAGE_NOT_IMPLEMENTED_ON_PLATFORM", "Windows 环境变量存储暂未实现");
|
|
32
|
+
const name = basename(this.shell);
|
|
33
|
+
if (name === "zsh")
|
|
34
|
+
return join(this.home, ".zshrc");
|
|
35
|
+
if (name === "bash")
|
|
36
|
+
return join(this.home, ".bashrc");
|
|
37
|
+
throw new EnvStorageError("ENV_STORAGE_SHELL_NOT_SUPPORTED", "仅支持 zsh 或 bash 的环境变量存储");
|
|
38
|
+
}
|
|
39
|
+
reloadCommand() {
|
|
40
|
+
const path = this.path();
|
|
41
|
+
return `source ${shellQuote(path)}`;
|
|
42
|
+
}
|
|
43
|
+
async write(apiKey, server) {
|
|
44
|
+
if (!parseHelperKey(apiKey) || /[\r\n]/.test(apiKey))
|
|
45
|
+
throw new EnvStorageError("KEY_INVALID", "API Key 格式无效");
|
|
46
|
+
const normalizedServer = normalizeServerURL(server);
|
|
47
|
+
const path = this.path();
|
|
48
|
+
let existing = "";
|
|
49
|
+
let mode = 0o600;
|
|
50
|
+
try {
|
|
51
|
+
const info = await stat(path);
|
|
52
|
+
mode = info.mode & 0o777;
|
|
53
|
+
existing = await readFile(path, "utf8");
|
|
54
|
+
await copyFile(path, `${path}.shubao-helper.bak`);
|
|
55
|
+
await chmod(`${path}.shubao-helper.bak`, 0o600);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (error.code !== "ENOENT")
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
const withoutManaged = existing.replace(MANAGED, "").replace(/\n{3,}/g, "\n\n").replace(/\s*$/, "");
|
|
62
|
+
const block = `${START}\nexport SHUBAO_API_KEY="${apiKey}"\nexport SHUBAO_API_BASE_URL="${normalizedServer}"\n${END}\n`;
|
|
63
|
+
await atomicWrite(path, `${withoutManaged}${withoutManaged ? "\n\n" : ""}${block}`, Math.min(mode, 0o600));
|
|
64
|
+
return path;
|
|
65
|
+
}
|
|
66
|
+
async revoke() {
|
|
67
|
+
const path = this.path();
|
|
68
|
+
let existing;
|
|
69
|
+
let mode = 0o600;
|
|
70
|
+
try {
|
|
71
|
+
const info = await stat(path);
|
|
72
|
+
mode = info.mode & 0o777;
|
|
73
|
+
existing = await readFile(path, "utf8");
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error.code === "ENOENT")
|
|
77
|
+
return;
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
if (!MANAGED.test(existing))
|
|
81
|
+
return;
|
|
82
|
+
MANAGED.lastIndex = 0;
|
|
83
|
+
await copyFile(path, `${path}.shubao-helper.bak`);
|
|
84
|
+
await chmod(`${path}.shubao-helper.bak`, 0o600);
|
|
85
|
+
await atomicWrite(path, existing.replace(MANAGED, "").replace(/\n{3,}/g, "\n\n"), Math.min(mode, 0o600));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async function atomicWrite(path, content, mode) {
|
|
89
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
90
|
+
const temp = `${path}.shubao-helper.tmp`;
|
|
91
|
+
await writeFile(temp, content, { encoding: "utf8", mode });
|
|
92
|
+
await rename(temp, path);
|
|
93
|
+
await chmod(path, mode);
|
|
94
|
+
}
|
|
95
|
+
function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
|
96
|
+
function shellQuote(value) { return `'${value.replace(/'/g, "'\\''")}'`; }
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const mint = "\u001B[96m";
|
|
2
|
+
const reset = "\u001B[0m";
|
|
3
|
+
const width = 66;
|
|
4
|
+
function visibleWidth(text) {
|
|
5
|
+
return [...text].reduce((total, character) => total + (/[^\u0000-\u00ff\u2500-\u259f]/u.test(character) ? 2 : 1), 0);
|
|
6
|
+
}
|
|
7
|
+
function center(text) {
|
|
8
|
+
return " ".repeat(Math.max(0, Math.floor((width - visibleWidth(text)) / 2))) + text;
|
|
9
|
+
}
|
|
10
|
+
function frame(lines) {
|
|
11
|
+
const border = "═".repeat(width);
|
|
12
|
+
const content = lines
|
|
13
|
+
.map((line) => `${mint} ║${line}${" ".repeat(Math.max(0, width - visibleWidth(line)))}║${reset}`)
|
|
14
|
+
.join("\n");
|
|
15
|
+
return `${mint} ╔${border}╗${reset}\n${content}\n${mint} ╚${border}╝${reset}`;
|
|
16
|
+
}
|
|
17
|
+
// FIGlet "Standard" font for "SHUBAO", generated with figlet-cli so the
|
|
18
|
+
// glyphs are correct (hand-drawn art previously misspelled the word).
|
|
19
|
+
// Uses only ASCII line characters so it renders uniformly in any
|
|
20
|
+
// monospace terminal, unlike the previous █-block logo which skewed
|
|
21
|
+
// when FULL BLOCK had non-unit width.
|
|
22
|
+
const LOGO_LINES = [
|
|
23
|
+
" ____ _ _ _ _ ____ _ ___ ",
|
|
24
|
+
" / ___|| | | | | | | __ ) / \\ / _ \\ ",
|
|
25
|
+
" \\___ \\| |_| | | | | _ \\ / _ \\| | | |",
|
|
26
|
+
" ___) | _ | |_| | |_) / ___ \\ |_| |",
|
|
27
|
+
" |____/|_| |_|\\___/|____/_/ \\_\\___/ ",
|
|
28
|
+
];
|
|
29
|
+
export function banner() {
|
|
30
|
+
const logo = LOGO_LINES.map(center);
|
|
31
|
+
return [
|
|
32
|
+
"",
|
|
33
|
+
frame([
|
|
34
|
+
"",
|
|
35
|
+
...logo,
|
|
36
|
+
"",
|
|
37
|
+
center("SHUBAO HELPER"),
|
|
38
|
+
center("数宝小助手 · v0.1.1"),
|
|
39
|
+
"",
|
|
40
|
+
]),
|
|
41
|
+
"",
|
|
42
|
+
frame(["", center("主菜单"), ""]),
|
|
43
|
+
"",
|
|
44
|
+
].join("\n");
|
|
45
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Separator, select } from "@inquirer/prompts";
|
|
2
|
+
const mint = "\u001B[96m";
|
|
3
|
+
const dim = "\u001B[2m";
|
|
4
|
+
const bold = "\u001B[1m";
|
|
5
|
+
const reset = "\u001B[0m";
|
|
6
|
+
export async function mainMenu(status = null) {
|
|
7
|
+
const statusLine = status ? `${dim}当前已登录:${status.account}(${status.apiKeyPrefix})${reset}\n ` : "";
|
|
8
|
+
return select({
|
|
9
|
+
message: `${statusLine}${bold}${mint}请选择操作${reset}`,
|
|
10
|
+
choices: [
|
|
11
|
+
{ name: ` 账户与认证${status ? "(重新认证将覆盖现有凭证)" : ""}`, value: "auth" },
|
|
12
|
+
{ name: " 系统诊断", value: "doctor" },
|
|
13
|
+
new Separator(`${dim} ─────────────────────────────────${reset}`),
|
|
14
|
+
{ name: " 退出", value: "exit" },
|
|
15
|
+
],
|
|
16
|
+
theme: {
|
|
17
|
+
prefix: { idle: `${mint} ?${reset}`, done: `${mint} ✓${reset}` },
|
|
18
|
+
icon: { cursor: `${mint}›${reset}` },
|
|
19
|
+
style: {
|
|
20
|
+
message: (text) => text,
|
|
21
|
+
highlight: (text) => `${bold}${mint}${text}${reset}`,
|
|
22
|
+
keysHelpTip: () => `${dim} ↑↓ 选择 · Enter 确认${reset}`,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@shubao-ai/shubao-cli",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "数宝小助手",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"shubao": "./dist/src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/src",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE",
|
|
13
|
+
"package.json"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.json",
|
|
20
|
+
"test": "node --test dist/tests/*.test.js",
|
|
21
|
+
"prepublishOnly": "npm run build && npm test"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@inquirer/prompts": "^7.8.4"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^22.10.2",
|
|
28
|
+
"typescript": "^5.7.2"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"registry": "https://registry.npmjs.org/",
|
|
32
|
+
"access": "public"
|
|
33
|
+
}
|
|
34
|
+
}
|