@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
package/package.json ADDED
@@ -0,0 +1,88 @@
1
+ {
2
+ "name": "@thxgg/steward",
3
+ "version": "0.1.0",
4
+ "description": "Local-first PRD workflow steward with codemode MCP and web UI.",
5
+ "type": "module",
6
+ "author": "thxgg",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/thxgg/prd-viewer.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/thxgg/prd-viewer/issues"
14
+ },
15
+ "homepage": "https://github.com/thxgg/prd-viewer#readme",
16
+ "keywords": [
17
+ "prd",
18
+ "mcp",
19
+ "workflow",
20
+ "nuxt",
21
+ "sqlite",
22
+ "developer-tools"
23
+ ],
24
+ "engines": {
25
+ "node": ">=22.6.0",
26
+ "npm": ">=10"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public",
30
+ "provenance": true
31
+ },
32
+ "bin": {
33
+ "prd": "./bin/prd"
34
+ },
35
+ "files": [
36
+ "bin/",
37
+ "dist/",
38
+ "app/",
39
+ "server/",
40
+ "public/",
41
+ "docs/MCP.md",
42
+ "nuxt.config.ts",
43
+ "components.json",
44
+ ".env.example",
45
+ "README.md",
46
+ "LICENSE",
47
+ "tsconfig.json"
48
+ ],
49
+ "scripts": {
50
+ "build": "npm run build:host && nuxt build",
51
+ "build:host": "tsc -p tsconfig.host.build.json",
52
+ "dev": "nuxt dev",
53
+ "generate": "nuxt generate",
54
+ "preview": "nuxt preview",
55
+ "mcp": "npm run build:host && node ./bin/prd mcp",
56
+ "ui": "npm run build:host && node ./bin/prd ui",
57
+ "typecheck": "nuxt typecheck && npm run typecheck:host",
58
+ "typecheck:host": "tsc -p tsconfig.host.json",
59
+ "prepack": "npm run build:host"
60
+ },
61
+ "dependencies": {
62
+ "@modelcontextprotocol/sdk": "^1.26.0",
63
+ "@vueuse/core": "^14.1.0",
64
+ "chokidar": "^5.0.0",
65
+ "class-variance-authority": "^0.7.1",
66
+ "clsx": "^2.1.1",
67
+ "dompurify": "^3.3.1",
68
+ "lucide-vue-next": "^0.563.0",
69
+ "marked": "^17.0.1",
70
+ "nuxt": "^3.21.0",
71
+ "reka-ui": "^2.7.0",
72
+ "shiki": "^3.21.0",
73
+ "tailwind-merge": "^3.4.0",
74
+ "vue": "latest",
75
+ "vue-sonner": "^1.3.2",
76
+ "zod": "^3.25.76"
77
+ },
78
+ "devDependencies": {
79
+ "@nuxtjs/color-mode": "^4.0.0",
80
+ "@tailwindcss/vite": "^4.1.18",
81
+ "@types/dompurify": "^3.2.0",
82
+ "@types/node": "^25.0.10",
83
+ "tailwindcss": "^4.0.0",
84
+ "typescript": "^5.9.2",
85
+ "tw-animate-css": "^1.4.0",
86
+ "vue-tsc": "^3.2.3"
87
+ }
88
+ }
Binary file
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,52 @@
1
+ import { readdir, stat } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const query = getQuery(event)
7
+ let path = (query.path as string) || homedir()
8
+
9
+ // Expand ~ to home directory
10
+ if (path.startsWith('~')) {
11
+ path = path.replace('~', homedir())
12
+ }
13
+
14
+ try {
15
+ const stats = await stat(path)
16
+ if (!stats.isDirectory()) {
17
+ throw createError({
18
+ statusCode: 400,
19
+ message: 'Path is not a directory'
20
+ })
21
+ }
22
+
23
+ const entries = await readdir(path, { withFileTypes: true })
24
+ const directories = entries
25
+ .filter(entry => entry.isDirectory() && !entry.name.startsWith('.'))
26
+ .map(entry => ({
27
+ name: entry.name,
28
+ path: join(path, entry.name)
29
+ }))
30
+ .sort((a, b) => a.name.localeCompare(b.name))
31
+
32
+ return {
33
+ current: path,
34
+ parent: join(path, '..'),
35
+ directories
36
+ }
37
+ } catch (err: any) {
38
+ if (err.code === 'ENOENT') {
39
+ throw createError({
40
+ statusCode: 404,
41
+ message: 'Directory not found'
42
+ })
43
+ }
44
+ if (err.code === 'EACCES') {
45
+ throw createError({
46
+ statusCode: 403,
47
+ message: 'Permission denied'
48
+ })
49
+ }
50
+ throw err
51
+ }
52
+ })
@@ -0,0 +1,103 @@
1
+ import { getRepos } from '~~/server/utils/repos'
2
+ import { isGitRepo, getCommitInfo } from '~~/server/utils/git'
3
+ import type { GitCommit } from '~/types/git'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const repoId = getRouterParam(event, 'repoId')
7
+
8
+ if (!repoId) {
9
+ throw createError({
10
+ statusCode: 400,
11
+ statusMessage: 'Repository ID is 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
+ // Get query parameters
26
+ const query = getQuery(event)
27
+ const shasParam = query.shas as string | undefined
28
+ const repoPath = query.repo as string | undefined
29
+
30
+ if (!shasParam) {
31
+ throw createError({
32
+ statusCode: 400,
33
+ statusMessage: 'shas query parameter is required',
34
+ })
35
+ }
36
+
37
+ const shas = shasParam.split(',').map(s => s.trim()).filter(Boolean)
38
+
39
+ if (shas.length === 0) {
40
+ throw createError({
41
+ statusCode: 400,
42
+ statusMessage: 'At least one SHA is required',
43
+ })
44
+ }
45
+
46
+ // Resolve the git repo path
47
+ let gitRepoPath = repo.path
48
+
49
+ if (repoPath) {
50
+ // Validate that repoPath is within discovered gitRepos
51
+ if (!repo.gitRepos || repo.gitRepos.length === 0) {
52
+ throw createError({
53
+ statusCode: 400,
54
+ statusMessage: 'repo parameter provided but no git repos discovered in this repository',
55
+ })
56
+ }
57
+
58
+ const matchedRepo = repo.gitRepos.find(gr => gr.relativePath === repoPath)
59
+ if (!matchedRepo) {
60
+ throw createError({
61
+ statusCode: 400,
62
+ statusMessage: `repo "${repoPath}" is not a discovered git repo. Available: ${repo.gitRepos.map(gr => gr.relativePath).join(', ')}`,
63
+ })
64
+ }
65
+
66
+ gitRepoPath = matchedRepo.absolutePath
67
+ }
68
+
69
+ // Check if resolved path is a git repository
70
+ if (!await isGitRepo(gitRepoPath)) {
71
+ throw createError({
72
+ statusCode: 400,
73
+ statusMessage: 'Resolved path is not a git repository',
74
+ })
75
+ }
76
+
77
+ // Fetch commit info for each SHA
78
+ const commits: GitCommit[] = []
79
+ const errors: string[] = []
80
+
81
+ for (const sha of shas) {
82
+ try {
83
+ const commit = await getCommitInfo(gitRepoPath, sha)
84
+ // Add repoPath to the response
85
+ commits.push({
86
+ ...commit,
87
+ repoPath: repoPath || '',
88
+ })
89
+ } catch (error) {
90
+ errors.push(`${sha}: ${(error as Error).message}`)
91
+ }
92
+ }
93
+
94
+ // If all commits failed, throw error
95
+ if (commits.length === 0 && errors.length > 0) {
96
+ throw createError({
97
+ statusCode: 404,
98
+ statusMessage: `No valid commits found: ${errors.join('; ')}`,
99
+ })
100
+ }
101
+
102
+ return commits
103
+ })
@@ -0,0 +1,77 @@
1
+ import { join } from 'node:path'
2
+ import { getRepos } from '~~/server/utils/repos'
3
+ import { isGitRepo, getCommitDiff } from '~~/server/utils/git'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const repoId = getRouterParam(event, 'repoId')
7
+
8
+ if (!repoId) {
9
+ throw createError({
10
+ statusCode: 400,
11
+ statusMessage: 'Repository ID is 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
+ // Get query parameters
26
+ const query = getQuery(event)
27
+ const commit = query.commit as string | undefined
28
+ const repoPath = query.repo as string | undefined
29
+
30
+ if (!commit) {
31
+ throw createError({
32
+ statusCode: 400,
33
+ statusMessage: 'commit query parameter is required',
34
+ })
35
+ }
36
+
37
+ // Resolve the git repo path
38
+ let gitRepoPath = repo.path
39
+
40
+ if (repoPath) {
41
+ // Validate that repoPath is within discovered gitRepos
42
+ if (!repo.gitRepos || repo.gitRepos.length === 0) {
43
+ throw createError({
44
+ statusCode: 400,
45
+ statusMessage: 'repo parameter provided but no git repos discovered in this repository',
46
+ })
47
+ }
48
+
49
+ const matchedRepo = repo.gitRepos.find(gr => gr.relativePath === repoPath)
50
+ if (!matchedRepo) {
51
+ throw createError({
52
+ statusCode: 400,
53
+ statusMessage: `repo "${repoPath}" is not a discovered git repo. Available: ${repo.gitRepos.map(gr => gr.relativePath).join(', ')}`,
54
+ })
55
+ }
56
+
57
+ gitRepoPath = matchedRepo.absolutePath
58
+ }
59
+
60
+ // Check if resolved path is a git repository
61
+ if (!await isGitRepo(gitRepoPath)) {
62
+ throw createError({
63
+ statusCode: 400,
64
+ statusMessage: 'Resolved path is not a git repository',
65
+ })
66
+ }
67
+
68
+ try {
69
+ const files = await getCommitDiff(gitRepoPath, commit)
70
+ return files
71
+ } catch (error) {
72
+ throw createError({
73
+ statusCode: 404,
74
+ statusMessage: `Commit not found or invalid: ${(error as Error).message}`,
75
+ })
76
+ }
77
+ })
@@ -0,0 +1,66 @@
1
+ import { getRepos } from '~~/server/utils/repos'
2
+ import { isGitRepo, getFileContent } from '~~/server/utils/git'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const repoId = getRouterParam(event, 'repoId')
6
+ const query = getQuery(event)
7
+ const commit = query.commit as string | undefined
8
+ const file = query.file as string | undefined
9
+ const repoPath = query.repo as string | undefined
10
+
11
+ if (!repoId) {
12
+ throw createError({ statusCode: 400, message: 'Repository ID is required' })
13
+ }
14
+
15
+ if (!commit) {
16
+ throw createError({ statusCode: 400, message: 'Commit SHA is required' })
17
+ }
18
+
19
+ if (!file) {
20
+ throw createError({ statusCode: 400, message: 'File path is required' })
21
+ }
22
+
23
+ const repos = await getRepos()
24
+ const repo = repos.find(r => r.id === repoId)
25
+
26
+ if (!repo) {
27
+ throw createError({ statusCode: 404, message: 'Repository not found' })
28
+ }
29
+
30
+ // Resolve the git repo path
31
+ let gitRepoPath = repo.path
32
+
33
+ if (repoPath) {
34
+ // Validate that repoPath is within discovered gitRepos
35
+ if (!repo.gitRepos || repo.gitRepos.length === 0) {
36
+ throw createError({
37
+ statusCode: 400,
38
+ message: 'repo parameter provided but no git repos discovered in this repository',
39
+ })
40
+ }
41
+
42
+ const matchedRepo = repo.gitRepos.find(gr => gr.relativePath === repoPath)
43
+ if (!matchedRepo) {
44
+ throw createError({
45
+ statusCode: 400,
46
+ message: `repo "${repoPath}" is not a discovered git repo. Available: ${repo.gitRepos.map(gr => gr.relativePath).join(', ')}`,
47
+ })
48
+ }
49
+
50
+ gitRepoPath = matchedRepo.absolutePath
51
+ }
52
+
53
+ if (!await isGitRepo(gitRepoPath)) {
54
+ throw createError({ statusCode: 400, message: 'Not a git repository' })
55
+ }
56
+
57
+ try {
58
+ const content = await getFileContent(gitRepoPath, commit, file)
59
+ return { content }
60
+ } catch (error) {
61
+ throw createError({
62
+ statusCode: 404,
63
+ message: error instanceof Error ? error.message : 'Failed to get file content'
64
+ })
65
+ }
66
+ })
@@ -0,0 +1,109 @@
1
+ import { getRepos } from '~~/server/utils/repos'
2
+ import { isGitRepo, getFileDiff, validatePathInRepo } from '~~/server/utils/git'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const repoId = getRouterParam(event, 'repoId')
6
+
7
+ if (!repoId) {
8
+ throw createError({
9
+ statusCode: 400,
10
+ statusMessage: 'Repository ID is required',
11
+ })
12
+ }
13
+
14
+ const repos = await getRepos()
15
+ const repo = repos.find(r => r.id === repoId)
16
+
17
+ if (!repo) {
18
+ throw createError({
19
+ statusCode: 404,
20
+ statusMessage: 'Repository not found',
21
+ })
22
+ }
23
+
24
+ // Get query parameters
25
+ const query = getQuery(event)
26
+ const commit = query.commit as string | undefined
27
+ const file = query.file as string | undefined
28
+ const repoPath = query.repo as string | undefined
29
+
30
+ if (!commit) {
31
+ throw createError({
32
+ statusCode: 400,
33
+ statusMessage: 'commit query parameter is required',
34
+ })
35
+ }
36
+
37
+ if (!file) {
38
+ throw createError({
39
+ statusCode: 400,
40
+ statusMessage: 'file query parameter is required',
41
+ })
42
+ }
43
+
44
+ // Resolve the git repo path
45
+ let gitRepoPath = repo.path
46
+
47
+ if (repoPath) {
48
+ // Validate that repoPath is within discovered gitRepos
49
+ if (!repo.gitRepos || repo.gitRepos.length === 0) {
50
+ throw createError({
51
+ statusCode: 400,
52
+ statusMessage: 'repo parameter provided but no git repos discovered in this repository',
53
+ })
54
+ }
55
+
56
+ const matchedRepo = repo.gitRepos.find(gr => gr.relativePath === repoPath)
57
+ if (!matchedRepo) {
58
+ throw createError({
59
+ statusCode: 400,
60
+ statusMessage: `repo "${repoPath}" is not a discovered git repo. Available: ${repo.gitRepos.map(gr => gr.relativePath).join(', ')}`,
61
+ })
62
+ }
63
+
64
+ gitRepoPath = matchedRepo.absolutePath
65
+ }
66
+
67
+ // Check if resolved path is a git repository
68
+ if (!await isGitRepo(gitRepoPath)) {
69
+ throw createError({
70
+ statusCode: 400,
71
+ statusMessage: 'Resolved path is not a git repository',
72
+ })
73
+ }
74
+
75
+ // Validate file path is within repo
76
+ if (!validatePathInRepo(gitRepoPath, file)) {
77
+ throw createError({
78
+ statusCode: 400,
79
+ statusMessage: 'Invalid file path: path traversal not allowed',
80
+ })
81
+ }
82
+
83
+ try {
84
+ const hunks = await getFileDiff(gitRepoPath, commit, file)
85
+ return hunks
86
+ } catch (error) {
87
+ const message = (error as Error).message
88
+
89
+ // Determine appropriate error code
90
+ if (message.includes('Invalid commit SHA')) {
91
+ throw createError({
92
+ statusCode: 400,
93
+ statusMessage: message,
94
+ })
95
+ }
96
+
97
+ if (message.includes('outside repository')) {
98
+ throw createError({
99
+ statusCode: 400,
100
+ statusMessage: message,
101
+ })
102
+ }
103
+
104
+ throw createError({
105
+ statusCode: 404,
106
+ statusMessage: `File not found in commit or commit invalid: ${message}`,
107
+ })
108
+ }
109
+ })
@@ -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?.progress ?? null
30
+ } catch (error) {
31
+ throw createError({
32
+ statusCode: 500,
33
+ statusMessage: `Failed to read progress state: ${(error as Error).message}`
34
+ })
35
+ }
36
+ })
@@ -0,0 +1,146 @@
1
+ import { getRepos, saveRepos, discoverGitRepos } from '~~/server/utils/repos'
2
+ import { getPrdState, migrateLegacyStateForRepo } from '~~/server/utils/prd-state'
3
+ import { resolveCommitRepo } from '~~/server/utils/git'
4
+ import type { ProgressFile, CommitRef } from '~/types/task'
5
+ import type { RepoConfig } from '~/types/repo'
6
+
7
+ /**
8
+ * Response format for resolved commits
9
+ */
10
+ interface ResolvedCommitResponse {
11
+ sha: string
12
+ repo: string
13
+ }
14
+
15
+ export default defineEventHandler(async (event) => {
16
+ const repoId = getRouterParam(event, 'repoId')
17
+ const prdSlug = getRouterParam(event, 'prdSlug')
18
+ const taskId = getRouterParam(event, 'taskId')
19
+
20
+ if (!repoId || !prdSlug || !taskId) {
21
+ throw createError({
22
+ statusCode: 400,
23
+ statusMessage: 'Repository ID, PRD slug, and task ID are required'
24
+ })
25
+ }
26
+
27
+ const repos = await getRepos()
28
+ const repo = repos.find(r => r.id === repoId)
29
+
30
+ if (!repo) {
31
+ throw createError({
32
+ statusCode: 404,
33
+ statusMessage: 'Repository not found'
34
+ })
35
+ }
36
+
37
+ await migrateLegacyStateForRepo(repo)
38
+
39
+ let progress: ProgressFile | null = null
40
+ try {
41
+ const state = await getPrdState(repo.id, prdSlug)
42
+ progress = state?.progress ?? null
43
+ } catch (error) {
44
+ throw createError({
45
+ statusCode: 500,
46
+ statusMessage: `Failed to read progress state: ${(error as Error).message}`
47
+ })
48
+ }
49
+
50
+ if (!progress) {
51
+ return []
52
+ }
53
+
54
+ // Find task log entry matching taskId
55
+ const taskLog = progress.taskLogs.find(log => log.taskId === taskId)
56
+
57
+ if (!taskLog) {
58
+ throw createError({
59
+ statusCode: 404,
60
+ statusMessage: `Task "${taskId}" not found in progress state`
61
+ })
62
+ }
63
+
64
+ // No commits recorded
65
+ if (!taskLog.commits || taskLog.commits.length === 0) {
66
+ return []
67
+ }
68
+
69
+ // Resolve all commits to normalized format with repo context
70
+ const resolvedCommits: ResolvedCommitResponse[] = []
71
+ const failedEntries: (string | CommitRef)[] = []
72
+
73
+ // First pass: try to resolve commits with current repo config
74
+ for (const commitEntry of taskLog.commits) {
75
+ try {
76
+ const resolved = await resolveCommitRepo(repo, commitEntry)
77
+ resolvedCommits.push({
78
+ sha: resolved.sha,
79
+ repo: resolved.repoPath,
80
+ })
81
+ } catch {
82
+ failedEntries.push(commitEntry)
83
+ }
84
+ }
85
+
86
+ // If some commits failed and this is a pseudo-monorepo, try re-discovering git repos
87
+ if (failedEntries.length > 0) {
88
+ const newGitRepos = await discoverGitRepos(repo.path)
89
+
90
+ // Check if we discovered any new repos
91
+ const existingPaths = new Set((repo.gitRepos || []).map(gr => gr.relativePath))
92
+ const hasNewRepos = newGitRepos.some(gr => !existingPaths.has(gr.relativePath))
93
+
94
+ if (hasNewRepos) {
95
+ // Update repo config with newly discovered repos
96
+ const repos = await getRepos()
97
+ const repoIndex = repos.findIndex(r => r.id === repoId)
98
+ if (repoIndex !== -1) {
99
+ const updatedRepo: RepoConfig = {
100
+ ...repos[repoIndex]!,
101
+ gitRepos: newGitRepos.length > 0 ? newGitRepos : undefined,
102
+ }
103
+ repos[repoIndex] = updatedRepo
104
+ await saveRepos(repos)
105
+
106
+ // Retry failed commits with updated config
107
+ for (const commitEntry of failedEntries) {
108
+ try {
109
+ const resolved = await resolveCommitRepo(updatedRepo, commitEntry)
110
+ resolvedCommits.push({
111
+ sha: resolved.sha,
112
+ repo: resolved.repoPath,
113
+ })
114
+ } catch {
115
+ // Still failed after re-discovery, add with empty repo
116
+ const sha = typeof commitEntry === 'string' ? commitEntry : commitEntry.sha
117
+ resolvedCommits.push({
118
+ sha,
119
+ repo: '',
120
+ })
121
+ }
122
+ }
123
+ } else {
124
+ // Repo not found in list, add failed entries with empty repo
125
+ for (const commitEntry of failedEntries) {
126
+ const sha = typeof commitEntry === 'string' ? commitEntry : commitEntry.sha
127
+ resolvedCommits.push({
128
+ sha,
129
+ repo: '',
130
+ })
131
+ }
132
+ }
133
+ } else {
134
+ // No new repos discovered, add failed entries with empty repo
135
+ for (const commitEntry of failedEntries) {
136
+ const sha = typeof commitEntry === 'string' ? commitEntry : commitEntry.sha
137
+ resolvedCommits.push({
138
+ sha,
139
+ repo: '',
140
+ })
141
+ }
142
+ }
143
+ }
144
+
145
+ return resolvedCommits
146
+ })