diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index 0a5230d..2ce1eb1 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -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('languages') ?? [DEFAULT_LANGUAGE], - pandocPath: vsc.get('pandocPath') ?? 'pandoc', - libreOfficePath: vsc.get('libreOfficePath') ?? 'libreoffice', + pandocPath: locateToolPath('pandoc', vsc.get('pandocPath')), + libreOfficePath: locateToolPath('libreoffice', vsc.get('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( diff --git a/vscode-ext/src/tool-locate.ts b/vscode-ext/src/tool-locate.ts new file mode 100644 index 0000000..5682248 --- /dev/null +++ b/vscode-ext/src/tool-locate.ts @@ -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(); + +/** 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 " 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; +} diff --git a/vscode-ext/test/tool-locate.test.ts b/vscode-ext/test/tool-locate.test.ts new file mode 100644 index 0000000..dc48c91 --- /dev/null +++ b/vscode-ext/test/tool-locate.test.ts @@ -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'); + }); +});