@meshy-ai/cli 0.2.0 → 0.3.1
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.
- package/.env.example +8 -2
- package/README.md +343 -108
- package/dist/client/endpoints/animation-catalog.d.ts +37 -0
- package/dist/client/endpoints/animation-catalog.d.ts.map +1 -0
- package/dist/client/endpoints/animation-catalog.js +55 -0
- package/dist/client/endpoints/animation-catalog.js.map +1 -0
- package/dist/client/endpoints/balance.d.ts +5 -0
- package/dist/client/endpoints/balance.d.ts.map +1 -1
- package/dist/client/endpoints/balance.js +5 -1
- package/dist/client/endpoints/balance.js.map +1 -1
- package/dist/client/endpoints/base.d.ts +41 -7
- package/dist/client/endpoints/base.d.ts.map +1 -1
- package/dist/client/endpoints/base.js +61 -40
- package/dist/client/endpoints/base.js.map +1 -1
- package/dist/client/endpoints/showcases.d.ts +31 -0
- package/dist/client/endpoints/showcases.d.ts.map +1 -0
- package/dist/client/endpoints/showcases.js +49 -0
- package/dist/client/endpoints/showcases.js.map +1 -0
- package/dist/client/errors.d.ts +1 -0
- package/dist/client/errors.d.ts.map +1 -1
- package/dist/client/errors.js +1 -1
- package/dist/client/errors.js.map +1 -1
- package/dist/client/index.d.ts +47 -38
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +89 -120
- package/dist/client/index.js.map +1 -1
- package/dist/client/resource-registry.d.ts +97 -0
- package/dist/client/resource-registry.d.ts.map +1 -0
- package/dist/client/resource-registry.js +157 -0
- package/dist/client/resource-registry.js.map +1 -0
- package/dist/client/transport.d.ts +99 -0
- package/dist/client/transport.d.ts.map +1 -0
- package/dist/client/transport.js +320 -0
- package/dist/client/transport.js.map +1 -0
- package/dist/client/types.d.ts +17 -6
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client/types.js +28 -6
- package/dist/client/types.js.map +1 -1
- package/dist/cmd/animation-catalog.d.ts +26 -0
- package/dist/cmd/animation-catalog.d.ts.map +1 -0
- package/dist/cmd/animation-catalog.js +80 -0
- package/dist/cmd/animation-catalog.js.map +1 -0
- package/dist/cmd/api.d.ts +3 -2
- package/dist/cmd/api.d.ts.map +1 -1
- package/dist/cmd/api.js +22 -8
- package/dist/cmd/api.js.map +1 -1
- package/dist/cmd/auth.d.ts.map +1 -1
- package/dist/cmd/auth.js +5 -0
- package/dist/cmd/auth.js.map +1 -1
- package/dist/cmd/balance.d.ts.map +1 -1
- package/dist/cmd/balance.js +10 -6
- package/dist/cmd/balance.js.map +1 -1
- package/dist/cmd/creative-lab.d.ts +26 -0
- package/dist/cmd/creative-lab.d.ts.map +1 -0
- package/dist/cmd/creative-lab.js +256 -0
- package/dist/cmd/creative-lab.js.map +1 -0
- package/dist/cmd/delete.d.ts.map +1 -1
- package/dist/cmd/delete.js +6 -7
- package/dist/cmd/delete.js.map +1 -1
- package/dist/cmd/doctor.d.ts +23 -0
- package/dist/cmd/doctor.d.ts.map +1 -0
- package/dist/cmd/doctor.js +61 -0
- package/dist/cmd/doctor.js.map +1 -0
- package/dist/cmd/download.d.ts +17 -0
- package/dist/cmd/download.d.ts.map +1 -0
- package/dist/cmd/download.js +436 -0
- package/dist/cmd/download.js.map +1 -0
- package/dist/cmd/inspect.d.ts +63 -0
- package/dist/cmd/inspect.d.ts.map +1 -0
- package/dist/cmd/inspect.js +179 -0
- package/dist/cmd/inspect.js.map +1 -0
- package/dist/cmd/make.d.ts +13 -3
- package/dist/cmd/make.d.ts.map +1 -1
- package/dist/cmd/make.js +240 -36
- package/dist/cmd/make.js.map +1 -1
- package/dist/cmd/mesh.d.ts +13 -0
- package/dist/cmd/mesh.d.ts.map +1 -0
- package/dist/cmd/mesh.js +67 -0
- package/dist/cmd/mesh.js.map +1 -0
- package/dist/cmd/project.d.ts +16 -0
- package/dist/cmd/project.d.ts.map +1 -0
- package/dist/cmd/project.js +136 -0
- package/dist/cmd/project.js.map +1 -0
- package/dist/cmd/resources.d.ts +5 -0
- package/dist/cmd/resources.d.ts.map +1 -1
- package/dist/cmd/resources.js +38 -10
- package/dist/cmd/resources.js.map +1 -1
- package/dist/cmd/showcases.d.ts +10 -0
- package/dist/cmd/showcases.d.ts.map +1 -0
- package/dist/cmd/showcases.js +63 -0
- package/dist/cmd/showcases.js.map +1 -0
- package/dist/cmd/slicer.d.ts +12 -0
- package/dist/cmd/slicer.d.ts.map +1 -0
- package/dist/cmd/slicer.js +46 -0
- package/dist/cmd/slicer.js.map +1 -0
- package/dist/cmd/uv-unwrap.d.ts +16 -0
- package/dist/cmd/uv-unwrap.d.ts.map +1 -0
- package/dist/cmd/uv-unwrap.js +52 -0
- package/dist/cmd/uv-unwrap.js.map +1 -0
- package/dist/index.d.ts +13 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +141 -8
- package/dist/index.js.map +1 -1
- package/dist/internal/artifacts.d.ts +97 -0
- package/dist/internal/artifacts.d.ts.map +1 -0
- package/dist/internal/artifacts.js +280 -0
- package/dist/internal/artifacts.js.map +1 -0
- package/dist/internal/atomic-file.d.ts +39 -0
- package/dist/internal/atomic-file.d.ts.map +1 -0
- package/dist/internal/atomic-file.js +160 -0
- package/dist/internal/atomic-file.js.map +1 -0
- package/dist/internal/command-helpers.d.ts +46 -0
- package/dist/internal/command-helpers.d.ts.map +1 -0
- package/dist/internal/command-helpers.js +61 -0
- package/dist/internal/command-helpers.js.map +1 -0
- package/dist/internal/config.d.ts +48 -5
- package/dist/internal/config.d.ts.map +1 -1
- package/dist/internal/config.js +95 -6
- package/dist/internal/config.js.map +1 -1
- package/dist/internal/context.d.ts +25 -0
- package/dist/internal/context.d.ts.map +1 -0
- package/dist/internal/context.js +37 -0
- package/dist/internal/context.js.map +1 -0
- package/dist/internal/credentials.d.ts +11 -0
- package/dist/internal/credentials.d.ts.map +1 -1
- package/dist/internal/credentials.js +2 -0
- package/dist/internal/credentials.js.map +1 -1
- package/dist/internal/doctor.d.ts +94 -0
- package/dist/internal/doctor.d.ts.map +1 -0
- package/dist/internal/doctor.js +280 -0
- package/dist/internal/doctor.js.map +1 -0
- package/dist/internal/download.d.ts +147 -13
- package/dist/internal/download.d.ts.map +1 -1
- package/dist/internal/download.js +735 -142
- package/dist/internal/download.js.map +1 -1
- package/dist/internal/env-file.d.ts +29 -0
- package/dist/internal/env-file.d.ts.map +1 -0
- package/dist/internal/env-file.js +95 -0
- package/dist/internal/env-file.js.map +1 -0
- package/dist/internal/errors.d.ts +74 -1
- package/dist/internal/errors.d.ts.map +1 -1
- package/dist/internal/errors.js +173 -1
- package/dist/internal/errors.js.map +1 -1
- package/dist/internal/file-input.d.ts +52 -12
- package/dist/internal/file-input.d.ts.map +1 -1
- package/dist/internal/file-input.js +162 -33
- package/dist/internal/file-input.js.map +1 -1
- package/dist/internal/global-options.d.ts +6 -4
- package/dist/internal/global-options.d.ts.map +1 -1
- package/dist/internal/global-options.js +19 -4
- package/dist/internal/global-options.js.map +1 -1
- package/dist/internal/image-models.d.ts +9 -2
- package/dist/internal/image-models.d.ts.map +1 -1
- package/dist/internal/image-models.js +24 -12
- package/dist/internal/image-models.js.map +1 -1
- package/dist/internal/inspect.d.ts +67 -0
- package/dist/internal/inspect.d.ts.map +1 -0
- package/dist/internal/inspect.js +111 -0
- package/dist/internal/inspect.js.map +1 -0
- package/dist/internal/lock.d.ts +19 -0
- package/dist/internal/lock.d.ts.map +1 -0
- package/dist/internal/lock.js +66 -0
- package/dist/internal/lock.js.map +1 -0
- package/dist/internal/material-links.d.ts +115 -0
- package/dist/internal/material-links.d.ts.map +1 -0
- package/dist/internal/material-links.js +577 -0
- package/dist/internal/material-links.js.map +1 -0
- package/dist/internal/obj-transform.d.ts +115 -0
- package/dist/internal/obj-transform.d.ts.map +1 -0
- package/dist/internal/obj-transform.js +649 -0
- package/dist/internal/obj-transform.js.map +1 -0
- package/dist/internal/operation-store.d.ts +123 -0
- package/dist/internal/operation-store.d.ts.map +1 -0
- package/dist/internal/operation-store.js +229 -0
- package/dist/internal/operation-store.js.map +1 -0
- package/dist/internal/output.d.ts +20 -2
- package/dist/internal/output.d.ts.map +1 -1
- package/dist/internal/output.js +32 -4
- package/dist/internal/output.js.map +1 -1
- package/dist/internal/paths.d.ts +89 -0
- package/dist/internal/paths.d.ts.map +1 -0
- package/dist/internal/paths.js +168 -0
- package/dist/internal/paths.js.map +1 -0
- package/dist/internal/payload.d.ts +15 -0
- package/dist/internal/payload.d.ts.map +1 -1
- package/dist/internal/payload.js +50 -3
- package/dist/internal/payload.js.map +1 -1
- package/dist/internal/poll.d.ts +30 -4
- package/dist/internal/poll.d.ts.map +1 -1
- package/dist/internal/poll.js +99 -16
- package/dist/internal/poll.js.map +1 -1
- package/dist/internal/project-store.d.ts +197 -0
- package/dist/internal/project-store.d.ts.map +1 -0
- package/dist/internal/project-store.js +467 -0
- package/dist/internal/project-store.js.map +1 -0
- package/dist/internal/result.d.ts +50 -0
- package/dist/internal/result.d.ts.map +1 -0
- package/dist/internal/result.js +41 -0
- package/dist/internal/result.js.map +1 -0
- package/dist/internal/runtime.d.ts +35 -5
- package/dist/internal/runtime.d.ts.map +1 -1
- package/dist/internal/runtime.js +68 -18
- package/dist/internal/runtime.js.map +1 -1
- package/dist/internal/slicers.d.ts +117 -0
- package/dist/internal/slicers.d.ts.map +1 -0
- package/dist/internal/slicers.js +314 -0
- package/dist/internal/slicers.js.map +1 -0
- package/dist/internal/stream.d.ts +58 -0
- package/dist/internal/stream.d.ts.map +1 -0
- package/dist/internal/stream.js +280 -0
- package/dist/internal/stream.js.map +1 -0
- package/dist/internal/task-command.d.ts +128 -11
- package/dist/internal/task-command.d.ts.map +1 -1
- package/dist/internal/task-command.js +991 -111
- package/dist/internal/task-command.js.map +1 -1
- package/dist/internal/task-view.d.ts +56 -0
- package/dist/internal/task-view.d.ts.map +1 -0
- package/dist/internal/task-view.js +95 -0
- package/dist/internal/task-view.js.map +1 -0
- package/dist/internal/update-notifier.d.ts +5 -1
- package/dist/internal/update-notifier.d.ts.map +1 -1
- package/dist/internal/update-notifier.js +7 -3
- package/dist/internal/update-notifier.js.map +1 -1
- package/dist/root.d.ts +10 -0
- package/dist/root.d.ts.map +1 -1
- package/dist/root.js +75 -10
- package/dist/root.js.map +1 -1
- package/package.json +3 -2
- package/skills/meshy-cli/SKILL.md +140 -59
- package/dist/client/endpoints/analyze-printability.d.ts +0 -5
- package/dist/client/endpoints/analyze-printability.d.ts.map +0 -1
- package/dist/client/endpoints/analyze-printability.js +0 -7
- package/dist/client/endpoints/analyze-printability.js.map +0 -1
- package/dist/client/endpoints/animate.d.ts +0 -6
- package/dist/client/endpoints/animate.d.ts.map +0 -1
- package/dist/client/endpoints/animate.js +0 -8
- package/dist/client/endpoints/animate.js.map +0 -1
- package/dist/client/endpoints/convert.d.ts +0 -5
- package/dist/client/endpoints/convert.d.ts.map +0 -1
- package/dist/client/endpoints/convert.js +0 -7
- package/dist/client/endpoints/convert.js.map +0 -1
- package/dist/client/endpoints/image-to-3d.d.ts +0 -5
- package/dist/client/endpoints/image-to-3d.d.ts.map +0 -1
- package/dist/client/endpoints/image-to-3d.js +0 -7
- package/dist/client/endpoints/image-to-3d.js.map +0 -1
- package/dist/client/endpoints/image-to-image.d.ts +0 -5
- package/dist/client/endpoints/image-to-image.d.ts.map +0 -1
- package/dist/client/endpoints/image-to-image.js +0 -7
- package/dist/client/endpoints/image-to-image.js.map +0 -1
- package/dist/client/endpoints/multi-color-print.d.ts +0 -5
- package/dist/client/endpoints/multi-color-print.d.ts.map +0 -1
- package/dist/client/endpoints/multi-color-print.js +0 -7
- package/dist/client/endpoints/multi-color-print.js.map +0 -1
- package/dist/client/endpoints/multi-image-to-3d.d.ts +0 -5
- package/dist/client/endpoints/multi-image-to-3d.d.ts.map +0 -1
- package/dist/client/endpoints/multi-image-to-3d.js +0 -7
- package/dist/client/endpoints/multi-image-to-3d.js.map +0 -1
- package/dist/client/endpoints/remesh.d.ts +0 -5
- package/dist/client/endpoints/remesh.d.ts.map +0 -1
- package/dist/client/endpoints/remesh.js +0 -7
- package/dist/client/endpoints/remesh.js.map +0 -1
- package/dist/client/endpoints/repair-printability.d.ts +0 -5
- package/dist/client/endpoints/repair-printability.d.ts.map +0 -1
- package/dist/client/endpoints/repair-printability.js +0 -7
- package/dist/client/endpoints/repair-printability.js.map +0 -1
- package/dist/client/endpoints/resize.d.ts +0 -5
- package/dist/client/endpoints/resize.d.ts.map +0 -1
- package/dist/client/endpoints/resize.js +0 -7
- package/dist/client/endpoints/resize.js.map +0 -1
- package/dist/client/endpoints/retexture.d.ts +0 -5
- package/dist/client/endpoints/retexture.d.ts.map +0 -1
- package/dist/client/endpoints/retexture.js +0 -7
- package/dist/client/endpoints/retexture.js.map +0 -1
- package/dist/client/endpoints/rigging.d.ts +0 -6
- package/dist/client/endpoints/rigging.d.ts.map +0 -1
- package/dist/client/endpoints/rigging.js +0 -8
- package/dist/client/endpoints/rigging.js.map +0 -1
- package/dist/client/endpoints/text-to-3d.d.ts +0 -6
- package/dist/client/endpoints/text-to-3d.d.ts.map +0 -1
- package/dist/client/endpoints/text-to-3d.js +0 -8
- package/dist/client/endpoints/text-to-3d.js.map +0 -1
- package/dist/client/endpoints/text-to-image.d.ts +0 -5
- package/dist/client/endpoints/text-to-image.d.ts.map +0 -1
- package/dist/client/endpoints/text-to-image.js +0 -7
- package/dist/client/endpoints/text-to-image.js.map +0 -1
- package/dist/client/endpoints/text-to-motion.d.ts +0 -6
- package/dist/client/endpoints/text-to-motion.d.ts.map +0 -1
- package/dist/client/endpoints/text-to-motion.js +0 -8
- package/dist/client/endpoints/text-to-motion.js.map +0 -1
|
@@ -1,34 +1,548 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Asset downloads with explicit boundaries.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
15
|
-
* the
|
|
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 {
|
|
18
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
677
|
+
const plan = [];
|
|
95
678
|
if (singleFileMode) {
|
|
96
679
|
targetDir = dirname(outputPath) || ".";
|
|
97
|
-
|
|
680
|
+
plan.push({ artifact: artifacts[0], target: outputPath });
|
|
98
681
|
}
|
|
99
682
|
else {
|
|
100
683
|
targetDir = outputPath;
|
|
101
|
-
for (const artifact of artifacts)
|
|
102
|
-
|
|
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 = [...
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
!requestedExt
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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(
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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
|