echopai 2.0.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 +386 -0
- package/dist/_generated/commands.js +274 -0
- package/dist/_generated/help.js +190 -0
- package/dist/_generated/operations.js +1306 -0
- package/dist/bin.js +170 -0
- package/dist/runtime/auth.js +95 -0
- package/dist/runtime/envelope.js +52 -0
- package/dist/runtime/errors.js +186 -0
- package/dist/runtime/filters.js +153 -0
- package/dist/runtime/format.js +143 -0
- package/dist/runtime/http.js +65 -0
- package/dist/runtime/idempotency.js +18 -0
- package/dist/runtime/invoker.js +387 -0
- package/dist/runtime/io.js +16 -0
- package/dist/runtime/paginator.js +146 -0
- package/dist/runtime/trace.js +99 -0
- package/dist/runtime/tty.js +51 -0
- package/dist/runtime/verb_cmd.js +70 -0
- package/dist/runtime/verb_runner.js +152 -0
- package/dist/runtime/whoami_cache.js +109 -0
- package/dist/tools/api.js +81 -0
- package/dist/tools/completion.js +116 -0
- package/dist/tools/config.js +123 -0
- package/dist/tools/doctor.js +183 -0
- package/dist/tools/login.js +99 -0
- package/dist/tools/mcp.js +141 -0
- package/dist/tools/raw.js +96 -0
- package/dist/tools/schema.js +58 -0
- package/dist/tools/trace.js +54 -0
- package/dist/tools/whoami.js +132 -0
- package/dist/verbs/_spec.js +15 -0
- package/dist/verbs/bars_batch.js +66 -0
- package/dist/verbs/chart.js +110 -0
- package/dist/verbs/digest.js +342 -0
- package/dist/verbs/hot.js +29 -0
- package/dist/verbs/index.js +49 -0
- package/dist/verbs/lookup.js +72 -0
- package/dist/verbs/news.js +67 -0
- package/dist/verbs/quote.js +53 -0
- package/dist/verbs/research.js +44 -0
- package/dist/verbs/scan.js +42 -0
- package/dist/verbs/sentiment.js +46 -0
- package/dist/verbs/views.js +83 -0
- package/dist/version.js +5 -0
- package/package.json +58 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output format renderer。
|
|
3
|
+
*
|
|
4
|
+
* - json JSON.stringify (multi-line in TTY, single-line otherwise)
|
|
5
|
+
* - ndjson one JSON object per line(流式友好)
|
|
6
|
+
* - table ASCII table(仅 array of flat object)
|
|
7
|
+
* - csv RFC-4180 quoted
|
|
8
|
+
* - tsv tab-separated
|
|
9
|
+
* - yaml minimal YAML(不依赖 lib,仅 flat / array of flat)
|
|
10
|
+
*
|
|
11
|
+
* 自动从 data 推断列名(取 array[0] 的 keys);若不是 array of object 则
|
|
12
|
+
* fallback 到 json(避免格式错误)。
|
|
13
|
+
*/
|
|
14
|
+
export function isOutputFormat(s) {
|
|
15
|
+
return ["json", "ndjson", "table", "csv", "tsv", "yaml"].includes(s);
|
|
16
|
+
}
|
|
17
|
+
export function render(env, fmt, isTty) {
|
|
18
|
+
if (fmt === "json") {
|
|
19
|
+
// TTY: pretty 2-space; non-TTY: single line (NDJSON-friendly pipelines)
|
|
20
|
+
return JSON.stringify(env, null, isTty ? 2 : 0);
|
|
21
|
+
}
|
|
22
|
+
if (fmt === "ndjson") {
|
|
23
|
+
return ndjson(env);
|
|
24
|
+
}
|
|
25
|
+
// table / csv / tsv / yaml — only useful on array of flat object data。
|
|
26
|
+
// server envelope 常见形态 {items: [...], total, has_more} —— 对 items 表格化。
|
|
27
|
+
let target = env.data;
|
|
28
|
+
if (typeof target === "object" &&
|
|
29
|
+
target !== null &&
|
|
30
|
+
!Array.isArray(target) &&
|
|
31
|
+
Array.isArray(target.items)) {
|
|
32
|
+
target = target.items;
|
|
33
|
+
}
|
|
34
|
+
const arr = arrayOfFlatObjects(target);
|
|
35
|
+
if (arr === null) {
|
|
36
|
+
// fallback to json
|
|
37
|
+
return JSON.stringify(env, null, isTty ? 2 : 0);
|
|
38
|
+
}
|
|
39
|
+
if (fmt === "table")
|
|
40
|
+
return renderTable(arr);
|
|
41
|
+
if (fmt === "csv")
|
|
42
|
+
return renderDelimited(arr, ",", true);
|
|
43
|
+
if (fmt === "tsv")
|
|
44
|
+
return renderDelimited(arr, "\t", false);
|
|
45
|
+
if (fmt === "yaml")
|
|
46
|
+
return renderYaml(arr);
|
|
47
|
+
return JSON.stringify(env, null, isTty ? 2 : 0);
|
|
48
|
+
}
|
|
49
|
+
function arrayOfFlatObjects(data) {
|
|
50
|
+
if (!Array.isArray(data))
|
|
51
|
+
return null;
|
|
52
|
+
if (data.length === 0)
|
|
53
|
+
return [];
|
|
54
|
+
// 允许嵌套字段(cell 渲染时 JSON.stringify),但顶层每条必须是 object。
|
|
55
|
+
for (const item of data) {
|
|
56
|
+
if (typeof item !== "object" || item === null || Array.isArray(item))
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return data;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Pick a stable subset of columns for table / CSV — the union of keys from
|
|
63
|
+
* the first few rows, capped at COLUMN_LIMIT. 防超宽表格 + 缺字段 row 不破列。
|
|
64
|
+
*/
|
|
65
|
+
const COLUMN_LIMIT = 8;
|
|
66
|
+
function pickColumns(arr) {
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
for (let i = 0; i < Math.min(arr.length, 5); i++) {
|
|
69
|
+
for (const k of Object.keys(arr[i])) {
|
|
70
|
+
if (!seen.has(k)) {
|
|
71
|
+
seen.add(k);
|
|
72
|
+
if (seen.size >= COLUMN_LIMIT)
|
|
73
|
+
return [...seen];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return [...seen];
|
|
78
|
+
}
|
|
79
|
+
function ndjson(env) {
|
|
80
|
+
if (Array.isArray(env.data)) {
|
|
81
|
+
return env.data.map((d) => JSON.stringify(d)).join("\n");
|
|
82
|
+
}
|
|
83
|
+
return JSON.stringify(env);
|
|
84
|
+
}
|
|
85
|
+
function renderTable(arr) {
|
|
86
|
+
if (arr.length === 0)
|
|
87
|
+
return "(empty)";
|
|
88
|
+
const cols = pickColumns(arr);
|
|
89
|
+
const CELL_MAX = 60; // truncate long cells (URLs, content)
|
|
90
|
+
const truncate = (s) => (s.length > CELL_MAX ? s.slice(0, CELL_MAX - 1) + "…" : s);
|
|
91
|
+
const rows = arr.map((r) => cols.map((c) => truncate(stringify(r[c]))));
|
|
92
|
+
const widths = cols.map((c, i) => Math.max(c.length, ...rows.map((r) => r[i].length)));
|
|
93
|
+
const sep = widths.map((w) => "-".repeat(w)).join("-+-");
|
|
94
|
+
const headerRow = cols.map((c, i) => c.padEnd(widths[i])).join(" | ");
|
|
95
|
+
const dataRows = rows.map((r) => r.map((cell, i) => cell.padEnd(widths[i])).join(" | "));
|
|
96
|
+
return [headerRow, sep, ...dataRows].join("\n");
|
|
97
|
+
}
|
|
98
|
+
function renderDelimited(arr, delim, csvQuote) {
|
|
99
|
+
if (arr.length === 0)
|
|
100
|
+
return "";
|
|
101
|
+
const cols = pickColumns(arr);
|
|
102
|
+
const escape = (s) => {
|
|
103
|
+
if (!csvQuote)
|
|
104
|
+
return s.replace(/\t/g, " ").replace(/\n/g, " ");
|
|
105
|
+
if (s.includes(delim) || s.includes('"') || s.includes("\n")) {
|
|
106
|
+
return `"${s.replace(/"/g, '""')}"`;
|
|
107
|
+
}
|
|
108
|
+
return s;
|
|
109
|
+
};
|
|
110
|
+
const header = cols.map(escape).join(delim);
|
|
111
|
+
const rows = arr.map((r) => cols.map((c) => escape(stringify(r[c]))).join(delim));
|
|
112
|
+
return [header, ...rows].join("\n");
|
|
113
|
+
}
|
|
114
|
+
function renderYaml(arr) {
|
|
115
|
+
if (arr.length === 0)
|
|
116
|
+
return "[]";
|
|
117
|
+
return arr
|
|
118
|
+
.map((r) => {
|
|
119
|
+
const lines = Object.entries(r).map(([k, v]) => ` ${k}: ${yamlScalar(v)}`);
|
|
120
|
+
return ["-", ...lines].join("\n");
|
|
121
|
+
})
|
|
122
|
+
.join("\n");
|
|
123
|
+
}
|
|
124
|
+
function yamlScalar(v) {
|
|
125
|
+
if (v === null || v === undefined)
|
|
126
|
+
return "null";
|
|
127
|
+
if (typeof v === "string") {
|
|
128
|
+
// quote if contains special chars
|
|
129
|
+
if (v === "" || /[:#\n"']|^\s|\s$/.test(v))
|
|
130
|
+
return JSON.stringify(v);
|
|
131
|
+
return v;
|
|
132
|
+
}
|
|
133
|
+
return String(v);
|
|
134
|
+
}
|
|
135
|
+
function stringify(v) {
|
|
136
|
+
if (v === null || v === undefined)
|
|
137
|
+
return "";
|
|
138
|
+
if (typeof v === "string")
|
|
139
|
+
return v;
|
|
140
|
+
if (typeof v === "number" || typeof v === "boolean")
|
|
141
|
+
return String(v);
|
|
142
|
+
return JSON.stringify(v);
|
|
143
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized header construction for all CLI outbound requests (HTTP + WS).
|
|
3
|
+
*
|
|
4
|
+
* 强制注入:
|
|
5
|
+
* - Authorization (HTTP only; WS 走 Sec-WebSocket-Protocol subprotocol)
|
|
6
|
+
* - Accept: application/json (HTTP)
|
|
7
|
+
* - User-Agent: echopai-cli/<v> Node/<v> <platform>-<arch>
|
|
8
|
+
* - X-Client: echopai-cli/<v>
|
|
9
|
+
* - X-Client-Channel: cli | mcp (默认 cli;mcp serve 注入 mcp)
|
|
10
|
+
* - X-Request-Id: client-generated uuidv4 (server 若 echo 同名 header 以 server 为准)
|
|
11
|
+
*
|
|
12
|
+
* 每次构造生成新的 X-Request-Id(除非显式传 requestId)。分页 / WS 重连场景每次
|
|
13
|
+
* 迭代都应该重新调一次 builder,以拿到独立的 request_id。
|
|
14
|
+
*
|
|
15
|
+
* 与 envelope.ts 的关系:本模块只负责出站;入站 / meta 拼装由 envelope.ts 做。
|
|
16
|
+
*/
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { Headers } from "undici";
|
|
19
|
+
export function buildUserAgent(cliVersion) {
|
|
20
|
+
// User-Agent identifier matches X-Client (also `echopai-cli/<v>`) so
|
|
21
|
+
// server-side metrics/audit can match on either header uniformly.
|
|
22
|
+
// Renamed from `@echopai/cli/<v>` in v2.0.0 alongside the npm rename
|
|
23
|
+
// from @echopai/cli → echopai. X-Client itself was already this form.
|
|
24
|
+
return `echopai-cli/${cliVersion} Node/${process.version} ${process.platform}-${process.arch}`;
|
|
25
|
+
}
|
|
26
|
+
export function buildHttpHeaders(ctx) {
|
|
27
|
+
const requestId = ctx.requestId ?? randomUUID();
|
|
28
|
+
const headers = new Headers({
|
|
29
|
+
Authorization: `Bearer ${ctx.bearer}`,
|
|
30
|
+
Accept: "application/json",
|
|
31
|
+
"User-Agent": buildUserAgent(ctx.cliVersion),
|
|
32
|
+
"X-Client": `echopai-cli/${ctx.cliVersion}`,
|
|
33
|
+
"X-Client-Channel": ctx.channel ?? "cli",
|
|
34
|
+
"X-Request-Id": requestId,
|
|
35
|
+
});
|
|
36
|
+
return { headers, requestId };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* WebSocket upgrade headers. NO Authorization — WS auth flows via
|
|
40
|
+
* Sec-WebSocket-Protocol `bearer.<token>` subprotocol.
|
|
41
|
+
*
|
|
42
|
+
* Plain object because `ws` lib's `headers` option is Record<string,string>,
|
|
43
|
+
* not undici Headers.
|
|
44
|
+
*/
|
|
45
|
+
export function buildWsHeaders(ctx) {
|
|
46
|
+
const requestId = ctx.requestId ?? randomUUID();
|
|
47
|
+
const headers = {
|
|
48
|
+
"User-Agent": buildUserAgent(ctx.cliVersion),
|
|
49
|
+
"X-Client": `echopai-cli/${ctx.cliVersion}`,
|
|
50
|
+
"X-Client-Channel": ctx.channel ?? "cli",
|
|
51
|
+
"X-Request-Id": requestId,
|
|
52
|
+
};
|
|
53
|
+
return { headers, requestId };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Effective request_id for meta envelope / logging:
|
|
57
|
+
* server echo wins (server can rewrite for correlation in its own audit);
|
|
58
|
+
* otherwise client-generated uuid is canonical.
|
|
59
|
+
*
|
|
60
|
+
* After Phase 1 lands, both ends will have a request_id even for endpoints
|
|
61
|
+
* the server doesn't echo yet — they just won't agree until server adopts echo.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveRequestId(serverHeader, clientGenerated) {
|
|
64
|
+
return serverHeader && serverHeader.length > 0 ? serverHeader : clientGenerated;
|
|
65
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,387 @@
|
|
|
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
|
+
const Ajv = (AjvPkg.default ??
|
|
27
|
+
AjvPkg);
|
|
28
|
+
const addFormats = (addFormatsPkg.default ??
|
|
29
|
+
addFormatsPkg);
|
|
30
|
+
// strict: false 让 Ajv 接受 OpenAPI 3.0 的 `example` / `examples` 关键字(不是
|
|
31
|
+
// 标准 JSON Schema draft 7,但 OpenAPI 普遍用)。
|
|
32
|
+
// coerceTypes: 'array' 允许 --codes SSE:000001 自动包成 ["SSE:000001"],
|
|
33
|
+
// 也允许 --codes "A,B,C" 字符串入 array schema(但用户更常用单值)。
|
|
34
|
+
const ajv = new Ajv({
|
|
35
|
+
allErrors: true,
|
|
36
|
+
useDefaults: true,
|
|
37
|
+
coerceTypes: "array",
|
|
38
|
+
strict: false,
|
|
39
|
+
});
|
|
40
|
+
addFormats(ajv);
|
|
41
|
+
const SYSTEM_FLAGS = new Set([
|
|
42
|
+
"output",
|
|
43
|
+
"all",
|
|
44
|
+
"key",
|
|
45
|
+
"profile",
|
|
46
|
+
"debug",
|
|
47
|
+
"raw",
|
|
48
|
+
"idempotency_key",
|
|
49
|
+
"max_pages",
|
|
50
|
+
"max_items",
|
|
51
|
+
// PR 1.4 — output filter pipeline (renamed root --query → --jq in Phase 6
|
|
52
|
+
// smoke-fix because subcommand specs declare their own --query as a body
|
|
53
|
+
// param, e.g. news.search. Stripping "query" here would eat that param.)
|
|
54
|
+
"jq",
|
|
55
|
+
"fields",
|
|
56
|
+
"max_bytes",
|
|
57
|
+
// Phase 6 — write safety + dry-run
|
|
58
|
+
"yes",
|
|
59
|
+
"dry_run",
|
|
60
|
+
]);
|
|
61
|
+
export async function invoke(op, args, ctx) {
|
|
62
|
+
const isTty = process.stdout.isTTY === true && !process.env.CI;
|
|
63
|
+
// 1. resolve --output
|
|
64
|
+
let outputFmt = op.outputDefault;
|
|
65
|
+
if (typeof args.output === "string" && isOutputFormat(args.output)) {
|
|
66
|
+
outputFmt = args.output;
|
|
67
|
+
}
|
|
68
|
+
const debug = Boolean(args.debug);
|
|
69
|
+
const raw = Boolean(args.raw);
|
|
70
|
+
// 2. resolve credentials
|
|
71
|
+
const resolveOpts = {};
|
|
72
|
+
if (typeof args.key === "string")
|
|
73
|
+
resolveOpts.key = args.key;
|
|
74
|
+
if (typeof args.profile === "string")
|
|
75
|
+
resolveOpts.profile = args.profile;
|
|
76
|
+
let creds;
|
|
77
|
+
try {
|
|
78
|
+
creds = resolveCredentials(resolveOpts);
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
if (e instanceof AuthMissingError) {
|
|
82
|
+
trace(op, { exit_code: 1, error_code: "auth_missing" });
|
|
83
|
+
writeError("auth_missing", e.message, 1, e.recovery_hint ? { recovery_hint: e.recovery_hint } : undefined);
|
|
84
|
+
}
|
|
85
|
+
throw e;
|
|
86
|
+
}
|
|
87
|
+
// 3. strip system flags + undefined to get actual API params
|
|
88
|
+
const apiParams = {};
|
|
89
|
+
for (const [k, v] of Object.entries(args)) {
|
|
90
|
+
if (SYSTEM_FLAGS.has(k))
|
|
91
|
+
continue;
|
|
92
|
+
if (v === undefined || v === "")
|
|
93
|
+
continue;
|
|
94
|
+
apiParams[k] = v;
|
|
95
|
+
}
|
|
96
|
+
// 4a. coerce CSV → array for params declared as array in schema (Ajv coerce
|
|
97
|
+
// wraps single-string as 1-element array, but for "A,B,C" we want split-on-comma).
|
|
98
|
+
for (const [pname, pschema] of Object.entries(op.inputSchema.properties)) {
|
|
99
|
+
if (typeof pschema === "object" &&
|
|
100
|
+
pschema !== null &&
|
|
101
|
+
pschema.type === "array" &&
|
|
102
|
+
typeof apiParams[pname] === "string" &&
|
|
103
|
+
apiParams[pname].includes(",")) {
|
|
104
|
+
apiParams[pname] = apiParams[pname].split(",").map((s) => s.trim());
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// 4b. Ajv pre-flight schema validation
|
|
108
|
+
const validate = ajv.compile(op.inputSchema);
|
|
109
|
+
if (!validate(apiParams)) {
|
|
110
|
+
const errs = (validate.errors || [])
|
|
111
|
+
.map((e) => `${e.instancePath || "(root)"}: ${e.message ?? "invalid"}`)
|
|
112
|
+
.join("; ");
|
|
113
|
+
trace(op, { exit_code: 1, error_code: "invalid_args" });
|
|
114
|
+
writeError("invalid_args", `Input validation failed: ${errs}`, 1);
|
|
115
|
+
}
|
|
116
|
+
// 4c. Phase 6 — write safety + dry-run gates
|
|
117
|
+
//
|
|
118
|
+
// Two CLI-side preflight checks, both fail-closed:
|
|
119
|
+
//
|
|
120
|
+
// a) non-TTY write without --yes → confirmation_required
|
|
121
|
+
// Agents/scripts can never accidentally trigger a side-effect; they
|
|
122
|
+
// must opt in explicitly. TTY users get the usual interactive flow
|
|
123
|
+
// (commander itself prompts? no — we keep this simple: TTY skips the
|
|
124
|
+
// gate and assumes the user typed the command knowingly).
|
|
125
|
+
//
|
|
126
|
+
// b) --dry-run on op where dryRunSupported=false → dry_run_unsupported
|
|
127
|
+
// Refuse to silently noop. The agent gets a structured error and
|
|
128
|
+
// knows to retry without --dry-run (or pick a different op).
|
|
129
|
+
//
|
|
130
|
+
// When --dry-run is honored we set X-Dry-Run:1 in the outgoing headers
|
|
131
|
+
// further down (step 5c). Read ops with --dry-run are a noop client-side
|
|
132
|
+
// (they don't mutate anyway) — we treat them as a soft success but stamp
|
|
133
|
+
// meta.dry_run=true so callers know nothing happened.
|
|
134
|
+
const yes = Boolean(args.yes);
|
|
135
|
+
const dryRun = Boolean(args.dry_run);
|
|
136
|
+
if (op.sideEffect === "write" && !isTty && !yes) {
|
|
137
|
+
trace(op, { exit_code: 1, error_code: "confirmation_required" });
|
|
138
|
+
writeError("confirmation_required", `Operation ${op.cliKey} is a write (side-effect=write) and stdout is not a TTY. Pass --yes to confirm.`, 1);
|
|
139
|
+
}
|
|
140
|
+
if (dryRun && !op.dryRunSupported && op.sideEffect === "write") {
|
|
141
|
+
trace(op, { exit_code: 1, error_code: "dry_run_unsupported" });
|
|
142
|
+
writeError("dry_run_unsupported", `Operation ${op.cliKey} does not support server-side dry-run (X-Dry-Run).`, 1);
|
|
143
|
+
}
|
|
144
|
+
// 5. build URL + query string
|
|
145
|
+
let url = creds.baseUrl + op.path;
|
|
146
|
+
// path templating: {id} → URL-encoded value, removed from query
|
|
147
|
+
const pathParamRegex = /\{([^}]+)\}/g;
|
|
148
|
+
url = url.replace(pathParamRegex, (_full, name) => {
|
|
149
|
+
const v = apiParams[name];
|
|
150
|
+
if (v === undefined) {
|
|
151
|
+
trace(op, { exit_code: 1, error_code: "invalid_args" });
|
|
152
|
+
writeError("invalid_args", `Path parameter '${name}' is required.`, 1);
|
|
153
|
+
}
|
|
154
|
+
delete apiParams[name];
|
|
155
|
+
return encodeURIComponent(String(v));
|
|
156
|
+
});
|
|
157
|
+
// 5a. WS streaming endpoint — REMOVED. CLI no longer ships /v1/ws/{news,views}
|
|
158
|
+
// as commands. The OpenAPI ops still exist for partner SDKs but the
|
|
159
|
+
// codegen drops x-cli-key, so this branch is unreachable today and
|
|
160
|
+
// should stay deleted unless the partner-side WS auth model is reworked
|
|
161
|
+
// (see WS removal commit + plan §3 follow-ups).
|
|
162
|
+
// 5b. paginate `--all`
|
|
163
|
+
if (args.all && op.pagination !== "none") {
|
|
164
|
+
try {
|
|
165
|
+
const result = await paginate(op, apiParams, {
|
|
166
|
+
baseUrl: creds.baseUrl,
|
|
167
|
+
bearer: creds.key,
|
|
168
|
+
cliVersion: ctx.cliVersion,
|
|
169
|
+
debug,
|
|
170
|
+
...(typeof args.max_pages === "string"
|
|
171
|
+
? { maxPages: Number(args.max_pages) }
|
|
172
|
+
: typeof args.max_pages === "number"
|
|
173
|
+
? { maxPages: args.max_pages }
|
|
174
|
+
: {}),
|
|
175
|
+
...(typeof args.max_items === "string"
|
|
176
|
+
? { maxItems: Number(args.max_items) }
|
|
177
|
+
: typeof args.max_items === "number"
|
|
178
|
+
? { maxItems: args.max_items }
|
|
179
|
+
: {}),
|
|
180
|
+
});
|
|
181
|
+
await writeStderr(`[paginate] ${result.pages} pages, ${result.items} items\n`);
|
|
182
|
+
trace(op, { exit_code: 0 });
|
|
183
|
+
process.exit(0);
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
if (e instanceof CallApiError) {
|
|
187
|
+
const exitCode = exitCodeForPaginateError(e);
|
|
188
|
+
trace(op, {
|
|
189
|
+
request_id: e.requestId,
|
|
190
|
+
status: e.httpStatus,
|
|
191
|
+
exit_code: exitCode,
|
|
192
|
+
error_code: e.code,
|
|
193
|
+
});
|
|
194
|
+
writeError(e.code, e.message, exitCode, {
|
|
195
|
+
retryable: e.retryable,
|
|
196
|
+
...(e.recovery_hint ? { recovery_hint: e.recovery_hint } : {}),
|
|
197
|
+
...(e.requestId ? { request_id: e.requestId } : {}),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
trace(op, { exit_code: 2, error_code: "internal_error" });
|
|
201
|
+
writeError("internal_error", e instanceof Error ? e.message : String(e), 2);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// 5c. single-shot HTTP request
|
|
205
|
+
const queryString = buildQueryString(apiParams);
|
|
206
|
+
if (op.method === "GET" && queryString)
|
|
207
|
+
url += "?" + queryString;
|
|
208
|
+
const { headers, requestId: clientRequestId } = buildHttpHeaders({
|
|
209
|
+
bearer: creds.key,
|
|
210
|
+
cliVersion: ctx.cliVersion,
|
|
211
|
+
});
|
|
212
|
+
// Idempotency-Key for endpoints that require it
|
|
213
|
+
if (op.idempotencyRequired) {
|
|
214
|
+
const userKey = typeof args.idempotency_key === "string" ? args.idempotency_key : null;
|
|
215
|
+
const idemKey = userKey || generateIdempotencyKey();
|
|
216
|
+
headers.set("Idempotency-Key", idemKey);
|
|
217
|
+
if (!userKey)
|
|
218
|
+
announceKey(idemKey, op.cliKey);
|
|
219
|
+
}
|
|
220
|
+
// X-Dry-Run: only set on writes where the server explicitly advertises
|
|
221
|
+
// support (op.dryRunSupported). Read ops with --dry-run flow through
|
|
222
|
+
// unchanged — they're a noop server-side, so there's nothing to dry-run.
|
|
223
|
+
if (dryRun && op.dryRunSupported) {
|
|
224
|
+
headers.set("X-Dry-Run", "1");
|
|
225
|
+
}
|
|
226
|
+
const init = {
|
|
227
|
+
method: op.method,
|
|
228
|
+
headers,
|
|
229
|
+
};
|
|
230
|
+
if (op.method === "POST") {
|
|
231
|
+
headers.set("Content-Type", "application/json");
|
|
232
|
+
init.body = JSON.stringify(apiParams);
|
|
233
|
+
}
|
|
234
|
+
if (debug) {
|
|
235
|
+
process.stderr.write(`> ${op.method} ${url}\n` +
|
|
236
|
+
`> Authorization: Bearer eps_live_***_***\n` +
|
|
237
|
+
Array.from(headers.entries())
|
|
238
|
+
.filter(([k]) => k.toLowerCase() !== "authorization")
|
|
239
|
+
.map(([k, v]) => `> ${k}: ${v}`)
|
|
240
|
+
.join("\n") +
|
|
241
|
+
"\n");
|
|
242
|
+
}
|
|
243
|
+
// 6. fire request
|
|
244
|
+
const startedAt = Date.now();
|
|
245
|
+
let res;
|
|
246
|
+
try {
|
|
247
|
+
res = await fetch(url, init);
|
|
248
|
+
}
|
|
249
|
+
catch (e) {
|
|
250
|
+
trace(op, { exit_code: 2, error_code: "network_error" });
|
|
251
|
+
writeError("network_error", e instanceof Error ? e.message : String(e), 2);
|
|
252
|
+
}
|
|
253
|
+
const requestId = resolveRequestId(res.headers.get("x-request-id"), clientRequestId);
|
|
254
|
+
const apiVersion = res.headers.get("x-api-version") || undefined;
|
|
255
|
+
const ct = res.headers.get("content-type") || "";
|
|
256
|
+
const bodyText = await res.text();
|
|
257
|
+
let bodyJson = null;
|
|
258
|
+
if (ct.includes("application/json") && bodyText) {
|
|
259
|
+
try {
|
|
260
|
+
bodyJson = JSON.parse(bodyText);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// body 不是 JSON
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const durationMs = Date.now() - startedAt;
|
|
267
|
+
if (debug) {
|
|
268
|
+
process.stderr.write(`< ${res.status} ${res.statusText}\n`);
|
|
269
|
+
if (requestId)
|
|
270
|
+
process.stderr.write(`< x-request-id: ${requestId}\n`);
|
|
271
|
+
}
|
|
272
|
+
// 7. error path
|
|
273
|
+
if (res.status >= 400) {
|
|
274
|
+
const apiErr = tryParseErrorEnvelope(bodyJson, res.status, requestId);
|
|
275
|
+
const exitCode = exitCodeForStatus(res.status);
|
|
276
|
+
const code = apiErr?.code ?? "http_error";
|
|
277
|
+
trace(op, {
|
|
278
|
+
request_id: requestId,
|
|
279
|
+
status: res.status,
|
|
280
|
+
duration_ms: durationMs,
|
|
281
|
+
exit_code: exitCode,
|
|
282
|
+
error_code: code,
|
|
283
|
+
});
|
|
284
|
+
if (apiErr) {
|
|
285
|
+
writeError(apiErr.code, apiErr.message, exitCode, {
|
|
286
|
+
retryable: apiErr.retryable,
|
|
287
|
+
...(apiErr.recovery_hint ? { recovery_hint: apiErr.recovery_hint } : {}),
|
|
288
|
+
request_id: requestId,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
writeError("http_error", `HTTP ${res.status} ${res.statusText}: ${bodyText.slice(0, 200)}`, exitCode, { request_id: requestId });
|
|
292
|
+
}
|
|
293
|
+
// 8. success path — render
|
|
294
|
+
if (raw) {
|
|
295
|
+
await writeStdout(bodyText + "\n");
|
|
296
|
+
process.exit(0);
|
|
297
|
+
}
|
|
298
|
+
const rawEnvelope = buildResponseEnvelope(bodyJson ?? bodyText, {
|
|
299
|
+
requestId,
|
|
300
|
+
endpoint: op.path,
|
|
301
|
+
method: op.method,
|
|
302
|
+
cliVersion: ctx.cliVersion,
|
|
303
|
+
durationMs,
|
|
304
|
+
...(apiVersion ? { apiVersion } : {}),
|
|
305
|
+
});
|
|
306
|
+
let envelope = rawEnvelope;
|
|
307
|
+
try {
|
|
308
|
+
// Global JMESPath filter is `--jq` (renamed from --query in Phase 6
|
|
309
|
+
// smoke-fix to avoid clashing with subcommand `--query` body params,
|
|
310
|
+
// e.g. news.search). Do NOT fall back to args.query here — that's the
|
|
311
|
+
// subcommand body field, not a JMESPath expression.
|
|
312
|
+
const jq = typeof args.jq === "string" ? args.jq : undefined;
|
|
313
|
+
envelope = applyFilters(rawEnvelope, {
|
|
314
|
+
...(jq ? { query: jq } : {}),
|
|
315
|
+
...(parseFieldsFlag(args.fields) ? { fields: parseFieldsFlag(args.fields) } : {}),
|
|
316
|
+
...(parseMaxBytesFlag(args.max_bytes) ? { maxBytes: parseMaxBytesFlag(args.max_bytes) } : {}),
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
catch (e) {
|
|
320
|
+
if (e instanceof FilterError) {
|
|
321
|
+
trace(op, {
|
|
322
|
+
request_id: requestId,
|
|
323
|
+
status: res.status,
|
|
324
|
+
duration_ms: durationMs,
|
|
325
|
+
exit_code: 1,
|
|
326
|
+
error_code: "invalid_args",
|
|
327
|
+
});
|
|
328
|
+
writeError("invalid_args", e.message, 1);
|
|
329
|
+
}
|
|
330
|
+
throw e;
|
|
331
|
+
}
|
|
332
|
+
await writeStdout(render(envelope, outputFmt, isTty) + "\n");
|
|
333
|
+
trace(op, {
|
|
334
|
+
request_id: requestId,
|
|
335
|
+
status: res.status,
|
|
336
|
+
duration_ms: durationMs,
|
|
337
|
+
exit_code: 0,
|
|
338
|
+
truncated: envelope.meta.truncated === true,
|
|
339
|
+
});
|
|
340
|
+
process.exit(0);
|
|
341
|
+
}
|
|
342
|
+
function buildQueryString(params) {
|
|
343
|
+
const parts = [];
|
|
344
|
+
for (const [k, v] of Object.entries(params)) {
|
|
345
|
+
if (v === undefined || v === null)
|
|
346
|
+
continue;
|
|
347
|
+
if (Array.isArray(v)) {
|
|
348
|
+
// edge-gateway expects ?codes=A,B not ?codes=A&codes=B
|
|
349
|
+
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v.join(","))}`);
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return parts.join("&");
|
|
356
|
+
}
|
|
357
|
+
function trace(op, t) {
|
|
358
|
+
appendTrace({
|
|
359
|
+
ts: new Date().toISOString(),
|
|
360
|
+
request_id: t.request_id ?? null,
|
|
361
|
+
cmd: op.cliKey,
|
|
362
|
+
status: t.status ?? null,
|
|
363
|
+
duration_ms: t.duration_ms ?? null,
|
|
364
|
+
exit_code: t.exit_code,
|
|
365
|
+
...(t.truncated ? { truncated: true } : {}),
|
|
366
|
+
...(t.error_code ? { error_code: t.error_code } : {}),
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
function writeError(code, message, exitCode, extras) {
|
|
370
|
+
const retryable = extras?.retryable ?? isRetryableByCode(code);
|
|
371
|
+
const hint = resolveRecoveryHint(code, extras?.recovery_hint);
|
|
372
|
+
const env = {
|
|
373
|
+
error: { code, message, retryable },
|
|
374
|
+
};
|
|
375
|
+
if (hint)
|
|
376
|
+
env.error.recovery_hint = hint;
|
|
377
|
+
if (extras?.request_id)
|
|
378
|
+
env.error.request_id = extras.request_id;
|
|
379
|
+
// TTY: 多行人性化输出 (color + recovery_hint 高亮);非 TTY 输出 single-line JSON。
|
|
380
|
+
if (isTtyHuman) {
|
|
381
|
+
process.stderr.write(renderError(env) + "\n");
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
process.stderr.write(JSON.stringify(env) + "\n");
|
|
385
|
+
}
|
|
386
|
+
process.exit(exitCode);
|
|
387
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
}
|