gangtise-openapi-cli 0.34.1 → 0.35.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.
@@ -1,7 +1,5 @@
1
1
  import { ValidationError } from "./errors.js";
2
2
  export function splitCsv(value) {
3
- // Also split on full-width ",": voice-input IMEs produce it, and an unsplit
4
- // "600519,000858" goes to the API as one bogus code with no local hint.
5
3
  return value
6
4
  .split(/[,,]/)
7
5
  .map((item) => item.trim())
@@ -62,9 +60,6 @@ export function collectKeyValue(value, previous = {}) {
62
60
  export function maybeArray(value) {
63
61
  return value.length > 0 ? value : undefined;
64
62
  }
65
- /** True when `latest` is strictly newer than `current` (numeric per-segment
66
- * compare). Plain inequality would nag "update available" during the
67
- * just-published window while the registry still serves the previous version. */
68
63
  export function isVersionNewer(latest, current) {
69
64
  const parse = (v) => v.split(".").map(Number);
70
65
  const a = parse(latest);
@@ -79,9 +74,6 @@ export function isVersionNewer(latest, current) {
79
74
  }
80
75
  return false;
81
76
  }
82
- // Whitelist for enum-valued repeatable options. Only used where the server was
83
- // probed NOT to reject bad values (it silently ignores the filter or returns
84
- // empty instead) — endpoints that answer 100003 keep server-side validation.
85
77
  export function parseChoiceList(values, optionName, allowed) {
86
78
  for (const value of values) {
87
79
  if (!allowed.includes(value)) {
@@ -91,40 +83,10 @@ export function parseChoiceList(values, optionName, allowed) {
91
83
  return maybeArray(values);
92
84
  }
93
85
  const YYYY_MM_DD = /^\d{4}-\d{2}-\d{2}$/;
94
- /**
95
- * Strict `YYYY-MM-DD` guard for Quote/Fundamental date options.
96
- *
97
- * Beyond the documented shape the server accepts two extra year-last formats
98
- * whose day/month order is *opposite* to each other (probed 2026-07-20 on
99
- * quote/kline/daily and fundamental/balance-sheet; other groups sharing these
100
- * flags were not probed, so the guard is applied uniformly as the safe default):
101
- *
102
- * "07/01/2026" -> 2026-01-07 slash reads DD/MM/YYYY
103
- * "07-01-2026" -> 2026-07-01 hyphen reads MM-DD-YYYY
104
- *
105
- * Same three digits, six months apart, both HTTP 200, and nothing in the
106
- * response echoes which date the server actually used. Confirmed by the
107
- * complement: "25/12/2026" parses while "12/25/2026" errors, and the hyphen
108
- * forms behave exactly the other way round. Since the CLI cannot know which
109
- * reading was meant, it forwards only the unambiguous form.
110
- *
111
- * Other unambiguous shapes (`20260701`, `2026/07/01`) are rejected too, even
112
- * though the server handles them — one accepted form beats a per-shape allowlist
113
- * that has to be re-probed per endpoint group. The message says which form is
114
- * wanted rather than claiming the input itself was ambiguous.
115
- *
116
- * Datetime options (`--start-time`) are guarded separately: pass-through ones by
117
- * `parseDatetimeOption` (a timezone-free field check that returns the string as-is),
118
- * and the two conversion endpoints (A-share announcement / knowledge-batch) by
119
- * `parseTimestamp13`.
120
- */
121
86
  export function parseDateOption(value, optionName) {
122
87
  if (!YYYY_MM_DD.test(value)) {
123
- throw new ValidationError(`Invalid ${optionName}: expected YYYY-MM-DD, got "${value}" — only that form is forwarded; some endpoints silently misread other layouts (e.g. "07/01/2026") as a different day`);
88
+ throw new ValidationError(`Invalid ${optionName}: expected YYYY-MM-DD, got "${value}" — only that form is forwarded as-is; on some endpoints other layouts (e.g. "07/01/2026") are read as a different day`);
124
89
  }
125
- // Shape alone lets 2026-02-30 / 2026-13-01 through; round-trip to reject those.
126
- // Built from the ISO string, not Date.UTC(y,...), whose two-digit-year mapping
127
- // would turn a valid year 0050 into 1950 and report it as a non-existent date.
128
90
  const [year, month, day] = value.split("-").map(Number);
129
91
  const parsed = new Date(`${value}T00:00:00Z`);
130
92
  if (parsed.getUTCFullYear() !== year || parsed.getUTCMonth() !== month - 1 || parsed.getUTCDate() !== day) {
@@ -132,24 +94,10 @@ export function parseDateOption(value, optionName) {
132
94
  }
133
95
  return value;
134
96
  }
135
- /** Commander argParser factory — `.option("--start-date <date>", desc, dateArg("--start-date"))`. */
136
97
  export function dateArg(optionName) {
137
98
  return (value) => parseDateOption(value, optionName);
138
99
  }
139
- /** `yyyy-MM-dd` with an optional ` HH:mm[:ss]` / `THH:mm[:ss]` tail. Anything else
140
- * is rejected rather than handed to `new Date()`: V8's fallback parser accepts the
141
- * same year-last shapes the server does but reads them the OTHER way round —
142
- * `07/01/2026` is July 1 to V8 and January 7 to the server, `25/12/2026` is invalid
143
- * to V8 and valid to the server (both probed 2026-07-20). Since `announcement list`
144
- * converts locally while its HK/US siblings pass the string through, an open
145
- * fallback made the same flag mean two dates six months apart across sibling
146
- * commands, silently and with exit 0. */
147
100
  const LOCAL_DATETIME = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/;
148
- /** Field-level datetime validation, timezone-free — a real calendar day plus a
149
- * valid clock time, judged by arithmetic alone (no Date construction, so no
150
- * dependence on the client's timezone or DST). This is what the pass-through guard
151
- * needs: a string the CLI forwards verbatim must be judged on its fields, not on
152
- * whether the local zone happens to contain that wall-clock instant. */
153
101
  function datetimeFieldsValid(value) {
154
102
  const parts = LOCAL_DATETIME.exec(value);
155
103
  if (!parts)
@@ -164,12 +112,6 @@ function datetimeFieldsValid(value) {
164
112
  return false;
165
113
  return Number(hh) <= 23 && Number(mi) <= 59 && Number(ss) <= 59;
166
114
  }
167
- /** A 10-digit seconds or 13-digit millis epoch, normalized to millis. Judged by
168
- * digit count, NOT magnitude: a `> 1e12` test sends a real 13-digit `1000000000000`
169
- * (which equals 1e12) down the seconds branch, and lets Number() coerce scientific
170
- * / hex / whitespace-padded / 11–12–14-digit inputs through as a "timestamp" — all
171
- * of which then convert wrong or get rejected by the server. Exactly 10 or 13
172
- * digits, nothing else. */
173
115
  function epochMillis(value) {
174
116
  if (/^\d{13}$/.test(value))
175
117
  return Number(value);
@@ -185,17 +127,10 @@ export function toTimestamp13(value) {
185
127
  return ts;
186
128
  if (!datetimeFieldsValid(value))
187
129
  return undefined;
188
- // `new Date("yyyy-MM-dd")` parses as UTC midnight while `new Date("yyyy-MM-dd
189
- // HH:mm:ss")` parses as local time — for CST users the two forms would differ by 8
190
- // hours. Build from local components so both mean the same wall-clock day.
191
130
  const parts = LOCAL_DATETIME.exec(value);
192
131
  const [, y, mo, d, hh = "0", mi = "0", ss = "0"] = parts;
193
132
  const year = Number(y);
194
133
  const dt = new Date(year, Number(mo) - 1, Number(d), Number(hh), Number(mi), Number(ss));
195
- // Full round-trip on every field: rejects the two inputs that cannot convert
196
- // faithfully — the two-digit-year remap (new Date(50,…) → 1950) and a DST gap (a
197
- // wall-clock time the local zone skips: 02:30 on a US spring-forward morning, or
198
- // 02:15 in Lord Howe's 30-minute gap, which only a minute-level check catches).
199
134
  const faithful = dt.getFullYear() === year && dt.getMonth() === Number(mo) - 1
200
135
  && dt.getDate() === Number(d) && dt.getHours() === Number(hh)
201
136
  && dt.getMinutes() === Number(mi) && dt.getSeconds() === Number(ss);
@@ -210,49 +145,23 @@ export function parseTimestamp13(value, optionName) {
210
145
  }
211
146
  return parsed;
212
147
  }
213
- /**
214
- * Guard for datetime options forwarded to the server AS A STRING (never converted
215
- * to a timestamp): the pass-through Insight/Vault list endpoints echo the string
216
- * verbatim, and probing 2026-07-21 showed they misread year-last separators exactly
217
- * like the date endpoints — `insight research list` read `07/01/2026` as 2026-01-07
218
- * but `07-01-2026` as 2026-07-01, a half-year apart, both HTTP 200 with nothing in
219
- * the response flagging it. Accept a finite epoch or a well-formed `YYYY-MM-DD
220
- * [ HH:mm[:ss]]` and return the ORIGINAL string unchanged.
221
- *
222
- * Validated with `datetimeFieldsValid`, NOT `toTimestamp13`: the latter's local Date
223
- * round-trip would reject a DST-gap string (e.g. `2026-03-08 02:30:00` under
224
- * America/New_York) that the server accepts — the CLI forwards this string as-is and
225
- * the server resolves it in its own zone, so the client's timezone must not decide
226
- * validity. Distinct from `parseTimestamp13`, which DOES convert (A-share
227
- * announcement / knowledge-batch want epoch millis, where an unrepresentable local
228
- * instant genuinely cannot convert).
229
- */
230
148
  export function parseDatetimeOption(value, optionName) {
231
149
  if (epochMillis(value) === undefined && !datetimeFieldsValid(value)) {
232
150
  throw new ValidationError(`Invalid ${optionName}: expected a Unix timestamp or "YYYY-MM-DD" optionally with " HH:mm[:ss]" (space or T separator), got "${value}" — year-last forms are refused because the API reads their day/month order differently per separator`);
233
151
  }
234
152
  return value;
235
153
  }
236
- /** Commander argParser factory for pass-through datetime options — same role as
237
- * `dateArg`, but allows an optional time part and keeps the string as-is. */
238
154
  export function datetimeArg(optionName) {
239
155
  return (value) => parseDatetimeOption(value, optionName);
240
156
  }
241
- /** Machine-local calendar date as `yyyy-MM-dd`, for CLI "default: today" options.
242
- * `new Date().toISOString().slice(0,10)` renders the UTC day — for CST users a
243
- * pre-08:00 "today" resolves to yesterday. Anchoring to local components matches
244
- * toTimestamp13's local-midnight convention. */
245
157
  export function localDateString(d) {
246
158
  const month = String(d.getMonth() + 1).padStart(2, "0");
247
159
  const day = String(d.getDate()).padStart(2, "0");
248
160
  return `${d.getFullYear()}-${month}-${day}`;
249
161
  }
250
- // Parse repeatable `"<lhs>:key=value"` specs into per-lhs parameter lists.
251
- // The left-hand side is an indicator code for cross-section / time-series and a
252
- // screener variable (F1, F2…) for the screener, so it stays an opaque string
253
- // here. Multiple specs for the same lhs accumulate, first-seen order.
254
162
  function parseParamSpecs(specs, option, syntax) {
255
163
  const groups = new Map();
164
+ const noQueryDate = new Set();
256
165
  for (const spec of specs) {
257
166
  const colon = spec.indexOf(":");
258
167
  const rest = colon === -1 ? "" : spec.slice(colon + 1);
@@ -260,6 +169,14 @@ function parseParamSpecs(specs, option, syntax) {
260
169
  const lhs = colon === -1 ? "" : spec.slice(0, colon).trim();
261
170
  const paramKey = eq === -1 ? "" : rest.slice(0, eq).trim();
262
171
  const paramValue = eq === -1 ? "" : rest.slice(eq + 1).trim();
172
+ if (rest.trim() === "") {
173
+ if (!lhs)
174
+ throw new ValidationError(`Invalid ${option}: expected "${syntax}", got "${spec}"`);
175
+ noQueryDate.add(lhs);
176
+ if (!groups.has(lhs))
177
+ groups.set(lhs, []);
178
+ continue;
179
+ }
263
180
  if (!lhs || !paramKey) {
264
181
  throw new ValidationError(`Invalid ${option}: expected "${syntax}", got "${spec}"`);
265
182
  }
@@ -270,27 +187,19 @@ function parseParamSpecs(specs, option, syntax) {
270
187
  }
271
188
  params.push({ paramKey, paramValue });
272
189
  }
273
- return groups;
190
+ return { groups, noQueryDate };
274
191
  }
275
- // Parse repeatable `--indicator-param "code:key=value"` specs into the nested
276
- // indicatorParamList the EDE cross-section / time-series endpoints expect.
277
192
  export function parseIndicatorParams(specs) {
278
193
  if (specs.length === 0)
279
194
  return undefined;
280
- return [...parseParamSpecs(specs, "--indicator-param", "code:key=value")].map(([indicatorCode, parameters]) => ({ indicatorCode, parameters }));
195
+ const { groups, noQueryDate } = parseParamSpecs(specs, "--indicator-param", "code:key=value");
196
+ return [...groups].map(([indicatorCode, parameters]) => (noQueryDate.has(indicatorCode)
197
+ ? { indicatorCode, parameters, noQueryDate: true }
198
+ : { indicatorCode, parameters }));
281
199
  }
282
- /** Screener variables are `F` + a positive integer; the server rejects anything
283
- * else, and a typo'd variable would otherwise surface as an opaque expression
284
- * error rather than pointing at the binding that is wrong. */
285
200
  const SCREENER_FIELD = /^F[1-9][0-9]*$/;
286
- /** Variable references inside a screener expression, e.g. the F1/F2 in
287
- * `F1 >= 800 && F2 <= 30`. String literals are stripped first, so a
288
- * `contains 'F2 系列'` operand is not mistaken for a reference. */
289
201
  const SCREENER_FIELD_REF = /\bF[1-9][0-9]*\b/g;
290
202
  const SCREENER_STRING_LITERAL = /'[^']*'|"[^"]*"/g;
291
- /** Split an expression into `(`, `)`, `&&`, `||` and the atoms between them.
292
- * String literals are copied verbatim so a `||` or an `F2` inside one is never
293
- * mistaken for syntax. */
294
203
  function tokenizeScreenerExpression(src) {
295
204
  const tokens = [];
296
205
  let atom = "";
@@ -325,20 +234,6 @@ function tokenizeScreenerExpression(src) {
325
234
  flush();
326
235
  return tokens;
327
236
  }
328
- /** Whether the expression still has a path that could have been evaluated, given
329
- * the variables the response actually returned a column for.
330
- *
331
- * The boolean structure decides, not the mere presence of a `||`:
332
- * - a term is evaluable when every variable it names has a column;
333
- * - `A && B` needs BOTH sides — a missing mandatory conjunct is an unprovable
334
- * claim even if the other side is a disjunction;
335
- * - `A || B` needs only one — a row can legitimately match through one operand
336
- * while the other is not evaluable at all (probed 2026-08-03: `F1 > 0 || F2
337
- * > 0` over 09992.HK, where `finc_pe_ttm` has no HK coverage, matches on F2).
338
- *
339
- * So `F1 && (F2 || F3)` missing F1, and `F1 || F2` missing both, are both
340
- * unevaluable and must fail — checking only for a `||` anywhere would let them
341
- * through. */
342
237
  export function screenerExpressionIsEvaluable(expression, present) {
343
238
  const tokens = tokenizeScreenerExpression(expression ?? "");
344
239
  let pos = 0;
@@ -351,13 +246,10 @@ export function screenerExpressionIsEvaluable(expression, present) {
351
246
  return value;
352
247
  }
353
248
  const atom = tokens[pos++] ?? "";
354
- // A term naming no variable (a literal comparison) is always evaluable.
355
249
  return (atom.replace(SCREENER_STRING_LITERAL, "").match(SCREENER_FIELD_REF) ?? []).every((ref) => present.has(ref));
356
250
  };
357
251
  const and = () => {
358
252
  let value = unit();
359
- // Evaluate both sides before combining: short-circuiting would leave the
360
- // parser mid-expression.
361
253
  while (tokens[pos] === "&&") {
362
254
  pos += 1;
363
255
  const right = unit();
@@ -376,18 +268,11 @@ export function screenerExpressionIsEvaluable(expression, present) {
376
268
  };
377
269
  return or();
378
270
  }
379
- /** Variables the expression actually filters on, string literals stripped. These
380
- * are the bindings whose VALUES the result depends on — a column missing for one
381
- * of them means the filter cannot be shown to have been applied. */
382
271
  export function screenerExpressionFields(expression) {
383
272
  return (expression ?? "").replace(SCREENER_STRING_LITERAL, "").match(SCREENER_FIELD_REF) ?? [];
384
273
  }
385
- // Parse the screener's `--indicator "F1:code"` bindings and merge each one's
386
- // `--indicator-param "F1:key=value"` specs. Params key off the variable, not the
387
- // code, because the same indicator may legitimately appear under two variables
388
- // with different parameters (e.g. the same price on two dates).
389
274
  export function parseScreenerIndicators(bindings, paramSpecs, expression) {
390
- const params = parseParamSpecs(paramSpecs, "--indicator-param", "F1:key=value");
275
+ const { groups: params, noQueryDate } = parseParamSpecs(paramSpecs, "--indicator-param", "F1:key=value");
391
276
  const indicators = new Map();
392
277
  for (const spec of bindings) {
393
278
  const colon = spec.indexOf(":");
@@ -404,16 +289,14 @@ export function parseScreenerIndicators(bindings, paramSpecs, expression) {
404
289
  }
405
290
  indicators.set(field, { field, indicatorCode, parameters: params.get(field) ?? [] });
406
291
  }
407
- // A param for an unbound variable is silently dropped by the server, so the
408
- // query would run with a filter the caller believes is applied but is not.
292
+ if (noQueryDate.size > 0) {
293
+ throw new ValidationError(`--indicator-param "${[...noQueryDate][0]}:" (the no-date opt-out) is not supported by the screener: an indicator sent with no parameters does not take part in the screen, so it would run without that condition and answer 0 rows with no error. Read the value with 'indicator cross-section --indicator-param "<code>:"' and filter client-side.`);
294
+ }
409
295
  for (const field of params.keys()) {
410
296
  if (!indicators.has(field)) {
411
297
  throw new ValidationError(`--indicator-param references "${field}", which no --indicator binds`);
412
298
  }
413
299
  }
414
- // The server does reject this (100003), but only after a round trip — and the
415
- // symmetric mistake (binding a variable the expression never uses) it accepts
416
- // silently while still billing the extra column.
417
300
  for (const ref of screenerExpressionFields(expression)) {
418
301
  if (!indicators.has(ref)) {
419
302
  throw new ValidationError(`--expression references "${ref}", which no --indicator binds`);
@@ -1,44 +1,26 @@
1
1
  import { ApiError } from "./errors.js";
2
2
  import { printData } from "./printer.js";
3
3
  import { isTransientError } from "./transport.js";
4
- // 14 attempts with exponential backoff (5s→30s cap) ≈ 316s total wait budget.
5
4
  export const POLL_MAX_ATTEMPTS = 14;
6
5
  const POLL_INITIAL_DELAY_MS = 5_000;
7
6
  const POLL_MAX_DELAY_MS = 30_000;
8
7
  export function nextPollDelayMs(attempt) {
9
- // 5s, 8s, 13s, 20s, 30s, 30s, ...
10
8
  const grown = POLL_INITIAL_DELAY_MS * 1.6 ** (attempt - 1);
11
9
  return Math.min(POLL_MAX_DELAY_MS, Math.round(grown));
12
10
  }
13
- // Probed 2026-07-20 against a real viewpoint-debate job: the async endpoints are
14
- // still entirely on the legacy codes — 410110 "正在生成中" and 410111 "生成失败",
15
- // both string-typed, both HTTP 400, neither carrying the new `errorType` field.
16
- // The 2026-07-17 spec renumbers them to 140001 RESULT_GENERATING (409) and
17
- // 140002 PROCESSING_FAILED (500); those are listed ahead of the switchover
18
- // because the failure mode is expensive and silent — a `--wait` that does not
19
- // recognize the pending code aborts the poll on a job already billed 50 credits.
20
11
  const PENDING_CODES = new Set(["410110", "140001"]);
21
12
  const FAILED_CODES = new Set(["410111", "140002"]);
22
- /** Shared by the AI content endpoints and `tool file-parse` — both report
23
- * "still generating" with the same codes. */
24
13
  export function isAsyncPending(error) {
25
14
  return error instanceof ApiError && error.code !== undefined && PENDING_CODES.has(error.code);
26
15
  }
27
16
  function isAsyncFailed(error) {
28
17
  return error instanceof ApiError && error.code !== undefined && FAILED_CODES.has(error.code);
29
18
  }
30
- /** Terminal failures are swallowed here rather than rethrown, so this line is the
31
- * user's only record of them — it has to carry what the global handler in cli.ts
32
- * would have printed (code, msg, traceId). Without the traceId the failure is
33
- * unreportable to Gangtise support, and README promises every error line has one. */
34
19
  function terminalFailureLine(error) {
35
20
  const api = error instanceof ApiError ? error : undefined;
36
21
  const code = api?.code ? ` ${api.code}` : "";
37
22
  const trace = api?.traceId ? ` [trace ${api.traceId}]` : "";
38
23
  const detail = api?.message ? `: ${api.message}` : "";
39
- // Name the *submit* endpoint explicitly: re-running a `*-check` is a free
40
- // lookup, it is resubmitting the generation job that re-bills 50 credits for a
41
- // verdict that will not change (probed 2026-07-20 on sensitive content).
42
24
  return `Content generation failed (terminal${code})${trace}${detail}\n`
43
25
  + "This dataId is final — re-checking it will not change. Resubmitting the generation task bills again for the same result; change the parameters first.\n";
44
26
  }
@@ -57,10 +39,6 @@ export async function pollAsyncContent(client, getContentEndpoint, dataId, forma
57
39
  return "failed";
58
40
  }
59
41
  if (!isAsyncPending(error)) {
60
- // AI generation windows are exactly when the server is busiest: one 5xx
61
- // (after the client's own retries) must not void minutes of waiting —
62
- // the dataId is still valid. Transient errors consume this attempt and
63
- // polling continues; anything else (no credits, bad params) aborts.
64
42
  if (!isTransientError(error))
65
43
  throw error;
66
44
  const msg = error instanceof Error ? error.message : String(error);
@@ -15,13 +15,6 @@ export async function readTokenCache(filePath) {
15
15
  return null;
16
16
  }
17
17
  }
18
- /**
19
- * Return a display-safe copy of a token cache for `auth status`: any field whose
20
- * name matches a credential pattern (token / key / secret / password / credential)
21
- * is replaced with "<redacted>" so the raw bearer token — or any unknown credential
22
- * field the cache file might carry (apiKey, privateKey, …) — is never printed; all
23
- * other metadata (expiresAt, userName, productCode, …) is preserved.
24
- */
25
18
  export function redactTokenCache(cache) {
26
19
  if (!cache)
27
20
  return null;
@@ -34,11 +27,6 @@ export function redactTokenCache(cache) {
34
27
  }
35
28
  export async function writeTokenCache(filePath, cache) {
36
29
  await fs.mkdir(path.dirname(filePath), { recursive: true });
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
30
  const tmp = `${filePath}.tmp-${randomUUID()}`;
43
31
  await fs.writeFile(tmp, JSON.stringify(cache, null, 2), { encoding: "utf8", mode: 0o600 });
44
32
  try {
@@ -57,8 +45,6 @@ export function isTokenCacheValid(cache, bufferSeconds = 300) {
57
45
  return cache.expiresAt - bufferSeconds > now;
58
46
  }
59
47
  export function normalizeToken(token) {
60
- // Case-insensitive prefix check: GANGTISE_TOKEN="bearer xxx" must become
61
- // "Bearer xxx", not the silently-invalid "Bearer bearer xxx".
62
48
  const prefix = /^bearer\s+/i.exec(token);
63
49
  return `Bearer ${prefix ? token.slice(prefix[0].length) : token}`;
64
50
  }