|
1 | 1 | import { mkdir, readdir, rename, stat, unlink } from "node:fs/promises"; |
2 | | -import { dirname, isAbsolute, join, relative, resolve } from "node:path"; |
| 2 | +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; |
3 | 3 | import matter from "gray-matter"; |
4 | 4 | import lockfile from "proper-lockfile"; |
5 | 5 | import { DEFAULT_DIRECTORIES, DEFAULT_FILES, DEFAULT_STATUSES, FALLBACK_STATUS } from "../constants/index.ts"; |
@@ -1738,6 +1738,127 @@ ${description || `Milestone: ${title}`}`, |
1738 | 1738 | }; |
1739 | 1739 | } |
1740 | 1740 |
|
| 1741 | + async saveWikiPage(pagePath: string, content: string, title?: string, labels?: string[]): Promise<void> { |
| 1742 | + const wikiRoot = resolve(join(this.resolvedBacklogDir, "wiki")); |
| 1743 | + const normalizedPath = pagePath.endsWith(".md") ? pagePath : `${pagePath}.md`; |
| 1744 | + const filePath = resolve(join(wikiRoot, normalizedPath)); |
| 1745 | + |
| 1746 | + // Directory traversal containment check |
| 1747 | + const rel = relative(wikiRoot, filePath); |
| 1748 | + const isInside = !rel.startsWith("..") && !isAbsolute(rel); |
| 1749 | + if (!isInside) { |
| 1750 | + throw new Error("Invalid wiki path"); |
| 1751 | + } |
| 1752 | + |
| 1753 | + await this.ensureDirectoryExists(dirname(filePath)); |
| 1754 | + |
| 1755 | + // Preserve existing frontmatter, update title, labels, and set updated_date |
| 1756 | + let frontmatter: Record<string, unknown> = {}; |
| 1757 | + if (await Bun.file(filePath).exists()) { |
| 1758 | + const existing = await Bun.file(filePath).text(); |
| 1759 | + const parsed = parseMarkdown(existing); |
| 1760 | + frontmatter = (parsed.frontmatter as Record<string, unknown>) || {}; |
| 1761 | + } |
| 1762 | + if (title !== undefined) { |
| 1763 | + frontmatter.title = title; |
| 1764 | + } |
| 1765 | + if (labels !== undefined) { |
| 1766 | + frontmatter.labels = labels; |
| 1767 | + } |
| 1768 | + const updatedDate = new Date().toISOString().slice(0, 16).replace("T", " "); |
| 1769 | + frontmatter.updated_date = updatedDate; |
| 1770 | + |
| 1771 | + const fileContent = Object.keys(frontmatter).length > 0 ? matter.stringify(content, frontmatter) : content; |
| 1772 | + await Bun.write(filePath, fileContent); |
| 1773 | + } |
| 1774 | + |
| 1775 | + async createWikiPage(pagePath: string, content = "", labels?: string[]): Promise<string> { |
| 1776 | + const wikiRoot = resolve(join(this.resolvedBacklogDir, "wiki")); |
| 1777 | + const normalizedPath = pagePath.endsWith(".md") ? pagePath : `${pagePath}.md`; |
| 1778 | + const filePath = resolve(join(wikiRoot, normalizedPath)); |
| 1779 | + |
| 1780 | + // Directory traversal containment check |
| 1781 | + const rel = relative(wikiRoot, filePath); |
| 1782 | + const isInside = !rel.startsWith("..") && !isAbsolute(rel); |
| 1783 | + if (!isInside) { |
| 1784 | + throw new Error("Invalid wiki path"); |
| 1785 | + } |
| 1786 | + |
| 1787 | + // Prevent overwriting existing files |
| 1788 | + if (await Bun.file(filePath).exists()) { |
| 1789 | + throw new Error("Wiki page already exists"); |
| 1790 | + } |
| 1791 | + |
| 1792 | + await this.ensureDirectoryExists(dirname(filePath)); |
| 1793 | + const title = basename(normalizedPath, ".md"); |
| 1794 | + const defaultContent = content || `# ${title}\n\n`; |
| 1795 | + const createdDate = new Date().toISOString().slice(0, 16).replace("T", " "); |
| 1796 | + const frontmatter: Record<string, unknown> = { title, created_date: createdDate }; |
| 1797 | + if (labels !== undefined && labels.length > 0) { |
| 1798 | + frontmatter.labels = labels; |
| 1799 | + } |
| 1800 | + const fileContent = matter.stringify(defaultContent, frontmatter); |
| 1801 | + await Bun.write(filePath, fileContent); |
| 1802 | + |
| 1803 | + return normalizedPath; |
| 1804 | + } |
| 1805 | + |
| 1806 | + async createWikiFolder(folderPath: string): Promise<string> { |
| 1807 | + const wikiRoot = resolve(join(this.resolvedBacklogDir, "wiki")); |
| 1808 | + const dirPath = resolve(join(wikiRoot, folderPath)); |
| 1809 | + |
| 1810 | + // Directory traversal containment check |
| 1811 | + const rel = relative(wikiRoot, dirPath); |
| 1812 | + const isInside = !rel.startsWith("..") && !isAbsolute(rel); |
| 1813 | + if (!isInside) { |
| 1814 | + throw new Error("Invalid wiki path"); |
| 1815 | + } |
| 1816 | + |
| 1817 | + // Prevent overwriting existing files or directories |
| 1818 | + if (await Bun.file(dirPath).exists()) { |
| 1819 | + throw new Error("Wiki folder already exists"); |
| 1820 | + } |
| 1821 | + |
| 1822 | + await mkdir(dirPath, { recursive: true }); |
| 1823 | + return folderPath; |
| 1824 | + } |
| 1825 | + |
| 1826 | + async renameWikiItem(oldPath: string, newPath: string): Promise<string> { |
| 1827 | + const wikiRoot = resolve(join(this.resolvedBacklogDir, "wiki")); |
| 1828 | + const oldFullPath = resolve(join(wikiRoot, oldPath)); |
| 1829 | + const newFullPath = resolve(join(wikiRoot, newPath)); |
| 1830 | + |
| 1831 | + // Directory traversal containment check for both paths |
| 1832 | + const oldRel = relative(wikiRoot, oldFullPath); |
| 1833 | + const newRel = relative(wikiRoot, newFullPath); |
| 1834 | + const oldIsInside = !oldRel.startsWith("..") && !isAbsolute(oldRel); |
| 1835 | + const newIsInside = !newRel.startsWith("..") && !isAbsolute(newRel); |
| 1836 | + if (!oldIsInside || !newIsInside) { |
| 1837 | + throw new Error("Invalid wiki path"); |
| 1838 | + } |
| 1839 | + |
| 1840 | + // Source must exist (use stat to handle both files and directories) |
| 1841 | + try { |
| 1842 | + await stat(oldFullPath); |
| 1843 | + } catch { |
| 1844 | + throw new Error("Item not found"); |
| 1845 | + } |
| 1846 | + |
| 1847 | + // Destination must not exist |
| 1848 | + try { |
| 1849 | + await stat(newFullPath); |
| 1850 | + throw new Error("Destination already exists"); |
| 1851 | + } catch (err: any) { |
| 1852 | + if (err.message === "Destination already exists") throw err; |
| 1853 | + // stat threw because file doesn't exist — that's what we want |
| 1854 | + } |
| 1855 | + |
| 1856 | + // Ensure parent directory of destination exists |
| 1857 | + await this.ensureDirectoryExists(dirname(newFullPath)); |
| 1858 | + await rename(oldFullPath, newFullPath); |
| 1859 | + return newPath; |
| 1860 | + } |
| 1861 | + |
1741 | 1862 | private normalizeDefinitionOfDone(definitionOfDone: unknown): string[] | undefined { |
1742 | 1863 | if (!Array.isArray(definitionOfDone)) { |
1743 | 1864 | return undefined; |
|
0 commit comments