diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..564ec1b --- /dev/null +++ b/src/config.js @@ -0,0 +1,33 @@ +'use strict'; + +/** + * Configuration — loads .env and resolves runtime settings. + * + * Config via .env (place in the fido project root or the working directory): + * OPENROUTER_API_KEY=sk-or-... + * MODEL=xiaomi/mimo-v2.6-flash + * UNDO_FILE=.fido-undo.json + * ROOT_DIR=. (optional, defaults to cwd) + */ + +import path from 'path'; +import { fileURLToPath } from 'url'; +import dotenv from 'dotenv'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Load .env from the fido project root (one level up from src/) +dotenv.config({ path: path.resolve(__dirname, '..', '.env') }); +// Also try cwd-relative .env +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + +export const API_KEY = process.env.OPENROUTER_API_KEY; +export const MODEL = process.env.MODEL || 'xiaomi/mimo-v2.6-flash'; +export const UNDO_FILE = process.env.UNDO_FILE || '.fido-undo.json'; + +// Root dir: --dir flag wins, then env ROOT_DIR, then cwd +const args = process.argv.slice(2); +const dirFlagIdx = args.indexOf('--dir'); +export const ROOT_DIR = path.resolve( + (dirFlagIdx !== -1 && args[dirFlagIdx + 1]) || process.env.ROOT_DIR || process.cwd() +); diff --git a/src/dir-listing.js b/src/dir-listing.js new file mode 100644 index 0000000..16a49cd --- /dev/null +++ b/src/dir-listing.js @@ -0,0 +1,83 @@ +'use strict'; + +/** + * Directory listing helpers — .gitignore-aware listing with pagination. + */ + +import fs from 'fs'; +import path from 'path'; +import ignore from 'ignore'; +import { ROOT_DIR } from './config.js'; + +/** + * Build an `ignore` filter that respects all .gitignore files found between + * ROOT_DIR and `dir` (inclusive), plus a hardcoded node_modules rule. + * Paths fed to ig.ignores() must be relative to ROOT_DIR and use forward slashes. + */ +export function buildIgnoreFilter(dir) { + const ig = ignore().add('node_modules/'); + // Collect .gitignore files from ROOT_DIR down to dir + const rel = path.relative(ROOT_DIR, dir); + const parts = rel === '' ? [] : rel.split(path.sep); + const ancestors = [ROOT_DIR]; + for (let i = 0; i < parts.length; i++) { + ancestors.push(path.join(ROOT_DIR, ...parts.slice(0, i + 1))); + } + for (const ancestor of ancestors) { + const gi = path.join(ancestor, '.gitignore'); + if (fs.existsSync(gi)) { + try { + const rules = fs.readFileSync(gi, 'utf8'); + // Rules in a nested .gitignore are relative to that directory; + // prefix them so they work relative to ROOT_DIR. + const prefix = path.relative(ROOT_DIR, ancestor).replace(/\\/g, '/'); + const prefixed = rules + .split('\n') + .filter(l => l.trim() && !l.startsWith('#')) + .map(l => (prefix ? `${prefix}/${l.trim()}` : l.trim())); + ig.add(prefixed); + } catch { /* ignore read errors */ } + } + } + return ig; +} + +/** + * List entries of a directory, optionally recursive, respecting .gitignore. + * Returns { entries: Array<{path,type,size}>, total, truncated } + * - type: 'd' for directory, 'f' for file + * - size: file size in bytes (null for directories) + * All paths are relative to ROOT_DIR and use forward slashes. + */ +export function listDir(dir, { seek = 0, limit = 50, recursive = false } = {}) { + const ig = buildIgnoreFilter(dir); + const allEntries = []; + + const collect = (current) => { + let entries; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + const full = path.join(current, e.name); + const rel = path.relative(ROOT_DIR, full).replace(/\\/g, '/'); + if (ig.ignores(e.isDirectory() ? rel + '/' : rel)) continue; + if (e.isDirectory()) { + allEntries.push({ path: rel + '/', type: 'd', size: null }); + if (recursive) collect(full); + } else { + let size = null; + try { size = fs.statSync(full).size; } catch { /* skip */ } + allEntries.push({ path: rel, type: 'f', size }); + } + } + }; + collect(dir); + + const total = allEntries.length; + const sliced = allEntries.slice(seek, seek + limit); + const truncated = total - (seek + sliced.length); + return { entries: sliced, total, truncated: Math.max(0, truncated) }; +} diff --git a/src/sandbox.js b/src/sandbox.js new file mode 100644 index 0000000..72e04c0 --- /dev/null +++ b/src/sandbox.js @@ -0,0 +1,38 @@ +'use strict'; + +/** + * Sandbox — path resolution restricted to the workspace root. + */ + +import path from 'path'; +import { ToolError } from 'llmorbiter'; +import { ROOT_DIR } from './config.js'; + +/** + * Resolve a user-supplied path within the sandbox root. + * Throws ToolError if the path is absolute or escapes the root. + */ +export function resolveSafe(userPath) { + if (!userPath || typeof userPath !== 'string') { + throw new ToolError('Path must be a non-empty string.'); + } + // Reject absolute paths so the user can't anchor to a filesystem root. + // Covers POSIX root, Windows rooted, and drive-letter forms. Paths must be + // relative to the workspace root. + if (path.isAbsolute(userPath) || /^[A-Za-z]:/.test(userPath)) { + throw new ToolError( + `Access denied: "${userPath}" is an absolute path. ` + + `You may only access files within the workspace using paths relative to the workspace root.` + ); + } + const resolved = path.resolve(ROOT_DIR, userPath); + // Must stay inside root (defense in depth; also catches ".." traversal). + const rootNorm = ROOT_DIR.endsWith(path.sep) ? ROOT_DIR : ROOT_DIR + path.sep; + if (resolved !== ROOT_DIR && !resolved.startsWith(rootNorm)) { + throw new ToolError( + `Access denied: "${userPath}" resolves outside the workspace root "${ROOT_DIR}". ` + + `You may only access files within the workspace.` + ); + } + return resolved; +} diff --git a/src/tools/ask-user.js b/src/tools/ask-user.js new file mode 100644 index 0000000..eb35d61 --- /dev/null +++ b/src/tools/ask-user.js @@ -0,0 +1,91 @@ +'use strict'; + +import chalk from 'chalk'; +import { input, select } from '@inquirer/prompts'; +import { defineTool } from 'llmorbiter'; + +export const askUserTool = defineTool({ + name: 'ask_user', + description: + 'Ask the user a question when you need clarification or a decision. ' + + 'Provide a concise question and up to 3 labelled options (A, B, C). ' + + 'The user can pick an option or type a free-form answer (D). ' + + 'Use this instead of guessing whenever intent is ambiguous.', + parameters: { + type: 'object', + additionalProperties: false, + required: ['question'], + properties: { + question: { + type: 'string', + description: 'The question to ask the user.', + }, + options: { + type: 'array', + description: 'Up to 3 short option labels for the user to choose from.', + items: { type: 'string' }, + maxItems: 3, + }, + }, + }, + invoke: async ({ question, options = [] }) => { + const capped = options.slice(0, 3); + const letters = ['A', 'B', 'C']; + + console.log(''); + console.log(chalk.magenta.bold(' ❓ Fido asks:')); + console.log(chalk.white(' ' + question)); + + if (capped.length === 0) { + // No options — just free text + const answer = await input({ + message: chalk.bold.magenta('Your answer'), + prefix: chalk.magenta('›'), + theme: { + prefix: chalk.magenta('›'), + style: { answer: chalk.white, message: chalk.bold.magenta }, + }, + }); + console.log(''); + return { answer }; + } + + // Build select choices: one per option + free-text + const choices = [ + ...capped.map((opt, i) => ({ + name: `${letters[i]}) ${opt}`, + value: letters[i], + short: letters[i], + })), + { name: 'D) Type my own answer…', value: '__custom__', short: 'D' }, + ]; + + const selection = await select({ + message: question, + choices, + prefix: chalk.magenta('›'), + theme: { + prefix: chalk.magenta('›'), + style: { message: chalk.bold.magenta }, + }, + }); + + let answer; + if (selection === '__custom__') { + answer = await input({ + message: chalk.bold.magenta('Your answer'), + prefix: chalk.magenta('›'), + theme: { + prefix: chalk.magenta('›'), + style: { answer: chalk.white, message: chalk.bold.magenta }, + }, + }); + } else { + const idx = letters.indexOf(selection); + answer = capped[idx]; + } + + console.log(''); + return { answer }; + }, +}); diff --git a/src/tools/index.js b/src/tools/index.js new file mode 100644 index 0000000..a84afd0 --- /dev/null +++ b/src/tools/index.js @@ -0,0 +1,33 @@ +'use strict'; + +/** + * Tool registry — one module per tool, aggregated here in registration order. + */ + +import { listFilesTool } from './list-files.js'; +import { readFileTool } from './read-file.js'; +import { patchTool } from './patch.js'; +import { mkdirTool } from './mkdir.js'; +import { rmdirTool } from './rmdir.js'; +import { rmTool } from './rm.js'; +import { askUserTool } from './ask-user.js'; + +export { + listFilesTool, + readFileTool, + patchTool, + mkdirTool, + rmdirTool, + rmTool, + askUserTool, +}; + +export const tools = [ + listFilesTool, + readFileTool, + patchTool, + mkdirTool, + rmdirTool, + rmTool, + askUserTool, +]; diff --git a/src/tools/list-files.js b/src/tools/list-files.js new file mode 100644 index 0000000..89be3de --- /dev/null +++ b/src/tools/list-files.js @@ -0,0 +1,60 @@ +'use strict'; + +import fs from 'fs'; +import { defineTool, ToolError } from 'llmorbiter'; +import { resolveSafe } from '../sandbox.js'; +import { listDir } from '../dir-listing.js'; + +export const listFilesTool = defineTool({ + name: 'list_files', + description: + 'List the immediate contents of a directory within the workspace (non-recursive by default). ' + + 'Entries matching .gitignore patterns (and node_modules/) are excluded automatically. ' + + 'Set recursive=true to walk the full subtree. ' + + 'Use seek/limit for pagination. Paths are relative to the workspace root.', + parameters: { + type: 'object', + additionalProperties: false, + required: ['dirname'], + properties: { + dirname: { + type: 'string', + description: 'Directory path relative to workspace root. Use "." for root.', + }, + recursive: { + type: 'boolean', + description: 'If true, walk the full subtree. Default: false (immediate children only).', + }, + seek: { + type: 'number', + description: 'Number of entries to skip (for pagination). Default: 0.', + }, + limit: { + type: 'number', + description: 'Maximum number of entries to return. Default: 50.', + }, + }, + }, + invoke: async ({ dirname, recursive = false, seek = 0, limit = 50 }) => { + if (!Number.isInteger(limit) || limit < 1) { + throw new ToolError(`"limit" must be a positive integer (received ${limit}).`); + } + if (!Number.isInteger(seek) || seek < 0) { + throw new ToolError(`"seek" must be a non-negative integer (received ${seek}).`); + } + const absDir = resolveSafe(dirname); + if (!fs.existsSync(absDir)) { + throw new ToolError(`Directory not found: "${dirname}"`); + } + if (!fs.statSync(absDir).isDirectory()) { + throw new ToolError(`"${dirname}" is not a directory.`); + } + const { entries, total, truncated } = listDir(absDir, { seek, limit, recursive }); + const result = { dirname, recursive, entries, total, returned: entries.length, seek }; + if (truncated > 0) { + result.truncated = truncated; + result.truncationNote = `${truncated} entries not shown. Use seek=${seek + entries.length} to continue.`; + } + return result; + }, +}); diff --git a/src/tools/mkdir.js b/src/tools/mkdir.js new file mode 100644 index 0000000..9a004df --- /dev/null +++ b/src/tools/mkdir.js @@ -0,0 +1,34 @@ +'use strict'; + +import fs from 'fs'; +import { defineTool, ToolError } from 'llmorbiter'; +import { resolveSafe } from '../sandbox.js'; +import { pushUndo } from '../undo.js'; + +export const mkdirTool = defineTool({ + name: 'mkdir', + description: 'Create a directory in the workspace (automatically recursive if parent directories do not exist).', + parameters: { + type: 'object', + additionalProperties: false, + required: ['dirname'], + properties: { + dirname: { + type: 'string', + description: 'Directory path relative to workspace root.', + }, + }, + }, + invoke: async ({ dirname }) => { + const absDir = resolveSafe(dirname); + if (fs.existsSync(absDir)) { + if (!fs.statSync(absDir).isDirectory()) { + throw new ToolError(`Path "${dirname}" already exists and is not a directory.`); + } + return { message: `Directory already exists: "${dirname}"`, created: false }; + } + fs.mkdirSync(absDir, { recursive: true }); + pushUndo({ op: 'DELETE_DIR', path: dirname }); + return { message: `Directory created: "${dirname}"`, created: true }; + }, +}); diff --git a/src/tools/patch.js b/src/tools/patch.js new file mode 100644 index 0000000..6f3f9d2 --- /dev/null +++ b/src/tools/patch.js @@ -0,0 +1,224 @@ +'use strict'; + +import fs from 'fs'; +import path from 'path'; +import { defineTool, ToolError } from 'llmorbiter'; +import { resolveSafe } from '../sandbox.js'; +import { pushUndo } from '../undo.js'; + +// ─── Patch executor ──────────────────────────────────────────────────────────── + +/** + * Apply a list of file patch operations. + * Each operation is an object: + * - file: string (relative path) + * - search: string | null (string to be replaced; null or empty means create/overwrite file) + * - replace: string (string to write) + * - skiplines: number | null (optional: line number offset to start searching from, or lines to skip before matching) + */ +export function applyOperations(operations) { + if (!Array.isArray(operations) || operations.length === 0) { + return { success: false, error: 'operations must be a non-empty array of objects.' }; + } + + // Validate all operations first (fast-fail) + for (let idx = 0; idx < operations.length; idx++) { + const op = operations[idx]; + if (!op || typeof op !== 'object') { + return { success: false, error: `Operation at index ${idx} is not an object.` }; + } + if (!op.file || typeof op.file !== 'string') { + return { success: false, error: `Operation at index ${idx} missing valid 'file' property.` }; + } + if (op.replace === undefined || op.replace === null || typeof op.replace !== 'string') { + return { success: false, error: `Operation at index ${idx} ('${op.file}') missing valid 'replace' string.` }; + } + try { + resolveSafe(op.file); + } catch (err) { + return { success: false, error: err.message, failedOp: op }; + } + } + + const applied = []; + for (const op of operations) { + const absPath = resolveSafe(op.file); + try { + if (op.search === null || op.search === undefined || op.search === '') { + // Create or overwrite file (with autodirectory creation) + let undoEntry; + if (fs.existsSync(absPath)) { + const existing = fs.readFileSync(absPath, 'utf8'); + undoEntry = { op: 'RESTORE', path: op.file, content: existing }; + } else { + undoEntry = { op: 'DELETE', path: op.file }; + } + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + fs.writeFileSync(absPath, op.replace, 'utf8'); + pushUndo(undoEntry); + applied.push(`CREATE/WRITE ${op.file}`); + } else { + // Search and replace (replace first hit, optionally respecting skiplines) + if (!fs.existsSync(absPath)) { + return { + success: false, + error: `File not found: "${op.file}"`, + failedOp: op, + appliedSoFar: applied, + }; + } + const original = fs.readFileSync(absPath, 'utf8'); + const isCrlf = original.includes('\r\n'); + let matchIndex = -1; + let matchedLen = op.search.length; + + // First try exact match + if (typeof op.skiplines === 'number' && op.skiplines > 0) { + let currentLine = 0; + let searchFromChar = 0; + for (let i = 0; i < original.length; i++) { + if (currentLine >= op.skiplines) { + searchFromChar = i; + break; + } + if (original[i] === '\n') { + currentLine++; + if (currentLine >= op.skiplines) { + searchFromChar = i + 1; + break; + } + } + } + matchIndex = original.indexOf(op.search, searchFromChar); + } else { + matchIndex = original.indexOf(op.search); + } + + // If not found and line endings differ, try line-ending normalized match + if (matchIndex === -1 && (op.search.includes('\n') || original.includes('\r\n'))) { + const normSearch = op.search.replace(/\r\n/g, '\n'); + const normOriginal = original.replace(/\r\n/g, '\n'); + let normFromChar = 0; + + if (typeof op.skiplines === 'number' && op.skiplines > 0) { + const linesBefore = normOriginal.split('\n').slice(0, op.skiplines); + normFromChar = linesBefore.join('\n').length + (linesBefore.length > 0 ? 1 : 0); + } + + const normMatchIndex = normOriginal.indexOf(normSearch, normFromChar); + if (normMatchIndex !== -1) { + // Map normalized index back to original offset + const prefixNorm = normOriginal.slice(0, normMatchIndex); + const numPrefixNewlines = (prefixNorm.match(/\n/g) || []).length; + matchIndex = isCrlf ? normMatchIndex + numPrefixNewlines : normMatchIndex; + + const matchedNormText = normOriginal.slice(normMatchIndex, normMatchIndex + normSearch.length); + const numMatchedNewlines = (matchedNormText.match(/\n/g) || []).length; + matchedLen = isCrlf ? normSearch.length + numMatchedNewlines : normSearch.length; + } + } + + if (matchIndex === -1) { + const skipMsg = (typeof op.skiplines === 'number' && op.skiplines > 0) + ? ` (after skipping ${op.skiplines} lines)` + : ''; + return { + success: false, + error: + `SEARCH/REPLACE failed in "${op.file}": search string not found${skipMsg}.\n` + + `Search string was:\n${op.search}\n\n` + + `Tip: use read_file to check current contents and line numbering of "${op.file}".`, + failedOp: op, + appliedSoFar: applied, + }; + } + + // If file uses CRLF, ensure replacement content uses CRLF as well + let replacementText = op.replace; + if (isCrlf && !replacementText.includes('\r\n') && replacementText.includes('\n')) { + replacementText = replacementText.replace(/\n/g, '\r\n'); + } + + const updated = + original.slice(0, matchIndex) + + replacementText + + original.slice(matchIndex + matchedLen); + + fs.writeFileSync(absPath, updated, 'utf8'); + pushUndo({ op: 'RESTORE', path: op.file, content: original }); + applied.push(`PATCH in ${op.file}`); + } + } catch (err) { + return { + success: false, + error: `Operation on "${op.file}" failed: ${err.message}`, + failedOp: op, + appliedSoFar: applied, + }; + } + } + + return { success: true, applied, undoLogEntries: applied.length }; +} + +// ─── Tool definition ───────────────────────────────────────────────────────────── + +export const patchTool = defineTool({ + name: 'patch', + description: `Apply one or more patch operations to files in the workspace. +Each operation has: +- file: (required) relative path to target file +- search: string to be replaced (exact match). If null, creating/overwriting the entire file (with automatic directory creation). +- replace: string to write +- skiplines: (optional) line number offset to skip before matching (0-indexed) if there are multiple occurrences, since patch replaces the first hit. + +Example create file: +[{"file": "src/utils.js", "search": null, "replace": "export const x = 1;\\n", "skiplines": null}] + +Example search/replace: +[{"file": "src/utils.js", "search": "export const x = 1;", "replace": "export const x = 2;", "skiplines": null}]`, + parameters: { + type: 'object', + additionalProperties: false, + required: ['operations'], + properties: { + operations: { + type: 'array', + description: 'List of patch operations to apply sequentially.', + items: { + type: 'object', + additionalProperties: false, + required: ['file', 'replace'], + properties: { + file: { + type: 'string', + description: 'Relative path to file.', + }, + search: { + type: ['string', 'null'], + description: 'String to be replaced (exact match). If null, writes/creates the entire file.', + }, + replace: { + type: 'string', + description: 'String to write.', + }, + skiplines: { + type: ['number', 'null'], + description: 'Optional line number offset to skip before searching for search text.', + }, + }, + }, + }, + }, + }, + invoke: async ({ operations }) => { + const result = applyOperations(operations); + if (!result.success) { + throw new ToolError( + `Patch failed: ${result.error}` + + (result.appliedSoFar?.length ? `\nSuccessfully applied before failure: ${result.appliedSoFar.join(', ')}` : '') + ); + } + return result; + }, +}); diff --git a/src/tools/read-file.js b/src/tools/read-file.js new file mode 100644 index 0000000..8e136f4 --- /dev/null +++ b/src/tools/read-file.js @@ -0,0 +1,57 @@ +'use strict'; + +import fs from 'fs'; +import { defineTool, ToolError } from 'llmorbiter'; +import { resolveSafe } from '../sandbox.js'; + +export const readFileTool = defineTool({ + name: 'read_file', + description: + 'Read a file from the workspace. Use seek/limit to read in chunks. ' + + 'Returns truncation info when the file is larger than the limit.', + parameters: { + type: 'object', + additionalProperties: false, + required: ['filename'], + properties: { + filename: { + type: 'string', + description: 'File path relative to workspace root.', + }, + seek: { + type: 'number', + description: 'Line number to start reading from (0-indexed). Default: 0.', + }, + limit: { + type: 'number', + description: 'Maximum number of lines to return. Default: 200.', + }, + }, + }, + invoke: async ({ filename, seek = 0, limit = 200 }) => { + const absPath = resolveSafe(filename); + if (!fs.existsSync(absPath)) { + throw new ToolError(`File not found: "${filename}"`); + } + if (!fs.statSync(absPath).isFile()) { + throw new ToolError(`"${filename}" is not a file.`); + } + const allLines = fs.readFileSync(absPath, 'utf8').split('\n'); + const total = allLines.length; + const slice = allLines.slice(seek, seek + limit); + const truncated = total - (seek + slice.length); + + const result = { + filename, + totalLines: total, + seek, + returnedLines: slice.length, + content: slice.join('\n'), + }; + if (truncated > 0) { + result.truncated = truncated; + result.truncationNote = `${truncated} lines not shown. Use seek=${seek + slice.length} to continue.`; + } + return result; + }, +}); diff --git a/src/tools/rm.js b/src/tools/rm.js new file mode 100644 index 0000000..d9cd3ae --- /dev/null +++ b/src/tools/rm.js @@ -0,0 +1,35 @@ +'use strict'; + +import fs from 'fs'; +import { defineTool, ToolError } from 'llmorbiter'; +import { resolveSafe } from '../sandbox.js'; +import { pushUndo } from '../undo.js'; + +export const rmTool = defineTool({ + name: 'rm', + description: 'Delete a file from the workspace.', + parameters: { + type: 'object', + additionalProperties: false, + required: ['filename'], + properties: { + filename: { + type: 'string', + description: 'File path relative to workspace root.', + }, + }, + }, + invoke: async ({ filename }) => { + const absPath = resolveSafe(filename); + if (!fs.existsSync(absPath)) { + throw new ToolError(`File not found: "${filename}"`); + } + if (!fs.statSync(absPath).isFile()) { + throw new ToolError(`"${filename}" is not a file. Use rmdir to delete directories.`); + } + const content = fs.readFileSync(absPath, 'utf8'); + fs.unlinkSync(absPath); + pushUndo({ op: 'CREATE', path: filename, content }); + return { message: `File deleted: "${filename}"`, deleted: true }; + }, +}); diff --git a/src/tools/rmdir.js b/src/tools/rmdir.js new file mode 100644 index 0000000..a2cf1cf --- /dev/null +++ b/src/tools/rmdir.js @@ -0,0 +1,34 @@ +'use strict'; + +import fs from 'fs'; +import { defineTool, ToolError } from 'llmorbiter'; +import { resolveSafe } from '../sandbox.js'; +import { pushUndo } from '../undo.js'; + +export const rmdirTool = defineTool({ + name: 'rmdir', + description: 'Remove a directory and its contents from the workspace.', + parameters: { + type: 'object', + additionalProperties: false, + required: ['dirname'], + properties: { + dirname: { + type: 'string', + description: 'Directory path relative to workspace root.', + }, + }, + }, + invoke: async ({ dirname }) => { + const absDir = resolveSafe(dirname); + if (!fs.existsSync(absDir)) { + throw new ToolError(`Directory not found: "${dirname}"`); + } + if (!fs.statSync(absDir).isDirectory()) { + throw new ToolError(`"${dirname}" is not a directory. Use rm to delete files.`); + } + fs.rmSync(absDir, { recursive: true, force: true }); + pushUndo({ op: 'DELETE_DIR_UNDO_NOTE', path: dirname, note: 'Directory was deleted; contents cannot be automatically restored.' }); + return { message: `Directory removed: "${dirname}"`, removed: true }; + }, +}); diff --git a/src/undo.js b/src/undo.js new file mode 100644 index 0000000..61283ee --- /dev/null +++ b/src/undo.js @@ -0,0 +1,41 @@ +'use strict'; + +/** + * Undo log — JSON array, newest-first, persisted on every mutation. + * Entry kinds: + * RESTORE — previous content of an overwritten/patched file + * CREATE — content of a deleted file (used to re-create it) + * DELETE / DELETE_DIR — reverse operation marker + * DELETE_DIR_UNDO_NOTE — directory removal (contents not auto-restorable) + */ + +import fs from 'fs'; +import path from 'path'; +import { ROOT_DIR, UNDO_FILE } from './config.js'; + +let undoLog = []; + +export function loadUndoLog() { + const undoPath = path.resolve(ROOT_DIR, UNDO_FILE); + if (fs.existsSync(undoPath)) { + try { + undoLog = JSON.parse(fs.readFileSync(undoPath, 'utf8')); + } catch { + undoLog = []; + } + } +} + +export function saveUndoLog() { + const undoPath = path.resolve(ROOT_DIR, UNDO_FILE); + fs.writeFileSync(undoPath, JSON.stringify(undoLog, null, 2) + '\n', 'utf8'); +} + +export function pushUndo(entry) { + undoLog.unshift(entry); // newest first + saveUndoLog(); +} + +export function getUndoLog() { + return undoLog; +}