@thxgg/steward 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +7 -0
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/app/app.vue +14 -0
- package/app/assets/css/main.css +129 -0
- package/app/components/CommandPalette.vue +182 -0
- package/app/components/ShortcutsHelp.vue +85 -0
- package/app/components/git/ChangesMinimap.vue +143 -0
- package/app/components/git/CommitList.vue +224 -0
- package/app/components/git/DiffPanel.vue +402 -0
- package/app/components/git/DiffViewer.vue +803 -0
- package/app/components/layout/RepoSelector.vue +358 -0
- package/app/components/layout/Sidebar.vue +91 -0
- package/app/components/prd/Meta.vue +69 -0
- package/app/components/prd/Viewer.vue +285 -0
- package/app/components/tasks/Board.vue +86 -0
- package/app/components/tasks/Card.vue +108 -0
- package/app/components/tasks/Column.vue +108 -0
- package/app/components/tasks/Detail.vue +291 -0
- package/app/components/ui/badge/Badge.vue +26 -0
- package/app/components/ui/badge/index.ts +26 -0
- package/app/components/ui/button/Button.vue +29 -0
- package/app/components/ui/button/index.ts +38 -0
- package/app/components/ui/card/Card.vue +22 -0
- package/app/components/ui/card/CardAction.vue +17 -0
- package/app/components/ui/card/CardContent.vue +17 -0
- package/app/components/ui/card/CardDescription.vue +17 -0
- package/app/components/ui/card/CardFooter.vue +17 -0
- package/app/components/ui/card/CardHeader.vue +17 -0
- package/app/components/ui/card/CardTitle.vue +17 -0
- package/app/components/ui/card/index.ts +7 -0
- package/app/components/ui/combobox/Combobox.vue +19 -0
- package/app/components/ui/combobox/ComboboxAnchor.vue +23 -0
- package/app/components/ui/combobox/ComboboxEmpty.vue +21 -0
- package/app/components/ui/combobox/ComboboxGroup.vue +27 -0
- package/app/components/ui/combobox/ComboboxInput.vue +42 -0
- package/app/components/ui/combobox/ComboboxItem.vue +24 -0
- package/app/components/ui/combobox/ComboboxItemIndicator.vue +23 -0
- package/app/components/ui/combobox/ComboboxList.vue +33 -0
- package/app/components/ui/combobox/ComboboxSeparator.vue +21 -0
- package/app/components/ui/combobox/ComboboxTrigger.vue +24 -0
- package/app/components/ui/combobox/ComboboxViewport.vue +23 -0
- package/app/components/ui/combobox/index.ts +13 -0
- package/app/components/ui/command/Command.vue +103 -0
- package/app/components/ui/command/CommandDialog.vue +33 -0
- package/app/components/ui/command/CommandEmpty.vue +27 -0
- package/app/components/ui/command/CommandGroup.vue +45 -0
- package/app/components/ui/command/CommandInput.vue +54 -0
- package/app/components/ui/command/CommandItem.vue +76 -0
- package/app/components/ui/command/CommandList.vue +25 -0
- package/app/components/ui/command/CommandSeparator.vue +21 -0
- package/app/components/ui/command/CommandShortcut.vue +17 -0
- package/app/components/ui/command/index.ts +25 -0
- package/app/components/ui/dialog/Dialog.vue +19 -0
- package/app/components/ui/dialog/DialogClose.vue +15 -0
- package/app/components/ui/dialog/DialogContent.vue +53 -0
- package/app/components/ui/dialog/DialogDescription.vue +23 -0
- package/app/components/ui/dialog/DialogFooter.vue +15 -0
- package/app/components/ui/dialog/DialogHeader.vue +17 -0
- package/app/components/ui/dialog/DialogOverlay.vue +21 -0
- package/app/components/ui/dialog/DialogScrollContent.vue +59 -0
- package/app/components/ui/dialog/DialogTitle.vue +23 -0
- package/app/components/ui/dialog/DialogTrigger.vue +15 -0
- package/app/components/ui/dialog/index.ts +10 -0
- package/app/components/ui/input/Input.vue +33 -0
- package/app/components/ui/input/index.ts +1 -0
- package/app/components/ui/scroll-area/ScrollArea.vue +33 -0
- package/app/components/ui/scroll-area/ScrollBar.vue +32 -0
- package/app/components/ui/scroll-area/index.ts +2 -0
- package/app/components/ui/separator/Separator.vue +29 -0
- package/app/components/ui/separator/index.ts +1 -0
- package/app/components/ui/sheet/Sheet.vue +19 -0
- package/app/components/ui/sheet/SheetClose.vue +15 -0
- package/app/components/ui/sheet/SheetContent.vue +62 -0
- package/app/components/ui/sheet/SheetDescription.vue +21 -0
- package/app/components/ui/sheet/SheetFooter.vue +16 -0
- package/app/components/ui/sheet/SheetHeader.vue +15 -0
- package/app/components/ui/sheet/SheetOverlay.vue +21 -0
- package/app/components/ui/sheet/SheetTitle.vue +21 -0
- package/app/components/ui/sheet/SheetTrigger.vue +15 -0
- package/app/components/ui/sheet/index.ts +8 -0
- package/app/components/ui/tabs/Tabs.vue +24 -0
- package/app/components/ui/tabs/TabsContent.vue +21 -0
- package/app/components/ui/tabs/TabsList.vue +24 -0
- package/app/components/ui/tabs/TabsTrigger.vue +26 -0
- package/app/components/ui/tabs/index.ts +4 -0
- package/app/components/ui/tooltip/Tooltip.vue +19 -0
- package/app/components/ui/tooltip/TooltipContent.vue +34 -0
- package/app/components/ui/tooltip/TooltipProvider.vue +14 -0
- package/app/components/ui/tooltip/TooltipTrigger.vue +15 -0
- package/app/components/ui/tooltip/index.ts +4 -0
- package/app/composables/useFileWatch.ts +78 -0
- package/app/composables/useGit.ts +180 -0
- package/app/composables/useKeyboard.ts +180 -0
- package/app/composables/usePrd.ts +86 -0
- package/app/composables/useRepos.ts +108 -0
- package/app/composables/useThemeMode.ts +38 -0
- package/app/composables/useToast.ts +31 -0
- package/app/layouts/default.vue +197 -0
- package/app/lib/utils.ts +7 -0
- package/app/pages/[repo]/[prd].vue +263 -0
- package/app/pages/index.vue +257 -0
- package/app/types/git.ts +81 -0
- package/app/types/index.ts +29 -0
- package/app/types/prd.ts +49 -0
- package/app/types/repo.ts +37 -0
- package/app/types/task.ts +134 -0
- package/bin/prd +21 -0
- package/components.json +21 -0
- package/dist/app/types/git.js +1 -0
- package/dist/app/types/prd.js +1 -0
- package/dist/app/types/repo.js +1 -0
- package/dist/app/types/task.js +1 -0
- package/dist/host/src/api/git.js +96 -0
- package/dist/host/src/api/index.js +4 -0
- package/dist/host/src/api/prds.js +195 -0
- package/dist/host/src/api/repos.js +47 -0
- package/dist/host/src/api/state.js +63 -0
- package/dist/host/src/executor.js +109 -0
- package/dist/host/src/index.js +95 -0
- package/dist/host/src/mcp.js +62 -0
- package/dist/host/src/ui.js +64 -0
- package/dist/server/utils/db.js +125 -0
- package/dist/server/utils/git.js +396 -0
- package/dist/server/utils/prd-state.js +229 -0
- package/dist/server/utils/repos.js +256 -0
- package/docs/MCP.md +180 -0
- package/nuxt.config.ts +34 -0
- package/package.json +88 -0
- package/public/favicon.ico +0 -0
- package/public/robots.txt +1 -0
- package/server/api/browse.get.ts +52 -0
- package/server/api/repos/[repoId]/git/commits.get.ts +103 -0
- package/server/api/repos/[repoId]/git/diff.get.ts +77 -0
- package/server/api/repos/[repoId]/git/file-content.get.ts +66 -0
- package/server/api/repos/[repoId]/git/file-diff.get.ts +109 -0
- package/server/api/repos/[repoId]/prd/[prdSlug]/progress.get.ts +36 -0
- package/server/api/repos/[repoId]/prd/[prdSlug]/tasks/[taskId]/commits.get.ts +146 -0
- package/server/api/repos/[repoId]/prd/[prdSlug]/tasks.get.ts +36 -0
- package/server/api/repos/[repoId]/prd/[prdSlug].get.ts +97 -0
- package/server/api/repos/[repoId]/prds.get.ts +85 -0
- package/server/api/repos/[repoId]/refresh-git-repos.post.ts +42 -0
- package/server/api/repos/[repoId].delete.ts +27 -0
- package/server/api/repos/index.get.ts +5 -0
- package/server/api/repos/index.post.ts +39 -0
- package/server/api/watch.get.ts +63 -0
- package/server/plugins/migrate-legacy-state.ts +19 -0
- package/server/tsconfig.json +3 -0
- package/server/utils/db.ts +169 -0
- package/server/utils/git.ts +478 -0
- package/server/utils/prd-state.ts +335 -0
- package/server/utils/repos.ts +322 -0
- package/server/utils/watcher.ts +179 -0
- package/tsconfig.json +4 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { getRepos } from '~~/server/utils/repos'
|
|
2
|
+
import { getPrdState, migrateLegacyStateForRepo } from '~~/server/utils/prd-state'
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
const repoId = getRouterParam(event, 'repoId')
|
|
6
|
+
const prdSlug = getRouterParam(event, 'prdSlug')
|
|
7
|
+
|
|
8
|
+
if (!repoId || !prdSlug) {
|
|
9
|
+
throw createError({
|
|
10
|
+
statusCode: 400,
|
|
11
|
+
statusMessage: 'Repository ID and PRD slug are required'
|
|
12
|
+
})
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const repos = await getRepos()
|
|
16
|
+
const repo = repos.find(r => r.id === repoId)
|
|
17
|
+
|
|
18
|
+
if (!repo) {
|
|
19
|
+
throw createError({
|
|
20
|
+
statusCode: 404,
|
|
21
|
+
statusMessage: 'Repository not found'
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
await migrateLegacyStateForRepo(repo)
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const state = await getPrdState(repo.id, prdSlug)
|
|
29
|
+
return state?.tasks ?? null
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw createError({
|
|
32
|
+
statusCode: 500,
|
|
33
|
+
statusMessage: `Failed to read task state: ${(error as Error).message}`
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { getRepos } from '~~/server/utils/repos'
|
|
4
|
+
import type { PrdDocument, PrdMetadata } from '~~/app/types/prd'
|
|
5
|
+
|
|
6
|
+
function parseMetadata(content: string): PrdMetadata {
|
|
7
|
+
const metadata: PrdMetadata = {}
|
|
8
|
+
|
|
9
|
+
// Look for metadata patterns in the document header
|
|
10
|
+
// Formats: "**Author:** Value" or "Author: Value"
|
|
11
|
+
|
|
12
|
+
// Author - match **Author:** or Author: and capture the value
|
|
13
|
+
const authorMatch = content.match(/\*{0,2}Author\*{0,2}:\*{0,2}\s*(.+?)(?:\n|$)/i)
|
|
14
|
+
if (authorMatch && authorMatch[1]) {
|
|
15
|
+
metadata.author = authorMatch[1].trim()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Date
|
|
19
|
+
const dateMatch = content.match(/\*{0,2}Date\*{0,2}:\*{0,2}\s*(.+?)(?:\n|$)/i)
|
|
20
|
+
if (dateMatch && dateMatch[1]) {
|
|
21
|
+
metadata.date = dateMatch[1].trim()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Status
|
|
25
|
+
const statusMatch = content.match(/\*{0,2}Status\*{0,2}:\*{0,2}\s*(.+?)(?:\n|$)/i)
|
|
26
|
+
if (statusMatch && statusMatch[1]) {
|
|
27
|
+
metadata.status = statusMatch[1].trim()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Shortcut Story - look for link format [SC-XXX](url) or just SC-XXX
|
|
31
|
+
const shortcutLinkMatch = content.match(/\[([Ss][Cc]-\d+)\]\(([^)]+)\)/)
|
|
32
|
+
if (shortcutLinkMatch && shortcutLinkMatch[1] && shortcutLinkMatch[2]) {
|
|
33
|
+
metadata.shortcutStory = shortcutLinkMatch[1]
|
|
34
|
+
metadata.shortcutUrl = shortcutLinkMatch[2]
|
|
35
|
+
} else {
|
|
36
|
+
// Try just the ID pattern
|
|
37
|
+
const shortcutIdMatch = content.match(/\*{0,2}Shortcut(?:\s+Story)?\*{0,2}:\*{0,2}\s*([Ss][Cc]-\d+)/i)
|
|
38
|
+
if (shortcutIdMatch && shortcutIdMatch[1]) {
|
|
39
|
+
metadata.shortcutStory = shortcutIdMatch[1]
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return metadata
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default defineEventHandler(async (event) => {
|
|
47
|
+
const repoId = getRouterParam(event, 'repoId')
|
|
48
|
+
const prdSlug = getRouterParam(event, 'prdSlug')
|
|
49
|
+
|
|
50
|
+
if (!repoId || !prdSlug) {
|
|
51
|
+
throw createError({
|
|
52
|
+
statusCode: 400,
|
|
53
|
+
statusMessage: 'Repository ID and PRD slug are required'
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const repos = await getRepos()
|
|
58
|
+
const repo = repos.find(r => r.id === repoId)
|
|
59
|
+
|
|
60
|
+
if (!repo) {
|
|
61
|
+
throw createError({
|
|
62
|
+
statusCode: 404,
|
|
63
|
+
statusMessage: 'Repository not found'
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const prdPath = join(repo.path, 'docs', 'prd', `${prdSlug}.md`)
|
|
68
|
+
|
|
69
|
+
let content: string
|
|
70
|
+
try {
|
|
71
|
+
content = await fs.readFile(prdPath, 'utf-8')
|
|
72
|
+
} catch {
|
|
73
|
+
throw createError({
|
|
74
|
+
statusCode: 404,
|
|
75
|
+
statusMessage: 'PRD not found'
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Extract title from first H1
|
|
80
|
+
let name = prdSlug
|
|
81
|
+
const h1Match = content.match(/^#\s+(.+)$/m)
|
|
82
|
+
if (h1Match && h1Match[1]) {
|
|
83
|
+
name = h1Match[1].trim()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Parse metadata from document
|
|
87
|
+
const metadata = parseMetadata(content)
|
|
88
|
+
|
|
89
|
+
const document: PrdDocument = {
|
|
90
|
+
slug: prdSlug,
|
|
91
|
+
name,
|
|
92
|
+
content,
|
|
93
|
+
metadata
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return document
|
|
97
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import { join, basename } from 'node:path'
|
|
3
|
+
import { getRepos } from '~~/server/utils/repos'
|
|
4
|
+
import { getPrdStateSummaries, migrateLegacyStateForRepo } from '~~/server/utils/prd-state'
|
|
5
|
+
import type { PrdListItem } from '~~/app/types/prd'
|
|
6
|
+
|
|
7
|
+
export default defineEventHandler(async (event) => {
|
|
8
|
+
const repoId = getRouterParam(event, 'repoId')
|
|
9
|
+
|
|
10
|
+
if (!repoId) {
|
|
11
|
+
throw createError({
|
|
12
|
+
statusCode: 400,
|
|
13
|
+
statusMessage: 'Repository ID is required'
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const repos = await getRepos()
|
|
18
|
+
const repo = repos.find(r => r.id === repoId)
|
|
19
|
+
|
|
20
|
+
if (!repo) {
|
|
21
|
+
throw createError({
|
|
22
|
+
statusCode: 404,
|
|
23
|
+
statusMessage: 'Repository not found'
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
await migrateLegacyStateForRepo(repo)
|
|
28
|
+
|
|
29
|
+
const prdDir = join(repo.path, 'docs', 'prd')
|
|
30
|
+
|
|
31
|
+
let prdFiles: string[] = []
|
|
32
|
+
try {
|
|
33
|
+
const files = await fs.readdir(prdDir)
|
|
34
|
+
prdFiles = files.filter(f => f.endsWith('.md'))
|
|
35
|
+
} catch {
|
|
36
|
+
// docs/prd doesn't exist, return empty array
|
|
37
|
+
return []
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const stateSummaries = await getPrdStateSummaries(repo.id)
|
|
41
|
+
|
|
42
|
+
const prds: PrdListItem[] = await Promise.all(
|
|
43
|
+
prdFiles.map(async (filename) => {
|
|
44
|
+
const slug = basename(filename, '.md')
|
|
45
|
+
const filePath = join(prdDir, filename)
|
|
46
|
+
|
|
47
|
+
// Get file modification time and extract title from first H1
|
|
48
|
+
let name = slug
|
|
49
|
+
let modifiedAt = 0
|
|
50
|
+
try {
|
|
51
|
+
const [content, stat] = await Promise.all([
|
|
52
|
+
fs.readFile(filePath, 'utf-8'),
|
|
53
|
+
fs.stat(filePath)
|
|
54
|
+
])
|
|
55
|
+
modifiedAt = stat.mtime.getTime()
|
|
56
|
+
const h1Match = content.match(/^#\s+(.+)$/m)
|
|
57
|
+
if (h1Match && h1Match[1]) {
|
|
58
|
+
name = h1Match[1].trim()
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// Couldn't read file, use slug as name
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const stateSummary = stateSummaries.get(slug)
|
|
65
|
+
const hasState = !!stateSummary?.hasState
|
|
66
|
+
const taskCount = stateSummary?.taskCount
|
|
67
|
+
const completedCount = stateSummary?.completedCount
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
slug,
|
|
71
|
+
name,
|
|
72
|
+
source: `docs/prd/${filename}`,
|
|
73
|
+
hasState,
|
|
74
|
+
modifiedAt,
|
|
75
|
+
...(taskCount !== undefined && { taskCount }),
|
|
76
|
+
...(completedCount !== undefined && { completedCount })
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
// Sort by modification time descending (most recent first)
|
|
82
|
+
prds.sort((a, b) => b.modifiedAt - a.modifiedAt)
|
|
83
|
+
|
|
84
|
+
return prds
|
|
85
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { getRepos, saveRepos, discoverGitRepos } from '~~/server/utils/repos'
|
|
2
|
+
|
|
3
|
+
export default defineEventHandler(async (event) => {
|
|
4
|
+
const repoId = getRouterParam(event, 'repoId')
|
|
5
|
+
|
|
6
|
+
if (!repoId) {
|
|
7
|
+
throw createError({
|
|
8
|
+
statusCode: 400,
|
|
9
|
+
statusMessage: 'Repository ID is required',
|
|
10
|
+
})
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const repos = await getRepos()
|
|
14
|
+
const repoIndex = repos.findIndex(r => r.id === repoId)
|
|
15
|
+
|
|
16
|
+
if (repoIndex === -1) {
|
|
17
|
+
throw createError({
|
|
18
|
+
statusCode: 404,
|
|
19
|
+
statusMessage: 'Repository not found',
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const repo = repos[repoIndex]!
|
|
24
|
+
|
|
25
|
+
// Re-discover git repos
|
|
26
|
+
const gitRepos = await discoverGitRepos(repo.path)
|
|
27
|
+
|
|
28
|
+
// Update the repo config
|
|
29
|
+
if (gitRepos.length > 0) {
|
|
30
|
+
repo.gitRepos = gitRepos
|
|
31
|
+
} else {
|
|
32
|
+
delete repo.gitRepos
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
repos[repoIndex] = repo
|
|
36
|
+
await saveRepos(repos)
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
discovered: gitRepos.length,
|
|
40
|
+
gitRepos,
|
|
41
|
+
}
|
|
42
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { removeRepo } from '~~/server/utils/repos'
|
|
2
|
+
import { refreshWatcher } from '~~/server/utils/watcher'
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
const id = getRouterParam(event, 'repoId')
|
|
6
|
+
|
|
7
|
+
if (!id) {
|
|
8
|
+
throw createError({
|
|
9
|
+
statusCode: 400,
|
|
10
|
+
statusMessage: 'Repository ID is required'
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const removed = await removeRepo(id)
|
|
15
|
+
|
|
16
|
+
if (!removed) {
|
|
17
|
+
throw createError({
|
|
18
|
+
statusCode: 404,
|
|
19
|
+
statusMessage: 'Repository not found'
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Refresh file watcher to remove repo from watch list
|
|
24
|
+
await refreshWatcher()
|
|
25
|
+
|
|
26
|
+
return { success: true }
|
|
27
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { addRepo, validateRepoPath } from '~~/server/utils/repos'
|
|
2
|
+
import { migrateLegacyStateForRepo } from '~~/server/utils/prd-state'
|
|
3
|
+
import { refreshWatcher } from '~~/server/utils/watcher'
|
|
4
|
+
import type { AddRepoRequest } from '~~/app/types/repo'
|
|
5
|
+
|
|
6
|
+
export default defineEventHandler(async (event) => {
|
|
7
|
+
const body = await readBody<AddRepoRequest>(event)
|
|
8
|
+
|
|
9
|
+
if (!body?.path) {
|
|
10
|
+
throw createError({
|
|
11
|
+
statusCode: 400,
|
|
12
|
+
statusMessage: 'Path is required'
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const validation = await validateRepoPath(body.path)
|
|
17
|
+
if (!validation.valid) {
|
|
18
|
+
throw createError({
|
|
19
|
+
statusCode: 400,
|
|
20
|
+
statusMessage: validation.error || 'Invalid path'
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const repo = await addRepo(body.path, body.name)
|
|
26
|
+
await migrateLegacyStateForRepo(repo)
|
|
27
|
+
// Refresh file watcher to include new repo
|
|
28
|
+
await refreshWatcher()
|
|
29
|
+
return repo
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error instanceof Error && error.message === 'Repository already added') {
|
|
32
|
+
throw createError({
|
|
33
|
+
statusCode: 409,
|
|
34
|
+
statusMessage: 'Repository already added'
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
throw error
|
|
38
|
+
}
|
|
39
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { initWatcher, addListener, type FileChangeEvent } from '~~/server/utils/watcher'
|
|
2
|
+
|
|
3
|
+
export default defineEventHandler(async (event) => {
|
|
4
|
+
// Initialize watcher if not already done
|
|
5
|
+
await initWatcher()
|
|
6
|
+
|
|
7
|
+
// Set headers for SSE
|
|
8
|
+
setHeader(event, 'Content-Type', 'text/event-stream')
|
|
9
|
+
setHeader(event, 'Cache-Control', 'no-cache')
|
|
10
|
+
setHeader(event, 'Connection', 'keep-alive')
|
|
11
|
+
|
|
12
|
+
// Create a response stream
|
|
13
|
+
const stream = new ReadableStream({
|
|
14
|
+
start(controller) {
|
|
15
|
+
// Send initial connection event
|
|
16
|
+
const connectMsg = `data: ${JSON.stringify({ type: 'connected' })}\n\n`
|
|
17
|
+
controller.enqueue(new TextEncoder().encode(connectMsg))
|
|
18
|
+
|
|
19
|
+
// Add listener for file changes
|
|
20
|
+
const removeListener = addListener((fileEvent: FileChangeEvent) => {
|
|
21
|
+
const msg = `data: ${JSON.stringify(fileEvent)}\n\n`
|
|
22
|
+
try {
|
|
23
|
+
controller.enqueue(new TextEncoder().encode(msg))
|
|
24
|
+
} catch {
|
|
25
|
+
// Stream closed, clean up
|
|
26
|
+
removeListener()
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// Handle client disconnect
|
|
31
|
+
event.node.req.on('close', () => {
|
|
32
|
+
removeListener()
|
|
33
|
+
try {
|
|
34
|
+
controller.close()
|
|
35
|
+
} catch {
|
|
36
|
+
// Already closed
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// Send keepalive every 30 seconds
|
|
41
|
+
const keepaliveInterval = setInterval(() => {
|
|
42
|
+
try {
|
|
43
|
+
const ping = `: keepalive\n\n`
|
|
44
|
+
controller.enqueue(new TextEncoder().encode(ping))
|
|
45
|
+
} catch {
|
|
46
|
+
clearInterval(keepaliveInterval)
|
|
47
|
+
}
|
|
48
|
+
}, 30000)
|
|
49
|
+
|
|
50
|
+
event.node.req.on('close', () => {
|
|
51
|
+
clearInterval(keepaliveInterval)
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
return new Response(stream, {
|
|
57
|
+
headers: {
|
|
58
|
+
'Content-Type': 'text/event-stream',
|
|
59
|
+
'Cache-Control': 'no-cache',
|
|
60
|
+
'Connection': 'keep-alive'
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { getRepos } from '~~/server/utils/repos'
|
|
2
|
+
import { migrateLegacyStateForRepo } from '~~/server/utils/prd-state'
|
|
3
|
+
|
|
4
|
+
export default defineNitroPlugin((nitroApp) => {
|
|
5
|
+
// Run on startup
|
|
6
|
+
nitroApp.hooks.hook('ready' as any, async () => {
|
|
7
|
+
try {
|
|
8
|
+
const repos = await getRepos()
|
|
9
|
+
for (const repo of repos) {
|
|
10
|
+
// Run migration in the background
|
|
11
|
+
migrateLegacyStateForRepo(repo).catch(err => {
|
|
12
|
+
console.error(`Failed to migrate legacy state for repo ${repo.name}:`, err)
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
} catch (err) {
|
|
16
|
+
console.error('Failed to run startup migration:', err)
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
})
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
|
|
5
|
+
type SqlParam = string | number | bigint | Uint8Array | null
|
|
6
|
+
type SqlRow = Record<string, unknown>
|
|
7
|
+
|
|
8
|
+
type SqlRunResult = {
|
|
9
|
+
changes: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type SqliteAdapter = {
|
|
13
|
+
exec: (sql: string) => void
|
|
14
|
+
run: (sql: string, params?: SqlParam[]) => SqlRunResult
|
|
15
|
+
get: <T extends SqlRow>(sql: string, params?: SqlParam[]) => T | null
|
|
16
|
+
all: <T extends SqlRow>(sql: string, params?: SqlParam[]) => T[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEFAULT_DATA_HOME = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share')
|
|
20
|
+
const DEFAULT_DB_PATH = join(DEFAULT_DATA_HOME, 'prd', 'state.db')
|
|
21
|
+
|
|
22
|
+
let adapterPromise: Promise<SqliteAdapter> | null = null
|
|
23
|
+
|
|
24
|
+
function coerceChanges(result: unknown): number {
|
|
25
|
+
if (!result || typeof result !== 'object') {
|
|
26
|
+
return 0
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const maybeChanges = (result as { changes?: unknown }).changes
|
|
30
|
+
return typeof maybeChanges === 'number' ? maybeChanges : 0
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveDbPath(): string {
|
|
34
|
+
const customPath = process.env.PRD_STATE_DB_PATH
|
|
35
|
+
if (customPath && customPath.trim().length > 0) {
|
|
36
|
+
return customPath
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const customHome = process.env.PRD_STATE_HOME
|
|
40
|
+
if (customHome && customHome.trim().length > 0) {
|
|
41
|
+
return join(customHome, 'state.db')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return DEFAULT_DB_PATH
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getDbPath(): string {
|
|
48
|
+
return resolveDbPath()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function createNodeAdapter(dbPath: string): Promise<SqliteAdapter> {
|
|
52
|
+
const sqliteModule = await import('node:sqlite')
|
|
53
|
+
const db = new sqliteModule.DatabaseSync(dbPath)
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
exec(sql: string) {
|
|
57
|
+
db.exec(sql)
|
|
58
|
+
},
|
|
59
|
+
run(sql: string, params: SqlParam[] = []) {
|
|
60
|
+
const result = db.prepare(sql).run(...params)
|
|
61
|
+
return { changes: coerceChanges(result) }
|
|
62
|
+
},
|
|
63
|
+
get<T extends SqlRow>(sql: string, params: SqlParam[] = []) {
|
|
64
|
+
const row = db.prepare(sql).get(...params)
|
|
65
|
+
return row ? (row as T) : null
|
|
66
|
+
},
|
|
67
|
+
all<T extends SqlRow>(sql: string, params: SqlParam[] = []) {
|
|
68
|
+
return db.prepare(sql).all(...params) as T[]
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function createBunAdapter(dbPath: string): Promise<SqliteAdapter> {
|
|
74
|
+
const bunModuleName = 'bun:sqlite'
|
|
75
|
+
const sqliteModule = await import(bunModuleName)
|
|
76
|
+
const Database = (sqliteModule as { Database: new (path: string, options?: { create?: boolean }) => {
|
|
77
|
+
exec: (sql: string) => void
|
|
78
|
+
query: (sql: string) => {
|
|
79
|
+
run: (...params: SqlParam[]) => unknown
|
|
80
|
+
get: (...params: SqlParam[]) => SqlRow | null
|
|
81
|
+
all: (...params: SqlParam[]) => SqlRow[]
|
|
82
|
+
}
|
|
83
|
+
} }).Database
|
|
84
|
+
|
|
85
|
+
const db = new Database(dbPath, { create: true })
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
exec(sql: string) {
|
|
89
|
+
db.exec(sql)
|
|
90
|
+
},
|
|
91
|
+
run(sql: string, params: SqlParam[] = []) {
|
|
92
|
+
const result = db.query(sql).run(...params)
|
|
93
|
+
return { changes: coerceChanges(result) }
|
|
94
|
+
},
|
|
95
|
+
get<T extends SqlRow>(sql: string, params: SqlParam[] = []) {
|
|
96
|
+
const row = db.query(sql).get(...params) as T | null | undefined
|
|
97
|
+
return row ?? null
|
|
98
|
+
},
|
|
99
|
+
all<T extends SqlRow>(sql: string, params: SqlParam[] = []) {
|
|
100
|
+
return db.query(sql).all(...params) as T[]
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function initializeDatabase(): Promise<SqliteAdapter> {
|
|
106
|
+
const dbPath = resolveDbPath()
|
|
107
|
+
await fs.mkdir(dirname(dbPath), { recursive: true })
|
|
108
|
+
|
|
109
|
+
const isBunRuntime = typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined'
|
|
110
|
+
const adapter = isBunRuntime
|
|
111
|
+
? await createBunAdapter(dbPath)
|
|
112
|
+
: await createNodeAdapter(dbPath)
|
|
113
|
+
|
|
114
|
+
adapter.exec('PRAGMA journal_mode = WAL;')
|
|
115
|
+
adapter.exec('PRAGMA foreign_keys = ON;')
|
|
116
|
+
adapter.exec('PRAGMA busy_timeout = 5000;')
|
|
117
|
+
|
|
118
|
+
adapter.exec(`
|
|
119
|
+
CREATE TABLE IF NOT EXISTS repos (
|
|
120
|
+
id TEXT PRIMARY KEY,
|
|
121
|
+
name TEXT NOT NULL,
|
|
122
|
+
path TEXT NOT NULL UNIQUE,
|
|
123
|
+
added_at TEXT NOT NULL,
|
|
124
|
+
git_repos_json TEXT
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
CREATE TABLE IF NOT EXISTS prd_states (
|
|
128
|
+
repo_id TEXT NOT NULL,
|
|
129
|
+
slug TEXT NOT NULL,
|
|
130
|
+
tasks_json TEXT,
|
|
131
|
+
progress_json TEXT,
|
|
132
|
+
notes_md TEXT,
|
|
133
|
+
updated_at TEXT NOT NULL,
|
|
134
|
+
PRIMARY KEY (repo_id, slug),
|
|
135
|
+
FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
CREATE INDEX IF NOT EXISTS idx_prd_states_repo_id ON prd_states(repo_id);
|
|
139
|
+
`)
|
|
140
|
+
|
|
141
|
+
return adapter
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function getAdapter(): Promise<SqliteAdapter> {
|
|
145
|
+
if (!adapterPromise) {
|
|
146
|
+
adapterPromise = initializeDatabase()
|
|
147
|
+
}
|
|
148
|
+
return adapterPromise
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function dbRun(sql: string, params: SqlParam[] = []): Promise<SqlRunResult> {
|
|
152
|
+
const adapter = await getAdapter()
|
|
153
|
+
return adapter.run(sql, params)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function dbGet<T extends SqlRow>(sql: string, params: SqlParam[] = []): Promise<T | null> {
|
|
157
|
+
const adapter = await getAdapter()
|
|
158
|
+
return adapter.get<T>(sql, params)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function dbAll<T extends SqlRow>(sql: string, params: SqlParam[] = []): Promise<T[]> {
|
|
162
|
+
const adapter = await getAdapter()
|
|
163
|
+
return adapter.all<T>(sql, params)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function dbExec(sql: string): Promise<void> {
|
|
167
|
+
const adapter = await getAdapter()
|
|
168
|
+
adapter.exec(sql)
|
|
169
|
+
}
|