@meshy-ai/cli 0.2.0 → 0.3.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 (285) hide show
  1. package/.env.example +8 -2
  2. package/README.md +342 -108
  3. package/dist/client/endpoints/animation-catalog.d.ts +37 -0
  4. package/dist/client/endpoints/animation-catalog.d.ts.map +1 -0
  5. package/dist/client/endpoints/animation-catalog.js +55 -0
  6. package/dist/client/endpoints/animation-catalog.js.map +1 -0
  7. package/dist/client/endpoints/balance.d.ts +5 -0
  8. package/dist/client/endpoints/balance.d.ts.map +1 -1
  9. package/dist/client/endpoints/balance.js +5 -1
  10. package/dist/client/endpoints/balance.js.map +1 -1
  11. package/dist/client/endpoints/base.d.ts +41 -7
  12. package/dist/client/endpoints/base.d.ts.map +1 -1
  13. package/dist/client/endpoints/base.js +61 -40
  14. package/dist/client/endpoints/base.js.map +1 -1
  15. package/dist/client/endpoints/showcases.d.ts +31 -0
  16. package/dist/client/endpoints/showcases.d.ts.map +1 -0
  17. package/dist/client/endpoints/showcases.js +49 -0
  18. package/dist/client/endpoints/showcases.js.map +1 -0
  19. package/dist/client/errors.d.ts +1 -0
  20. package/dist/client/errors.d.ts.map +1 -1
  21. package/dist/client/errors.js +1 -1
  22. package/dist/client/errors.js.map +1 -1
  23. package/dist/client/index.d.ts +47 -38
  24. package/dist/client/index.d.ts.map +1 -1
  25. package/dist/client/index.js +89 -120
  26. package/dist/client/index.js.map +1 -1
  27. package/dist/client/resource-registry.d.ts +97 -0
  28. package/dist/client/resource-registry.d.ts.map +1 -0
  29. package/dist/client/resource-registry.js +157 -0
  30. package/dist/client/resource-registry.js.map +1 -0
  31. package/dist/client/transport.d.ts +99 -0
  32. package/dist/client/transport.d.ts.map +1 -0
  33. package/dist/client/transport.js +320 -0
  34. package/dist/client/transport.js.map +1 -0
  35. package/dist/client/types.d.ts +17 -6
  36. package/dist/client/types.d.ts.map +1 -1
  37. package/dist/client/types.js +28 -6
  38. package/dist/client/types.js.map +1 -1
  39. package/dist/cmd/animation-catalog.d.ts +26 -0
  40. package/dist/cmd/animation-catalog.d.ts.map +1 -0
  41. package/dist/cmd/animation-catalog.js +80 -0
  42. package/dist/cmd/animation-catalog.js.map +1 -0
  43. package/dist/cmd/api.d.ts +3 -2
  44. package/dist/cmd/api.d.ts.map +1 -1
  45. package/dist/cmd/api.js +22 -8
  46. package/dist/cmd/api.js.map +1 -1
  47. package/dist/cmd/auth.d.ts.map +1 -1
  48. package/dist/cmd/auth.js +5 -0
  49. package/dist/cmd/auth.js.map +1 -1
  50. package/dist/cmd/balance.d.ts.map +1 -1
  51. package/dist/cmd/balance.js +10 -6
  52. package/dist/cmd/balance.js.map +1 -1
  53. package/dist/cmd/creative-lab.d.ts +26 -0
  54. package/dist/cmd/creative-lab.d.ts.map +1 -0
  55. package/dist/cmd/creative-lab.js +256 -0
  56. package/dist/cmd/creative-lab.js.map +1 -0
  57. package/dist/cmd/delete.d.ts.map +1 -1
  58. package/dist/cmd/delete.js +6 -7
  59. package/dist/cmd/delete.js.map +1 -1
  60. package/dist/cmd/doctor.d.ts +23 -0
  61. package/dist/cmd/doctor.d.ts.map +1 -0
  62. package/dist/cmd/doctor.js +61 -0
  63. package/dist/cmd/doctor.js.map +1 -0
  64. package/dist/cmd/download.d.ts +17 -0
  65. package/dist/cmd/download.d.ts.map +1 -0
  66. package/dist/cmd/download.js +436 -0
  67. package/dist/cmd/download.js.map +1 -0
  68. package/dist/cmd/inspect.d.ts +63 -0
  69. package/dist/cmd/inspect.d.ts.map +1 -0
  70. package/dist/cmd/inspect.js +179 -0
  71. package/dist/cmd/inspect.js.map +1 -0
  72. package/dist/cmd/make.d.ts +13 -3
  73. package/dist/cmd/make.d.ts.map +1 -1
  74. package/dist/cmd/make.js +240 -36
  75. package/dist/cmd/make.js.map +1 -1
  76. package/dist/cmd/mesh.d.ts +13 -0
  77. package/dist/cmd/mesh.d.ts.map +1 -0
  78. package/dist/cmd/mesh.js +67 -0
  79. package/dist/cmd/mesh.js.map +1 -0
  80. package/dist/cmd/project.d.ts +16 -0
  81. package/dist/cmd/project.d.ts.map +1 -0
  82. package/dist/cmd/project.js +136 -0
  83. package/dist/cmd/project.js.map +1 -0
  84. package/dist/cmd/resources.d.ts +5 -0
  85. package/dist/cmd/resources.d.ts.map +1 -1
  86. package/dist/cmd/resources.js +38 -10
  87. package/dist/cmd/resources.js.map +1 -1
  88. package/dist/cmd/showcases.d.ts +10 -0
  89. package/dist/cmd/showcases.d.ts.map +1 -0
  90. package/dist/cmd/showcases.js +63 -0
  91. package/dist/cmd/showcases.js.map +1 -0
  92. package/dist/cmd/slicer.d.ts +12 -0
  93. package/dist/cmd/slicer.d.ts.map +1 -0
  94. package/dist/cmd/slicer.js +46 -0
  95. package/dist/cmd/slicer.js.map +1 -0
  96. package/dist/cmd/uv-unwrap.d.ts +16 -0
  97. package/dist/cmd/uv-unwrap.d.ts.map +1 -0
  98. package/dist/cmd/uv-unwrap.js +52 -0
  99. package/dist/cmd/uv-unwrap.js.map +1 -0
  100. package/dist/index.d.ts +13 -1
  101. package/dist/index.d.ts.map +1 -1
  102. package/dist/index.js +141 -8
  103. package/dist/index.js.map +1 -1
  104. package/dist/internal/artifacts.d.ts +97 -0
  105. package/dist/internal/artifacts.d.ts.map +1 -0
  106. package/dist/internal/artifacts.js +280 -0
  107. package/dist/internal/artifacts.js.map +1 -0
  108. package/dist/internal/atomic-file.d.ts +39 -0
  109. package/dist/internal/atomic-file.d.ts.map +1 -0
  110. package/dist/internal/atomic-file.js +160 -0
  111. package/dist/internal/atomic-file.js.map +1 -0
  112. package/dist/internal/command-helpers.d.ts +46 -0
  113. package/dist/internal/command-helpers.d.ts.map +1 -0
  114. package/dist/internal/command-helpers.js +61 -0
  115. package/dist/internal/command-helpers.js.map +1 -0
  116. package/dist/internal/config.d.ts +48 -5
  117. package/dist/internal/config.d.ts.map +1 -1
  118. package/dist/internal/config.js +95 -6
  119. package/dist/internal/config.js.map +1 -1
  120. package/dist/internal/context.d.ts +25 -0
  121. package/dist/internal/context.d.ts.map +1 -0
  122. package/dist/internal/context.js +37 -0
  123. package/dist/internal/context.js.map +1 -0
  124. package/dist/internal/credentials.d.ts +11 -0
  125. package/dist/internal/credentials.d.ts.map +1 -1
  126. package/dist/internal/credentials.js +2 -0
  127. package/dist/internal/credentials.js.map +1 -1
  128. package/dist/internal/doctor.d.ts +94 -0
  129. package/dist/internal/doctor.d.ts.map +1 -0
  130. package/dist/internal/doctor.js +280 -0
  131. package/dist/internal/doctor.js.map +1 -0
  132. package/dist/internal/download.d.ts +147 -13
  133. package/dist/internal/download.d.ts.map +1 -1
  134. package/dist/internal/download.js +735 -142
  135. package/dist/internal/download.js.map +1 -1
  136. package/dist/internal/env-file.d.ts +29 -0
  137. package/dist/internal/env-file.d.ts.map +1 -0
  138. package/dist/internal/env-file.js +95 -0
  139. package/dist/internal/env-file.js.map +1 -0
  140. package/dist/internal/errors.d.ts +74 -1
  141. package/dist/internal/errors.d.ts.map +1 -1
  142. package/dist/internal/errors.js +173 -1
  143. package/dist/internal/errors.js.map +1 -1
  144. package/dist/internal/file-input.d.ts +52 -12
  145. package/dist/internal/file-input.d.ts.map +1 -1
  146. package/dist/internal/file-input.js +162 -33
  147. package/dist/internal/file-input.js.map +1 -1
  148. package/dist/internal/global-options.d.ts +6 -4
  149. package/dist/internal/global-options.d.ts.map +1 -1
  150. package/dist/internal/global-options.js +19 -4
  151. package/dist/internal/global-options.js.map +1 -1
  152. package/dist/internal/inspect.d.ts +67 -0
  153. package/dist/internal/inspect.d.ts.map +1 -0
  154. package/dist/internal/inspect.js +111 -0
  155. package/dist/internal/inspect.js.map +1 -0
  156. package/dist/internal/lock.d.ts +19 -0
  157. package/dist/internal/lock.d.ts.map +1 -0
  158. package/dist/internal/lock.js +66 -0
  159. package/dist/internal/lock.js.map +1 -0
  160. package/dist/internal/material-links.d.ts +115 -0
  161. package/dist/internal/material-links.d.ts.map +1 -0
  162. package/dist/internal/material-links.js +577 -0
  163. package/dist/internal/material-links.js.map +1 -0
  164. package/dist/internal/obj-transform.d.ts +115 -0
  165. package/dist/internal/obj-transform.d.ts.map +1 -0
  166. package/dist/internal/obj-transform.js +649 -0
  167. package/dist/internal/obj-transform.js.map +1 -0
  168. package/dist/internal/operation-store.d.ts +123 -0
  169. package/dist/internal/operation-store.d.ts.map +1 -0
  170. package/dist/internal/operation-store.js +229 -0
  171. package/dist/internal/operation-store.js.map +1 -0
  172. package/dist/internal/output.d.ts +20 -2
  173. package/dist/internal/output.d.ts.map +1 -1
  174. package/dist/internal/output.js +32 -4
  175. package/dist/internal/output.js.map +1 -1
  176. package/dist/internal/paths.d.ts +89 -0
  177. package/dist/internal/paths.d.ts.map +1 -0
  178. package/dist/internal/paths.js +168 -0
  179. package/dist/internal/paths.js.map +1 -0
  180. package/dist/internal/payload.d.ts +15 -0
  181. package/dist/internal/payload.d.ts.map +1 -1
  182. package/dist/internal/payload.js +50 -3
  183. package/dist/internal/payload.js.map +1 -1
  184. package/dist/internal/poll.d.ts +30 -4
  185. package/dist/internal/poll.d.ts.map +1 -1
  186. package/dist/internal/poll.js +99 -16
  187. package/dist/internal/poll.js.map +1 -1
  188. package/dist/internal/project-store.d.ts +197 -0
  189. package/dist/internal/project-store.d.ts.map +1 -0
  190. package/dist/internal/project-store.js +467 -0
  191. package/dist/internal/project-store.js.map +1 -0
  192. package/dist/internal/result.d.ts +50 -0
  193. package/dist/internal/result.d.ts.map +1 -0
  194. package/dist/internal/result.js +41 -0
  195. package/dist/internal/result.js.map +1 -0
  196. package/dist/internal/runtime.d.ts +35 -5
  197. package/dist/internal/runtime.d.ts.map +1 -1
  198. package/dist/internal/runtime.js +68 -18
  199. package/dist/internal/runtime.js.map +1 -1
  200. package/dist/internal/slicers.d.ts +117 -0
  201. package/dist/internal/slicers.d.ts.map +1 -0
  202. package/dist/internal/slicers.js +314 -0
  203. package/dist/internal/slicers.js.map +1 -0
  204. package/dist/internal/stream.d.ts +58 -0
  205. package/dist/internal/stream.d.ts.map +1 -0
  206. package/dist/internal/stream.js +280 -0
  207. package/dist/internal/stream.js.map +1 -0
  208. package/dist/internal/task-command.d.ts +128 -11
  209. package/dist/internal/task-command.d.ts.map +1 -1
  210. package/dist/internal/task-command.js +991 -111
  211. package/dist/internal/task-command.js.map +1 -1
  212. package/dist/internal/task-view.d.ts +56 -0
  213. package/dist/internal/task-view.d.ts.map +1 -0
  214. package/dist/internal/task-view.js +95 -0
  215. package/dist/internal/task-view.js.map +1 -0
  216. package/dist/internal/update-notifier.d.ts +5 -1
  217. package/dist/internal/update-notifier.d.ts.map +1 -1
  218. package/dist/internal/update-notifier.js +7 -3
  219. package/dist/internal/update-notifier.js.map +1 -1
  220. package/dist/root.d.ts +10 -0
  221. package/dist/root.d.ts.map +1 -1
  222. package/dist/root.js +75 -10
  223. package/dist/root.js.map +1 -1
  224. package/package.json +3 -2
  225. package/skills/meshy-cli/SKILL.md +140 -59
  226. package/dist/client/endpoints/analyze-printability.d.ts +0 -5
  227. package/dist/client/endpoints/analyze-printability.d.ts.map +0 -1
  228. package/dist/client/endpoints/analyze-printability.js +0 -7
  229. package/dist/client/endpoints/analyze-printability.js.map +0 -1
  230. package/dist/client/endpoints/animate.d.ts +0 -6
  231. package/dist/client/endpoints/animate.d.ts.map +0 -1
  232. package/dist/client/endpoints/animate.js +0 -8
  233. package/dist/client/endpoints/animate.js.map +0 -1
  234. package/dist/client/endpoints/convert.d.ts +0 -5
  235. package/dist/client/endpoints/convert.d.ts.map +0 -1
  236. package/dist/client/endpoints/convert.js +0 -7
  237. package/dist/client/endpoints/convert.js.map +0 -1
  238. package/dist/client/endpoints/image-to-3d.d.ts +0 -5
  239. package/dist/client/endpoints/image-to-3d.d.ts.map +0 -1
  240. package/dist/client/endpoints/image-to-3d.js +0 -7
  241. package/dist/client/endpoints/image-to-3d.js.map +0 -1
  242. package/dist/client/endpoints/image-to-image.d.ts +0 -5
  243. package/dist/client/endpoints/image-to-image.d.ts.map +0 -1
  244. package/dist/client/endpoints/image-to-image.js +0 -7
  245. package/dist/client/endpoints/image-to-image.js.map +0 -1
  246. package/dist/client/endpoints/multi-color-print.d.ts +0 -5
  247. package/dist/client/endpoints/multi-color-print.d.ts.map +0 -1
  248. package/dist/client/endpoints/multi-color-print.js +0 -7
  249. package/dist/client/endpoints/multi-color-print.js.map +0 -1
  250. package/dist/client/endpoints/multi-image-to-3d.d.ts +0 -5
  251. package/dist/client/endpoints/multi-image-to-3d.d.ts.map +0 -1
  252. package/dist/client/endpoints/multi-image-to-3d.js +0 -7
  253. package/dist/client/endpoints/multi-image-to-3d.js.map +0 -1
  254. package/dist/client/endpoints/remesh.d.ts +0 -5
  255. package/dist/client/endpoints/remesh.d.ts.map +0 -1
  256. package/dist/client/endpoints/remesh.js +0 -7
  257. package/dist/client/endpoints/remesh.js.map +0 -1
  258. package/dist/client/endpoints/repair-printability.d.ts +0 -5
  259. package/dist/client/endpoints/repair-printability.d.ts.map +0 -1
  260. package/dist/client/endpoints/repair-printability.js +0 -7
  261. package/dist/client/endpoints/repair-printability.js.map +0 -1
  262. package/dist/client/endpoints/resize.d.ts +0 -5
  263. package/dist/client/endpoints/resize.d.ts.map +0 -1
  264. package/dist/client/endpoints/resize.js +0 -7
  265. package/dist/client/endpoints/resize.js.map +0 -1
  266. package/dist/client/endpoints/retexture.d.ts +0 -5
  267. package/dist/client/endpoints/retexture.d.ts.map +0 -1
  268. package/dist/client/endpoints/retexture.js +0 -7
  269. package/dist/client/endpoints/retexture.js.map +0 -1
  270. package/dist/client/endpoints/rigging.d.ts +0 -6
  271. package/dist/client/endpoints/rigging.d.ts.map +0 -1
  272. package/dist/client/endpoints/rigging.js +0 -8
  273. package/dist/client/endpoints/rigging.js.map +0 -1
  274. package/dist/client/endpoints/text-to-3d.d.ts +0 -6
  275. package/dist/client/endpoints/text-to-3d.d.ts.map +0 -1
  276. package/dist/client/endpoints/text-to-3d.js +0 -8
  277. package/dist/client/endpoints/text-to-3d.js.map +0 -1
  278. package/dist/client/endpoints/text-to-image.d.ts +0 -5
  279. package/dist/client/endpoints/text-to-image.d.ts.map +0 -1
  280. package/dist/client/endpoints/text-to-image.js +0 -7
  281. package/dist/client/endpoints/text-to-image.js.map +0 -1
  282. package/dist/client/endpoints/text-to-motion.d.ts +0 -6
  283. package/dist/client/endpoints/text-to-motion.d.ts.map +0 -1
  284. package/dist/client/endpoints/text-to-motion.js +0 -8
  285. package/dist/client/endpoints/text-to-motion.js.map +0 -1
@@ -1,34 +1,548 @@
1
1
  /**
2
- * Download a completed task's artifacts to the local filesystem.
2
+ * Asset downloads with explicit boundaries.
3
3
  *
4
- * Two target shapes:
5
- * 1. File path (e.g. `character/front.jpeg`) — used when the task has a
6
- * single downloadable artifact (most 2D image tasks). The Content-Type
7
- * of the HTTP response is authoritative; if the user-supplied extension
8
- * disagrees, the file is saved with the correct extension instead.
9
- * 2. Directory path (anything without a recognized file extension) — used
10
- * when the task emits multiple artifacts (3D models, thumbnails,
11
- * textures, animation outputs). Each artifact is named after its role
12
- * (model.glb, thumbnail.png, texture_0_base_color.png, ...).
4
+ * Every byte that lands on disk goes through `fetchToTemp`: http(s) only, no
5
+ * embedded credentials, no Authorization or Cookie ever attached (asset hosts
6
+ * are not the API), redirects re-validated hop by hop (max 5), private
7
+ * network literals refused (loopback is allowed for local test servers), a
8
+ * hard size cap enforced while streaming, sha256 computed on the way. The
9
+ * temp file lives in the target directory and is published exclusively
10
+ * (`link`), or replaced atomically with --overwrite; the final path — after
11
+ * any MIME-driven extension change — is checked against the authorised root.
13
12
  *
14
- * A `meta.json` alongside the artifacts records the full task response plus
15
- * the saved paths for later reference.
13
+ * Two entry points share the core:
14
+ * downloadArtifacts — the 0.2.0 `-o` behaviour (all artifacts, role-based
15
+ * names, meta.json sidecar); output shape unchanged. An
16
+ * explicit `root` (the --workspace) confines every path.
17
+ * downloadAssets — the selective downloader behind `meshy download` and
18
+ * the v1 manifest.
19
+ * Both relink OBJ → MTL → texture references after the set has landed (see
20
+ * material-links.ts), so a saved OBJ loads with the files beside it.
16
21
  */
17
- import { createWriteStream, existsSync, mkdirSync, writeFileSync } from "node:fs";
18
- import { dirname, extname, join } from "node:path";
22
+ import { createHash } from "node:crypto";
23
+ import { closeSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync } from "node:fs";
24
+ import { Transform } from "node:stream";
25
+ import { basename, dirname, extname, join, relative, resolve as resolvePath } from "node:path";
19
26
  import { Readable } from "node:stream";
20
27
  import { pipeline } from "node:stream/promises";
21
28
  import sharp from "sharp";
22
- import { UsageError } from "./errors.js";
29
+ import { publishTempFile, tempPathFor, writeJsonFile } from "./atomic-file.js";
30
+ import { CliError, UsageError } from "./errors.js";
23
31
  import { logger } from "./logger.js";
32
+ import { freezeRoot, isInside, resolveWithinRoot, safeExtension, safeSegment } from "./paths.js";
33
+ import { USER_AGENT } from "./user-agent.js";
34
+ import { modelAsset, productFromTaskType } from "./artifacts.js";
35
+ import { fileDigest, relinkMaterials } from "./material-links.js";
24
36
  /** Extensions sharp can transcode between. */
25
37
  const CONVERTIBLE_IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "webp", "gif", "tiff", "tif", "avif"]);
38
+ export const DEFAULT_DOWNLOAD_LIMITS = {
39
+ /** 2 GiB — engineering default, not a Meshy limit. */
40
+ maxBytes: 2 * 1024 * 1024 * 1024,
41
+ timeoutMs: 300_000,
42
+ maxRedirects: 5,
43
+ };
44
+ export const DEFAULT_DOWNLOAD_POLICY = { allowHttpLoopback: true, allowPrivateNetwork: false };
45
+ function isLoopbackHost(host) {
46
+ const h = host.replace(/^\[|\]$/g, "").toLowerCase();
47
+ return h === "localhost" || h === "::1" || /^127\.\d+\.\d+\.\d+$/.test(h);
48
+ }
49
+ function isPrivateLiteral(host) {
50
+ const h = host.replace(/^\[|\]$/g, "").toLowerCase();
51
+ const v4 = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(h);
52
+ if (v4) {
53
+ const [a, b] = [Number(v4[1]), Number(v4[2])];
54
+ if (a === 10)
55
+ return true;
56
+ if (a === 172 && b >= 16 && b <= 31)
57
+ return true;
58
+ if (a === 192 && b === 168)
59
+ return true;
60
+ if (a === 169 && b === 254)
61
+ return true;
62
+ if (a === 0)
63
+ return true;
64
+ return false;
65
+ }
66
+ if (h.includes(":")) {
67
+ if (/^f[cd]/.test(h))
68
+ return true; // fc00::/7
69
+ if (/^fe[89ab]/.test(h))
70
+ return true; // fe80::/10
71
+ return false;
72
+ }
73
+ return false;
74
+ }
75
+ /** Validate an asset URL against the policy; throws CliError (local_io) with a clear reason. */
76
+ export function validateAssetUrl(raw, policy, label = "asset URL") {
77
+ let url;
78
+ try {
79
+ url = new URL(raw);
80
+ }
81
+ catch {
82
+ throw new CliError({ code: "validation", message: `${label} is not a valid URL: ${raw}` });
83
+ }
84
+ if (url.username || url.password)
85
+ throw new CliError({ code: "validation", message: `${label} carries embedded credentials; refused` });
86
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
87
+ throw new CliError({ code: "validation", message: `${label} must be http(s), got ${url.protocol}` });
88
+ }
89
+ const loopback = isLoopbackHost(url.hostname);
90
+ if (!loopback && isPrivateLiteral(url.hostname) && !policy.allowPrivateNetwork) {
91
+ throw new CliError({ code: "validation", message: `${label} points at a private network address (${url.hostname}); refused` });
92
+ }
93
+ if (url.protocol === "http:" && !(loopback && policy.allowHttpLoopback)) {
94
+ throw new CliError({ code: "validation", message: `${label} uses plain http to ${url.hostname}; only https (or http to a loopback test host) is accepted` });
95
+ }
96
+ return url;
97
+ }
98
+ /**
99
+ * Stream a URL into a temp file next to `target`, following at most
100
+ * `maxRedirects` re-validated redirects, without any credential header.
101
+ */
102
+ export async function fetchToTemp(rawUrl, target, opts = {}) {
103
+ const limits = { ...DEFAULT_DOWNLOAD_LIMITS, ...(opts.limits ?? {}) };
104
+ const policy = { ...DEFAULT_DOWNLOAD_POLICY, ...(opts.policy ?? {}) };
105
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
106
+ mkdirSync(dirname(target), { recursive: true });
107
+ const tmpPath = tempPathFor(target);
108
+ const controller = new AbortController();
109
+ const timer = setTimeout(() => controller.abort(new Error(`download timed out after ${limits.timeoutMs}ms`)), limits.timeoutMs);
110
+ const signal = opts.signal ? AbortSignal.any([controller.signal, opts.signal]) : controller.signal;
111
+ let url = validateAssetUrl(rawUrl, policy);
112
+ try {
113
+ let resp = null;
114
+ for (let hop = 0;; hop++) {
115
+ let r;
116
+ try {
117
+ r = await fetchImpl(url, { method: "GET", redirect: "manual", signal, headers: { "User-Agent": USER_AGENT, Accept: "*/*" } });
118
+ }
119
+ catch (err) {
120
+ if (opts.signal?.aborted)
121
+ throw new CliError({ code: "interrupted", message: `download of ${redact(url)} interrupted` });
122
+ if (controller.signal.aborted)
123
+ throw new CliError({ code: "network", message: `download of ${redact(url)} timed out after ${limits.timeoutMs}ms` });
124
+ throw new CliError({ code: "network", message: `download of ${redact(url)} failed: ${err instanceof Error ? err.message : String(err)}`, cause: err });
125
+ }
126
+ if (r.status >= 300 && r.status < 400) {
127
+ const loc = r.headers.get("location");
128
+ await r.body?.cancel().catch(() => undefined);
129
+ if (!loc)
130
+ throw new CliError({ code: "network", message: `redirect from ${redact(url)} without a Location header` });
131
+ if (hop >= limits.maxRedirects)
132
+ throw new CliError({ code: "network", message: `too many redirects downloading ${redact(url)}` });
133
+ const next = new URL(loc, url);
134
+ if (url.protocol === "https:" && next.protocol === "http:") {
135
+ throw new CliError({ code: "validation", message: `refusing https → http downgrade redirect from ${redact(url)}` });
136
+ }
137
+ url = validateAssetUrl(next.href, policy, "redirect target");
138
+ continue;
139
+ }
140
+ resp = r;
141
+ break;
142
+ }
143
+ if (!resp.ok) {
144
+ await resp.body?.cancel().catch(() => undefined);
145
+ throw new CliError({
146
+ code: resp.status === 404 ? "not_found" : resp.status === 401 || resp.status === 403 || resp.status === 410 ? "validation" : "network",
147
+ message: `download failed for ${redact(url)} (HTTP ${resp.status}${resp.statusText ? ` ${resp.statusText}` : ""})`,
148
+ httpStatus: resp.status,
149
+ details: { expired_or_denied: resp.status === 401 || resp.status === 403 || resp.status === 410 },
150
+ });
151
+ }
152
+ const declared = resp.headers.get("content-length");
153
+ if (declared && Number(declared) > limits.maxBytes) {
154
+ await resp.body?.cancel().catch(() => undefined);
155
+ throw new CliError({ code: "local_io", message: `asset ${redact(url)} declares ${declared} bytes, above the ${limits.maxBytes}-byte limit` });
156
+ }
157
+ const hash = createHash("sha256");
158
+ let bytes = 0;
159
+ const source = resp.body ? Readable.fromWeb(resp.body) : Readable.from([]);
160
+ const counter = new Transform({
161
+ transform(chunk, _enc, cb) {
162
+ bytes += chunk.length;
163
+ if (bytes > limits.maxBytes) {
164
+ cb(new CliError({ code: "local_io", message: `asset ${redact(url)} exceeds the ${limits.maxBytes}-byte limit` }));
165
+ return;
166
+ }
167
+ hash.update(chunk);
168
+ cb(null, chunk);
169
+ },
170
+ });
171
+ try {
172
+ await pipeline(source, counter, createWriteStream(tmpPath, { mode: 0o600 }));
173
+ }
174
+ catch (err) {
175
+ removeQuietly(tmpPath);
176
+ if (err instanceof CliError)
177
+ throw err;
178
+ if (opts.signal?.aborted)
179
+ throw new CliError({ code: "interrupted", message: `download of ${redact(url)} interrupted` });
180
+ if (controller.signal.aborted)
181
+ throw new CliError({ code: "network", message: `download of ${redact(url)} timed out after ${limits.timeoutMs}ms` });
182
+ throw new CliError({ code: "network", message: `download of ${redact(url)} failed mid-stream: ${err instanceof Error ? err.message : String(err)}`, cause: err });
183
+ }
184
+ return { tmpPath, bytes, sha256: hash.digest("hex"), contentType: resp.headers.get("content-type"), finalUrl: url.href, status: resp.status };
185
+ }
186
+ finally {
187
+ clearTimeout(timer);
188
+ }
189
+ }
190
+ /** URL without its query string — signed parameters never reach logs or messages. */
191
+ export function redact(url) {
192
+ try {
193
+ const u = typeof url === "string" ? new URL(url) : url;
194
+ return `${u.origin}${u.pathname}`;
195
+ }
196
+ catch {
197
+ return "<invalid url>";
198
+ }
199
+ }
200
+ function removeQuietly(path) {
201
+ try {
202
+ unlinkSync(path);
203
+ }
204
+ catch {
205
+ /* gone */
206
+ }
207
+ }
208
+ // ---------------------------------------------------------------------------
209
+ // Content checks
210
+ // ---------------------------------------------------------------------------
211
+ export function extFromContentType(ct) {
212
+ if (!ct)
213
+ return "";
214
+ const base = ct.split(";")[0].trim().toLowerCase();
215
+ const map = {
216
+ "image/jpeg": "jpg",
217
+ "image/png": "png",
218
+ "image/webp": "webp",
219
+ "image/gif": "gif",
220
+ "image/tiff": "tiff",
221
+ "image/bmp": "bmp",
222
+ "model/gltf-binary": "glb",
223
+ "model/gltf+json": "gltf",
224
+ "model/obj": "obj",
225
+ "model/vnd.usdz+zip": "usdz",
226
+ "model/stl": "stl",
227
+ "model/3mf": "3mf",
228
+ "application/zip": "zip",
229
+ "application/json": "json",
230
+ "video/mp4": "mp4",
231
+ };
232
+ return map[base] ?? "";
233
+ }
234
+ function extEquivalent(a, b) {
235
+ const groups = [new Set(["jpg", "jpeg"]), new Set(["tif", "tiff"])];
236
+ return groups.some((g) => g.has(a) && g.has(b));
237
+ }
238
+ function readHead(path, n = 16) {
239
+ const fd = openSync(path, "r");
240
+ try {
241
+ const buf = Buffer.alloc(n);
242
+ const read = readSync(fd, buf, 0, n, 0);
243
+ return buf.subarray(0, read);
244
+ }
245
+ finally {
246
+ closeSync(fd);
247
+ }
248
+ }
249
+ /**
250
+ * Reject bodies that cannot be what the asset claims: an HTML error page
251
+ * served as a model, a non-GLB under .glb, a non-ZIP under .zip.
252
+ */
253
+ export function validateContent(tmpPath, expectedFormat, kind, contentType) {
254
+ const ct = (contentType ?? "").split(";")[0].trim().toLowerCase();
255
+ const head = readHead(tmpPath, 16);
256
+ const text = head.toString("latin1").toLowerCase();
257
+ if ((kind === "model" || kind === "rig" || kind === "animation" || kind === "motion") && (ct === "text/html" || text.startsWith("<!doctype") || text.startsWith("<html"))) {
258
+ throw new CliError({ code: "validation", message: "asset body is an HTML page, not a model file (the download URL may have expired)" });
259
+ }
260
+ if (expectedFormat === "glb" && !(head.length >= 4 && head.subarray(0, 4).toString("ascii") === "glTF")) {
261
+ throw new CliError({ code: "validation", message: "asset saved under .glb does not start with the glTF magic; refusing to keep an invalid GLB" });
262
+ }
263
+ if (expectedFormat === "zip" && !(head.length >= 2 && head[0] === 0x50 && head[1] === 0x4b)) {
264
+ throw new CliError({ code: "validation", message: "asset expected to be a ZIP container does not start with the PK magic" });
265
+ }
266
+ }
267
+ function plannedName(asset, targetFile) {
268
+ if (targetFile)
269
+ return basename(targetFile);
270
+ return asset.filename;
271
+ }
272
+ /**
273
+ * Download the given assets one by one. Each file is published on its own;
274
+ * a failure stops the loop and the result lists what was written so far.
275
+ * No rollback deletes anything the user already had.
276
+ */
277
+ export async function downloadAssets(assets, opts) {
278
+ if (opts.targetFile && assets.length !== 1) {
279
+ throw new UsageError(`--output names a single file but ${assets.length} assets were selected; pass --output-dir <dir> instead`);
280
+ }
281
+ if (!opts.targetFile && !opts.targetDir)
282
+ throw new UsageError("an output file or directory is required");
283
+ const files = [];
284
+ const warnings = [];
285
+ const dir = opts.targetFile ? dirname(resolvePath(opts.targetFile)) : resolvePath(opts.targetDir);
286
+ // The root is frozen once: its real path and directory identity now, re-proven
287
+ // at every later check, so a boundary replaced during a transfer is refused.
288
+ const root = typeof opts.root === "string" ? freezeRoot(opts.root, { label: "output root" }) : opts.root;
289
+ const rootReal = root.real;
290
+ // The directory and every planned leaf must be inside the root (and no
291
+ // symlink) before anything at all is created — a refused target must not
292
+ // leave a directory behind; the check repeats after any MIME-driven rename.
293
+ resolveWithinRoot(dir, root, { label: "output directory" });
294
+ for (const asset of assets) {
295
+ resolveWithinRoot(join(dir, plannedName(asset, opts.targetFile)), root, { label: "planned download path" });
296
+ }
297
+ mkdirSync(dir, { recursive: true });
298
+ let refreshed;
299
+ for (const asset of assets) {
300
+ const planned = join(dir, plannedName(asset, opts.targetFile));
301
+ let entry;
302
+ try {
303
+ if (asset.kind === "report") {
304
+ const target = resolveWithinRoot(planned.endsWith(".json") ? planned : `${planned}.json`, root, { label: "report path" }).path;
305
+ const res = writeJsonFile(target, asset.report, { overwrite: opts.overwrite ?? false });
306
+ const bytes = statSync(target).size;
307
+ entry = { key: asset.key, path: target, relative_path: rel(rootReal, target), bytes, sha256: createHash("sha256").update(readFileSync(target)).digest("hex"), content_type: "application/json", format: "json", container_format: null, extracted: null, status: "written", error: null, publish_method: res.method, relinked: false };
308
+ }
309
+ else {
310
+ if (!asset.url)
311
+ throw new CliError({ code: "validation", message: `asset ${asset.key} has no URL` });
312
+ let url = asset.url;
313
+ let fetched;
314
+ try {
315
+ fetched = await fetchToTemp(url, planned, opts);
316
+ }
317
+ catch (err) {
318
+ const expired = err instanceof CliError && err.details?.expired_or_denied;
319
+ if (expired && opts.refreshUrls) {
320
+ if (refreshed === undefined)
321
+ refreshed = await opts.refreshUrls();
322
+ const fresh = refreshed?.get(asset.key);
323
+ if (fresh && fresh !== url) {
324
+ warnings.push({ code: "asset_url_refreshed", message: `${asset.key}: signed URL rejected; refreshed once from the task` });
325
+ url = fresh;
326
+ fetched = await fetchToTemp(url, planned, opts);
327
+ }
328
+ else
329
+ throw err;
330
+ }
331
+ else
332
+ throw err;
333
+ }
334
+ entry = await placeFetched(asset, fetched, planned, root, opts, warnings);
335
+ }
336
+ }
337
+ catch (err) {
338
+ const message = err instanceof Error ? err.message : String(err);
339
+ const failed = { key: asset.key, path: planned, relative_path: rel(rootReal, planned), bytes: 0, sha256: "", content_type: null, format: asset.format, container_format: asset.containerFormat, extracted: null, status: "failed", error: message, publish_method: null, relinked: false };
340
+ files.push(failed);
341
+ opts.onFile?.(failed);
342
+ const code = err instanceof CliError ? err.code : "local_io";
343
+ throw new CliError({
344
+ code: code === "interrupted" ? "interrupted" : code === "network" || code === "not_found" || code === "validation" ? code : "local_io",
345
+ message: `${asset.key}: ${message}`,
346
+ httpStatus: err instanceof CliError ? err.httpStatus : null,
347
+ result: { downloads: { state: files.some((f) => f.status === "written") ? "partial" : "failed", files, metadata_path: null } },
348
+ warnings,
349
+ cause: err,
350
+ });
351
+ }
352
+ files.push(entry);
353
+ opts.onFile?.(entry);
354
+ }
355
+ const sources = new Map(assets.map((a) => [a.key, a.url ? basenameOfUrl(a.url) : null]));
356
+ let materialLinks = null;
357
+ try {
358
+ if (opts.signal?.aborted)
359
+ throw new CliError({ code: "interrupted", message: "interrupted before the material references were relinked" });
360
+ materialLinks = await relinkWritten(files, warnings, sources, opts.signal);
361
+ }
362
+ catch (err) {
363
+ // Every file is on disk; say so, with the bytes actually there.
364
+ redigest(files);
365
+ const interrupted = (err instanceof CliError && err.code === "interrupted") || Boolean(opts.signal?.aborted);
366
+ throw new CliError({
367
+ code: interrupted ? "interrupted" : err instanceof CliError ? err.code : "local_io",
368
+ message: `${files.length} file(s) were written but relinking the material references ${interrupted ? "was interrupted" : "failed"}: ${err instanceof Error ? err.message : String(err)}`,
369
+ httpStatus: err instanceof CliError ? err.httpStatus : null,
370
+ result: { downloads: { state: "partial", files, metadata_path: null, failed_step: "relink" } },
371
+ warnings,
372
+ cause: err,
373
+ });
374
+ }
375
+ return { files, complete: files.every((f) => f.status === "written"), warnings, materialLinks };
376
+ }
377
+ /** Re-take each committed file's digest from disk (a relink may have rewritten it before a later step failed). */
378
+ function redigest(files) {
379
+ for (const f of files) {
380
+ if (f.status !== "written")
381
+ continue;
382
+ try {
383
+ const d = fileDigest(f.path);
384
+ if (d.sha256 !== f.sha256)
385
+ f.relinked = true;
386
+ f.bytes = d.bytes;
387
+ f.sha256 = d.sha256;
388
+ }
389
+ catch {
390
+ /* keep the recorded digest */
391
+ }
392
+ }
393
+ }
394
+ /** Last path segment of an asset URL (decoded), the name the server knew the file by; null when unparseable. */
395
+ export function basenameOfUrl(url) {
396
+ try {
397
+ const segment = new URL(url).pathname.split("/").filter(Boolean).at(-1) ?? "";
398
+ let name = segment;
399
+ try {
400
+ name = decodeURIComponent(segment);
401
+ }
402
+ catch {
403
+ /* keep the raw segment */
404
+ }
405
+ return name || null;
406
+ }
407
+ catch {
408
+ return null;
409
+ }
410
+ }
411
+ /** After the set landed: point OBJ → MTL → textures at the saved names and re-take the digests of rewritten files. */
412
+ async function relinkWritten(files, warnings, sources, signal) {
413
+ const written = files.filter((f) => f.status === "written" && f.container_format === null);
414
+ const links = await relinkMaterials(written.map((f) => ({ key: f.key, path: f.path, sourceName: sources.get(f.key) ?? null })), { signal });
415
+ if (!links)
416
+ return null;
417
+ for (const path of links.rewritten) {
418
+ const entry = files.find((f) => f.path === path);
419
+ if (!entry)
420
+ continue;
421
+ const digest = fileDigest(path);
422
+ entry.bytes = digest.bytes;
423
+ entry.sha256 = digest.sha256;
424
+ entry.relinked = true;
425
+ }
426
+ warnings.push(...links.warnings);
427
+ return links;
428
+ }
429
+ function rel(root, path) {
430
+ const r = relative(root, path);
431
+ return isInside(root, path) ? r.split(/[\\/]/).join("/") : null;
432
+ }
433
+ /** Reconcile the extension with the real content type, validate, and publish exclusively. */
434
+ async function placeFetched(asset, fetched, planned, root, opts, warnings) {
435
+ const rootReal = root.real;
436
+ const actualExt = extFromContentType(fetched.contentType);
437
+ const requestedExt = safeExtension(extname(planned));
438
+ let finalPath = planned;
439
+ let tmp = fetched.tmpPath;
440
+ let bytes = fetched.bytes;
441
+ let sha = fetched.sha256;
442
+ if (asset.containerFormat === "zip") {
443
+ // Bundles are delivered as ZIP whatever the content-type says.
444
+ if (requestedExt !== "zip")
445
+ finalPath = `${stripExt(planned)}.zip`;
446
+ }
447
+ else if (!requestedExt && actualExt) {
448
+ finalPath = `${planned}.${actualExt}`;
449
+ }
450
+ else if (requestedExt && actualExt && requestedExt !== actualExt && !extEquivalent(requestedExt, actualExt)) {
451
+ if (CONVERTIBLE_IMAGE_EXTS.has(actualExt) && CONVERTIBLE_IMAGE_EXTS.has(requestedExt)) {
452
+ const converted = `${tmp}.conv`;
453
+ await convertImage(readFileSync(tmp), requestedExt, converted);
454
+ removeQuietly(tmp);
455
+ tmp = converted;
456
+ bytes = statSync(converted).size;
457
+ sha = createHash("sha256").update(readFileSync(converted)).digest("hex");
458
+ warnings.push({ code: "image_transcoded", message: `${asset.key}: server sent ${actualExt}, transcoded to ${requestedExt} as requested` });
459
+ }
460
+ else {
461
+ finalPath = `${stripExt(planned)}.${actualExt}`;
462
+ warnings.push({ code: "extension_corrected", message: `${asset.key}: requested .${requestedExt} but the server sent ${fetched.contentType ?? "unknown"}; saved as ${basename(finalPath)}` });
463
+ }
464
+ }
465
+ const expectedFormat = asset.containerFormat === "zip" ? "zip" : safeExtension(extname(finalPath)) || null;
466
+ if (opts.validateContent !== false) {
467
+ try {
468
+ validateContent(tmp, expectedFormat, asset.kind, fetched.contentType);
469
+ }
470
+ catch (err) {
471
+ removeQuietly(tmp);
472
+ throw err;
473
+ }
474
+ }
475
+ // The final path — possibly renamed — must still be inside the root, and
476
+ // must not be a symlink or an existing file (unless --overwrite).
477
+ let resolved;
478
+ try {
479
+ resolved = resolveWithinRoot(finalPath, root, { label: "final download path" }).path;
480
+ }
481
+ catch (err) {
482
+ removeQuietly(tmp);
483
+ throw err;
484
+ }
485
+ const res = publishTempFile(tmp, resolved, { overwrite: opts.overwrite ?? false });
486
+ return {
487
+ key: asset.key,
488
+ path: resolved,
489
+ relative_path: rel(rootReal, resolved),
490
+ bytes,
491
+ sha256: sha,
492
+ content_type: fetched.contentType,
493
+ format: asset.containerFormat === "zip" ? "zip" : safeExtension(extname(resolved)) || asset.format,
494
+ container_format: asset.containerFormat,
495
+ extracted: asset.containerFormat ? false : null,
496
+ status: "written",
497
+ error: null,
498
+ publish_method: res.method,
499
+ relinked: false,
500
+ };
501
+ }
502
+ function stripExt(path) {
503
+ const ext = extname(path);
504
+ return ext ? path.slice(0, -ext.length) : path;
505
+ }
506
+ async function convertImage(buffer, targetExt, targetPath) {
507
+ const pipe = sharp(buffer);
508
+ switch (targetExt) {
509
+ case "jpg":
510
+ case "jpeg":
511
+ await pipe.jpeg({ quality: 92 }).toFile(targetPath);
512
+ return;
513
+ case "png":
514
+ await pipe.png().toFile(targetPath);
515
+ return;
516
+ case "webp":
517
+ await pipe.webp({ quality: 92 }).toFile(targetPath);
518
+ return;
519
+ case "gif":
520
+ await pipe.gif().toFile(targetPath);
521
+ return;
522
+ case "tiff":
523
+ case "tif":
524
+ await pipe.tiff().toFile(targetPath);
525
+ return;
526
+ case "avif":
527
+ await pipe.avif({ quality: 60 }).toFile(targetPath);
528
+ return;
529
+ default:
530
+ throw new Error(`unsupported image target extension: ${targetExt}`);
531
+ }
532
+ }
26
533
  export function enumerateArtifacts(task) {
27
534
  const out = [];
28
535
  if (task.model_urls) {
536
+ // `model_urls` keys are usually formats (glb, obj) but Creative Lab builds
537
+ // use part names (`lamp_stl`, `base_stl`, `bundle_zip`) and deliver the
538
+ // keychain / fridge-magnet OBJ as a ZIP bundle. The selective downloader
539
+ // already knows this (artifacts.ts); the legacy layout names the files the
540
+ // same way while keeping its slot keys (`model_lamp_stl`).
541
+ const product = productFromTaskType(task.type)?.product ?? null;
29
542
  for (const [ext, url] of Object.entries(task.model_urls)) {
30
543
  if (typeof url === "string" && url) {
31
- out.push({ key: `model_${ext}`, url, preferredExt: ext.toLowerCase() });
544
+ const asset = modelAsset(ext, url, product);
545
+ out.push({ key: `model_${ext}`, url, preferredExt: asset.format ?? ext.toLowerCase(), filename: asset.filename });
32
546
  }
33
547
  }
34
548
  }
@@ -51,10 +565,20 @@ export function enumerateArtifacts(task) {
51
565
  }
52
566
  });
53
567
  }
54
- // Rigging / animate-style endpoints nest outputs under `result`.
568
+ // Rigging / animate-style endpoints nest outputs under `result`; the
569
+ // bundled walking/running clips live one level deeper.
55
570
  if (task.result && typeof task.result === "object") {
56
571
  const motionFormat = task.result["motion_format"];
57
572
  for (const [key, value] of Object.entries(task.result)) {
573
+ if (key === "basic_animations" && value && typeof value === "object" && !Array.isArray(value)) {
574
+ for (const [sub, url] of Object.entries(value)) {
575
+ if (typeof url !== "string" || !/^https?:/.test(url))
576
+ continue;
577
+ const extMatch = sub.match(/_(fbx|glb|usdz|obj|png|jpg|jpeg|webp)_url$/i);
578
+ out.push({ key: `basic_animations_${sub}`, url, preferredExt: extMatch ? extMatch[1].toLowerCase() : "" });
579
+ }
580
+ continue;
581
+ }
58
582
  if (typeof value !== "string" || !/^https?:/.test(value))
59
583
  continue;
60
584
  const extMatch = key.match(/_(fbx|glb|usdz|obj|png|jpg|jpeg|webp)_url$/i);
@@ -71,14 +595,73 @@ export function enumerateArtifacts(task) {
71
595
  export function looksLikeFile(path) {
72
596
  return /\.[A-Za-z0-9]{2,6}$/.test(path);
73
597
  }
74
- export async function downloadArtifacts(task, outputPath, resource) {
598
+ function interruptedBefore(what) {
599
+ return new CliError({ code: "interrupted", message: `interrupted before ${what}; nothing further was written` });
600
+ }
601
+ /**
602
+ * A failure *after* every transfer landed (relink, digest refresh, sidecar).
603
+ * The manifest keeps every committed file with the bytes actually on disk —
604
+ * a relink may already have rewritten some — and names the step that failed;
605
+ * a cooperative interrupt is `interrupted` (130), everything else keeps its class.
606
+ */
607
+ function finalisationFailure(step, err, files, signal) {
608
+ redigest(files);
609
+ const downloads = { state: "partial", files, metadata_path: null, failed_step: step };
610
+ const written = files.filter((f) => f.status === "written").length;
611
+ const interrupted = (err instanceof CliError && err.code === "interrupted") || Boolean(signal?.aborted);
612
+ const message = `${written} file(s) were written but the ${step} step ${interrupted ? "was interrupted" : "failed"}: ${err instanceof Error ? err.message : String(err)}`;
613
+ if (err instanceof CliError) {
614
+ return new CliError({
615
+ code: interrupted ? "interrupted" : err.code,
616
+ message,
617
+ httpStatus: err.httpStatus,
618
+ retryable: err.retryable,
619
+ recovery: err.recovery,
620
+ hint: err.hint,
621
+ details: err.details,
622
+ warnings: err.warnings,
623
+ result: { ...(err.result ?? {}), downloads },
624
+ cause: err,
625
+ });
626
+ }
627
+ return new CliError({ code: interrupted ? "interrupted" : "local_io", message, result: { downloads }, cause: err });
628
+ }
629
+ /**
630
+ * Wrap a per-artifact failure so the caller sees what already landed. The
631
+ * original classification (network / not_found / validation / interrupted /
632
+ * local_io, HTTP status, recovery) is kept; only the message names the artifact.
633
+ */
634
+ function downloadFailure(artifact, err, files) {
635
+ const message = err instanceof Error ? err.message : String(err);
636
+ const downloads = { state: files.some((f) => f.status === "written") ? "partial" : "failed", files, metadata_path: null };
637
+ if (err instanceof CliError) {
638
+ return new CliError({
639
+ code: err.code,
640
+ message: `download failed for ${artifact.key}: ${message}`,
641
+ exitCode: err.exitCode,
642
+ httpStatus: err.httpStatus,
643
+ retryable: err.retryable,
644
+ recovery: err.recovery,
645
+ hint: err.hint,
646
+ details: err.details,
647
+ warnings: err.warnings,
648
+ result: { ...(err.result ?? {}), downloads },
649
+ cause: err,
650
+ });
651
+ }
652
+ return new CliError({ code: "local_io", message: `download failed for ${artifact.key}: ${message}`, result: { downloads }, cause: err });
653
+ }
654
+ export async function downloadArtifacts(task, outputPath, resource, opts = {}) {
75
655
  const artifacts = enumerateArtifacts(task);
656
+ // An explicit workspace is the root for everything written here — the
657
+ // directory, every planned file and the sidecar — checked before mkdir.
658
+ const workspaceRoot = opts.root === undefined ? null : typeof opts.root === "string" ? freezeRoot(opts.root, { label: "--workspace" }) : opts.root;
76
659
  if (artifacts.length === 0) {
77
660
  // Report-only tasks (analyze-printability) carry their result in a
78
661
  // structured field instead of downloadable files. Persist the full task
79
662
  // JSON so `-o` still means "give me the result on disk".
80
663
  if (task.printability != null) {
81
- return saveReportOnly(task, outputPath, resource);
664
+ return saveReportOnly(task, outputPath, resource, workspaceRoot);
82
665
  }
83
666
  throw new Error(`task ${task.id} has no downloadable artifacts`);
84
667
  }
@@ -91,16 +674,15 @@ export async function downloadArtifacts(task, outputPath, resource) {
91
674
  // work when a destination already exists. This prevents silent overwrite
92
675
  // of prior runs.
93
676
  let targetDir;
94
- const plannedArtifactPaths = [];
677
+ const plan = [];
95
678
  if (singleFileMode) {
96
679
  targetDir = dirname(outputPath) || ".";
97
- plannedArtifactPaths.push(outputPath);
680
+ plan.push({ artifact: artifacts[0], target: outputPath });
98
681
  }
99
682
  else {
100
683
  targetDir = outputPath;
101
- for (const artifact of artifacts) {
102
- plannedArtifactPaths.push(join(targetDir, deriveFilename(artifact)));
103
- }
684
+ for (const artifact of artifacts)
685
+ plan.push({ artifact, target: join(targetDir, deriveFilename(artifact)) });
104
686
  }
105
687
  // Per-file meta in single-file mode (`-o a.png` → `a_meta.json`) so two
106
688
  // outputs can share a directory without trampling each other. Directory
@@ -108,26 +690,73 @@ export async function downloadArtifacts(task, outputPath, resource) {
108
690
  const metadataPath = singleFileMode
109
691
  ? `${stripExt(outputPath)}_meta.json`
110
692
  : join(targetDir, "meta.json");
111
- const existing = [...plannedArtifactPaths, metadataPath].filter((p) => existsSync(p));
693
+ const existing = [...plan.map((p) => p.target), metadataPath].filter((p) => existsSync(p));
112
694
  if (existing.length > 0) {
113
695
  throw new UsageError(`refusing to overwrite existing file(s):\n ${existing.join("\n ")}\n` +
114
696
  `(delete them or choose a different --output path to rerun)`);
115
697
  }
698
+ if (workspaceRoot) {
699
+ resolveWithinRoot(targetDir, workspaceRoot, { label: "output directory" });
700
+ for (const p of [...plan.map((x) => x.target), metadataPath])
701
+ resolveWithinRoot(p, workspaceRoot, { label: "planned download path" });
702
+ }
116
703
  mkdirSync(targetDir, { recursive: true });
704
+ const root = workspaceRoot ?? freezeRoot(targetDir, { label: "output directory" });
705
+ const files = [];
117
706
  const saved = [];
118
- if (singleFileMode) {
119
- saved.push(await downloadArtifact(artifacts[0], outputPath));
707
+ for (const { artifact, target } of plan) {
708
+ if (opts.signal?.aborted)
709
+ throw downloadFailure(artifact, interruptedBefore(`${artifact.key} was downloaded`), files);
710
+ try {
711
+ const placed = await downloadArtifact(artifact, target, root, opts.signal);
712
+ saved.push(placed.path);
713
+ files.push({ key: artifact.key, path: placed.path, bytes: placed.bytes, sha256: placed.sha256, content_type: placed.contentType, status: "written", error: null, relinked: false });
714
+ }
715
+ catch (err) {
716
+ files.push({ key: artifact.key, path: target, bytes: 0, sha256: "", content_type: null, status: "failed", error: err instanceof Error ? err.message : String(err), relinked: false });
717
+ throw downloadFailure(artifact, err, files);
718
+ }
120
719
  }
121
- else {
122
- for (const artifact of artifacts) {
123
- const targetPath = join(targetDir, deriveFilename(artifact));
124
- saved.push(await downloadArtifact(artifact, targetPath));
720
+ // --- Finalisation: relink, refresh digests, publish the sidecar. Every step
721
+ // runs under the same failure handling as the transfers: whatever fails or is
722
+ // interrupted, the manifest still lists every committed file with the bytes
723
+ // actually on disk, and the sidecar is published like an asset (root re-proven
724
+ // at publication, symlink refused, exclusive — never truncating a file that
725
+ // appeared since the preflight).
726
+ const linkables = plan.map((p, i) => ({ key: p.artifact.key, path: resolvePath(saved[i]), sourceName: basenameOfUrl(p.artifact.url) }));
727
+ let step = "relink";
728
+ let materialLinks = null;
729
+ try {
730
+ if (opts.signal?.aborted)
731
+ throw interruptedBefore("the material references were relinked");
732
+ materialLinks = await relinkMaterials(linkables, { signal: opts.signal });
733
+ step = "digest";
734
+ if (materialLinks) {
735
+ for (const path of materialLinks.rewritten) {
736
+ const entry = files.find((f) => resolvePath(f.path) === path);
737
+ if (!entry)
738
+ continue;
739
+ const digest = fileDigest(path);
740
+ entry.bytes = digest.bytes;
741
+ entry.sha256 = digest.sha256;
742
+ entry.relinked = true;
743
+ }
744
+ for (const w of materialLinks.warnings)
745
+ logger.warn(w.message);
125
746
  }
747
+ step = "sidecar";
748
+ if (opts.signal?.aborted)
749
+ throw interruptedBefore("the sidecar was written");
750
+ writeMeta(task, resource, metadataPath, saved, root);
751
+ }
752
+ catch (err) {
753
+ throw finalisationFailure(step, err, files, opts.signal);
126
754
  }
127
- writeMeta(task, resource, metadataPath, saved);
128
- return { savedFiles: saved, metadataPath };
755
+ return { savedFiles: saved, metadataPath, materialLinks, files };
129
756
  }
130
757
  function deriveFilename(artifact) {
758
+ if (artifact.filename)
759
+ return artifact.filename;
131
760
  if (artifact.key.startsWith("model_")) {
132
761
  const ext = artifact.key.slice("model_".length);
133
762
  return `model.${ext}`;
@@ -143,103 +772,53 @@ function deriveFilename(artifact) {
143
772
  // content-type-derived extension so we don't emit a bogus `.bin`.
144
773
  return artifact.preferredExt ? `${stem}.${artifact.preferredExt}` : stem;
145
774
  }
146
- function stripExt(path) {
147
- const ext = extname(path);
148
- return ext ? path.slice(0, -ext.length) : path;
149
- }
150
- async function downloadArtifact(artifact, targetPath) {
151
- logger.debug(`GET ${artifact.url}`);
152
- const resp = await fetch(artifact.url);
153
- if (!resp.ok) {
154
- throw new Error(`download failed for ${artifact.key} (${resp.status} ${resp.statusText})`);
155
- }
156
- if (!resp.body)
157
- throw new Error(`empty body for ${artifact.url}`);
158
- const contentType = resp.headers.get("content-type") ?? "";
159
- const actualExt = extFromContentType(contentType);
775
+ /** Fetch one artifact into place. Errors keep their class: a CliError from the fetch/publish core is rethrown untouched. */
776
+ async function downloadArtifact(artifact, targetPath, root, signal) {
777
+ logger.debug(`GET ${redact(artifact.url)}`);
778
+ const fetched = await fetchToTemp(artifact.url, targetPath, { signal });
779
+ const actualExt = extFromContentType(fetched.contentType);
160
780
  const requestedExt = extname(targetPath).slice(1).toLowerCase();
161
- mkdirSync(dirname(targetPath), { recursive: true });
162
- // No extension requested (e.g. unknown-format image artifact) — pick one
163
- // from the content-type so the file has a reasonable suffix.
164
- if (!requestedExt && actualExt) {
165
- const finalPath = `${targetPath}.${actualExt}`;
166
- await pipeline(Readable.fromWeb(resp.body), createWriteStream(finalPath));
167
- return finalPath;
168
- }
169
- // Fast path: extensions agree (or the server didn't specify one) — stream through.
170
- if (!actualExt ||
171
- !requestedExt ||
172
- actualExt === requestedExt ||
173
- extEquivalent(actualExt, requestedExt)) {
174
- await pipeline(Readable.fromWeb(resp.body), createWriteStream(targetPath));
175
- return targetPath;
176
- }
177
- // Extensions differ. Buffer the body so we can either transcode or rename.
178
- const buffer = Buffer.from(await resp.arrayBuffer());
179
- // Both sides are image formats sharp understands — convert.
180
- if (CONVERTIBLE_IMAGE_EXTS.has(actualExt) && CONVERTIBLE_IMAGE_EXTS.has(requestedExt)) {
181
- logger.debug(`converting ${actualExt} → ${requestedExt} for ${artifact.key}`);
182
- await convertImage(buffer, requestedExt, targetPath);
183
- return targetPath;
184
- }
185
- // Can't convert safely — save with the true extension so the file isn't a lie.
186
- const base = targetPath.slice(0, targetPath.length - (requestedExt.length + 1));
187
- const fallback = `${base}.${actualExt}`;
188
- logger.warn(`extension mismatch for ${artifact.key}: requested .${requestedExt}, got ${contentType || "?"}; ` +
189
- `cannot transcode ${actualExt} → ${requestedExt}, saving as ${fallback}`);
190
- await pipeline(Readable.from(buffer), createWriteStream(fallback));
191
- return fallback;
192
- }
193
- async function convertImage(buffer, targetExt, targetPath) {
194
- const pipe = sharp(buffer);
195
- switch (targetExt) {
196
- case "jpg":
197
- case "jpeg":
198
- await pipe.jpeg({ quality: 92 }).toFile(targetPath);
199
- return;
200
- case "png":
201
- await pipe.png().toFile(targetPath);
202
- return;
203
- case "webp":
204
- await pipe.webp({ quality: 92 }).toFile(targetPath);
205
- return;
206
- case "gif":
207
- await pipe.gif().toFile(targetPath);
208
- return;
209
- case "tiff":
210
- case "tif":
211
- await pipe.tiff().toFile(targetPath);
212
- return;
213
- case "avif":
214
- await pipe.avif({ quality: 60 }).toFile(targetPath);
215
- return;
216
- default:
217
- throw new Error(`unsupported image target extension: ${targetExt}`);
781
+ let finalPath = targetPath;
782
+ let tmp = fetched.tmpPath;
783
+ let bytes = fetched.bytes;
784
+ let sha = fetched.sha256;
785
+ try {
786
+ if (!requestedExt && actualExt) {
787
+ // No extension requested (e.g. unknown-format image artifact) — pick one
788
+ // from the content-type so the file has a reasonable suffix.
789
+ finalPath = `${targetPath}.${actualExt}`;
790
+ }
791
+ else if (actualExt && requestedExt && actualExt !== requestedExt && !extEquivalent(actualExt, requestedExt)) {
792
+ if (CONVERTIBLE_IMAGE_EXTS.has(actualExt) && CONVERTIBLE_IMAGE_EXTS.has(requestedExt)) {
793
+ // Both sides are image formats sharp understands — convert.
794
+ logger.debug(`converting ${actualExt} → ${requestedExt} for ${artifact.key}`);
795
+ const converted = `${tmp}.conv`;
796
+ await convertImage(readFileSync(tmp), requestedExt, converted);
797
+ removeQuietly(tmp);
798
+ tmp = converted;
799
+ const digest = fileDigest(converted);
800
+ bytes = digest.bytes;
801
+ sha = digest.sha256;
802
+ }
803
+ else {
804
+ // Can't convert safely — save with the true extension so the file isn't a lie.
805
+ finalPath = `${targetPath.slice(0, targetPath.length - (requestedExt.length + 1))}.${actualExt}`;
806
+ logger.warn(`extension mismatch for ${artifact.key}: requested .${requestedExt}, got ${fetched.contentType || "?"}; ` +
807
+ `cannot transcode ${actualExt} → ${requestedExt}, saving as ${finalPath}`);
808
+ }
809
+ }
810
+ if (signal?.aborted)
811
+ throw new CliError({ code: "interrupted", message: `interrupted before ${artifact.key} was published` });
812
+ // The final name may differ from the pre-checked one; it still may not
813
+ // clobber anything and must stay under the output directory.
814
+ const resolved = resolveWithinRoot(finalPath, root, { label: "download target" }).path;
815
+ publishTempFile(tmp, resolved, { overwrite: false });
218
816
  }
219
- }
220
- function extFromContentType(ct) {
221
- const base = ct.split(";")[0].trim().toLowerCase();
222
- const map = {
223
- "image/jpeg": "jpg",
224
- "image/png": "png",
225
- "image/webp": "webp",
226
- "image/gif": "gif",
227
- "image/tiff": "tiff",
228
- "image/bmp": "bmp",
229
- "model/gltf-binary": "glb",
230
- "model/gltf+json": "gltf",
231
- "model/obj": "obj",
232
- "model/vnd.usdz+zip": "usdz",
233
- "model/stl": "stl",
234
- "model/3mf": "3mf",
235
- "application/json": "json",
236
- "video/mp4": "mp4",
237
- };
238
- return map[base] ?? "";
239
- }
240
- function extEquivalent(a, b) {
241
- const groups = [new Set(["jpg", "jpeg"]), new Set(["tif", "tiff"])];
242
- return groups.some((g) => g.has(a) && g.has(b));
817
+ catch (err) {
818
+ removeQuietly(tmp);
819
+ throw err;
820
+ }
821
+ return { path: finalPath === targetPath ? targetPath : finalPath, bytes, sha256: sha, contentType: fetched.contentType };
243
822
  }
244
823
  /**
245
824
  * Save a no-artifact task (e.g. analyze-printability) to disk. Two layouts:
@@ -248,40 +827,54 @@ function extEquivalent(a, b) {
248
827
  * - `-o some/dir/` (or any non-file path): write `meta.json` inside,
249
828
  * matching the directory-mode layout used elsewhere.
250
829
  * Other single-file extensions are rejected — the data is JSON, lying about
251
- * the extension would be worse than a clear error.
830
+ * the extension would be worse than a clear error. Every path is proven inside
831
+ * the workspace (when given) before any directory or file is created.
252
832
  */
253
- function saveReportOnly(task, outputPath, resource) {
833
+ function saveReportOnly(task, outputPath, resource, workspaceRoot) {
254
834
  const singleFileMode = looksLikeFile(outputPath);
835
+ const abs = resolvePath(outputPath);
255
836
  if (singleFileMode) {
256
- const ext = extname(outputPath).slice(1).toLowerCase();
837
+ const ext = extname(abs).slice(1).toLowerCase();
257
838
  if (ext !== "json") {
258
839
  throw new UsageError(`${resource} produces a JSON report — pass '-o <path>.json' or a directory path (got '${outputPath}').`);
259
840
  }
260
- if (existsSync(outputPath)) {
841
+ if (existsSync(abs)) {
261
842
  throw new UsageError(`refusing to overwrite existing file:\n ${outputPath}\n` +
262
843
  `(delete it or choose a different --output path to rerun)`);
263
844
  }
264
- mkdirSync(dirname(outputPath) || ".", { recursive: true });
265
- writeFileSync(outputPath, `${JSON.stringify({ resource, task, downloaded_at: new Date().toISOString() }, null, 2)}\n`, "utf8");
266
- return { savedFiles: [], metadataPath: outputPath };
845
+ if (workspaceRoot)
846
+ resolveWithinRoot(abs, workspaceRoot, { label: "report path" });
847
+ writeJsonFile(abs, { resource, task, downloaded_at: new Date().toISOString() }, { overwrite: false, mode: 0o644 });
848
+ return { savedFiles: [], metadataPath: abs, materialLinks: null, files: [] };
267
849
  }
268
- const metadataPath = join(outputPath, "meta.json");
850
+ const metadataPath = join(abs, "meta.json");
269
851
  if (existsSync(metadataPath)) {
270
852
  throw new UsageError(`refusing to overwrite existing file:\n ${metadataPath}\n` +
271
853
  `(delete it or choose a different --output path to rerun)`);
272
854
  }
273
- mkdirSync(outputPath, { recursive: true });
274
- writeMeta(task, resource, metadataPath, []);
275
- return { savedFiles: [], metadataPath };
855
+ if (workspaceRoot) {
856
+ resolveWithinRoot(abs, workspaceRoot, { label: "output directory" });
857
+ resolveWithinRoot(metadataPath, workspaceRoot, { label: "planned download path" });
858
+ }
859
+ mkdirSync(abs, { recursive: true });
860
+ writeMeta(task, resource, metadataPath, [], workspaceRoot ?? freezeRoot(abs, { label: "output directory" }));
861
+ return { savedFiles: [], metadataPath, materialLinks: null, files: [] };
276
862
  }
277
- function writeMeta(task, resource, path, savedFiles) {
863
+ /**
864
+ * The legacy sidecar (`meta.json` / `<stem>_meta.json`), published under the
865
+ * same rules as an asset: the real path is re-proven inside `root` at
866
+ * publication time (a symlink or file that appeared since the preflight is
867
+ * refused, never followed or truncated) and the write is exclusive and atomic.
868
+ */
869
+ function writeMeta(task, resource, path, savedFiles, root) {
278
870
  const meta = {
279
871
  resource,
280
872
  task,
281
873
  saved_files: savedFiles,
282
874
  downloaded_at: new Date().toISOString(),
283
875
  };
284
- mkdirSync(dirname(path), { recursive: true });
285
- writeFileSync(path, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
876
+ const resolved = resolveWithinRoot(path, root, { label: "sidecar path" }).path;
877
+ writeJsonFile(resolved, meta, { overwrite: false, mode: 0o644 });
286
878
  }
879
+ export { safeSegment as _safeSegmentForTests };
287
880
  //# sourceMappingURL=download.js.map