diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 89284c5e..d7c37cf9 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -70,7 +70,7 @@ import { urlToMarkdown } from "@plannotator/shared/url-to-markdown"; import { fetchRef, createWorktree, removeWorktree, ensureObjectAvailable } from "@plannotator/shared/worktree"; import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr"; import { writeRemoteShareLink } from "@plannotator/server/share-url"; -import { resolveMarkdownFile, hasMarkdownFiles } from "@plannotator/shared/resolve-file"; +import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file"; import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; import { statSync, rmSync, realpathSync, existsSync } from "fs"; import { parseRemoteUrl } from "@plannotator/shared/repo"; @@ -502,7 +502,7 @@ if (args[0] === "sessions") { sourceInfo = filePath; // Full URL for source attribution } else { // Check if the argument is a directory (folder annotation mode) - const resolvedArg = path.resolve(projectRoot, filePath); + const resolvedArg = resolveUserPath(filePath, projectRoot); let isFolder = false; try { isFolder = statSync(resolvedArg).isDirectory(); diff --git a/apps/opencode-plugin/commands.ts b/apps/opencode-plugin/commands.ts index 4f1350c2..1fc8cd55 100644 --- a/apps/opencode-plugin/commands.ts +++ b/apps/opencode-plugin/commands.ts @@ -21,7 +21,7 @@ import { import { getGitContext, runGitDiffWithContext } from "@plannotator/server/git"; import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr"; import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config"; -import { resolveMarkdownFile } from "@plannotator/shared/resolve-file"; +import { resolveMarkdownFile, resolveUserPath } from "@plannotator/shared/resolve-file"; import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown"; import { urlToMarkdown } from "@plannotator/shared/url-to-markdown"; import { statSync } from "fs"; @@ -178,7 +178,7 @@ export async function handleAnnotateCommand( sourceInfo = filePath; } else { const projectRoot = process.cwd(); - const resolvedArg = path.resolve(projectRoot, filePath); + const resolvedArg = resolveUserPath(filePath, projectRoot); if (/\.html?$/i.test(resolvedArg)) { // HTML file annotation — convert to markdown via Turndown diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index afe9f472..1cba685f 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -19,7 +19,7 @@ */ import { existsSync, readFileSync, statSync } from "node:fs"; -import { resolve, basename } from "node:path"; +import { basename, resolve } from "node:path"; import type { ThinkingLevel } from "@mariozechner/pi-agent-core"; import { Type } from "@mariozechner/pi-ai"; import type { @@ -34,7 +34,7 @@ import { parseChecklist, } from "./generated/checklist.js"; import { planDenyFeedback } from "./generated/feedback-templates.js"; -import { hasMarkdownFiles } from "./generated/resolve-file.js"; +import { hasMarkdownFiles, resolveUserPath } from "./generated/resolve-file.js"; import { FILE_BROWSER_EXCLUDED } from "./generated/reference-common.js"; import { htmlToMarkdown } from "./generated/html-to-markdown.js"; import { urlToMarkdown } from "./generated/url-to-markdown.js"; @@ -417,7 +417,7 @@ export default function plannotator(pi: ExtensionAPI): void { absolutePath = filePath; sourceInfo = filePath; } else { - absolutePath = resolve(ctx.cwd, filePath); + absolutePath = resolveUserPath(filePath, ctx.cwd); if (!existsSync(absolutePath)) { ctx.ui.notify(`File not found: ${absolutePath}`, "error"); return; diff --git a/apps/pi-extension/package.json b/apps/pi-extension/package.json index 583f8b94..71f7026b 100644 --- a/apps/pi-extension/package.json +++ b/apps/pi-extension/package.json @@ -44,5 +44,11 @@ }, "peerDependencies": { "@mariozechner/pi-coding-agent": ">=0.53.0" + }, + "devDependencies": { + "@mariozechner/pi-coding-agent": ">=0.53.0", + "@mariozechner/pi-agent-core": ">=0.53.0", + "@mariozechner/pi-ai": ">=0.53.0", + "@mariozechner/pi-tui": ">=0.53.0" } } diff --git a/apps/pi-extension/server/integrations.ts b/apps/pi-extension/server/integrations.ts index 19fda7d6..a68bcb30 100644 --- a/apps/pi-extension/server/integrations.ts +++ b/apps/pi-extension/server/integrations.ts @@ -23,6 +23,7 @@ import { detectObsidianVaults, } from "../generated/integrations-common.js"; import { sanitizeTag } from "../generated/project.js"; +import { resolveUserPath } from "../generated/resolve-file.js"; export type { ObsidianConfig, BearConfig, OctarineConfig, IntegrationResult }; export { @@ -111,11 +112,10 @@ export async function saveToObsidian( ): Promise { try { const { vaultPath, folder, plan } = config; - let normalizedVault = vaultPath.trim(); - if (normalizedVault.startsWith("~")) { - const home = process.env.HOME || process.env.USERPROFILE || ""; - normalizedVault = join(home, normalizedVault.slice(1)); + if (!vaultPath?.trim()) { + return { success: false, error: "Vault path is required" }; } + const normalizedVault = resolveUserPath(vaultPath); if (!existsSync(normalizedVault)) return { success: false, diff --git a/apps/pi-extension/server/reference.ts b/apps/pi-extension/server/reference.ts index 04f76963..d58dc136 100644 --- a/apps/pi-extension/server/reference.ts +++ b/apps/pi-extension/server/reference.ts @@ -22,7 +22,12 @@ import { FILE_BROWSER_EXCLUDED, } from "../generated/reference-common.js"; import { detectObsidianVaults } from "../generated/integrations-common.js"; -import { resolveMarkdownFile, isWithinProjectRoot } from "../generated/resolve-file.js"; +import { + isAbsoluteUserPath, + resolveMarkdownFile, + resolveUserPath, + isWithinProjectRoot, +} from "../generated/resolve-file.js"; import { htmlToMarkdown } from "../generated/html-to-markdown.js"; type Res = ServerResponse; @@ -62,12 +67,13 @@ export function handleDocRequest(res: Res, url: URL): void { // server (see serverAnnotate.ts /api/doc route). The standalone HTML // block below (no base) retains its cwd-based containment check. const base = url.searchParams.get("base"); + const resolvedBase = base ? resolveUserPath(base) : null; if ( - base && - !requestedPath.startsWith("/") && + resolvedBase && + !isAbsoluteUserPath(requestedPath) && /\.(mdx?|html?)$/i.test(requestedPath) ) { - const fromBase = resolvePath(base, requestedPath); + const fromBase = resolveUserPath(requestedPath, resolvedBase); try { if (existsSync(fromBase)) { const raw = readFileSync(fromBase, "utf-8"); @@ -83,7 +89,7 @@ export function handleDocRequest(res: Res, url: URL): void { // HTML files: resolve directly (not via resolveMarkdownFile which only handles .md/.mdx) const projectRoot = process.cwd(); if (/\.html?$/i.test(requestedPath)) { - const resolvedHtml = resolvePath(base || projectRoot, requestedPath); + const resolvedHtml = resolveUserPath(requestedPath, resolvedBase || projectRoot); if (!isWithinProjectRoot(resolvedHtml, projectRoot)) { json(res, { error: "Access denied: path is outside project root" }, 403); return; @@ -136,7 +142,7 @@ export function handleObsidianFilesRequest(res: Res, url: URL): void { json(res, { error: "Missing vaultPath parameter" }, 400); return; } - const resolvedVault = resolvePath(vaultPath); + const resolvedVault = resolveUserPath(vaultPath); if (!existsSync(resolvedVault) || !statSync(resolvedVault).isDirectory()) { json(res, { error: "Invalid vault path" }, 400); return; @@ -162,7 +168,7 @@ export function handleObsidianDocRequest(res: Res, url: URL): void { json(res, { error: "Only markdown files are supported" }, 400); return; } - const resolvedVault = resolvePath(vaultPath); + const resolvedVault = resolveUserPath(vaultPath); let resolvedFile = resolvePath(resolvedVault, filePath); // Bare filename search within vault @@ -214,7 +220,7 @@ export function handleFileBrowserRequest(res: Res, url: URL): void { json(res, { error: "Missing dirPath parameter" }, 400); return; } - const resolvedDir = resolvePath(dirPath); + const resolvedDir = resolveUserPath(dirPath); if (!existsSync(resolvedDir) || !statSync(resolvedDir).isDirectory()) { json(res, { error: "Invalid directory path" }, 400); return; diff --git a/bun.lock b/bun.lock index 633e9ebd..f7767c31 100644 --- a/bun.lock +++ b/bun.lock @@ -91,6 +91,12 @@ "@joplin/turndown-plugin-gfm": "^1.0.64", "turndown": "^7.2.4", }, + "devDependencies": { + "@mariozechner/pi-agent-core": ">=0.53.0", + "@mariozechner/pi-ai": ">=0.53.0", + "@mariozechner/pi-coding-agent": ">=0.53.0", + "@mariozechner/pi-tui": ">=0.53.0", + }, "peerDependencies": { "@mariozechner/pi-coding-agent": ">=0.53.0", }, @@ -1329,7 +1335,7 @@ "dagre-d3-es": ["dagre-d3-es@7.0.13", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q=="], - "data-uri-to-buffer": ["data-uri-to-buffer@2.0.2", "", {}, "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA=="], + "data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], "dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], @@ -2387,7 +2393,7 @@ "underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="], - "undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], @@ -2571,8 +2577,6 @@ "@mariozechner/pi-ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.73.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw=="], - "@mariozechner/pi-ai/undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="], - "@mariozechner/pi-coding-agent/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "@mariozechner/pi-coding-agent/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], @@ -2639,8 +2643,6 @@ "cheerio/parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], - "cheerio/undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="], - "chevrotain/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -2675,9 +2677,9 @@ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "get-source/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "get-source/data-uri-to-buffer": ["data-uri-to-buffer@2.0.2", "", {}, "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA=="], - "get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], + "get-source/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], @@ -2701,6 +2703,8 @@ "miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], + "miniflare/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], "node-fetch/data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], diff --git a/package.json b/package.json index 8b565a3c..4d140188 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "build:vscode": "bun run --cwd apps/vscode-extension build", "package:vscode": "bun run --cwd apps/vscode-extension package", "test": "bun test", - "typecheck": "tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json" + "typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.92", diff --git a/packages/server/integrations.ts b/packages/server/integrations.ts index 08c396e6..1f7b4b77 100644 --- a/packages/server/integrations.ts +++ b/packages/server/integrations.ts @@ -21,6 +21,7 @@ import { buildBearContent, detectObsidianVaults, } from "@plannotator/shared/integrations-common"; +import { resolveUserPath } from "@plannotator/shared/resolve-file"; export type { ObsidianConfig, BearConfig, OctarineConfig, IntegrationResult }; export { detectObsidianVaults, extractTitle, generateFrontmatter, generateFilename, generateOctarineFrontmatter, stripH1, buildHashtags, buildBearContent }; @@ -98,15 +99,12 @@ export async function saveToObsidian( try { const { vaultPath, folder, plan } = config; - // Normalize path (handle ~ on Unix, forward/back slashes) - let normalizedVault = vaultPath.trim(); - - // Expand ~ to home directory (Unix/macOS) - if (normalizedVault.startsWith("~")) { - const home = process.env.HOME || process.env.USERPROFILE || ""; - normalizedVault = join(home, normalizedVault.slice(1)); + if (!vaultPath?.trim()) { + return { success: false, error: "Vault path is required" }; } + const normalizedVault = resolveUserPath(vaultPath); + // Validate vault path exists and is a directory if (!existsSync(normalizedVault)) { return { diff --git a/packages/server/reference-handlers.ts b/packages/server/reference-handlers.ts index b3968e3d..6d3c9055 100644 --- a/packages/server/reference-handlers.ts +++ b/packages/server/reference-handlers.ts @@ -9,7 +9,12 @@ import { existsSync, statSync } from "fs"; import { resolve } from "path"; import { buildFileTree, FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; import { detectObsidianVaults } from "./integrations"; -import { resolveMarkdownFile, isWithinProjectRoot } from "@plannotator/shared/resolve-file"; +import { + isAbsoluteUserPath, + resolveMarkdownFile, + resolveUserPath, + isWithinProjectRoot, +} from "@plannotator/shared/resolve-file"; import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown"; // --- Route handlers --- @@ -29,12 +34,13 @@ export async function handleDoc(req: Request): Promise { // server (see annotate.ts /api/doc route). The standalone HTML block // below (no base) retains its cwd-based containment check. const base = url.searchParams.get("base"); + const resolvedBase = base ? resolveUserPath(base) : null; if ( - base && - !requestedPath.startsWith("/") && + resolvedBase && + !isAbsoluteUserPath(requestedPath) && /\.(mdx?|html?)$/i.test(requestedPath) ) { - const fromBase = resolve(base, requestedPath); + const fromBase = resolveUserPath(requestedPath, resolvedBase); try { const file = Bun.file(fromBase); if (await file.exists()) { @@ -50,7 +56,7 @@ export async function handleDoc(req: Request): Promise { // HTML files: resolve directly (not via resolveMarkdownFile which only handles .md/.mdx) const projectRoot = process.cwd(); if (/\.html?$/i.test(requestedPath)) { - const resolvedHtml = resolve(base || projectRoot, requestedPath); + const resolvedHtml = resolveUserPath(requestedPath, resolvedBase || projectRoot); if (!isWithinProjectRoot(resolvedHtml, projectRoot)) { return Response.json({ error: "Access denied: path is outside project root" }, { status: 403 }); } @@ -109,7 +115,7 @@ export async function handleObsidianFiles(req: Request): Promise { ); } - const resolvedVault = resolve(vaultPath); + const resolvedVault = resolveUserPath(vaultPath); if (!existsSync(resolvedVault) || !statSync(resolvedVault).isDirectory()) { return Response.json({ error: "Invalid vault path" }, { status: 400 }); } @@ -154,7 +160,7 @@ export async function handleObsidianDoc(req: Request): Promise { ); } - const resolvedVault = resolve(vaultPath); + const resolvedVault = resolveUserPath(vaultPath); let resolvedFile = resolve(resolvedVault, filePath); // If direct path doesn't exist and it's a bare filename, search the vault @@ -220,7 +226,7 @@ export async function handleFileBrowserFiles(req: Request): Promise { ); } - const resolvedDir = resolve(dirPath); + const resolvedDir = resolveUserPath(dirPath); if (!existsSync(resolvedDir) || !statSync(resolvedDir).isDirectory()) { return Response.json({ error: "Invalid directory path" }, { status: 400 }); } diff --git a/packages/server/resolve-file.test.ts b/packages/server/resolve-file.test.ts index cc4302cb..6d8beb0a 100644 --- a/packages/server/resolve-file.test.ts +++ b/packages/server/resolve-file.test.ts @@ -6,20 +6,23 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { - isAbsoluteMarkdownPath, - normalizeMarkdownPathInput, - resolveMarkdownFile, + expandHomePath, + isAbsoluteUserPath, + normalizeUserPathInput, + resolveMarkdownFile, + resolveUserPath, } from "@plannotator/shared/resolve-file"; const tempDirs: string[] = []; function createTempProject( files: Record = {}, + baseDir = join(tmpdir(), "plannotator-resolve-file-"), ): string { - const root = mkdtempSync(join(tmpdir(), "plannotator-resolve-file-")); + const root = mkdtempSync(baseDir); tempDirs.push(root); for (const [relativePath, content] of Object.entries(files)) { const full = join(root, relativePath); @@ -35,39 +38,88 @@ afterEach(() => { } }); -// --- Windows path normalization (PR #267) --- +// --- User path normalization --- + +describe("normalizeUserPathInput", () => { + test("expands tilde paths before normalization", () => { + expect(normalizeUserPathInput("~/test-plan.md")).toBe( + join(homedir(), "test-plan.md"), + ); + }); + + test("strips wrapping quotes", () => { + expect(normalizeUserPathInput('"~/test-plan.md"')).toBe( + join(homedir(), "test-plan.md"), + ); + }); -describe("normalizeMarkdownPathInput", () => { test("converts MSYS paths on Windows", () => { - expect(normalizeMarkdownPathInput("/c/Users/dev/test-plan.md", "win32")).toBe( + expect(normalizeUserPathInput("/c/Users/dev/test-plan.md", "win32")).toBe( "C:/Users/dev/test-plan.md", ); }); test("converts Cygwin paths on Windows", () => { - expect(normalizeMarkdownPathInput("/cygdrive/c/Users/dev/test-plan.md", "win32")).toBe( + expect(normalizeUserPathInput("/cygdrive/c/Users/dev/test-plan.md", "win32")).toBe( "C:/Users/dev/test-plan.md", ); }); test("leaves non-Windows paths unchanged", () => { - expect(normalizeMarkdownPathInput("/Users/dev/test-plan.md", "darwin")).toBe( + expect(normalizeUserPathInput("/Users/dev/test-plan.md", "darwin")).toBe( "/Users/dev/test-plan.md", ); }); }); -describe("isAbsoluteMarkdownPath", () => { +describe("expandHomePath", () => { + test("expands bare home alias", () => { + expect(expandHomePath("~", "/tmp/home")).toBe("/tmp/home"); + }); + + test("expands home-relative paths", () => { + expect(expandHomePath("~/docs/plan.md", "/tmp/home")).toBe( + join("/tmp/home", "docs/plan.md"), + ); + }); + + test("does not expand tilde usernames", () => { + expect(expandHomePath("~alice/docs/plan.md", "/tmp/home")).toBe( + "~alice/docs/plan.md", + ); + }); +}); + +describe("isAbsoluteUserPath", () => { test("detects Windows drive letter paths", () => { - expect(isAbsoluteMarkdownPath("C:\\Users\\dev\\test-plan.md", "win32")).toBe(true); - expect(isAbsoluteMarkdownPath("C:/Users/dev/test-plan.md", "win32")).toBe(true); + expect(isAbsoluteUserPath("C:\\Users\\dev\\test-plan.md", "win32")).toBe(true); + expect(isAbsoluteUserPath("C:/Users/dev/test-plan.md", "win32")).toBe(true); }); test("detects converted MSYS paths as absolute on Windows", () => { - expect(isAbsoluteMarkdownPath("/c/Users/dev/test-plan.md", "win32")).toBe(true); + expect(isAbsoluteUserPath("/c/Users/dev/test-plan.md", "win32")).toBe(true); }); }); +describe("resolveUserPath", () => { + test("resolves relative paths against a base directory", () => { + expect(resolveUserPath("docs/plan.md", "/tmp/project")).toBe( + resolve("/tmp/project", "docs/plan.md"), + ); + }); + + test("resolves quoted tilde paths", () => { + expect(resolveUserPath('"~/docs/plan.md"')).toBe( + resolve(homedir(), "docs/plan.md"), + ); + }); + + test("returns empty string for whitespace-only input", () => { + expect(resolveUserPath(" ", "/tmp/project")).toBe(""); + expect(resolveUserPath("", "/tmp/project")).toBe(""); + }); +}); + // --- Core resolution strategies --- describe("resolveMarkdownFile", () => { @@ -80,6 +132,15 @@ describe("resolveMarkdownFile", () => { expect(result).toEqual({ kind: "found", path: absPath }); }); + test("resolves tilde-prefixed absolute paths", async () => { + const homeRoot = createTempProject({}, join(homedir(), ".plannotator-resolve-file-")); + const absPath = resolve(homeRoot, "plan.md"); + writeFileSync(absPath, "# Plan"); + const relativeToHome = absPath.slice(homedir().length + 1).replace(/\\/g, "/"); + const result = resolveMarkdownFile(`~/${relativeToHome}`, "/unused"); + expect(result).toEqual({ kind: "found", path: absPath }); + }); + test("returns not_found for absolute path that doesn't exist", async () => { const root = createTempProject(); const result = resolveMarkdownFile("/nonexistent/path.md", root); diff --git a/packages/server/storage.test.ts b/packages/server/storage.test.ts index 8774d5e9..f2502551 100644 --- a/packages/server/storage.test.ts +++ b/packages/server/storage.test.ts @@ -75,6 +75,12 @@ describe("getPlanDir", () => { const result = getPlanDir(null); expect(result).toMatch(/\.plannotator\/plans$/); }); + + test("uses default for whitespace-only custom path", () => { + const result = getPlanDir(" "); + expect(result).toMatch(/\.plannotator\/plans$/); + expect(result).not.toBe(process.cwd()); + }); }); describe("savePlan", () => { diff --git a/packages/shared/resolve-file.ts b/packages/shared/resolve-file.ts index ac763c53..2ef9644b 100644 --- a/packages/shared/resolve-file.ts +++ b/packages/shared/resolve-file.ts @@ -9,6 +9,7 @@ * Used by both the CLI (`plannotator annotate`) and the `/api/doc` endpoint. */ +import { homedir } from "os"; import { isAbsolute, join, resolve, win32 } from "path"; import { existsSync, readdirSync, type Dirent } from "fs"; @@ -42,8 +43,93 @@ function stripTrailingSlashes(input: string): string { return input.replace(/\/+$/, ""); } +export function expandHomePath(input: string, home = homedir()): string { + if (input === "~") { + return home; + } + + if (input.startsWith("~/") || input.startsWith("~\\")) { + return join(home, input.slice(2)); + } + + return input; +} + +function stripWrappingQuotes(input: string): string { + if (input.length < 2) { + return input; + } + + const first = input[0]; + const last = input[input.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return input.slice(1, -1); + } + + return input; +} + +export function normalizeUserPathInput( + input: string, + platform = process.platform, +): string { + const trimmedInput = input.trim(); + const unquotedInput = stripWrappingQuotes(trimmedInput); + const expandedInput = expandHomePath(unquotedInput); + + if (platform !== "win32") { + return expandedInput; + } + + for (const pattern of WINDOWS_DRIVE_PATH_PATTERNS) { + const match = expandedInput.match(pattern); + if (!match) { + continue; + } + + const [, driveLetter, rest] = match; + return `${driveLetter.toUpperCase()}:/${rest}`; + } + + return expandedInput; +} + +function isAbsoluteNormalizedUserPath( + input: string, + platform = process.platform, +): boolean { + if (hasWindowsDriveLetter(input)) { + return true; + } + + return platform === "win32" + ? win32.isAbsolute(input) + : isAbsolute(input); +} + +export function isAbsoluteUserPath( + input: string, + platform = process.platform, +): boolean { + return isAbsoluteNormalizedUserPath(normalizeUserPathInput(input, platform), platform); +} + +export function resolveUserPath( + input: string, + baseDir = process.cwd(), + platform = process.platform, +): string { + const normalizedInput = normalizeUserPathInput(input, platform); + if (!normalizedInput) { + return ""; + } + return isAbsoluteNormalizedUserPath(normalizedInput, platform) + ? resolveAbsolutePath(normalizedInput, platform) + : resolve(baseDir, normalizedInput); +} + function normalizeComparablePath(input: string): string { - return stripTrailingSlashes(normalizeSeparators(resolve(input))); + return stripTrailingSlashes(normalizeSeparators(resolveUserPath(input))); } export function isWithinProjectRoot(candidate: string, projectRoot: string): boolean { @@ -78,20 +164,6 @@ function isSearchableMarkdownPath(input: string): boolean { return MARKDOWN_PATH_REGEX.test(input.trim()); } -function stripWrappingQuotes(input: string): string { - if (input.length < 2) { - return input; - } - - const first = input[0]; - const last = input[input.length - 1]; - if ((first === '"' && last === '"') || (first === "'" && last === "'")) { - return input.slice(1, -1); - } - - return input; -} - /** Check if a path looks like a Windows absolute path (e.g. C:\ or C:/) */ function hasWindowsDriveLetter(input: string): boolean { return /^[a-zA-Z]:[/\\]/.test(input); @@ -127,42 +199,6 @@ function walkMarkdownFiles(dir: string, root: string, results: string[], ignored } } -export function normalizeMarkdownPathInput( - input: string, - platform = process.platform, -): string { - if (platform !== "win32") { - return input; - } - - for (const pattern of WINDOWS_DRIVE_PATH_PATTERNS) { - const match = input.match(pattern); - if (!match) { - continue; - } - - const [, driveLetter, rest] = match; - return `${driveLetter.toUpperCase()}:/${rest}`; - } - - return input; -} - -export function isAbsoluteMarkdownPath( - input: string, - platform = process.platform, -): boolean { - const normalizedInput = normalizeMarkdownPathInput(input, platform); - // Always check for Windows drive letters (handles compiled Bun exes where - // process.platform may not reflect the actual OS correctly) - if (hasWindowsDriveLetter(normalizedInput)) { - return true; - } - return platform === "win32" - ? win32.isAbsolute(normalizedInput) - : isAbsolute(normalizedInput); -} - /** * Resolve a markdown file path within a project root. * @@ -173,7 +209,7 @@ function resolveMarkdownFileCore( input: string, projectRoot: string, ): ResolveResult { - const normalizedInput = normalizeMarkdownPathInput(input); + const normalizedInput = normalizeUserPathInput(input); const searchInput = normalizeSeparators(normalizedInput); const isBareFilename = !searchInput.includes("/"); const targetLookupKey = getLookupKey(searchInput, isBareFilename); @@ -185,7 +221,7 @@ function resolveMarkdownFileCore( // 1. Absolute path — use as-is (no project root restriction; // the user explicitly typed the full path) - if (isAbsoluteMarkdownPath(normalizedInput)) { + if (isAbsoluteNormalizedUserPath(normalizedInput)) { const absolutePath = resolveAbsolutePath(normalizedInput); if (fileExists(absolutePath)) { return { kind: "found", path: absolutePath }; diff --git a/packages/shared/storage.ts b/packages/shared/storage.ts index 758634fe..c3d805c2 100644 --- a/packages/shared/storage.ts +++ b/packages/shared/storage.ts @@ -11,6 +11,7 @@ import { homedir } from "os"; import { join, resolve, sep } from "path"; import { mkdirSync, writeFileSync, readFileSync, readdirSync, statSync, existsSync } from "fs"; import { sanitizeTag } from "./project"; +import { resolveUserPath } from "./resolve-file"; /** * Get the plan storage directory, creating it if needed. @@ -20,16 +21,12 @@ import { sanitizeTag } from "./project"; export function getPlanDir(customPath?: string | null): string { let planDir: string; - if (customPath) { - // Expand ~ to home directory - planDir = customPath.startsWith("~") - ? join(homedir(), customPath.slice(1)) - : customPath; + if (customPath?.trim()) { + planDir = resolveUserPath(customPath); } else { planDir = join(homedir(), ".plannotator", "plans"); } - planDir = resolve(planDir); mkdirSync(planDir, { recursive: true }); return planDir; }