first commit

This commit is contained in:
2026-07-22 13:48:46 +08:00
commit c87751c3dc
2820 changed files with 726976 additions and 0 deletions
@@ -0,0 +1,217 @@
#!/usr/bin/env bash
set -euo pipefail
function usage() {
cat <<'EOF'
Usage:
VIEWER_VERCEL_BLOB_PREFIX=<public-url-prefix> scripts/viewer/download-viewer-models-catalog.sh <target-dir> [--force]
Downloads catalog.json and all catalog-referenced Blob assets from VIEWER_VERCEL_BLOB_PREFIX.
Environment:
VIEWER_VERCEL_BLOB_PREFIX Required. Public Blob URL prefix, for example: https://<store>.public.blob.vercel-storage.com/models
VIEWER_ASSET_BACKEND Optional. Defaults to vercel-blob.
Options:
--force Replace <target-dir> if it already exists.
-h, --help Show this help.
EOF
}
case "${1:-}" in
-h|--help)
usage
exit 0
;;
esac
: "${VIEWER_VERCEL_BLOB_PREFIX:?Set VIEWER_VERCEL_BLOB_PREFIX to the public Vercel Blob URL prefix before downloading.}"
export VIEWER_ASSET_BACKEND="${VIEWER_ASSET_BACKEND:-vercel-blob}"
node --input-type=module - "$@" <<'NODE'
import { createWriteStream } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
function usage() {
return `Usage:
scripts/viewer/download-viewer-models-catalog.sh <target-dir> [--force]
Downloads catalog.json and all catalog-referenced Blob assets from VIEWER_VERCEL_BLOB_PREFIX.
Options:
--force Replace <target-dir> if it already exists.
-h, --help Show this help.`;
}
function parseArgs(argv) {
let targetDir = "";
let force = false;
for (const arg of argv) {
if (arg === "-h" || arg === "--help") {
return { help: true };
}
if (arg === "--force") {
force = true;
continue;
}
if (!targetDir) {
targetDir = arg;
continue;
}
throw new Error(`Unexpected argument: ${arg}`);
}
if (!targetDir) {
throw new Error("Missing target directory.");
}
return { targetDir, force };
}
function catalogUrlFromPrefix(prefix) {
const url = new URL(prefix);
const prefixPath = url.pathname.replace(/^\/+|\/+$/g, "");
url.pathname = `/${[prefixPath, "catalog.json"].filter(Boolean).join("/")}`;
url.search = "";
url.hash = "";
return url;
}
function fileRefForBlobUrl(rawUrl, prefixUrl) {
const assetUrl = new URL(rawUrl, prefixUrl);
if (assetUrl.origin !== prefixUrl.origin) {
return "";
}
const prefixPath = prefixUrl.pathname.replace(/\/+$/g, "");
const assetPath = assetUrl.pathname;
let relativePath = "";
if (!prefixPath || prefixPath === "/") {
relativePath = assetPath.replace(/^\/+/, "");
} else if (assetPath.startsWith(`${prefixPath}/`)) {
relativePath = assetPath.slice(prefixPath.length + 1);
}
const decoded = decodeURIComponent(relativePath);
const normalized = path.posix.normalize(decoded.replace(/\\/g, "/").replace(/^\/+/, ""));
if (!normalized || normalized === "." || normalized.startsWith("../")) {
return "";
}
return normalized;
}
function collectBlobDownloads(catalog, prefixUrl) {
const downloads = new Map();
function visit(value) {
if (Array.isArray(value)) {
for (const item of value) {
visit(item);
}
return;
}
if (!value || typeof value !== "object") {
return;
}
for (const [key, child] of Object.entries(value)) {
if (typeof child === "string" && (key === "url" || key.endsWith("Url"))) {
const fileRef = fileRefForBlobUrl(child, prefixUrl);
if (fileRef && fileRef !== "catalog.json") {
downloads.set(fileRef, new URL(child, prefixUrl).toString());
}
continue;
}
visit(child);
}
}
visit(catalog);
return [...downloads.entries()]
.map(([fileRef, url]) => ({ fileRef, url }))
.sort((a, b) => a.fileRef.localeCompare(b.fileRef));
}
function destinationPath(targetDir, fileRef) {
const normalized = path.posix.normalize(String(fileRef || "").replace(/\\/g, "/").replace(/^\/+/, ""));
if (!normalized || normalized === "." || normalized.startsWith("../")) {
throw new Error(`Unsafe catalog path: ${fileRef}`);
}
const resolved = path.resolve(targetDir, ...normalized.split("/"));
if (!resolved.startsWith(`${targetDir}${path.sep}`)) {
throw new Error(`Catalog path escapes target directory: ${fileRef}`);
}
return resolved;
}
async function fetchChecked(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
}
return response;
}
async function downloadFile(url, filePath) {
const response = await fetchChecked(url);
await fs.mkdir(path.dirname(filePath), { recursive: true });
const tmpPath = `${filePath}.download`;
await pipeline(Readable.fromWeb(response.body), createWriteStream(tmpPath));
await fs.rename(tmpPath, filePath);
}
function prefixUrlFromEnv() {
try {
return new URL(process.env.VIEWER_VERCEL_BLOB_PREFIX);
} catch {
throw new Error("VIEWER_VERCEL_BLOB_PREFIX must be a full public Vercel Blob URL for downloads.");
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
console.log(usage());
return;
}
const prefixUrl = prefixUrlFromEnv();
const catalogUrl = catalogUrlFromPrefix(prefixUrl);
const targetDir = path.resolve(options.targetDir);
if (targetDir === path.parse(targetDir).root) {
throw new Error("Refusing to use filesystem root as target directory.");
}
try {
await fs.lstat(targetDir);
if (!options.force) {
throw new Error(`Target directory already exists: ${targetDir}. Pass --force to replace it.`);
}
await fs.rm(targetDir, { recursive: true, force: true });
} catch (error) {
if (error?.code !== "ENOENT") {
throw error;
}
}
await fs.mkdir(targetDir, { recursive: true });
console.log(`Downloading ${catalogUrl}`);
const catalogResponse = await fetchChecked(catalogUrl);
const catalog = await catalogResponse.json();
const downloads = collectBlobDownloads(catalog, prefixUrl);
await fs.writeFile(
path.join(targetDir, "catalog.json"),
`${JSON.stringify(catalog, null, 2)}\n`
);
for (const download of downloads) {
console.log(`Downloading ${download.fileRef}`);
await downloadFile(download.url, destinationPath(targetDir, download.fileRef));
}
console.log(`Downloaded catalog and ${downloads.length} assets to ${targetDir}`);
}
main().catch((error) => {
console.error(error?.message || String(error));
process.exit(1);
});
NODE
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SOURCE_REPO_ROOT="$(git -C "$SCRIPT_DIR/../.." rev-parse --show-toplevel)"
DEFAULT_TARGET="../cad-viewer"
TARGET_ARG="$DEFAULT_TARGET"
TARGET_SET=0
DRY_RUN=0
usage() {
cat <<'EOF'
Usage:
scripts/viewer/sync-cad-viewer-repo.sh [--dry-run] [target-relative-dir]
Copies the CAD Viewer source repo layout into a separate standalone cad-viewer
git checkout. The target path is resolved relative to this text-to-cad repo.
Default target:
../cad-viewer
What gets copied:
viewer/* -> target root
packages/cadjs/* -> target/packages/cadjs
packages/cadpy/* -> target/packages/cadpy
The target checkout must already exist and must be the root of a separate git
repo. Everything in the target root is deleted before copying, except .git.
Options:
--dry-run Print what would be overwritten without changing files.
-h, --help Show this help.
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--dry-run)
DRY_RUN=1
;;
-h|--help)
usage
exit 0
;;
--)
shift
if [ "$#" -gt 0 ]; then
if [ "$TARGET_SET" -eq 1 ]; then
echo "Target path was provided more than once." >&2
usage >&2
exit 2
fi
TARGET_ARG="$1"
TARGET_SET=1
shift
fi
break
;;
-*)
echo "Unknown option: $1" >&2
usage >&2
exit 2
;;
*)
if [ "$TARGET_SET" -eq 1 ]; then
echo "Target path was provided more than once." >&2
usage >&2
exit 2
fi
TARGET_ARG="$1"
TARGET_SET=1
;;
esac
shift
done
if [ "$#" -gt 0 ]; then
echo "Unexpected extra arguments: $*" >&2
usage >&2
exit 2
fi
case "$TARGET_ARG" in
/*)
echo "Target must be a relative path; got: $TARGET_ARG" >&2
exit 2
;;
"")
echo "Target path must not be empty." >&2
exit 2
;;
esac
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "$1 is required." >&2
exit 1
fi
}
require_path() {
local path_to_check="$1"
local label="$2"
if [ ! -e "$path_to_check" ]; then
echo "Missing $label: $path_to_check" >&2
exit 1
fi
}
require_command git
require_command node
require_command rsync
VIEWER_DIR="$SOURCE_REPO_ROOT/viewer"
CADJS_DIR="$SOURCE_REPO_ROOT/packages/cadjs"
CADPY_DIR="$SOURCE_REPO_ROOT/packages/cadpy"
TARGET_INPUT="$SOURCE_REPO_ROOT/$TARGET_ARG"
require_path "$VIEWER_DIR/package.json" "viewer package"
require_path "$CADJS_DIR/package.json" "cadjs package"
require_path "$CADPY_DIR/pyproject.toml" "cadpy package"
if [ ! -d "$TARGET_INPUT" ]; then
echo "Target directory does not exist: $TARGET_INPUT" >&2
echo "Create or clone the standalone cad-viewer repo first." >&2
exit 1
fi
TARGET_DIR="$(cd "$TARGET_INPUT" && pwd -P)"
SOURCE_REPO_ROOT="$(cd "$SOURCE_REPO_ROOT" && pwd -P)"
TARGET_GIT_ROOT="$(git -C "$TARGET_DIR" rev-parse --show-toplevel 2>/dev/null || true)"
if [ -z "$TARGET_GIT_ROOT" ]; then
echo "Target is not inside a git repository: $TARGET_DIR" >&2
exit 1
fi
TARGET_GIT_ROOT="$(cd "$TARGET_GIT_ROOT" && pwd -P)"
if [ "$TARGET_GIT_ROOT" != "$TARGET_DIR" ]; then
echo "Target must be the root of its own git repo." >&2
echo "Target: $TARGET_DIR" >&2
echo "Git root: $TARGET_GIT_ROOT" >&2
exit 1
fi
if [ "$TARGET_DIR" = "$SOURCE_REPO_ROOT" ]; then
echo "Refusing to overwrite the source repo." >&2
exit 1
fi
if [ "$TARGET_DIR" = "$VIEWER_DIR" ] || [ "$TARGET_DIR" = "$CADJS_DIR" ] || [ "$TARGET_DIR" = "$CADPY_DIR" ]; then
echo "Refusing to overwrite a source directory: $TARGET_DIR" >&2
exit 1
fi
RSYNC_EXCLUDES=(
--exclude node_modules
--exclude dist
--exclude dist-verify
--exclude .vite
--exclude .next
--exclude .next-build
--exclude .next-dev
--exclude .next-verify
--exclude .vercel
--exclude coverage
--exclude tmp
--exclude .pytest_cache
--exclude __pycache__
--exclude '*.pyc'
--exclude '*.pyo'
--exclude .DS_Store
--include '.env.example'
--include '.env.*.example'
--exclude '.env'
--exclude '.env.*'
)
echo "Source: $SOURCE_REPO_ROOT"
echo "Target: $TARGET_DIR"
echo ""
echo "This will delete everything in the target root except .git, then copy viewer/, packages/cadjs, and packages/cadpy."
if [ "$DRY_RUN" -eq 1 ]; then
echo ""
echo "Dry run only. Target entries that would be removed:"
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name .git -print | sort
echo ""
echo "Dry run only. Copy plan:"
echo " $VIEWER_DIR/ -> $TARGET_DIR/"
echo " $CADJS_DIR/ -> $TARGET_DIR/packages/cadjs/"
echo " $CADPY_DIR/ -> $TARGET_DIR/packages/cadpy/"
exit 0
fi
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +
rsync -a "${RSYNC_EXCLUDES[@]}" "$VIEWER_DIR/" "$TARGET_DIR/"
mkdir -p "$TARGET_DIR/packages"
rsync -a "${RSYNC_EXCLUDES[@]}" "$CADJS_DIR/" "$TARGET_DIR/packages/cadjs/"
rsync -a "${RSYNC_EXCLUDES[@]}" "$CADPY_DIR/" "$TARGET_DIR/packages/cadpy/"
node --input-type=module - "$TARGET_DIR" <<'NODE'
import fs from "node:fs";
import path from "node:path";
const targetRoot = process.argv[2];
function readJson(relativePath) {
return JSON.parse(fs.readFileSync(path.join(targetRoot, relativePath), "utf8"));
}
function writeJson(relativePath, value) {
fs.writeFileSync(path.join(targetRoot, relativePath), `${JSON.stringify(value, null, 2)}\n`);
}
function replaceText(relativePath, replacements) {
const filePath = path.join(targetRoot, relativePath);
if (!fs.existsSync(filePath)) {
return;
}
let text = fs.readFileSync(filePath, "utf8");
for (const [from, to] of replacements) {
text = text.split(from).join(to);
}
fs.writeFileSync(filePath, text);
}
const packageJson = readJson("package.json");
packageJson.dependencies = {
...packageJson.dependencies,
cadjs: "file:./packages/cadjs",
};
if (packageJson.scripts) {
delete packageJson.scripts["runtime:bundle"];
delete packageJson.scripts["runtime:check"];
}
writeJson("package.json", packageJson);
const lockPath = path.join(targetRoot, "package-lock.json");
if (fs.existsSync(lockPath)) {
const lock = readJson("package-lock.json");
const rootPackage = lock.packages?.[""];
if (rootPackage?.dependencies) {
rootPackage.dependencies.cadjs = "file:./packages/cadjs";
}
if (lock.packages?.["../packages/cadjs"]) {
lock.packages["packages/cadjs"] = lock.packages["../packages/cadjs"];
delete lock.packages["../packages/cadjs"];
}
if (lock.packages?.["node_modules/cadjs"]) {
lock.packages["node_modules/cadjs"].resolved = "packages/cadjs";
}
writeJson("package-lock.json", lock);
}
const pathReplacements = [
["file:../packages/cadjs", "file:./packages/cadjs"],
["../packages/cadjs", "packages/cadjs"],
["../packages/cadpy", "packages/cadpy"],
["../.venv/bin/python -m pip install -e packages/cadpy", "python -m pip install -e packages/cadpy"],
["npm --prefix viewer run upload:blob", "npm run upload:blob"],
["npm run build\nnpm run runtime:check", "npm run build"],
[
"\nWhen changing Viewer source that feeds the cad-viewer skill runtime, refresh the\ngenerated runtime from the repository root:\n\n```bash\nscripts/bundle/bundle-skill.sh cad-viewer\nscripts/bundle/bundle-skill.sh cad-viewer --check\n```\n",
"\n",
],
[
"The generated cad-viewer skill runtime\nbundles `cadpy` under `packages/cadpy` and does not need the repository\nroot.",
"The standalone checkout keeps `cadpy` under `packages/cadpy` so local\nregeneration can discover it without the source workbench.",
],
];
replaceText("README.md", pathReplacements);
replaceText("docs/backend.md", pathReplacements);
const gitignorePath = path.join(targetRoot, ".gitignore");
if (fs.existsSync(gitignorePath)) {
const gitignore = fs.readFileSync(gitignorePath, "utf8");
if (!/^\.venv$/mu.test(gitignore)) {
fs.writeFileSync(gitignorePath, `${gitignore.replace(/\s*$/u, "\n")}.venv\n`);
}
}
NODE
echo ""
echo "Synced standalone CAD Viewer checkout:"
echo " $TARGET_DIR"
echo ""
echo "Next steps:"
echo " cd \"$TARGET_DIR\""
echo " npm install"
echo " npm run test"
echo " npm run build"
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
DEFAULT_VIEWER_VERCEL_BLOB_PREFIX="models"
function usage() {
cat <<'EOF'
Usage:
BLOB_READ_WRITE_TOKEN=<token> scripts/viewer/upload-viewer-models-catalog.sh [directory] [upload options]
Uploads the models catalog and CAD Viewer-supported assets to Vercel Blob.
The uploader excludes mechbench/, mechbench2/, 7dof_arm/, and Python source
files by default.
Environment:
BLOB_READ_WRITE_TOKEN Vercel Blob read/write token.
VIEWER_VERCEL_BLOB_READ_WRITE_TOKEN Alternate Vercel Blob read/write token.
VIEWER_ASSET_BACKEND Optional. Defaults to vercel-blob.
Blob prefix:
models Script-defined upload prefix for this branch.
Options are passed through to npm --prefix viewer run upload:blob.
If no directory is passed, models/ is uploaded.
Use --skip-existing to reuse matching remote catalog assets, and
--fetch-missing-lfs to download only the Git LFS objects needed for new or
changed uploads.
EOF
}
case "${1:-}" in
-h|--help)
usage
exit 0
;;
esac
if [ -z "${BLOB_READ_WRITE_TOKEN:-}" ] && [ -z "${VIEWER_VERCEL_BLOB_READ_WRITE_TOKEN:-}" ]; then
echo "Set BLOB_READ_WRITE_TOKEN or VIEWER_VERCEL_BLOB_READ_WRITE_TOKEN before uploading to Vercel Blob." >&2
exit 1
fi
export VIEWER_ASSET_BACKEND="${VIEWER_ASSET_BACKEND:-vercel-blob}"
export VIEWER_VERCEL_BLOB_PREFIX="$DEFAULT_VIEWER_VERCEL_BLOB_PREFIX"
UPLOAD_DIR="$REPO_ROOT/models"
if [ "$#" -gt 0 ] && [[ "${1:-}" != --* ]]; then
UPLOAD_DIR="$1"
shift
fi
echo "Uploading viewer models catalog to Blob prefix: $VIEWER_VERCEL_BLOB_PREFIX"
npm --prefix "$REPO_ROOT/viewer" run upload:blob -- \
"$UPLOAD_DIR" \
"$@"