gangtise-openapi-cli 0.43.0 → 0.44.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.
Files changed (42) hide show
  1. package/README.md +93 -19
  2. package/dist/src/cli.js +29 -18
  3. package/dist/src/commands/ai.js +20 -29
  4. package/dist/src/commands/auth.js +5 -4
  5. package/dist/src/commands/bond.js +5 -4
  6. package/dist/src/commands/fund.js +100 -0
  7. package/dist/src/commands/fundamental.js +2 -0
  8. package/dist/src/commands/groups.js +14 -0
  9. package/dist/src/commands/indicator.js +5 -5
  10. package/dist/src/commands/insight.js +3 -4
  11. package/dist/src/commands/raw.js +4 -3
  12. package/dist/src/commands/shared.js +35 -6
  13. package/dist/src/commands/tool.js +2 -2
  14. package/dist/src/commands/vault.js +30 -30
  15. package/dist/src/core/args.js +23 -7
  16. package/dist/src/core/asyncContent.js +1 -1
  17. package/dist/src/core/auth.js +2 -10
  18. package/dist/src/core/client.js +96 -35
  19. package/dist/src/core/commandBodies.js +1 -27
  20. package/dist/src/core/config.js +9 -5
  21. package/dist/src/core/download.js +47 -10
  22. package/dist/src/core/endpoints.js +198 -3
  23. package/dist/src/core/errors.js +1 -1
  24. package/dist/src/core/opinionDetail.js +16 -6
  25. package/dist/src/core/output.js +103 -72
  26. package/dist/src/core/printer.js +10 -5
  27. package/dist/src/core/quoteSharding.js +28 -4
  28. package/dist/src/core/rowSink.js +24 -10
  29. package/dist/src/core/titleCache.js +2 -10
  30. package/dist/src/core/transport.js +25 -7
  31. package/dist/src/core/updateCheck.js +3 -9
  32. package/gangtise-openapi/SKILL.md +53 -17
  33. package/gangtise-openapi/references/commands/fund.md +192 -0
  34. package/gangtise-openapi/references/commands/fundamental.md +2 -2
  35. package/gangtise-openapi/references/commands/indicator.md +7 -7
  36. package/gangtise-openapi/references/commands/insight.md +3 -3
  37. package/gangtise-openapi/references/commands/quote.md +1 -1
  38. package/gangtise-openapi/references/commands/tool.md +1 -1
  39. package/gangtise-openapi/references/commands/vault.md +1 -1
  40. package/gangtise-openapi/references/errors.md +5 -5
  41. package/gangtise-openapi/references/response-schema.md +10 -0
  42. package/package.json +1 -1
@@ -6,10 +6,10 @@ import { pipeline } from "node:stream/promises";
6
6
  import { FormData, request } from "undici";
7
7
  import { credentialFingerprint, isTokenCacheValid, normalizeToken, readTokenCache, requireAccessCredentials, writeTokenCache } from "./auth.js";
8
8
  import { ApiError, attachEnvelopeTraceId, markStructural, ValidationError } from "./errors.js";
9
- import { ENDPOINTS, resolveTimeoutMs } from "./endpoints.js";
9
+ import { COSTLY_FETCH_CREDITS, ENDPOINTS, resolveTimeoutMs } from "./endpoints.js";
10
10
  import { getLookupData } from "./lookupData/index.js";
11
11
  import { stagingPath } from "./output.js";
12
- import { decodeResponseBody, getDispatcher, isVerbose, logTiming, markRetryable, PAGE_CONCURRENCY, parseRetryAfterMs, quoteBigIntFields, runInOrder, withRetry } from "./transport.js";
12
+ import { decodeResponseBody, getDispatcher, isVerbose, JSON_TOTAL_FACTOR, logTiming, markAuthReplay, PAGE_CONCURRENCY, parseRetryAfterMs, quoteBigIntFields, runInOrder, TRANSFER_TOTAL_FACTOR, withRetry } from "./transport.js";
13
13
  import { attachRowSink } from "./rowSink.js";
14
14
  import { markIncomplete } from "./exitStatus.js";
15
15
  const AUTH_RETRY_CODES = new Set(["8000014", "8000015", "0000001008", "999002"]);
@@ -18,7 +18,6 @@ function hasExpectedShape(expects, payload) {
18
18
  return Array.isArray(payload);
19
19
  return Boolean(payload && typeof payload === "object" && Array.isArray(payload.list));
20
20
  }
21
- export const COSTLY_FETCH_CREDITS = 1000;
22
21
  const PROBE_ABOVE_CREDITS = 50;
23
22
  const OFFSET_REFUSAL_CODES = new Set(["140002", "100006"]);
24
23
  function readEmptyListAsArray(page) {
@@ -31,12 +30,22 @@ function markTotalCapped(out, why, collected, keepBelow) {
31
30
  out.partial = true;
32
31
  out.totalCapped = true;
33
32
  }
33
+ function stableStringify(value) {
34
+ if (Array.isArray(value))
35
+ return `[${value.map(stableStringify).join(",")}]`;
36
+ if (value && typeof value === "object") {
37
+ const obj = value;
38
+ return `{${Object.keys(obj).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`).join(",")}}`;
39
+ }
40
+ return JSON.stringify(value);
41
+ }
34
42
  export class GangtiseClient {
35
43
  config;
36
44
  rowSink;
37
45
  allowCostlyFetch = false;
38
46
  refreshPromise = null;
39
47
  memoCache = null;
48
+ loginToken = null;
40
49
  envTokenInvalidated = false;
41
50
  rowSinkClaimed = false;
42
51
  constructor(config, rowSink) {
@@ -89,6 +98,7 @@ export class GangtiseClient {
89
98
  issuedFor: credentialFingerprint(credentials.accessKey, this.config.baseUrl),
90
99
  };
91
100
  this.memoCache = cache;
101
+ this.loginToken = accessToken;
92
102
  try {
93
103
  await writeTokenCache(this.config.tokenCachePath, cache);
94
104
  }
@@ -99,23 +109,45 @@ export class GangtiseClient {
99
109
  return accessToken;
100
110
  }
101
111
  async refreshAuthIfRecoverable(error, useAuth, authState, usedAuthorization) {
102
- if (useAuth
103
- && !authState.retried
112
+ if (!(useAuth
104
113
  && error instanceof ApiError
105
114
  && error.code
106
115
  && AUTH_RETRY_CODES.has(error.code)
107
116
  && this.config.accessKey
108
- && this.config.secretKey) {
109
- authState.retried = true;
110
- this.envTokenInvalidated = true;
117
+ && this.config.secretKey))
118
+ return;
119
+ if (authState.usedLoginToken)
120
+ return;
121
+ this.envTokenInvalidated = true;
122
+ const replay = () => markAuthReplay(new ApiError(error.message, error.code, error.statusCode, error.details));
123
+ const tryMemory = () => {
111
124
  const memoToken = this.memoCache && isTokenCacheValid(this.memoCache) ? normalizeToken(this.memoCache.accessToken) : null;
112
- const alreadyRefreshed = memoToken !== null && usedAuthorization !== undefined && usedAuthorization !== memoToken;
113
- if (!alreadyRefreshed) {
114
- this.memoCache = null;
115
- await this.getAuthorizationHeader(true);
125
+ if (memoToken === null || usedAuthorization === undefined || memoToken === usedAuthorization)
126
+ return;
127
+ if (memoToken === this.loginToken) {
128
+ authState.usedLoginToken = true;
129
+ throw replay();
130
+ }
131
+ if (!authState.usedUnprovenToken) {
132
+ authState.usedUnprovenToken = true;
133
+ throw replay();
134
+ }
135
+ };
136
+ tryMemory();
137
+ if (!authState.usedUnprovenToken) {
138
+ const expected = credentialFingerprint(this.config.accessKey, this.config.baseUrl);
139
+ const disk = await readTokenCache(this.config.tokenCachePath);
140
+ tryMemory();
141
+ if (disk && isTokenCacheValid(disk, undefined, expected) && normalizeToken(disk.accessToken) !== usedAuthorization) {
142
+ this.memoCache = disk;
143
+ authState.usedUnprovenToken = true;
144
+ throw replay();
116
145
  }
117
- throw markRetryable(new ApiError(error.message, error.code, error.statusCode, error.details));
118
146
  }
147
+ this.memoCache = null;
148
+ await this.getAuthorizationHeader(true);
149
+ authState.usedLoginToken = true;
150
+ throw replay();
119
151
  }
120
152
  buildUrl(path) {
121
153
  const base = this.config.baseUrl.endsWith("/") ? this.config.baseUrl : `${this.config.baseUrl}/`;
@@ -184,15 +216,18 @@ export class GangtiseClient {
184
216
  }
185
217
  const windowRoom = maxWindow === undefined ? Infinity : maxWindow - startFrom;
186
218
  const billing = endpoint.billing;
187
- const guarded = requestedSize === undefined && billing?.per === "row" && !this.allowCostlyFetch;
219
+ const guarded = billing?.per === "row" && !this.allowCostlyFetch
220
+ && (requestedSize === undefined || requestedSize * billing.price > COSTLY_FETCH_CREDITS);
188
221
  const refuseIfCostly = (reportedTotal, fetched) => {
189
222
  if (!guarded || !billing)
190
223
  return;
191
- const rows = Math.min(Math.max(reportedTotal - startFrom, 0), windowRoom);
224
+ const rows = Math.min(Math.max(reportedTotal - startFrom, 0), windowRoom, requestedSize ?? Infinity);
192
225
  const estimatedCredits = Math.round(rows * billing.price * 100) / 100;
193
226
  if (estimatedCredits <= COSTLY_FETCH_CREDITS)
194
227
  return;
195
- throw new ValidationError(`fetching all ${rows} rows of ${endpoint.key} would cost about ${estimatedCredits} credits (${billing.price} per row), above the ${COSTLY_FETCH_CREDITS}-credit guard for a fetch without --size. Only ${fetched} row(s) were fetched, to learn the total. Pass --size N for a bounded subset, or --yes to fetch them all.`);
228
+ const what = requestedSize === undefined ? `all ${rows} rows` : `${rows} rows (--size ${requestedSize})`;
229
+ const escape = requestedSize === undefined ? "Pass --size N for a bounded subset" : "Pass a smaller --size";
230
+ throw new ValidationError(`fetching ${what} of ${endpoint.key} would cost about ${estimatedCredits} credits (${billing.price} per row), above the ${COSTLY_FETCH_CREDITS}-credit guard. Only ${fetched} row(s) were fetched, to learn the total. ${escape}, or --yes to fetch them all.`);
196
231
  };
197
232
  let probe;
198
233
  let probed = false;
@@ -206,7 +241,7 @@ export class GangtiseClient {
206
241
  else {
207
242
  refuseIfCostly(probe.total, probe.list.length);
208
243
  probeRows = probe.list.length;
209
- probed = probe.list.length === Math.min(Math.max(probe.total - startFrom, 0), windowRoom);
244
+ probed = probe.list.length === Math.min(Math.max(probe.total - startFrom, 0), windowRoom, requestedSize ?? Infinity);
210
245
  }
211
246
  }
212
247
  const firstPageSize = probed ? 1 : Math.min(requestedSize === undefined ? maxPageSize : Math.min(maxPageSize, requestedSize), windowRoom);
@@ -245,29 +280,33 @@ export class GangtiseClient {
245
280
  const dropRepeats = (rows) => {
246
281
  if (!seen || !rowId)
247
282
  return rows;
248
- const onThisPage = new Set();
283
+ const onThisPage = new Map();
249
284
  return rows.filter((row) => {
250
285
  const id = row && typeof row === "object" ? row[rowId] : undefined;
251
286
  if (id === undefined || id === null)
252
287
  return true;
253
- const digest = createHash("sha1").update(JSON.stringify(row)).digest("base64");
288
+ const digest = createHash("sha1").update(stableStringify(row)).digest("base64");
254
289
  const key = String(id);
255
- const previous = seen.get(key);
256
- const earlierOnThisPage = onThisPage.has(key);
257
- onThisPage.add(key);
258
- if (previous === undefined) {
259
- seen.set(key, digest);
290
+ const pageVersions = onThisPage.get(key);
291
+ if (pageVersions && !pageVersions.has(digest))
292
+ idIsRowKey = false;
293
+ if (pageVersions)
294
+ pageVersions.add(digest);
295
+ else
296
+ onThisPage.set(key, new Set([digest]));
297
+ const versions = seen.get(key);
298
+ if (versions === undefined) {
299
+ seen.set(key, new Set([digest]));
260
300
  return true;
261
301
  }
262
- if (previous !== digest) {
263
- if (earlierOnThisPage)
264
- idIsRowKey = false;
265
- else
266
- changedRows++;
267
- return true;
302
+ if (versions.has(digest)) {
303
+ duplicateRows++;
304
+ return false;
268
305
  }
269
- duplicateRows++;
270
- return false;
306
+ versions.add(digest);
307
+ if (!pageVersions)
308
+ changedRows++;
309
+ return true;
271
310
  });
272
311
  };
273
312
  const keep = async (fetched) => {
@@ -433,7 +472,7 @@ export class GangtiseClient {
433
472
  }
434
473
  const dispatcher = getDispatcher();
435
474
  const url = this.buildUrl(endpoint.path);
436
- const authState = { retried: false };
475
+ const authState = {};
437
476
  const timeoutMs = resolveTimeoutMs(this.config.timeoutMs, endpoint);
438
477
  return withRetry(async () => {
439
478
  const isUpload = endpoint.kind === 'upload';
@@ -454,6 +493,7 @@ export class GangtiseClient {
454
493
  body: endpoint.method === 'GET' ? undefined : (isUpload ? body : JSON.stringify(body ?? {})),
455
494
  headersTimeout: timeoutMs,
456
495
  bodyTimeout: timeoutMs,
496
+ signal: AbortSignal.timeout(timeoutMs * (isUpload ? TRANSFER_TOTAL_FACTOR : JSON_TOTAL_FACTOR)),
457
497
  dispatcher,
458
498
  });
459
499
  const encoding = response.headers['content-encoding'];
@@ -510,7 +550,7 @@ export class GangtiseClient {
510
550
  }).catch((error) => {
511
551
  const isIndicatorFetch = endpoint.key === 'indicator.cross-section' || endpoint.key === 'indicator.time-series' || endpoint.key === 'indicator.screener';
512
552
  if (isIndicatorFetch && error instanceof ApiError && error.code === '999999') {
513
- throw new ApiError(error.message, error.code, error.statusCode, error.details, error.retryAfterMs, 'EDE 取数故障。先核对参数再重试:参数名以 indicator search 的 parameterList 为准(传错名会报 100003 并指名)、日期匹配指标周期(财务报表类用报告期末如 2025-12-31,PE/PB 等日频估值用交易日)、标的在 scopeList 覆盖内、required 参数已补。注意此码不表示无数据——无数据返回保留行列的 null 单元格;code 或参数名写错报 100003、缺必填参数报 100001,都会指名。');
553
+ throw new ApiError(error.message, error.code, error.statusCode, error.details, error.retryAfterMs, 'EDE 取数故障。先核对参数再重试:参数名以 indicator search 的 parameterList 为准(传错名会报 100003 并指名)、日期匹配指标周期(财务报表类用报告期末如 2025-12-31,PE/PB 等日频估值用交易日)、标的在 scopeList 覆盖内、required 参数已补。注意此码不表示无数据——无数据返回保留行列的 null 单元格;code 或参数名写错报 100003、缺必填参数报 100001,都会指名(fiscalYear 例外:漏传不报错,返回 null 或默认年度的值)。');
514
554
  }
515
555
  throw error;
516
556
  });
@@ -534,11 +574,12 @@ export class GangtiseClient {
534
574
  Object.entries(query).forEach(([key, value]) => {
535
575
  url.searchParams.set(key, String(value));
536
576
  });
537
- const authState = { retried: false };
577
+ const authState = {};
538
578
  const timeoutMs = resolveTimeoutMs(this.config.timeoutMs, endpoint);
539
579
  return withRetry(async () => {
540
580
  const authorization = await this.getAuthorizationHeader();
541
581
  const startedAt = Date.now();
582
+ const signal = AbortSignal.timeout(timeoutMs * TRANSFER_TOTAL_FACTOR);
542
583
  let currentUrl = url;
543
584
  let auth = authorization;
544
585
  const isPost = endpoint.method === 'POST';
@@ -550,6 +591,7 @@ export class GangtiseClient {
550
591
  body: isPost ? JSON.stringify(body ?? {}) : undefined,
551
592
  headersTimeout: timeoutMs,
552
593
  bodyTimeout: timeoutMs,
594
+ signal,
553
595
  dispatcher,
554
596
  });
555
597
  for (let hops = 0; hops < 3 && response.statusCode >= 300 && response.statusCode < 400; hops++) {
@@ -567,6 +609,7 @@ export class GangtiseClient {
567
609
  headers: auth ? { Authorization: auth } : {},
568
610
  headersTimeout: timeoutMs,
569
611
  bodyTimeout: timeoutMs,
612
+ signal,
570
613
  dispatcher,
571
614
  });
572
615
  }
@@ -662,6 +705,23 @@ export class GangtiseClient {
662
705
  },
663
706
  });
664
707
  }
708
+ refuseIfEstimatedCostly(endpoint, body) {
709
+ const billing = endpoint.billing;
710
+ if (!endpoint.estimateBilledUnits || !billing || this.allowCostlyFetch)
711
+ return;
712
+ let units;
713
+ try {
714
+ units = endpoint.estimateBilledUnits(body && typeof body === "object" ? body : {});
715
+ }
716
+ catch (error) {
717
+ const why = error instanceof Error ? error.message : String(error);
718
+ throw new ValidationError(`${endpoint.key} could not be priced before sending (${why}), and it is billed per ${billing.per} with no upper bound. Fix the request, or pass --yes to send it as it is.`);
719
+ }
720
+ const credits = Math.ceil(units * billing.price);
721
+ if (credits <= COSTLY_FETCH_CREDITS)
722
+ return;
723
+ throw new ValidationError(`${endpoint.key} would cost about ${credits} credits (${billing.price} per ${billing.per}, about ${units} estimated from the requested date range), above the ${COSTLY_FETCH_CREDITS}-credit guard. Narrow the date range, or pass --yes to send it.`);
724
+ }
665
725
  async call(endpointKey, body, query, options) {
666
726
  const endpoint = ENDPOINTS[endpointKey];
667
727
  if (!endpoint) {
@@ -673,6 +733,7 @@ export class GangtiseClient {
673
733
  if (endpoint.kind === 'download') {
674
734
  return this.download(endpoint, query ?? {}, options, body);
675
735
  }
736
+ this.refuseIfEstimatedCostly(endpoint, body);
676
737
  if (endpoint.kind === 'json' && endpoint.pagination?.enabled) {
677
738
  return this.requestPaginated(endpoint, body);
678
739
  }
@@ -1,4 +1,4 @@
1
- import { maybeArray, parseFrom, parseIndicatorParams, parseOptionalNumberOption, parseScreenerIndicators, parseSize } from "./args.js";
1
+ import { maybeArray, parseIndicatorParams, parseOptionalNumberOption, parseScreenerIndicators } from "./args.js";
2
2
  import { ValidationError } from "./errors.js";
3
3
  import { QUOTE_MAX_LIMIT } from "./quoteSharding.js";
4
4
  export function buildQuoteKlineBody(options) {
@@ -10,32 +10,6 @@ export function buildQuoteKlineBody(options) {
10
10
  fieldList: maybeArray(options.field),
11
11
  };
12
12
  }
13
- export function buildWechatMessageListBody(options) {
14
- return {
15
- from: parseFrom(options.from),
16
- size: parseSize(options.size),
17
- startTime: options.startTime,
18
- endTime: options.endTime,
19
- keyword: options.keyword,
20
- securityList: maybeArray(options.security),
21
- wechatGroupIdList: maybeArray(options.wechatGroupId),
22
- industryIdList: maybeArray(options.industry),
23
- categoryList: maybeArray(options.category),
24
- tagList: maybeArray(options.tag),
25
- };
26
- }
27
- export function buildWechatChatroomListBody(options) {
28
- return {
29
- from: parseFrom(options.from),
30
- size: parseSize(options.size),
31
- roomName: options.roomName.length > 0 ? options.roomName.join(",") : undefined,
32
- };
33
- }
34
- export function buildStockPoolStocksBody(options) {
35
- return {
36
- poolIdList: options.poolId?.length ? options.poolId : ["all"],
37
- };
38
- }
39
13
  const DATE_PARAM_KEYS = new Set(["tradeDate", "reportDate"]);
40
14
  function assertParamCodesBound(groups, codes) {
41
15
  if (!groups?.length)
@@ -12,14 +12,18 @@ export function loadConfig() {
12
12
  process.stderr.write(`[gangtise] warning: GANGTISE_TIMEOUT_MS=${rawTimeout} is not in effect, using ${timeoutMs} ms: expected whole milliseconds from ${MIN_TIMEOUT_MS} to ${MAX_TIMEOUT_MS}\n`);
13
13
  }
14
14
  return {
15
- baseUrl: process.env.GANGTISE_BASE_URL ?? DEFAULT_BASE_URL,
15
+ baseUrl: envValue("GANGTISE_BASE_URL") ?? DEFAULT_BASE_URL,
16
16
  timeoutMs,
17
- accessKey: process.env.GANGTISE_ACCESS_KEY,
18
- secretKey: process.env.GANGTISE_SECRET_KEY,
19
- token: process.env.GANGTISE_TOKEN,
20
- tokenCachePath: process.env.GANGTISE_TOKEN_CACHE_PATH ?? DEFAULT_TOKEN_CACHE_PATH,
17
+ accessKey: envValue("GANGTISE_ACCESS_KEY"),
18
+ secretKey: envValue("GANGTISE_SECRET_KEY"),
19
+ token: envValue("GANGTISE_TOKEN"),
20
+ tokenCachePath: envValue("GANGTISE_TOKEN_CACHE_PATH") ?? DEFAULT_TOKEN_CACHE_PATH,
21
21
  };
22
22
  }
23
+ function envValue(name) {
24
+ const value = process.env[name]?.trim();
25
+ return value ? value : undefined;
26
+ }
23
27
  export const MIN_TIMEOUT_MS = 1_000;
24
28
  export const MAX_TIMEOUT_MS = 3_600_000;
25
29
  export function resolveTimeoutEnv(raw) {
@@ -84,10 +84,27 @@ export function extFromContentType(contentType) {
84
84
  const mime = contentType.split(";")[0].trim().toLowerCase();
85
85
  return MIME_EXT[mime] ?? "";
86
86
  }
87
+ const KNOWN_EXT = new Set(Object.values(MIME_EXT).concat([".md", ".jpeg", ".htm"]));
88
+ export function extFromUrl(url) {
89
+ try {
90
+ const ext = extname(decodeURIComponent(new URL(url).pathname)).toLowerCase();
91
+ return KNOWN_EXT.has(ext) ? ext : "";
92
+ }
93
+ catch {
94
+ return "";
95
+ }
96
+ }
97
+ function resultExt(file) {
98
+ if (file.filename)
99
+ return extname(file.filename);
100
+ if (typeof file.url === "string")
101
+ return extFromUrl(file.url);
102
+ return extFromContentType(file.contentType);
103
+ }
87
104
  export async function resolveTitle(client, result, listEndpoint, idField, idValue, options = {}) {
88
105
  const titleField = options.titleField ?? "title";
89
106
  const file = result;
90
- const serverExt = file.filename ? extname(file.filename) : extFromContentType(file.contentType);
107
+ const serverExt = resultExt(file);
91
108
  function buildFilename(rawTitle) {
92
109
  let title = sanitizeFilename(rawTitle).trim();
93
110
  if (serverExt && !title.toLowerCase().endsWith(serverExt.toLowerCase())) {
@@ -130,7 +147,7 @@ async function downloadUrlTo(url, outputPath) {
130
147
  const { pipeline } = await import("node:stream/promises");
131
148
  const { dirname } = await import("node:path");
132
149
  const { request } = await import("undici");
133
- const { getDispatcher, logTiming, withRetry } = await import("./transport.js");
150
+ const { getDispatcher, logTiming, TRANSFER_TOTAL_FACTOR, withRetry } = await import("./transport.js");
134
151
  const { loadConfig } = await import("./config.js");
135
152
  const timeoutMs = loadConfig().timeoutMs;
136
153
  await fs.mkdir(dirname(outputPath), { recursive: true });
@@ -150,7 +167,7 @@ async function downloadUrlTo(url, outputPath) {
150
167
  method: "GET",
151
168
  headersTimeout: timeoutMs,
152
169
  bodyTimeout: timeoutMs,
153
- signal: AbortSignal.timeout(timeoutMs * 10),
170
+ signal: AbortSignal.timeout(timeoutMs * TRANSFER_TOTAL_FACTOR),
154
171
  dispatcher: getDispatcher(),
155
172
  };
156
173
  let currentUrl = url;
@@ -189,6 +206,23 @@ async function downloadUrlTo(url, outputPath) {
189
206
  throw error;
190
207
  }
191
208
  }
209
+ export function autoDownloadName(file, fallbackName) {
210
+ return truncateFilename(file.filename ? sanitizeFilename(file.filename) : sanitizeFilename(fallbackName) + extFromContentType(file.contentType));
211
+ }
212
+ export async function placeStreamedDownload(file, fallbackName, named) {
213
+ let target;
214
+ try {
215
+ target = await uniquePath(truncateFilename(named ?? autoDownloadName(file, fallbackName)));
216
+ await fs.rename(file.savedPath, target);
217
+ }
218
+ catch (error) {
219
+ if (target)
220
+ await releaseClaim(target);
221
+ await fs.unlink(file.savedPath).catch(() => { });
222
+ throw error;
223
+ }
224
+ return target;
225
+ }
192
226
  export async function saveDownloadResult(result, fallbackName, output) {
193
227
  if (!(result && typeof result === "object")) {
194
228
  throw new DownloadError("Unexpected download response");
@@ -200,8 +234,7 @@ export async function saveDownloadResult(result, fallbackName, output) {
200
234
  return;
201
235
  }
202
236
  if (file.data instanceof Uint8Array) {
203
- const autoName = (file.filename ? sanitizeFilename(file.filename) : undefined) ?? (safeFallback + extFromContentType(file.contentType));
204
- const outputPath = output ?? await uniquePath(truncateFilename(autoName));
237
+ const outputPath = output ?? await uniquePath(autoDownloadName(file, fallbackName));
205
238
  try {
206
239
  await saveOutputIfNeeded(file.data, outputPath, false);
207
240
  }
@@ -227,12 +260,16 @@ export async function saveDownloadResult(result, fallbackName, output) {
227
260
  return;
228
261
  }
229
262
  if (typeof file.url === "string") {
230
- if (output) {
231
- await downloadUrlTo(file.url, output);
232
- process.stdout.write(`${output}\n`);
233
- return;
263
+ const outputPath = output ?? await uniquePath(truncateFilename(safeFallback + extFromUrl(file.url)));
264
+ try {
265
+ await downloadUrlTo(file.url, outputPath);
266
+ }
267
+ catch (error) {
268
+ if (!output)
269
+ await releaseClaim(outputPath);
270
+ throw error;
234
271
  }
235
- process.stdout.write(`${file.url}\n`);
272
+ process.stdout.write(`${outputPath}\n`);
236
273
  return;
237
274
  }
238
275
  throw new DownloadError("Unexpected download response");