Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import { z } from "zod";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { isAbsolute, resolve } from "node:path";

/* eslint-disable @typescript-eslint/no-explicit-any */

Expand Down Expand Up @@ -81,24 +81,34 @@ export function applyModelDefaults(

/**
* Normalize and validate a directory path:
* - Resolves to absolute (handles "..", trailing slashes, etc.)
* - Rejects relative paths (must start with /)
* - Resolves to absolute (handles "..", ".", trailing slashes, and
* converts relative inputs against `process.cwd()`)
* - Confirms the resolved path is absolute for the current platform
* - Validates that the path exists on disk
*
* Accepts both POSIX ("/home/user/my-project") and Windows
* ("C:\\Users\\me\\my-project", "\\\\server\\share") absolute paths via
* the platform-aware `resolve` + `isAbsolute` from `node:path`.
*
* Returns the normalized path, or undefined if input was undefined.
* Throws a descriptive Error on validation failure.
*/
export function normalizeDirectory(directory?: string): string | undefined {
if (!directory) return undefined;

// Resolve to absolute (handles "..", ".", trailing slashes)
// Resolve to an absolute, platform-appropriate form. `resolve` handles
// "..", ".", trailing slashes, and will convert a relative input against
// `process.cwd()`.
const normalized = resolve(directory);

// Must be an absolute path
if (!normalized.startsWith("/")) {
// Defensive check: `resolve` guarantees an absolute path on every
// supported platform, but we verify via the platform-aware `isAbsolute`
// so callers get a clear error if that assumption is ever violated.
if (!isAbsolute(normalized)) {
throw new Error(
`Invalid directory: "${directory}" is not an absolute path. ` +
`Provide a full path like "/home/user/my-project".`,
`Provide a full path like "/home/user/my-project" (POSIX) or ` +
`"C:\\\\Users\\\\me\\\\my-project" (Windows).`,
);
}

Expand Down
23 changes: 23 additions & 0 deletions tests/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -927,6 +927,29 @@ describe("normalizeDirectory", () => {
const result = normalizeDirectory("/tmp");
expect(result).toBe("/tmp");
});

it("accepts the process working directory on any platform", () => {
// Platform-agnostic regression: process.cwd() is always absolute for the
// current platform, so normalizeDirectory must accept it regardless of OS.
const result = normalizeDirectory(process.cwd());
expect(result).toBeDefined();
});

// Regression for Windows drive-letter paths.
// Before the fix, normalizeDirectory required the resolved path to start
// with "/", which meant Windows absolute paths like "C:\\Users\\..." were
// rejected. The fix switches to the platform-aware `path.isAbsolute`, so
// this test guards the Windows path on a Windows CI runner.
it.runIf(process.platform === "win32")(
"accepts Windows drive-letter absolute paths",
() => {
const cwd = process.cwd(); // e.g. "C:\\Users\\runner\\work\\..."
expect(cwd).toMatch(/^[A-Za-z]:\\/);
const result = normalizeDirectory(cwd);
expect(result).toBeDefined();
expect(result).toMatch(/^[A-Za-z]:\\/);
},
);
});

// ─── diagnoseError (via toolError) ───────────────────────────────────────
Expand Down