From bf1d0465284e78f98a47acfa0e2cfce33c91ac93 Mon Sep 17 00:00:00 2001 From: Joao-O-Santos Date: Fri, 24 Apr 2026 20:30:43 +0100 Subject: [PATCH 1/9] shared: introduce XDG-style path resolution Add packages/shared/paths.ts as the single source of truth for config/data/cache directories. Respects PLANNOTATOR_*_DIR overrides and XDG_*_HOME variables, falling back to ~/.config/plannotator, ~/.config/plannotator/data, and ~/.cache/plannotator. Updates config.ts, storage.ts, draft.ts, and improvement-hooks.ts to use the new paths with transparent legacy fallback for reads. Archive listing merges results from new and legacy paths so existing plans remain visible. --- packages/shared/config.ts | 36 +++++++++---- packages/shared/draft.ts | 6 +-- packages/shared/improvement-hooks.ts | 12 ++--- packages/shared/paths.ts | 49 ++++++++++++++++++ packages/shared/storage.ts | 75 ++++++++++++++++++++-------- 5 files changed, 136 insertions(+), 42 deletions(-) create mode 100644 packages/shared/paths.ts diff --git a/packages/shared/config.ts b/packages/shared/config.ts index eeba1b951..fd9322714 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -1,14 +1,14 @@ /** * Plannotator Config * - * Reads/writes ~/.plannotator/config.json for persistent user settings. + * Reads/writes ~/.config/plannotator/config.json for persistent user settings. * Runtime-agnostic: uses only node:fs, node:os, node:child_process. */ -import { homedir } from "os"; import { join } from "path"; -import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; +import { readFileSync, writeFileSync, existsSync } from "fs"; import { execSync } from "child_process"; +import { getConfigDir, getLegacyDir } from "./paths"; export type DefaultDiffType = 'uncommitted' | 'unstaged' | 'staged'; @@ -54,17 +54,32 @@ export interface PlannotatorConfig { jina?: boolean; } -const CONFIG_DIR = join(homedir(), ".plannotator"); -const CONFIG_PATH = join(CONFIG_DIR, "config.json"); +function getConfigPath(): string { + return join(getConfigDir(), "config.json"); +} + +function getLegacyConfigPath(): string { + return join(getLegacyDir(), "config.json"); +} /** - * Load config from ~/.plannotator/config.json. + * Load config from ~/.config/plannotator/config.json. + * Falls back to legacy ~/.plannotator/config.json if the new path does not exist. * Returns {} on missing file or malformed JSON. */ export function loadConfig(): PlannotatorConfig { try { - if (!existsSync(CONFIG_PATH)) return {}; - const raw = readFileSync(CONFIG_PATH, "utf-8"); + const newPath = getConfigPath(); + const legacyPath = getLegacyConfigPath(); + + let raw: string | undefined; + if (existsSync(newPath)) { + raw = readFileSync(newPath, "utf-8"); + } else if (existsSync(legacyPath)) { + raw = readFileSync(legacyPath, "utf-8"); + } + + if (raw === undefined) return {}; const parsed = JSON.parse(raw); return typeof parsed === "object" && parsed !== null ? parsed : {}; } catch (e) { @@ -75,7 +90,7 @@ export function loadConfig(): PlannotatorConfig { /** * Save config by merging partial values into the existing file. - * Creates ~/.plannotator/ directory if needed. + * Creates ~/.config/plannotator/ directory if needed. */ export function saveConfig(partial: Partial): void { try { @@ -84,8 +99,7 @@ export function saveConfig(partial: Partial): void { ? { ...current.diffOptions, ...partial.diffOptions } : undefined; const merged = { ...current, ...partial, diffOptions: mergedDiffOptions }; - mkdirSync(CONFIG_DIR, { recursive: true }); - writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2) + "\n", "utf-8"); + writeFileSync(getConfigPath(), JSON.stringify(merged, null, 2) + "\n", "utf-8"); } catch (e) { process.stderr.write(`[plannotator] Warning: failed to write config.json: ${e}\n`); } diff --git a/packages/shared/draft.ts b/packages/shared/draft.ts index 41ca501e3..0ef3dc66d 100644 --- a/packages/shared/draft.ts +++ b/packages/shared/draft.ts @@ -1,23 +1,23 @@ /** * Draft Storage * - * Persists annotation drafts to ~/.plannotator/drafts/ so they survive + * Persists annotation drafts to ~/.cache/plannotator/drafts/ so they survive * server crashes. Each draft is keyed by a content hash of the plan/diff * it was created against. * * Runtime-agnostic: uses only node:fs, node:path, node:os, node:crypto. */ -import { homedir } from "os"; import { join } from "path"; import { mkdirSync, writeFileSync, readFileSync, unlinkSync, existsSync } from "fs"; import { createHash } from "crypto"; +import { getCacheDir } from "./paths"; /** * Get the drafts directory, creating it if needed. */ export function getDraftDir(): string { - const dir = join(homedir(), ".plannotator", "drafts"); + const dir = join(getCacheDir(), "drafts"); mkdirSync(dir, { recursive: true }); return dir; } diff --git a/packages/shared/improvement-hooks.ts b/packages/shared/improvement-hooks.ts index 465cc0aca..ebf00e0a4 100644 --- a/packages/shared/improvement-hooks.ts +++ b/packages/shared/improvement-hooks.ts @@ -1,7 +1,7 @@ /** * Improvement Hook Reader * - * Reads improvement hook files from ~/.plannotator/hooks/. + * Reads improvement hook files from ~/.config/plannotator/data/hooks/. * Falls back to the legacy path (~/.plannotator/) when the new-path * file is absent, for compatibility with files written before the * path migration. If the new-path file exists but is invalid (empty, @@ -14,25 +14,25 @@ * - Hardcoded base paths (no user input determines file path) * - KNOWN_HOOKS allowlist (only pre-registered relative paths) * - Size cap to prevent runaway context injection - * - Same trust model as ~/.plannotator/config.json + * - Same trust model as ~/.config/plannotator/config.json */ -import { homedir } from "os"; import { join } from "path"; import { readFileSync, statSync } from "fs"; +import { getDataDir, getLegacyDir } from "./paths"; /** Base directory for hook-injectable files (new path) */ -const HOOKS_BASE_DIR = join(homedir(), ".plannotator", "hooks"); +const HOOKS_BASE_DIR = join(getDataDir(), "hooks"); /** Legacy base directory (pre-migration path) */ -const LEGACY_BASE_DIR = join(homedir(), ".plannotator"); +const LEGACY_BASE_DIR = getLegacyDir(); /** Maximum file size to read (50 KB) */ const MAX_FILE_SIZE = 50 * 1024; /** * Known improvement hook file paths, keyed by hook name. - * `path` is relative to HOOKS_BASE_DIR (~/.plannotator/hooks/). + * `path` is relative to HOOKS_BASE_DIR (~/.config/plannotator/data/hooks/). * `legacyPath` is relative to LEGACY_BASE_DIR (~/.plannotator/). */ const KNOWN_HOOKS = { diff --git a/packages/shared/paths.ts b/packages/shared/paths.ts new file mode 100644 index 000000000..38be5110c --- /dev/null +++ b/packages/shared/paths.ts @@ -0,0 +1,49 @@ +/** + * Plannotator Path Resolution + * + * Single source of truth for all directory paths. + * Respects XDG Base Directory Specification and PLANNOTATOR_*_DIR overrides. + * + * Resolution priority (highest wins): + * 1. PLANNOTATOR_*_DIR environment variable + * 2. XDG_*_HOME environment variable + * 3. Default (~/.config, ~/.cache) + * + * Migration strategy: + * - Reads: new path first, fall back to legacy ~/.plannotator/ + * - Writes: always write to new path + * - Cache: no fallback (reconstructible) + */ + +import { homedir } from "os"; +import { join } from "path"; +import { mkdirSync } from "fs"; + +export function getConfigDir(): string { + const dir = + process.env.PLANNOTATOR_CONFIG_DIR || + (process.env.XDG_CONFIG_HOME && join(process.env.XDG_CONFIG_HOME, "plannotator")) || + join(homedir(), ".config", "plannotator"); + mkdirSync(dir, { recursive: true }); + return dir; +} + +export function getDataDir(): string { + const dir = + process.env.PLANNOTATOR_DATA_DIR || join(getConfigDir(), "data"); + mkdirSync(dir, { recursive: true }); + return dir; +} + +export function getCacheDir(): string { + const dir = + process.env.PLANNOTATOR_CACHE_DIR || + (process.env.XDG_CACHE_HOME && join(process.env.XDG_CACHE_HOME, "plannotator")) || + join(homedir(), ".cache", "plannotator"); + mkdirSync(dir, { recursive: true }); + return dir; +} + +export function getLegacyDir(): string { + return join(homedir(), ".plannotator"); +} diff --git a/packages/shared/storage.ts b/packages/shared/storage.ts index c3d805c2e..4bbb74942 100644 --- a/packages/shared/storage.ts +++ b/packages/shared/storage.ts @@ -1,17 +1,17 @@ /** * Plan Storage Utility * - * Saves plans and annotations to ~/.plannotator/plans/ + * Saves plans and annotations to ~/.config/plannotator/data/plans/ * Cross-platform: works on Windows, macOS, and Linux. * * Runtime-agnostic: uses only node:fs, node:path, node:os. */ -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"; +import { getDataDir, getLegacyDir } from "./paths"; /** * Get the plan storage directory, creating it if needed. @@ -24,7 +24,7 @@ export function getPlanDir(customPath?: string | null): string { if (customPath?.trim()) { planDir = resolveUserPath(customPath); } else { - planDir = join(homedir(), ".plannotator", "plans"); + planDir = join(getDataDir(), "plans"); } mkdirSync(planDir, { recursive: true }); @@ -149,12 +149,7 @@ export function parseArchiveFilename(filename: string): ArchivedPlan | null { return { filename, title, date, timestamp: "", status, size: 0 }; } -/** - * List all archived plans (approved/denied decision snapshots). - * Returns plans sorted by date descending. - */ -export function listArchivedPlans(customPath?: string | null): ArchivedPlan[] { - const planDir = getPlanDir(customPath); +function readArchiveDir(planDir: string): ArchivedPlan[] { try { const entries = readdirSync(planDir); const plans: ArchivedPlan[] = []; @@ -169,37 +164,73 @@ export function listArchivedPlans(customPath?: string | null): ArchivedPlan[] { } catch { /* keep defaults */ } plans.push(parsed); } - return plans.sort((a, b) => b.date.localeCompare(a.date) || b.timestamp.localeCompare(a.timestamp)); + return plans; } catch { return []; } } +/** + * List all archived plans (approved/denied decision snapshots). + * Merges results from new data path and legacy path so existing plans remain visible. + * Returns plans sorted by date descending. + */ +export function listArchivedPlans(customPath?: string | null): ArchivedPlan[] { + const planDir = getPlanDir(customPath); + const legacyDir = customPath?.trim() + ? null + : join(getLegacyDir(), "plans"); + + const plans = readArchiveDir(planDir); + const legacyPlans = legacyDir ? readArchiveDir(legacyDir) : []; + + // Deduplicate by filename + const seen = new Set(plans.map((p) => p.filename)); + for (const p of legacyPlans) { + if (!seen.has(p.filename)) plans.push(p); + } + + return plans.sort((a, b) => b.date.localeCompare(a.date) || b.timestamp.localeCompare(a.timestamp)); +} + /** * Read an archived plan file by filename. + * Tries new path first, falls back to legacy path. * Returns null if the file doesn't exist or on read error. */ export function readArchivedPlan(filename: string, customPath?: string | null): string | null { const planDir = getPlanDir(customPath); const filePath = resolve(planDir, filename); // Guard against path traversal (resolve + trailing separator, matching reference-handlers.ts) - if (!filePath.startsWith(planDir + sep)) return null; - try { - return readFileSync(filePath, "utf-8"); - } catch { - return null; + if (filePath.startsWith(planDir + sep)) { + try { + return readFileSync(filePath, "utf-8"); + } catch { /* fall through */ } } + + // Fallback to legacy path + if (!customPath?.trim()) { + const legacyDir = join(getLegacyDir(), "plans"); + const legacyPath = resolve(legacyDir, filename); + if (legacyPath.startsWith(legacyDir + sep)) { + try { + return readFileSync(legacyPath, "utf-8"); + } catch { /* fall through */ } + } + } + + return null; } // --- Version History --- /** * Get the history directory for a project/slug combination, creating it if needed. - * History is always stored in ~/.plannotator/history/{project}/{slug}/. + * History is stored in ~/.config/plannotator/data/history/{project}/{slug}/. * Not affected by the customPath setting (that only affects decision saves). */ export function getHistoryDir(project: string, slug: string): string { - const historyDir = join(homedir(), ".plannotator", "history", project, slug); + const historyDir = join(getDataDir(), "history", project, slug); mkdirSync(historyDir, { recursive: true }); return historyDir; } @@ -266,7 +297,7 @@ export function getPlanVersion( slug: string, version: number ): string | null { - const historyDir = join(homedir(), ".plannotator", "history", project, slug); + const historyDir = join(getDataDir(), "history", project, slug); const fileName = `${String(version).padStart(3, "0")}.md`; const filePath = join(historyDir, fileName); @@ -286,7 +317,7 @@ export function getPlanVersionPath( slug: string, version: number ): string | null { - const historyDir = join(homedir(), ".plannotator", "history", project, slug); + const historyDir = join(getDataDir(), "history", project, slug); const fileName = `${String(version).padStart(3, "0")}.md`; const filePath = join(historyDir, fileName); return existsSync(filePath) ? filePath : null; @@ -297,7 +328,7 @@ export function getPlanVersionPath( * Returns 0 if the directory doesn't exist. */ export function getVersionCount(project: string, slug: string): number { - const historyDir = join(homedir(), ".plannotator", "history", project, slug); + const historyDir = join(getDataDir(), "history", project, slug); try { const entries = readdirSync(historyDir); return entries.filter((e) => /^\d+\.md$/.test(e)).length; @@ -314,7 +345,7 @@ export function listVersions( project: string, slug: string ): Array<{ version: number; timestamp: string }> { - const historyDir = join(homedir(), ".plannotator", "history", project, slug); + const historyDir = join(getDataDir(), "history", project, slug); try { const entries = readdirSync(historyDir); const versions: Array<{ version: number; timestamp: string }> = []; @@ -344,7 +375,7 @@ export function listVersions( export function listProjectPlans( project: string ): Array<{ slug: string; versions: number; lastModified: string }> { - const projectDir = join(homedir(), ".plannotator", "history", project); + const projectDir = join(getDataDir(), "history", project); try { const entries = readdirSync(projectDir, { withFileTypes: true }); const plans: Array<{ slug: string; versions: number; lastModified: string }> = []; From cf2f41fe8914cee1087b5cb07d310037243143df Mon Sep 17 00:00:00 2001 From: Joao-O-Santos Date: Fri, 24 Apr 2026 20:32:39 +0100 Subject: [PATCH 2/9] server: migrate runtime paths to XDG Update server-side paths to use getCacheDir() for transient state: - sessions.ts: ~/.cache/plannotator/sessions/ - browser.ts: ~/.cache/plannotator/vscode-ipc.json - codex-review.ts: ~/.cache/plannotator/ (debug log + schema) - tour/tour-review.ts: ~/.cache/plannotator/ (tour schema) --- packages/server/browser.ts | 5 +++-- packages/server/codex-review.ts | 9 +++++---- packages/server/sessions.ts | 6 +++--- packages/server/tour/tour-review.ts | 5 +++-- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/server/browser.ts b/packages/server/browser.ts index 68a85375c..00e4c1be9 100644 --- a/packages/server/browser.ts +++ b/packages/server/browser.ts @@ -3,11 +3,11 @@ */ import { $ } from "bun"; -import os from "node:os"; import path from "node:path"; import fs from "node:fs"; +import { getCacheDir } from "@plannotator/shared/paths"; -const IPC_REGISTRY = path.join(os.homedir(), ".plannotator", "vscode-ipc.json"); +const IPC_REGISTRY = path.join(getCacheDir(), "vscode-ipc.json"); /** * Try opening URL via VS Code extension IPC registry. @@ -46,6 +46,7 @@ export async function isWSL(): Promise { return false; } + const os = await import("node:os"); if (os.release().toLowerCase().includes("microsoft")) { return true; } diff --git a/packages/server/codex-review.ts b/packages/server/codex-review.ts index abef52f6e..97878d440 100644 --- a/packages/server/codex-review.ts +++ b/packages/server/codex-review.ts @@ -6,24 +6,25 @@ */ import { join } from "node:path"; -import { homedir, tmpdir } from "node:os"; +import { tmpdir } from "node:os"; import { appendFile, mkdir, unlink, writeFile, readFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import type { DiffType } from "./vcs"; import type { PRMetadata } from "./pr"; import { toRelativePath } from "./path-utils"; +import { getCacheDir } from "@plannotator/shared/paths"; // --------------------------------------------------------------------------- // Debug log โ€” only active when PLANNOTATOR_DEBUG is set // --------------------------------------------------------------------------- const DEBUG_ENABLED = !!process.env.PLANNOTATOR_DEBUG; -const DEBUG_LOG_PATH = join(homedir(), ".plannotator", "codex-review-debug.log"); +const DEBUG_LOG_PATH = join(getCacheDir(), "codex-review-debug.log"); async function debugLog(label: string, data?: unknown): Promise { if (!DEBUG_ENABLED) return; try { - await mkdir(join(homedir(), ".plannotator"), { recursive: true }); + await mkdir(getCacheDir(), { recursive: true }); const timestamp = new Date().toISOString(); const line = data !== undefined ? `[${timestamp}] ${label}: ${typeof data === "string" ? data : JSON.stringify(data, null, 2)}\n` @@ -80,7 +81,7 @@ const CODEX_REVIEW_SCHEMA = JSON.stringify({ additionalProperties: false, }); -const SCHEMA_DIR = join(homedir(), ".plannotator"); +const SCHEMA_DIR = getCacheDir(); const SCHEMA_FILE = join(SCHEMA_DIR, "codex-review-schema.json"); let schemaMaterialized = false; diff --git a/packages/server/sessions.ts b/packages/server/sessions.ts index 9a109cc11..aa8979aaa 100644 --- a/packages/server/sessions.ts +++ b/packages/server/sessions.ts @@ -1,11 +1,10 @@ /** * Session Registry * - * Tracks active Plannotator server sessions in ~/.plannotator/sessions/ + * Tracks active Plannotator server sessions in ~/.cache/plannotator/sessions/ * so users can discover and reopen closed browser tabs. */ -import { homedir } from "os"; import { join } from "path"; import { mkdirSync, @@ -15,6 +14,7 @@ import { unlinkSync, existsSync, } from "fs"; +import { getCacheDir } from "@plannotator/shared/paths"; export interface SessionInfo { pid: number; @@ -27,7 +27,7 @@ export interface SessionInfo { } function getSessionsDir(): string { - const dir = join(homedir(), ".plannotator", "sessions"); + const dir = join(getCacheDir(), "sessions"); mkdirSync(dir, { recursive: true }); return dir; } diff --git a/packages/server/tour/tour-review.ts b/packages/server/tour/tour-review.ts index 36d451287..8e72f2e49 100644 --- a/packages/server/tour/tour-review.ts +++ b/packages/server/tour/tour-review.ts @@ -1,8 +1,9 @@ import { join } from "node:path"; -import { homedir, tmpdir } from "node:os"; +import { tmpdir } from "node:os"; import { mkdir, writeFile, readFile, unlink } from "node:fs/promises"; import type { DiffType } from "../vcs"; import type { PRMetadata } from "../pr"; +import { getCacheDir } from "@plannotator/shared/paths"; import type { CodeTourOutput, TourDiffAnchor, @@ -385,7 +386,7 @@ export function buildTourClaudeCommand(prompt: string, model: string = "sonnet", }; } -const TOUR_SCHEMA_DIR = join(homedir(), ".plannotator"); +const TOUR_SCHEMA_DIR = getCacheDir(); const TOUR_SCHEMA_FILE = join(TOUR_SCHEMA_DIR, "tour-schema.json"); let tourSchemaMaterialized = false; From 2bee7b8acec11234ce926da1c2ba04f8d0ba3ede Mon Sep 17 00:00:00 2001 From: Joao-O-Santos Date: Fri, 24 Apr 2026 20:34:05 +0100 Subject: [PATCH 3/9] vscode-ext, paste-service: use XDG cache dir - vscode-extension/src/extension.ts: resolve vscode-ipc.json via getCacheDir() (PLANNOTATOR_CACHE_DIR / XDG_CACHE_HOME / ~/.cache) - paste-service/targets/bun.ts: default paste dir under cache, keeping PASTE_DATA_DIR override for backward compat --- apps/paste-service/targets/bun.ts | 11 +++++++++-- apps/vscode-extension/src/extension.ts | 11 ++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/paste-service/targets/bun.ts b/apps/paste-service/targets/bun.ts index a670e9246..52a67bc7e 100644 --- a/apps/paste-service/targets/bun.ts +++ b/apps/paste-service/targets/bun.ts @@ -4,9 +4,16 @@ import { handleRequest } from "../core/handler"; import { corsHeaders, getAllowedOrigins } from "../core/cors"; import { FsPasteStore } from "../stores/fs"; +function getCacheDir(): string { + const dir = + process.env.PLANNOTATOR_CACHE_DIR || + (process.env.XDG_CACHE_HOME && join(process.env.XDG_CACHE_HOME, "plannotator")) || + join(homedir(), ".cache", "plannotator"); + return dir; +} + const port = parseInt(process.env.PASTE_PORT || "19433", 10); -const dataDir = - process.env.PASTE_DATA_DIR || join(homedir(), ".plannotator", "pastes"); +const dataDir = process.env.PASTE_DATA_DIR || join(getCacheDir(), "pastes"); const ttlDays = parseInt(process.env.PASTE_TTL_DAYS || "7", 10); const ttlSeconds = ttlDays * 24 * 60 * 60; const maxSize = parseInt(process.env.PASTE_MAX_SIZE || "524288", 10); diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index f2042c5f8..ef4ef16d4 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -7,7 +7,16 @@ import { createCookieProxy } from "./cookie-proxy"; import { PanelManager } from "./panel-manager"; import { setActiveProxyPort, registerEditorAnnotationCommand } from "./editor-annotations"; -const IPC_REGISTRY = path.join(os.homedir(), ".plannotator", "vscode-ipc.json"); +function getCacheDir(): string { + const dir = + process.env.PLANNOTATOR_CACHE_DIR || + (process.env.XDG_CACHE_HOME && path.join(process.env.XDG_CACHE_HOME, "plannotator")) || + path.join(os.homedir(), ".cache", "plannotator"); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +const IPC_REGISTRY = path.join(getCacheDir(), "vscode-ipc.json"); function readIpcRegistry(): Record { try { From 511ed2de53a17db6b22eabbf6668e27d8581e5af Mon Sep 17 00:00:00 2001 From: Joao-O-Santos Date: Fri, 24 Apr 2026 20:34:49 +0100 Subject: [PATCH 4/9] ui: update default path strings for XDG migration Update user-facing labels, placeholders, and tooltip text to reflect the new default data directory: ~/.config/plannotator/data/plans/ --- packages/ui/components/Settings.tsx | 4 ++-- packages/ui/utils/planSave.ts | 2 +- packages/ui/utils/quickLabels.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index fbb620928..bae9fb972 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -1253,7 +1253,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange
Save Plans
- Auto-save plans to ~/.plannotator/plans/ + Auto-save plans to ~/.config/plannotator/data/plans/