gangtise-openapi-cli 0.20.0 → 0.21.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 CHANGED
@@ -4,6 +4,22 @@
4
4
 
5
5
  ## Changelog
6
6
 
7
+ ### v0.21.0 — 2026-06-29
8
+
9
+ **行为变更(注意)**
10
+ - ⚠️ `vault wechat-chatroom-list` 省略 `--size` 现在**拉全量**(此前默认只返回 20 条)。该接口不返回 `total`,CLI 改为串行翻页(翻到不满页为止,单页上限 50);传 `--size N` 仍只取前 N 条。依赖"默认 20 条"的脚本会拿到全部群。
11
+
12
+ **修复**
13
+ - `quote day-kline --security all` 等大结果集用默认 `table` 格式输出时不再因 `Math.max(...大数组)` 撑爆调用栈崩溃(`RangeError`);`renderTable` 改用 reduce 计算列宽
14
+ - CSV 导出:含回车符 `\r` 的字段现在正确加引号(RFC 4180);`table` / `markdown` 的多行字段折叠换行,保持表格对齐
15
+ - 下载文件名剥离控制字符 / NUL,避免 `fs.writeFile` 报错
16
+
17
+ **修复(安全)**
18
+ - token 缓存文件(`~/.config/gangtise/token.json`)改为临时文件 + 原子 `rename` 写入:从第一字节即 `0600`,消除"旧文件宽松权限残留"与"崩溃截断"两个隐患
19
+
20
+ **内部 / 工程**
21
+ - 依赖 `vitest` 升级到 3.2.6(修复 dev-only 安全告警);新增 `npm run typecheck`;测试 257 → 272
22
+
7
23
  ### v0.20.0 — 2026-06-26
8
24
 
9
25
  **新增接口**
@@ -64,21 +80,7 @@
64
80
  - `--industry` 用 `citicIndustry` 码(`1008001xx`,全命令通用);`--research-area` 用 `gangtiseIndustry` 码(行业 `1008001xx` + 宏观/策略/固收/金工/海外等方向 `122000xxx`)。详见 `gangtise-openapi/references/commands/reference-and-lookup.md`
65
81
  - 日程类 `--location`(domesticCity)服务端过滤已生效(v0.16.0 时曾未生效)
66
82
 
67
- ### v0.16.0 — 2026-06-12
68
-
69
- **新增接口(参考数据 · 常量查询,均免积分)**
70
- - `reference constant-category` — 查询常量分类:全量导出常量分类及各分类适用于哪些接口的哪些参数(7 个分类:中信/申万/Gangtise 行业、国内城市、A股/港股公告分类、区域)
71
- - `reference constant-list --category <code>` — 查询常量值:按分类导出全量常量(`constantId` / `constantName`,树形分类含 `children` 嵌套)
72
- - `reference concept-search --keyword <kw>` — 查询题材 ID:按名称/拼音/分组名搜索,返回 `conceptId`(供 `alternative concept-info / concept-securities`、`ai theme-tracking` 使用)
73
- - `reference sector-search --keyword <kw>` — 查询板块 ID:返回 `sectorId` + `hierarchy` 层级路径
74
- - `reference sector-constituents --sector-id <id>` — 查询板块成分股:返回该板块全量成分股(`gtsCode` / `gtsName`);注意 sectorId 必须来自 sector-search,题材 conceptId 查不到成分
75
-
76
- **接口变更(Breaking)**
77
- - 移除已被新 API 覆盖的 6 个本地 lookup 子命令及静态数据:`lookup research-area / industry / region / announcement-category / theme-id / industry-code list`,请改用 `reference constant-list` / `reference concept-search` / `reference sector-constituents`(申万行业代码 `821xxx.SWI` 全量:`sector-constituents --sector-id 2000000014`,即申万一级行业指数板块)
78
- - `lookup` 仅保留 2 个 API 未覆盖的本地表:`broker-org` / `meeting-org`
79
- - 路演/调研/策略会/论坛 list 新增 `--location <id>` 按城市过滤(domesticCity 常量 ID;服务端过滤 v0.17.0 起已生效)
80
-
81
- > 更早版本(v0.15.0 及之前)的完整更新历史见 [GitHub Releases](https://github.com/gangtiser/gangtise-openapi-cli/releases)。
83
+ > 更早版本及完整更新历史见 [CHANGELOG.md](CHANGELOG.md)。
82
84
 
83
85
  ## 首次安装
84
86
 
@@ -131,6 +133,7 @@ export GANGTISE_TIMEOUT_MS=30000 # 请求超时(默认 30s)
131
133
  本项目包含 Skill 定义(`gangtise-openapi/SKILL.md`),可让 AI agent 自动调用 `gangtise` CLI 完成投研数据查询。支持以下 AI 编程助手:
132
134
 
133
135
  - [Claude Code](https://claude.ai/claude-code) — `~/.claude/skills/`
136
+ - [Codex](https://github.com/openai/codex) — `~/.codex/skills/`
134
137
  - [OpenClaw](https://github.com/openclaw/openclaw) — `~/.openclaw/skills/`
135
138
  - [Hermes](https://github.com/nicepkg/hermes) — `~/.hermes/skills/`
136
139
 
@@ -161,6 +164,9 @@ gangtise-openapi/
161
164
  # Claude Code
162
165
  cp -r gangtise-openapi ~/.claude/skills/gangtise-openapi
163
166
 
167
+ # Codex
168
+ cp -r gangtise-openapi ~/.codex/skills/gangtise-openapi
169
+
164
170
  # OpenClaw
165
171
  cp -r gangtise-openapi ~/.openclaw/skills/gangtise-openapi
166
172
 
@@ -321,6 +327,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
321
327
  - `vault record-list`
322
328
  - `vault my-conference-list`
323
329
  - `vault wechat-message-list`
330
+ - `vault wechat-chatroom-list`(特例:接口无 `total`,CLI 串行翻页)
324
331
  - `ai hot-topic`
325
332
 
326
333
  规则:
@@ -330,6 +337,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
330
337
  - `--from` 必须是非负整数,`--size` 必须是正整数;非法数字会在本地直接报 `ValidationError`,不会继续请求 API
331
338
  - 安全上限:自动翻页最多 1000 页,防止异常循环
332
339
  - 分页结果中 `total` 字段会被保留(json 格式输出 `{total, list}`),同时 stderr 输出 `Total: N, showing: M`
340
+ - `vault wechat-chatroom-list` 是特例:接口不返回 `total`,CLI 改为串行翻页——省略 `--size` 拉全量、传 `--size N` 取前 N 条,单页 50,无 `Total:` 提示
333
341
 
334
342
  ## 智能文件命名
335
343
 
package/dist/src/cli.js CHANGED
@@ -471,7 +471,7 @@ addDownloadCommand(vault, { endpointKey: "vault.record.download", name: "record-
471
471
  vault.command("my-conference-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-time <datetime>").option("--end-time <datetime>").option("--keyword <text>").option("--research-area <id>", "Research area ID", collectList, []).option("--security <code>", "Security code", collectList, []).option("--institution <id>", "Institution ID", collectList, []).option("--category <name>", "Conference category: earningsCall/strategyMeeting/fundRoadshow/shareholdersMeeting/maMeeting/specialMeeting/companyAnalysis/industryAnalysis/other", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.my-conference.list", { from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword, researchAreaList: maybeArray(options.researchArea), securityList: maybeArray(options.security), institutionList: maybeArray(options.institution), categoryList: maybeArray(options.category) }), { endpointKey: "vault.my-conference.list", idField: "conferenceId" }));
472
472
  addDownloadCommand(vault, { endpointKey: "vault.my-conference.download", name: "my-conference-download", idOption: "--conference-id", idField: "conferenceId", fallbackPrefix: "conference", contentTypeDescription: "Content type: asr/summary", titleListEndpoint: "vault.my-conference.list" });
473
473
  vault.command("wechat-message-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-time <datetime>").option("--end-time <datetime>").option("--keyword <text>").option("--security <code>", "Security code (e.g. 000001.SZ)", collectList, []).option("--wechat-group-id <id>", "WeChat group ID", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--category <name>", "Message type: text/image/documents/url", collectList, []).option("--tag <name>", "Tag: roadShow/research/strategyMeeting/meetingSummary/industryComment/companyComment/earningsReview", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.wechat-message.list", buildWechatMessageListBody(options))));
474
- vault.command("wechat-chatroom-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Rows to return", "20").option("--room-name <name>", "WeChat group name; repeat or comma-separate for multiple names", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.wechat-chatroom.list", buildWechatChatroomListBody(options))));
474
+ vault.command("wechat-chatroom-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--room-name <name>", "WeChat group name; repeat or comma-separate for multiple names", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.wechat-chatroom.list", buildWechatChatroomListBody(options))));
475
475
  vault.command("stock-pool-list").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.stock-pool.list", {})));
476
476
  vault.command("stock-pool-stocks").option("--pool-id <id>", "Pool ID; repeat for multiple; omit (or 'all') for all pools", collectList).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.stock-pool.stocks", buildStockPoolStocksBody(options))));
477
477
  program.addCommand(vault);
@@ -42,12 +42,12 @@ export function collectNumberList(value, previous = []) {
42
42
  export function collectKeyValue(value, previous = {}) {
43
43
  const index = value.indexOf("=");
44
44
  if (index === -1) {
45
- throw new Error(`Invalid key=value pair: ${value}`);
45
+ throw new ValidationError(`Invalid key=value pair: ${value}`);
46
46
  }
47
47
  const key = value.slice(0, index).trim();
48
48
  const rawValue = value.slice(index + 1).trim();
49
49
  if (!key) {
50
- throw new Error(`Invalid key=value pair: ${value}`);
50
+ throw new ValidationError(`Invalid key=value pair: ${value}`);
51
51
  }
52
52
  return {
53
53
  ...previous,
@@ -1,10 +1,9 @@
1
1
  import { ApiError } from "./errors.js";
2
2
  import { printData } from "./printer.js";
3
+ // 14 attempts with exponential backoff (5s→30s cap) ≈ 316s total wait budget.
3
4
  export const POLL_MAX_ATTEMPTS = 14;
4
5
  const POLL_INITIAL_DELAY_MS = 5_000;
5
6
  const POLL_MAX_DELAY_MS = 30_000;
6
- /** Total wait time stays close to the previous 12*15s=180s budget. */
7
- export const POLL_DELAY_MS = POLL_INITIAL_DELAY_MS;
8
7
  function nextDelayMs(attempt) {
9
8
  // 5s, 8s, 13s, 20s, 30s, 30s, ...
10
9
  const grown = POLL_INITIAL_DELAY_MS * 1.6 ** (attempt - 1);
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import fs from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { ConfigError } from "./errors.js";
@@ -33,7 +34,20 @@ export function redactTokenCache(cache) {
33
34
  }
34
35
  export async function writeTokenCache(filePath, cache) {
35
36
  await fs.mkdir(path.dirname(filePath), { recursive: true });
36
- await fs.writeFile(filePath, JSON.stringify(cache, null, 2), { encoding: "utf8", mode: 0o600 });
37
+ // Write to a fresh 0600 temp file then rename over the target. Writing the bearer
38
+ // token straight to token.json would (a) keep an existing file's lax perms — the
39
+ // `mode` option only applies on creation, so a follow-up chmod still leaves a brief
40
+ // world-readable window — and (b) risk a truncated file on crash. A temp file is
41
+ // 0600 from the first byte and rename is atomic, carrying the 0600 perms over.
42
+ const tmp = `${filePath}.tmp-${randomUUID()}`;
43
+ await fs.writeFile(tmp, JSON.stringify(cache, null, 2), { encoding: "utf8", mode: 0o600 });
44
+ try {
45
+ await fs.rename(tmp, filePath);
46
+ }
47
+ catch (error) {
48
+ await fs.unlink(tmp).catch(() => { });
49
+ throw error;
50
+ }
37
51
  }
38
52
  export function isTokenCacheValid(cache, bufferSeconds = 300) {
39
53
  if (!cache?.accessToken || !cache.expiresAt) {
@@ -244,6 +244,70 @@ export class GangtiseClient {
244
244
  }
245
245
  return out;
246
246
  }
247
+ /**
248
+ * Sequential pagination for endpoints that page by offset but return NO `total`
249
+ * and use a non-standard list key (e.g. wechat chatroom's `chatRoomList`). We
250
+ * can't fan out like requestPaginated (no total ⇒ unknown page count), so page
251
+ * serially until a short page (fewer rows than requested) signals the end.
252
+ * Returns `{ [listKey]: rows }` so normalize/printer treat it like any list.
253
+ */
254
+ async requestSequentialPaginated(endpoint, body) {
255
+ const initialBody = body && typeof body === 'object' ? { ...body } : {};
256
+ if ('from' in initialBody && (typeof initialBody.from !== 'number' || !Number.isFinite(initialBody.from) || initialBody.from < 0)) {
257
+ throw new ValidationError('Invalid from: expected a non-negative number');
258
+ }
259
+ if ('size' in initialBody && initialBody.size !== undefined && (typeof initialBody.size !== 'number' || !Number.isFinite(initialBody.size) || initialBody.size <= 0)) {
260
+ throw new ValidationError('Invalid size: expected a positive number');
261
+ }
262
+ const listKey = endpoint.pagination?.listKey ?? 'list';
263
+ const maxPageSize = endpoint.pagination?.maxPageSize ?? 50;
264
+ const startFrom = typeof initialBody.from === 'number' && Number.isFinite(initialBody.from) ? initialBody.from : 0;
265
+ const requestedSize = typeof initialBody.size === 'number' && Number.isFinite(initialBody.size) ? initialBody.size : undefined;
266
+ const extractList = (page) => {
267
+ if (!page || typeof page !== 'object')
268
+ return null;
269
+ const arr = page[listKey];
270
+ return Array.isArray(arr) ? arr : null;
271
+ };
272
+ const collected = [];
273
+ let firstPage = null;
274
+ let from = startFrom;
275
+ const MAX_PAGES = 1000;
276
+ let truncatedByPageCap = false;
277
+ for (let page = 0;; page++) {
278
+ const remaining = requestedSize === undefined ? maxPageSize : requestedSize - collected.length;
279
+ if (requestedSize !== undefined && remaining <= 0)
280
+ break;
281
+ const size = Math.min(maxPageSize, remaining);
282
+ const pageData = await this.requestJson(endpoint, { ...initialBody, from, size });
283
+ if (firstPage === null)
284
+ firstPage = pageData;
285
+ const list = extractList(pageData);
286
+ if (list === null) {
287
+ // First response isn't a paginated-list shape → return it untouched. But a
288
+ // LATER page losing shape must NOT discard the rows already collected (mirrors
289
+ // requestPaginated's fail-soft fan-out): stop, keep them, and warn loudly.
290
+ if (page === 0)
291
+ return firstPage;
292
+ process.stderr.write(`[gangtise] warning: a page response had unexpected shape; results are partial — ${collected.length} rows fetched.\n`);
293
+ break;
294
+ }
295
+ for (const item of list)
296
+ collected.push(item);
297
+ if (list.length < size)
298
+ break; // short page ⇒ no more rows
299
+ if (page + 1 >= MAX_PAGES) {
300
+ truncatedByPageCap = true;
301
+ break;
302
+ }
303
+ from += list.length;
304
+ }
305
+ if (truncatedByPageCap) {
306
+ process.stderr.write(`[gangtise] warning: hit the ${MAX_PAGES}-page safety cap; fetched ${collected.length} rows. Pass --size to fetch a bounded subset.\n`);
307
+ }
308
+ const rows = requestedSize === undefined ? collected : collected.slice(0, requestedSize);
309
+ return { [listKey]: rows };
310
+ }
247
311
  async login() {
248
312
  const authorization = await this.getAuthorizationHeader();
249
313
  const cache = await readTokenCache(this.config.tokenCachePath);
@@ -411,7 +475,9 @@ export class GangtiseClient {
411
475
  return this.download(endpoint, query ?? {}, options);
412
476
  }
413
477
  if (endpoint.kind === 'json' && endpoint.pagination?.enabled) {
414
- return this.requestPaginated(endpoint, body);
478
+ return endpoint.pagination.sequential
479
+ ? this.requestSequentialPaginated(endpoint, body)
480
+ : this.requestPaginated(endpoint, body);
415
481
  }
416
482
  return this.requestJson(endpoint, body);
417
483
  }
@@ -2,11 +2,12 @@ import { extname } from "node:path";
2
2
  import { DownloadError } from "./errors.js";
3
3
  import { saveOutputIfNeeded } from "./output.js";
4
4
  import { lookupTitleCache, readTitleCache, TITLE_LOOKUP_SIZE } from "./titleCache.js";
5
- /** Replace filesystem-unsafe characters with `_` so a title or a server-supplied
6
- * filename can't create stray subdirectories or escape the intended output path.
5
+ /** Replace filesystem-unsafe characters (path separators, wildcards, and control
6
+ * characters / NUL) with `_` so a title or a server-supplied filename can't create
7
+ * stray subdirectories, escape the intended output path, or break fs.writeFile.
7
8
  * Shared by title-based naming and the download fallback. */
8
9
  function sanitizeFilename(name) {
9
- return name.replace(/[/\\:*?"<>|]/g, "_");
10
+ return name.replace(/[/\\:*?"<>|\u0000-\u001f]/g, "_");
10
11
  }
11
12
  const MIME_EXT = {
12
13
  "application/pdf": ".pdf",
@@ -565,6 +565,8 @@ export const ENDPOINTS = {
565
565
  path: "/application/open-vault/wechatgroupmsg/chatroomId",
566
566
  kind: "json",
567
567
  description: "List WeChat group chatroom IDs",
568
+ // No `total` in the response, list key is `chatRoomList`, server caps size at 50.
569
+ pagination: { enabled: true, maxPageSize: 50, sequential: true, listKey: "chatRoomList" },
568
570
  },
569
571
  "vault.stock-pool.list": {
570
572
  key: "vault.stock-pool.list",
@@ -42,14 +42,17 @@ function renderTable(rows) {
42
42
  return "(empty)";
43
43
  }
44
44
  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
- });
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):
48
+ // 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));
49
52
  const renderLine = (values) => values.map((value, index) => value.padEnd(widths[index], " ")).join(" ");
50
53
  const header = renderLine(columns);
51
54
  const divider = renderLine(widths.map((width) => "-".repeat(width)));
52
- const body = rows.map((row) => renderLine(columns.map((column) => formatScalar(row[column]))));
55
+ const body = matrix.map((cells) => renderLine(cells));
53
56
  return [header, divider, ...body].join("\n");
54
57
  }
55
58
  function renderMarkdown(rows) {
@@ -59,7 +62,7 @@ function renderMarkdown(rows) {
59
62
  const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
60
63
  const header = `| ${columns.join(" | ")} |`;
61
64
  const divider = `| ${columns.map(() => "---").join(" | ")} |`;
62
- const body = rows.map((row) => `| ${columns.map((column) => formatScalar(row[column]).replaceAll("|", "\\|")).join(" | ")} |`);
65
+ const body = rows.map((row) => `| ${columns.map((column) => formatScalar(row[column]).replace(/[\r\n]+/g, " ").replaceAll("|", "\\|")).join(" | ")} |`);
63
66
  return [header, divider, ...body].join("\n");
64
67
  }
65
68
  function renderCsv(rows) {
@@ -96,7 +99,7 @@ export function renderOutput(value, format) {
96
99
  export async function streamOutputToFile(value, format, outputPath) {
97
100
  if (format !== "jsonl" && format !== "csv")
98
101
  return false;
99
- const list = pickListForStreaming(value);
102
+ const list = pickList(value);
100
103
  if (!list)
101
104
  return false;
102
105
  // Below this row count the join() approach is cheaper than per-row writes.
@@ -129,7 +132,9 @@ export async function streamOutputToFile(value, format, outputPath) {
129
132
  }
130
133
  return true;
131
134
  }
132
- function pickListForStreaming(value) {
135
+ /** Extract a row array from a value: the array itself, or its `.list` property,
136
+ * else null. Shared by streaming, printer's title-cache, and list detection. */
137
+ export function pickList(value) {
133
138
  if (Array.isArray(value))
134
139
  return value;
135
140
  if (value && typeof value === "object") {
@@ -146,7 +151,7 @@ function csvEscape(value) {
146
151
  // so values like "-3.5" stay numeric for Excel/pandas.
147
152
  if (/^[=@\t\r]/.test(out) || (/^[+\-]/.test(out) && !Number.isFinite(Number(out))))
148
153
  out = "'" + out;
149
- if (/[",\n]/.test(out))
154
+ if (/[",\n\r]/.test(out))
150
155
  return `"${out.replaceAll("\"", "\"\"")}"`;
151
156
  return out;
152
157
  }
@@ -164,7 +169,7 @@ export async function saveOutputIfNeeded(content, outputPath) {
164
169
  return;
165
170
  }
166
171
  const { dirname } = await import("node:path");
167
- await (await import("node:fs/promises")).mkdir(dirname(outputPath), { recursive: true });
172
+ await fs.mkdir(dirname(outputPath), { recursive: true });
168
173
  if (typeof content === "string") {
169
174
  await fs.writeFile(outputPath, content, "utf8");
170
175
  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) {
@@ -93,8 +93,11 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
93
93
  header = rec;
94
94
  if (!fieldList && Array.isArray(rec.fieldList))
95
95
  fieldList = rec.fieldList;
96
+ // Append one-by-one rather than push(...list): a future higher row cap could
97
+ // make a single shard's list large enough to overflow the stack via spread.
96
98
  if (Array.isArray(rec.list))
97
- merged.push(...rec.list);
99
+ for (const item of rec.list)
100
+ merged.push(item);
98
101
  }
99
102
  // Every shard failed → surface the error loudly (non-zero exit) rather than
100
103
  // masking a total outage as an empty success.
@@ -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.21.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.21.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": {
@@ -39,6 +40,6 @@
39
40
  "@types/node": "^24.3.0",
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
  }