Browse Source

Merge pull request #41 from shrimbly/fix/normalize-directory-path-hostname-prefix

fix: normalize directory path hostname prefix from macOS network volumes
handoff-20260429-1057
Willie 5 months ago
committed by GitHub
parent
commit
6f13995611
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 54
      src/app/api/browse-directory/__tests__/route.test.ts
  2. 35
      src/app/api/browse-directory/route.ts
  3. 6
      src/components/ProjectSetupModal.tsx
  4. 35
      src/components/__tests__/ProjectSetupModal.test.tsx

54
src/app/api/browse-directory/__tests__/route.test.ts

@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import { normalizeSelectedPath } from "../route";
describe("normalizeSelectedPath", () => {
it("should strip hostname prefix on macOS", () => {
expect(normalizeSelectedPath("AT-ALGKG9VR/Users/guy/Desktop", "darwin"))
.toBe("/Users/guy/Desktop");
});
it("should preserve absolute paths on macOS", () => {
expect(normalizeSelectedPath("/Users/guy/Desktop", "darwin"))
.toBe("/Users/guy/Desktop");
});
it("should remove trailing slash", () => {
expect(normalizeSelectedPath("/Users/guy/Desktop/", "darwin"))
.toBe("/Users/guy/Desktop");
});
it("should strip hostname and trailing slash", () => {
expect(normalizeSelectedPath("HOST/Users/guy/", "darwin"))
.toBe("/Users/guy");
});
it("should strip hostname prefix on Linux", () => {
expect(normalizeSelectedPath("hostname/home/user", "linux"))
.toBe("/home/user");
});
it("should not modify Windows drive paths", () => {
expect(normalizeSelectedPath("C:\\Users\\guy", "win32"))
.toBe("C:\\Users\\guy");
});
it("should preserve Windows drive root with backslash", () => {
expect(normalizeSelectedPath("C:\\", "win32"))
.toBe("C:\\");
});
it("should preserve Windows drive root with forward slash", () => {
expect(normalizeSelectedPath("C:/", "win32"))
.toBe("C:/");
});
it("should preserve Unix root /", () => {
expect(normalizeSelectedPath("/", "darwin"))
.toBe("/");
});
it("should leave hostname-only (no slash) as-is", () => {
expect(normalizeSelectedPath("HOSTNAME", "darwin"))
.toBe("HOSTNAME");
});
});

35
src/app/api/browse-directory/route.ts

@ -7,6 +7,33 @@ import { join } from "path";
const execAsync = promisify(exec);
/**
* Normalize a path returned by native directory pickers.
* On macOS, osascript can return hostname-prefixed paths for network volumes
* (e.g. "HOSTNAME/Users/..." instead of "/Users/..."). This strips the
* hostname prefix and cleans up trailing slashes.
*/
export function normalizeSelectedPath(selectedPath: string, platform: string): string {
// On macOS/Linux, ensure the path is absolute.
// osascript can return hostname-prefixed paths for network volumes
// e.g. "AT-ALGKG9VR/Users/guy/Desktop" instead of "/Users/guy/Desktop"
if ((platform === "darwin" || platform === "linux") && !selectedPath.startsWith("/")) {
const firstSlash = selectedPath.indexOf("/");
if (firstSlash >= 0) {
selectedPath = selectedPath.substring(firstSlash);
}
}
// Remove trailing slash/backslash (except root paths like "/" or "C:\")
if (selectedPath.length > 1 && (selectedPath.endsWith("/") || selectedPath.endsWith("\\"))) {
if (!(platform === "win32" && /^[A-Za-z]:[\\\/]$/.test(selectedPath))) {
selectedPath = selectedPath.slice(0, -1);
}
}
return selectedPath;
}
// GET: Open native directory picker and return the selected path
export async function GET() {
const platform = process.platform;
@ -121,13 +148,7 @@ if ($result) { Write-Output $result }
});
}
// Remove trailing slash/backslash if present (except for root paths like "/" or "C:\")
if (selectedPath.length > 1 && (selectedPath.endsWith("/") || selectedPath.endsWith("\\"))) {
// Don't remove trailing slash from Windows drive roots like "C:\"
if (!(platform === "win32" && /^[A-Za-z]:\\$/.test(selectedPath))) {
selectedPath = selectedPath.slice(0, -1);
}
}
selectedPath = normalizeSelectedPath(selectedPath, platform);
return NextResponse.json({
success: true,

6
src/components/ProjectSetupModal.tsx

@ -199,6 +199,12 @@ export function ProjectSetupModal({
return;
}
const trimmedPath = directoryPath.trim();
if (!(trimmedPath.startsWith("/") || /^[A-Za-z]:[\\\/]/.test(trimmedPath) || trimmedPath.startsWith("\\\\"))) {
setError("Project directory must be an absolute path (starting with /, a drive letter, or a UNC path)");
return;
}
setIsValidating(true);
setError(null);

35
src/components/__tests__/ProjectSetupModal.test.tsx

@ -382,6 +382,41 @@ describe("ProjectSetupModal", () => {
expect(onSave).not.toHaveBeenCalled();
});
it("should show error when path is not absolute", async () => {
const onSave = vi.fn();
render(
<ProjectSetupModal
isOpen={true}
onClose={vi.fn()}
onSave={onSave}
mode="new"
/>
);
// Fill name and a hostname-prefixed relative path
fireEvent.change(screen.getByPlaceholderText("my-project"), {
target: { value: "My Project" },
});
fireEvent.change(screen.getByPlaceholderText("/Users/username/projects/my-project"), {
target: { value: "AT-ALGKG9VR/Users/guy/Desktop/AI Project" },
});
// Click Create
fireEvent.click(screen.getByText("Create"));
await waitFor(() => {
expect(
screen.getByText("Project directory must be an absolute path (starting with /, a drive letter, or a UNC path)")
).toBeInTheDocument();
});
expect(onSave).not.toHaveBeenCalled();
// Validation should fail client-side without making a fetch to /api/workflow
expect(mockFetch).not.toHaveBeenCalledWith(
expect.stringContaining("/api/workflow")
);
});
it("should show error when path is not a directory", async () => {
mockFetch.mockImplementation((url: string) => {
if (url === "/api/env-status") {

Loading…
Cancel
Save