From f39349b059d8bc7bdaee86f758f68ed4f4714377 Mon Sep 17 00:00:00 2001 From: Nico Prananta <311343+nicnocquee@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:19:06 +0700 Subject: [PATCH] Implement project root detection and enhance route path resolution in CLI - Added a new `findProjectRoot` function to locate the project root directory by searching for `package.json` in parent directories. - Updated `resolveRoutePath` methods in `NextAppRouterGenerator` and `NextPagesRouterGenerator` to utilize the detected project root for more accurate route path resolution. - Enhanced tests for route path resolution to cover various scenarios, including multiple `/app/` segments and projects using a `src` directory structure. - Introduced a new test file for `findProjectRoot` to ensure its functionality and correctness. These changes improve the flexibility and accuracy of route path generation in the CLI, particularly for complex project structures. --- .../src/cli/find-project-root.test.ts | 72 +++++++++++++++++++ .../src/cli/find-project-root.ts | 31 ++++++++ .../src/cli/frameworks/next-app-router.ts | 25 +++++-- .../src/cli/frameworks/next-pages-router.ts | 25 +++++-- packages/route-action-gen/src/cli/index.ts | 6 +- packages/route-action-gen/src/cli/types.ts | 9 ++- packages/route-action-gen/src/index.test.ts | 43 +++++++++++ 7 files changed, 199 insertions(+), 12 deletions(-) create mode 100644 packages/route-action-gen/src/cli/find-project-root.test.ts create mode 100644 packages/route-action-gen/src/cli/find-project-root.ts diff --git a/packages/route-action-gen/src/cli/find-project-root.test.ts b/packages/route-action-gen/src/cli/find-project-root.test.ts new file mode 100644 index 0000000..59b419b --- /dev/null +++ b/packages/route-action-gen/src/cli/find-project-root.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { findProjectRoot } from "./find-project-root.js"; +import path from "node:path"; + +describe("findProjectRoot", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns the directory that contains package.json when walking up", () => { + // Setup: only /project has package.json + const projectRoot = path.resolve("/project"); + const configDir = path.join(projectRoot, "app", "dashboard", "tickers"); + const existsSync = vi.fn((filePath: string) => { + return filePath === path.join(projectRoot, "package.json"); + }); + + // Act + const result = findProjectRoot(configDir, existsSync); + + // Assert + expect(result).toBe(projectRoot); + expect(existsSync).toHaveBeenCalledWith( + path.join(projectRoot, "package.json"), + ); + }); + + it("returns the first ancestor with package.json when multiple exist", () => { + // Setup: both /project and /project/app have package.json; we want the nearest (first found when walking up) + const projectRoot = path.resolve("/project"); + const appDir = path.join(projectRoot, "app"); + const configDir = path.join(appDir, "dashboard"); + const existsSync = vi.fn((filePath: string) => { + return ( + filePath === path.join(projectRoot, "package.json") || + filePath === path.join(appDir, "package.json") + ); + }); + + // Act + const result = findProjectRoot(configDir, existsSync); + + // Assert: should find app's package.json first (nearest when walking up) + expect(result).toBe(appDir); + }); + + it("returns null when no ancestor has package.json", () => { + // Setup: no directory has package.json + const configDir = path.resolve("/some", "deep", "directory"); + const existsSync = vi.fn(() => false); + + // Act + const result = findProjectRoot(configDir, existsSync); + + // Assert + expect(result).toBeNull(); + }); + + it("returns the given directory when it contains package.json", () => { + // Setup: the directory itself has package.json + const projectRoot = path.resolve("/project"); + const existsSync = vi.fn((filePath: string) => { + return filePath === path.join(projectRoot, "package.json"); + }); + + // Act + const result = findProjectRoot(projectRoot, existsSync); + + // Assert + expect(result).toBe(projectRoot); + }); +}); diff --git a/packages/route-action-gen/src/cli/find-project-root.ts b/packages/route-action-gen/src/cli/find-project-root.ts new file mode 100644 index 0000000..5bf2196 --- /dev/null +++ b/packages/route-action-gen/src/cli/find-project-root.ts @@ -0,0 +1,31 @@ +import path from "node:path"; + +/** + * Finds the project root by walking up from the given directory until a + * directory containing package.json is found. + * + * @param directory - Absolute path to start from (e.g. config directory). + * @param existsSync - Function to check if a file path exists (e.g. fs.existsSync). + * @returns The absolute path of the directory containing package.json, or null if not found. + */ +export function findProjectRoot( + directory: string, + existsSync: (filePath: string) => boolean, +): string | null { + let current = path.resolve(directory); + const root = path.parse(current).root; + + while (current !== root) { + const packagePath = path.join(current, "package.json"); + if (existsSync(packagePath)) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + + return null; +} diff --git a/packages/route-action-gen/src/cli/frameworks/next-app-router.ts b/packages/route-action-gen/src/cli/frameworks/next-app-router.ts index 703be3f..09b34a8 100644 --- a/packages/route-action-gen/src/cli/frameworks/next-app-router.ts +++ b/packages/route-action-gen/src/cli/frameworks/next-app-router.ts @@ -59,13 +59,30 @@ export class NextAppRouterGenerator implements FrameworkGenerator { }; } - resolveRoutePath(directory: string): string { - // Find the 'app/' segment in the path and use everything after it - const appIndex = directory.indexOf("/app/"); + resolveRoutePath(directory: string, projectRoot?: string | null): string { + if (projectRoot) { + const normalizedDir = path.resolve(directory); + // Next.js supports both app/ and src/app/ at project root + const appDirCandidates = [ + path.join(projectRoot, "app"), + path.join(projectRoot, "src", "app"), + ]; + for (const appDir of appDirCandidates) { + const normalizedAppDir = path.resolve(appDir); + if ( + normalizedDir === normalizedAppDir || + normalizedDir.startsWith(normalizedAppDir + path.sep) + ) { + const relative = path.relative(normalizedAppDir, normalizedDir); + return "/" + relative.split(path.sep).join("/"); + } + } + } + // Fallback: use the last 'app/' segment so Docker WORKDIR /app or parent /app/ still work + const appIndex = directory.lastIndexOf("/app/"); if (appIndex !== -1) { return directory.slice(appIndex + "/app".length); } - // Fallback: use the last path segment const parts = directory.split("/"); return "/" + parts[parts.length - 1]; } diff --git a/packages/route-action-gen/src/cli/frameworks/next-pages-router.ts b/packages/route-action-gen/src/cli/frameworks/next-pages-router.ts index 0346738..7ac77a7 100644 --- a/packages/route-action-gen/src/cli/frameworks/next-pages-router.ts +++ b/packages/route-action-gen/src/cli/frameworks/next-pages-router.ts @@ -60,13 +60,30 @@ export class NextPagesRouterGenerator implements FrameworkGenerator { }; } - resolveRoutePath(directory: string): string { - // Find the 'pages/' segment in the path and use everything after it - const pagesIndex = directory.indexOf("/pages/"); + resolveRoutePath(directory: string, projectRoot?: string | null): string { + if (projectRoot) { + const normalizedDir = path.resolve(directory); + // Next.js supports both pages/ and src/pages/ at project root + const pagesDirCandidates = [ + path.join(projectRoot, "pages"), + path.join(projectRoot, "src", "pages"), + ]; + for (const pagesDir of pagesDirCandidates) { + const normalizedPagesDir = path.resolve(pagesDir); + if ( + normalizedDir === normalizedPagesDir || + normalizedDir.startsWith(normalizedPagesDir + path.sep) + ) { + const relative = path.relative(normalizedPagesDir, normalizedDir); + return "/" + relative.split(path.sep).join("/"); + } + } + } + // Fallback: use the last 'pages/' segment for consistency with app router + const pagesIndex = directory.lastIndexOf("/pages/"); if (pagesIndex !== -1) { return directory.slice(pagesIndex + "/pages".length); } - // Fallback: use the last path segment const parts = directory.split("/"); return "/" + parts[parts.length - 1]; } diff --git a/packages/route-action-gen/src/cli/index.ts b/packages/route-action-gen/src/cli/index.ts index 0792500..2fbeb22 100644 --- a/packages/route-action-gen/src/cli/index.ts +++ b/packages/route-action-gen/src/cli/index.ts @@ -25,6 +25,7 @@ import { } from "./frameworks/index.js"; import type { CliDeps, GenerationContext, HttpMethod } from "./types.js"; import { createConfigFile, isValidMethod } from "./create.js"; +import { findProjectRoot } from "./find-project-root.js"; // Re-export modules for testing export { scanConfigFiles } from "./scanner.js"; @@ -252,8 +253,9 @@ export function generate( return parseConfigFile(content, scanned.method, scanned.fileName); }); - // Compute the route path using the framework generator - const routePath = generator.resolveRoutePath(group.directory); + // Compute the route path using the framework generator (project root from package.json when available) + const projectRoot = findProjectRoot(group.directory, deps.existsSync); + const routePath = generator.resolveRoutePath(group.directory, projectRoot); // Resolve the generated output directory (may differ from config dir) const generatedDir = generator.resolveGeneratedDir(group.directory, cwd); diff --git a/packages/route-action-gen/src/cli/types.ts b/packages/route-action-gen/src/cli/types.ts index 64b2c86..7cb90bc 100644 --- a/packages/route-action-gen/src/cli/types.ts +++ b/packages/route-action-gen/src/cli/types.ts @@ -87,9 +87,14 @@ export interface FrameworkGenerator { name: string; /** * Compute the route path from a config file's absolute directory path. - * e.g. "/abs/path/app/api/posts/[postId]" -> "/api/posts/[postId]" + * When projectRoot is provided (e.g. from findProjectRoot), the route path + * is relative to projectRoot/app (or projectRoot/pages). Otherwise falls back + * to path heuristics. e.g. "/abs/path/app/api/posts/[postId]" -> "/api/posts/[postId]" + * + * @param directory - Absolute path to the config directory. + * @param projectRoot - Optional directory containing package.json; when set, route path is derived from it. */ - resolveRoutePath(directory: string): string; + resolveRoutePath(directory: string, projectRoot?: string | null): string; /** * Resolve the absolute path of the generated output directory. * diff --git a/packages/route-action-gen/src/index.test.ts b/packages/route-action-gen/src/index.test.ts index fd4e1a1..e46f42c 100644 --- a/packages/route-action-gen/src/index.test.ts +++ b/packages/route-action-gen/src/index.test.ts @@ -446,6 +446,38 @@ describe("NextAppRouterGenerator", () => { generator.resolveRoutePath("/project/apps/web/app/api/users"), ).toBe("/api/users"); }); + + it("uses projectRoot when path contains /app/ more than once (Docker or monorepo)", () => { + // Act + const result = generator.resolveRoutePath( + "/app/apps/hermes/app/dashboard/tickers/actions/import", + "/app/apps/hermes", + ); + + // Assert: route path is relative to projectRoot/app, not the first /app/ + expect(result).toBe("/dashboard/tickers/actions/import"); + }); + + it("falls back to lastIndexOf when projectRoot is not provided", () => { + // Act: same path as above but no projectRoot + const result = generator.resolveRoutePath( + "/app/apps/hermes/app/dashboard/tickers/actions/import", + ); + + // Assert: fallback uses last /app/ so we still get the Next.js route path + expect(result).toBe("/dashboard/tickers/actions/import"); + }); + + it("uses projectRoot with src/app when Next.js project uses src directory", () => { + // Act + const result = generator.resolveRoutePath( + "/project/src/app/api/users", + "/project", + ); + + // Assert: route path is relative to projectRoot/src/app + expect(result).toBe("/api/users"); + }); }); describe("generate", () => { @@ -705,6 +737,17 @@ describe("NextPagesRouterGenerator", () => { "/api/users", ); }); + + it("uses projectRoot with src/pages when Next.js project uses src directory", () => { + // Act + const result = generator.resolveRoutePath( + "/project/src/pages/api/users", + "/project", + ); + + // Assert: route path is relative to projectRoot/src/pages + expect(result).toBe("/api/users"); + }); }); describe("generate", () => {