gaoding-cli 1.0.0-alpha.21 → 1.0.0-alpha.22

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 (37) hide show
  1. package/contracts/operations/dam.copy/input.schema.json +19 -0
  2. package/contracts/operations/dam.copy/output.schema.json +19 -0
  3. package/contracts/operations/dam.download/input.schema.json +14 -0
  4. package/contracts/operations/dam.download/output.schema.json +25 -0
  5. package/contracts/operations/dam.folder.list/input.schema.json +15 -0
  6. package/contracts/operations/dam.folder.list/output.schema.json +37 -0
  7. package/contracts/operations/dam.list/output.schema.json +3 -3
  8. package/contracts/operations/dam.move/input.schema.json +19 -0
  9. package/contracts/operations/dam.move/output.schema.json +18 -0
  10. package/contracts/operations/dam.recycle.list/input.schema.json +13 -0
  11. package/contracts/operations/dam.recycle.list/output.schema.json +34 -0
  12. package/contracts/operations/dam.recycle.restore/input.schema.json +14 -0
  13. package/contracts/operations/dam.recycle.restore/output.schema.json +6 -0
  14. package/contracts/operations/dam.rename/input.schema.json +18 -0
  15. package/contracts/operations/dam.rename/output.schema.json +18 -0
  16. package/contracts/operations/dam.repository.list/input.schema.json +8 -0
  17. package/contracts/operations/dam.repository.list/output.schema.json +52 -0
  18. package/contracts/operations/dam.search/output.schema.json +3 -3
  19. package/contracts/operations/dam.tag.list/input.schema.json +12 -0
  20. package/contracts/operations/dam.tag.list/output.schema.json +26 -0
  21. package/dist/src/bootstrap/create-runtime.js +24 -0
  22. package/dist/src/bootstrap/validators.js +39 -0
  23. package/dist/src/cli/dam-commands.js +248 -1
  24. package/dist/src/cli/errors.js +22 -2
  25. package/dist/src/cli/presenter.js +12 -0
  26. package/dist/src/features/dam/dam-api-adapter.js +369 -28
  27. package/dist/src/features/dam/file-downloader.js +242 -0
  28. package/dist/src/features/dam/operation-executor.js +72 -0
  29. package/dist/src/features/dam/repository-catalog.js +127 -0
  30. package/dist/src/features/dam/use-cases.js +323 -11
  31. package/dist/src/features/editor/bridge-server.js +6 -1
  32. package/dist/src/platform/signature.js +2 -1
  33. package/dist/src/platform/signed-http-transport.js +9 -1
  34. package/package.json +1 -1
  35. package/skills/gd-cli/SKILL.md +1 -1
  36. package/skills/gd-cli/references/dam.md +45 -13
  37. package/skills/gd-cli/references/editor.md +3 -1
@@ -1,4 +1,6 @@
1
1
  import { RemoteRequestError } from "../../platform/signed-http-transport.js";
2
+ import { RemoteProtocolError } from "../../platform/remote-protocol.js";
3
+ import { parseSafeRemoteAssetUrl, UrlSafetyError } from "../../platform/url-safety.js";
2
4
  import { projectDamAssetDetail, projectDamAssetSummary } from "./asset-projection.js";
3
5
  export class DamAssetNotFoundError extends Error {
4
6
  constructor() {
@@ -6,6 +8,18 @@ export class DamAssetNotFoundError extends Error {
6
8
  this.name = "DamAssetNotFoundError";
7
9
  }
8
10
  }
11
+ export class DamAssetNotDownloadableError extends Error {
12
+ constructor() {
13
+ super("该 DAM 素材当前不可下载。");
14
+ this.name = "DamAssetNotDownloadableError";
15
+ }
16
+ }
17
+ export class DamFolderNotFoundError extends Error {
18
+ constructor() {
19
+ super("DAM 文件夹不存在。");
20
+ this.name = "DamFolderNotFoundError";
21
+ }
22
+ }
9
23
  const contentFormats = {
10
24
  image: ["gdpic", "gdimage"],
11
25
  video: ["gdvideo"],
@@ -25,28 +39,245 @@ function nonblank(value) {
25
39
  function finiteNumber(value) {
26
40
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
27
41
  }
28
- function repositories(response) {
29
- const source = Array.isArray(response)
30
- ? response
31
- : Array.isArray(record(response)?.data)
32
- ? record(response)?.data
33
- : undefined;
34
- if (source === undefined)
35
- throw new RemoteRequestError();
36
- return source.flatMap((value) => {
37
- const item = record(value);
38
- const id = nonblank(item?.repository_id) ?? nonblank(item?.id);
39
- if (item === undefined || id === undefined)
42
+ function nonnegativeInteger(value) {
43
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : 0;
44
+ }
45
+ const permissions = new Set(["OWNER", "MANAGE", "EDIT", "USE", "READ"]);
46
+ function folderPermission(value) {
47
+ return typeof value === "string"
48
+ && permissions.has(value)
49
+ ? value
50
+ : undefined;
51
+ }
52
+ function optionalString(value) {
53
+ if (value === undefined || value === null)
54
+ return undefined;
55
+ const projected = String(value);
56
+ return projected.trim() === "" ? undefined : projected;
57
+ }
58
+ function folderRows(value) {
59
+ if (!Array.isArray(value))
60
+ throw new RemoteProtocolError();
61
+ return value.flatMap((candidate) => {
62
+ const item = record(candidate);
63
+ const folderId = nonblank(item?.id);
64
+ const title = nonblank(item?.title);
65
+ const parentId = nonblank(item?.parent_id);
66
+ const repositoryId = nonblank(item?.repository_id);
67
+ if (folderId === undefined
68
+ || title === undefined
69
+ || parentId === undefined
70
+ || repositoryId === undefined)
40
71
  return [];
41
- const name = nonblank(item.name) ?? nonblank(item.title);
42
- const type = finiteNumber(item.type);
72
+ const permissionCode = folderPermission(item?.permission_code);
73
+ const path = nonblank(item?.path);
74
+ const createdAt = optionalString(item?.created_at);
75
+ const updatedAt = optionalString(item?.updated_at);
43
76
  return [{
44
- id,
45
- ...(name === undefined ? {} : { name }),
46
- ...(type === undefined ? {} : { type })
77
+ folder_id: folderId,
78
+ title,
79
+ parent_id: parentId,
80
+ repository_id: repositoryId,
81
+ ...(permissionCode === undefined ? {} : { permission_code: permissionCode }),
82
+ child_folder_count: nonnegativeInteger(item?.child_folder_count),
83
+ asset_count: nonnegativeInteger(item?.content_count),
84
+ ...(path === undefined ? {} : { path }),
85
+ ...(createdAt === undefined ? {} : { created_at: createdAt }),
86
+ ...(updatedAt === undefined ? {} : { updated_at: updatedAt })
47
87
  }];
48
88
  });
49
89
  }
90
+ function folderPagination(headers) {
91
+ const raw = headers.get("x-pagination");
92
+ if (raw === null)
93
+ throw new RemoteProtocolError();
94
+ let value;
95
+ try {
96
+ value = JSON.parse(raw);
97
+ }
98
+ catch {
99
+ throw new RemoteProtocolError();
100
+ }
101
+ const pagination = record(value);
102
+ const page = pagination?.num;
103
+ const pageSize = pagination?.size;
104
+ const total = pagination?.total;
105
+ if (!Number.isInteger(page) || page < 1
106
+ || !Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100
107
+ || !Number.isInteger(total) || total < 0) {
108
+ throw new RemoteProtocolError();
109
+ }
110
+ return { page: page, pageSize: pageSize, total: total };
111
+ }
112
+ function tagRows(value) {
113
+ if (!Array.isArray(value))
114
+ throw new RemoteProtocolError();
115
+ return value.flatMap((candidate) => {
116
+ const item = record(candidate);
117
+ const tagId = nonblank(item?.id);
118
+ const name = nonblank(item?.name);
119
+ if (tagId === undefined || name === undefined)
120
+ return [];
121
+ const groupId = nonblank(item?.group_id);
122
+ return [{
123
+ tag_id: tagId,
124
+ name,
125
+ ...(groupId === undefined ? {} : { group_id: groupId }),
126
+ asset_count: nonnegativeInteger(item?.file_count)
127
+ }];
128
+ });
129
+ }
130
+ function downloadUrls(value) {
131
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
132
+ throw new RemoteProtocolError();
133
+ }
134
+ const urls = value.filter((item) => (typeof item === "string" && item.trim() !== ""));
135
+ if (urls.length === 0)
136
+ throw new DamAssetNotDownloadableError();
137
+ for (const value of urls) {
138
+ try {
139
+ const url = parseSafeRemoteAssetUrl(value);
140
+ if (url.protocol !== "https:")
141
+ throw new UrlSafetyError();
142
+ }
143
+ catch (error) {
144
+ if (error instanceof UrlSafetyError)
145
+ throw new RemoteProtocolError();
146
+ throw error;
147
+ }
148
+ }
149
+ return urls;
150
+ }
151
+ function folderDetail(response) {
152
+ for (const source of responseRecords(response).reverse()) {
153
+ const entryId = nonblank(source.id);
154
+ const title = nonblank(source.title);
155
+ const repositoryId = nonblank(source.repository_id);
156
+ const parentId = nonblank(source.parent_id);
157
+ if (entryId !== undefined
158
+ && title !== undefined
159
+ && repositoryId !== undefined
160
+ && parentId !== undefined) {
161
+ return {
162
+ entry_id: entryId,
163
+ kind: "folder",
164
+ title,
165
+ repository_id: repositoryId,
166
+ folder_id: parentId
167
+ };
168
+ }
169
+ }
170
+ throw new RemoteProtocolError();
171
+ }
172
+ function operationSubmission(response) {
173
+ const root = record(response);
174
+ const source = record(root?.data) ?? root;
175
+ if (source === undefined)
176
+ throw new RemoteProtocolError();
177
+ const rawTaskId = source.task_id;
178
+ let taskId;
179
+ if (rawTaskId !== undefined && rawTaskId !== null) {
180
+ if (typeof rawTaskId === "number") {
181
+ if (!Number.isSafeInteger(rawTaskId) || rawTaskId < 0)
182
+ throw new RemoteProtocolError();
183
+ if (rawTaskId !== 0)
184
+ taskId = String(rawTaskId);
185
+ }
186
+ else if (typeof rawTaskId === "string" && rawTaskId.trim() !== "") {
187
+ if (rawTaskId.trim() !== "0")
188
+ taskId = rawTaskId.trim();
189
+ }
190
+ else {
191
+ throw new RemoteProtocolError();
192
+ }
193
+ }
194
+ const rawResultIds = source.result_ids;
195
+ if (rawResultIds !== undefined && !Array.isArray(rawResultIds)) {
196
+ throw new RemoteProtocolError();
197
+ }
198
+ const resultIds = Array.isArray(rawResultIds)
199
+ ? rawResultIds.filter((item) => nonblank(item) !== undefined)
200
+ : [];
201
+ return {
202
+ ...(taskId === undefined ? {} : { task_id: taskId }),
203
+ ...(rawResultIds === undefined ? {} : { result_ids: resultIds })
204
+ };
205
+ }
206
+ function taskRows(response) {
207
+ if (Array.isArray(response))
208
+ return response;
209
+ const data = record(response)?.data;
210
+ if (Array.isArray(data))
211
+ return data;
212
+ throw new RemoteProtocolError();
213
+ }
214
+ function operationTask(response, taskId) {
215
+ const matches = taskRows(response).flatMap((candidate) => {
216
+ const item = record(candidate);
217
+ return nonblank(item?.id) === taskId ? [item] : [];
218
+ });
219
+ if (matches.length === 0)
220
+ return null;
221
+ if (matches.length !== 1)
222
+ throw new RemoteProtocolError();
223
+ const item = matches[0];
224
+ const status = item.status;
225
+ const progress = item.progress;
226
+ const totalCount = item.total_count;
227
+ const successCount = item.success_count;
228
+ const failCount = item.fail_count;
229
+ if (!Number.isInteger(status)
230
+ || typeof progress !== "number" || !Number.isFinite(progress) || progress < 0
231
+ || (totalCount !== null
232
+ && (!Number.isInteger(totalCount) || totalCount < 0))
233
+ || (successCount !== null
234
+ && (!Number.isInteger(successCount) || successCount < 0))
235
+ || (failCount !== null
236
+ && (!Number.isInteger(failCount) || failCount < 0))) {
237
+ throw new RemoteProtocolError();
238
+ }
239
+ return {
240
+ id: taskId,
241
+ status: status,
242
+ progress,
243
+ total_count: totalCount,
244
+ success_count: successCount,
245
+ fail_count: failCount
246
+ };
247
+ }
248
+ function operationChildren(response) {
249
+ const root = record(response);
250
+ const source = record(root?.data) ?? root;
251
+ if (source === undefined)
252
+ throw new RemoteProtocolError();
253
+ const rawResultIds = source.result_ids;
254
+ if (rawResultIds !== undefined && !Array.isArray(rawResultIds)) {
255
+ throw new RemoteProtocolError();
256
+ }
257
+ const repositoryId = nonblank(source.repository_id);
258
+ const targetRepositoryId = nonblank(source.target_repository_id);
259
+ const targetParentId = nonblank(source.target_parent_id);
260
+ return {
261
+ result_ids: Array.isArray(rawResultIds)
262
+ ? rawResultIds.filter((item) => nonblank(item) !== undefined)
263
+ : [],
264
+ ...(repositoryId === undefined ? {} : { repository_id: repositoryId }),
265
+ ...(targetRepositoryId === undefined ? {} : {
266
+ target_repository_id: targetRepositoryId
267
+ }),
268
+ ...(targetParentId === undefined ? {} : { target_parent_id: targetParentId })
269
+ };
270
+ }
271
+ function assertSingleOperationInput(input) {
272
+ if (nonblank(input.entryId) === undefined
273
+ || (input.kind !== "asset" && input.kind !== "folder")
274
+ || (input.operation !== "move" && input.operation !== "copy")
275
+ || nonblank(input.sourceRepositoryId) === undefined
276
+ || nonblank(input.targetRepositoryId) === undefined
277
+ || nonblank(input.targetFolderId) === undefined) {
278
+ throw new TypeError("Invalid DAM single-entry operation input.");
279
+ }
280
+ }
50
281
  function assetId(response) {
51
282
  const root = record(response);
52
283
  const nested = record(root?.data);
@@ -120,13 +351,6 @@ function recycleBody(input) {
120
351
  }
121
352
  export function createDamApiAdapter(dependencies) {
122
353
  return {
123
- async listRepositories(input) {
124
- const response = await dependencies.transport.getJson({
125
- path: "/tb-dam/repositories",
126
- ...requestContext(input)
127
- });
128
- return repositories(response);
129
- },
130
354
  async search(input) {
131
355
  const response = await dependencies.transport.postJson({
132
356
  path: "/dam/asset/v2/search/simple",
@@ -137,18 +361,16 @@ export function createDamApiAdapter(dependencies) {
137
361
  include_child_folder: input.includeChildFolder,
138
362
  recycle: false,
139
363
  page_size: input.pageSize,
140
- page_num: 1,
141
- page: 1,
142
364
  sort: ["-top_time", "-updated_at"],
143
365
  ...(input.cursor === undefined
144
366
  ? {}
145
- : { query_id: input.cursor, cursor: input.cursor }),
367
+ : { query_id: input.cursor }),
146
368
  ...(input.type === undefined
147
369
  ? {}
148
370
  : { content_format_list: contentFormats[input.type] }),
149
371
  ...(input.assetFormats === undefined || input.assetFormats.length === 0
150
372
  ? {}
151
- : { format_list: input.assetFormats }),
373
+ : { format: input.assetFormats }),
152
374
  ...(input.tagIds === undefined || input.tagIds.length === 0
153
375
  ? {}
154
376
  : { tag_ids: input.tagIds })
@@ -157,6 +379,125 @@ export function createDamApiAdapter(dependencies) {
157
379
  });
158
380
  return searchResult(response, input);
159
381
  },
382
+ async listFolders(input) {
383
+ const response = await dependencies.transport.getJsonResponse({
384
+ path: "/tb-dam/folders",
385
+ query: {
386
+ repository_ids: input.repositoryId,
387
+ parent_id: input.parentId,
388
+ keyword: input.query ?? "",
389
+ page_num: String(input.page),
390
+ page_size: String(input.pageSize),
391
+ filter_no_permission: "true",
392
+ include_children: String(input.query !== undefined),
393
+ sort: "topTime:desc,updatedAt:desc"
394
+ },
395
+ ...requestContext(input)
396
+ });
397
+ const pagination = folderPagination(response.headers);
398
+ return {
399
+ repository_id: input.repositoryId,
400
+ parent_id: input.parentId,
401
+ page: pagination.page,
402
+ page_size: pagination.pageSize,
403
+ total: pagination.total,
404
+ folders: folderRows(response.body)
405
+ };
406
+ },
407
+ async listTags(input) {
408
+ const response = await dependencies.transport.getJson({
409
+ path: "/tb-dam/tags",
410
+ query: {
411
+ repository_id: input.repositoryId,
412
+ group_id: "",
413
+ ...(input.query === undefined ? {} : { keyword: input.query })
414
+ },
415
+ ...requestContext(input)
416
+ });
417
+ return { repository_id: input.repositoryId, tags: tagRows(response) };
418
+ },
419
+ async getDownloadUrls(input) {
420
+ const response = await dependencies.transport.getJson({
421
+ path: `/dam/asset/files/download/${encodeURIComponent(input.assetId)}/url-list`,
422
+ query: { repository_id: input.repositoryId },
423
+ ...requestContext(input)
424
+ });
425
+ return downloadUrls(response);
426
+ },
427
+ async getFolder(input) {
428
+ let response;
429
+ try {
430
+ response = await dependencies.transport.getJson({
431
+ path: `/dam/folders/${encodeURIComponent(input.folderId)}`,
432
+ query: { repository_id: input.repositoryId },
433
+ ...requestContext(input)
434
+ });
435
+ }
436
+ catch (error) {
437
+ if (error instanceof RemoteRequestError && error.status === 404) {
438
+ throw new DamFolderNotFoundError();
439
+ }
440
+ throw error;
441
+ }
442
+ return folderDetail(response);
443
+ },
444
+ async renameEntry(input) {
445
+ await dependencies.transport.putJson({
446
+ path: input.kind === "asset"
447
+ ? `/dam/asset/${encodeURIComponent(input.entryId)}/rename`
448
+ : `/dam/folders/${encodeURIComponent(input.entryId)}/rename`,
449
+ body: {
450
+ id: input.entryId,
451
+ repository_id: input.repositoryId,
452
+ title: input.title
453
+ },
454
+ ...requestContext(input)
455
+ });
456
+ },
457
+ async submitEntryOperation(input) {
458
+ assertSingleOperationInput(input);
459
+ const asset = input.kind === "asset";
460
+ const body = asset
461
+ ? {
462
+ source_infos: [{
463
+ source_repository_id: input.sourceRepositoryId,
464
+ content_ids: [input.entryId]
465
+ }],
466
+ target_folder_id: input.targetFolderId,
467
+ target_repository_id: input.targetRepositoryId,
468
+ ...(input.operation === "copy" ? { expand: false } : {})
469
+ }
470
+ : {
471
+ folder_ids: [input.entryId],
472
+ parent_id: input.targetFolderId,
473
+ repository_id: input.sourceRepositoryId,
474
+ target_repository_id: input.targetRepositoryId,
475
+ ...(input.operation === "copy" ? { expand: false } : {})
476
+ };
477
+ const response = await dependencies.transport.postJson({
478
+ path: asset
479
+ ? `/dam/asset/multi-repository/${input.operation}`
480
+ : `/dam/folders/v2/${input.operation}`,
481
+ body,
482
+ ...requestContext(input)
483
+ });
484
+ return operationSubmission(response);
485
+ },
486
+ async getOperationTask(input) {
487
+ const response = await dependencies.transport.getJson({
488
+ path: "/dam/task/list",
489
+ query: { ids: input.taskId },
490
+ ...requestContext(input)
491
+ });
492
+ return operationTask(response, input.taskId);
493
+ },
494
+ async getOperationChildren(input) {
495
+ const response = await dependencies.transport.getJson({
496
+ path: `/dam/task/children-detail/${encodeURIComponent(input.taskId)}`,
497
+ ...requestContext(input)
498
+ });
499
+ return operationChildren(response);
500
+ },
160
501
  async getAsset(input) {
161
502
  let response;
162
503
  try {
@@ -0,0 +1,242 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { access, mkdir, open, rename, rm } from "node:fs/promises";
3
+ import { extname, resolve } from "node:path";
4
+ import { parseSafeRemoteAssetUrl } from "../../platform/url-safety.js";
5
+ export class DamDownloadError extends Error {
6
+ constructor() {
7
+ super("素材下载失败。");
8
+ this.name = "DamDownloadError";
9
+ }
10
+ }
11
+ export class DamDownloadDestinationExistsError extends Error {
12
+ constructor() {
13
+ super("目标文件已存在,未覆盖任何文件。");
14
+ this.name = "DamDownloadDestinationExistsError";
15
+ }
16
+ }
17
+ const redirectStatuses = new Set([301, 302, 303, 307, 308]);
18
+ const maximumRedirects = 5;
19
+ const maximumFilenameLength = 180;
20
+ const nodeFileSystem = {
21
+ async mkdir(path, options) {
22
+ await mkdir(path, options);
23
+ },
24
+ async open(path, flags, mode) {
25
+ const handle = await open(path, flags, mode);
26
+ return {
27
+ write: async (data) => handle.write(data),
28
+ sync: async () => handle.sync(),
29
+ close: async () => handle.close()
30
+ };
31
+ },
32
+ access,
33
+ rename,
34
+ async rm(path, options) {
35
+ await rm(path, options);
36
+ }
37
+ };
38
+ function safeDownloadUrl(value) {
39
+ try {
40
+ const url = parseSafeRemoteAssetUrl(value);
41
+ if (url.protocol !== "https:")
42
+ throw new Error("HTTPS required");
43
+ return url;
44
+ }
45
+ catch {
46
+ throw new DamDownloadError();
47
+ }
48
+ }
49
+ function decodedFilename(value) {
50
+ const encoded = /(?:^|;)\s*filename\*\s*=\s*(?:UTF-8'')?("?)([^;"]*)\1/iu.exec(value);
51
+ if (encoded?.[2] !== undefined) {
52
+ try {
53
+ return decodeURIComponent(encoded[2].trim());
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
59
+ const plain = /(?:^|;)\s*filename\s*=\s*(?:"([^"]*)"|([^;]*))/iu.exec(value);
60
+ return plain?.[1]?.trim() ?? plain?.[2]?.trim();
61
+ }
62
+ function safeFormat(value) {
63
+ const format = value?.trim().replace(/^\.+/u, "").replace(/[^a-zA-Z0-9]+/gu, "");
64
+ return format === undefined || format === "" ? "bin" : format.toLowerCase();
65
+ }
66
+ function truncateFilename(value) {
67
+ if (value.length <= maximumFilenameLength)
68
+ return value;
69
+ const extension = extname(value);
70
+ const extensionLength = Math.min(extension.length, 20);
71
+ const safeExtension = extension.slice(0, extensionLength);
72
+ const stemLength = maximumFilenameLength - safeExtension.length;
73
+ return `${value.slice(0, Math.max(1, stemLength))}${safeExtension}`;
74
+ }
75
+ function safeFilename(candidate, fallback, format) {
76
+ const raw = (candidate === undefined || candidate.trim() === "") ? fallback : candidate;
77
+ const segments = raw.normalize("NFKC").replace(/[\u0000-\u001f\u007f]/gu, "")
78
+ .split(/[\\/]+/u);
79
+ let filename = (segments.at(-1) ?? "").trim().replace(/^\.+/u, "");
80
+ if (filename === "" || filename === "." || filename === "..")
81
+ filename = "asset";
82
+ if (extname(filename) === "")
83
+ filename = `${filename}.${format}`;
84
+ return truncateFilename(filename);
85
+ }
86
+ function withIndex(filename, index, total) {
87
+ if (total === 1)
88
+ return filename;
89
+ const extension = extname(filename);
90
+ const stem = extension === "" ? filename : filename.slice(0, -extension.length);
91
+ return truncateFilename(`${stem}-${index + 1}${extension}`);
92
+ }
93
+ async function fetchDownload(initial, fetch, signal) {
94
+ let url = safeDownloadUrl(initial);
95
+ for (let redirects = 0; redirects <= maximumRedirects; redirects += 1) {
96
+ signal.throwIfAborted();
97
+ let response;
98
+ try {
99
+ response = await fetch(url, { method: "GET", redirect: "manual", signal });
100
+ }
101
+ catch {
102
+ if (signal.aborted)
103
+ throw signal.reason;
104
+ throw new DamDownloadError();
105
+ }
106
+ signal.throwIfAborted();
107
+ if (!redirectStatuses.has(response.status)) {
108
+ if (!response.ok || response.body === null)
109
+ throw new DamDownloadError();
110
+ return response;
111
+ }
112
+ const location = response.headers.get("location");
113
+ if (location === null || redirects === maximumRedirects)
114
+ throw new DamDownloadError();
115
+ try {
116
+ url = safeDownloadUrl(new URL(location, url).href);
117
+ }
118
+ catch {
119
+ throw new DamDownloadError();
120
+ }
121
+ finally {
122
+ await response.body?.cancel().catch(() => undefined);
123
+ }
124
+ }
125
+ throw new DamDownloadError();
126
+ }
127
+ async function writeAll(handle, data) {
128
+ let offset = 0;
129
+ while (offset < data.byteLength) {
130
+ const { bytesWritten } = await handle.write(data.subarray(offset));
131
+ if (!Number.isInteger(bytesWritten) || bytesWritten <= 0)
132
+ throw new DamDownloadError();
133
+ offset += bytesWritten;
134
+ }
135
+ }
136
+ function isMissingFile(error) {
137
+ return error !== null
138
+ && typeof error === "object"
139
+ && "code" in error
140
+ && error.code === "ENOENT";
141
+ }
142
+ export function createDamFileDownloader(options = {}) {
143
+ const dependencies = {
144
+ fetch: options.fetch ?? globalThis.fetch,
145
+ fileSystem: options.fileSystem ?? nodeFileSystem,
146
+ randomId: options.randomId ?? randomUUID,
147
+ cwd: options.cwd ?? process.cwd
148
+ };
149
+ return {
150
+ async download(input) {
151
+ input.signal.throwIfAborted();
152
+ if (input.urls.length === 0)
153
+ throw new DamDownloadError();
154
+ const outputDirectory = resolve(dependencies.cwd(), input.outputDirectory);
155
+ const pending = [];
156
+ const createdFinalPaths = [];
157
+ try {
158
+ await dependencies.fileSystem.mkdir(outputDirectory, { recursive: true });
159
+ for (const [index, rawUrl] of input.urls.entries()) {
160
+ input.signal.throwIfAborted();
161
+ const response = await fetchDownload(rawUrl, dependencies.fetch, input.signal);
162
+ const format = safeFormat(input.fallbackFormat);
163
+ const fallback = safeFilename(input.fallbackTitle, "asset", format);
164
+ const disposition = response.headers.get("content-disposition");
165
+ const filename = withIndex(safeFilename(disposition === null ? undefined : decodedFilename(disposition), fallback, format), index, input.urls.length);
166
+ const finalPath = resolve(outputDirectory, filename);
167
+ if (!finalPath.startsWith(`${outputDirectory}/`) && finalPath !== outputDirectory) {
168
+ throw new DamDownloadError();
169
+ }
170
+ const temporaryPath = resolve(outputDirectory, `.part-${dependencies.randomId()}-${index + 1}`);
171
+ const handle = await dependencies.fileSystem.open(temporaryPath, "wx", 0o600);
172
+ const item = {
173
+ temporaryPath,
174
+ finalPath,
175
+ handle,
176
+ open: true,
177
+ bytes: 0,
178
+ sha256: ""
179
+ };
180
+ pending.push(item);
181
+ const hash = createHash("sha256");
182
+ const reader = response.body.getReader();
183
+ try {
184
+ while (true) {
185
+ input.signal.throwIfAborted();
186
+ const chunk = await reader.read();
187
+ if (chunk.done)
188
+ break;
189
+ await writeAll(handle, chunk.value);
190
+ hash.update(chunk.value);
191
+ item.bytes += chunk.value.byteLength;
192
+ }
193
+ }
194
+ finally {
195
+ reader.releaseLock();
196
+ }
197
+ item.sha256 = hash.digest("hex");
198
+ }
199
+ for (const item of pending) {
200
+ try {
201
+ await dependencies.fileSystem.access(item.finalPath);
202
+ throw new DamDownloadDestinationExistsError();
203
+ }
204
+ catch (error) {
205
+ if (error instanceof DamDownloadDestinationExistsError)
206
+ throw error;
207
+ if (!isMissingFile(error))
208
+ throw new DamDownloadError();
209
+ }
210
+ }
211
+ for (const item of pending) {
212
+ await item.handle.sync();
213
+ await item.handle.close();
214
+ item.open = false;
215
+ }
216
+ for (const item of pending) {
217
+ await dependencies.fileSystem.rename(item.temporaryPath, item.finalPath);
218
+ createdFinalPaths.push(item.finalPath);
219
+ }
220
+ return pending.map((item) => ({
221
+ path: item.finalPath,
222
+ bytes: item.bytes,
223
+ sha256: item.sha256
224
+ }));
225
+ }
226
+ catch (error) {
227
+ await Promise.all(pending.map(async (item) => {
228
+ if (item.open)
229
+ await item.handle.close().catch(() => undefined);
230
+ await dependencies.fileSystem.rm(item.temporaryPath, { force: true }).catch(() => undefined);
231
+ }));
232
+ await Promise.all(createdFinalPaths.map((path) => (dependencies.fileSystem.rm(path, { force: true }).catch(() => undefined))));
233
+ if (input.signal.aborted)
234
+ throw input.signal.reason;
235
+ if (error instanceof DamDownloadDestinationExistsError
236
+ || error instanceof DamDownloadError)
237
+ throw error;
238
+ throw new DamDownloadError();
239
+ }
240
+ }
241
+ };
242
+ }