Skip to content

Commit 54c206c

Browse files
Add ~ support for user-entered file paths (#572)
* refactor(path): centralize user path resolution * fix(annotate): resolve user paths in file entrypoints * fix(pi-extension): restore resolve import and add typecheck to CI The refactor removed `resolve` from `node:path` imports, but `resolvePlanPath()` and the planning-mode write/edit guards still call `resolve(...)`. That breaks plan submission and plan-file restriction at runtime for Pi users. Also wires pi-extension's tsconfig into the root `typecheck` script so CI catches this class of missing-symbol regression in the future. Required adding @mariozechner/pi-* packages as explicit devDependencies so tsc can resolve them (they were previously only reachable transitively via the peer dep, which Bun keeps in its `.bun/` store unhoisted). For provenance purposes, this commit was AI assisted. * fix(path): reject whitespace-only user paths and run vendor before typecheck resolveUserPath() trims input, so whitespace-only customPath/vaultPath resolved to process.cwd(). Plans silently wrote into the repo root and Obsidian notes landed in <cwd>/plannotator/ instead of erroring. Guard at both call sites (getPlanDir, saveToObsidian — Bun + Pi copies). Also prepend vendor.sh to the root typecheck script so fresh-clone `bun run typecheck` works without a separate vendoring step. For provenance purposes, this commit was AI assisted. * fix(path): short-circuit resolveUserPath on empty input Trimming in normalizeUserPathInput meant whitespace-only input resolved to cwd/baseDir. Callers like the annotate CLI and reference API endpoints would then list the project root instead of erroring. Return "" early so downstream existsSync/resolveMarkdownFile checks fail naturally. For provenance purposes, this commit was AI assisted. --------- Co-authored-by: Michael Ramos <mdramos8@gmail.com>
1 parent 9bc1ba9 commit 54c206c

File tree

14 files changed

+236
-116
lines changed

14 files changed

+236
-116
lines changed

apps/hook/server/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ import { urlToMarkdown } from "@plannotator/shared/url-to-markdown";
7070
import { fetchRef, createWorktree, removeWorktree, ensureObjectAvailable } from "@plannotator/shared/worktree";
7171
import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr";
7272
import { writeRemoteShareLink } from "@plannotator/server/share-url";
73-
import { resolveMarkdownFile, hasMarkdownFiles } from "@plannotator/shared/resolve-file";
73+
import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file";
7474
import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common";
7575
import { statSync, rmSync, realpathSync, existsSync } from "fs";
7676
import { parseRemoteUrl } from "@plannotator/shared/repo";
@@ -513,7 +513,7 @@ if (args[0] === "sessions") {
513513
sourceInfo = filePath; // Full URL for source attribution
514514
} else {
515515
// Check if the argument is a directory (folder annotation mode)
516-
const resolvedArg = path.resolve(projectRoot, filePath);
516+
const resolvedArg = resolveUserPath(filePath, projectRoot);
517517
let isFolder = false;
518518
try {
519519
isFolder = statSync(resolvedArg).isDirectory();

apps/opencode-plugin/commands.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
import { getGitContext, runGitDiffWithContext } from "@plannotator/server/git";
2222
import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr";
2323
import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config";
24-
import { resolveMarkdownFile } from "@plannotator/shared/resolve-file";
24+
import { resolveMarkdownFile, resolveUserPath } from "@plannotator/shared/resolve-file";
2525
import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown";
2626
import { urlToMarkdown } from "@plannotator/shared/url-to-markdown";
2727
import { statSync } from "fs";
@@ -179,7 +179,7 @@ export async function handleAnnotateCommand(
179179
sourceInfo = filePath;
180180
} else {
181181
const projectRoot = process.cwd();
182-
const resolvedArg = path.resolve(projectRoot, filePath);
182+
const resolvedArg = resolveUserPath(filePath, projectRoot);
183183

184184
if (/\.html?$/i.test(resolvedArg)) {
185185
// HTML file annotation — convert to markdown via Turndown

apps/pi-extension/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
*/
2020

2121
import { existsSync, readFileSync, statSync } from "node:fs";
22-
import { resolve, basename } from "node:path";
22+
import { basename, resolve } from "node:path";
2323
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
2424
import { Type } from "@mariozechner/pi-ai";
2525
import type {
@@ -34,7 +34,7 @@ import {
3434
parseChecklist,
3535
} from "./generated/checklist.js";
3636
import { planDenyFeedback } from "./generated/feedback-templates.js";
37-
import { hasMarkdownFiles } from "./generated/resolve-file.js";
37+
import { hasMarkdownFiles, resolveUserPath } from "./generated/resolve-file.js";
3838
import { FILE_BROWSER_EXCLUDED } from "./generated/reference-common.js";
3939
import { htmlToMarkdown } from "./generated/html-to-markdown.js";
4040
import { urlToMarkdown } from "./generated/url-to-markdown.js";
@@ -417,7 +417,7 @@ export default function plannotator(pi: ExtensionAPI): void {
417417
absolutePath = filePath;
418418
sourceInfo = filePath;
419419
} else {
420-
absolutePath = resolve(ctx.cwd, filePath);
420+
absolutePath = resolveUserPath(filePath, ctx.cwd);
421421
if (!existsSync(absolutePath)) {
422422
ctx.ui.notify(`File not found: ${absolutePath}`, "error");
423423
return;

apps/pi-extension/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,11 @@
4444
},
4545
"peerDependencies": {
4646
"@mariozechner/pi-coding-agent": ">=0.53.0"
47+
},
48+
"devDependencies": {
49+
"@mariozechner/pi-coding-agent": ">=0.53.0",
50+
"@mariozechner/pi-agent-core": ">=0.53.0",
51+
"@mariozechner/pi-ai": ">=0.53.0",
52+
"@mariozechner/pi-tui": ">=0.53.0"
4753
}
4854
}

apps/pi-extension/server/integrations.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
detectObsidianVaults,
2424
} from "../generated/integrations-common.js";
2525
import { sanitizeTag } from "../generated/project.js";
26+
import { resolveUserPath } from "../generated/resolve-file.js";
2627

2728
export type { ObsidianConfig, BearConfig, OctarineConfig, IntegrationResult };
2829
export {
@@ -111,11 +112,10 @@ export async function saveToObsidian(
111112
): Promise<IntegrationResult> {
112113
try {
113114
const { vaultPath, folder, plan } = config;
114-
let normalizedVault = vaultPath.trim();
115-
if (normalizedVault.startsWith("~")) {
116-
const home = process.env.HOME || process.env.USERPROFILE || "";
117-
normalizedVault = join(home, normalizedVault.slice(1));
115+
if (!vaultPath?.trim()) {
116+
return { success: false, error: "Vault path is required" };
118117
}
118+
const normalizedVault = resolveUserPath(vaultPath);
119119
if (!existsSync(normalizedVault))
120120
return {
121121
success: false,

apps/pi-extension/server/reference.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@ import {
2222
FILE_BROWSER_EXCLUDED,
2323
} from "../generated/reference-common.js";
2424
import { detectObsidianVaults } from "../generated/integrations-common.js";
25-
import { resolveMarkdownFile, isWithinProjectRoot } from "../generated/resolve-file.js";
25+
import {
26+
isAbsoluteUserPath,
27+
resolveMarkdownFile,
28+
resolveUserPath,
29+
isWithinProjectRoot,
30+
} from "../generated/resolve-file.js";
2631
import { htmlToMarkdown } from "../generated/html-to-markdown.js";
2732

2833
type Res = ServerResponse;
@@ -62,12 +67,13 @@ export function handleDocRequest(res: Res, url: URL): void {
6267
// server (see serverAnnotate.ts /api/doc route). The standalone HTML
6368
// block below (no base) retains its cwd-based containment check.
6469
const base = url.searchParams.get("base");
70+
const resolvedBase = base ? resolveUserPath(base) : null;
6571
if (
66-
base &&
67-
!requestedPath.startsWith("/") &&
72+
resolvedBase &&
73+
!isAbsoluteUserPath(requestedPath) &&
6874
/\.(mdx?|html?)$/i.test(requestedPath)
6975
) {
70-
const fromBase = resolvePath(base, requestedPath);
76+
const fromBase = resolveUserPath(requestedPath, resolvedBase);
7177
try {
7278
if (existsSync(fromBase)) {
7379
const raw = readFileSync(fromBase, "utf-8");
@@ -83,7 +89,7 @@ export function handleDocRequest(res: Res, url: URL): void {
8389
// HTML files: resolve directly (not via resolveMarkdownFile which only handles .md/.mdx)
8490
const projectRoot = process.cwd();
8591
if (/\.html?$/i.test(requestedPath)) {
86-
const resolvedHtml = resolvePath(base || projectRoot, requestedPath);
92+
const resolvedHtml = resolveUserPath(requestedPath, resolvedBase || projectRoot);
8793
if (!isWithinProjectRoot(resolvedHtml, projectRoot)) {
8894
json(res, { error: "Access denied: path is outside project root" }, 403);
8995
return;
@@ -136,7 +142,7 @@ export function handleObsidianFilesRequest(res: Res, url: URL): void {
136142
json(res, { error: "Missing vaultPath parameter" }, 400);
137143
return;
138144
}
139-
const resolvedVault = resolvePath(vaultPath);
145+
const resolvedVault = resolveUserPath(vaultPath);
140146
if (!existsSync(resolvedVault) || !statSync(resolvedVault).isDirectory()) {
141147
json(res, { error: "Invalid vault path" }, 400);
142148
return;
@@ -162,7 +168,7 @@ export function handleObsidianDocRequest(res: Res, url: URL): void {
162168
json(res, { error: "Only markdown files are supported" }, 400);
163169
return;
164170
}
165-
const resolvedVault = resolvePath(vaultPath);
171+
const resolvedVault = resolveUserPath(vaultPath);
166172
let resolvedFile = resolvePath(resolvedVault, filePath);
167173

168174
// Bare filename search within vault
@@ -214,7 +220,7 @@ export function handleFileBrowserRequest(res: Res, url: URL): void {
214220
json(res, { error: "Missing dirPath parameter" }, 400);
215221
return;
216222
}
217-
const resolvedDir = resolvePath(dirPath);
223+
const resolvedDir = resolveUserPath(dirPath);
218224
if (!existsSync(resolvedDir) || !statSync(resolvedDir).isDirectory()) {
219225
json(res, { error: "Invalid directory path" }, 400);
220226
return;

bun.lock

Lines changed: 12 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"build:vscode": "bun run --cwd apps/vscode-extension build",
3131
"package:vscode": "bun run --cwd apps/vscode-extension package",
3232
"test": "bun test",
33-
"typecheck": "tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json"
33+
"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"
3434
},
3535
"dependencies": {
3636
"@anthropic-ai/claude-agent-sdk": "^0.2.92",

packages/server/integrations.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
buildBearContent,
2222
detectObsidianVaults,
2323
} from "@plannotator/shared/integrations-common";
24+
import { resolveUserPath } from "@plannotator/shared/resolve-file";
2425

2526
export type { ObsidianConfig, BearConfig, OctarineConfig, IntegrationResult };
2627
export { detectObsidianVaults, extractTitle, generateFrontmatter, generateFilename, generateOctarineFrontmatter, stripH1, buildHashtags, buildBearContent };
@@ -98,15 +99,12 @@ export async function saveToObsidian(
9899
try {
99100
const { vaultPath, folder, plan } = config;
100101

101-
// Normalize path (handle ~ on Unix, forward/back slashes)
102-
let normalizedVault = vaultPath.trim();
103-
104-
// Expand ~ to home directory (Unix/macOS)
105-
if (normalizedVault.startsWith("~")) {
106-
const home = process.env.HOME || process.env.USERPROFILE || "";
107-
normalizedVault = join(home, normalizedVault.slice(1));
102+
if (!vaultPath?.trim()) {
103+
return { success: false, error: "Vault path is required" };
108104
}
109105

106+
const normalizedVault = resolveUserPath(vaultPath);
107+
110108
// Validate vault path exists and is a directory
111109
if (!existsSync(normalizedVault)) {
112110
return {

packages/server/reference-handlers.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ import { existsSync, statSync } from "fs";
99
import { resolve } from "path";
1010
import { buildFileTree, FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common";
1111
import { detectObsidianVaults } from "./integrations";
12-
import { resolveMarkdownFile, isWithinProjectRoot } from "@plannotator/shared/resolve-file";
12+
import {
13+
isAbsoluteUserPath,
14+
resolveMarkdownFile,
15+
resolveUserPath,
16+
isWithinProjectRoot,
17+
} from "@plannotator/shared/resolve-file";
1318
import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown";
1419

1520
// --- Route handlers ---
@@ -29,12 +34,13 @@ export async function handleDoc(req: Request): Promise<Response> {
2934
// server (see annotate.ts /api/doc route). The standalone HTML block
3035
// below (no base) retains its cwd-based containment check.
3136
const base = url.searchParams.get("base");
37+
const resolvedBase = base ? resolveUserPath(base) : null;
3238
if (
33-
base &&
34-
!requestedPath.startsWith("/") &&
39+
resolvedBase &&
40+
!isAbsoluteUserPath(requestedPath) &&
3541
/\.(mdx?|html?)$/i.test(requestedPath)
3642
) {
37-
const fromBase = resolve(base, requestedPath);
43+
const fromBase = resolveUserPath(requestedPath, resolvedBase);
3844
try {
3945
const file = Bun.file(fromBase);
4046
if (await file.exists()) {
@@ -50,7 +56,7 @@ export async function handleDoc(req: Request): Promise<Response> {
5056
// HTML files: resolve directly (not via resolveMarkdownFile which only handles .md/.mdx)
5157
const projectRoot = process.cwd();
5258
if (/\.html?$/i.test(requestedPath)) {
53-
const resolvedHtml = resolve(base || projectRoot, requestedPath);
59+
const resolvedHtml = resolveUserPath(requestedPath, resolvedBase || projectRoot);
5460
if (!isWithinProjectRoot(resolvedHtml, projectRoot)) {
5561
return Response.json({ error: "Access denied: path is outside project root" }, { status: 403 });
5662
}
@@ -109,7 +115,7 @@ export async function handleObsidianFiles(req: Request): Promise<Response> {
109115
);
110116
}
111117

112-
const resolvedVault = resolve(vaultPath);
118+
const resolvedVault = resolveUserPath(vaultPath);
113119
if (!existsSync(resolvedVault) || !statSync(resolvedVault).isDirectory()) {
114120
return Response.json({ error: "Invalid vault path" }, { status: 400 });
115121
}
@@ -154,7 +160,7 @@ export async function handleObsidianDoc(req: Request): Promise<Response> {
154160
);
155161
}
156162

157-
const resolvedVault = resolve(vaultPath);
163+
const resolvedVault = resolveUserPath(vaultPath);
158164
let resolvedFile = resolve(resolvedVault, filePath);
159165

160166
// 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<Response> {
220226
);
221227
}
222228

223-
const resolvedDir = resolve(dirPath);
229+
const resolvedDir = resolveUserPath(dirPath);
224230
if (!existsSync(resolvedDir) || !statSync(resolvedDir).isDirectory()) {
225231
return Response.json({ error: "Invalid directory path" }, { status: 400 });
226232
}

0 commit comments

Comments
 (0)