// Cross-platform replacement for the previous `mkdir -p … && cp -r …` shell // chain, which failed on Windows because `cp` doesn't accept `-r` there. import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, } from "node:fs"; import { createRequire } from "node:module"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { setTimeout as sleep } from "node:timers/promises"; const HERE = dirname(fileURLToPath(import.meta.url)); const CLI_ROOT = resolve(HERE, ".."); const REPO_ROOT = resolve(CLI_ROOT, "..", ".."); const DIST = join(CLI_ROOT, "dist"); // Studio's vite build clears its dist before rewriting it; don't start the // copy until both sentinels are present so we never observe a partial tree. const STUDIO_WAIT_TIMEOUT_MS = 30_000; const STUDIO_POLL_INTERVAL_MS = 250; // fallow-ignore-next-line complexity async function waitForStudioDist(dir) { const deadline = Date.now() + STUDIO_WAIT_TIMEOUT_MS; while (Date.now() < deadline) { try { const entries = new Set(readdirSync(dir)); // vite emits `assets/` before rewriting `index.html` at the end of the // build — so once both are present, the tree is complete. if (entries.has("index.html") && entries.has("assets")) return; } catch { // dir doesn't exist yet — vite will create it } await sleep(STUDIO_POLL_INTERVAL_MS); } throw new Error(`[build-copy] timed out waiting for studio dist at ${dir}`); } function copyDir(src, dest) { cpSync(src, dest, { recursive: true, force: true }); } // Source maps stay out: the CLI ships none of its own (tsup sourcemap: false). function copyDirContents(src, dest) { for (const entry of readdirSync(src)) { cpSync(join(src, entry), join(dest, entry), { recursive: true, force: true, filter: (path) => !path.endsWith(".map"), }); } } function copyMdFiles(srcDir, destDir) { if (!existsSync(srcDir)) return; for (const name of readdirSync(srcDir)) { if (name.endsWith(".md")) { cpSync(join(srcDir, name), join(destDir, name)); } } } // Studio preview loads these from the CLI's own server instead of jsDelivr // (studioServer.ts serves dist/vendor/npm). They come from the workspace's // installed gsap, which must match the version core asks for, or Studio would // quietly fall back to the CDN for every preview. const GSAP_VENDOR_FILES = ["gsap.min.js", "CustomEase.min.js", "MotionPathPlugin.min.js"]; function copyGsapVendorFiles() { const coreRequire = createRequire(join(REPO_ROOT, "packages", "core", "package.json")); const gsapRoot = dirname(coreRequire.resolve("gsap/package.json")); const { version } = JSON.parse(readFileSync(join(gsapRoot, "package.json"), "utf-8")); const coreSource = readFileSync( join(REPO_ROOT, "packages", "core", "src", "gsapCdn.ts"), "utf-8", ); const expected = coreSource.match(/GSAP_CDN_VERSION = "([^"]+)"/)?.[1]; if (version !== expected) { throw new Error( `[build-copy] installed gsap ${version} does not match GSAP_CDN_VERSION ${expected}`, ); } const vendorDir = join(DIST, "vendor", "npm"); rmSync(vendorDir, { recursive: true, force: true }); const dest = join(vendorDir, `gsap@${version}`, "dist"); mkdirSync(dest, { recursive: true }); for (const file of GSAP_VENDOR_FILES) { copyFileSync(join(gsapRoot, "dist", file), join(dest, file)); chmodSync(join(dest, file), 0o644); } } // fallow-ignore-next-line complexity async function main() { for (const sub of ["studio", "docs", "templates", "skills", "docker"]) { mkdirSync(join(DIST, sub), { recursive: true }); } mkdirSync(join(DIST, "commands"), { recursive: true }); const studioDist = resolve(CLI_ROOT, "..", "studio", "dist"); await waitForStudioDist(studioDist); // Only the SPA studioServer.ts serves (index.html, /assets, /icons, /favicon.svg); // the rest of studio/dist is the npm library build. for (const entry of ["index.html", "assets", "icons", "favicon.svg"]) { copyDir(join(studioDist, entry), join(DIST, "studio", entry)); } for (const tmpl of ["blank", "from-file", "_shared"]) { copyDir(join(CLI_ROOT, "src", "templates", tmpl), join(DIST, "templates", tmpl)); } // Bundle warm-grain from the repo registry so the built CLI can scaffold it // offline and CI smoke tests pick up PR-branch changes before merge to main. const warmGrainSrc = join(REPO_ROOT, "registry", "examples", "warm-grain"); if (existsSync(warmGrainSrc)) { copyDir(warmGrainSrc, join(DIST, "templates", "warm-grain")); } // Skills bundled into the published CLI. Branches don't all carry the same // skills/ tree (it gets restructured), so each entry is existsSync-guarded: // a missing skill dir warns + skips instead of crashing the build. for (const skill of ["hyperframes", "hyperframes-cli", "gsap", "media-use"]) { const src = join(REPO_ROOT, "skills", skill); if (!existsSync(src)) { console.warn(`[build-copy] skill not found, skipping: skills/${skill}`); continue; } const destination = join(DIST, "skills", skill); rmSync(destination, { recursive: true, force: true }); copyDir(src, destination); } // The media-use engine lives with the CLI source, but keeps its published // skill-relative layout so the moved .mjs tree can run without a rewrite. const mediaEngine = join(CLI_ROOT, "src", "media-use"); const publishedMediaLib = join(DIST, "skills", "media-use", "scripts", "lib"); rmSync(publishedMediaLib, { recursive: true, force: true }); mkdirSync(publishedMediaLib, { recursive: true }); copyDirContents(join(mediaEngine, "lib"), publishedMediaLib); cpSync( join(mediaEngine, "resolve.mjs"), join(DIST, "skills", "media-use", "scripts", "resolve.mjs"), ); const dockerfile = join(CLI_ROOT, "src", "docker", "Dockerfile.render"); if (existsSync(dockerfile)) { cpSync(dockerfile, join(DIST, "docker", "Dockerfile.render")); } const layoutAuditScript = join(CLI_ROOT, "src", "commands", "layout-audit.browser.js"); if (existsSync(layoutAuditScript)) { cpSync(layoutAuditScript, join(DIST, "commands", "layout-audit.browser.js")); } const contrastAuditScript = join(CLI_ROOT, "src", "commands", "contrast-audit.browser.js"); if (existsSync(contrastAuditScript)) { cpSync(contrastAuditScript, join(DIST, "commands", "contrast-audit.browser.js")); } const motionSignatureScript = join(CLI_ROOT, "src", "commands", "motion-signature.browser.js"); if (existsSync(motionSignatureScript)) { cpSync(motionSignatureScript, join(DIST, "commands", "motion-signature.browser.js")); } const motionSampleScript = join(CLI_ROOT, "src", "commands", "motion-sample.browser.js"); if (existsSync(motionSampleScript)) { cpSync(motionSampleScript, join(DIST, "commands", "motion-sample.browser.js")); } // Player bundles for the standalone browser player used by `present` and // `play`. resolvePlayerPath/resolveSlideshowPath look for these alongside the // built CLI (dist/.global.js), so they must ship in the package — the // monorepo-dev fallback paths don't exist once installed from npm. Without // this, `npx hyperframes present` fails with "@hyperframes/player not found". const playerDist = join(REPO_ROOT, "packages", "player", "dist"); const playerGlobals = [ [join(playerDist, "hyperframes-player.global.js"), join(DIST, "hyperframes-player.global.js")], [ join(playerDist, "slideshow", "hyperframes-slideshow.global.js"), join(DIST, "hyperframes-slideshow.global.js"), ], ]; for (const [src, dest] of playerGlobals) { if (existsSync(src)) { cpSync(src, dest); } else { console.warn(`[build-copy] player bundle not found, skipping: ${src}`); } } // The bundled inspector reads its page runtime from beside its own chunk. const inspectorRuntime = join(REPO_ROOT, "packages", "inspector", "dist", "inspection.js"); cpSync(inspectorRuntime, join(DIST, "inspection.js")); copyGsapVendorFiles(); copyMdFiles(join(CLI_ROOT, "src", "docs"), join(DIST, "docs")); console.log("[build-copy] done"); } await main();