#!/usr/bin/env node import { spawnSync } from "node:child_process"; import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { inlineStepGlbArtifactPathForSource, stepParameterPathForStepSource, } from "cadjs/common/stepSidecars.mjs"; import { CAD_CATALOG_SCHEMA_VERSION, isServedCadAsset, scanCadDirectory, VIEWER_SKIPPED_DIRECTORIES, } from "../src/server/catalog/cadDirectoryScanner.mjs"; import { pathIsInsideOrEqual, toPosixPath, } from "cadjs/lib/pathUtils.mjs"; import { CATALOG_BLOB_CACHE_CONTROL_MAX_AGE_SECONDS, contentTypeForFileRef, createVercelBlobAssetBackend, normalizeVercelBlobCatalog, } from "../src/server/vercelBlobAssetBackend.mjs"; import { envValue, normalizeViewerAssetBackend, vercelBlobConfigFromEnv, VIEWER_ASSET_BACKENDS, } from "../src/server/viewerEnv.mjs"; export const DEFAULT_UPLOAD_IGNORE_FILE = ".vieweruploadignore"; export const DEFAULT_UPLOAD_EXCLUDE_PATTERNS = Object.freeze([ "/mechbench/", "/mechbench2/", "/7dof_arm/", "*.py", ]); const DEFAULT_UPLOAD_CONCURRENCY = 4; const CACHE_BUSTED_UPLOAD_EXTENSIONS = new Set([".js", ".mjs"]); const GIT_LFS_POINTER_PREFIX = "version https://git-lfs.github.com/spec/v1"; function usage() { return `Usage: npm --prefix viewer run upload:blob -- [directory] [options] Uploads a CAD Viewer catalog and viewer-supported assets to Vercel Blob. Options: --ignore-file Gitignore-style exclude file. May be repeated. --exclude Gitignore-style exclude pattern. May be repeated. --concurrency Concurrent uploads. Defaults to ${DEFAULT_UPLOAD_CONCURRENCY}. --skip-existing Skip assets whose content is already in the remote catalog. --fetch-missing-lfs Fetch Git LFS pointers only for assets that must be uploaded. -h, --help Show this help. Environment: VIEWER_VERCEL_BLOB_PREFIX VIEWER_VERCEL_BLOB_READ_WRITE_TOKEN or BLOB_READ_WRITE_TOKEN VIEWER_ASSET_BACKEND=vercel-blob (optional) Default excludes: ${DEFAULT_UPLOAD_EXCLUDE_PATTERNS.join("\n ")}`; } function cleanPosixPath(value) { const normalized = path.posix.normalize(String(value || "").trim().replace(/\\/g, "/").replace(/^\/+/, "")); return normalized && normalized !== "." && !normalized.startsWith("../") ? normalized : ""; } function fileStats(filePath) { try { const stats = fs.statSync(filePath, { bigint: true }); return stats.isFile() ? stats : null; } catch { return null; } } function sha256File(filePath) { const hash = crypto.createHash("sha256"); const fd = fs.openSync(filePath, "r"); try { const buffer = Buffer.alloc(1024 * 1024); let bytesRead = 0; do { bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null); if (bytesRead > 0) { hash.update(buffer.subarray(0, bytesRead)); } } while (bytesRead > 0); } finally { fs.closeSync(fd); } return hash.digest("hex"); } function fileAssetMetadata(filePath) { const stats = fileStats(filePath); if (!stats) { return null; } return { hash: sha256File(filePath), bytes: Number(stats.size), }; } function readGitLfsPointer(filePath) { const stats = fileStats(filePath); if (!stats || Number(stats.size) > 1024) { return null; } let text = ""; try { text = fs.readFileSync(filePath, "utf-8"); } catch { return null; } if (!text.startsWith(GIT_LFS_POINTER_PREFIX)) { return null; } const oid = /^oid sha256:([a-f0-9]{64})$/mi.exec(text)?.[1] || ""; const sizeText = /^size ([0-9]+)$/mi.exec(text)?.[1] || ""; return { oid, size: sizeText ? Number(sizeText) : 0, }; } function comparableFileAssetMetadata(filePath) { const pointer = readGitLfsPointer(filePath); if (pointer?.oid) { return { hash: pointer.oid, bytes: pointer.size, gitLfsPointer: true, }; } const metadata = fileAssetMetadata(filePath); return metadata ? { ...metadata, gitLfsPointer: false, } : null; } function cacheBustedUploadFileRef({ fileRef, hash }) { const normalizedRef = cleanPosixPath(fileRef); const extension = path.posix.extname(normalizedRef).toLowerCase(); const normalizedHash = String(hash || "").trim().replace(/[^a-fA-F0-9]/g, "").slice(0, 16); if (!normalizedRef || !normalizedHash || !CACHE_BUSTED_UPLOAD_EXTENSIONS.has(extension)) { return normalizedRef; } return `${normalizedRef.slice(0, -extension.length)}.${normalizedHash}${extension}`; } function escapeRegExp(value) { return String(value).replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); } function globToRegExpSource(pattern) { let source = ""; for (let index = 0; index < pattern.length;) { const char = pattern[index]; if (char === "*") { if (pattern[index + 1] === "*") { source += ".*"; index += 2; } else { source += "[^/]*"; index += 1; } continue; } if (char === "?") { source += "[^/]"; index += 1; continue; } source += escapeRegExp(char); index += 1; } return source; } function normalizeIgnoreLine(line) { const trimmed = String(line || "").trim(); if (!trimmed || trimmed.startsWith("#")) { return ""; } return trimmed.startsWith("\\#") ? trimmed.slice(1) : trimmed; } export function parseIgnorePatterns(text) { return String(text || "") .split(/\r?\n/) .map(normalizeIgnoreLine) .filter(Boolean); } function compileIgnoreRule(rawPattern) { let pattern = normalizeIgnoreLine(rawPattern); if (!pattern) { return null; } let negated = false; if (pattern.startsWith("!")) { negated = true; pattern = normalizeIgnoreLine(pattern.slice(1)); } if (!pattern) { return null; } const directoryOnly = pattern.endsWith("/"); const anchored = pattern.startsWith("/"); const normalizedPattern = pattern.replace(/^\/+|\/+$/g, ""); if (!normalizedPattern) { return null; } const basenameOnly = !normalizedPattern.includes("/"); const regexSource = globToRegExpSource(normalizedPattern); const regex = basenameOnly ? new RegExp(`(?:^|/)${regexSource}${directoryOnly ? "(?:/|$)" : "$"}`) : new RegExp(`${anchored ? "^" : "(?:^|.*/)"}${regexSource}${directoryOnly ? "(?:/|$)" : "$"}`); return { negated, pattern, regex, }; } export function createIgnoreMatcher(patterns = []) { const rules = patterns .map(compileIgnoreRule) .filter(Boolean); return ({ relativePath = "", isDirectory = false } = {}) => { const normalizedPath = cleanPosixPath(relativePath); if (!normalizedPath) { return false; } const target = isDirectory ? `${normalizedPath}/` : normalizedPath; let ignored = false; for (const rule of rules) { if (rule.regex.test(target)) { ignored = !rule.negated; } } return ignored; }; } function readIgnoreFile(ignoreFile, cwd) { const resolved = path.resolve(cwd, ignoreFile); return parseIgnorePatterns(fs.readFileSync(resolved, "utf-8")); } function resolveUploadRoot({ directory = "", rootDir = "", env = process.env, cwd = process.cwd(), } = {}) { if (rootDir) { throw new Error("--root-dir has been removed; pass the upload directory as the positional argument."); } const callerRoot = path.resolve(env.INIT_CWD || cwd); const rawRootDir = directory || "."; const resolvedDirectory = path.resolve(callerRoot, rawRootDir); return { repoRoot: resolvedDirectory, rootDir: "", rootPath: resolvedDirectory, }; } function includePathFromIgnoreMatcher(ignoreMatcher) { return ({ relativePath, isDirectory }) => !ignoreMatcher({ relativePath, isDirectory }); } function shouldSkipUploadDirectory(name) { return VIEWER_SKIPPED_DIRECTORIES.has(name) || String(name || "").startsWith("."); } function rootRelativePath(rootPath, filePath) { const relativePath = path.relative(path.resolve(rootPath), path.resolve(filePath)); if (!pathIsInsideOrEqual(filePath, rootPath)) { return ""; } return cleanPosixPath(toPosixPath(relativePath)); } function isPythonSourceFileRef(fileRef) { const raw = String(fileRef || "").trim().replace(/\\/g, "/"); let pathname = raw.split(/[?#]/)[0]; try { pathname = new URL(raw).pathname; } catch { // Plain catalog refs are expected here. } return path.posix.extname(cleanPosixPath(pathname)).toLowerCase() === ".py"; } function addUploadFile(uploadFiles, { rootPath, filePath }) { const fileRef = rootRelativePath(rootPath, filePath); if (!fileRef) { return; } const stats = fileStats(filePath); if (!stats) { return; } uploadFiles.set(fileRef, { fileRef, filePath, bytes: Number(stats.size), }); } function maybeAddAbsoluteUploadFile(uploadFiles, { rootPath, filePath, includePath = null, }) { const fileRef = rootRelativePath(rootPath, filePath); if (!fileRef) { return null; } if (includePath && includePath({ filePath, relativePath: fileRef, isDirectory: false }) === false) { return null; } addUploadFile(uploadFiles, { rootPath, filePath }); return uploadFiles.get(fileRef) || null; } function collectServedAssetFiles(rootPath, { scanRootPath = rootPath, includePath = null, uploadFiles = new Map(), } = {}) { let entries = []; try { entries = fs.readdirSync(rootPath, { withFileTypes: true }); } catch { return uploadFiles; } for (const entry of entries) { const entryPath = path.join(rootPath, entry.name); const relativePath = rootRelativePath(scanRootPath, entryPath); if (entry.isDirectory()) { if ( !shouldSkipUploadDirectory(entry.name) && (!includePath || includePath({ filePath: entryPath, relativePath, isDirectory: true }) !== false) ) { collectServedAssetFiles(entryPath, { scanRootPath, includePath, uploadFiles }); } continue; } if ( entry.isFile() && (!includePath || includePath({ filePath: entryPath, relativePath, isDirectory: false }) !== false) && isServedCadAsset(entryPath) ) { addUploadFile(uploadFiles, { rootPath: scanRootPath, filePath: entryPath }); } } return uploadFiles; } function resolveExistingFileInsideRoot({ repoRoot, rootPath, fileRef }) { const normalizedRef = cleanPosixPath(fileRef); if (!normalizedRef) { return null; } const candidates = [ path.resolve(rootPath, normalizedRef), path.resolve(repoRoot, normalizedRef), ]; for (const candidate of [...new Set(candidates)]) { if (pathIsInsideOrEqual(candidate, rootPath) && fileStats(candidate)) { return { filePath: candidate, fileRef: rootRelativePath(rootPath, candidate), }; } } return null; } function sourceFileRefsForEntry(entry, { includeSourceCode = true } = {}) { const refs = [ entry?.sourceFile, entry?.source?.file, entry?.source?.path, entry?.source?.sourcePath, ].map(cleanPosixPath).filter(Boolean); return includeSourceCode ? refs : refs.filter((ref) => !isPythonSourceFileRef(ref)); } function maybeAddCatalogRef(uploadFiles, { repoRoot, rootPath, includePath, fileRef, }) { const resolved = resolveExistingFileInsideRoot({ repoRoot, rootPath, fileRef }); if (!resolved) { return null; } if (includePath && includePath({ filePath: resolved.filePath, relativePath: resolved.fileRef, isDirectory: false, }) === false) { return null; } addUploadFile(uploadFiles, { rootPath, filePath: resolved.filePath }); return resolved; } function addCatalogReferencedFiles(uploadFiles, { catalog, repoRoot, rootPath, includePath, }) { for (const entry of Array.isArray(catalog?.entries) ? catalog.entries : []) { const fileRef = cleanPosixPath(entry?.file); maybeAddCatalogRef(uploadFiles, { repoRoot, rootPath, includePath, fileRef }); const extension = path.posix.extname(fileRef).toLowerCase(); if (extension === ".step" || extension === ".stp") { const stepPath = path.resolve(rootPath, fileRef); maybeAddAbsoluteUploadFile(uploadFiles, { rootPath, filePath: inlineStepGlbArtifactPathForSource(stepPath), includePath, }); maybeAddAbsoluteUploadFile(uploadFiles, { rootPath, filePath: stepParameterPathForStepSource(stepPath), includePath, }); } maybeAddCatalogRef(uploadFiles, { repoRoot, rootPath, includePath, fileRef: entry?.relations?.urdf?.file, }); for (const sourceRef of sourceFileRefsForEntry(entry, { includeSourceCode: false })) { maybeAddCatalogRef(uploadFiles, { repoRoot, rootPath, includePath, fileRef: sourceRef }); } } return uploadFiles; } function normalizedHash(value) { return String(value || "").trim().toLowerCase().replace(/^sha256:/i, ""); } function normalizedBytes(value) { const number = Number(value || 0); return Number.isFinite(number) && number >= 0 ? number : 0; } function pathnameFromUrl(url) { try { return new URL(url).pathname.replace(/^\/+/, ""); } catch { return ""; } } function catalogUploadRecord({ fileRef, url = "", hash = "", bytes = undefined } = {}) { const normalizedRef = cleanPosixPath(fileRef); const normalizedUrl = String(url || "").trim(); if (!normalizedRef || !normalizedUrl) { return null; } const normalized = { fileRef: normalizedRef, filePath: "", hash: normalizedHash(hash), blobFileRef: cleanPosixPath(pathnameFromUrl(normalizedUrl)) || normalizedRef, url: normalizedUrl, pathname: pathnameFromUrl(normalizedUrl), }; const bytesNumber = Number(bytes); if (String(bytes ?? "").trim() && Number.isFinite(bytesNumber)) { normalized.bytes = bytesNumber; } return normalized; } function addExistingCatalogUpload(uploads, upload) { if (upload?.fileRef && upload?.url) { uploads.set(upload.fileRef, upload); } } function catalogFieldUpload(fileRef, value) { if (!value || typeof value !== "object") { return null; } return catalogUploadRecord({ fileRef, url: value.url, hash: value.hash, bytes: value.bytes, }); } function cacheBustedHashFromUrl(url, extension) { let basename = ""; try { basename = path.posix.basename(new URL(url).pathname); } catch { return ""; } const escapedExtension = escapeRegExp(extension); const match = new RegExp(`\\.([a-fA-F0-9]{16})${escapedExtension}$`).exec(basename); return match?.[1]?.toLowerCase() || ""; } function existingCatalogUploads(catalog, { rootPath } = {}) { const uploads = new Map(); for (const entry of Array.isArray(catalog?.entries) ? catalog.entries : []) { const fileRef = cleanPosixPath(entry?.file); if (!fileRef) { continue; } const extension = path.posix.extname(fileRef).toLowerCase(); if (extension === ".step" || extension === ".stp") { addExistingCatalogUpload(uploads, catalogFieldUpload(fileRef, { ...(entry?.step && typeof entry.step === "object" ? entry.step : {}), url: entry?.stepUrl || entry?.step?.url, hash: entry?.stepHash || entry?.step?.hash, bytes: entry?.stepBytes || entry?.step?.bytes, })); if (rootPath) { const stepPath = path.resolve(rootPath, fileRef); const glbRef = rootRelativePath(rootPath, inlineStepGlbArtifactPathForSource(stepPath)); addExistingCatalogUpload(uploads, catalogUploadRecord({ fileRef: glbRef, url: entry?.url, hash: entry?.hash, bytes: entry?.bytes, })); const moduleRef = rootRelativePath(rootPath, stepParameterPathForStepSource(stepPath)); const moduleUrl = String(entry?.moduleUrl || "").trim(); addExistingCatalogUpload(uploads, catalogUploadRecord({ fileRef: moduleRef, url: moduleUrl, hash: cacheBustedHashFromUrl(moduleUrl, path.posix.extname(moduleRef).toLowerCase()), bytes: entry?.moduleBytes, })); } } else { addExistingCatalogUpload(uploads, catalogFieldUpload(fileRef, entry)); } const relationUrdf = entry?.relations?.urdf; addExistingCatalogUpload(uploads, catalogFieldUpload(relationUrdf?.file, relationUrdf)); const source = entry?.source; if (source && !isPythonSourceFileRef(source?.file || source?.sourcePath || "")) { addExistingCatalogUpload(uploads, catalogFieldUpload(source?.file || source?.sourcePath, source)); } } return uploads; } function uploadMatchesExistingCatalog(uploadFile, existingUpload) { const existingHash = normalizedHash(existingUpload?.hash); if (!existingHash) { return false; } const metadata = comparableFileAssetMetadata(uploadFile.filePath); const localHash = normalizedHash(metadata?.hash); if (!localHash || localHash !== existingHash) { return false; } const existingBytes = normalizedBytes(existingUpload?.bytes); const localBytes = normalizedBytes(metadata?.bytes); return !existingBytes || !localBytes || existingBytes === localBytes; } function findGitRoot({ cwd = process.cwd(), rootPath = "" } = {}) { const candidates = [cwd, rootPath].filter(Boolean); for (const candidate of candidates) { const result = spawnSync("git", ["-C", candidate, "rev-parse", "--show-toplevel"], { encoding: "utf-8", }); if (result.status === 0) { return String(result.stdout || "").trim(); } } return ""; } function gitRelativePath(gitRoot, filePath) { const relativePath = path.relative(path.resolve(gitRoot), path.resolve(filePath)); if (!relativePath || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) { return ""; } return toPosixPath(relativePath); } function ensureGitLfsFiles({ uploadFiles, cwd = process.cwd(), rootPath = "", logger = console, } = {}) { const pointerFiles = uploadFiles .filter((uploadFile) => readGitLfsPointer(uploadFile.filePath)) .sort((a, b) => a.fileRef.localeCompare(b.fileRef)); if (!pointerFiles.length) { return 0; } const gitRoot = findGitRoot({ cwd, rootPath }); if (!gitRoot) { throw new Error("Cannot fetch Git LFS pointers because the upload directory is not inside a git worktree."); } const includePaths = pointerFiles .map((uploadFile) => gitRelativePath(gitRoot, uploadFile.filePath)) .filter(Boolean); if (includePaths.length !== pointerFiles.length) { throw new Error("Cannot fetch Git LFS pointers because one or more upload files are outside the git worktree."); } logger.log?.(`Fetching ${includePaths.length} Git LFS object(s) for missing Blob uploads.`); const result = spawnSync("git", [ "-C", gitRoot, "lfs", "pull", "--include", includePaths.join(","), "--exclude", "", ], { stdio: "inherit", }); if (result.status !== 0) { throw new Error(`git lfs pull failed while fetching missing Blob upload assets (exit ${result.status ?? "unknown"}).`); } const remainingPointers = pointerFiles.filter((uploadFile) => readGitLfsPointer(uploadFile.filePath)); if (remainingPointers.length) { throw new Error( `Git LFS did not materialize ${remainingPointers.length} upload asset(s): ` + remainingPointers.map((uploadFile) => uploadFile.fileRef).join(", ") ); } return pointerFiles.length; } function assertNoGitLfsPointers(uploadFiles) { const pointerFile = uploadFiles.find((uploadFile) => readGitLfsPointer(uploadFile.filePath)); if (pointerFile) { throw new Error( `${pointerFile.fileRef} is a Git LFS pointer. ` + "Use --fetch-missing-lfs to fetch only missing Blob upload assets before uploading." ); } } async function uploadOneFile(backend, uploadFile) { const metadata = fileAssetMetadata(uploadFile.filePath); if (!metadata) { throw new Error(`Upload asset is not a file: ${uploadFile.fileRef}`); } const preparedUploadFile = { ...uploadFile, ...metadata, }; const blobFileRef = cacheBustedUploadFileRef(preparedUploadFile); const upload = await backend.writeAsset({ fileRef: blobFileRef, body: fs.createReadStream(preparedUploadFile.filePath), contentType: contentTypeForFileRef(preparedUploadFile.fileRef), }); return { upload, uploadFile: preparedUploadFile, blobFileRef, }; } async function uploadFilesToBlob({ backend, uploadFiles, concurrency = DEFAULT_UPLOAD_CONCURRENCY, logger = console, existingUploads = new Map(), skipExisting = false, fetchMissingLfs = false, cwd = process.cwd(), rootPath = "", }) { const allFiles = [...uploadFiles.values()].sort((a, b) => a.fileRef.localeCompare(b.fileRef)); const uploads = new Map(); const queue = []; let skippedCount = 0; for (const uploadFile of allFiles) { const existingUpload = skipExisting ? existingUploads.get(uploadFile.fileRef) : null; if (existingUpload && uploadMatchesExistingCatalog(uploadFile, existingUpload)) { logger.log?.(`Skipping existing ${uploadFile.fileRef}`); uploads.set(uploadFile.fileRef, { ...uploadFile, ...existingUpload, fileRef: uploadFile.fileRef, filePath: uploadFile.filePath, }); skippedCount += 1; continue; } queue.push(uploadFile); } let fetchedLfsFiles = 0; if (fetchMissingLfs) { fetchedLfsFiles = ensureGitLfsFiles({ uploadFiles: queue, cwd, rootPath, logger }); } else { assertNoGitLfsPointers(queue); } let index = 0; const workerCount = Math.max(1, Math.min(Number(concurrency) || DEFAULT_UPLOAD_CONCURRENCY, queue.length || 1)); let uploadedCount = 0; async function worker() { for (;;) { const uploadFile = queue[index]; index += 1; if (!uploadFile) { return; } logger.log?.(`Uploading ${uploadFile.fileRef}`); const { upload, uploadFile: preparedUploadFile, blobFileRef, } = await uploadOneFile(backend, uploadFile); uploadedCount += 1; uploads.set(preparedUploadFile.fileRef, { ...preparedUploadFile, blobFileRef, url: upload?.url || "", pathname: upload?.pathname || "", }); } } await Promise.all(Array.from({ length: workerCount }, () => worker())); return { uploads, uploadedCount, skippedCount, fetchedLfsFiles, }; } function uploadedAssetForRef(uploads, fileRef) { return uploads.get(cleanPosixPath(fileRef)) || null; } function rewriteAssetFields(target, upload) { if (!upload?.url) { return target; } const next = { ...target, url: upload.url, }; if (upload.hash) { next.hash = upload.hash; } if (Number.isFinite(Number(upload.bytes))) { next.bytes = Number(upload.bytes); } return next; } function containsPythonSourceReference(value) { if (typeof value === "string") { return isPythonSourceFileRef(value); } if (Array.isArray(value)) { return value.some(containsPythonSourceReference); } if (value && typeof value === "object") { return Object.values(value).some(containsPythonSourceReference); } return false; } const SOURCE_CODE_CATALOG_KEYS = new Set([ "source", "sourceFile", "sourcePath", "sourceStatus", "sourceUrl", ]); function stripSourceCodeReferences(value, key = "") { if (Array.isArray(value)) { return value .map((item) => stripSourceCodeReferences(item)) .filter((item) => item !== undefined); } if (!value || typeof value !== "object") { return SOURCE_CODE_CATALOG_KEYS.has(key) && containsPythonSourceReference(value) ? undefined : value; } if (SOURCE_CODE_CATALOG_KEYS.has(key) && containsPythonSourceReference(value)) { return undefined; } const stripped = {}; for (const [childKey, childValue] of Object.entries(value)) { const nextValue = stripSourceCodeReferences(childValue, childKey); if (nextValue !== undefined) { stripped[childKey] = nextValue; } } return stripped; } function rewriteCatalogEntry(entry, { uploads, repoRoot, rootPath, }) { const nextEntry = { ...entry }; const fileRef = cleanPosixPath(entry?.file); const extension = path.posix.extname(fileRef).toLowerCase(); if (extension === ".step" || extension === ".stp") { const stepUpload = uploadedAssetForRef(uploads, fileRef); if (stepUpload) { nextEntry.step = rewriteAssetFields({ ...(nextEntry.step && typeof nextEntry.step === "object" ? nextEntry.step : {}), file: fileRef, }, stepUpload); } const glbRef = rootRelativePath( rootPath, inlineStepGlbArtifactPathForSource(path.resolve(rootPath, fileRef)), ); const glbUpload = uploadedAssetForRef(uploads, glbRef); if (glbUpload) { Object.assign(nextEntry, rewriteAssetFields({}, glbUpload)); } const moduleRef = rootRelativePath( rootPath, stepParameterPathForStepSource(path.resolve(rootPath, fileRef)), ); const moduleUpload = uploadedAssetForRef(uploads, moduleRef); if (moduleUpload?.url) { nextEntry.moduleUrl = moduleUpload.url; } } else { const outputUpload = uploadedAssetForRef(uploads, fileRef); if (outputUpload) { Object.assign(nextEntry, rewriteAssetFields({}, outputUpload)); } } const relationUrdfRef = cleanPosixPath(nextEntry.relations?.urdf?.file); const relationUrdfUpload = uploadedAssetForRef(uploads, relationUrdfRef); if (relationUrdfUpload) { nextEntry.relations = { ...nextEntry.relations, urdf: rewriteAssetFields({ ...nextEntry.relations.urdf, file: relationUrdfRef, }, relationUrdfUpload), }; } const sourceRef = sourceFileRefsForEntry(entry, { includeSourceCode: false }) .map((ref) => resolveExistingFileInsideRoot({ repoRoot, rootPath, fileRef: ref })) .find(Boolean)?.fileRef || ""; const sourceUpload = uploadedAssetForRef(uploads, sourceRef); if (sourceUpload) { nextEntry.source = rewriteAssetFields({ ...(nextEntry.source && typeof nextEntry.source === "object" ? nextEntry.source : {}), file: sourceUpload.fileRef, sourcePath: sourceUpload.fileRef, }, sourceUpload); } return stripSourceCodeReferences(nextEntry); } export function rewriteCatalogForBlob(catalog, { uploads, repoRoot, rootPath, } = {}) { return normalizeVercelBlobCatalog({ schemaVersion: CAD_CATALOG_SCHEMA_VERSION, entries: (Array.isArray(catalog?.entries) ? catalog.entries : []).map((entry) => ( rewriteCatalogEntry(entry, { uploads, repoRoot, rootPath }) )), }); } // Skip-existing publishes leave unchanged assets as unfetched Git LFS // pointers, so artifact validation cannot read them and stamps false // missing_step_topology warnings into the catalog. For those entries, the // remote catalog entry with the same content hash carries the status computed // when the real bytes were last scanned, so reuse it. export function carryForwardRemoteArtifactStatuses(catalog, { existingCatalog, rootPath, } = {}) { const remoteEntries = Array.isArray(existingCatalog?.entries) ? existingCatalog.entries : []; const localEntries = Array.isArray(catalog?.entries) ? catalog.entries : []; if (!remoteEntries.length || !localEntries.length || !rootPath) { return catalog; } const remoteByFile = new Map(remoteEntries.map((entry) => [String(entry?.file || ""), entry])); let changed = false; const entries = localEntries.map((entry) => { const artifact = entry?.artifact; if (!artifact || typeof artifact !== "object" || Array.isArray(artifact) || artifact.ok !== false) { return entry; } const glbPath = cleanPosixPath(String(artifact.glbPath || "")); if (!glbPath || !readGitLfsPointer(path.join(rootPath, ...glbPath.split("/")))) { return entry; } const remoteEntry = remoteByFile.get(String(entry?.file || "")); const localHash = String(entry?.hash || ""); if (!remoteEntry || !localHash || String(remoteEntry.hash || "") !== localHash) { return entry; } changed = true; if (remoteEntry.artifact === undefined) { const { artifact: _droppedArtifact, ...cleanEntry } = entry; return cleanEntry; } return { ...entry, artifact: remoteEntry.artifact }; }); return changed ? { ...catalog, entries } : catalog; } export function catalogJsonBody(catalog) { return JSON.stringify({ schemaVersion: CAD_CATALOG_SCHEMA_VERSION, entries: Array.isArray(catalog?.entries) ? catalog.entries : [], }, null, 2); } export async function uploadCatalogJsonToBlob({ backend, catalog, catalogPath = "catalog.json", } = {}) { if (!backend || typeof backend.writeAsset !== "function") { throw new Error("Blob catalog upload requires a writable asset backend."); } return backend.writeAsset({ fileRef: catalogPath || "catalog.json", body: catalogJsonBody(catalog), contentType: "application/json; charset=utf-8", cacheControlMaxAge: CATALOG_BLOB_CACHE_CONTROL_MAX_AGE_SECONDS, }); } function resolveIgnorePatterns({ rootPath, ignoreFiles = [], excludePatterns = [], cwd = process.cwd() }) { const patterns = [...DEFAULT_UPLOAD_EXCLUDE_PATTERNS]; const defaultIgnoreFile = path.join(rootPath, DEFAULT_UPLOAD_IGNORE_FILE); if (fs.existsSync(defaultIgnoreFile)) { patterns.push(...readIgnoreFile(defaultIgnoreFile, cwd)); } for (const ignoreFile of ignoreFiles) { patterns.push(...readIgnoreFile(ignoreFile, cwd)); } patterns.push(...excludePatterns); return patterns; } export function parseUploadArgs(argv, env = process.env) { if (env.VIEWER_LOCAL_ROOT_DIR || env.VIEWER_LOCAL_WORKSPACE_ROOT) { throw new Error("VIEWER_LOCAL_ROOT_DIR and VIEWER_LOCAL_WORKSPACE_ROOT have been removed; pass the upload directory as the positional argument."); } const options = { directory: "", ignoreFiles: [], excludePatterns: [], concurrency: DEFAULT_UPLOAD_CONCURRENCY, skipExisting: false, fetchMissingLfs: false, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; const readValue = () => { index += 1; if (index >= argv.length) { throw new Error(`Missing value for ${arg}`); } return argv[index]; }; if (arg === "-h" || arg === "--help") { return { ...options, help: true }; } if (arg === "--root-dir" || arg.startsWith("--root-dir=")) { throw new Error("--root-dir has been removed; pass the upload directory as the positional argument."); } if (arg === "--ignore-file") { options.ignoreFiles.push(readValue()); continue; } if (arg === "--exclude") { options.excludePatterns.push(readValue()); continue; } if (arg === "--concurrency") { options.concurrency = Number(readValue()); continue; } if (arg === "--skip-existing") { options.skipExisting = true; continue; } if (arg === "--fetch-missing-lfs") { options.fetchMissingLfs = true; continue; } if (arg.startsWith("-")) { throw new Error(`Unknown option: ${arg}`); } if (options.directory) { throw new Error(`Unexpected extra directory: ${arg}`); } options.directory = arg; } return options; } function assertVercelUploadEnv(env = process.env) { const requestedBackend = envValue(env, "VIEWER_ASSET_BACKEND"); if ( requestedBackend && normalizeViewerAssetBackend(requestedBackend) !== VIEWER_ASSET_BACKENDS.VERCEL_BLOB ) { throw new Error("Blob upload requires VIEWER_ASSET_BACKEND=vercel-blob when VIEWER_ASSET_BACKEND is set."); } const config = vercelBlobConfigFromEnv(env); if (!config.prefix) { throw new Error("VIEWER_VERCEL_BLOB_PREFIX is required."); } if (!config.token) { throw new Error("VIEWER_VERCEL_BLOB_READ_WRITE_TOKEN or BLOB_READ_WRITE_TOKEN is required for Blob uploads."); } return config; } export async function uploadCatalogDirectoryToVercelBlob({ directory = "", rootDir = "", ignoreFiles = [], excludePatterns = [], concurrency = DEFAULT_UPLOAD_CONCURRENCY, skipExisting = false, fetchMissingLfs = false, env = process.env, cwd = process.cwd(), client = null, fetchImpl = globalThis.fetch, logger = console, } = {}) { const uploadRoot = resolveUploadRoot({ directory, rootDir, env, cwd }); const ignorePatterns = resolveIgnorePatterns({ rootPath: uploadRoot.rootPath, ignoreFiles, excludePatterns, cwd, }); const ignoreMatcher = createIgnoreMatcher(ignorePatterns); const includePath = includePathFromIgnoreMatcher(ignoreMatcher); let catalog = scanCadDirectory({ repoRoot: uploadRoot.repoRoot, rootDir: uploadRoot.rootDir, includePath, }); const uploadFiles = collectServedAssetFiles(uploadRoot.rootPath, { scanRootPath: uploadRoot.rootPath, includePath, }); addCatalogReferencedFiles(uploadFiles, { catalog, repoRoot: uploadRoot.repoRoot, rootPath: uploadRoot.rootPath, includePath, }); const config = assertVercelUploadEnv(env); const backend = createVercelBlobAssetBackend({ ...config, client, fetchImpl, readOnly: false, }); let existingUploads = new Map(); let existingCatalog = null; if (skipExisting) { try { existingCatalog = await backend.readCatalog(); existingUploads = existingCatalogUploads(existingCatalog, { rootPath: uploadRoot.rootPath, }); logger.log?.(`Loaded remote catalog with ${existingUploads.size} upload asset reference(s).`); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (!/404|not found/i.test(message)) { throw error; } logger.log?.(`No remote catalog found at ${backend.catalogPath}; uploading all catalog assets.`); } } const uploadResult = await uploadFilesToBlob({ backend, uploadFiles, concurrency, logger, existingUploads, skipExisting, fetchMissingLfs, cwd, rootPath: uploadRoot.rootPath, }); const uploads = uploadResult.uploads; if (skipExisting) { for (const [fileRef, existingUpload] of existingUploads) { if (!uploads.has(fileRef)) { uploads.set(fileRef, existingUpload); } } } if (uploadResult.fetchedLfsFiles > 0) { catalog = scanCadDirectory({ repoRoot: uploadRoot.repoRoot, rootDir: uploadRoot.rootDir, includePath, }); } const blobCatalog = carryForwardRemoteArtifactStatuses( rewriteCatalogForBlob(catalog, { uploads, repoRoot: uploadRoot.repoRoot, rootPath: uploadRoot.rootPath, }), { existingCatalog, rootPath: uploadRoot.rootPath, }, ); const catalogFileRef = config.catalogPath || "catalog.json"; logger.log?.(`Uploading ${catalogFileRef}`); const catalogUpload = await uploadCatalogJsonToBlob({ backend, catalog: blobCatalog, catalogPath: catalogFileRef, }); return { ...uploadRoot, prefix: backend.prefix, catalogPath: backend.catalogPath, catalogUrl: catalogUpload?.url || "", ignoredPatterns: ignorePatterns, uploadedFiles: uploadResult.uploadedCount, skippedFiles: uploadResult.skippedCount, fetchedLfsFiles: uploadResult.fetchedLfsFiles, catalogEntries: blobCatalog.entries.length, catalog: blobCatalog, }; } async function main(argv = process.argv.slice(2)) { const options = parseUploadArgs(argv); if (options.help) { console.log(usage()); return; } const result = await uploadCatalogDirectoryToVercelBlob(options); console.log(JSON.stringify({ prefix: result.prefix, catalogPath: result.catalogPath, catalogUrl: result.catalogUrl, uploadedFiles: result.uploadedFiles, skippedFiles: result.skippedFiles, fetchedLfsFiles: result.fetchedLfsFiles, catalogEntries: result.catalogEntries, ignoredPatterns: result.ignoredPatterns, }, null, 2)); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); }); }