@chongqingliangyunzhijing/ssquant-cli 0.0.0-stage → 0.2.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 +11 -2
- package/bin/ssquant.js +197 -0
- package/package.json +26 -4
- package/src/auth.js +167 -0
- package/src/commands/account.js +190 -0
- package/src/commands/mandate.js +92 -0
- package/src/commands/plan.js +66 -0
- package/src/commands/sim.js +45 -0
- package/src/commands/system.js +25 -0
- package/src/commands/trade-mode.js +31 -0
- package/src/commands/trade.js +275 -0
- package/src/config.js +115 -0
- package/src/http.js +217 -0
- package/src/output.js +133 -0
- package/src/version.js +7 -0
package/src/http.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP 客户端。
|
|
3
|
+
*
|
|
4
|
+
* 三件事必须做对:
|
|
5
|
+
* ① access 过期自动用 refresh 换,选手无感
|
|
6
|
+
* ② 服务端错误信封原样透出 code / message / suggestion —— Agent 靠 suggestion 自我修正
|
|
7
|
+
* ③ 限流按 retryAfter 等待,**不立即重试**
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
apiUrl, clientId, loadCredentials, saveCredentials, withCredentialLock,
|
|
11
|
+
} from './config.js';
|
|
12
|
+
import { EXIT_AUTH, EXIT_NET } from './output.js';
|
|
13
|
+
import { CLIENT_VERSION } from './version.js';
|
|
14
|
+
|
|
15
|
+
export class ApiError extends Error {
|
|
16
|
+
constructor(code, message, { suggestion, requestId, data, exitCode } = {}) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.suggestion = suggestion;
|
|
20
|
+
this.requestId = requestId;
|
|
21
|
+
this.data = data || {};
|
|
22
|
+
this.exitCode = exitCode;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class AuthError extends ApiError {
|
|
27
|
+
constructor(message = '未登录或凭证已失效,请执行 ssquant login') {
|
|
28
|
+
super('login_required', message, { exitCode: EXIT_AUTH });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function creds() {
|
|
33
|
+
const c = loadCredentials();
|
|
34
|
+
if (!c || !c.accessToken) throw new AuthError();
|
|
35
|
+
return c;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function rawFetch(url, {
|
|
39
|
+
method = 'GET', body, token, form = false, timeout = 30000,
|
|
40
|
+
} = {}) {
|
|
41
|
+
const ctrl = new AbortController();
|
|
42
|
+
const timer = setTimeout(() => ctrl.abort(), timeout);
|
|
43
|
+
let res;
|
|
44
|
+
try {
|
|
45
|
+
const headers = {
|
|
46
|
+
Accept: 'application/json',
|
|
47
|
+
'X-Client-Source': 'cli',
|
|
48
|
+
'X-Client-Version': CLIENT_VERSION,
|
|
49
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
50
|
+
};
|
|
51
|
+
let requestBody;
|
|
52
|
+
if (body !== undefined) {
|
|
53
|
+
if (form) {
|
|
54
|
+
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
55
|
+
requestBody = new URLSearchParams(body).toString();
|
|
56
|
+
} else {
|
|
57
|
+
headers['Content-Type'] = 'application/json';
|
|
58
|
+
requestBody = JSON.stringify(body);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
res = await fetch(url, {
|
|
62
|
+
method,
|
|
63
|
+
headers,
|
|
64
|
+
body: requestBody,
|
|
65
|
+
signal: ctrl.signal,
|
|
66
|
+
});
|
|
67
|
+
} catch (e) {
|
|
68
|
+
if (e.name === 'AbortError') {
|
|
69
|
+
throw new ApiError('timeout', `请求超时(${timeout / 1000}s):${url}`, {
|
|
70
|
+
suggestion: '检查网络,或稍后重试', exitCode: EXIT_NET,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
throw new ApiError('network_error', `无法连接服务:${e.message}`, {
|
|
74
|
+
suggestion: '检查网络与 SSQUANT_API_BASE 是否正确', exitCode: EXIT_NET,
|
|
75
|
+
});
|
|
76
|
+
} finally {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const text = await res.text();
|
|
81
|
+
let json = null;
|
|
82
|
+
try {
|
|
83
|
+
json = text ? JSON.parse(text) : null;
|
|
84
|
+
} catch {
|
|
85
|
+
throw new ApiError('bad_response',
|
|
86
|
+
`服务返回了非 JSON 内容(HTTP ${res.status}):${text.slice(0, 200)}`,
|
|
87
|
+
{ exitCode: EXIT_NET });
|
|
88
|
+
}
|
|
89
|
+
return { res, json };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 服务端信封 → 结果或抛错 */
|
|
93
|
+
function unwrap({ res, json }) {
|
|
94
|
+
if (res.ok && json && json.code === '0') {
|
|
95
|
+
return { data: json.data, meta: json.meta, requestId: json.request_id };
|
|
96
|
+
}
|
|
97
|
+
const code = (json && json.code) || `http_${res.status}`;
|
|
98
|
+
const message = (json && json.message) || `HTTP ${res.status}`;
|
|
99
|
+
const data = (json && json.data) || {};
|
|
100
|
+
const exitCode = res.status === 401 ? EXIT_AUTH
|
|
101
|
+
: res.status >= 500 ? EXIT_NET : undefined;
|
|
102
|
+
throw new ApiError(code, message, {
|
|
103
|
+
suggestion: data.suggestion,
|
|
104
|
+
requestId: json && json.request_id,
|
|
105
|
+
data,
|
|
106
|
+
exitCode,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let refreshPromise = null;
|
|
111
|
+
|
|
112
|
+
async function refreshTokenLocked(c) {
|
|
113
|
+
const body = {
|
|
114
|
+
grant_type: 'refresh_token',
|
|
115
|
+
refresh_token: c.refreshToken,
|
|
116
|
+
client_id: c.clientId || clientId(),
|
|
117
|
+
};
|
|
118
|
+
let out = await rawFetch(apiUrl('/oauth/token'), {
|
|
119
|
+
method: 'POST', form: true, body,
|
|
120
|
+
});
|
|
121
|
+
if (out.res.status === 409 && out.json?.code === 'refresh_in_progress') {
|
|
122
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
123
|
+
out = await rawFetch(apiUrl('/oauth/token'), {
|
|
124
|
+
method: 'POST', form: true, body,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const { res, json } = out;
|
|
128
|
+
if (!res.ok || !json || json.code !== '0') {
|
|
129
|
+
if (json?.code === 'refresh_in_progress') {
|
|
130
|
+
throw new ApiError(json.code, json.message || '登录凭证正在刷新,请稍后重试', {
|
|
131
|
+
suggestion: json.data?.suggestion,
|
|
132
|
+
requestId: json.request_id,
|
|
133
|
+
data: json.data,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
throw new AuthError('登录已过期,请重新执行 ssquant login');
|
|
137
|
+
}
|
|
138
|
+
const d = json.data;
|
|
139
|
+
const updated = {
|
|
140
|
+
...c,
|
|
141
|
+
accessToken: d.access_token,
|
|
142
|
+
refreshToken: d.refresh_token || c.refreshToken,
|
|
143
|
+
expiresAt: Date.now() + (d.expires_in || 2592000) * 1000,
|
|
144
|
+
accountId: d.account_id || c.accountId,
|
|
145
|
+
};
|
|
146
|
+
saveCredentials(updated);
|
|
147
|
+
return updated;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function refreshToken(c) {
|
|
151
|
+
if (refreshPromise) return refreshPromise;
|
|
152
|
+
refreshPromise = withCredentialLock(async () => {
|
|
153
|
+
// 另一个进程可能已经完成轮转,拿锁后必须读取最新凭据。
|
|
154
|
+
const latest = loadCredentials();
|
|
155
|
+
if (latest && latest.accountId === c.accountId && latest.accessToken && latest.refreshToken
|
|
156
|
+
&& latest.refreshToken !== c.refreshToken
|
|
157
|
+
&& (!latest.expiresAt || latest.expiresAt - Date.now() >= 60_000)) {
|
|
158
|
+
return latest;
|
|
159
|
+
}
|
|
160
|
+
if (latest && latest.accountId !== c.accountId) throw new AuthError('当前账户已切换,请重新发起操作');
|
|
161
|
+
return refreshTokenLocked(latest || c);
|
|
162
|
+
}).finally(() => {
|
|
163
|
+
refreshPromise = null;
|
|
164
|
+
});
|
|
165
|
+
return refreshPromise;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 带鉴权的请求。access 快过期(剩余 <60s)时先刷新,
|
|
170
|
+
* 拿到 401 再刷一次并重试一次 —— 服务端可能提前吊销。
|
|
171
|
+
*/
|
|
172
|
+
export async function request(path, { method = 'GET', body, retry = true } = {}) {
|
|
173
|
+
let c = creds();
|
|
174
|
+
if (c.expiresAt && c.expiresAt - Date.now() < 60_000 && c.refreshToken) {
|
|
175
|
+
c = await refreshToken(c);
|
|
176
|
+
}
|
|
177
|
+
const out = await rawFetch(apiUrl(path), { method, body, token: c.accessToken });
|
|
178
|
+
if (out.res.status === 401 && retry && c.refreshToken) {
|
|
179
|
+
c = await refreshToken(c);
|
|
180
|
+
const again = await rawFetch(apiUrl(path), { method, body, token: c.accessToken });
|
|
181
|
+
return unwrap(again);
|
|
182
|
+
}
|
|
183
|
+
return unwrap(out);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 不带鉴权(OAuth 授权码 / token 端点) */
|
|
187
|
+
export async function requestAnon(path, { method = 'POST', body, form = false } = {}) {
|
|
188
|
+
return unwrap(await rawFetch(apiUrl(path), { method, body, form }));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 下载公开文本资源(skill 文档等),地址由调用方决定。 */
|
|
192
|
+
export async function fetchText(url) {
|
|
193
|
+
const ctrl = new AbortController();
|
|
194
|
+
const timer = setTimeout(() => ctrl.abort(), 30000);
|
|
195
|
+
try {
|
|
196
|
+
const res = await fetch(url, { signal: ctrl.signal });
|
|
197
|
+
if (!res.ok) {
|
|
198
|
+
throw new ApiError('download_failed', `下载失败 HTTP ${res.status}:${url}`,
|
|
199
|
+
{ exitCode: EXIT_NET });
|
|
200
|
+
}
|
|
201
|
+
return await res.text();
|
|
202
|
+
} catch (e) {
|
|
203
|
+
if (e instanceof ApiError) throw e;
|
|
204
|
+
throw new ApiError('network_error', `下载失败:${e.message}`, { exitCode: EXIT_NET });
|
|
205
|
+
} finally {
|
|
206
|
+
clearTimeout(timer);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function qs(params) {
|
|
211
|
+
const p = new URLSearchParams();
|
|
212
|
+
for (const [k, v] of Object.entries(params || {})) {
|
|
213
|
+
if (v !== undefined && v !== null && v !== '') p.append(k, String(v));
|
|
214
|
+
}
|
|
215
|
+
const s = p.toString();
|
|
216
|
+
return s ? `?${s}` : '';
|
|
217
|
+
}
|
package/src/output.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 输出与退出码。
|
|
3
|
+
*
|
|
4
|
+
* ★ --json 的信封是**对 Agent 的契约**,不能随意改:
|
|
5
|
+
* 成功 { ok:true, data, meta }
|
|
6
|
+
* 失败 { ok:false, error:{ code, message, suggestion, requestId } }
|
|
7
|
+
* Agent 只需判断 ok,失败时读 error.suggestion 就知道该怎么办。
|
|
8
|
+
*
|
|
9
|
+
* ★ 退出码让 shell 脚本能区分「该重试」和「该停手」:
|
|
10
|
+
* 0 成功 1 业务错误 2 认证失效(需重新 login) 3 网络/服务不可用
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const EXIT_OK = 0;
|
|
14
|
+
export const EXIT_BIZ = 1;
|
|
15
|
+
export const EXIT_AUTH = 2;
|
|
16
|
+
export const EXIT_NET = 3;
|
|
17
|
+
|
|
18
|
+
let jsonMode = false;
|
|
19
|
+
let updateNotice = null;
|
|
20
|
+
|
|
21
|
+
export function setUpdateNotice(notice) {
|
|
22
|
+
updateNotice = notice;
|
|
23
|
+
if (!jsonMode && notice?.available) {
|
|
24
|
+
process.stderr.write(`更新提示:${notice.message}\n`);
|
|
25
|
+
if (notice.cli.updateAvailable) process.stderr.write(` ${notice.cli.command}\n`);
|
|
26
|
+
for (const item of notice.skill.installations.filter(x => x.updateAvailable)) {
|
|
27
|
+
process.stderr.write(` ${item.command}\n`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function withUpdate(meta) {
|
|
33
|
+
return updateNotice ? { ...meta, update: updateNotice } : meta;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function setJsonMode(on) {
|
|
37
|
+
jsonMode = !!on;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isJson() {
|
|
41
|
+
return jsonMode;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function emit(data, meta) {
|
|
45
|
+
if (jsonMode) {
|
|
46
|
+
const body = { ok: true, data };
|
|
47
|
+
const combined = withUpdate(meta);
|
|
48
|
+
if (combined) body.meta = combined;
|
|
49
|
+
process.stdout.write(JSON.stringify(body) + '\n');
|
|
50
|
+
}
|
|
51
|
+
return data;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function fail(error, exitCode = EXIT_BIZ) {
|
|
55
|
+
const payload = {
|
|
56
|
+
code: error.code || 'error',
|
|
57
|
+
message: error.message || String(error),
|
|
58
|
+
};
|
|
59
|
+
if (error.suggestion) payload.suggestion = error.suggestion;
|
|
60
|
+
if (error.requestId) payload.requestId = error.requestId;
|
|
61
|
+
if (error.data && Object.keys(error.data).length) payload.detail = error.data;
|
|
62
|
+
|
|
63
|
+
if (jsonMode) {
|
|
64
|
+
const body = { ok: false, error: payload };
|
|
65
|
+
if (updateNotice) body.meta = withUpdate();
|
|
66
|
+
process.stdout.write(JSON.stringify(body) + '\n');
|
|
67
|
+
} else {
|
|
68
|
+
process.stderr.write(`✗ ${payload.message}\n`);
|
|
69
|
+
if (payload.suggestion) process.stderr.write(` 建议:${payload.suggestion}\n`);
|
|
70
|
+
if (payload.requestId) process.stderr.write(` 请求号:${payload.requestId}\n`);
|
|
71
|
+
}
|
|
72
|
+
process.exitCode = exitCode;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 人读模式下打印一行行的 key: value */
|
|
76
|
+
export function kv(pairs) {
|
|
77
|
+
if (jsonMode) return;
|
|
78
|
+
const width = Math.max(...pairs.map(([k]) => strWidth(k)));
|
|
79
|
+
for (const [k, v] of pairs) {
|
|
80
|
+
const pad = ' '.repeat(Math.max(0, width - strWidth(k)));
|
|
81
|
+
process.stdout.write(` ${k}${pad} ${v}\n`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 人读模式下打印表格。中文按 2 列宽计算,否则对不齐 */
|
|
86
|
+
export function table(headers, rows) {
|
|
87
|
+
if (jsonMode) return;
|
|
88
|
+
if (!rows.length) {
|
|
89
|
+
process.stdout.write(' (无数据)\n');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const all = [headers, ...rows];
|
|
93
|
+
const widths = headers.map((_, i) =>
|
|
94
|
+
Math.max(...all.map((r) => strWidth(String(r[i] ?? '')))));
|
|
95
|
+
const line = (r) =>
|
|
96
|
+
' ' + r.map((c, i) => {
|
|
97
|
+
const s = String(c ?? '');
|
|
98
|
+
return s + ' '.repeat(Math.max(0, widths[i] - strWidth(s)));
|
|
99
|
+
}).join(' ');
|
|
100
|
+
process.stdout.write(line(headers) + '\n');
|
|
101
|
+
process.stdout.write(' ' + widths.map((w) => '─'.repeat(w)).join(' ') + '\n');
|
|
102
|
+
for (const r of rows) process.stdout.write(line(r) + '\n');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function say(msg) {
|
|
106
|
+
if (!jsonMode) process.stdout.write(msg + '\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 全角字符占 2 列 */
|
|
110
|
+
function strWidth(s) {
|
|
111
|
+
let w = 0;
|
|
112
|
+
for (const ch of String(s)) {
|
|
113
|
+
w += /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]/.test(ch) ? 2 : 1;
|
|
114
|
+
}
|
|
115
|
+
return w;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function num(v, digits = 2) {
|
|
119
|
+
if (v === null || v === undefined || v === '') return '-';
|
|
120
|
+
const n = Number(v);
|
|
121
|
+
if (Number.isNaN(n)) return String(v);
|
|
122
|
+
return n.toLocaleString('zh-CN', {
|
|
123
|
+
minimumFractionDigits: digits,
|
|
124
|
+
maximumFractionDigits: digits,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function pct(v, digits = 2) {
|
|
129
|
+
if (v === null || v === undefined || v === '') return '-';
|
|
130
|
+
const n = Number(v);
|
|
131
|
+
if (Number.isNaN(n)) return String(v);
|
|
132
|
+
return `${(n * 100).toFixed(digits)}%`;
|
|
133
|
+
}
|
package/src/version.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
const packageJson = require('../package.json');
|
|
5
|
+
|
|
6
|
+
export const PACKAGE_NAME = '@chongqingliangyunzhijing/ssquant-cli';
|
|
7
|
+
export const CLIENT_VERSION = packageJson.version;
|