echopai 2.3.0 → 2.4.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 +63 -348
- package/dist/bin.js +8298 -190
- package/package.json +11 -13
- package/dist/_generated/commands.js +0 -378
- package/dist/_generated/help.js +0 -295
- package/dist/_generated/operations.js +0 -2385
- package/dist/runtime/auth.js +0 -95
- package/dist/runtime/envelope.js +0 -52
- package/dist/runtime/errors.js +0 -186
- package/dist/runtime/filters.js +0 -153
- package/dist/runtime/format.js +0 -143
- package/dist/runtime/http.js +0 -65
- package/dist/runtime/idempotency.js +0 -18
- package/dist/runtime/invoker.js +0 -391
- package/dist/runtime/io.js +0 -16
- package/dist/runtime/paginator.js +0 -146
- package/dist/runtime/trace.js +0 -99
- package/dist/runtime/tty.js +0 -51
- package/dist/runtime/update_check.js +0 -120
- package/dist/runtime/update_worker.js +0 -63
- package/dist/runtime/verb_cmd.js +0 -72
- package/dist/runtime/verb_runner.js +0 -152
- package/dist/runtime/whoami_cache.js +0 -109
- package/dist/tools/api.js +0 -81
- package/dist/tools/completion.js +0 -116
- package/dist/tools/config.js +0 -123
- package/dist/tools/doctor.js +0 -183
- package/dist/tools/login.js +0 -99
- package/dist/tools/mcp.js +0 -141
- package/dist/tools/raw.js +0 -96
- package/dist/tools/schema.js +0 -58
- package/dist/tools/trace.js +0 -54
- package/dist/tools/upgrade.js +0 -103
- package/dist/tools/welcome.js +0 -225
- package/dist/tools/whoami.js +0 -132
- package/dist/verbs/_spec.js +0 -15
- package/dist/verbs/announcements.js +0 -195
- package/dist/verbs/bars_batch.js +0 -66
- package/dist/verbs/chart.js +0 -110
- package/dist/verbs/concepts.js +0 -393
- package/dist/verbs/digest.js +0 -351
- package/dist/verbs/financials.js +0 -212
- package/dist/verbs/hot.js +0 -29
- package/dist/verbs/index.js +0 -88
- package/dist/verbs/limit_up.js +0 -156
- package/dist/verbs/lookup.js +0 -72
- package/dist/verbs/market.js +0 -185
- package/dist/verbs/news.js +0 -81
- package/dist/verbs/quote.js +0 -53
- package/dist/verbs/scan.js +0 -42
- package/dist/verbs/search.js +0 -105
- package/dist/verbs/sentiment.js +0 -231
- package/dist/verbs/views.js +0 -85
- package/dist/version.js +0 -5
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Idempotency-Key 自动生成。
|
|
3
|
-
*
|
|
4
|
-
* 端点 op.idempotencyRequired === true 时,invoker 自动 gen UUIDv4 并:
|
|
5
|
-
* - 加 Idempotency-Key request header
|
|
6
|
-
* - echo 到 stderr 让用户 / AI agent 能记下来重试时复用同一 key
|
|
7
|
-
*
|
|
8
|
-
* 用户也可显式 --idempotency-key <uuid> 覆盖(debug 重放用)。
|
|
9
|
-
*/
|
|
10
|
-
import * as crypto from "node:crypto";
|
|
11
|
-
export function generateIdempotencyKey() {
|
|
12
|
-
return crypto.randomUUID();
|
|
13
|
-
}
|
|
14
|
-
export function announceKey(key, op) {
|
|
15
|
-
// stderr 不污染 stdout JSON envelope
|
|
16
|
-
process.stderr.write(`[idempotency-key] ${op}: ${key}\n` +
|
|
17
|
-
`[idempotency-key] retry: pass --idempotency-key ${key} to dedupe.\n`);
|
|
18
|
-
}
|
package/dist/runtime/invoker.js
DELETED
|
@@ -1,391 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 端点调用器:Ajv pre-flight → undici fetch → envelope 解析 → render → exit。
|
|
3
|
-
*
|
|
4
|
-
* 由 _generated/commands.ts 的 dispatch 入口调用。
|
|
5
|
-
*
|
|
6
|
-
* 支持参数:
|
|
7
|
-
* --output <fmt> 覆盖默认 outputDefault
|
|
8
|
-
* --query <jmespath> Phase 3 加,本 PR 不接
|
|
9
|
-
* --debug stderr 打 HTTP wire trace(脱敏 Bearer)
|
|
10
|
-
* --raw 跳 envelope 解析,输出原始 body
|
|
11
|
-
*/
|
|
12
|
-
import AjvPkg from "ajv";
|
|
13
|
-
import addFormatsPkg from "ajv-formats";
|
|
14
|
-
import { fetch } from "undici";
|
|
15
|
-
import { resolveCredentials, AuthMissingError } from "./auth.js";
|
|
16
|
-
import { buildResponseEnvelope } from "./envelope.js";
|
|
17
|
-
import { exitCodeForStatus, isRetryableByCode, resolveRecoveryHint, tryParseErrorEnvelope, CallApiError, } from "./errors.js";
|
|
18
|
-
import { applyFilters, parseFieldsFlag, parseMaxBytesFlag, FilterError, } from "./filters.js";
|
|
19
|
-
import { isOutputFormat, render } from "./format.js";
|
|
20
|
-
import { buildHttpHeaders, resolveRequestId } from "./http.js";
|
|
21
|
-
import { appendTrace } from "./trace.js";
|
|
22
|
-
import { generateIdempotencyKey, announceKey } from "./idempotency.js";
|
|
23
|
-
import { writeStdout, writeStderr } from "./io.js";
|
|
24
|
-
import { paginate, exitCodeForPaginateError } from "./paginator.js";
|
|
25
|
-
import { renderError, isTtyHuman } from "./tty.js";
|
|
26
|
-
import { maybeEmitUpdateBanner } from "./update_check.js";
|
|
27
|
-
const Ajv = (AjvPkg.default ??
|
|
28
|
-
AjvPkg);
|
|
29
|
-
const addFormats = (addFormatsPkg.default ??
|
|
30
|
-
addFormatsPkg);
|
|
31
|
-
// strict: false 让 Ajv 接受 OpenAPI 3.0 的 `example` / `examples` 关键字(不是
|
|
32
|
-
// 标准 JSON Schema draft 7,但 OpenAPI 普遍用)。
|
|
33
|
-
// coerceTypes: 'array' 允许 --codes SSE:000001 自动包成 ["SSE:000001"],
|
|
34
|
-
// 也允许 --codes "A,B,C" 字符串入 array schema(但用户更常用单值)。
|
|
35
|
-
const ajv = new Ajv({
|
|
36
|
-
allErrors: true,
|
|
37
|
-
useDefaults: true,
|
|
38
|
-
coerceTypes: "array",
|
|
39
|
-
strict: false,
|
|
40
|
-
});
|
|
41
|
-
addFormats(ajv);
|
|
42
|
-
const SYSTEM_FLAGS = new Set([
|
|
43
|
-
"output",
|
|
44
|
-
"all",
|
|
45
|
-
"key",
|
|
46
|
-
"profile",
|
|
47
|
-
"debug",
|
|
48
|
-
"raw",
|
|
49
|
-
"idempotency_key",
|
|
50
|
-
"max_pages",
|
|
51
|
-
"max_items",
|
|
52
|
-
// PR 1.4 — output filter pipeline (renamed root --query → --jq in Phase 6
|
|
53
|
-
// smoke-fix because subcommand specs declare their own --query as a body
|
|
54
|
-
// param, e.g. news.search. Stripping "query" here would eat that param.)
|
|
55
|
-
"jq",
|
|
56
|
-
"fields",
|
|
57
|
-
"max_bytes",
|
|
58
|
-
// Phase 6 — write safety + dry-run
|
|
59
|
-
"yes",
|
|
60
|
-
"dry_run",
|
|
61
|
-
]);
|
|
62
|
-
export async function invoke(op, args, ctx) {
|
|
63
|
-
const isTty = process.stdout.isTTY === true && !process.env.CI;
|
|
64
|
-
// 1. resolve --output
|
|
65
|
-
let outputFmt = op.outputDefault;
|
|
66
|
-
if (typeof args.output === "string" && isOutputFormat(args.output)) {
|
|
67
|
-
outputFmt = args.output;
|
|
68
|
-
}
|
|
69
|
-
const debug = Boolean(args.debug);
|
|
70
|
-
const raw = Boolean(args.raw);
|
|
71
|
-
// 2. resolve credentials
|
|
72
|
-
const resolveOpts = {};
|
|
73
|
-
if (typeof args.key === "string")
|
|
74
|
-
resolveOpts.key = args.key;
|
|
75
|
-
if (typeof args.profile === "string")
|
|
76
|
-
resolveOpts.profile = args.profile;
|
|
77
|
-
let creds;
|
|
78
|
-
try {
|
|
79
|
-
creds = resolveCredentials(resolveOpts);
|
|
80
|
-
}
|
|
81
|
-
catch (e) {
|
|
82
|
-
if (e instanceof AuthMissingError) {
|
|
83
|
-
trace(op, { exit_code: 1, error_code: "auth_missing" });
|
|
84
|
-
writeError("auth_missing", e.message, 1, e.recovery_hint ? { recovery_hint: e.recovery_hint } : undefined);
|
|
85
|
-
}
|
|
86
|
-
throw e;
|
|
87
|
-
}
|
|
88
|
-
// 3. strip system flags + undefined to get actual API params
|
|
89
|
-
const apiParams = {};
|
|
90
|
-
for (const [k, v] of Object.entries(args)) {
|
|
91
|
-
if (SYSTEM_FLAGS.has(k))
|
|
92
|
-
continue;
|
|
93
|
-
if (v === undefined || v === "")
|
|
94
|
-
continue;
|
|
95
|
-
apiParams[k] = v;
|
|
96
|
-
}
|
|
97
|
-
// 4a. coerce CSV → array for params declared as array in schema (Ajv coerce
|
|
98
|
-
// wraps single-string as 1-element array, but for "A,B,C" we want split-on-comma).
|
|
99
|
-
for (const [pname, pschema] of Object.entries(op.inputSchema.properties)) {
|
|
100
|
-
if (typeof pschema === "object" &&
|
|
101
|
-
pschema !== null &&
|
|
102
|
-
pschema.type === "array" &&
|
|
103
|
-
typeof apiParams[pname] === "string" &&
|
|
104
|
-
apiParams[pname].includes(",")) {
|
|
105
|
-
apiParams[pname] = apiParams[pname].split(",").map((s) => s.trim());
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
// 4b. Ajv pre-flight schema validation
|
|
109
|
-
const validate = ajv.compile(op.inputSchema);
|
|
110
|
-
if (!validate(apiParams)) {
|
|
111
|
-
const errs = (validate.errors || [])
|
|
112
|
-
.map((e) => `${e.instancePath || "(root)"}: ${e.message ?? "invalid"}`)
|
|
113
|
-
.join("; ");
|
|
114
|
-
trace(op, { exit_code: 1, error_code: "invalid_args" });
|
|
115
|
-
writeError("invalid_args", `Input validation failed: ${errs}`, 1);
|
|
116
|
-
}
|
|
117
|
-
// 4c. Phase 6 — write safety + dry-run gates
|
|
118
|
-
//
|
|
119
|
-
// Two CLI-side preflight checks, both fail-closed:
|
|
120
|
-
//
|
|
121
|
-
// a) non-TTY write without --yes → confirmation_required
|
|
122
|
-
// Agents/scripts can never accidentally trigger a side-effect; they
|
|
123
|
-
// must opt in explicitly. TTY users get the usual interactive flow
|
|
124
|
-
// (commander itself prompts? no — we keep this simple: TTY skips the
|
|
125
|
-
// gate and assumes the user typed the command knowingly).
|
|
126
|
-
//
|
|
127
|
-
// b) --dry-run on op where dryRunSupported=false → dry_run_unsupported
|
|
128
|
-
// Refuse to silently noop. The agent gets a structured error and
|
|
129
|
-
// knows to retry without --dry-run (or pick a different op).
|
|
130
|
-
//
|
|
131
|
-
// When --dry-run is honored we set X-Dry-Run:1 in the outgoing headers
|
|
132
|
-
// further down (step 5c). Read ops with --dry-run are a noop client-side
|
|
133
|
-
// (they don't mutate anyway) — we treat them as a soft success but stamp
|
|
134
|
-
// meta.dry_run=true so callers know nothing happened.
|
|
135
|
-
const yes = Boolean(args.yes);
|
|
136
|
-
const dryRun = Boolean(args.dry_run);
|
|
137
|
-
if (op.sideEffect === "write" && !isTty && !yes) {
|
|
138
|
-
trace(op, { exit_code: 1, error_code: "confirmation_required" });
|
|
139
|
-
writeError("confirmation_required", `Operation ${op.cliKey} is a write (side-effect=write) and stdout is not a TTY. Pass --yes to confirm.`, 1);
|
|
140
|
-
}
|
|
141
|
-
if (dryRun && !op.dryRunSupported && op.sideEffect === "write") {
|
|
142
|
-
trace(op, { exit_code: 1, error_code: "dry_run_unsupported" });
|
|
143
|
-
writeError("dry_run_unsupported", `Operation ${op.cliKey} does not support server-side dry-run (X-Dry-Run).`, 1);
|
|
144
|
-
}
|
|
145
|
-
// 5. build URL + query string
|
|
146
|
-
let url = creds.baseUrl + op.path;
|
|
147
|
-
// path templating: {id} → URL-encoded value, removed from query
|
|
148
|
-
const pathParamRegex = /\{([^}]+)\}/g;
|
|
149
|
-
url = url.replace(pathParamRegex, (_full, name) => {
|
|
150
|
-
const v = apiParams[name];
|
|
151
|
-
if (v === undefined) {
|
|
152
|
-
trace(op, { exit_code: 1, error_code: "invalid_args" });
|
|
153
|
-
writeError("invalid_args", `Path parameter '${name}' is required.`, 1);
|
|
154
|
-
}
|
|
155
|
-
delete apiParams[name];
|
|
156
|
-
return encodeURIComponent(String(v));
|
|
157
|
-
});
|
|
158
|
-
// 5a. WS streaming endpoint — REMOVED. CLI no longer ships /v1/ws/{news,views}
|
|
159
|
-
// as commands. The OpenAPI ops still exist for partner SDKs but the
|
|
160
|
-
// codegen drops x-cli-key, so this branch is unreachable today and
|
|
161
|
-
// should stay deleted unless the partner-side WS auth model is reworked
|
|
162
|
-
// (see WS removal commit + plan §3 follow-ups).
|
|
163
|
-
// 5b. paginate `--all`
|
|
164
|
-
if (args.all && op.pagination !== "none") {
|
|
165
|
-
try {
|
|
166
|
-
const result = await paginate(op, apiParams, {
|
|
167
|
-
baseUrl: creds.baseUrl,
|
|
168
|
-
bearer: creds.key,
|
|
169
|
-
cliVersion: ctx.cliVersion,
|
|
170
|
-
debug,
|
|
171
|
-
...(typeof args.max_pages === "string"
|
|
172
|
-
? { maxPages: Number(args.max_pages) }
|
|
173
|
-
: typeof args.max_pages === "number"
|
|
174
|
-
? { maxPages: args.max_pages }
|
|
175
|
-
: {}),
|
|
176
|
-
...(typeof args.max_items === "string"
|
|
177
|
-
? { maxItems: Number(args.max_items) }
|
|
178
|
-
: typeof args.max_items === "number"
|
|
179
|
-
? { maxItems: args.max_items }
|
|
180
|
-
: {}),
|
|
181
|
-
});
|
|
182
|
-
await writeStderr(`[paginate] ${result.pages} pages, ${result.items} items\n`);
|
|
183
|
-
trace(op, { exit_code: 0 });
|
|
184
|
-
maybeEmitUpdateBanner();
|
|
185
|
-
process.exit(0);
|
|
186
|
-
}
|
|
187
|
-
catch (e) {
|
|
188
|
-
if (e instanceof CallApiError) {
|
|
189
|
-
const exitCode = exitCodeForPaginateError(e);
|
|
190
|
-
trace(op, {
|
|
191
|
-
request_id: e.requestId,
|
|
192
|
-
status: e.httpStatus,
|
|
193
|
-
exit_code: exitCode,
|
|
194
|
-
error_code: e.code,
|
|
195
|
-
});
|
|
196
|
-
writeError(e.code, e.message, exitCode, {
|
|
197
|
-
retryable: e.retryable,
|
|
198
|
-
...(e.recovery_hint ? { recovery_hint: e.recovery_hint } : {}),
|
|
199
|
-
...(e.requestId ? { request_id: e.requestId } : {}),
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
trace(op, { exit_code: 2, error_code: "internal_error" });
|
|
203
|
-
writeError("internal_error", e instanceof Error ? e.message : String(e), 2);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
// 5c. single-shot HTTP request
|
|
207
|
-
const queryString = buildQueryString(apiParams);
|
|
208
|
-
if (op.method === "GET" && queryString)
|
|
209
|
-
url += "?" + queryString;
|
|
210
|
-
const { headers, requestId: clientRequestId } = buildHttpHeaders({
|
|
211
|
-
bearer: creds.key,
|
|
212
|
-
cliVersion: ctx.cliVersion,
|
|
213
|
-
});
|
|
214
|
-
// Idempotency-Key for endpoints that require it
|
|
215
|
-
if (op.idempotencyRequired) {
|
|
216
|
-
const userKey = typeof args.idempotency_key === "string" ? args.idempotency_key : null;
|
|
217
|
-
const idemKey = userKey || generateIdempotencyKey();
|
|
218
|
-
headers.set("Idempotency-Key", idemKey);
|
|
219
|
-
if (!userKey)
|
|
220
|
-
announceKey(idemKey, op.cliKey);
|
|
221
|
-
}
|
|
222
|
-
// X-Dry-Run: only set on writes where the server explicitly advertises
|
|
223
|
-
// support (op.dryRunSupported). Read ops with --dry-run flow through
|
|
224
|
-
// unchanged — they're a noop server-side, so there's nothing to dry-run.
|
|
225
|
-
if (dryRun && op.dryRunSupported) {
|
|
226
|
-
headers.set("X-Dry-Run", "1");
|
|
227
|
-
}
|
|
228
|
-
const init = {
|
|
229
|
-
method: op.method,
|
|
230
|
-
headers,
|
|
231
|
-
};
|
|
232
|
-
if (op.method === "POST") {
|
|
233
|
-
headers.set("Content-Type", "application/json");
|
|
234
|
-
init.body = JSON.stringify(apiParams);
|
|
235
|
-
}
|
|
236
|
-
if (debug) {
|
|
237
|
-
process.stderr.write(`> ${op.method} ${url}\n` +
|
|
238
|
-
`> Authorization: Bearer eps_live_***_***\n` +
|
|
239
|
-
Array.from(headers.entries())
|
|
240
|
-
.filter(([k]) => k.toLowerCase() !== "authorization")
|
|
241
|
-
.map(([k, v]) => `> ${k}: ${v}`)
|
|
242
|
-
.join("\n") +
|
|
243
|
-
"\n");
|
|
244
|
-
}
|
|
245
|
-
// 6. fire request
|
|
246
|
-
const startedAt = Date.now();
|
|
247
|
-
let res;
|
|
248
|
-
try {
|
|
249
|
-
res = await fetch(url, init);
|
|
250
|
-
}
|
|
251
|
-
catch (e) {
|
|
252
|
-
trace(op, { exit_code: 2, error_code: "network_error" });
|
|
253
|
-
writeError("network_error", e instanceof Error ? e.message : String(e), 2);
|
|
254
|
-
}
|
|
255
|
-
const requestId = resolveRequestId(res.headers.get("x-request-id"), clientRequestId);
|
|
256
|
-
const apiVersion = res.headers.get("x-api-version") || undefined;
|
|
257
|
-
const ct = res.headers.get("content-type") || "";
|
|
258
|
-
const bodyText = await res.text();
|
|
259
|
-
let bodyJson = null;
|
|
260
|
-
if (ct.includes("application/json") && bodyText) {
|
|
261
|
-
try {
|
|
262
|
-
bodyJson = JSON.parse(bodyText);
|
|
263
|
-
}
|
|
264
|
-
catch {
|
|
265
|
-
// body 不是 JSON
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
const durationMs = Date.now() - startedAt;
|
|
269
|
-
if (debug) {
|
|
270
|
-
process.stderr.write(`< ${res.status} ${res.statusText}\n`);
|
|
271
|
-
if (requestId)
|
|
272
|
-
process.stderr.write(`< x-request-id: ${requestId}\n`);
|
|
273
|
-
}
|
|
274
|
-
// 7. error path
|
|
275
|
-
if (res.status >= 400) {
|
|
276
|
-
const apiErr = tryParseErrorEnvelope(bodyJson, res.status, requestId);
|
|
277
|
-
const exitCode = exitCodeForStatus(res.status);
|
|
278
|
-
const code = apiErr?.code ?? "http_error";
|
|
279
|
-
trace(op, {
|
|
280
|
-
request_id: requestId,
|
|
281
|
-
status: res.status,
|
|
282
|
-
duration_ms: durationMs,
|
|
283
|
-
exit_code: exitCode,
|
|
284
|
-
error_code: code,
|
|
285
|
-
});
|
|
286
|
-
if (apiErr) {
|
|
287
|
-
writeError(apiErr.code, apiErr.message, exitCode, {
|
|
288
|
-
retryable: apiErr.retryable,
|
|
289
|
-
...(apiErr.recovery_hint ? { recovery_hint: apiErr.recovery_hint } : {}),
|
|
290
|
-
request_id: requestId,
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
writeError("http_error", `HTTP ${res.status} ${res.statusText}: ${bodyText.slice(0, 200)}`, exitCode, { request_id: requestId });
|
|
294
|
-
}
|
|
295
|
-
// 8. success path — render
|
|
296
|
-
if (raw) {
|
|
297
|
-
await writeStdout(bodyText + "\n");
|
|
298
|
-
maybeEmitUpdateBanner();
|
|
299
|
-
process.exit(0);
|
|
300
|
-
}
|
|
301
|
-
const rawEnvelope = buildResponseEnvelope(bodyJson ?? bodyText, {
|
|
302
|
-
requestId,
|
|
303
|
-
endpoint: op.path,
|
|
304
|
-
method: op.method,
|
|
305
|
-
cliVersion: ctx.cliVersion,
|
|
306
|
-
durationMs,
|
|
307
|
-
...(apiVersion ? { apiVersion } : {}),
|
|
308
|
-
});
|
|
309
|
-
let envelope = rawEnvelope;
|
|
310
|
-
try {
|
|
311
|
-
// Global JMESPath filter is `--jq` (renamed from --query in Phase 6
|
|
312
|
-
// smoke-fix to avoid clashing with subcommand `--query` body params,
|
|
313
|
-
// e.g. news.search). Do NOT fall back to args.query here — that's the
|
|
314
|
-
// subcommand body field, not a JMESPath expression.
|
|
315
|
-
const jq = typeof args.jq === "string" ? args.jq : undefined;
|
|
316
|
-
envelope = applyFilters(rawEnvelope, {
|
|
317
|
-
...(jq ? { query: jq } : {}),
|
|
318
|
-
...(parseFieldsFlag(args.fields) ? { fields: parseFieldsFlag(args.fields) } : {}),
|
|
319
|
-
...(parseMaxBytesFlag(args.max_bytes) ? { maxBytes: parseMaxBytesFlag(args.max_bytes) } : {}),
|
|
320
|
-
});
|
|
321
|
-
}
|
|
322
|
-
catch (e) {
|
|
323
|
-
if (e instanceof FilterError) {
|
|
324
|
-
trace(op, {
|
|
325
|
-
request_id: requestId,
|
|
326
|
-
status: res.status,
|
|
327
|
-
duration_ms: durationMs,
|
|
328
|
-
exit_code: 1,
|
|
329
|
-
error_code: "invalid_args",
|
|
330
|
-
});
|
|
331
|
-
writeError("invalid_args", e.message, 1);
|
|
332
|
-
}
|
|
333
|
-
throw e;
|
|
334
|
-
}
|
|
335
|
-
await writeStdout(render(envelope, outputFmt, isTty) + "\n");
|
|
336
|
-
trace(op, {
|
|
337
|
-
request_id: requestId,
|
|
338
|
-
status: res.status,
|
|
339
|
-
duration_ms: durationMs,
|
|
340
|
-
exit_code: 0,
|
|
341
|
-
truncated: envelope.meta.truncated === true,
|
|
342
|
-
});
|
|
343
|
-
maybeEmitUpdateBanner();
|
|
344
|
-
process.exit(0);
|
|
345
|
-
}
|
|
346
|
-
function buildQueryString(params) {
|
|
347
|
-
const parts = [];
|
|
348
|
-
for (const [k, v] of Object.entries(params)) {
|
|
349
|
-
if (v === undefined || v === null)
|
|
350
|
-
continue;
|
|
351
|
-
if (Array.isArray(v)) {
|
|
352
|
-
// edge-gateway expects ?codes=A,B not ?codes=A&codes=B
|
|
353
|
-
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v.join(","))}`);
|
|
354
|
-
}
|
|
355
|
-
else {
|
|
356
|
-
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
return parts.join("&");
|
|
360
|
-
}
|
|
361
|
-
function trace(op, t) {
|
|
362
|
-
appendTrace({
|
|
363
|
-
ts: new Date().toISOString(),
|
|
364
|
-
request_id: t.request_id ?? null,
|
|
365
|
-
cmd: op.cliKey,
|
|
366
|
-
status: t.status ?? null,
|
|
367
|
-
duration_ms: t.duration_ms ?? null,
|
|
368
|
-
exit_code: t.exit_code,
|
|
369
|
-
...(t.truncated ? { truncated: true } : {}),
|
|
370
|
-
...(t.error_code ? { error_code: t.error_code } : {}),
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
function writeError(code, message, exitCode, extras) {
|
|
374
|
-
const retryable = extras?.retryable ?? isRetryableByCode(code);
|
|
375
|
-
const hint = resolveRecoveryHint(code, extras?.recovery_hint);
|
|
376
|
-
const env = {
|
|
377
|
-
error: { code, message, retryable },
|
|
378
|
-
};
|
|
379
|
-
if (hint)
|
|
380
|
-
env.error.recovery_hint = hint;
|
|
381
|
-
if (extras?.request_id)
|
|
382
|
-
env.error.request_id = extras.request_id;
|
|
383
|
-
// TTY: 多行人性化输出 (color + recovery_hint 高亮);非 TTY 输出 single-line JSON。
|
|
384
|
-
if (isTtyHuman) {
|
|
385
|
-
process.stderr.write(renderError(env) + "\n");
|
|
386
|
-
}
|
|
387
|
-
else {
|
|
388
|
-
process.stderr.write(JSON.stringify(env) + "\n");
|
|
389
|
-
}
|
|
390
|
-
process.exit(exitCode);
|
|
391
|
-
}
|
package/dist/runtime/io.js
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
function writeStream(stream, text) {
|
|
2
|
-
return new Promise((resolve, reject) => {
|
|
3
|
-
stream.write(text, (err) => {
|
|
4
|
-
if (err)
|
|
5
|
-
reject(err);
|
|
6
|
-
else
|
|
7
|
-
resolve();
|
|
8
|
-
});
|
|
9
|
-
});
|
|
10
|
-
}
|
|
11
|
-
export function writeStdout(text) {
|
|
12
|
-
return writeStream(process.stdout, text);
|
|
13
|
-
}
|
|
14
|
-
export function writeStderr(text) {
|
|
15
|
-
return writeStream(process.stderr, text);
|
|
16
|
-
}
|
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pagination 策略:自动 follow next_cursor / next_offset 把全部 page NDJSON 输出。
|
|
3
|
-
*
|
|
4
|
-
* 触发:用户加 `--all` flag。`x-cli-pagination` 决定策略:
|
|
5
|
-
* - `cursor`:response.meta.next_cursor 非空 → 透传到下一次请求 ?cursor=...
|
|
6
|
-
* - `offset`:response.data.has_more=true → 用 ?offset = sum(已收) 拉下一页
|
|
7
|
-
* - `none`:不该走 pagination 路径
|
|
8
|
-
*
|
|
9
|
-
* 输出:NDJSON,每行一条 item(剥去 envelope,便于 `| jq` `| while read` 等管道)。
|
|
10
|
-
*
|
|
11
|
-
* 上限:默认 1_000 页或 100_000 items 防失控;可 --max-pages / --max-items 覆盖。
|
|
12
|
-
*/
|
|
13
|
-
import { fetch as undiciFetch } from "undici";
|
|
14
|
-
import { tryParseErrorEnvelope, exitCodeForStatus, CallApiError } from "./errors.js";
|
|
15
|
-
import { buildHttpHeaders, resolveRequestId } from "./http.js";
|
|
16
|
-
import { writeStdout } from "./io.js";
|
|
17
|
-
const DEFAULT_MAX_PAGES = 1000;
|
|
18
|
-
const DEFAULT_MAX_ITEMS = 100_000;
|
|
19
|
-
/**
|
|
20
|
-
* 调用并 NDJSON-stream 所有 page。失败抛 CallApiError。
|
|
21
|
-
*
|
|
22
|
-
* write 由调用方注入(默认走 process.stdout.write 且等待 flush,便于测试 mock)。
|
|
23
|
-
*/
|
|
24
|
-
export async function paginate(op, initialParams, ctx, write = writeStdout) {
|
|
25
|
-
if (op.pagination === "none") {
|
|
26
|
-
throw new Error(`${op.cliKey}: --all not supported (x-cli-pagination=none)`);
|
|
27
|
-
}
|
|
28
|
-
const maxPages = ctx.maxPages ?? DEFAULT_MAX_PAGES;
|
|
29
|
-
const maxItems = ctx.maxItems ?? DEFAULT_MAX_ITEMS;
|
|
30
|
-
const fetchFn = ctx.fetchImpl ?? undiciFetch;
|
|
31
|
-
const params = { ...initialParams };
|
|
32
|
-
let pages = 0;
|
|
33
|
-
let items = 0;
|
|
34
|
-
while (true) {
|
|
35
|
-
const url = ctx.baseUrl + op.path + "?" + buildQueryString(params);
|
|
36
|
-
// Rebuild headers per page — each page is a distinct request and needs
|
|
37
|
-
// its own X-Request-Id for server-side correlation.
|
|
38
|
-
const { headers, requestId: clientRequestId } = buildHttpHeaders({
|
|
39
|
-
bearer: ctx.bearer,
|
|
40
|
-
cliVersion: ctx.cliVersion,
|
|
41
|
-
});
|
|
42
|
-
if (ctx.debug) {
|
|
43
|
-
process.stderr.write(`> [page ${pages + 1}] ${op.method} ${url}\n`);
|
|
44
|
-
}
|
|
45
|
-
const res = await fetchFn(url, { method: op.method, headers });
|
|
46
|
-
const body = await res.text();
|
|
47
|
-
let json = null;
|
|
48
|
-
try {
|
|
49
|
-
json = body ? JSON.parse(body) : null;
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
52
|
-
// ignore
|
|
53
|
-
}
|
|
54
|
-
if (res.status >= 400) {
|
|
55
|
-
const reqId = resolveRequestId(res.headers.get("x-request-id"), clientRequestId);
|
|
56
|
-
const apiErr = tryParseErrorEnvelope(json, res.status, reqId);
|
|
57
|
-
throw apiErr ??
|
|
58
|
-
new CallApiError({
|
|
59
|
-
code: "http_error",
|
|
60
|
-
message: `HTTP ${res.status}`,
|
|
61
|
-
httpStatus: res.status,
|
|
62
|
-
requestId: reqId,
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
pages += 1;
|
|
66
|
-
const { itemsArr, nextParams, hasMore } = extractPage(op, json, params);
|
|
67
|
-
for (const item of itemsArr) {
|
|
68
|
-
await write(JSON.stringify(item) + "\n");
|
|
69
|
-
items += 1;
|
|
70
|
-
if (items >= maxItems) {
|
|
71
|
-
if (ctx.debug)
|
|
72
|
-
process.stderr.write(`[paginate] hit max-items ${maxItems}, stopping\n`);
|
|
73
|
-
return { pages, items };
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
if (!hasMore)
|
|
77
|
-
return { pages, items };
|
|
78
|
-
if (pages >= maxPages) {
|
|
79
|
-
if (ctx.debug)
|
|
80
|
-
process.stderr.write(`[paginate] hit max-pages ${maxPages}, stopping\n`);
|
|
81
|
-
return { pages, items };
|
|
82
|
-
}
|
|
83
|
-
Object.assign(params, nextParams);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
function extractPage(op, body, currentParams) {
|
|
87
|
-
// 公认 envelope 形态:
|
|
88
|
-
// { data: { items: [...], total?, has_more?, next_cursor? }, meta?: { next_cursor? } }
|
|
89
|
-
// 或 { data: [...], meta: { next_cursor? } } —— 直接 array
|
|
90
|
-
let itemsArr = [];
|
|
91
|
-
let dataObj = null;
|
|
92
|
-
let metaObj = null;
|
|
93
|
-
if (typeof body === "object" && body !== null) {
|
|
94
|
-
const top = body;
|
|
95
|
-
if (Array.isArray(top.data)) {
|
|
96
|
-
itemsArr = top.data;
|
|
97
|
-
}
|
|
98
|
-
else if (typeof top.data === "object" && top.data !== null) {
|
|
99
|
-
dataObj = top.data;
|
|
100
|
-
if (Array.isArray(dataObj.items))
|
|
101
|
-
itemsArr = dataObj.items;
|
|
102
|
-
}
|
|
103
|
-
else if (Array.isArray(top.items)) {
|
|
104
|
-
dataObj = top;
|
|
105
|
-
itemsArr = top.items;
|
|
106
|
-
}
|
|
107
|
-
if (typeof top.meta === "object" && top.meta !== null) {
|
|
108
|
-
metaObj = top.meta;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
if (op.pagination === "cursor") {
|
|
112
|
-
const nextCursor = metaObj?.next_cursor ??
|
|
113
|
-
dataObj?.next_cursor;
|
|
114
|
-
if (typeof nextCursor === "string" && nextCursor) {
|
|
115
|
-
return { itemsArr, nextParams: { cursor: nextCursor }, hasMore: true };
|
|
116
|
-
}
|
|
117
|
-
return { itemsArr, nextParams: {}, hasMore: false };
|
|
118
|
-
}
|
|
119
|
-
if (op.pagination === "offset") {
|
|
120
|
-
const hasMore = Boolean(dataObj?.has_more);
|
|
121
|
-
if (!hasMore)
|
|
122
|
-
return { itemsArr, nextParams: {}, hasMore: false };
|
|
123
|
-
const currentOffset = Number(currentParams.offset ?? 0) + itemsArr.length;
|
|
124
|
-
return { itemsArr, nextParams: { offset: currentOffset }, hasMore: true };
|
|
125
|
-
}
|
|
126
|
-
return { itemsArr, nextParams: {}, hasMore: false };
|
|
127
|
-
}
|
|
128
|
-
function buildQueryString(params) {
|
|
129
|
-
const parts = [];
|
|
130
|
-
for (const [k, v] of Object.entries(params)) {
|
|
131
|
-
if (v === undefined || v === null)
|
|
132
|
-
continue;
|
|
133
|
-
if (Array.isArray(v)) {
|
|
134
|
-
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v.join(","))}`);
|
|
135
|
-
}
|
|
136
|
-
else {
|
|
137
|
-
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
return parts.join("&");
|
|
141
|
-
}
|
|
142
|
-
export function exitCodeForPaginateError(e) {
|
|
143
|
-
if (e instanceof CallApiError && e.httpStatus)
|
|
144
|
-
return exitCodeForStatus(e.httpStatus);
|
|
145
|
-
return 2;
|
|
146
|
-
}
|
package/dist/runtime/trace.js
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Local CLI invocation trace (ring buffer).
|
|
3
|
-
*
|
|
4
|
-
* 写入 `~/.echopai/trace.ndjson`,每行一条 NDJSON。文件达 RING_MAX_BYTES (50MB)
|
|
5
|
-
* 时旋转为 `trace.ndjson.1` (覆盖前一代)。
|
|
6
|
-
*
|
|
7
|
-
* 用途:事后追溯 request_id / 看最近一次 401 是什么命令打的 / 排查 agent
|
|
8
|
-
* 调用频率。完全本地、不外发、对 prod 零影响。
|
|
9
|
-
*
|
|
10
|
-
* 关闭:`ECHOPAI_NO_TRACE=1`。
|
|
11
|
-
* 重定向:`ECHOPAI_TRACE_FILE=/path/to/file` (主要给测试用)。
|
|
12
|
-
*
|
|
13
|
-
* 失败容错:trace 是 best-effort,任何 fs 错误吞掉,绝不让用户命令因 trace
|
|
14
|
-
* 失败而 crash。
|
|
15
|
-
*
|
|
16
|
-
* 同步写入:CLI 退出时序 (process.exit) 不等 async flush,所以全用 sync API。
|
|
17
|
-
* 一次 appendFileSync ≈ μs 级,对 CLI 启停 latency 可忽略。
|
|
18
|
-
*/
|
|
19
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, } from "node:fs";
|
|
20
|
-
import { homedir } from "node:os";
|
|
21
|
-
import { dirname, join } from "node:path";
|
|
22
|
-
const DEFAULT_TRACE_FILE = join(homedir(), ".echopai", "trace.ndjson");
|
|
23
|
-
const RING_MAX_BYTES = 50 * 1024 * 1024;
|
|
24
|
-
export function isTraceDisabled() {
|
|
25
|
-
return process.env.ECHOPAI_NO_TRACE === "1";
|
|
26
|
-
}
|
|
27
|
-
export function resolveTraceFile(opts) {
|
|
28
|
-
return (opts?.filePath ?? process.env.ECHOPAI_TRACE_FILE ?? DEFAULT_TRACE_FILE);
|
|
29
|
-
}
|
|
30
|
-
export function appendTrace(record, opts) {
|
|
31
|
-
if (isTraceDisabled())
|
|
32
|
-
return;
|
|
33
|
-
const file = resolveTraceFile(opts);
|
|
34
|
-
const maxBytes = opts?.ringMaxBytes ?? RING_MAX_BYTES;
|
|
35
|
-
try {
|
|
36
|
-
mkdirSync(dirname(file), { recursive: true });
|
|
37
|
-
if (existsSync(file)) {
|
|
38
|
-
const size = statSync(file).size;
|
|
39
|
-
if (size >= maxBytes) {
|
|
40
|
-
renameSync(file, file + ".1");
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
appendFileSync(file, JSON.stringify(record) + "\n");
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
// best-effort
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
function readNdjsonLines(file) {
|
|
50
|
-
try {
|
|
51
|
-
if (!existsSync(file))
|
|
52
|
-
return [];
|
|
53
|
-
const raw = readFileSync(file, "utf-8");
|
|
54
|
-
const out = [];
|
|
55
|
-
for (const line of raw.split("\n")) {
|
|
56
|
-
if (line.length === 0)
|
|
57
|
-
continue;
|
|
58
|
-
try {
|
|
59
|
-
out.push(JSON.parse(line));
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
// skip malformed line
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return out;
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
return [];
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
/**
|
|
72
|
-
* Last N records in chronological order (oldest → newest).
|
|
73
|
-
* Reads current ring file; falls back to .1 to fill if needed.
|
|
74
|
-
*/
|
|
75
|
-
export function tailTraces(n, opts) {
|
|
76
|
-
if (n <= 0)
|
|
77
|
-
return [];
|
|
78
|
-
const file = resolveTraceFile(opts);
|
|
79
|
-
const current = readNdjsonLines(file);
|
|
80
|
-
if (current.length >= n)
|
|
81
|
-
return current.slice(-n);
|
|
82
|
-
const previous = readNdjsonLines(file + ".1");
|
|
83
|
-
return previous.concat(current).slice(-n);
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* Find a single record by request_id. Scans current ring then .1; newest first
|
|
87
|
-
* (so retried request_ids return the latest occurrence).
|
|
88
|
-
*/
|
|
89
|
-
export function getTraceByRequestId(requestId, opts) {
|
|
90
|
-
const file = resolveTraceFile(opts);
|
|
91
|
-
for (const candidate of [file, file + ".1"]) {
|
|
92
|
-
const records = readNdjsonLines(candidate);
|
|
93
|
-
for (let i = records.length - 1; i >= 0; i--) {
|
|
94
|
-
if (records[i].request_id === requestId)
|
|
95
|
-
return records[i];
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
return null;
|
|
99
|
-
}
|