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.
@@ -7,8 +7,7 @@ import { isTokenCacheValid, normalizeToken, readTokenCache, requireAccessCredent
7
7
  import { ApiError, ValidationError } from "./errors.js";
8
8
  import { ENDPOINTS } from "./endpoints.js";
9
9
  import { getLookupData } from "./lookupData/index.js";
10
- import { getDispatcher, isVerbose, logTiming, markRetryable, runWithConcurrency, withRetry } from "./transport.js";
11
- const PAGINATION_CONCURRENCY = Number(process.env.GANGTISE_PAGE_CONCURRENCY ?? 5) || 5;
10
+ import { getDispatcher, isVerbose, logTiming, markRetryable, PAGE_CONCURRENCY, runWithConcurrency, withRetry } from "./transport.js";
12
11
  // Auth errors that warrant a forced re-login + one replay. 8000014/8000015 are
13
12
  // AK/SK errors; 0000001008 is a server-side token invalidation (the token still
14
13
  // looks valid by local expiry, so only a forced refresh recovers it).
@@ -48,11 +47,28 @@ export class GangtiseClient {
48
47
  accessKey: credentials.accessKey,
49
48
  secretKey: credentials.secretKey,
50
49
  }, false);
50
+ // Validate the shape before touching it: a missing accessToken used to surface
51
+ // as a bare TypeError from normalizeToken, hiding the real cause.
52
+ if (typeof envelope?.accessToken !== "string" || !envelope.accessToken) {
53
+ throw new ApiError("Login succeeded but the response carried no accessToken", undefined, undefined, envelope);
54
+ }
51
55
  const accessToken = normalizeToken(envelope.accessToken);
52
- const expiresAt = Math.floor(Date.now() / 1000) + envelope.expiresIn;
53
- const cache = { ...envelope, accessToken, expiresAt };
56
+ // A non-numeric expiresIn would make expiresAt NaN the cache would never
57
+ // validate and every command would silently re-login. Degrade to 0 instead
58
+ // (token works now, next process logs in again).
59
+ const expiresIn = Number.isFinite(envelope.expiresIn) ? envelope.expiresIn : 0;
60
+ const expiresAt = Math.floor(Date.now() / 1000) + expiresIn;
61
+ const cache = { ...envelope, accessToken, expiresIn, expiresAt };
54
62
  this.memoCache = cache;
55
- await writeTokenCache(this.config.tokenCachePath, cache);
63
+ try {
64
+ await writeTokenCache(this.config.tokenCachePath, cache);
65
+ }
66
+ catch (error) {
67
+ // A read-only HOME or full disk must not fail the request — the in-memory
68
+ // token is valid; we just can't persist it for the next process.
69
+ const msg = error instanceof Error ? error.message : String(error);
70
+ process.stderr.write(`[gangtise] warning: could not persist token cache: ${msg}\n`);
71
+ }
56
72
  return accessToken;
57
73
  }
58
74
  /**
@@ -62,7 +78,7 @@ export class GangtiseClient {
62
78
  * the caller re-throws the original error. `authState` persists across the
63
79
  * withRetry attempts so we only refresh once per logical request.
64
80
  */
65
- async refreshAuthIfRecoverable(error, useAuth, authState) {
81
+ async refreshAuthIfRecoverable(error, useAuth, authState, usedAuthorization) {
66
82
  if (useAuth
67
83
  && !authState.retried
68
84
  && error instanceof ApiError
@@ -71,12 +87,30 @@ export class GangtiseClient {
71
87
  && this.config.accessKey
72
88
  && this.config.secretKey) {
73
89
  authState.retried = true;
74
- this.memoCache = null;
75
90
  this.envTokenInvalidated = true;
76
- await this.getAuthorizationHeader(true);
91
+ // If the failed request was still carrying an OLDER token than the one now in
92
+ // memoCache, another request already refreshed — replay with the fresh token
93
+ // instead of logging in again (back-to-back logins can kick each other's
94
+ // sessions server-side, the 0000001008 semantics). If the failed request used
95
+ // the CURRENT token, that token is genuinely dead: force a new login. A time
96
+ // window is NOT a valid proxy here — right after the initial login the window
97
+ // is always "recent", which would skip the refresh exactly when it's needed.
98
+ const memoToken = this.memoCache && isTokenCacheValid(this.memoCache) ? normalizeToken(this.memoCache.accessToken) : null;
99
+ const alreadyRefreshed = memoToken !== null && usedAuthorization !== undefined && usedAuthorization !== memoToken;
100
+ if (!alreadyRefreshed) {
101
+ this.memoCache = null;
102
+ await this.getAuthorizationHeader(true);
103
+ }
77
104
  throw markRetryable(new ApiError(error.message, error.code, error.statusCode, error.details));
78
105
  }
79
106
  }
107
+ /** `new URL("/a/b", "https://proxy/prefix")` drops "/prefix" — an absolute path
108
+ * replaces the base's path per the URL spec. Join manually so a reverse-proxy
109
+ * GANGTISE_BASE_URL with a path prefix keeps working. */
110
+ buildUrl(path) {
111
+ const base = this.config.baseUrl.endsWith("/") ? this.config.baseUrl : `${this.config.baseUrl}/`;
112
+ return new URL(path.replace(/^\//, ""), base);
113
+ }
80
114
  isEnvelope(parsed) {
81
115
  if (!parsed || typeof parsed !== 'object')
82
116
  return false;
@@ -145,16 +179,25 @@ export class GangtiseClient {
145
179
  return firstPage;
146
180
  const total = firstPage.total;
147
181
  const collected = [...firstPage.list];
148
- // Last page reached on first request
182
+ const available = Math.max(total - startFrom, 0);
183
+ const target = requestedSize === undefined ? available : Math.min(requestedSize, available);
184
+ // Last page reached on first request. If `total` promises more rows than the
185
+ // short page delivered, the server's page cap may be lower than our configured
186
+ // maxPageSize — say so instead of silently returning a subset as "everything".
149
187
  if (firstPage.list.length < firstPageSize) {
150
- return {
188
+ const out = {
151
189
  ...firstPage,
152
190
  total,
153
191
  list: requestedSize === undefined ? collected : collected.slice(0, requestedSize),
154
192
  };
193
+ if (collected.length < target) {
194
+ process.stderr.write(`[gangtise] warning: server returned a short page (${collected.length} rows) but reported total=${total}; treating it as the end of data — results may be incomplete\n`);
195
+ // Machine-readable counterpart of the warning: scripts key off partial /
196
+ // exit code 3, and must not mistake a truncated result for a complete one.
197
+ out.partial = true;
198
+ }
199
+ return out;
155
200
  }
156
- const available = Math.max(total - startFrom, 0);
157
- const target = requestedSize === undefined ? available : Math.min(requestedSize, available);
158
201
  if (collected.length >= target) {
159
202
  return {
160
203
  ...firstPage,
@@ -162,21 +205,24 @@ export class GangtiseClient {
162
205
  list: requestedSize === undefined ? collected : collected.slice(0, requestedSize),
163
206
  };
164
207
  }
208
+ // Build remaining page requests. The cap lives inside the loop: a corrupt
209
+ // server `total` (e.g. 9e15) must not materialize millions of page objects —
210
+ // or spin for minutes — before a post-hoc truncation applies.
211
+ const MAX_PAGES = 1000;
212
+ let truncatedByPageCap = false;
165
213
  const pageRequests = [];
166
214
  let nextFrom = startFrom + firstPage.list.length;
167
215
  const endFrom = startFrom + target;
168
216
  while (nextFrom < endFrom) {
217
+ if (pageRequests.length + 1 >= MAX_PAGES) {
218
+ truncatedByPageCap = true;
219
+ break;
220
+ }
169
221
  const remaining = endFrom - nextFrom;
170
222
  const size = Math.min(maxPageSize, remaining);
171
223
  pageRequests.push({ from: nextFrom, size });
172
224
  nextFrom += size;
173
225
  }
174
- const MAX_PAGES = 1000;
175
- let truncatedByPageCap = false;
176
- if (pageRequests.length + 1 > MAX_PAGES) {
177
- pageRequests.length = MAX_PAGES - 1;
178
- truncatedByPageCap = true;
179
- }
180
226
  let unexpectedShape = false;
181
227
  let totalDrift = false;
182
228
  // Fail-soft fan-out: a hard page failure (rate-limit 903301, no-perm, retries
@@ -187,7 +233,7 @@ export class GangtiseClient {
187
233
  const failedPages = [];
188
234
  let firstError = null;
189
235
  let aborted = false;
190
- const pages = await runWithConcurrency(pageRequests, PAGINATION_CONCURRENCY, async (req) => {
236
+ const pages = await runWithConcurrency(pageRequests, PAGE_CONCURRENCY, async (req) => {
191
237
  if (aborted) {
192
238
  failedPages.push(req);
193
239
  return [];
@@ -199,7 +245,10 @@ export class GangtiseClient {
199
245
  size: req.size,
200
246
  });
201
247
  if (!this.isPaginatedListResponse(page)) {
248
+ // Treat a shape-broken page like a failed page: its rows are missing, so
249
+ // the result must carry the partial marker instead of looking complete.
202
250
  unexpectedShape = true;
251
+ failedPages.push(req);
203
252
  return [];
204
253
  }
205
254
  if (page.total !== total)
@@ -219,11 +268,11 @@ export class GangtiseClient {
219
268
  continue;
220
269
  collected.push(...list);
221
270
  }
222
- if (unexpectedShape && isVerbose()) {
223
- process.stderr.write(`[gangtise] warning: a page response had unexpected shape; results may be incomplete\n`);
271
+ if (unexpectedShape) {
272
+ process.stderr.write(`[gangtise] warning: a page response had unexpected shape; its rows are missing (counted in failedPages)\n`);
224
273
  }
225
- if (totalDrift && isVerbose()) {
226
- process.stderr.write(`[gangtise] warning: 'total' changed across pages (data shifted during fetch)\n`);
274
+ if (totalDrift) {
275
+ process.stderr.write(`[gangtise] warning: 'total' changed across pages (data shifted during fetch); rows may be duplicated or missing\n`);
227
276
  }
228
277
  // Always surface a cap-induced truncation (not gated on verbose): the user
229
278
  // asked for everything and is silently getting a subset, mirroring the
@@ -240,8 +289,78 @@ export class GangtiseClient {
240
289
  out.partial = true;
241
290
  out.failedPages = failedPages.map((p) => ({ from: p.from, size: p.size }));
242
291
  const detail = firstError instanceof Error ? `: ${firstError.message}` : "";
243
- process.stderr.write(`[gangtise] warning: ${failedPages.length}/${pageRequests.length} pages not fetched${detail}; results are partial — got ${collected.length}/${total} rows (see failedPages). A page hit a non-retryable error (e.g. rate limit); remaining pages were skipped.\n`);
292
+ const skippedHint = aborted ? " A page hit a non-retryable error (e.g. rate limit); remaining pages were skipped." : "";
293
+ process.stderr.write(`[gangtise] warning: ${failedPages.length}/${pageRequests.length} pages not fetched${detail}; results are partial — got ${collected.length}/${total} rows (see failedPages).${skippedHint}\n`);
294
+ }
295
+ return out;
296
+ }
297
+ /**
298
+ * Sequential pagination for endpoints that page by offset but return NO `total`
299
+ * and use a non-standard list key (e.g. wechat chatroom's `chatRoomList`). We
300
+ * can't fan out like requestPaginated (no total ⇒ unknown page count), so page
301
+ * serially until a short page (fewer rows than requested) signals the end.
302
+ * Returns `{ [listKey]: rows }` so normalize/printer treat it like any list.
303
+ */
304
+ async requestSequentialPaginated(endpoint, body) {
305
+ const initialBody = body && typeof body === 'object' ? { ...body } : {};
306
+ if ('from' in initialBody && (typeof initialBody.from !== 'number' || !Number.isFinite(initialBody.from) || initialBody.from < 0)) {
307
+ throw new ValidationError('Invalid from: expected a non-negative number');
308
+ }
309
+ if ('size' in initialBody && initialBody.size !== undefined && (typeof initialBody.size !== 'number' || !Number.isFinite(initialBody.size) || initialBody.size <= 0)) {
310
+ throw new ValidationError('Invalid size: expected a positive number');
311
+ }
312
+ const listKey = endpoint.pagination?.listKey ?? 'list';
313
+ const maxPageSize = endpoint.pagination?.maxPageSize ?? 50;
314
+ const startFrom = typeof initialBody.from === 'number' && Number.isFinite(initialBody.from) ? initialBody.from : 0;
315
+ const requestedSize = typeof initialBody.size === 'number' && Number.isFinite(initialBody.size) ? initialBody.size : undefined;
316
+ const extractList = (page) => {
317
+ if (!page || typeof page !== 'object')
318
+ return null;
319
+ const arr = page[listKey];
320
+ return Array.isArray(arr) ? arr : null;
321
+ };
322
+ const collected = [];
323
+ let firstPage = null;
324
+ let from = startFrom;
325
+ const MAX_PAGES = 1000;
326
+ let truncatedByPageCap = false;
327
+ let partialShape = false;
328
+ for (let page = 0;; page++) {
329
+ const remaining = requestedSize === undefined ? maxPageSize : requestedSize - collected.length;
330
+ if (requestedSize !== undefined && remaining <= 0)
331
+ break;
332
+ const size = Math.min(maxPageSize, remaining);
333
+ const pageData = await this.requestJson(endpoint, { ...initialBody, from, size });
334
+ if (firstPage === null)
335
+ firstPage = pageData;
336
+ const list = extractList(pageData);
337
+ if (list === null) {
338
+ // First response isn't a paginated-list shape → return it untouched. But a
339
+ // LATER page losing shape must NOT discard the rows already collected (mirrors
340
+ // requestPaginated's fail-soft fan-out): stop, keep them, and warn loudly.
341
+ if (page === 0)
342
+ return firstPage;
343
+ partialShape = true;
344
+ process.stderr.write(`[gangtise] warning: a page response had unexpected shape; results are partial — ${collected.length} rows fetched.\n`);
345
+ break;
346
+ }
347
+ for (const item of list)
348
+ collected.push(item);
349
+ if (list.length < size)
350
+ break; // short page ⇒ no more rows
351
+ if (page + 1 >= MAX_PAGES) {
352
+ truncatedByPageCap = true;
353
+ break;
354
+ }
355
+ from += list.length;
244
356
  }
357
+ if (truncatedByPageCap) {
358
+ process.stderr.write(`[gangtise] warning: hit the ${MAX_PAGES}-page safety cap; fetched ${collected.length} rows. Pass --size to fetch a bounded subset.\n`);
359
+ }
360
+ const rows = requestedSize === undefined ? collected : collected.slice(0, requestedSize);
361
+ const out = { [listKey]: rows };
362
+ if (partialShape)
363
+ out.partial = true;
245
364
  return out;
246
365
  }
247
366
  async login() {
@@ -257,14 +376,18 @@ export class GangtiseClient {
257
376
  return this.readLocalLookup(endpoint);
258
377
  }
259
378
  const dispatcher = getDispatcher();
260
- const url = new URL(endpoint.path, this.config.baseUrl);
379
+ const url = this.buildUrl(endpoint.path);
261
380
  const authState = { retried: false };
262
381
  return withRetry(async () => {
263
382
  const headers = {
264
383
  'content-type': 'application/json',
265
384
  };
385
+ // Keep the header we actually sent: the self-heal check compares it against
386
+ // the current memoCache token to tell "stale token" from "fresh token died".
387
+ let usedAuthorization;
266
388
  if (useAuth) {
267
- headers.Authorization = await this.getAuthorizationHeader();
389
+ usedAuthorization = await this.getAuthorizationHeader();
390
+ headers.Authorization = usedAuthorization;
268
391
  }
269
392
  const startedAt = Date.now();
270
393
  const response = await request(url, {
@@ -287,14 +410,16 @@ export class GangtiseClient {
287
410
  : 'Failed to parse API response';
288
411
  throw new ApiError(message, undefined, response.statusCode, text.slice(0, 500));
289
412
  }
290
- if (response.statusCode >= 400) {
291
- this.throwHttpError(parsed, response.statusCode);
292
- }
293
413
  try {
414
+ // Auth errors can arrive as HTTP 4xx or as a 200-wrapped error envelope;
415
+ // both routes must reach the self-heal check below.
416
+ if (response.statusCode >= 400) {
417
+ this.throwHttpError(parsed, response.statusCode);
418
+ }
294
419
  return this.unwrapEnvelope(parsed, response.statusCode);
295
420
  }
296
421
  catch (error) {
297
- await this.refreshAuthIfRecoverable(error, useAuth, authState);
422
+ await this.refreshAuthIfRecoverable(error, useAuth, authState, usedAuthorization);
298
423
  throw error;
299
424
  }
300
425
  }, {
@@ -308,7 +433,7 @@ export class GangtiseClient {
308
433
  }
309
434
  async download(endpoint, query, options) {
310
435
  const dispatcher = getDispatcher();
311
- const url = new URL(endpoint.path, this.config.baseUrl);
436
+ const url = this.buildUrl(endpoint.path);
312
437
  Object.entries(query).forEach(([key, value]) => {
313
438
  url.searchParams.set(key, String(value));
314
439
  });
@@ -316,13 +441,37 @@ export class GangtiseClient {
316
441
  return withRetry(async () => {
317
442
  const authorization = await this.getAuthorizationHeader();
318
443
  const startedAt = Date.now();
319
- const response = await request(url, {
444
+ let currentUrl = url;
445
+ let auth = authorization;
446
+ let response = await request(currentUrl, {
320
447
  method: endpoint.method,
321
448
  headers: { Authorization: authorization },
322
449
  headersTimeout: this.config.timeoutMs,
323
450
  bodyTimeout: this.config.timeoutMs,
324
451
  dispatcher,
325
452
  });
453
+ // undici does not follow redirects, and a download endpoint may 302 to a
454
+ // pre-signed object-store URL — without this the redirect body would be
455
+ // saved as the "file". Follow up to 3 hops, dropping Authorization once the
456
+ // redirect leaves the API origin so the bearer never reaches storage hosts.
457
+ for (let hops = 0; hops < 3 && response.statusCode >= 300 && response.statusCode < 400; hops++) {
458
+ const locationHeader = response.headers.location;
459
+ const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader;
460
+ if (!location)
461
+ break;
462
+ await response.body.text().catch(() => { });
463
+ const next = new URL(location, currentUrl);
464
+ if (next.origin !== currentUrl.origin)
465
+ auth = undefined;
466
+ currentUrl = next;
467
+ response = await request(currentUrl, {
468
+ method: 'GET',
469
+ headers: auth ? { Authorization: auth } : {},
470
+ headersTimeout: this.config.timeoutMs,
471
+ bodyTimeout: this.config.timeoutMs,
472
+ dispatcher,
473
+ });
474
+ }
326
475
  const contentType = Array.isArray(response.headers['content-type']) ? response.headers['content-type'][0] : response.headers['content-type'];
327
476
  if (contentType?.includes('application/json')) {
328
477
  const text = await response.body.text();
@@ -337,15 +486,15 @@ export class GangtiseClient {
337
486
  }
338
487
  return { text, contentType };
339
488
  }
340
- if (response.statusCode >= 400) {
341
- this.throwHttpError(parsed, response.statusCode);
342
- }
343
489
  let data;
344
490
  try {
491
+ if (response.statusCode >= 400) {
492
+ this.throwHttpError(parsed, response.statusCode);
493
+ }
345
494
  data = this.unwrapEnvelope(parsed, response.statusCode);
346
495
  }
347
496
  catch (error) {
348
- await this.refreshAuthIfRecoverable(error, true, authState);
497
+ await this.refreshAuthIfRecoverable(error, true, authState, authorization);
349
498
  throw error;
350
499
  }
351
500
  if (data && typeof data === 'object' && 'url' in data && typeof data.url === 'string') {
@@ -369,7 +518,19 @@ export class GangtiseClient {
369
518
  const filenameMatch = Array.isArray(contentDisposition)
370
519
  ? contentDisposition[0]?.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i)
371
520
  : contentDisposition?.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i);
372
- const filename = filenameMatch ? decodeURIComponent(filenameMatch[1] || filenameMatch[2]) : undefined;
521
+ // A plain filename= value with a bare % ("增长100%.pdf") is not valid URI
522
+ // encoding — decodeURIComponent would throw and fail the whole download over
523
+ // a cosmetic hint. Fall back to the raw value instead.
524
+ let filename;
525
+ if (filenameMatch) {
526
+ const raw = filenameMatch[1] || filenameMatch[2];
527
+ try {
528
+ filename = decodeURIComponent(raw);
529
+ }
530
+ catch {
531
+ filename = raw;
532
+ }
533
+ }
373
534
  // Stream directly to disk when caller already knows the destination
374
535
  if (options?.streamTo) {
375
536
  await fs.mkdir(path.dirname(options.streamTo), { recursive: true });
@@ -411,7 +572,9 @@ export class GangtiseClient {
411
572
  return this.download(endpoint, query ?? {}, options);
412
573
  }
413
574
  if (endpoint.kind === 'json' && endpoint.pagination?.enabled) {
414
- return this.requestPaginated(endpoint, body);
575
+ return endpoint.pagination.sequential
576
+ ? this.requestSequentialPaginated(endpoint, body)
577
+ : this.requestPaginated(endpoint, body);
415
578
  }
416
579
  return this.requestJson(endpoint, body);
417
580
  }
@@ -1,12 +1,44 @@
1
+ import fs from "node:fs/promises";
1
2
  import { extname } from "node:path";
2
3
  import { DownloadError } from "./errors.js";
3
4
  import { saveOutputIfNeeded } from "./output.js";
4
5
  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.
6
+ /** Replace filesystem-unsafe characters (path separators, wildcards, and control
7
+ * characters / NUL) with `_` so a title or a server-supplied filename can't create
8
+ * stray subdirectories, escape the intended output path, or break fs.writeFile.
7
9
  * Shared by title-based naming and the download fallback. */
8
10
  function sanitizeFilename(name) {
9
- return name.replace(/[/\\:*?"<>|]/g, "_");
11
+ return name.replace(/[/\\:*?"<>|\u0000-\u001f]/g, "_");
12
+ }
13
+ /** Keep auto-derived filenames under 200 UTF-8 bytes, preserving the extension.
14
+ * ext4 caps directory entries at 255 bytes — long Chinese titles hit that at
15
+ * ~85 chars (3 bytes each) and fs.writeFile throws ENAMETOOLONG after the body
16
+ * has already been downloaded. */
17
+ function truncateFilename(name, maxBytes = 200) {
18
+ if (Buffer.byteLength(name, "utf8") <= maxBytes)
19
+ return name;
20
+ const ext = extname(name);
21
+ let stem = name.slice(0, name.length - ext.length);
22
+ while (stem.length > 1 && Buffer.byteLength(stem + ext, "utf8") > maxBytes)
23
+ stem = stem.slice(0, -1);
24
+ return stem + ext;
25
+ }
26
+ /** Pick a non-existing path by suffixing -1, -2, … before the extension, so batch
27
+ * downloads whose titles collide ("2025年第一季度报告" from several companies) don't
28
+ * silently overwrite each other. Only auto-derived names go through this — an
29
+ * explicit --output path keeps plain overwrite semantics. */
30
+ export async function uniquePath(p) {
31
+ const exists = (f) => fs.access(f).then(() => true, () => false);
32
+ if (!(await exists(p)))
33
+ return p;
34
+ const ext = extname(p);
35
+ const stem = p.slice(0, p.length - ext.length);
36
+ for (let i = 1; i <= 99; i++) {
37
+ const candidate = `${stem}-${i}${ext}`;
38
+ if (!(await exists(candidate)))
39
+ return candidate;
40
+ }
41
+ return p;
10
42
  }
11
43
  const MIME_EXT = {
12
44
  "application/pdf": ".pdf",
@@ -48,7 +80,7 @@ export async function resolveTitle(client, result, listEndpoint, idField, idValu
48
80
  if (serverExt && !title.toLowerCase().endsWith(serverExt.toLowerCase())) {
49
81
  title += serverExt;
50
82
  }
51
- return title;
83
+ return truncateFilename(title);
52
84
  }
53
85
  try {
54
86
  const cacheData = await readTitleCache();
@@ -72,11 +104,32 @@ export async function resolveTitle(client, result, listEndpoint, idField, idValu
72
104
  }
73
105
  return undefined;
74
106
  }
107
+ async function downloadUrlTo(url, outputPath) {
108
+ const { createWriteStream } = await import("node:fs");
109
+ const { Readable } = await import("node:stream");
110
+ const { pipeline } = await import("node:stream/promises");
111
+ const { dirname } = await import("node:path");
112
+ const response = await fetch(url);
113
+ if (!response.ok || !response.body) {
114
+ throw new DownloadError(`Failed to fetch download URL (HTTP ${response.status})`);
115
+ }
116
+ await fs.mkdir(dirname(outputPath), { recursive: true });
117
+ try {
118
+ await pipeline(Readable.fromWeb(response.body), createWriteStream(outputPath));
119
+ }
120
+ catch (error) {
121
+ await fs.unlink(outputPath).catch(() => { });
122
+ throw error;
123
+ }
124
+ }
75
125
  export async function saveDownloadResult(result, fallbackName, output) {
76
126
  if (!(result && typeof result === "object")) {
77
127
  throw new DownloadError("Unexpected download response");
78
128
  }
79
129
  const file = result;
130
+ // The fallback prefix embeds a user-supplied id (e.g. --report-id): sanitize it
131
+ // like server-provided names so an auto filename never interprets input as a path.
132
+ const safeFallback = sanitizeFilename(fallbackName);
80
133
  if (typeof file.savedPath === "string") {
81
134
  process.stdout.write(`${file.savedPath}\n`);
82
135
  return;
@@ -84,20 +137,24 @@ export async function saveDownloadResult(result, fallbackName, output) {
84
137
  if (file.data instanceof Uint8Array) {
85
138
  // Sanitize the server-provided filename so a Content-Disposition value with
86
139
  // / or : can't write outside the intended path (same rule as buildFilename).
87
- const outputPath = output ?? (file.filename ? sanitizeFilename(file.filename) : undefined) ?? (fallbackName + extFromContentType(file.contentType));
140
+ const autoName = (file.filename ? sanitizeFilename(file.filename) : undefined) ?? (safeFallback + extFromContentType(file.contentType));
141
+ const outputPath = output ?? await uniquePath(truncateFilename(autoName));
88
142
  await saveOutputIfNeeded(file.data, outputPath);
89
143
  process.stdout.write(`${outputPath}\n`);
90
144
  return;
91
145
  }
92
146
  if (typeof file.text === "string") {
93
- const outputPath = output ?? `${fallbackName}.txt`;
147
+ const outputPath = output ?? await uniquePath(truncateFilename(`${safeFallback}.txt`));
94
148
  await saveOutputIfNeeded(file.text, outputPath);
95
149
  process.stdout.write(`${outputPath}\n`);
96
150
  return;
97
151
  }
98
152
  if (typeof file.url === "string") {
99
153
  if (output) {
100
- await saveOutputIfNeeded(file.url, output);
154
+ // The server handed us a (typically signed, short-lived) URL instead of the
155
+ // bytes. The user asked for a file — follow the URL and stream the content
156
+ // to disk instead of writing the URL string into a fake .pdf.
157
+ await downloadUrlTo(file.url, output);
101
158
  process.stdout.write(`${output}\n`);
102
159
  return;
103
160
  }