@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.
Files changed (154) hide show
  1. package/.env.example +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +175 -0
  4. package/app/app.vue +14 -0
  5. package/app/assets/css/main.css +129 -0
  6. package/app/components/CommandPalette.vue +182 -0
  7. package/app/components/ShortcutsHelp.vue +85 -0
  8. package/app/components/git/ChangesMinimap.vue +143 -0
  9. package/app/components/git/CommitList.vue +224 -0
  10. package/app/components/git/DiffPanel.vue +402 -0
  11. package/app/components/git/DiffViewer.vue +803 -0
  12. package/app/components/layout/RepoSelector.vue +358 -0
  13. package/app/components/layout/Sidebar.vue +91 -0
  14. package/app/components/prd/Meta.vue +69 -0
  15. package/app/components/prd/Viewer.vue +285 -0
  16. package/app/components/tasks/Board.vue +86 -0
  17. package/app/components/tasks/Card.vue +108 -0
  18. package/app/components/tasks/Column.vue +108 -0
  19. package/app/components/tasks/Detail.vue +291 -0
  20. package/app/components/ui/badge/Badge.vue +26 -0
  21. package/app/components/ui/badge/index.ts +26 -0
  22. package/app/components/ui/button/Button.vue +29 -0
  23. package/app/components/ui/button/index.ts +38 -0
  24. package/app/components/ui/card/Card.vue +22 -0
  25. package/app/components/ui/card/CardAction.vue +17 -0
  26. package/app/components/ui/card/CardContent.vue +17 -0
  27. package/app/components/ui/card/CardDescription.vue +17 -0
  28. package/app/components/ui/card/CardFooter.vue +17 -0
  29. package/app/components/ui/card/CardHeader.vue +17 -0
  30. package/app/components/ui/card/CardTitle.vue +17 -0
  31. package/app/components/ui/card/index.ts +7 -0
  32. package/app/components/ui/combobox/Combobox.vue +19 -0
  33. package/app/components/ui/combobox/ComboboxAnchor.vue +23 -0
  34. package/app/components/ui/combobox/ComboboxEmpty.vue +21 -0
  35. package/app/components/ui/combobox/ComboboxGroup.vue +27 -0
  36. package/app/components/ui/combobox/ComboboxInput.vue +42 -0
  37. package/app/components/ui/combobox/ComboboxItem.vue +24 -0
  38. package/app/components/ui/combobox/ComboboxItemIndicator.vue +23 -0
  39. package/app/components/ui/combobox/ComboboxList.vue +33 -0
  40. package/app/components/ui/combobox/ComboboxSeparator.vue +21 -0
  41. package/app/components/ui/combobox/ComboboxTrigger.vue +24 -0
  42. package/app/components/ui/combobox/ComboboxViewport.vue +23 -0
  43. package/app/components/ui/combobox/index.ts +13 -0
  44. package/app/components/ui/command/Command.vue +103 -0
  45. package/app/components/ui/command/CommandDialog.vue +33 -0
  46. package/app/components/ui/command/CommandEmpty.vue +27 -0
  47. package/app/components/ui/command/CommandGroup.vue +45 -0
  48. package/app/components/ui/command/CommandInput.vue +54 -0
  49. package/app/components/ui/command/CommandItem.vue +76 -0
  50. package/app/components/ui/command/CommandList.vue +25 -0
  51. package/app/components/ui/command/CommandSeparator.vue +21 -0
  52. package/app/components/ui/command/CommandShortcut.vue +17 -0
  53. package/app/components/ui/command/index.ts +25 -0
  54. package/app/components/ui/dialog/Dialog.vue +19 -0
  55. package/app/components/ui/dialog/DialogClose.vue +15 -0
  56. package/app/components/ui/dialog/DialogContent.vue +53 -0
  57. package/app/components/ui/dialog/DialogDescription.vue +23 -0
  58. package/app/components/ui/dialog/DialogFooter.vue +15 -0
  59. package/app/components/ui/dialog/DialogHeader.vue +17 -0
  60. package/app/components/ui/dialog/DialogOverlay.vue +21 -0
  61. package/app/components/ui/dialog/DialogScrollContent.vue +59 -0
  62. package/app/components/ui/dialog/DialogTitle.vue +23 -0
  63. package/app/components/ui/dialog/DialogTrigger.vue +15 -0
  64. package/app/components/ui/dialog/index.ts +10 -0
  65. package/app/components/ui/input/Input.vue +33 -0
  66. package/app/components/ui/input/index.ts +1 -0
  67. package/app/components/ui/scroll-area/ScrollArea.vue +33 -0
  68. package/app/components/ui/scroll-area/ScrollBar.vue +32 -0
  69. package/app/components/ui/scroll-area/index.ts +2 -0
  70. package/app/components/ui/separator/Separator.vue +29 -0
  71. package/app/components/ui/separator/index.ts +1 -0
  72. package/app/components/ui/sheet/Sheet.vue +19 -0
  73. package/app/components/ui/sheet/SheetClose.vue +15 -0
  74. package/app/components/ui/sheet/SheetContent.vue +62 -0
  75. package/app/components/ui/sheet/SheetDescription.vue +21 -0
  76. package/app/components/ui/sheet/SheetFooter.vue +16 -0
  77. package/app/components/ui/sheet/SheetHeader.vue +15 -0
  78. package/app/components/ui/sheet/SheetOverlay.vue +21 -0
  79. package/app/components/ui/sheet/SheetTitle.vue +21 -0
  80. package/app/components/ui/sheet/SheetTrigger.vue +15 -0
  81. package/app/components/ui/sheet/index.ts +8 -0
  82. package/app/components/ui/tabs/Tabs.vue +24 -0
  83. package/app/components/ui/tabs/TabsContent.vue +21 -0
  84. package/app/components/ui/tabs/TabsList.vue +24 -0
  85. package/app/components/ui/tabs/TabsTrigger.vue +26 -0
  86. package/app/components/ui/tabs/index.ts +4 -0
  87. package/app/components/ui/tooltip/Tooltip.vue +19 -0
  88. package/app/components/ui/tooltip/TooltipContent.vue +34 -0
  89. package/app/components/ui/tooltip/TooltipProvider.vue +14 -0
  90. package/app/components/ui/tooltip/TooltipTrigger.vue +15 -0
  91. package/app/components/ui/tooltip/index.ts +4 -0
  92. package/app/composables/useFileWatch.ts +78 -0
  93. package/app/composables/useGit.ts +180 -0
  94. package/app/composables/useKeyboard.ts +180 -0
  95. package/app/composables/usePrd.ts +86 -0
  96. package/app/composables/useRepos.ts +108 -0
  97. package/app/composables/useThemeMode.ts +38 -0
  98. package/app/composables/useToast.ts +31 -0
  99. package/app/layouts/default.vue +197 -0
  100. package/app/lib/utils.ts +7 -0
  101. package/app/pages/[repo]/[prd].vue +263 -0
  102. package/app/pages/index.vue +257 -0
  103. package/app/types/git.ts +81 -0
  104. package/app/types/index.ts +29 -0
  105. package/app/types/prd.ts +49 -0
  106. package/app/types/repo.ts +37 -0
  107. package/app/types/task.ts +134 -0
  108. package/bin/prd +21 -0
  109. package/components.json +21 -0
  110. package/dist/app/types/git.js +1 -0
  111. package/dist/app/types/prd.js +1 -0
  112. package/dist/app/types/repo.js +1 -0
  113. package/dist/app/types/task.js +1 -0
  114. package/dist/host/src/api/git.js +96 -0
  115. package/dist/host/src/api/index.js +4 -0
  116. package/dist/host/src/api/prds.js +195 -0
  117. package/dist/host/src/api/repos.js +47 -0
  118. package/dist/host/src/api/state.js +63 -0
  119. package/dist/host/src/executor.js +109 -0
  120. package/dist/host/src/index.js +95 -0
  121. package/dist/host/src/mcp.js +62 -0
  122. package/dist/host/src/ui.js +64 -0
  123. package/dist/server/utils/db.js +125 -0
  124. package/dist/server/utils/git.js +396 -0
  125. package/dist/server/utils/prd-state.js +229 -0
  126. package/dist/server/utils/repos.js +256 -0
  127. package/docs/MCP.md +180 -0
  128. package/nuxt.config.ts +34 -0
  129. package/package.json +88 -0
  130. package/public/favicon.ico +0 -0
  131. package/public/robots.txt +1 -0
  132. package/server/api/browse.get.ts +52 -0
  133. package/server/api/repos/[repoId]/git/commits.get.ts +103 -0
  134. package/server/api/repos/[repoId]/git/diff.get.ts +77 -0
  135. package/server/api/repos/[repoId]/git/file-content.get.ts +66 -0
  136. package/server/api/repos/[repoId]/git/file-diff.get.ts +109 -0
  137. package/server/api/repos/[repoId]/prd/[prdSlug]/progress.get.ts +36 -0
  138. package/server/api/repos/[repoId]/prd/[prdSlug]/tasks/[taskId]/commits.get.ts +146 -0
  139. package/server/api/repos/[repoId]/prd/[prdSlug]/tasks.get.ts +36 -0
  140. package/server/api/repos/[repoId]/prd/[prdSlug].get.ts +97 -0
  141. package/server/api/repos/[repoId]/prds.get.ts +85 -0
  142. package/server/api/repos/[repoId]/refresh-git-repos.post.ts +42 -0
  143. package/server/api/repos/[repoId].delete.ts +27 -0
  144. package/server/api/repos/index.get.ts +5 -0
  145. package/server/api/repos/index.post.ts +39 -0
  146. package/server/api/watch.get.ts +63 -0
  147. package/server/plugins/migrate-legacy-state.ts +19 -0
  148. package/server/tsconfig.json +3 -0
  149. package/server/utils/db.ts +169 -0
  150. package/server/utils/git.ts +478 -0
  151. package/server/utils/prd-state.ts +335 -0
  152. package/server/utils/repos.ts +322 -0
  153. package/server/utils/watcher.ts +179 -0
  154. 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,5 @@
1
+ import { getRepos } from '~~/server/utils/repos'
2
+
3
+ export default defineEventHandler(async () => {
4
+ return await getRepos()
5
+ })
@@ -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,3 @@
1
+ {
2
+ "extends": "../.nuxt/tsconfig.server.json"
3
+ }
@@ -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
+ }