diff --git a/cli/scripts/build-cli.js b/cli/scripts/build-cli.js index 3af3120f..d4e4d279 100644 --- a/cli/scripts/build-cli.js +++ b/cli/scripts/build-cli.js @@ -81,205 +81,272 @@ function copyRecursive(src, dest) { } } -console.log("đŸ“Ļ Building 9Router CLI package with Next.js...\n"); +function resolveStandaloneBuild(appDir, buildDistDir) { + const legacyStandaloneRoot = path.join(appDir, ".next", "standalone"); + const resolvedStandaloneRoot = path.join(buildDistDir, "standalone"); + let standaloneRoot = fs.existsSync(resolvedStandaloneRoot) + ? resolvedStandaloneRoot + : legacyStandaloneRoot; -fs.mkdirSync(buildHomeDir, { recursive: true }); -fs.mkdirSync(path.join(buildHomeDir, "AppData", "Roaming"), { recursive: true }); -fs.mkdirSync(path.join(buildHomeDir, "AppData", "Local"), { recursive: true }); - -// Step 0: Sync version from app/cli/package.json to app/package.json -console.log("0ī¸âƒŖ Syncing version to app/package.json..."); -const cliPkg = JSON.parse(fs.readFileSync(path.join(cliDir, "package.json"), "utf8")); -const appPkgPath = path.join(appDir, "package.json"); -const appPkg = JSON.parse(fs.readFileSync(appPkgPath, "utf8")); -if (appPkg.version !== cliPkg.version) { - appPkg.version = cliPkg.version; - fs.writeFileSync(appPkgPath, JSON.stringify(appPkg, null, 2) + "\n"); - console.log(`✅ Version synced: ${cliPkg.version}\n`); -} else { - console.log(`✅ Version already synced: ${cliPkg.version}\n`); -} - -// Step 1: Build app with Next.js (workspace tracing root → traced node_modules in standalone). -console.log("1ī¸âƒŖ Building Next.js app..."); -try { - execSync("npm run build", { - stdio: "inherit", - cwd: appDir, - env: { - ...process.env, - HOME: buildHomeDir, - USERPROFILE: buildHomeDir, - APPDATA: path.join(buildHomeDir, "AppData", "Roaming"), - LOCALAPPDATA: path.join(buildHomeDir, "AppData", "Local"), - NEXT_DIST_DIR: buildDistDirName, - NEXT_TRACING_ROOT_MODE: "workspace", - } - }); - console.log("✅ Next.js build completed\n"); -} catch (error) { - console.error("❌ Next.js build failed"); - process.exit(1); -} - -// Step 2: Clean old app/cli/app if exists -console.log("2ī¸âƒŖ Cleaning old app/cli/app..."); -if (fs.existsSync(cliAppDir)) { - fs.rmSync(cliAppDir, { recursive: true, force: true }); -} -console.log("✅ Cleaned\n"); - -// Step 3: Copy Next.js standalone build to app/cli/app. -// Newer Next.js standalone output writes server.js/package.json plus .next/, src/, and -// node_modules/ directly under .next/standalone. Older builds may still use a nested app/. -console.log("3ī¸âƒŖ Copying Next.js standalone build to app/cli/app..."); -const standaloneRoot = path.join(appDir, ".next", "standalone"); -const standaloneRootResolved = path.join(buildDistDir, "standalone"); -let standaloneRootToUse = fs.existsSync(standaloneRootResolved) ? standaloneRootResolved : standaloneRoot; -// Next.js 16 nests standalone output under the project name when NEXT_TRACING_ROOT_MODE=workspace -// e.g. .next-cli-build/standalone/9router/server.js -const pkgName = path.basename(appDir); -const nestedRoot = path.join(standaloneRootToUse, pkgName); -if (fs.existsSync(path.join(nestedRoot, "server.js")) && !fs.existsSync(path.join(standaloneRootToUse, "server.js"))) { - console.log(`â„šī¸ Detected nested standalone output: ${pkgName}/`); - standaloneRootToUse = nestedRoot; -} -const standaloneApp = fs.existsSync(path.join(standaloneRootToUse, "server.js")) - ? standaloneRootToUse - : path.join(standaloneRootToUse, "app"); -if (!fs.existsSync(standaloneApp)) { - console.error("❌ Next.js standalone build not found under .next/standalone"); - console.error("Expected either .next/standalone/server.js or .next/standalone/app/"); - process.exit(1); -} -copyRecursive(standaloneApp, cliAppDir); - -// Older nested-app layout stores traced node_modules at standalone root. -const standaloneNodeModules = path.join(standaloneRootToUse, "node_modules"); -if (standaloneApp !== standaloneRootToUse && fs.existsSync(standaloneNodeModules)) { - copyRecursive(standaloneNodeModules, path.join(cliAppDir, "node_modules")); -} -console.log("✅ Copied standalone build\n"); - -// Step 3a: Copy custom server (injects real socket IP, strips spoofable XFF). -const customServerSrc = path.join(appDir, "custom-server.js"); -if (fs.existsSync(customServerSrc)) { - fs.copyFileSync(customServerSrc, path.join(cliAppDir, "custom-server.js")); - console.log("✅ Copied custom-server.js\n"); -} else { - console.warn("âš ī¸ custom-server.js not found — server will run without real-IP injection\n"); -} - -// Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules. -// Strip better-sqlite3 (native) — it lives in ~/.9router/runtime to avoid -// Windows EBUSY during global CLI updates. node:sqlite (Node â‰Ĩ22.5) is also -// available as a no-install middle tier. -console.log("3ī¸âƒŖ b Configuring SQLite drivers..."); -function ensureModuleInBundle(pkg) { - const dest = path.join(cliAppDir, "node_modules", pkg); - if (fs.existsSync(dest)) { - console.log(`✅ ${pkg} already bundled`); - return; + // Next.js 16 nests standalone output under the project name when + // NEXT_TRACING_ROOT_MODE=workspace, e.g. standalone/9router/server.js. + const pkgName = path.basename(appDir); + const nestedRoot = path.join(standaloneRoot, pkgName); + if (fs.existsSync(path.join(nestedRoot, "server.js")) && !fs.existsSync(path.join(standaloneRoot, "server.js"))) { + console.log(`â„šī¸ Detected nested standalone output: ${pkgName}/`); + standaloneRoot = nestedRoot; } - const candidates = [ - path.join(appDir, "node_modules", pkg), - path.join(rootDir, "node_modules", pkg), + + const standaloneApp = fs.existsSync(path.join(standaloneRoot, "server.js")) + ? standaloneRoot + : path.join(standaloneRoot, "app"); + if (!fs.existsSync(standaloneApp)) { + throw new Error( + "Next.js standalone build not found under .next/standalone; " + + "expected either .next/standalone/server.js or .next/standalone/app/", + ); + } + + return { standaloneApp, standaloneRoot }; +} + +function copyStandaloneBuild(appDir, buildDistDir, cliAppDir) { + const { standaloneApp, standaloneRoot } = resolveStandaloneBuild(appDir, buildDistDir); + copyRecursive(standaloneApp, cliAppDir); + + // Older nested-app layout stores traced node_modules at standalone root. + const standaloneNodeModules = path.join(standaloneRoot, "node_modules"); + if (standaloneApp !== standaloneRoot && fs.existsSync(standaloneNodeModules)) { + copyRecursive(standaloneNodeModules, path.join(cliAppDir, "node_modules")); + } +} + +function mergeServerArtifacts(buildDistDir, cliAppDir) { + const serverSrc = path.join(buildDistDir, "server"); + const serverDest = path.join(cliAppDir, buildDistDirName, "server"); + if (!fs.existsSync(serverSrc)) { + throw new Error(`Complete Next.js server build not found: ${serverSrc}`); + } + copyRecursive(serverSrc, serverDest); +} + +function assertRequiredApiArtifacts(cliAppDir) { + const requiredArtifacts = [ + "app/api/v1/chat/completions/route.js", + "app/api/v1/messages/route.js", ]; - const src = candidates.find((p) => fs.existsSync(p)); - if (!src) { - console.warn(`âš ī¸ ${pkg} not found locally — bundle will rely on node:sqlite or runtime install`); - return; + const serverDir = path.join(cliAppDir, buildDistDirName, "server"); + const missingArtifacts = requiredArtifacts + .map((artifact) => path.join(serverDir, artifact)) + .filter((artifact) => !fs.existsSync(artifact)); + + if (missingArtifacts.length > 0) { + throw new Error( + `Required CLI API route artifact${missingArtifacts.length === 1 ? " is" : "s are"} missing:\n` + + missingArtifacts.join("\n"), + ); } - fs.mkdirSync(path.dirname(dest), { recursive: true }); - copyRecursive(src, dest); - console.log(`✅ Bundled ${pkg}`); -} -ensureModuleInBundle("sql.js"); -// `open` is external (see serverExternalPackages in next.config.mjs), so it must exist in -// the bundle's node_modules or every importer throws MODULE_NOT_FOUND at runtime. Output -// tracing normally copies it; this is the same belt-and-braces guard used for sql.js. -ensureModuleInBundle("open"); -const betterDir = path.join(cliAppDir, "node_modules", "better-sqlite3"); -if (fs.existsSync(betterDir)) { - fs.rmSync(betterDir, { recursive: true, force: true }); - console.log("✅ Stripped better-sqlite3 (lives in ~/.9router/runtime)"); -} -console.log(""); - -// Step 4: Copy static files -console.log("4ī¸âƒŖ Copying static files..."); -const staticSrc = path.join(appDir, ".next", "static"); -const staticSrcResolved = path.join(buildDistDir, "static"); -const staticDest = path.join(cliAppDir, buildDistDirName, "static"); -if (fs.existsSync(staticSrcResolved) || fs.existsSync(staticSrc)) { - copyRecursive(fs.existsSync(staticSrcResolved) ? staticSrcResolved : staticSrc, staticDest); - console.log("✅ Copied static files\n"); -} else { - console.log("â­ī¸ No static files found\n"); } -// Step 5: Copy public folder if exists -console.log("5ī¸âƒŖ Copying public folder..."); -const publicSrc = path.join(appDir, "public"); -const publicDest = path.join(cliAppDir, "public"); -if (fs.existsSync(publicSrc)) { - copyRecursive(publicSrc, publicDest); - console.log("✅ Copied public folder\n"); -} else { - console.log("â­ī¸ No public folder found\n"); +function buildCliPackage() { + console.log("đŸ“Ļ Building 9Router CLI package with Next.js...\n"); + + fs.mkdirSync(buildHomeDir, { recursive: true }); + fs.mkdirSync(path.join(buildHomeDir, "AppData", "Roaming"), { recursive: true }); + fs.mkdirSync(path.join(buildHomeDir, "AppData", "Local"), { recursive: true }); + + // Step 0: Sync version from app/cli/package.json to app/package.json + console.log("0ī¸âƒŖ Syncing version to app/package.json..."); + const cliPkg = JSON.parse(fs.readFileSync(path.join(cliDir, "package.json"), "utf8")); + const appPkgPath = path.join(appDir, "package.json"); + const appPkg = JSON.parse(fs.readFileSync(appPkgPath, "utf8")); + if (appPkg.version !== cliPkg.version) { + appPkg.version = cliPkg.version; + fs.writeFileSync(appPkgPath, JSON.stringify(appPkg, null, 2) + "\n"); + console.log(`✅ Version synced: ${cliPkg.version}\n`); + } else { + console.log(`✅ Version already synced: ${cliPkg.version}\n`); + } + + // Step 1: Build app with Next.js (workspace tracing root → traced node_modules in standalone). + console.log("1ī¸âƒŖ Building Next.js app..."); + try { + execSync("npm run build", { + stdio: "inherit", + cwd: appDir, + env: { + ...process.env, + HOME: buildHomeDir, + USERPROFILE: buildHomeDir, + APPDATA: path.join(buildHomeDir, "AppData", "Roaming"), + LOCALAPPDATA: path.join(buildHomeDir, "AppData", "Local"), + NEXT_DIST_DIR: buildDistDirName, + NEXT_TRACING_ROOT_MODE: "workspace", + } + }); + console.log("✅ Next.js build completed\n"); + } catch (error) { + console.error("❌ Next.js build failed"); + process.exit(1); + } + + // Step 2: Clean old app/cli/app if exists + console.log("2ī¸âƒŖ Cleaning old app/cli/app..."); + if (fs.existsSync(cliAppDir)) { + fs.rmSync(cliAppDir, { recursive: true, force: true }); + } + console.log("✅ Cleaned\n"); + + // Step 3: Copy Next.js standalone build to app/cli/app. + // Newer Next.js standalone output writes server.js/package.json plus .next/, src/, and + // node_modules/ directly under .next/standalone. Older builds may still use a nested app/. + console.log("3ī¸âƒŖ Copying Next.js standalone build to app/cli/app..."); + try { + copyStandaloneBuild(appDir, buildDistDir, cliAppDir); + } catch (error) { + console.error("❌ Next.js standalone build not found under .next/standalone"); + console.error("Expected either .next/standalone/server.js or .next/standalone/app/"); + process.exit(1); + } + console.log("✅ Copied standalone build\n"); + + // Step 3a: Copy custom server (injects real socket IP, strips spoofable XFF). + const customServerSrc = path.join(appDir, "custom-server.js"); + if (fs.existsSync(customServerSrc)) { + fs.copyFileSync(customServerSrc, path.join(cliAppDir, "custom-server.js")); + console.log("✅ Copied custom-server.js\n"); + } else { + console.warn("âš ī¸ custom-server.js not found — server will run without real-IP injection\n"); + } + + // Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules. + // Strip better-sqlite3 (native) — it lives in ~/.9router/runtime to avoid + // Windows EBUSY during global CLI updates. node:sqlite (Node â‰Ĩ22.5) is also + // available as a no-install middle tier. + console.log("3ī¸âƒŖ b Configuring SQLite drivers..."); + function ensureModuleInBundle(pkg) { + const dest = path.join(cliAppDir, "node_modules", pkg); + if (fs.existsSync(dest)) { + console.log(`✅ ${pkg} already bundled`); + return; + } + const candidates = [ + path.join(appDir, "node_modules", pkg), + path.join(rootDir, "node_modules", pkg), + ]; + const src = candidates.find((p) => fs.existsSync(p)); + if (!src) { + console.warn(`âš ī¸ ${pkg} not found locally — bundle will rely on node:sqlite or runtime install`); + return; + } + fs.mkdirSync(path.dirname(dest), { recursive: true }); + copyRecursive(src, dest); + console.log(`✅ Bundled ${pkg}`); + } + ensureModuleInBundle("sql.js"); + // `open` is external (see serverExternalPackages in next.config.mjs), so it must exist in + // the bundle's node_modules or every importer throws MODULE_NOT_FOUND at runtime. Output + // tracing normally copies it; this is the same belt-and-braces guard used for sql.js. + ensureModuleInBundle("open"); + const betterDir = path.join(cliAppDir, "node_modules", "better-sqlite3"); + if (fs.existsSync(betterDir)) { + fs.rmSync(betterDir, { recursive: true, force: true }); + console.log("✅ Stripped better-sqlite3 (lives in ~/.9router/runtime)"); + } + console.log(""); + + // Step 4: Copy static files + console.log("4ī¸âƒŖ Copying static files..."); + const staticSrc = path.join(appDir, ".next", "static"); + const staticSrcResolved = path.join(buildDistDir, "static"); + const staticDest = path.join(cliAppDir, buildDistDirName, "static"); + if (fs.existsSync(staticSrcResolved) || fs.existsSync(staticSrc)) { + copyRecursive(fs.existsSync(staticSrcResolved) ? staticSrcResolved : staticSrc, staticDest); + console.log("✅ Copied static files\n"); + } else { + console.log("â­ī¸ No static files found\n"); + } + + // Step 5: Copy public folder if exists + console.log("5ī¸âƒŖ Copying public folder..."); + const publicSrc = path.join(appDir, "public"); + const publicDest = path.join(cliAppDir, "public"); + if (fs.existsSync(publicSrc)) { + copyRecursive(publicSrc, publicDest); + console.log("✅ Copied public folder\n"); + } else { + console.log("â­ī¸ No public folder found\n"); + } + + // Step 6: Copy vendor-chunks (required for production) + console.log("6ī¸âƒŖ Copying vendor-chunks..."); + const vendorChunksSrc = path.join(appDir, ".next", "server", "vendor-chunks"); + const vendorChunksSrcResolved = path.join(buildDistDir, "server", "vendor-chunks"); + const vendorChunksDest = path.join(cliAppDir, buildDistDirName, "server", "vendor-chunks"); + if (fs.existsSync(vendorChunksSrcResolved) || fs.existsSync(vendorChunksSrc)) { + copyRecursive(fs.existsSync(vendorChunksSrcResolved) ? vendorChunksSrcResolved : vendorChunksSrc, vendorChunksDest); + console.log("✅ Copied vendor-chunks\n"); + } else { + console.log("â­ī¸ No vendor-chunks found\n"); + } + + // Step 6b: Merge the complete generated server tree. Next.js standalone output + // is trace-pruned and can omit route modules or chunks loaded dynamically. + console.log("6ī¸âƒŖ b Copying complete server artifacts..."); + mergeServerArtifacts(buildDistDir, cliAppDir); + assertRequiredApiArtifacts(cliAppDir); + console.log("✅ Copied complete server artifacts\n"); + + // Step 7: Copy MITM server files (not bundled by Next.js standalone) + console.log("7ī¸âƒŖ Copying MITM server files..."); + const mitmSrc = path.join(appDir, "src", "mitm"); + const mitmDest = path.join(cliAppDir, "src", "mitm"); + if (fs.existsSync(mitmSrc)) { + copyRecursive(mitmSrc, mitmDest); + console.log("✅ Copied MITM files\n"); + } else { + console.log("â­ī¸ No MITM files found\n"); + } + + // Step 7b: Copy standalone updater (headless Node process for install progress) + console.log("7ī¸âƒŖ b Copying updater files..."); + const updaterSrc = path.join(appDir, "src", "lib", "updater"); + const updaterDest = path.join(cliAppDir, "src", "lib", "updater"); + if (fs.existsSync(updaterSrc)) { + copyRecursive(updaterSrc, updaterDest); + console.log("✅ Copied updater files\n"); + } else { + console.log("â­ī¸ No updater files found\n"); + } + + // Step 8: Build MITM server (config driven - see app/cli/scripts/buildMitm.js) + console.log("8ī¸âƒŖ Building MITM server..."); + try { + execSync("node scripts/buildMitm.js", { stdio: "inherit", cwd: cliDir }); + console.log("✅ MITM server build completed\n"); + } catch (error) { + console.error("❌ MITM build failed"); + process.exit(1); + } + + console.log("✨ CLI package build completed!"); + console.log(`📁 Output: ${cliAppDir}`); + + try { + const { execSync: exec } = require("child_process"); + const size = exec(`du -sh "${cliAppDir}"`, { encoding: "utf8" }).trim(); + console.log(`📊 Package size: ${size.split("\t")[0]}`); + } catch (e) { + // Silent fail on size check + } } -// Step 6: Copy vendor-chunks (required for production) -console.log("6ī¸âƒŖ Copying vendor-chunks..."); -const vendorChunksSrc = path.join(appDir, ".next", "server", "vendor-chunks"); -const vendorChunksSrcResolved = path.join(buildDistDir, "server", "vendor-chunks"); -const vendorChunksDest = path.join(cliAppDir, buildDistDirName, "server", "vendor-chunks"); -if (fs.existsSync(vendorChunksSrcResolved) || fs.existsSync(vendorChunksSrc)) { - copyRecursive(fs.existsSync(vendorChunksSrcResolved) ? vendorChunksSrcResolved : vendorChunksSrc, vendorChunksDest); - console.log("✅ Copied vendor-chunks\n"); -} else { - console.log("â­ī¸ No vendor-chunks found\n"); -} +module.exports = { + assertRequiredApiArtifacts, + copyStandaloneBuild, + mergeServerArtifacts, +}; -// Step 7: Copy MITM server files (not bundled by Next.js standalone) -console.log("7ī¸âƒŖ Copying MITM server files..."); -const mitmSrc = path.join(appDir, "src", "mitm"); -const mitmDest = path.join(cliAppDir, "src", "mitm"); -if (fs.existsSync(mitmSrc)) { - copyRecursive(mitmSrc, mitmDest); - console.log("✅ Copied MITM files\n"); -} else { - console.log("â­ī¸ No MITM files found\n"); -} - -// Step 7b: Copy standalone updater (headless Node process for install progress) -console.log("7ī¸âƒŖ b Copying updater files..."); -const updaterSrc = path.join(appDir, "src", "lib", "updater"); -const updaterDest = path.join(cliAppDir, "src", "lib", "updater"); -if (fs.existsSync(updaterSrc)) { - copyRecursive(updaterSrc, updaterDest); - console.log("✅ Copied updater files\n"); -} else { - console.log("â­ī¸ No updater files found\n"); -} - -// Step 8: Build MITM server (config driven - see app/cli/scripts/buildMitm.js) -console.log("8ī¸âƒŖ Building MITM server..."); -try { - execSync("node scripts/buildMitm.js", { stdio: "inherit", cwd: cliDir }); - console.log("✅ MITM server build completed\n"); -} catch (error) { - console.error("❌ MITM build failed"); - process.exit(1); -} - -console.log("✨ CLI package build completed!"); -console.log(`📁 Output: ${cliAppDir}`); - -try { - const { execSync: exec } = require("child_process"); - const size = exec(`du -sh "${cliAppDir}"`, { encoding: "utf8" }).trim(); - console.log(`📊 Package size: ${size.split("\t")[0]}`); -} catch (e) { - // Silent fail on size check +if (require.main === module) { + buildCliPackage(); } diff --git a/tests/unit/cli-build-artifacts.test.js b/tests/unit/cli-build-artifacts.test.js new file mode 100644 index 00000000..0644a1cd --- /dev/null +++ b/tests/unit/cli-build-artifacts.test.js @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +let testApi; +try { + testApi = await import("vitest"); +} catch (error) { + if (error.code !== "ERR_MODULE_NOT_FOUND") throw error; + testApi = await import("node:test"); +} +const { afterEach, describe, it } = testApi; + +const require = createRequire(import.meta.url); +const { + assertRequiredApiArtifacts, + copyStandaloneBuild, + mergeServerArtifacts, +} = require("../../cli/scripts/build-cli.js"); + +const tempDirs = []; + +function createTempDir() { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-cli-build-")); + tempDirs.push(tempDir); + return tempDir; +} + +function writeFixture(root, relativePath, contents = relativePath) { + const filePath = path.join(root, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents); + return filePath; +} + +function createCompleteServer(buildDistDir) { + const serverDir = path.join(buildDistDir, "server"); + writeFixture(serverDir, "app/api/v1/chat/completions/route.js", "chat route"); + writeFixture(serverDir, "app/api/v1/messages/route.js", "messages route"); + writeFixture(serverDir, "chunks/openai-provider.js", "openai chunk"); + writeFixture(serverDir, "chunks/anthropic-provider.js", "anthropic chunk"); + return serverDir; +} + +afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe("CLI build server artifacts", () => { + for (const { name, standalonePath } of [ + { + name: "legacy nested app", + standalonePath: (appDir, buildDistDir) => path.join(appDir, ".next", "standalone", "app"), + }, + { + name: "Next 16 workspace", + standalonePath: (appDir, buildDistDir) => path.join(buildDistDir, "standalone", path.basename(appDir)), + }, + ]) { + it(`merges complete API routes and provider chunks for the ${name} layout`, () => { + const root = createTempDir(); + const appDir = path.join(root, "9router"); + const buildDistDir = path.join(appDir, ".next-cli-build"); + const cliAppDir = path.join(root, "cli-app"); + const standaloneDir = standalonePath(appDir, buildDistDir); + + writeFixture(standaloneDir, "server.js", "standalone server"); + writeFixture( + standaloneDir, + ".next-cli-build/server/app/api/v1/chat/completions/route.js", + "standalone chat route", + ); + createCompleteServer(buildDistDir); + + copyStandaloneBuild(appDir, buildDistDir, cliAppDir); + mergeServerArtifacts(buildDistDir, cliAppDir); + assertRequiredApiArtifacts(cliAppDir); + + const packagedServer = path.join(cliAppDir, ".next-cli-build", "server"); + assert.equal( + fs.readFileSync(path.join(packagedServer, "app/api/v1/messages/route.js"), "utf8"), + "messages route", + ); + assert.equal( + fs.readFileSync(path.join(packagedServer, "chunks/openai-provider.js"), "utf8"), + "openai chunk", + ); + assert.equal( + fs.readFileSync(path.join(packagedServer, "chunks/anthropic-provider.js"), "utf8"), + "anthropic chunk", + ); + }); + } + + it("merges idempotently without removing standalone-generated files", () => { + const root = createTempDir(); + const buildDistDir = path.join(root, ".next-cli-build"); + const cliAppDir = path.join(root, "cli-app"); + const packagedServer = path.join(cliAppDir, ".next-cli-build", "server"); + + createCompleteServer(buildDistDir); + writeFixture(packagedServer, "standalone-only.js", "keep me"); + + mergeServerArtifacts(buildDistDir, cliAppDir); + mergeServerArtifacts(buildDistDir, cliAppDir); + + assert.equal( + fs.readFileSync(path.join(packagedServer, "standalone-only.js"), "utf8"), + "keep me", + ); + assert.equal( + fs.readFileSync(path.join(packagedServer, "app/api/v1/messages/route.js"), "utf8"), + "messages route", + ); + }); + + it("reports the missing required API route artifact path", () => { + const root = createTempDir(); + const buildDistDir = path.join(root, ".next-cli-build"); + const cliAppDir = path.join(root, "cli-app"); + + writeFixture( + path.join(buildDistDir, "server"), + "app/api/v1/chat/completions/route.js", + "chat route", + ); + mergeServerArtifacts(buildDistDir, cliAppDir); + + assert.throws( + () => assertRequiredApiArtifacts(cliAppDir), + (error) => error.message.includes(path.join( + cliAppDir, + ".next-cli-build/server/app/api/v1/messages/route.js", + )), + ); + }); +});