gangtise-openapi-cli 0.21.0 → 0.22.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.
@@ -8,6 +8,26 @@ export function parseOutputFormat(value) {
8
8
  }
9
9
  throw new ConfigError(`Unsupported format: ${format}`);
10
10
  }
11
+ /** Cell text for terminal-facing formats (table/markdown): newlines collapsed for
12
+ * alignment, remaining C0/DEL control chars stripped so server data can't inject
13
+ * terminal escape sequences (ESC[31m etc.) into the user's terminal. */
14
+ function sanitizeCell(value) {
15
+ return value.replace(/[\r\n]+/g, " ").replace(/[\u0000-\u001f\u007f]/g, "");
16
+ }
17
+ /** Terminal display width: CJK/fullwidth chars occupy 2 columns — padEnd counts
18
+ * UTF-16 code units and misaligns every table containing Chinese text. */
19
+ function displayWidth(value) {
20
+ let width = 0;
21
+ for (const ch of value) {
22
+ const cp = ch.codePointAt(0);
23
+ const wide = (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf)
24
+ || (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff)
25
+ || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60)
26
+ || (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x20000 && cp <= 0x3fffd);
27
+ width += wide ? 2 : 1;
28
+ }
29
+ return width;
30
+ }
11
31
  function formatScalar(value) {
12
32
  if (value === null || value === undefined) {
13
33
  return "";
@@ -17,21 +37,24 @@ function formatScalar(value) {
17
37
  }
18
38
  return String(value);
19
39
  }
40
+ /** Rows for a list: object rows as-is with stray null/scalar rows dropped (one bad
41
+ * row must not degrade the whole table to index/value pairs — and the streaming CSV
42
+ * path already skips them, so both paths agree). A list with NO object rows at all
43
+ * (e.g. plain string codes) still renders as index/value pairs. */
44
+ function rowsFromList(list) {
45
+ const objectRows = list.filter((item) => Boolean(item && typeof item === "object" && !Array.isArray(item)));
46
+ if (objectRows.length > 0)
47
+ return objectRows;
48
+ return list.map((item, index) => ({ index, value: item }));
49
+ }
20
50
  function toRows(value) {
21
51
  if (Array.isArray(value)) {
22
- if (value.every((item) => item && typeof item === "object" && !Array.isArray(item))) {
23
- return value;
24
- }
25
- return value.map((item, index) => ({ index, value: item }));
52
+ return rowsFromList(value);
26
53
  }
27
54
  if (value && typeof value === "object") {
28
55
  const record = value;
29
56
  if (Array.isArray(record.list)) {
30
- const list = record.list;
31
- if (list.every((item) => item && typeof item === "object" && !Array.isArray(item))) {
32
- return list;
33
- }
34
- return list.map((item, index) => ({ index, value: item }));
57
+ return rowsFromList(record.list);
35
58
  }
36
59
  return [record];
37
60
  }
@@ -42,15 +65,17 @@ function renderTable(rows) {
42
65
  return "(empty)";
43
66
  }
44
67
  const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
45
- // Format every cell once (formatScalar may JSON.stringify objects), collapsing
46
- // newlines so multi-line fields don't break alignment. Reuse the matrix for both
47
- // width and rendering — and compute widths with reduce, NOT Math.max(...arr):
68
+ // Format every cell once (formatScalar may JSON.stringify objects), sanitizing
69
+ // control chars so multi-line fields don't break alignment. Reuse the matrix for
70
+ // both width and rendering — and compute widths with reduce, NOT Math.max(...arr):
48
71
  // spreading a per-row array overflows the call stack on large results (table is
49
- // the default format, e.g. `quote day-kline --security all`).
50
- const matrix = rows.map((row) => columns.map((column) => formatScalar(row[column]).replace(/[\r\n]+/g, " ")));
51
- const widths = columns.map((column, c) => matrix.reduce((max, cells) => Math.max(max, cells[c].length), column.length));
52
- const renderLine = (values) => values.map((value, index) => value.padEnd(widths[index], " ")).join(" ");
53
- const header = renderLine(columns);
72
+ // the default format, e.g. `quote day-kline --security all`). Widths and padding
73
+ // use displayWidth so CJK cells stay aligned.
74
+ const headerCells = columns.map(sanitizeCell);
75
+ const matrix = rows.map((row) => columns.map((column) => sanitizeCell(formatScalar(row[column]))));
76
+ const widths = columns.map((_, c) => matrix.reduce((max, cells) => Math.max(max, displayWidth(cells[c])), displayWidth(headerCells[c])));
77
+ const renderLine = (values) => values.map((value, index) => value + " ".repeat(Math.max(0, widths[index] - displayWidth(value)))).join(" ");
78
+ const header = renderLine(headerCells);
54
79
  const divider = renderLine(widths.map((width) => "-".repeat(width)));
55
80
  const body = matrix.map((cells) => renderLine(cells));
56
81
  return [header, divider, ...body].join("\n");
@@ -60,9 +85,11 @@ function renderMarkdown(rows) {
60
85
  return "(empty)";
61
86
  }
62
87
  const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
63
- const header = `| ${columns.join(" | ")} |`;
88
+ // Column names come from server data (e.g. EDE indicator display names) — escape
89
+ // them like cell values or a name containing | / , breaks the whole table.
90
+ const header = `| ${columns.map((column) => sanitizeCell(column).replaceAll("|", "\\|")).join(" | ")} |`;
64
91
  const divider = `| ${columns.map(() => "---").join(" | ")} |`;
65
- const body = rows.map((row) => `| ${columns.map((column) => formatScalar(row[column]).replace(/[\r\n]+/g, " ").replaceAll("|", "\\|")).join(" | ")} |`);
92
+ const body = rows.map((row) => `| ${columns.map((column) => sanitizeCell(formatScalar(row[column])).replaceAll("|", "\\|")).join(" | ")} |`);
66
93
  return [header, divider, ...body].join("\n");
67
94
  }
68
95
  function renderCsv(rows) {
@@ -70,7 +97,7 @@ function renderCsv(rows) {
70
97
  return "";
71
98
  }
72
99
  const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
73
- const header = columns.join(",");
100
+ const header = columns.map(csvEscape).join(",");
74
101
  const body = rows.map((row) => columns.map((column) => csvEscape(formatScalar(row[column]))).join(","));
75
102
  return [header, ...body].join("\n");
76
103
  }
@@ -105,10 +132,24 @@ export async function streamOutputToFile(value, format, outputPath) {
105
132
  // Below this row count the join() approach is cheaper than per-row writes.
106
133
  if (list.length < 1000)
107
134
  return false;
135
+ // csv can only stream object rows; an all-scalar list has no columns — fall back
136
+ // to renderOutput's index/value shaping instead of writing a BOM-only file.
137
+ let csvRows = [];
138
+ let csvColumns = [];
139
+ if (format === "csv") {
140
+ csvRows = list.filter((row) => Boolean(row && typeof row === "object" && !Array.isArray(row)));
141
+ if (csvRows.length === 0)
142
+ return false;
143
+ csvColumns = Array.from(new Set(csvRows.flatMap((row) => Object.keys(row))));
144
+ }
108
145
  const { dirname } = await import("node:path");
109
146
  const { createWriteStream } = await import("node:fs");
110
147
  await fs.mkdir(dirname(outputPath), { recursive: true });
111
148
  const stream = createWriteStream(outputPath, { encoding: "utf8" });
149
+ // A stream 'error' with no listener (EACCES on open, ENOSPC mid-write) crashes the
150
+ // process before any write callback fires. Swallow the event here — the failure
151
+ // still surfaces through the write/end callbacks below.
152
+ stream.on("error", () => { });
112
153
  try {
113
154
  if (format === "jsonl") {
114
155
  for (const item of list) {
@@ -116,20 +157,23 @@ export async function streamOutputToFile(value, format, outputPath) {
116
157
  }
117
158
  }
118
159
  else {
119
- const objectRows = list.filter((row) => Boolean(row && typeof row === "object" && !Array.isArray(row)));
120
- const columns = Array.from(new Set(objectRows.flatMap((row) => Object.keys(row))));
121
- await writeLine(stream, columns.join(","));
122
- for (const row of objectRows) {
123
- const cells = columns.map((column) => csvEscape(formatScalar(row[column])));
160
+ // BOM so Excel double-click decodes Chinese as UTF-8 instead of ANSI/GBK.
161
+ await writeLine(stream, "\ufeff" + csvColumns.map(csvEscape).join(","));
162
+ for (const row of csvRows) {
163
+ const cells = csvColumns.map((column) => csvEscape(formatScalar(row[column])));
124
164
  await writeLine(stream, cells.join(","));
125
165
  }
126
166
  }
127
- }
128
- finally {
129
167
  await new Promise((resolve, reject) => {
130
168
  stream.end((err) => err ? reject(err) : resolve());
131
169
  });
132
170
  }
171
+ catch (error) {
172
+ // Mirror the download path: never leave a truncated file that looks complete.
173
+ stream.destroy();
174
+ await fs.unlink(outputPath).catch(() => { });
175
+ throw error;
176
+ }
133
177
  return true;
134
178
  }
135
179
  /** Extract a row array from a value: the array itself, or its `.list` property,
@@ -158,10 +202,16 @@ function csvEscape(value) {
158
202
  function writeLine(stream, line) {
159
203
  return new Promise((resolve, reject) => {
160
204
  const ok = stream.write(line + "\n", (err) => err ? reject(err) : undefined);
161
- if (ok)
205
+ if (ok) {
162
206
  resolve();
163
- else
164
- stream.once("drain", () => resolve());
207
+ return;
208
+ }
209
+ // Waiting only for 'drain' would hang forever if the stream errors instead;
210
+ // race the two and detach the loser so listeners don't pile up per write.
211
+ const onDrain = () => { stream.off("error", onError); resolve(); };
212
+ const onError = (err) => { stream.off("drain", onDrain); reject(err); };
213
+ stream.once("drain", onDrain);
214
+ stream.once("error", onError);
165
215
  });
166
216
  }
167
217
  export async function saveOutputIfNeeded(content, outputPath) {
@@ -16,6 +16,12 @@ export async function printData(data, format, output, cache) {
16
16
  const listLen = Array.isArray(meta.list) ? meta.list.length : 0;
17
17
  process.stderr.write(`Total: ${meta.total}, showing: ${listLen}\n`);
18
18
  }
19
+ // Partial results (failed pages/shards) exit with code 3: the table/csv/jsonl
20
+ // renderers only emit the rows, so without a distinct exit code a script or AI
21
+ // consumer cannot tell a partial export from a complete one.
22
+ if (meta.partial === true) {
23
+ process.exitCode = 3;
24
+ }
19
25
  }
20
26
  if (output) {
21
27
  if (await streamOutputToFile(normalized, format, output)) {
@@ -23,7 +29,9 @@ export async function printData(data, format, output, cache) {
23
29
  return;
24
30
  }
25
31
  const content = renderOutput(normalized, format);
26
- await saveOutputIfNeeded(content, output);
32
+ // CSV files get a BOM so Excel double-click decodes Chinese as UTF-8 (stdout
33
+ // stays BOM-free for pipes).
34
+ await saveOutputIfNeeded(format === "csv" ? `\ufeff${content}` : content, output);
27
35
  process.stdout.write(`${output}\n`);
28
36
  return;
29
37
  }
@@ -1,12 +1,9 @@
1
- import { runWithConcurrency } from "./transport.js";
1
+ import { isVerbose, PAGE_CONCURRENCY, runWithConcurrency } from "./transport.js";
2
2
  const DAY_MS = 86_400_000;
3
3
  /** API-side row cap (per docs). Used to lift the default 6000-row cap on
4
4
  * `--security all` queries so a 2-day A-share shard (~11K rows) isn't
5
5
  * silently truncated. Single-security queries are untouched. */
6
6
  const ALL_MARKET_LIMIT = 10_000;
7
- /** Shard fan-out concurrency. Shares the GANGTISE_PAGE_CONCURRENCY knob with
8
- * pagination (see transport/client) so one env var tunes all request fan-out. */
9
- const SHARD_CONCURRENCY = Number(process.env.GANGTISE_PAGE_CONCURRENCY ?? 5) || 5;
10
7
  function parseDate(value) {
11
8
  // Accept yyyy-MM-dd; reject anything else so we can fall back to a single request.
12
9
  if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
@@ -46,12 +43,17 @@ function buildShards(start, end, shardDays) {
46
43
  * single-security queries this is a no-op.
47
44
  */
48
45
  export async function callKlineWithSharding(client, endpointKey, body, config) {
49
- if (!isAllMarket(body) || !body.startDate || !body.endDate) {
46
+ if (!isAllMarket(body)) {
50
47
  return client.call(endpointKey, body);
51
48
  }
52
49
  // `--security all` returns thousands of rows per day; lift the default 6000-row
53
- // cap to the API max so single-shard requests aren't silently truncated.
50
+ // cap to the API max so single-shard requests aren't silently truncated. This
51
+ // must apply even when a date is missing (no sharding possible then, but the
52
+ // single request still needs the lifted cap).
54
53
  const allMarketBody = { ...body, limit: body.limit ?? ALL_MARKET_LIMIT };
54
+ if (!body.startDate || !body.endDate) {
55
+ return client.call(endpointKey, allMarketBody);
56
+ }
55
57
  const start = parseDate(body.startDate);
56
58
  const end = parseDate(body.endDate);
57
59
  if (!start || !end || end < start) {
@@ -62,7 +64,9 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
62
64
  return client.call(endpointKey, allMarketBody);
63
65
  }
64
66
  const shards = buildShards(start, end, config.shardDays);
65
- if (process.env.GANGTISE_VERBOSE === "1" || process.env.GANGTISE_VERBOSE === "true") {
67
+ // isVerbose() (not a direct env read) so the global --verbose flag reaches
68
+ // shard logging too — cli.ts enables it via setVerbose in a preAction hook.
69
+ if (isVerbose()) {
66
70
  process.stderr.write(`[gangtise] sharding ${endpointKey} into ${shards.length} requests (${config.shardDays} day(s) each)\n`);
67
71
  }
68
72
  // Per-shard fault tolerance: a failing shard is recorded and skipped (returns a
@@ -71,7 +75,7 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
71
75
  // every shard on the first rejection.
72
76
  const failedShards = [];
73
77
  let firstError = null;
74
- const results = await runWithConcurrency(shards, config.concurrency ?? SHARD_CONCURRENCY, async (shard) => {
78
+ const results = await runWithConcurrency(shards, config.concurrency ?? PAGE_CONCURRENCY, async (shard) => {
75
79
  try {
76
80
  return await client.call(endpointKey, { ...allMarketBody, startDate: shard.startDate, endDate: shard.endDate });
77
81
  }
@@ -75,9 +75,11 @@ function capTitles(merged, freshKeys, cap) {
75
75
  }
76
76
  }
77
77
  if (n < cap) {
78
- for (const k of Object.keys(merged)) {
79
- if (n >= cap)
80
- break;
78
+ // Newest-inserted keys sit at the END of the merged object — fill in reverse so
79
+ // eviction drops the oldest entries, not the batch the user just listed yesterday.
80
+ const rest = Object.keys(merged);
81
+ for (let i = rest.length - 1; i >= 0 && n < cap; i--) {
82
+ const k = rest[i];
81
83
  if (k in out)
82
84
  continue;
83
85
  out[k] = merged[k];
@@ -29,6 +29,8 @@ export async function runWithConcurrency(items, concurrency, fn) {
29
29
  await Promise.all(Array.from({ length: limit }, () => worker()));
30
30
  return results;
31
31
  }
32
+ /** Fan-out width for pagination and kline shards — one env knob tunes both. */
33
+ export const PAGE_CONCURRENCY = Number(process.env.GANGTISE_PAGE_CONCURRENCY ?? 5) || 5;
32
34
  const RETRYABLE_HTTP_STATUS = new Set([429, 500, 502, 503, 504]);
33
35
  const RETRYABLE_NETWORK_CODES = new Set(["ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_SOCKET", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
34
36
  const RETRYABLE_API_CODES = new Set(["999999"]);
@@ -1,2 +1,2 @@
1
1
  // Auto-generated — DO NOT EDIT
2
- export const CLI_VERSION = "0.21.0";
2
+ export const CLI_VERSION = "0.22.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gangtise-openapi-cli",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "CLI for Gangtise OpenAPI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,7 +37,7 @@
37
37
  "undici": "^7.16.0"
38
38
  },
39
39
  "devDependencies": {
40
- "@types/node": "^24.3.0",
40
+ "@types/node": "^20.19.43",
41
41
  "tsx": "^4.20.5",
42
42
  "typescript": "^5.9.2",
43
43
  "vitest": "^3.2.6"