From 8a10d87ce88e7f8bf70bd1d752079b1d62b8672f Mon Sep 17 00:00:00 2001 From: zoomdong <1344492820@qq.com> Date: Tue, 27 Oct 2026 11:41:50 +0800 Subject: [PATCH 1/2] feat(pack): centralize root path configuration for worktrees --- packages/pack-cli/README.md | 15 ++ packages/pack/README.md | 25 ++++ .../pack/src/__test__/projectRootPath.test.ts | 138 ++++++++++++++++++ .../pack/src/__test__/resolveRootPath.test.ts | 75 ++++++++++ packages/pack/src/__test__/rootPath.test.ts | 111 ++++++++++++++ packages/pack/src/commands/build.ts | 7 +- packages/pack/src/commands/dev.ts | 6 +- packages/pack/src/core/project.ts | 17 ++- packages/pack/src/core/types.ts | 4 +- packages/pack/src/utils/env.ts | 7 + packages/pack/src/utils/findRoot.ts | 4 + packages/pack/src/utils/resolveRootPath.ts | 13 ++ 12 files changed, 408 insertions(+), 14 deletions(-) create mode 100644 packages/pack/src/__test__/projectRootPath.test.ts create mode 100644 packages/pack/src/__test__/resolveRootPath.test.ts create mode 100644 packages/pack/src/__test__/rootPath.test.ts create mode 100644 packages/pack/src/utils/resolveRootPath.ts diff --git a/packages/pack-cli/README.md b/packages/pack-cli/README.md index 84d832768e..1736eadf7f 100644 --- a/packages/pack-cli/README.md +++ b/packages/pack-cli/README.md @@ -61,6 +61,21 @@ Build your project for production: utx up build ``` +### Worktree and Symlink Debugging + +To reuse `node_modules` from another checkout or debug a linked dependency, +set the root to a common parent containing both the project and the real +dependency paths: + +```bash +UTOO_ROOT_PATH=/path/to/common-parent utx up dev +UTOO_ROOT_PATH=.. utx up build +``` + +`UTOO_ROOT_PATH` overrides `--root`, config `rootPath`, and automatic root +detection. Relative environment paths resolve from the current working +directory; an empty value is ignored. The project directory stays unchanged. + ### Webpack Compatibility Mode If you have an existing `webpack.config.js`, you can run `@utoo/pack` in compatibility mode: diff --git a/packages/pack/README.md b/packages/pack/README.md index 23ca031b06..4b4d5d0013 100644 --- a/packages/pack/README.md +++ b/packages/pack/README.md @@ -159,6 +159,31 @@ Node.js, or library builds. } ``` +### Worktree and Symlink Debugging + +Set `UTOO_ROOT_PATH` to temporarily override the bundler's filesystem root when +debugging in a worktree that shares `node_modules`, or when linking a local +dependency outside the project: + +```bash +UTOO_ROOT_PATH=/path/to/common-parent utx up dev +# Relative paths resolve from the current working directory: +UTOO_ROOT_PATH=.. utx up build +# Framework integrations use the same override: +UTOO_ROOT_PATH=/path/to/common-parent smallfish dev +``` + +The root must contain both the project and the real paths of its linked +dependencies. The project directory stays unchanged. Precedence is +`UTOO_ROOT_PATH` > explicit `rootPath` (including CLI `--root` and config +`rootPath`) > automatic root detection. An empty environment variable is ignored. + +Utoopack applies this policy when creating projects, including direct +`projectFactory()` callers. Frameworks can omit `rootPath` and let Utoopack +detect it; they do not need to call `findRootDir()` themselves. For integrations +that still call `findRootDir()`, it respects the same environment override. +Updates that omit `rootPath` retain the project's existing root. + ## 🛠️ Development ### Prerequisites diff --git a/packages/pack/src/__test__/projectRootPath.test.ts b/packages/pack/src/__test__/projectRootPath.test.ts new file mode 100644 index 0000000000..6b3168efd1 --- /dev/null +++ b/packages/pack/src/__test__/projectRootPath.test.ts @@ -0,0 +1,138 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const bindingMocks = vi.hoisted(() => ({ + projectNew: vi.fn(), + projectUpdate: vi.fn(), + registerWorkerScheduler: undefined, +})); + +vi.mock("../binding", () => bindingMocks); + +import { projectFactory } from "../core/project"; +import { normalizePath } from "../utils/normalizePath"; + +const nativeProject = { __napiType: "Project" as const }; +let fixture: string; +let projectPath: string; + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("UTOO_ROOT_PATH", undefined); + bindingMocks.projectNew.mockResolvedValue(nativeProject); + bindingMocks.projectUpdate.mockResolvedValue(undefined); + fixture = fs.mkdtempSync(path.join(os.tmpdir(), "utoo-project-root-")); + projectPath = path.join(fixture, "worktree", "app"); + fs.mkdirSync(projectPath, { recursive: true }); + fs.writeFileSync(path.join(fixture, "package-lock.json"), "{}"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(fixture, { recursive: true, force: true }); +}); + +function createProject(rootPath?: string) { + return projectFactory()( + { + projectPath, + rootPath, + config: { entry: [] }, + processEnv: {}, + watch: { enable: false }, + dev: false, + buildId: "root-path-test", + tracing: false, + packPath: fixture, + }, + {}, + ); +} + +function expectNativeRoot(rootPath: string) { + expect(bindingMocks.projectNew).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + projectPath: normalizePath(projectPath), + rootPath: normalizePath(rootPath), + }), + {}, + expect.any(Object), + ); +} + +describe("project root configuration", () => { + it("overrides an explicit root before passing options to NAPI", async () => { + vi.stubEnv("UTOO_ROOT_PATH", fixture); + + await createProject(projectPath); + + expectNativeRoot(fixture); + }); + + it("resolves relative environment roots from cwd", async () => { + vi.stubEnv("UTOO_ROOT_PATH", path.relative(process.cwd(), fixture)); + + await createProject(projectPath); + + expectNativeRoot(fixture); + }); + + it.each([undefined, ""])( + "preserves an explicit root when the environment is %s", + async (value) => { + vi.stubEnv("UTOO_ROOT_PATH", value); + + await createProject(projectPath); + + expectNativeRoot(projectPath); + }, + ); + + it.each([undefined, ""])( + "detects the root for direct project creation when the environment is %s", + async (value) => { + vi.stubEnv("UTOO_ROOT_PATH", value); + + await createProject(); + + expectNativeRoot(fixture); + }, + ); + + it("preserves the existing root during config-only updates", async () => { + const project = await createProject(projectPath); + vi.stubEnv("UTOO_ROOT_PATH", fixture); + + await project.update({ config: { entry: [] } }); + + expect(bindingMocks.projectUpdate).toHaveBeenCalledExactlyOnceWith( + nativeProject, + expect.objectContaining({ rootPath: undefined }), + ); + }); + + it("applies the environment override to explicit root updates", async () => { + const project = await createProject(projectPath); + vi.stubEnv("UTOO_ROOT_PATH", fixture); + + await project.update({ rootPath: projectPath }); + + expect(bindingMocks.projectUpdate).toHaveBeenCalledExactlyOnceWith( + nativeProject, + expect.objectContaining({ rootPath: normalizePath(fixture) }), + ); + }); + + it("preserves explicit root updates without an environment override", async () => { + const project = await createProject(projectPath); + + await project.update({ rootPath: fixture }); + + expect(bindingMocks.projectUpdate).toHaveBeenCalledExactlyOnceWith( + nativeProject, + expect.objectContaining({ rootPath: normalizePath(fixture) }), + ); + }); +}); diff --git a/packages/pack/src/__test__/resolveRootPath.test.ts b/packages/pack/src/__test__/resolveRootPath.test.ts new file mode 100644 index 0000000000..14ab4545a9 --- /dev/null +++ b/packages/pack/src/__test__/resolveRootPath.test.ts @@ -0,0 +1,75 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { findRootDir } from "../utils/findRoot"; +import { resolveRootPath } from "../utils/resolveRootPath"; + +let fixture: string; +let projectPath: string; + +beforeEach(() => { + vi.stubEnv("UTOO_ROOT_PATH", undefined); + fixture = fs.mkdtempSync(path.join(os.tmpdir(), "utoo-root-path-")); + projectPath = path.join(fixture, "worktree"); + fs.mkdirSync(projectPath); + fs.writeFileSync(path.join(projectPath, "package-lock.json"), "{}"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(fixture, { recursive: true, force: true }); +}); + +describe("resolveRootPath", () => { + it("overrides the configured root with a common worktree parent", () => { + vi.stubEnv("UTOO_ROOT_PATH", fixture); + + expect(resolveRootPath(projectPath, projectPath)).toBe(fixture); + }); + + it("resolves relative environment paths from cwd, independently of projectPath", () => { + vi.stubEnv("UTOO_ROOT_PATH", ".."); + + expect(resolveRootPath(projectPath)).toBe(path.resolve("..")); + }); + + it.each(["absolute", "relative"])( + "uses the %s environment root for integrations that call findRootDir directly", + (kind) => { + const rootPath = + kind === "absolute" ? fixture : path.relative(process.cwd(), fixture); + vi.stubEnv("UTOO_ROOT_PATH", rootPath); + + expect(findRootDir(projectPath)).toBe(fixture); + expect(resolveRootPath(projectPath, projectPath)).toBe(fixture); + }, + ); + + it.each([undefined, ""])( + "preserves the configured root when the environment is %s", + (value) => { + vi.stubEnv("UTOO_ROOT_PATH", value); + + expect(resolveRootPath(projectPath, fixture)).toBe(fixture); + }, + ); + + it.each([undefined, ""])( + "detects the project root when the environment is %s and no root is configured", + (value) => { + vi.stubEnv("UTOO_ROOT_PATH", value); + + expect(resolveRootPath(projectPath)).toBe(projectPath); + }, + ); + + it("falls back to detecting the root from cwd when projectPath is omitted", () => { + const cwd = vi.spyOn(process, "cwd").mockReturnValue(projectPath); + try { + expect(resolveRootPath()).toBe(projectPath); + } finally { + cwd.mockRestore(); + } + }); +}); diff --git a/packages/pack/src/__test__/rootPath.test.ts b/packages/pack/src/__test__/rootPath.test.ts new file mode 100644 index 0000000000..16aafb2fb0 --- /dev/null +++ b/packages/pack/src/__test__/rootPath.test.ts @@ -0,0 +1,111 @@ +import path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const rootMocks = vi.hoisted(() => { + const stopped = new Error("Stop before starting the bundler"); + return { + stopped, + createProject: vi.fn(async () => { + throw stopped; + }), + createHotReloader: vi.fn(async () => { + throw stopped; + }), + resolveBundleOptions: vi.fn(() => ({ + config: { entry: [], persistentCaching: false }, + })), + }; +}); + +vi.mock("../core/project", () => ({ + projectFactory: () => rootMocks.createProject, +})); +vi.mock("../core/hmr", () => ({ + createHotReloader: rootMocks.createHotReloader, +})); +vi.mock("../config/webpackCompat", () => ({ + resolveBundleOptions: rootMocks.resolveBundleOptions, +})); +vi.mock("../utils/common", () => ({ + blockStdout: vi.fn(), + getPackPath: () => "/fixture/pack", +})); +vi.mock("../utils/cleanOutput", () => ({ + cleanOutput: vi.fn(async () => {}), + getOutputPath: vi.fn(), +})); +vi.mock("../utils/lockfile", () => ({ + acquirePersistentCacheLock: vi.fn(async () => undefined), +})); +vi.mock("get-port", () => ({ default: async () => 3000 })); +vi.mock("domparser-rs", () => ({ DOMParser: vi.fn() })); + +import { build } from "../commands/build"; +import { serve } from "../commands/dev"; + +const projectPath = path.resolve("/fixture/worktree/app"); +const configuredRoot = path.resolve("/fixture/worktree"); +const environmentRoot = path.resolve("/fixture"); +const originalTitle = process.title; + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("UTOO_ROOT_PATH", undefined); + vi.stubEnv("XCODE_PROFILE", undefined); + vi.stubEnv("NODE_ENV", process.env.NODE_ENV); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + process.title = originalTitle; +}); + +describe.each([ + ["build", build], + ["serve", serve], +] as const)("%s root path", (name, command) => { + async function expectRoot( + expectedRoot: string, + options: Parameters[0] = { config: { entry: [] } }, + ) { + await expect(command(options, projectPath, configuredRoot)).rejects.toBe( + rootMocks.stopped, + ); + + if (name === "build") { + expect(rootMocks.createProject).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ rootPath: expectedRoot }), + expect.any(Object), + ); + } else { + expect(rootMocks.createHotReloader).toHaveBeenCalledExactlyOnceWith( + expect.any(Object), + projectPath, + expectedRoot, + ); + } + } + + it("passes the environment root instead of the configured root", async () => { + vi.stubEnv("UTOO_ROOT_PATH", environmentRoot); + + await expectRoot(environmentRoot); + }); + + it("preserves the configured root when the environment is unset", async () => { + await expectRoot(configuredRoot); + }); + + it("loads webpack configuration using the original root", async () => { + vi.stubEnv("UTOO_ROOT_PATH", environmentRoot); + const options = { webpackMode: true }; + + await expectRoot(environmentRoot, options); + + expect(rootMocks.resolveBundleOptions).toHaveBeenCalledExactlyOnceWith( + options, + projectPath, + configuredRoot, + ); + }); +}); diff --git a/packages/pack/src/commands/build.ts b/packages/pack/src/commands/build.ts index e5ebf8c8e0..638b197c4b 100644 --- a/packages/pack/src/commands/build.ts +++ b/packages/pack/src/commands/build.ts @@ -14,11 +14,11 @@ import { isTruthyEnv, normalizeTurbopackMemoryEviction, } from "../utils/env"; -import { findRootDir } from "../utils/findRoot"; import { HtmlGenerationManager } from "../utils/HtmlGenerationManager"; import { processHtmlEntry } from "../utils/htmlEntry"; import { acquirePersistentCacheLock } from "../utils/lockfile"; import { normalizePath } from "../utils/normalizePath"; +import { resolveRootPath } from "../utils/resolveRootPath"; import { useWorkerThreads } from "../utils/runtimePluginStratety"; import { formatDuration, @@ -37,10 +37,7 @@ export function build( ) { const bundleOptions = resolveBundleOptions(options, projectPath, rootPath); - if (!rootPath) { - // help user to find the rootDir automatically. - rootPath = findRootDir(projectPath || process.cwd()); - } + rootPath = resolveRootPath(projectPath, rootPath); return buildInternal(bundleOptions, projectPath, rootPath); } diff --git a/packages/pack/src/commands/dev.ts b/packages/pack/src/commands/dev.ts index c8a65e5881..94535a6a9b 100644 --- a/packages/pack/src/commands/dev.ts +++ b/packages/pack/src/commands/dev.ts @@ -24,9 +24,9 @@ import { createHttpProxyMiddleware } from "../core/proxyHono"; import type { Project } from "../core/types"; import { getOutputPath } from "../utils/cleanOutput"; import { blockStdout, getPackPath } from "../utils/common"; -import { findRootDir } from "../utils/findRoot"; import { createSelfSignedCertificate } from "../utils/mkcert"; import { printServerInfo } from "../utils/printServerInfo"; +import { resolveRootPath } from "../utils/resolveRootPath"; import { xcodeProfilingReady } from "../utils/xcodeProfile"; // --- Path helpers (same logic as dev.ts, not exported) --- @@ -286,9 +286,7 @@ export function serve( ): Promise { const bundleOptions = resolveBundleOptions(options, projectPath, rootPath); - if (!rootPath) { - rootPath = findRootDir(projectPath || process.cwd()); - } + rootPath = resolveRootPath(projectPath, rootPath); return runDev(bundleOptions, projectPath, rootPath, serverOptions).then( (handle) => { if (serverOptions?.returnHandle) return handle; diff --git a/packages/pack/src/core/project.ts b/packages/pack/src/core/project.ts index aee8668ac6..4c80b7c170 100644 --- a/packages/pack/src/core/project.ts +++ b/packages/pack/src/core/project.ts @@ -21,6 +21,7 @@ import { } from "../config/types"; import { getPackPath, rustifyEnv } from "../utils/common"; import { normalizePath } from "../utils/normalizePath"; +import { resolveRootPath } from "../utils/resolveRootPath"; import { DiagnosticSources } from "./diagnosticSources"; import { runLoaderWorkerPool } from "./loaderWorkerPool"; import { @@ -31,6 +32,9 @@ import { Update, } from "./types"; +type ProjectCreationOptions = Omit, "rootPath"> & + Pick; + /** * An error caused by a bug in Turbopack, and not the user's code (e.g. a Rust panic). These should * be written to a log file and details should not be shown to the user. @@ -384,7 +388,10 @@ async function rustifyPartialProjectOptions( ): Promise { return { ...options, - rootPath: normalizePathOption(options.rootPath), + rootPath: + options.rootPath === undefined + ? undefined + : normalizePath(resolveRootPath(options.projectPath, options.rootPath)), projectPath: normalizePathOption(options.projectPath), packPath: normalizePathOption(options.packPath), config: @@ -407,11 +414,13 @@ function normalizePathOption(pathLike: string | undefined): string | undefined { } async function rustifyProjectOptions( - options: Required, + options: ProjectCreationOptions, ): Promise { return { ...options, - rootPath: normalizePath(options.rootPath), + rootPath: normalizePath( + resolveRootPath(options.projectPath, options.rootPath), + ), projectPath: normalizePath(options.projectPath), packPath: normalizePath(options.packPath), config: await serializeConfig(options.config, options.dev), @@ -767,7 +776,7 @@ export function projectFactory(endpointWatchOptions?: { } return async function createProject( - options: Required, + options: ProjectCreationOptions, turboEngineOptions: binding.NapiTurboEngineOptions, ) { return new ProjectImpl( diff --git a/packages/pack/src/core/types.ts b/packages/pack/src/core/types.ts index 01300978b6..325a9810ba 100644 --- a/packages/pack/src/core/types.ts +++ b/packages/pack/src/core/types.ts @@ -48,8 +48,10 @@ export interface ProjectOptions extends BundleOptions { /** * A root path from which all files must be nested under. Trying to access * a file outside this root will fail. Think of this as a chroot. + * Defaults to automatic root detection from projectPath. UTOO_ROOT_PATH + * overrides both explicit paths and automatic detection. */ - rootPath: string; + rootPath?: string; /** * A path inside the root_path which contains the app/pages directories. diff --git a/packages/pack/src/utils/env.ts b/packages/pack/src/utils/env.ts index 33b01fb1e8..77405d4d26 100644 --- a/packages/pack/src/utils/env.ts +++ b/packages/pack/src/utils/env.ts @@ -1,3 +1,10 @@ +import path from "path"; + +export function getRootPathFromEnv(): string | undefined { + const rootPath = process.env.UTOO_ROOT_PATH; + return rootPath ? path.resolve(rootPath) : undefined; +} + export function isTruthyEnv(value: string | undefined): boolean { return value === "1" || value === "true"; } diff --git a/packages/pack/src/utils/findRoot.ts b/packages/pack/src/utils/findRoot.ts index 587ee7ce2a..e071d29337 100644 --- a/packages/pack/src/utils/findRoot.ts +++ b/packages/pack/src/utils/findRoot.ts @@ -1,6 +1,7 @@ import findUp from "find-up"; import fs from "fs"; import path from "path"; +import { getRootPathFromEnv } from "./env"; export function findRootLockFile(cwd: string) { return findUp.sync( @@ -56,6 +57,9 @@ export function findWorkspacesRoot(cwd: string) { } export function findRootDir(cwd: string): string { + const envRootPath = getRootPathFromEnv(); + if (envRootPath) return envRootPath; + const lockFile = findRootLockFile(cwd); if (!lockFile) return findWorkspacesRoot(cwd); diff --git a/packages/pack/src/utils/resolveRootPath.ts b/packages/pack/src/utils/resolveRootPath.ts new file mode 100644 index 0000000000..98793f1842 --- /dev/null +++ b/packages/pack/src/utils/resolveRootPath.ts @@ -0,0 +1,13 @@ +import { getRootPathFromEnv } from "./env"; +import { findRootDir } from "./findRoot"; + +export function resolveRootPath( + projectPath?: string, + rootPath?: string, +): string { + return ( + getRootPathFromEnv() || + rootPath || + findRootDir(projectPath || process.cwd()) + ); +} From 438164f18a04545bab1a5cf35ea61273cc5be9c6 Mon Sep 17 00:00:00 2001 From: zoomdong <1344492820@qq.com> Date: Tue, 27 Oct 2026 12:23:47 +0800 Subject: [PATCH 2/2] fix(pack): preserve framework root and type compatibility --- packages/pack-cli/README.md | 15 --- packages/pack/README.md | 22 ++-- .../src/__test__/projectRootPath.test-d.ts | 12 ++ .../pack/src/__test__/projectRootPath.test.ts | 53 ++++----- .../pack/src/__test__/resolveRootPath.test.ts | 75 ------------ packages/pack/src/__test__/rootPath.test.ts | 111 ------------------ packages/pack/src/commands/build.ts | 2 +- packages/pack/src/commands/dev.ts | 2 +- packages/pack/src/core/project.ts | 4 +- packages/pack/src/core/types.ts | 2 +- packages/pack/src/utils/env.ts | 7 -- packages/pack/src/utils/findRoot.ts | 18 ++- packages/pack/src/utils/resolveRootPath.ts | 13 -- 13 files changed, 59 insertions(+), 277 deletions(-) create mode 100644 packages/pack/src/__test__/projectRootPath.test-d.ts delete mode 100644 packages/pack/src/__test__/resolveRootPath.test.ts delete mode 100644 packages/pack/src/__test__/rootPath.test.ts delete mode 100644 packages/pack/src/utils/resolveRootPath.ts diff --git a/packages/pack-cli/README.md b/packages/pack-cli/README.md index 1736eadf7f..84d832768e 100644 --- a/packages/pack-cli/README.md +++ b/packages/pack-cli/README.md @@ -61,21 +61,6 @@ Build your project for production: utx up build ``` -### Worktree and Symlink Debugging - -To reuse `node_modules` from another checkout or debug a linked dependency, -set the root to a common parent containing both the project and the real -dependency paths: - -```bash -UTOO_ROOT_PATH=/path/to/common-parent utx up dev -UTOO_ROOT_PATH=.. utx up build -``` - -`UTOO_ROOT_PATH` overrides `--root`, config `rootPath`, and automatic root -detection. Relative environment paths resolve from the current working -directory; an empty value is ignored. The project directory stays unchanged. - ### Webpack Compatibility Mode If you have an existing `webpack.config.js`, you can run `@utoo/pack` in compatibility mode: diff --git a/packages/pack/README.md b/packages/pack/README.md index 4b4d5d0013..08db912664 100644 --- a/packages/pack/README.md +++ b/packages/pack/README.md @@ -161,28 +161,20 @@ Node.js, or library builds. ### Worktree and Symlink Debugging -Set `UTOO_ROOT_PATH` to temporarily override the bundler's filesystem root when -debugging in a worktree that shares `node_modules`, or when linking a local -dependency outside the project: +Set `UTOO_ROOT_PATH` to a common parent containing the project and the real paths +of linked dependencies when debugging worktrees or symlinks: ```bash UTOO_ROOT_PATH=/path/to/common-parent utx up dev # Relative paths resolve from the current working directory: UTOO_ROOT_PATH=.. utx up build -# Framework integrations use the same override: -UTOO_ROOT_PATH=/path/to/common-parent smallfish dev ``` -The root must contain both the project and the real paths of its linked -dependencies. The project directory stays unchanged. Precedence is -`UTOO_ROOT_PATH` > explicit `rootPath` (including CLI `--root` and config -`rootPath`) > automatic root detection. An empty environment variable is ignored. - -Utoopack applies this policy when creating projects, including direct -`projectFactory()` callers. Frameworks can omit `rootPath` and let Utoopack -detect it; they do not need to call `findRootDir()` themselves. For integrations -that still call `findRootDir()`, it respects the same environment override. -Updates that omit `rootPath` retain the project's existing root. +Precedence is `UTOO_ROOT_PATH` > explicit `rootPath` > automatic detection. Empty +values are ignored. This applies to `build`, `serve`, and direct project creation, +where `rootPath` can be omitted. Updates that omit it retain the existing root. +`findRootDir()` and the project directory stay unchanged, preserving framework +plugin lookup and generated file locations. ## 🛠️ Development diff --git a/packages/pack/src/__test__/projectRootPath.test-d.ts b/packages/pack/src/__test__/projectRootPath.test-d.ts new file mode 100644 index 0000000000..19312e279c --- /dev/null +++ b/packages/pack/src/__test__/projectRootPath.test-d.ts @@ -0,0 +1,12 @@ +import type { projectFactory } from "../core/project"; +import type { ProjectOptions } from "../core/types"; + +declare const options: ProjectOptions; + +// Existing consumers can keep reading a required string from ProjectOptions. +export const rootPath: string = options.rootPath; + +// Only project creation accepts an omitted root for automatic discovery. +declare const withoutRoot: Omit, "rootPath">; +export const creationOptions: Parameters>[0] = + withoutRoot; diff --git a/packages/pack/src/__test__/projectRootPath.test.ts b/packages/pack/src/__test__/projectRootPath.test.ts index 6b3168efd1..488ac03b9e 100644 --- a/packages/pack/src/__test__/projectRootPath.test.ts +++ b/packages/pack/src/__test__/projectRootPath.test.ts @@ -12,6 +12,7 @@ const bindingMocks = vi.hoisted(() => ({ vi.mock("../binding", () => bindingMocks); import { projectFactory } from "../core/project"; +import { findRootDir } from "../utils/findRoot"; import { normalizePath } from "../utils/normalizePath"; const nativeProject = { __napiType: "Project" as const }; @@ -52,51 +53,39 @@ function createProject(rootPath?: string) { } function expectNativeRoot(rootPath: string) { - expect(bindingMocks.projectNew).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ - projectPath: normalizePath(projectPath), - rootPath: normalizePath(rootPath), - }), - {}, - expect.any(Object), - ); + expect(bindingMocks.projectNew.mock.calls[0][0]).toMatchObject({ + projectPath: normalizePath(projectPath), + rootPath: normalizePath(rootPath), + }); } describe("project root configuration", () => { - it("overrides an explicit root before passing options to NAPI", async () => { - vi.stubEnv("UTOO_ROOT_PATH", fixture); - - await createProject(projectPath); - - expectNativeRoot(fixture); - }); - - it("resolves relative environment roots from cwd", async () => { - vi.stubEnv("UTOO_ROOT_PATH", path.relative(process.cwd(), fixture)); - - await createProject(projectPath); - - expectNativeRoot(fixture); - }); - - it.each([undefined, ""])( - "preserves an explicit root when the environment is %s", - async (value) => { - vi.stubEnv("UTOO_ROOT_PATH", value); - + it.each(["absolute", "relative"])( + "applies an %s environment root without changing framework discovery", + async (kind) => { + const accessRoot = path.dirname(fixture); + vi.stubEnv( + "UTOO_ROOT_PATH", + kind === "absolute" + ? accessRoot + : path.relative(process.cwd(), accessRoot), + ); await createProject(projectPath); - expectNativeRoot(projectPath); + expectNativeRoot(accessRoot); + expect(findRootDir(projectPath)).toBe(fixture); }, ); it.each([undefined, ""])( - "detects the root for direct project creation when the environment is %s", + "preserves explicit roots and discovers omitted roots when the environment is %s", async (value) => { vi.stubEnv("UTOO_ROOT_PATH", value); + await createProject(projectPath); + expectNativeRoot(projectPath); + bindingMocks.projectNew.mockClear(); await createProject(); - expectNativeRoot(fixture); }, ); diff --git a/packages/pack/src/__test__/resolveRootPath.test.ts b/packages/pack/src/__test__/resolveRootPath.test.ts deleted file mode 100644 index 14ab4545a9..0000000000 --- a/packages/pack/src/__test__/resolveRootPath.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import fs from "fs"; -import os from "os"; -import path from "path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { findRootDir } from "../utils/findRoot"; -import { resolveRootPath } from "../utils/resolveRootPath"; - -let fixture: string; -let projectPath: string; - -beforeEach(() => { - vi.stubEnv("UTOO_ROOT_PATH", undefined); - fixture = fs.mkdtempSync(path.join(os.tmpdir(), "utoo-root-path-")); - projectPath = path.join(fixture, "worktree"); - fs.mkdirSync(projectPath); - fs.writeFileSync(path.join(projectPath, "package-lock.json"), "{}"); -}); - -afterEach(() => { - vi.unstubAllEnvs(); - fs.rmSync(fixture, { recursive: true, force: true }); -}); - -describe("resolveRootPath", () => { - it("overrides the configured root with a common worktree parent", () => { - vi.stubEnv("UTOO_ROOT_PATH", fixture); - - expect(resolveRootPath(projectPath, projectPath)).toBe(fixture); - }); - - it("resolves relative environment paths from cwd, independently of projectPath", () => { - vi.stubEnv("UTOO_ROOT_PATH", ".."); - - expect(resolveRootPath(projectPath)).toBe(path.resolve("..")); - }); - - it.each(["absolute", "relative"])( - "uses the %s environment root for integrations that call findRootDir directly", - (kind) => { - const rootPath = - kind === "absolute" ? fixture : path.relative(process.cwd(), fixture); - vi.stubEnv("UTOO_ROOT_PATH", rootPath); - - expect(findRootDir(projectPath)).toBe(fixture); - expect(resolveRootPath(projectPath, projectPath)).toBe(fixture); - }, - ); - - it.each([undefined, ""])( - "preserves the configured root when the environment is %s", - (value) => { - vi.stubEnv("UTOO_ROOT_PATH", value); - - expect(resolveRootPath(projectPath, fixture)).toBe(fixture); - }, - ); - - it.each([undefined, ""])( - "detects the project root when the environment is %s and no root is configured", - (value) => { - vi.stubEnv("UTOO_ROOT_PATH", value); - - expect(resolveRootPath(projectPath)).toBe(projectPath); - }, - ); - - it("falls back to detecting the root from cwd when projectPath is omitted", () => { - const cwd = vi.spyOn(process, "cwd").mockReturnValue(projectPath); - try { - expect(resolveRootPath()).toBe(projectPath); - } finally { - cwd.mockRestore(); - } - }); -}); diff --git a/packages/pack/src/__test__/rootPath.test.ts b/packages/pack/src/__test__/rootPath.test.ts deleted file mode 100644 index 16aafb2fb0..0000000000 --- a/packages/pack/src/__test__/rootPath.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import path from "path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const rootMocks = vi.hoisted(() => { - const stopped = new Error("Stop before starting the bundler"); - return { - stopped, - createProject: vi.fn(async () => { - throw stopped; - }), - createHotReloader: vi.fn(async () => { - throw stopped; - }), - resolveBundleOptions: vi.fn(() => ({ - config: { entry: [], persistentCaching: false }, - })), - }; -}); - -vi.mock("../core/project", () => ({ - projectFactory: () => rootMocks.createProject, -})); -vi.mock("../core/hmr", () => ({ - createHotReloader: rootMocks.createHotReloader, -})); -vi.mock("../config/webpackCompat", () => ({ - resolveBundleOptions: rootMocks.resolveBundleOptions, -})); -vi.mock("../utils/common", () => ({ - blockStdout: vi.fn(), - getPackPath: () => "/fixture/pack", -})); -vi.mock("../utils/cleanOutput", () => ({ - cleanOutput: vi.fn(async () => {}), - getOutputPath: vi.fn(), -})); -vi.mock("../utils/lockfile", () => ({ - acquirePersistentCacheLock: vi.fn(async () => undefined), -})); -vi.mock("get-port", () => ({ default: async () => 3000 })); -vi.mock("domparser-rs", () => ({ DOMParser: vi.fn() })); - -import { build } from "../commands/build"; -import { serve } from "../commands/dev"; - -const projectPath = path.resolve("/fixture/worktree/app"); -const configuredRoot = path.resolve("/fixture/worktree"); -const environmentRoot = path.resolve("/fixture"); -const originalTitle = process.title; - -beforeEach(() => { - vi.clearAllMocks(); - vi.stubEnv("UTOO_ROOT_PATH", undefined); - vi.stubEnv("XCODE_PROFILE", undefined); - vi.stubEnv("NODE_ENV", process.env.NODE_ENV); -}); - -afterEach(() => { - vi.unstubAllEnvs(); - process.title = originalTitle; -}); - -describe.each([ - ["build", build], - ["serve", serve], -] as const)("%s root path", (name, command) => { - async function expectRoot( - expectedRoot: string, - options: Parameters[0] = { config: { entry: [] } }, - ) { - await expect(command(options, projectPath, configuredRoot)).rejects.toBe( - rootMocks.stopped, - ); - - if (name === "build") { - expect(rootMocks.createProject).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ rootPath: expectedRoot }), - expect.any(Object), - ); - } else { - expect(rootMocks.createHotReloader).toHaveBeenCalledExactlyOnceWith( - expect.any(Object), - projectPath, - expectedRoot, - ); - } - } - - it("passes the environment root instead of the configured root", async () => { - vi.stubEnv("UTOO_ROOT_PATH", environmentRoot); - - await expectRoot(environmentRoot); - }); - - it("preserves the configured root when the environment is unset", async () => { - await expectRoot(configuredRoot); - }); - - it("loads webpack configuration using the original root", async () => { - vi.stubEnv("UTOO_ROOT_PATH", environmentRoot); - const options = { webpackMode: true }; - - await expectRoot(environmentRoot, options); - - expect(rootMocks.resolveBundleOptions).toHaveBeenCalledExactlyOnceWith( - options, - projectPath, - configuredRoot, - ); - }); -}); diff --git a/packages/pack/src/commands/build.ts b/packages/pack/src/commands/build.ts index 638b197c4b..748b7f79ff 100644 --- a/packages/pack/src/commands/build.ts +++ b/packages/pack/src/commands/build.ts @@ -14,11 +14,11 @@ import { isTruthyEnv, normalizeTurbopackMemoryEviction, } from "../utils/env"; +import { resolveRootPath } from "../utils/findRoot"; import { HtmlGenerationManager } from "../utils/HtmlGenerationManager"; import { processHtmlEntry } from "../utils/htmlEntry"; import { acquirePersistentCacheLock } from "../utils/lockfile"; import { normalizePath } from "../utils/normalizePath"; -import { resolveRootPath } from "../utils/resolveRootPath"; import { useWorkerThreads } from "../utils/runtimePluginStratety"; import { formatDuration, diff --git a/packages/pack/src/commands/dev.ts b/packages/pack/src/commands/dev.ts index 94535a6a9b..997e32ee31 100644 --- a/packages/pack/src/commands/dev.ts +++ b/packages/pack/src/commands/dev.ts @@ -24,9 +24,9 @@ import { createHttpProxyMiddleware } from "../core/proxyHono"; import type { Project } from "../core/types"; import { getOutputPath } from "../utils/cleanOutput"; import { blockStdout, getPackPath } from "../utils/common"; +import { resolveRootPath } from "../utils/findRoot"; import { createSelfSignedCertificate } from "../utils/mkcert"; import { printServerInfo } from "../utils/printServerInfo"; -import { resolveRootPath } from "../utils/resolveRootPath"; import { xcodeProfilingReady } from "../utils/xcodeProfile"; // --- Path helpers (same logic as dev.ts, not exported) --- diff --git a/packages/pack/src/core/project.ts b/packages/pack/src/core/project.ts index 4c80b7c170..6f351ea5d8 100644 --- a/packages/pack/src/core/project.ts +++ b/packages/pack/src/core/project.ts @@ -20,8 +20,8 @@ import { TurbopackRuleConfigItem, } from "../config/types"; import { getPackPath, rustifyEnv } from "../utils/common"; +import { resolveRootPath } from "../utils/findRoot"; import { normalizePath } from "../utils/normalizePath"; -import { resolveRootPath } from "../utils/resolveRootPath"; import { DiagnosticSources } from "./diagnosticSources"; import { runLoaderWorkerPool } from "./loaderWorkerPool"; import { @@ -33,7 +33,7 @@ import { } from "./types"; type ProjectCreationOptions = Omit, "rootPath"> & - Pick; + Partial>; /** * An error caused by a bug in Turbopack, and not the user's code (e.g. a Rust panic). These should diff --git a/packages/pack/src/core/types.ts b/packages/pack/src/core/types.ts index 325a9810ba..0bf90ec944 100644 --- a/packages/pack/src/core/types.ts +++ b/packages/pack/src/core/types.ts @@ -51,7 +51,7 @@ export interface ProjectOptions extends BundleOptions { * Defaults to automatic root detection from projectPath. UTOO_ROOT_PATH * overrides both explicit paths and automatic detection. */ - rootPath?: string; + rootPath: string; /** * A path inside the root_path which contains the app/pages directories. diff --git a/packages/pack/src/utils/env.ts b/packages/pack/src/utils/env.ts index 77405d4d26..33b01fb1e8 100644 --- a/packages/pack/src/utils/env.ts +++ b/packages/pack/src/utils/env.ts @@ -1,10 +1,3 @@ -import path from "path"; - -export function getRootPathFromEnv(): string | undefined { - const rootPath = process.env.UTOO_ROOT_PATH; - return rootPath ? path.resolve(rootPath) : undefined; -} - export function isTruthyEnv(value: string | undefined): boolean { return value === "1" || value === "true"; } diff --git a/packages/pack/src/utils/findRoot.ts b/packages/pack/src/utils/findRoot.ts index e071d29337..d60b9a78f2 100644 --- a/packages/pack/src/utils/findRoot.ts +++ b/packages/pack/src/utils/findRoot.ts @@ -1,7 +1,16 @@ import findUp from "find-up"; import fs from "fs"; import path from "path"; -import { getRootPathFromEnv } from "./env"; + +export function resolveRootPath( + projectPath?: string, + rootPath?: string, +): string { + const envRootPath = process.env.UTOO_ROOT_PATH; + return envRootPath + ? path.resolve(envRootPath) + : rootPath || findRootDir(projectPath || process.cwd()); +} export function findRootLockFile(cwd: string) { return findUp.sync( @@ -56,10 +65,11 @@ export function findWorkspacesRoot(cwd: string) { return path.dirname(pkgJsonFiles[pkgJsonFiles.length - 1]); } +/** + * Discover the existing project/workspace root for framework configuration. + * UTOO_ROOT_PATH overrides the bundler's access root, not this directory. + */ export function findRootDir(cwd: string): string { - const envRootPath = getRootPathFromEnv(); - if (envRootPath) return envRootPath; - const lockFile = findRootLockFile(cwd); if (!lockFile) return findWorkspacesRoot(cwd); diff --git a/packages/pack/src/utils/resolveRootPath.ts b/packages/pack/src/utils/resolveRootPath.ts deleted file mode 100644 index 98793f1842..0000000000 --- a/packages/pack/src/utils/resolveRootPath.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { getRootPathFromEnv } from "./env"; -import { findRootDir } from "./findRoot"; - -export function resolveRootPath( - projectPath?: string, - rootPath?: string, -): string { - return ( - getRootPathFromEnv() || - rootPath || - findRootDir(projectPath || process.cwd()) - ); -}