import picomatch from "picomatch"; import { parse as parseYaml } from "yaml"; import { type ComputeAppManifest, type DetectedComputeApp, detectComputeApp, } from "./detect-app.ts"; import { type FrameworkBuildType, frameworkByKey } from "./frameworks.ts"; import type { ComputeFramework } from "./types.ts"; const MAX_PACKAGE_MANIFESTS = 80; const PACKAGE_FETCH_BATCH_SIZE = 10; const IGNORED_PATH_SEGMENTS = new Set([ ".git", ".next", ".output", "build", "dist", "node_modules", "vendor", ]); /** * Lockfile filenames in precedence order, paired with their package manager. * bun.lock (text format, Bun 1.1+) is checked before bun.lockb (binary). */ const LOCKFILE_ENTRIES = [ ["bun.lock", "bun"], ["bun.lockb", "bun"], ["pnpm-lock.yaml", "pnpm"], ["yarn.lock", "yarn"], ["package-lock.json", "npm"], ] as const satisfies ReadonlyArray<[string, "npm" | "pnpm" | "yarn" | "bun"]>; interface ParsedManifest { path: string; root: string; value: ComputeAppManifest; } export interface DetectComputeRepositoryInput { repositoryName: string; filePaths: Iterable; readFile: (path: string) => Promise; } /** Package manager detected for the repository root. */ export interface DetectedPackageManager { /** Package manager name. */ name: "npm" | "pnpm" | "yarn" | "bun"; /** * Lockfile found at the repository root. * null when the package manager was resolved from the packageManager field * alone and no lockfile was present. */ lockfile: string | null; } export interface DetectedComputeRepositoryApp { id: string; target: string; name: string; root: string; framework: ComputeFramework; frameworkName: string; httpPort: number; entry: string; build: { command: string | null; outputDirectory: string; entrypoint: string; }; inferredFrom: string[]; reviewRequired: boolean; /** * Prisma Next usage for this app. "Prisma" here means Prisma Next (Prisma 8+), * the engine-less, @prisma-next/-scoped client. Earlier Prisma clients * (@prisma/client, prisma-client-js) are not part of this system's vocabulary * and are not detected. */ prismaNext: { /** True when a Prisma Next config file or a @prisma-next/* package was found. */ detected: boolean; /** Signal that triggered detection, or empty when detected is false. */ inferredFrom: string[]; }; } export interface DetectComputeRepositoryResult { apps: DetectedComputeRepositoryApp[]; warnings: string[]; /** * Package manager detected from the repository root's packageManager field * and lockfile. null when neither signal is present. */ packageManager: DetectedPackageManager | null; } /** * Detects every deployable app in a remote repository snapshot, including * workspace discovery, root-app ownership, and inferred build settings. */ export async function detectComputeRepository( input: DetectComputeRepositoryInput, ): Promise { const filePaths = new Set(input.filePaths); const warnings: string[] = []; const manifestPaths = [...filePaths] .filter( (path) => (path === "package.json" || path.endsWith("/package.json")) && !path.split("/").some((segment) => IGNORED_PATH_SEGMENTS.has(segment)), ) .sort((a, b) => pathDepth(a) - pathDepth(b) || a.localeCompare(b)); if (manifestPaths.length > MAX_PACKAGE_MANIFESTS) { warnings.push( `Only the first ${MAX_PACKAGE_MANIFESTS} package manifests were inspected.`, ); } const manifests: ParsedManifest[] = []; for ( let offset = 0; offset < Math.min(manifestPaths.length, MAX_PACKAGE_MANIFESTS); offset += PACKAGE_FETCH_BATCH_SIZE ) { const batch = manifestPaths.slice( offset, offset + PACKAGE_FETCH_BATCH_SIZE, ); const fetched = await Promise.all( batch.map(async (path) => { try { return { path, content: await input.readFile(path) }; } catch { warnings.push(`Skipped unreadable ${path}.`); return { path, content: undefined }; } }), ); for (const item of fetched) { if (item.content === undefined) continue; if (item.content === null) { warnings.push(`Skipped unavailable ${item.path}.`); continue; } try { const value: unknown = JSON.parse(item.content); if (!value || typeof value !== "object" || Array.isArray(value)) { warnings.push(`Skipped invalid package manifest in ${item.path}.`); continue; } manifests.push({ path: item.path, root: directoryName(item.path), value: value as ComputeAppManifest, }); } catch { warnings.push(`Skipped invalid JSON in ${item.path}.`); } } } const rootManifest = manifests.find((manifest) => manifest.root === ""); const workspacePatterns = await readWorkspacePatterns({ input, filePaths, rootManifest, warnings, }); const candidateManifests = manifests.filter( (manifest) => manifest.root === "" || workspacePatterns.length === 0 || matchesWorkspace(manifest.root, workspacePatterns), ); const allDetected = candidateManifests .map((manifest) => { const detectedApp = detectComputeApp({ root: manifest.root, manifest: manifest.value, filePaths, }); return detectedApp ? { manifest, detectedApp } : null; }) .filter((item): item is NonNullable => item !== null); const nestedDetected = allDetected.filter( (item) => item.manifest.root !== "", ); const nestedAppRoots = nestedDetected.map((item) => item.manifest.root); const detected = allDetected.filter( (item) => item.manifest.root !== "" || nestedDetected.length === 0 || hasDistinctRootAppSignal( item.manifest, item.detectedApp, filePaths, nestedAppRoots, ), ); if (detected.length !== allDetected.length) { warnings.push( "The root package did not identify an application distinct from the detected workspace apps, so it was not added as a separate deploy target.", ); } const selected = detected.length > 0 ? detected : [ { manifest: rootManifest ?? candidateManifests[0] ?? { path: "package.json", root: "", value: {}, }, detectedApp: null, }, ]; if (detected.length === 0) { warnings.push( "No supported framework signal was found. Review the Bun entrypoint and build settings.", ); } const apps = selected.map((item) => inferApp({ repositoryName: input.repositoryName, filePaths, rootManifest, manifest: item.manifest, detectedApp: item.detectedApp, }), ); uniquifyTargets(apps); const packageManager = detectRepoPackageManager({ rootManifest, filePaths, warnings, }); return { apps, warnings, packageManager }; } async function readWorkspacePatterns(args: { input: DetectComputeRepositoryInput; filePaths: Set; rootManifest: ParsedManifest | undefined; warnings: string[]; }): Promise { const fromPackageJson = workspacePatternsFromPackageJson( args.rootManifest?.value.workspaces, ); if (!args.filePaths.has("pnpm-workspace.yaml")) { return fromPackageJson; } let content: string | null; try { content = await args.input.readFile("pnpm-workspace.yaml"); } catch { args.warnings.push("Skipped unreadable pnpm-workspace.yaml."); return fromPackageJson; } if (content === null) return fromPackageJson; try { const parsed = parseYaml(content) as { packages?: unknown } | null; const fromPnpm = Array.isArray(parsed?.packages) ? parsed.packages.filter( (value): value is string => typeof value === "string", ) : []; return [...new Set([...fromPackageJson, ...fromPnpm])]; } catch { args.warnings.push("Skipped invalid YAML in pnpm-workspace.yaml."); return fromPackageJson; } } function workspacePatternsFromPackageJson(workspaces: unknown): string[] { if (Array.isArray(workspaces)) { return workspaces.filter( (value): value is string => typeof value === "string", ); } if ( workspaces && typeof workspaces === "object" && "packages" in workspaces && Array.isArray(workspaces.packages) ) { return workspaces.packages.filter( (value): value is string => typeof value === "string", ); } return []; } function matchesWorkspace(root: string, patterns: string[]): boolean { const included = patterns .filter((pattern) => !pattern.startsWith("!")) .some((pattern) => picomatch(pattern, { dot: true })(root)); const excluded = patterns .filter((pattern) => pattern.startsWith("!")) .some((pattern) => picomatch(pattern.slice(1), { dot: true })(root)); return included && !excluded; } function hasDistinctRootAppSignal( manifest: ParsedManifest, detectedApp: DetectedComputeApp, filePaths: Set, nestedAppRoots: string[], ): boolean { const descriptor = frameworkByKey(detectedApp.framework); if ( descriptor.detectConfigFiles.some((fileName) => filePaths.has(fileName)) ) { return true; } const entrypoint = detectedApp.entrypoint; if ( entrypoint && !nestedAppRoots.some((root) => isPathInside(entrypoint, root)) ) { return true; } const sourcePaths = [...filePaths].filter( (path) => !nestedAppRoots.some((root) => isPathInside(path, root)), ); switch (detectedApp.buildType) { case "nextjs": if ( sourcePaths.some((path) => ["app/", "pages/", "src/app/", "src/pages/"].some((prefix) => path.startsWith(prefix), ), ) ) { return true; } break; case "nuxt": if ( filePaths.has("app.vue") || sourcePaths.some( (path) => path.startsWith("pages/") || path.startsWith("server/"), ) ) { return true; } break; case "astro": if (sourcePaths.some((path) => path.startsWith("src/pages/"))) { return true; } break; case "nestjs": if (filePaths.has("src/main.ts") || filePaths.has("src/main.js")) { return true; } break; case "tanstack-start": if ( sourcePaths.some( (path) => path.startsWith("app/routes/") || path.startsWith("src/routes/"), ) ) { return true; } break; case "custom": case "bun": break; } const buildScript = readBuildScript(manifest.value.scripts); if (!buildScript) return false; switch (detectedApp.buildType) { case "nextjs": return /(^|\s)next\s+build(?:\s|$)/.test(buildScript); case "nuxt": return /(^|\s)(?:nuxt|nuxi)\s+build(?:\s|$)/.test(buildScript); case "astro": return /(^|\s)astro\s+build(?:\s|$)/.test(buildScript); case "nestjs": return /(^|\s)nest\s+build(?:\s|$)/.test(buildScript); case "tanstack-start": return /(^|\s)vite\s+build(?:\s|$)/.test(buildScript); case "custom": case "bun": return false; } } function inferApp(args: { repositoryName: string; filePaths: Set; rootManifest: ParsedManifest | undefined; manifest: ParsedManifest; detectedApp: DetectedComputeApp | null; }): DetectedComputeRepositoryApp { const packageName = typeof args.manifest.value.name === "string" ? args.manifest.value.name : undefined; const target = normalizeTarget( packageName ?? lastPathSegment(args.manifest.root) ?? args.repositoryName, ); const buildScript = readBuildScript(args.manifest.value.scripts); const packageManager = resolveRemotePackageManager({ manifest: args.manifest, rootManifest: args.rootManifest, filePaths: args.filePaths, }); const descriptor = frameworkByKey(args.detectedApp?.framework ?? "bun"); const inferredBuild = inferBuildSettings({ buildType: args.detectedApp?.buildType ?? descriptor.buildType, buildScript, packageManager, }); const entry = args.detectedApp?.entrypoint ?? ""; const detectionSource = args.detectedApp?.evidence.kind === "dependency" ? `${args.detectedApp.evidence.path} dependency ${args.detectedApp.evidence.value}` : (args.detectedApp?.evidence.path ?? "Bun fallback"); return { id: args.manifest.root || "root", target, name: target, root: args.manifest.root ? `./${args.manifest.root}` : "./", framework: descriptor.key, frameworkName: descriptor.displayName, httpPort: descriptor.defaultHttpPort, entry, build: { command: inferredBuild.command, outputDirectory: inferredBuild.outputDirectory, entrypoint: inferredBuild.entrypoint, }, inferredFrom: [ detectionSource, buildScript ? `${args.manifest.path} scripts.build` : inferredBuild.source, ].filter((value, index, values) => values.indexOf(value) === index), reviewRequired: args.detectedApp === null || (descriptor.usesEntrypoint && !entry), prismaNext: detectAppPrismaNext({ manifest: args.manifest.value, root: args.manifest.root, filePaths: args.filePaths, }), }; } /** * Detects the package manager declared at the repository root. * Reads the root packageManager field and lockfiles; warns on conflict. */ function detectRepoPackageManager(args: { rootManifest: ParsedManifest | undefined; filePaths: Set; warnings: string[]; }): DetectedPackageManager | null { const nameFromField = packageManagerFromField( args.rootManifest?.value.packageManager, ); let lockfile: string | null = null; let nameFromLockfile: "npm" | "pnpm" | "yarn" | "bun" | null = null; for (const [file, name] of LOCKFILE_ENTRIES) { if (args.filePaths.has(file)) { lockfile = file; nameFromLockfile = name; break; } } if (nameFromField) { if (nameFromLockfile && nameFromField !== nameFromLockfile) { args.warnings.push( `The packageManager field declares "${nameFromField}" but lockfile "${lockfile}" belongs to "${nameFromLockfile}". Using the packageManager field.`, ); } return { name: nameFromField, lockfile }; } if (nameFromLockfile) { return { name: nameFromLockfile, lockfile }; } return null; } /** * Detects Prisma Next usage for one app. * * Signals checked (in order): * 1. prisma-next.config.ts at the app root — the definitive Prisma Next config file. * 2. Any @prisma-next/* scoped package in dependencies or devDependencies. * 3. The prisma-next bare package (CLI tool) in devDependencies. * * Earlier Prisma clients (@prisma/client, prisma) are not part of this system * and are deliberately ignored here. */ function detectAppPrismaNext(args: { manifest: ComputeAppManifest; root: string; filePaths: Set; }): DetectedComputeRepositoryApp["prismaNext"] { // Config file is the strongest single-file signal. const configFile = joinRepoPath(args.root, "prisma-next.config.ts"); if (args.filePaths.has(configFile)) { return { detected: true, inferredFrom: [configFile] }; } // Any @prisma-next/* scoped package or the bare prisma-next CLI. const deps = asRecord(args.manifest.dependencies); const devDeps = asRecord(args.manifest.devDependencies); const manifestPath = joinRepoPath(args.root, "package.json"); for (const name of [...Object.keys(deps), ...Object.keys(devDeps)]) { if (name === "prisma-next" || name.startsWith("@prisma-next/")) { return { detected: true, inferredFrom: [`${manifestPath} dependency ${name}`], }; } } return { detected: false, inferredFrom: [] }; } /** Coerces an unknown value to a string-keyed record, returning {} for non-objects. */ function asRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } function inferBuildSettings(args: { buildType: FrameworkBuildType; buildScript: string | null; packageManager: string | null; }): DetectedComputeRepositoryApp["build"] & { source: string } { const scriptCommand = args.buildScript ? args.packageManager ? `${args.packageManager} run build` : args.buildScript : null; switch (args.buildType) { case "nextjs": return { command: scriptCommand ?? "next build", outputDirectory: "", entrypoint: "", source: "Next.js build strategy", }; case "nuxt": return { command: scriptCommand ?? "nuxt build", outputDirectory: ".output", entrypoint: "", source: "Nuxt defaults", }; case "astro": return { command: scriptCommand ?? "astro build", outputDirectory: "dist", entrypoint: "", source: "Astro defaults", }; case "nestjs": return { command: scriptCommand ?? "nest build", outputDirectory: "dist", entrypoint: "", source: "NestJS defaults", }; case "tanstack-start": return { command: scriptCommand ?? "vite build", outputDirectory: ".output", entrypoint: "", source: "TanStack Start defaults", }; case "custom": return { command: scriptCommand, outputDirectory: "dist", entrypoint: "server.js", source: "Custom defaults", }; case "bun": return { command: scriptCommand, outputDirectory: ".", entrypoint: "", source: "Bun defaults", }; } } function resolveRemotePackageManager(args: { manifest: ParsedManifest; rootManifest: ParsedManifest | undefined; filePaths: Set; }): "npm" | "pnpm" | "yarn" | "bun" | null { const fromField = packageManagerFromField(args.manifest.value.packageManager); if (fromField) return fromField; const fromRoot = packageManagerFromField( args.rootManifest?.value.packageManager, ); if (fromRoot) return fromRoot; for (const root of pathLineage(args.manifest.root)) { if ( args.filePaths.has(joinRepoPath(root, "bun.lock")) || args.filePaths.has(joinRepoPath(root, "bun.lockb")) ) { return "bun"; } if (args.filePaths.has(joinRepoPath(root, "pnpm-lock.yaml"))) return "pnpm"; if (args.filePaths.has(joinRepoPath(root, "yarn.lock"))) return "yarn"; if (args.filePaths.has(joinRepoPath(root, "package-lock.json"))) { return "npm"; } } return null; } function packageManagerFromField( value: unknown, ): "npm" | "pnpm" | "yarn" | "bun" | null { if (typeof value !== "string") return null; const name = value.split("@", 1)[0]; return name === "bun" || name === "pnpm" || name === "yarn" || name === "npm" ? name : null; } function readBuildScript(scripts: unknown): string | null { if (!scripts || typeof scripts !== "object" || !("build" in scripts)) { return null; } return typeof scripts.build === "string" && scripts.build.trim() ? scripts.build.trim() : null; } function pathLineage(root: string): string[] { const parts = root.split("/").filter(Boolean); const lineage: string[] = []; for (let length = parts.length; length >= 0; length--) { lineage.push(parts.slice(0, length).join("/")); } return lineage; } function directoryName(path: string): string { const index = path.lastIndexOf("/"); return index === -1 ? "" : path.slice(0, index); } function lastPathSegment(path: string): string | null { return path.split("/").filter(Boolean).at(-1) ?? null; } function pathDepth(path: string): number { return path.split("/").length; } function joinRepoPath(...parts: string[]): string { return parts.filter(Boolean).join("/").replace(/\/+/g, "/"); } function isPathInside(path: string, root: string): boolean { return path === root || path.startsWith(`${root}/`); } function normalizeTarget(value: string): string { const unscoped = value.split("/").filter(Boolean).at(-1) ?? "app"; const normalized = unscoped .toLowerCase() .replace(/[^a-z0-9-]+/g, "-") .replace(/^-+|-+$/g, ""); return normalized || "app"; } function uniquifyTargets(apps: DetectedComputeRepositoryApp[]): void { const reservedTargets = new Set(apps.map((app) => app.target)); const usedTargets = new Set(); for (const app of apps) { const baseTarget = app.target; let target = baseTarget; let suffix = 2; while (usedTargets.has(target)) { do { target = `${baseTarget}-${suffix}`; suffix += 1; } while (usedTargets.has(target) || reservedTargets.has(target)); } usedTargets.add(target); app.target = target; app.name = target; } }