diff --git a/packages/pack/README.md b/packages/pack/README.md index 23ca031b0..08db91266 100644 --- a/packages/pack/README.md +++ b/packages/pack/README.md @@ -159,6 +159,23 @@ Node.js, or library builds. } ``` +### Worktree and Symlink Debugging + +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 +``` + +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 ### Prerequisites 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 000000000..19312e279 --- /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 new file mode 100644 index 000000000..488ac03b9 --- /dev/null +++ b/packages/pack/src/__test__/projectRootPath.test.ts @@ -0,0 +1,127 @@ +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 { findRootDir } from "../utils/findRoot"; +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.mock.calls[0][0]).toMatchObject({ + projectPath: normalizePath(projectPath), + rootPath: normalizePath(rootPath), + }); +} + +describe("project root configuration", () => { + 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(accessRoot); + expect(findRootDir(projectPath)).toBe(fixture); + }, + ); + + it.each([undefined, ""])( + "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); + }, + ); + + 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/commands/build.ts b/packages/pack/src/commands/build.ts index e5ebf8c8e..748b7f79f 100644 --- a/packages/pack/src/commands/build.ts +++ b/packages/pack/src/commands/build.ts @@ -14,7 +14,7 @@ import { isTruthyEnv, normalizeTurbopackMemoryEviction, } from "../utils/env"; -import { findRootDir } from "../utils/findRoot"; +import { resolveRootPath } from "../utils/findRoot"; import { HtmlGenerationManager } from "../utils/HtmlGenerationManager"; import { processHtmlEntry } from "../utils/htmlEntry"; import { acquirePersistentCacheLock } from "../utils/lockfile"; @@ -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 c8a65e588..997e32ee3 100644 --- a/packages/pack/src/commands/dev.ts +++ b/packages/pack/src/commands/dev.ts @@ -24,7 +24,7 @@ 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 { resolveRootPath } from "../utils/findRoot"; import { createSelfSignedCertificate } from "../utils/mkcert"; import { printServerInfo } from "../utils/printServerInfo"; import { xcodeProfilingReady } from "../utils/xcodeProfile"; @@ -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 aee8668ac..6f351ea5d 100644 --- a/packages/pack/src/core/project.ts +++ b/packages/pack/src/core/project.ts @@ -20,6 +20,7 @@ import { TurbopackRuleConfigItem, } from "../config/types"; import { getPackPath, rustifyEnv } from "../utils/common"; +import { resolveRootPath } from "../utils/findRoot"; import { normalizePath } from "../utils/normalizePath"; import { DiagnosticSources } from "./diagnosticSources"; import { runLoaderWorkerPool } from "./loaderWorkerPool"; @@ -31,6 +32,9 @@ import { Update, } from "./types"; +type ProjectCreationOptions = Omit, "rootPath"> & + Partial>; + /** * 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 01300978b..0bf90ec94 100644 --- a/packages/pack/src/core/types.ts +++ b/packages/pack/src/core/types.ts @@ -48,6 +48,8 @@ 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; diff --git a/packages/pack/src/utils/findRoot.ts b/packages/pack/src/utils/findRoot.ts index 587ee7ce2..d60b9a78f 100644 --- a/packages/pack/src/utils/findRoot.ts +++ b/packages/pack/src/utils/findRoot.ts @@ -2,6 +2,16 @@ import findUp from "find-up"; import fs from "fs"; import path from "path"; +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( [ @@ -55,6 +65,10 @@ 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 lockFile = findRootLockFile(cwd); if (!lockFile) return findWorkspacesRoot(cwd);