gangtise-openapi-cli 0.20.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,14 +65,19 @@ function renderTable(rows) {
42
65
  return "(empty)";
43
66
  }
44
67
  const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
45
- const widths = columns.map((column) => {
46
- const cellWidths = rows.map((row) => formatScalar(row[column]).length);
47
- return Math.max(column.length, ...cellWidths);
48
- });
49
- const renderLine = (values) => values.map((value, index) => value.padEnd(widths[index], " ")).join(" ");
50
- const header = renderLine(columns);
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):
71
+ // spreading a per-row array overflows the call stack on large results (table is
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);
51
79
  const divider = renderLine(widths.map((width) => "-".repeat(width)));
52
- const body = rows.map((row) => renderLine(columns.map((column) => formatScalar(row[column]))));
80
+ const body = matrix.map((cells) => renderLine(cells));
53
81
  return [header, divider, ...body].join("\n");
54
82
  }
55
83
  function renderMarkdown(rows) {
@@ -57,9 +85,11 @@ function renderMarkdown(rows) {
57
85
  return "(empty)";
58
86
  }
59
87
  const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
60
- 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(" | ")} |`;
61
91
  const divider = `| ${columns.map(() => "---").join(" | ")} |`;
62
- const body = rows.map((row) => `| ${columns.map((column) => formatScalar(row[column]).replaceAll("|", "\\|")).join(" | ")} |`);
92
+ const body = rows.map((row) => `| ${columns.map((column) => sanitizeCell(formatScalar(row[column])).replaceAll("|", "\\|")).join(" | ")} |`);
63
93
  return [header, divider, ...body].join("\n");
64
94
  }
65
95
  function renderCsv(rows) {
@@ -67,7 +97,7 @@ function renderCsv(rows) {
67
97
  return "";
68
98
  }
69
99
  const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
70
- const header = columns.join(",");
100
+ const header = columns.map(csvEscape).join(",");
71
101
  const body = rows.map((row) => columns.map((column) => csvEscape(formatScalar(row[column]))).join(","));
72
102
  return [header, ...body].join("\n");
73
103
  }
@@ -96,16 +126,30 @@ export function renderOutput(value, format) {
96
126
  export async function streamOutputToFile(value, format, outputPath) {
97
127
  if (format !== "jsonl" && format !== "csv")
98
128
  return false;
99
- const list = pickListForStreaming(value);
129
+ const list = pickList(value);
100
130
  if (!list)
101
131
  return false;
102
132
  // Below this row count the join() approach is cheaper than per-row writes.
103
133
  if (list.length < 1000)
104
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
+ }
105
145
  const { dirname } = await import("node:path");
106
146
  const { createWriteStream } = await import("node:fs");
107
147
  await fs.mkdir(dirname(outputPath), { recursive: true });
108
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", () => { });
109
153
  try {
110
154
  if (format === "jsonl") {
111
155
  for (const item of list) {
@@ -113,23 +157,28 @@ export async function streamOutputToFile(value, format, outputPath) {
113
157
  }
114
158
  }
115
159
  else {
116
- const objectRows = list.filter((row) => Boolean(row && typeof row === "object" && !Array.isArray(row)));
117
- const columns = Array.from(new Set(objectRows.flatMap((row) => Object.keys(row))));
118
- await writeLine(stream, columns.join(","));
119
- for (const row of objectRows) {
120
- 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])));
121
164
  await writeLine(stream, cells.join(","));
122
165
  }
123
166
  }
124
- }
125
- finally {
126
167
  await new Promise((resolve, reject) => {
127
168
  stream.end((err) => err ? reject(err) : resolve());
128
169
  });
129
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
+ }
130
177
  return true;
131
178
  }
132
- function pickListForStreaming(value) {
179
+ /** Extract a row array from a value: the array itself, or its `.list` property,
180
+ * else null. Shared by streaming, printer's title-cache, and list detection. */
181
+ export function pickList(value) {
133
182
  if (Array.isArray(value))
134
183
  return value;
135
184
  if (value && typeof value === "object") {
@@ -146,17 +195,23 @@ function csvEscape(value) {
146
195
  // so values like "-3.5" stay numeric for Excel/pandas.
147
196
  if (/^[=@\t\r]/.test(out) || (/^[+\-]/.test(out) && !Number.isFinite(Number(out))))
148
197
  out = "'" + out;
149
- if (/[",\n]/.test(out))
198
+ if (/[",\n\r]/.test(out))
150
199
  return `"${out.replaceAll("\"", "\"\"")}"`;
151
200
  return out;
152
201
  }
153
202
  function writeLine(stream, line) {
154
203
  return new Promise((resolve, reject) => {
155
204
  const ok = stream.write(line + "\n", (err) => err ? reject(err) : undefined);
156
- if (ok)
205
+ if (ok) {
157
206
  resolve();
158
- else
159
- 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);
160
215
  });
161
216
  }
162
217
  export async function saveOutputIfNeeded(content, outputPath) {
@@ -164,7 +219,7 @@ export async function saveOutputIfNeeded(content, outputPath) {
164
219
  return;
165
220
  }
166
221
  const { dirname } = await import("node:path");
167
- await (await import("node:fs/promises")).mkdir(dirname(outputPath), { recursive: true });
222
+ await fs.mkdir(dirname(outputPath), { recursive: true });
168
223
  if (typeof content === "string") {
169
224
  await fs.writeFile(outputPath, content, "utf8");
170
225
  return;
@@ -1,13 +1,9 @@
1
1
  import { normalizeRows } from "./normalize.js";
2
- import { renderOutput, saveOutputIfNeeded, streamOutputToFile } from "./output.js";
2
+ import { pickList, renderOutput, saveOutputIfNeeded, streamOutputToFile } from "./output.js";
3
3
  import { extractTitles, writeTitleCache } from "./titleCache.js";
4
4
  export async function printData(data, format, output, cache) {
5
5
  const normalized = normalizeRows(data);
6
- const items = Array.isArray(normalized)
7
- ? normalized
8
- : (normalized && typeof normalized === "object" && Array.isArray(normalized.list))
9
- ? normalized.list
10
- : null;
6
+ const items = pickList(normalized);
11
7
  if (cache && items) {
12
8
  const titles = extractTitles(items, cache);
13
9
  if (Object.keys(titles).length > 0) {
@@ -20,6 +16,12 @@ export async function printData(data, format, output, cache) {
20
16
  const listLen = Array.isArray(meta.list) ? meta.list.length : 0;
21
17
  process.stderr.write(`Total: ${meta.total}, showing: ${listLen}\n`);
22
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
+ }
23
25
  }
24
26
  if (output) {
25
27
  if (await streamOutputToFile(normalized, format, output)) {
@@ -27,7 +29,9 @@ export async function printData(data, format, output, cache) {
27
29
  return;
28
30
  }
29
31
  const content = renderOutput(normalized, format);
30
- 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);
31
35
  process.stdout.write(`${output}\n`);
32
36
  return;
33
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
  }
@@ -93,8 +97,11 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
93
97
  header = rec;
94
98
  if (!fieldList && Array.isArray(rec.fieldList))
95
99
  fieldList = rec.fieldList;
100
+ // Append one-by-one rather than push(...list): a future higher row cap could
101
+ // make a single shard's list large enough to overflow the stack via spread.
96
102
  if (Array.isArray(rec.list))
97
- merged.push(...rec.list);
103
+ for (const item of rec.list)
104
+ merged.push(item);
98
105
  }
99
106
  // Every shard failed → surface the error loudly (non-zero exit) rather than
100
107
  // masking a total outage as an empty success.
@@ -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.20.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.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "CLI for Gangtise OpenAPI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,6 +26,7 @@
26
26
  "build": "tsc -p tsconfig.json",
27
27
  "dev": "tsx src/cli.ts",
28
28
  "test": "vitest run",
29
+ "typecheck": "tsc --noEmit -p tsconfig.json",
29
30
  "prepare": "node scripts/prepare.cjs && npm run build"
30
31
  },
31
32
  "engines": {
@@ -36,9 +37,9 @@
36
37
  "undici": "^7.16.0"
37
38
  },
38
39
  "devDependencies": {
39
- "@types/node": "^24.3.0",
40
+ "@types/node": "^20.19.43",
40
41
  "tsx": "^4.20.5",
41
42
  "typescript": "^5.9.2",
42
- "vitest": "^3.2.4"
43
+ "vitest": "^3.2.6"
43
44
  }
44
45
  }