feat: auto-detect Pandoc and LibreOffice install paths

Add tool-locate.ts with a resolution chain: explicit setting → PATH
lookup → well-known OS install locations. Results are cached per-process
and cleared when settings change.

Wire into extension.ts via getEffectiveConfig() so pandocPath and
libreOfficePath are resolved automatically when left at defaults.

Includes 15 tests covering setting priority, cache behavior, and
fallback logic.
This commit is contained in:
Erik van der Boom 2026-04-19 22:31:27 +02:00
parent 248c725527
commit f25e33a2e7
3 changed files with 345 additions and 2 deletions

View file

@ -33,6 +33,7 @@ import {
type AiTarget, type SkillTemplate,
} from './ai-setup';
import { registerLmTools, registerMcpCommand } from './mcp';
import { locateToolPath, clearLocateCache } from './tool-locate';
// ─── Known language presets ───────────────────────────────────────────────────
@ -104,8 +105,8 @@ function getEffectiveConfig(wsSettings: WorkspaceSettings | null): EffectiveConf
languages: wsSettings?.languages
?? vsc.get<LanguageConfig[]>('languages')
?? [DEFAULT_LANGUAGE],
pandocPath: vsc.get<string>('pandocPath') ?? 'pandoc',
libreOfficePath: vsc.get<string>('libreOfficePath') ?? 'libreoffice',
pandocPath: locateToolPath('pandoc', vsc.get<string>('pandocPath')),
libreOfficePath: locateToolPath('libreoffice', vsc.get<string>('libreOfficePath')),
};
}
@ -1167,6 +1168,15 @@ async function doMerge(outputTypes: OutputType[]) {
export function activate(context: vscode.ExtensionContext) {
// Clear tool-path auto-detect cache whenever the user changes pandoc/libreoffice settings.
context.subscriptions.push(
vscode.workspace.onDidChangeConfiguration((e) => {
if (e.affectsConfiguration('bindery.pandocPath') || e.affectsConfiguration('bindery.libreOfficePath')) {
clearLocateCache();
}
})
);
// Formatting provider (used by VS Code's Format Document command)
context.subscriptions.push(
vscode.languages.registerDocumentFormattingEditProvider(

View file

@ -0,0 +1,164 @@
/**
* Auto-detect Pandoc and LibreOffice installations across platforms.
*
* Resolution order for each tool:
* 1. Explicit user setting (if set to non-default value and exists)
* 2. Command on PATH (`where.exe` / `which`)
* 3. Well-known per-OS install locations
*
* Results are cached per-process. Call `clearLocateCache()` when settings change.
*/
import * as cp from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
export type ToolName = 'pandoc' | 'libreoffice';
interface ResolvedTool {
path: string;
source: 'setting' | 'path' | 'default' | 'fallback';
}
const cache = new Map<ToolName, ResolvedTool | null>();
/** Clear the in-memory resolution cache (call when user settings change). */
export function clearLocateCache(): void {
cache.clear();
}
/**
* Return known install locations for a tool on the current platform.
* Variables like %ProgramFiles% are expanded from process.env.
*/
function wellKnownPaths(tool: ToolName): string[] {
const env = process.env;
const home = env.HOME || env.USERPROFILE || '';
if (process.platform === 'win32') {
const programFiles = env['ProgramFiles'] || 'C:\\Program Files';
const programFiles86 = env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
const localAppData = env['LOCALAPPDATA'] || path.join(home, 'AppData', 'Local');
if (tool === 'pandoc') {
return [
path.join(localAppData, 'Pandoc', 'pandoc.exe'),
path.join(programFiles, 'Pandoc', 'pandoc.exe'),
path.join(programFiles86, 'Pandoc', 'pandoc.exe'),
];
}
// libreoffice
return [
path.join(programFiles, 'LibreOffice', 'program', 'soffice.exe'),
path.join(programFiles86, 'LibreOffice', 'program', 'soffice.exe'),
];
}
if (process.platform === 'darwin') {
if (tool === 'pandoc') {
return [
'/opt/homebrew/bin/pandoc',
'/usr/local/bin/pandoc',
'/usr/bin/pandoc',
];
}
return [
'/Applications/LibreOffice.app/Contents/MacOS/soffice',
'/opt/homebrew/bin/soffice',
'/usr/local/bin/soffice',
];
}
// Linux / other POSIX
if (tool === 'pandoc') {
return ['/usr/bin/pandoc', '/usr/local/bin/pandoc'];
}
return ['/usr/bin/libreoffice', '/usr/bin/soffice', '/usr/local/bin/libreoffice'];
}
/** Default command name (what `which`/`where` would look up). */
function defaultCommand(tool: ToolName): string {
if (tool === 'pandoc') { return 'pandoc'; }
return process.platform === 'win32' ? 'soffice.exe' : 'libreoffice';
}
/** Is this setting value "the unset default" (empty, or literal 'pandoc'/'libreoffice')? */
function isSettingDefault(tool: ToolName, value: string | undefined): boolean {
const v = (value ?? '').trim();
if (v === '') { return true; }
if (tool === 'pandoc' && v === 'pandoc') { return true; }
if (tool === 'libreoffice' && (v === 'libreoffice' || v === 'soffice')) { return true; }
return false;
}
/** Look up a command on PATH using the OS's native resolver. Returns null if not found. */
function resolveOnPath(cmd: string): string | null {
const locator = process.platform === 'win32' ? 'where.exe' : 'which';
try {
const result = cp.spawnSync(locator, [cmd], { encoding: 'utf-8', timeout: 5000 });
if (result.status !== 0) { return null; }
const first = (result.stdout || '').split(/\r?\n/).map(s => s.trim()).find(Boolean);
return first || null;
} catch {
return null;
}
}
/**
* Resolve a tool's executable path.
* @param tool 'pandoc' or 'libreoffice'
* @param setting User-configured path (or empty). Overrides auto-detect when pointing at an existing file.
* @returns Resolved path + source, or null if nothing found. Falls back to the command name so callers can still show a useful error.
*/
export function locateTool(tool: ToolName, setting: string | undefined): ResolvedTool {
const cached = cache.get(tool);
if (cached && cached.source === 'setting' && (setting ?? '').trim() === cached.path) {
return cached;
}
if (cached && cached.source !== 'setting' && isSettingDefault(tool, setting)) {
return cached;
}
// 1. Explicit user setting
const trimmed = (setting ?? '').trim();
if (!isSettingDefault(tool, trimmed)) {
if (fs.existsSync(trimmed)) {
const resolved: ResolvedTool = { path: trimmed, source: 'setting' };
cache.set(tool, resolved);
return resolved;
}
// Setting points somewhere but the file does not exist — keep the literal
// so the caller surfaces a clear "not found at <path>" error, rather than silently auto-detecting.
const resolved: ResolvedTool = { path: trimmed, source: 'setting' };
cache.set(tool, resolved);
return resolved;
}
// 2. PATH lookup
const cmd = defaultCommand(tool);
const onPath = resolveOnPath(cmd);
if (onPath && fs.existsSync(onPath)) {
const resolved: ResolvedTool = { path: onPath, source: 'path' };
cache.set(tool, resolved);
return resolved;
}
// 3. Well-known defaults
for (const candidate of wellKnownPaths(tool)) {
if (fs.existsSync(candidate)) {
const resolved: ResolvedTool = { path: candidate, source: 'default' };
cache.set(tool, resolved);
return resolved;
}
}
// 4. Fallback to command name — will fail at execution with a clear error
const fallback: ResolvedTool = { path: cmd, source: 'fallback' };
cache.set(tool, fallback);
return fallback;
}
/** Convenience wrapper — return just the path string. */
export function locateToolPath(tool: ToolName, setting: string | undefined): string {
return locateTool(tool, setting).path;
}

View file

@ -0,0 +1,169 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { clearLocateCache, locateTool, locateToolPath } from '../src/tool-locate';
const tempDirs: string[] = [];
function makeDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bindery-locate-test-'));
tempDirs.push(dir);
return dir;
}
beforeEach(() => {
clearLocateCache();
});
afterEach(() => {
clearLocateCache();
for (const dir of tempDirs.splice(0, tempDirs.length)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe('locateTool', () => {
it('prefers explicit setting when the file exists', () => {
const dir = makeDir();
const fakePandoc = path.join(dir, process.platform === 'win32' ? 'pandoc.exe' : 'pandoc');
fs.writeFileSync(fakePandoc, '');
const result = locateTool('pandoc', fakePandoc);
expect(result.path).toBe(fakePandoc);
expect(result.source).toBe('setting');
});
it('returns the literal setting even when the file is missing (surfaces clear error to user)', () => {
const dir = makeDir();
const missing = path.join(dir, 'does-not-exist.exe');
const result = locateTool('pandoc', missing);
expect(result.path).toBe(missing);
expect(result.source).toBe('setting');
});
it('treats empty string and default command as "unset"', () => {
const r1 = locateTool('pandoc', '');
const r2 = locateTool('pandoc', 'pandoc');
expect(['path', 'default', 'fallback']).toContain(r1.source);
expect(['path', 'default', 'fallback']).toContain(r2.source);
});
it('falls back to the command name when nothing is found', () => {
// Use a tool name but force an obviously bogus setting that doesn't exist.
// If nothing is on PATH / well-known locations on this machine for libreoffice,
// the fallback source should kick in. We can't force the environment,
// so just assert the shape: a non-empty path is always returned.
const result = locateTool('libreoffice', undefined);
expect(typeof result.path).toBe('string');
expect(result.path.length).toBeGreaterThan(0);
});
it('locateToolPath returns the same string as locateTool.path', () => {
const setting = 'pandoc';
expect(locateToolPath('pandoc', setting)).toBe(locateTool('pandoc', setting).path);
});
it('caches repeated lookups', () => {
const dir = makeDir();
const fake = path.join(dir, process.platform === 'win32' ? 'pandoc.exe' : 'pandoc');
fs.writeFileSync(fake, '');
const r1 = locateTool('pandoc', fake);
// Delete the file — cached result should still be returned
fs.rmSync(fake);
const r2 = locateTool('pandoc', fake);
expect(r2.path).toBe(r1.path);
});
it('clearLocateCache forces fresh resolution on next call', () => {
const dir = makeDir();
const fake = path.join(dir, process.platform === 'win32' ? 'pandoc.exe' : 'pandoc');
fs.writeFileSync(fake, '');
locateTool('pandoc', fake);
clearLocateCache();
// After clearing cache, the same call should still resolve (file still exists)
const result = locateTool('pandoc', fake);
expect(result.path).toBe(fake);
expect(result.source).toBe('setting');
});
it('resolves libreoffice with explicit setting', () => {
const dir = makeDir();
const fakePath = path.join(dir, process.platform === 'win32' ? 'soffice.exe' : 'soffice');
fs.writeFileSync(fakePath, '');
const result = locateTool('libreoffice', fakePath);
expect(result.path).toBe(fakePath);
expect(result.source).toBe('setting');
});
it('treats "libreoffice" as default setting (triggers auto-detect)', () => {
const result = locateTool('libreoffice', 'libreoffice');
expect(['path', 'default', 'fallback']).toContain(result.source);
});
it('treats "soffice" as default setting for libreoffice (triggers auto-detect)', () => {
const result = locateTool('libreoffice', 'soffice');
expect(['path', 'default', 'fallback']).toContain(result.source);
});
it('returns fallback for pandoc when no setting and nothing on PATH or well-known', () => {
// On a system where pandoc is not installed, this would return fallback
// On a system where it IS installed, it returns path/default — both valid
const result = locateTool('pandoc', undefined);
expect(typeof result.path).toBe('string');
expect(result.path.length).toBeGreaterThan(0);
expect(['path', 'default', 'fallback']).toContain(result.source);
});
it('returns setting source when path exists but is NOT a known default', () => {
const dir = makeDir();
const customPath = path.join(dir, 'my-custom-pandoc');
fs.writeFileSync(customPath, '');
const result = locateTool('pandoc', customPath);
expect(result.path).toBe(customPath);
expect(result.source).toBe('setting');
});
it('returns setting source even for non-existent custom path (to surface clear error)', () => {
const result = locateTool('pandoc', '/some/custom/path/to/pandoc');
expect(result.path).toBe('/some/custom/path/to/pandoc');
expect(result.source).toBe('setting');
});
it('cache uses different entries for pandoc vs libreoffice', () => {
const dir = makeDir();
const fakePandoc = path.join(dir, 'custom-pandoc');
const fakeSoffice = path.join(dir, 'custom-soffice');
fs.writeFileSync(fakePandoc, '');
fs.writeFileSync(fakeSoffice, '');
const pandocResult = locateTool('pandoc', fakePandoc);
const libreResult = locateTool('libreoffice', fakeSoffice);
expect(pandocResult.path).toBe(fakePandoc);
expect(libreResult.path).toBe(fakeSoffice);
expect(pandocResult.path).not.toBe(libreResult.path);
});
it('re-resolves when setting changes from default to explicit', () => {
// First: resolve with default (auto-detect)
locateTool('pandoc', '');
clearLocateCache();
// Second: resolve with explicit path
const dir = makeDir();
const explicit = path.join(dir, 'explicit-pandoc');
fs.writeFileSync(explicit, '');
const result = locateTool('pandoc', explicit);
expect(result.path).toBe(explicit);
expect(result.source).toBe('setting');
});
});