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
12 changes: 3 additions & 9 deletions packages/server/call-flow-install-endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,9 @@ import { join } from 'node:path';
import type { CallFlowInstallStage, CallFlowNodePreflight, CallFlowRuntimeInstallResult } from '@plannotator/shared/call-flow';

// PLANNOTATOR_DATA_DIR is only ever changed INSIDE tests (boot() below) and
// restored to its original value after each one. It must never be overridden
// at module-eval time: bun evaluates every test file's module before running
// tests in one shared process, and Pi's generated/storage.ts caches its data
// dir at import time. A module-eval override here makes storage's cached dir
// and later files' live getPlannotatorDataDir() calls disagree, which is
// exactly the Pi annotate-history / durable-submit CI failure this comment
// guards against. Config writes made by these tests target whatever dir the
// process's config module froze at first import; the snapshot/restore in
// afterAll below keeps those writes from leaking into a real config.json.
// restored after each one. Module-eval overrides would leak into other test
// files because Bun runs the suite in one shared process. The config
// snapshot/restore in afterAll also protects against shared config state.
const originalDataDir = process.env.PLANNOTATOR_DATA_DIR;
const originalPort = process.env.PLANNOTATOR_PORT;
const originalPath = process.env.PATH;
Expand Down
36 changes: 36 additions & 0 deletions packages/server/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,39 @@ describe("listVersions", () => {
expect(versions[0].timestamp).toBeTruthy();
});
});

describe("PLANNOTATOR_DATA_DIR", () => {
test("isolates plan and history data when the data directory changes after import", () => {
const savedDataDir = process.env.PLANNOTATOR_DATA_DIR;
const firstDir = makeTempDir();
const secondDir = makeTempDir();
const project = "data-dir-project";
const slug = "data-dir-plan";

try {
process.env.PLANNOTATOR_DATA_DIR = firstDir;
savePlan(slug, "# First plan");
saveToHistory(project, slug, "# First version");
expect(readFileSync(join(firstDir, "plans", `${slug}.md`), "utf-8")).toBe("# First plan");
expect(getPlanVersion(project, slug, 1)).toBe("# First version");
expect(getVersionCount(project, slug)).toBe(1);

process.env.PLANNOTATOR_DATA_DIR = secondDir;
expect(getPlanVersion(project, slug, 1)).toBeNull();
expect(getVersionCount(project, slug)).toBe(0);
savePlan(slug, "# Second plan");
saveToHistory(project, slug, "# Second version");
expect(readFileSync(join(secondDir, "plans", `${slug}.md`), "utf-8")).toBe("# Second plan");
expect(getPlanVersion(project, slug, 1)).toBe("# Second version");
expect(getVersionCount(project, slug)).toBe(1);

process.env.PLANNOTATOR_DATA_DIR = firstDir;
expect(readFileSync(join(firstDir, "plans", `${slug}.md`), "utf-8")).toBe("# First plan");
expect(getPlanVersion(project, slug, 1)).toBe("# First version");
expect(getVersionCount(project, slug)).toBe(1);
} finally {
if (savedDataDir === undefined) delete process.env.PLANNOTATOR_DATA_DIR;
else process.env.PLANNOTATOR_DATA_DIR = savedDataDir;
}
});
});
16 changes: 7 additions & 9 deletions packages/shared/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ import { sanitizeTag } from "./project";
import { resolveUserPath } from "./resolve-file";
import { getPlannotatorDataDir } from "./data-dir";

const DATA_DIR = getPlannotatorDataDir();

/**
* Get the plan storage directory, creating it if needed.
* Cross-platform: uses os.homedir() for Windows/macOS/Linux compatibility.
Expand All @@ -26,7 +24,7 @@ export function getPlanDir(customPath?: string | null): string {
if (customPath?.trim()) {
planDir = resolveUserPath(customPath);
} else {
planDir = join(DATA_DIR, "plans");
planDir = join(getPlannotatorDataDir(), "plans");
}

mkdirSync(planDir, { recursive: true });
Expand Down Expand Up @@ -195,7 +193,7 @@ export function readArchivedPlan(filename: string, customPath?: string | null):
* Not affected by the customPath setting (that only affects decision saves).
*/
export function getHistoryDir(project: string, slug: string): string {
const historyDir = join(DATA_DIR, "history", project, slug);
const historyDir = join(getPlannotatorDataDir(), "history", project, slug);
mkdirSync(historyDir, { recursive: true });
return historyDir;
}
Expand Down Expand Up @@ -294,7 +292,7 @@ export function getPlanVersion(
slug: string,
version: number
): string | null {
const historyDir = join(DATA_DIR, "history", project, slug);
const historyDir = join(getPlannotatorDataDir(), "history", project, slug);
const fileName = `${String(version).padStart(3, "0")}.md`;
const filePath = join(historyDir, fileName);

Expand All @@ -314,7 +312,7 @@ export function getPlanVersionPath(
slug: string,
version: number
): string | null {
const historyDir = join(DATA_DIR, "history", project, slug);
const historyDir = join(getPlannotatorDataDir(), "history", project, slug);
const fileName = `${String(version).padStart(3, "0")}.md`;
const filePath = join(historyDir, fileName);
return existsSync(filePath) ? filePath : null;
Expand All @@ -325,7 +323,7 @@ export function getPlanVersionPath(
* Returns 0 if the directory doesn't exist.
*/
export function getVersionCount(project: string, slug: string): number {
const historyDir = join(DATA_DIR, "history", project, slug);
const historyDir = join(getPlannotatorDataDir(), "history", project, slug);
try {
const entries = readdirSync(historyDir);
return entries.filter((e) => /^\d+\.md$/.test(e)).length;
Expand All @@ -342,7 +340,7 @@ export function listVersions(
project: string,
slug: string
): Array<{ version: number; timestamp: string }> {
const historyDir = join(DATA_DIR, "history", project, slug);
const historyDir = join(getPlannotatorDataDir(), "history", project, slug);
try {
const entries = readdirSync(historyDir);
const versions: Array<{ version: number; timestamp: string }> = [];
Expand Down Expand Up @@ -372,7 +370,7 @@ export function listVersions(
export function listProjectPlans(
project: string
): Array<{ slug: string; versions: number; lastModified: string }> {
const projectDir = join(DATA_DIR, "history", project);
const projectDir = join(getPlannotatorDataDir(), "history", project);
try {
const entries = readdirSync(projectDir, { withFileTypes: true });
const plans: Array<{ slug: string; versions: number; lastModified: string }> = [];
Expand Down
12 changes: 8 additions & 4 deletions packages/ui/components/ExportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export const ExportModal: React.FC<ExportModalProps> = ({
await handleCopy(wrapCopiedAnnotations(annotationsOutput), 'annotations');
};

// Whether the hash URL is large enough to warrant a short URL option
// Warn when the hash URL may be too long for messaging apps
const urlIsLarge = shareUrl.length > 2048;
// Hash-based sharing unavailable (e.g. HTML render mode) — show only short link
const hashUnavailable = !shareUrl && !!onGenerateShortUrl;
Expand Down Expand Up @@ -327,9 +327,13 @@ export const ExportModal: React.FC<ExportModalProps> = ({
</svg>
Generating short link...
</div>
) : (urlIsLarge || hashUnavailable) && onGenerateShortUrl ? (
<div className="p-3 bg-amber-500/10 border border-amber-500/20 rounded-lg">
{!hashUnavailable && (
) : onGenerateShortUrl ? (
<div className={`p-3 rounded-lg border ${
urlIsLarge
? 'bg-amber-500/10 border-amber-500/20'
: 'bg-muted/50 border-border'
}`}>
{urlIsLarge && (
<p className="text-xs text-amber-600 dark:text-amber-400 mb-2">
This URL may be too long for some messaging apps.
</p>
Expand Down
Loading