#!/usr/bin/env node import { spawnSync } from "node:child_process"; import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs"; import { cp } from "node:fs/promises"; import path from "node:path"; import { gzipSync } from "node:zlib"; import { cacheBustZooModelingAppAssets } from "./cache-bust-zoo-modeling-app-assets.mjs"; const repoUrl = "https://github.com/KittyCAD/modeling-app.git"; const repoRef = process.env.ZOO_MODELING_APP_REF || "6f87f4e66c07c67f09d8f7db284496a3ae3332e7"; const repoTarballUrls = [ `https://ghfast.top/https://github.com/KittyCAD/modeling-app/archive/${repoRef}.tar.gz`, `https://github.com/KittyCAD/modeling-app/archive/${repoRef}.tar.gz`, ]; const root = process.cwd(); const workspaceDir = path.join(root, ".zoo-modeling-app"); const sourceDir = path.join(workspaceDir, "modeling-app"); const sourceRefFile = path.join(sourceDir, ".hicad-source-ref"); const outputDir = path.join(root, "public/vendor/zoo-modeling-app"); const customizationDir = path.join(root, "vendor/zoo-modeling-app"); const proxyToken = "hicad-session"; function run(command, args, options = {}) { const result = spawnSync(command, args, { cwd: options.cwd || root, env: { ...process.env, ...(options.env || {}) }, shell: false, stdio: "inherit", }); if (result.status !== 0) { throw new Error(`${command} ${args.join(" ")} failed`); } } function tryRun(command, args, options = {}) { const result = spawnSync(command, args, { cwd: options.cwd || root, env: { ...process.env, ...(options.env || {}) }, shell: false, stdio: "inherit", }); return result.status === 0; } function downloadOfficialSource() { mkdirSync(workspaceDir, { recursive: true }); const archivePath = path.join(workspaceDir, "modeling-app-main.tar.gz"); const extractedDir = path.join(workspaceDir, `modeling-app-${repoRef}`); rmSync(archivePath, { force: true }); rmSync(extractedDir, { force: true, recursive: true }); const downloaded = repoTarballUrls.some((url) => tryRun("curl", [ "--http1.1", "--location", "--fail", "--retry", "3", url, "--output", archivePath, ]) ); if (!downloaded) { throw new Error("Unable to download KittyCAD/modeling-app"); } run("tar", ["-xzf", archivePath, "-C", workspaceDir]); rmSync(sourceDir, { force: true, recursive: true }); run("mv", [extractedDir, sourceDir]); writeFileSync(sourceRefFile, `${repoRef}\n`); } function ensureOfficialSource() { if (existsSync(path.join(sourceDir, "package.json"))) { if (!existsSync(sourceRefFile)) { writeFileSync(sourceRefFile, `${repoRef}\n`); } const currentRef = readFileSync(sourceRefFile, "utf8").trim(); if (currentRef !== repoRef) { downloadOfficialSource(); } return; } downloadOfficialSource(); } function replaceSource(relativePath, before, after) { const filePath = path.join(sourceDir, relativePath); const source = readFileSync(filePath, "utf8"); if (after && source.includes(after)) { return; } if (!source.includes(before)) { if (!after) { return; } throw new Error(`Official source changed; patch failed for ${relativePath}`); } writeFileSync(filePath, source.replace(before, after)); } function patchOfficialSource() { copyFileSync( path.join(customizationDir, "Logo.tsx"), path.join(sourceDir, "src/components/Logo.tsx") ); copyFileSync( path.join(customizationDir, "localization.ts"), path.join(sourceDir, "src/lib/hicadLocalization.ts") ); copyFileSync( path.join(customizationDir, "FitAllButton.tsx.template"), path.join(sourceDir, "src/components/FitAllButton.tsx") ); replaceSource( "index.html", ' ', ' ' ); replaceSource( "index.html", ' \n', "" ); replaceSource( "index.html", " Zoo Design Studio", " HiCAD" ); replaceSource( "src/Router.tsx", " createBrowserRouter,\n createHashRouter,", " createHashRouter," ); replaceSource( "src/Router.tsx", "const createRouter = isDesktop() ? createHashRouter : createBrowserRouter", "const createRouter = createHashRouter" ); replaceSource( "src/Router.tsx", "import { isDesktop } from '@src/lib/isDesktop'\n", "" ); replaceSource( "src/lib/socket.ts", ` ws.addEventListener('open', () => {\n ws.send(\n JSON.stringify({\n type: 'headers',\n headers: {\n Authorization: 'Bearer ' + token,\n },\n })\n )\n resolve(ws)\n })`, ` ws.addEventListener('open', () => {\n if (token !== '${proxyToken}') {\n ws.send(\n JSON.stringify({\n type: 'headers',\n headers: {\n Authorization: 'Bearer ' + token,\n },\n })\n )\n }\n resolve(ws)\n })` ); replaceSource( "src/network/websocketConnection.ts", " if (token) {", ` if (token && token !== '${proxyToken}') {` ); replaceSource( "src/lib/withBaseURL.ts", `export function withKittycadWebSocketURL(qs: string): string {\n return mergeQueryString(env().VITE_KITTYCAD_WEBSOCKET_URL, qs)\n}\n\nexport function withMlephantWebSocketURL(qs: string): string {\n return mergeQueryString(env().VITE_MLEPHANT_WEBSOCKET_URL, qs)\n}`, `export function withKittycadWebSocketURL(qs: string): string {\n return mergeWebSocketQueryString(env().VITE_KITTYCAD_WEBSOCKET_URL, qs)\n}\n\nexport function withMlephantWebSocketURL(qs: string): string {\n return mergeWebSocketQueryString(env().VITE_MLEPHANT_WEBSOCKET_URL, qs)\n}\n\nfunction mergeWebSocketQueryString(\n url: string | undefined,\n qs: string\n): string {\n if (!url) return ''\n const absoluteUrl =\n typeof window !== 'undefined' && url.startsWith('/')\n ? \`\${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//\${window.location.host}\${url}\`\n : url\n return mergeQueryString(absoluteUrl, qs)\n}` ); replaceSource( "src/lang/wasmUtils.ts", `export const wasmUrl = () => {\n const wasmFile = '/kcl_wasm_lib_bg.wasm'\n // Check for test environment override\n if (typeof process !== 'undefined' && process.env?.VITE_WASM_BASE_URL) {\n return process.env.VITE_WASM_BASE_URL + wasmFile\n }\n\n // For when we're in electron (file based) or web server (network based)\n // For some reason relative paths don't work as expected. Otherwise we would\n // just do /wasm_lib_bg.wasm. In particular, the issue arises when the path\n // is used from within worker.ts.\n const fullUrl = document.location.protocol.includes('http')\n ? document.location.origin + '/kcl_wasm_lib_bg.wasm'\n : document.location.protocol +\n webSafeJoin(webSafePathSplit(document.location.pathname).slice(0, -1)) +\n wasmFile\n\n return fullUrl\n}`, `export const wasmUrl = () => {\n const wasmFile = 'kcl_wasm_lib_bg.wasm'\n if (typeof process !== 'undefined' && process.env?.VITE_WASM_BASE_URL) {\n return process.env.VITE_WASM_BASE_URL + '/' + wasmFile\n }\n\n return new URL(wasmFile, document.baseURI).toString()\n}` ); replaceSource( "src/lang/wasmUtils.ts", "import { webSafeJoin, webSafePathSplit } from '@src/lib/paths'\n", "" ); replaceSource( "src/components/UserSidebarMenu.tsx", " const displayedName = getDisplayName(user)\n", ` const isHiCadEmbedded = env().VITE_ZOO_API_TOKEN === '${proxyToken}'\n const displayedName = isHiCadEmbedded ? 'HiCAD' : getDisplayName(user)\n` ); replaceSource( "src/components/UserSidebarMenu.tsx", ` [\n {\n id: 'account',`, ` [\n {\n id: 'hicad-home',\n Element: 'button',\n children: 'Back to HiCAD',\n onClick: () => window.top?.location.assign('/'),\n className: isHiCadEmbedded ? '' : 'hidden',\n },\n {\n id: 'account',` ); replaceSource( "src/components/UserSidebarMenu.tsx", ` ),\n },\n {\n id: 'settings',`, ` ),\n className: isHiCadEmbedded ? 'hidden' : '',\n },\n {\n id: 'settings',` ); replaceSource( "src/components/UserSidebarMenu.tsx", " className: '',", " className: isHiCadEmbedded ? 'hidden' : ''," ); replaceSource( "src/components/UserSidebarMenu.tsx", " {user?.image && !imageLoadFailed ? (", " {!isHiCadEmbedded && user?.image && !imageLoadFailed ? (" ); replaceSource( "src/components/UserSidebarMenu.tsx", " {displayedName !== user.email && (", " {!isHiCadEmbedded && displayedName !== user.email && (" ); replaceSource( "src/components/environment/Environment.tsx", " new URL(env().VITE_KITTYCAD_WEBSOCKET_URL ?? ''),", " new URL(env().VITE_KITTYCAD_WEBSOCKET_URL ?? '', window.location.href)," ); replaceSource( "src/components/environment/Environment.tsx", " new URL(env().VITE_MLEPHANT_WEBSOCKET_URL ?? '')", " new URL(env().VITE_MLEPHANT_WEBSOCKET_URL ?? '', window.location.href)" ); replaceSource( "src/components/ConnectionStream.tsx", ` className="cursor-pointer"\n id="freeze-frame"`, ` className="absolute inset-0 h-full w-full cursor-pointer"\n id="freeze-frame"` ); replaceSource( "src/index.tsx", "import { markOnce } from '@src/lib/performance'\n", "import { installHiCadLocalization } from '@src/lib/hicadLocalization'\nimport { markOnce } from '@src/lib/performance'\n" ); replaceSource( "src/index.tsx", "function launchApp(app: App) {\n initSingletonBehavior(app)", "function launchApp(app: App) {\n installHiCadLocalization()\n initSingletonBehavior(app)" ); replaceSource( "src/registry/extensions/engineScene/viewExtensionControls.tsx", "import { CustomIcon } from '@src/components/CustomIcon'\n", "import { CustomIcon } from '@src/components/CustomIcon'\nimport { FitAllButton } from '@src/components/FitAllButton'\n" ); replaceSource( "src/registry/extensions/engineScene/viewExtensionControls.tsx", ` \n \n `, ` \n
\n \n \n
\n
` ); replaceSource( "src/lib/resetCameraPosition.ts", ` // We need a padding of 0.1 for zoom_to_fit for all E2E tests since they were originally\n // written with zoom_to_fit with padding 0.1\n const padding = 0.1\n if (isPlaywright()) {`, ` // Keep the official E2E framing stable while giving HiCAD users enough\n // breathing room to see the complete model after generation and file loads.\n const padding = isPlaywright() ? 0.1 : 0.35\n if (isPlaywright()) {` ); replaceSource( "src/lib/commandBarConfigs/standardViewsConfig.ts", "engineStreamZoomToFit({ engineCommandManager, padding: 0.1 })", "engineStreamZoomToFit({ engineCommandManager, padding: 0.35 })" ); replaceSource( "src/lib/app.ts", " padding: 0.2,\n animated: false,", " padding: 0.35,\n animated: false," ); replaceSource( "src/clientSideScene/ClientSideSceneComp.tsx", "import { throttle, toSync } from '@src/lib/utils'", `import {\n engineStreamZoomToFit,\n throttle,\n toSync,\n} from '@src/lib/utils'` ); replaceSource( "src/clientSideScene/ClientSideSceneComp.tsx", ` const {\n kclManager: { sceneEntitiesManager, sceneInfra, engineCommandManager },\n } = useSingletons()`, ` const { kclManager } = useSingletons()\n const { sceneEntitiesManager, sceneInfra, engineCommandManager } = kclManager` ); replaceSource( "src/clientSideScene/ClientSideSceneComp.tsx", ` // Detect canvas size changes\n const observer = new ResizeObserver(() => {\n // This is called initially too, not just on resize\n sceneInfra.onCanvasResized()\n sceneInfra.camControls.onWindowResize()\n sceneEntitiesManager.onCamChange().catch(reportRejection)\n })\n observer.observe(container)`, ` // Preserve the full-model framing when panels or the browser resize.\n let fitAfterResizeTimer: ReturnType | undefined\n const observer = new ResizeObserver(() => {\n // This is called initially too, not just on resize\n sceneInfra.onCanvasResized()\n sceneInfra.camControls.onWindowResize()\n sceneEntitiesManager.onCamChange().catch(reportRejection)\n\n clearTimeout(fitAfterResizeTimer)\n fitAfterResizeTimer = setTimeout(() => {\n if (kclManager.artifactGraph.size === 0) return\n engineStreamZoomToFit({\n engineCommandManager,\n padding: 0.35,\n }).catch(reportRejection)\n }, 180)\n })\n observer.observe(container)` ); replaceSource( "src/clientSideScene/ClientSideSceneComp.tsx", ` observer.disconnect()\n media.removeEventListener('change', handleChange)`, ` observer.disconnect()\n clearTimeout(fitAfterResizeTimer)\n media.removeEventListener('change', handleChange)` ); replaceSource( "src/routes/Onboarding/utils.tsx", `

To view your account, manage payment methods, or change tiers, simply{' '}`, `

To view your account, manage payment methods, or change tiers, simply{' '}` ); } function ensureDependencies() { if (existsSync(path.join(sourceDir, "node_modules/.package-lock.json"))) { return; } run("npm", ["ci", "--ignore-scripts", "--no-audit", "--no-fund"], { cwd: sourceDir, }); } function ensureWasm() { const wasmJs = path.join( sourceDir, "rust/kcl-wasm-lib/pkg/kcl_wasm_lib.js" ); const wasmBinary = path.join( sourceDir, "rust/kcl-wasm-lib/pkg/kcl_wasm_lib_bg.wasm" ); if (!(existsSync(wasmJs) && existsSync(wasmBinary))) { const cargoEnv = { CARGO_HTTP_MULTIPLEXING: "false", CARGO_REGISTRIES_CRATES_IO_INDEX: process.env.CARGO_REGISTRIES_CRATES_IO_INDEX || "sparse+https://rsproxy.cn/index/", CARGO_REGISTRIES_CRATES_IO_PROTOCOL: "sparse", }; run( "wasm-pack", [ "build", "kcl-wasm-lib", "--release", "--target", "web", "--out-dir", "pkg", "--scope", "kittycad", "--no-opt", ], { cwd: path.join(sourceDir, "rust"), env: cargoEnv } ); run( "cargo", [ "test", "-p", "kcl-lib", "--features", "artifact-graph", "export_bindings", ], { cwd: path.join(sourceDir, "rust"), env: cargoEnv } ); } copyFileSync( path.join(sourceDir, "rust/kcl-wasm-lib/README.md"), path.join(sourceDir, "rust/kcl-wasm-lib/pkg/README.md") ); copyFileSync(wasmBinary, path.join(sourceDir, "public/kcl_wasm_lib_bg.wasm")); } function precompressVersionedAssets(assetsDir) { for (const entry of readdirSync(assetsDir, { withFileTypes: true })) { if (!entry.isFile() || !/\.(?:css|js)$/.test(entry.name)) { continue; } const assetPath = path.join(assetsDir, entry.name); writeFileSync(`${assetPath}.gz`, gzipSync(readFileSync(assetPath), { level: 9 })); } } async function main() { ensureOfficialSource(); ensureDependencies(); ensureWasm(); patchOfficialSource(); const buildEnv = { VITE_KITTYCAD_WEBSOCKET_URL: process.env.HICAD_ZOO_MODELING_WEBSOCKET_URL || "/api/ai-cad/zoo-ws/modeling/commands", VITE_MLEPHANT_WEBSOCKET_URL: process.env.HICAD_ZOO_AGENT_WEBSOCKET_URL || "/api/ai-cad/zoo-ws/ml/copilot", VITE_ZOO_API_BASE_URL: "/api/ai-cad/zoo", VITE_ZOO_API_TOKEN: proxyToken, VITE_ZOO_BASE_DOMAIN: "zoo.dev", }; rmSync(path.join(sourceDir, "build"), { force: true, recursive: true }); run("npx", ["vite", "build", "--base=./", "--mode=production"], { cwd: sourceDir, env: buildEnv, }); const buildDir = path.join(sourceDir, "build"); if (!existsSync(path.join(buildDir, "index.html"))) { throw new Error("Zoo Design Studio build did not produce index.html"); } precompressVersionedAssets(path.join(buildDir, "assets")); cacheBustZooModelingAppAssets(buildDir); rmSync(outputDir, { force: true, recursive: true }); mkdirSync(path.dirname(outputDir), { recursive: true }); await cp(buildDir, outputDir, { recursive: true }); const license = path.join(sourceDir, "LICENSE"); if (existsSync(license)) { await cp(license, path.join(outputDir, "LICENSE")); } writeFileSync( path.join(outputDir, "SOURCE.txt"), [ "HiCAD web frontend based on Zoo Design Studio", `Source: ${repoUrl}`, `Source commit: ${repoRef}`, "License: MIT", "Authentication: HiCAD session with server-side Zoo proxy", "", ].join("\n") ); console.log(`HiCAD official frontend copied to ${outputDir}`); } main().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exit(1); });