@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,335 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import type { RepoConfig } from '../../app/types/repo.js'
4
+ import type { TasksFile, ProgressFile } from '../../app/types/task.js'
5
+ import { dbAll, dbGet, dbRun } from './db.js'
6
+
7
+ type PrdStateRow = {
8
+ repo_id: string
9
+ slug: string
10
+ tasks_json: string | null
11
+ progress_json: string | null
12
+ notes_md: string | null
13
+ updated_at: string
14
+ }
15
+
16
+ export type StoredPrdState = {
17
+ slug: string
18
+ tasks: TasksFile | null
19
+ progress: ProgressFile | null
20
+ notes: string | null
21
+ updatedAt: string
22
+ }
23
+
24
+ export type PrdStateUpdate = {
25
+ tasks?: TasksFile | null
26
+ progress?: ProgressFile | null
27
+ notes?: string | null
28
+ }
29
+
30
+ export type PrdStateSummary = {
31
+ hasState: boolean
32
+ taskCount?: number
33
+ completedCount?: number
34
+ }
35
+
36
+ const LEGACY_STATE_STABLE_MS = 0
37
+ const migrationInFlight = new Map<string, Promise<void>>()
38
+ const cleanupCompletedRepoIds = new Set<string>()
39
+
40
+ function parseStoredJson<T>(raw: string | null, fieldName: string): T | null {
41
+ if (!raw) {
42
+ return null
43
+ }
44
+
45
+ try {
46
+ return JSON.parse(raw) as T
47
+ } catch (error) {
48
+ const message = error instanceof Error ? error.message : String(error)
49
+ throw new Error(`Invalid JSON stored in ${fieldName}: ${message}`)
50
+ }
51
+ }
52
+
53
+ function getTaskCounts(tasksFile: TasksFile): { taskCount: number; completedCount: number } | null {
54
+ if (!tasksFile || !Array.isArray(tasksFile.tasks)) {
55
+ return null
56
+ }
57
+
58
+ const taskCount = tasksFile.tasks.length
59
+ const completedCount = tasksFile.tasks.filter(task => task.status === 'completed').length
60
+ return { taskCount, completedCount }
61
+ }
62
+
63
+ function normalizeLegacyTasksFile(tasksFile: TasksFile | null): TasksFile | null {
64
+ if (!tasksFile || !Array.isArray(tasksFile.tasks)) {
65
+ return tasksFile
66
+ }
67
+
68
+ const tasks = tasksFile.tasks.map((task) => {
69
+ const passes = (task as { passes?: unknown }).passes
70
+ if (Array.isArray(passes)) {
71
+ return task
72
+ }
73
+
74
+ return {
75
+ ...task,
76
+ passes: []
77
+ }
78
+ })
79
+
80
+ return {
81
+ ...tasksFile,
82
+ tasks
83
+ }
84
+ }
85
+
86
+ export async function getPrdState(repoId: string, slug: string): Promise<StoredPrdState | null> {
87
+ const row = await dbGet<PrdStateRow>(
88
+ `
89
+ SELECT repo_id, slug, tasks_json, progress_json, notes_md, updated_at
90
+ FROM prd_states
91
+ WHERE repo_id = ? AND slug = ?
92
+ `,
93
+ [repoId, slug]
94
+ )
95
+
96
+ if (!row) {
97
+ return null
98
+ }
99
+
100
+ const tasks = normalizeLegacyTasksFile(parseStoredJson<TasksFile>(row.tasks_json, 'prd_states.tasks_json'))
101
+
102
+ return {
103
+ slug: row.slug,
104
+ tasks,
105
+ progress: parseStoredJson<ProgressFile>(row.progress_json, 'prd_states.progress_json'),
106
+ notes: row.notes_md,
107
+ updatedAt: row.updated_at
108
+ }
109
+ }
110
+
111
+ export async function getPrdStateSummaries(repoId: string): Promise<Map<string, PrdStateSummary>> {
112
+ const rows = await dbAll<Pick<PrdStateRow, 'slug' | 'tasks_json'>>(
113
+ 'SELECT slug, tasks_json FROM prd_states WHERE repo_id = ?',
114
+ [repoId]
115
+ )
116
+
117
+ const summaries = new Map<string, PrdStateSummary>()
118
+
119
+ for (const row of rows) {
120
+ const summary: PrdStateSummary = { hasState: true }
121
+
122
+ if (row.tasks_json) {
123
+ try {
124
+ const tasksFile = JSON.parse(row.tasks_json) as TasksFile
125
+ const counts = getTaskCounts(tasksFile)
126
+ if (counts) {
127
+ summary.taskCount = counts.taskCount
128
+ summary.completedCount = counts.completedCount
129
+ }
130
+ } catch {
131
+ // Keep hasState=true and omit counts when JSON is malformed.
132
+ }
133
+ }
134
+
135
+ summaries.set(row.slug, summary)
136
+ }
137
+
138
+ return summaries
139
+ }
140
+
141
+ export async function upsertPrdState(repoId: string, slug: string, update: PrdStateUpdate): Promise<void> {
142
+ const existing = await dbGet<PrdStateRow>(
143
+ `
144
+ SELECT repo_id, slug, tasks_json, progress_json, notes_md, updated_at
145
+ FROM prd_states
146
+ WHERE repo_id = ? AND slug = ?
147
+ `,
148
+ [repoId, slug]
149
+ )
150
+
151
+ const tasksJson = update.tasks === undefined
152
+ ? existing?.tasks_json ?? null
153
+ : (update.tasks === null ? null : JSON.stringify(update.tasks))
154
+
155
+ const progressJson = update.progress === undefined
156
+ ? existing?.progress_json ?? null
157
+ : (update.progress === null ? null : JSON.stringify(update.progress))
158
+
159
+ const notesMd = update.notes === undefined
160
+ ? existing?.notes_md ?? null
161
+ : update.notes
162
+
163
+ const updatedAt = new Date().toISOString()
164
+
165
+ if (existing) {
166
+ await dbRun(
167
+ `
168
+ UPDATE prd_states
169
+ SET tasks_json = ?, progress_json = ?, notes_md = ?, updated_at = ?
170
+ WHERE repo_id = ? AND slug = ?
171
+ `,
172
+ [tasksJson, progressJson, notesMd, updatedAt, repoId, slug]
173
+ )
174
+ return
175
+ }
176
+
177
+ await dbRun(
178
+ `
179
+ INSERT INTO prd_states (repo_id, slug, tasks_json, progress_json, notes_md, updated_at)
180
+ VALUES (?, ?, ?, ?, ?, ?)
181
+ `,
182
+ [repoId, slug, tasksJson, progressJson, notesMd, updatedAt]
183
+ )
184
+ }
185
+
186
+ type LegacyJsonReadResult<T> = {
187
+ value: T | null
188
+ imported: boolean
189
+ }
190
+
191
+ async function readStableLegacyFile(filePath: string, minFileAgeMs: number): Promise<string | null> {
192
+ try {
193
+ const stats = await fs.stat(filePath)
194
+ if (!stats.isFile()) {
195
+ return null
196
+ }
197
+
198
+ if (Date.now() - stats.mtimeMs < minFileAgeMs) {
199
+ return null
200
+ }
201
+
202
+ return await fs.readFile(filePath, 'utf-8')
203
+ } catch {
204
+ return null
205
+ }
206
+ }
207
+
208
+ async function readLegacyJsonFile<T>(
209
+ filePath: string,
210
+ label: string,
211
+ minFileAgeMs: number
212
+ ): Promise<LegacyJsonReadResult<T>> {
213
+ const content = await readStableLegacyFile(filePath, minFileAgeMs)
214
+ if (!content) {
215
+ return { value: null, imported: false }
216
+ }
217
+
218
+ try {
219
+ return { value: JSON.parse(content) as T, imported: true }
220
+ } catch (error) {
221
+ const message = error instanceof Error ? error.message : String(error)
222
+ console.warn(`[legacy-state] Skipping invalid ${label} at ${filePath}: ${message}`)
223
+ return { value: null, imported: false }
224
+ }
225
+ }
226
+
227
+ async function removeIfExists(filePath: string): Promise<void> {
228
+ try {
229
+ await fs.unlink(filePath)
230
+ } catch {
231
+ // File may already be removed.
232
+ }
233
+ }
234
+
235
+ async function removeDirIfEmpty(dirPath: string): Promise<void> {
236
+ try {
237
+ const entries = await fs.readdir(dirPath)
238
+ if (entries.length === 0) {
239
+ await fs.rmdir(dirPath)
240
+ }
241
+ } catch {
242
+ // Directory may not exist or may contain files.
243
+ }
244
+ }
245
+
246
+ async function runLegacyStateMigration(
247
+ repo: RepoConfig,
248
+ cleanupLegacyFiles: boolean,
249
+ minFileAgeMs: number
250
+ ): Promise<void> {
251
+ const legacyStateDir = join(repo.path, '.claude', 'state')
252
+
253
+ const entries = await fs.readdir(legacyStateDir, { withFileTypes: true, encoding: 'utf8' }).catch(() => null)
254
+ if (!entries) {
255
+ return
256
+ }
257
+
258
+ for (const entry of entries) {
259
+ if (!entry.isDirectory()) {
260
+ continue
261
+ }
262
+
263
+ const slug = entry.name
264
+ const slugDir = join(legacyStateDir, slug)
265
+ const tasksPath = join(slugDir, 'tasks.json')
266
+ const progressPath = join(slugDir, 'progress.json')
267
+ const notesPath = join(slugDir, 'notes.md')
268
+
269
+ const [tasksResult, progressResult, notesContent] = await Promise.all([
270
+ readLegacyJsonFile<TasksFile>(tasksPath, 'tasks.json', minFileAgeMs),
271
+ readLegacyJsonFile<ProgressFile>(progressPath, 'progress.json', minFileAgeMs),
272
+ readStableLegacyFile(notesPath, minFileAgeMs)
273
+ ])
274
+
275
+ const shouldImportNotes = notesContent !== null
276
+ const shouldImport = tasksResult.imported || progressResult.imported || shouldImportNotes
277
+
278
+ if (!shouldImport) {
279
+ continue
280
+ }
281
+
282
+ await upsertPrdState(repo.id, slug, {
283
+ ...(tasksResult.imported && { tasks: tasksResult.value }),
284
+ ...(progressResult.imported && { progress: progressResult.value }),
285
+ ...(shouldImportNotes && { notes: notesContent })
286
+ })
287
+
288
+ if (cleanupLegacyFiles) {
289
+ if (tasksResult.imported) {
290
+ await removeIfExists(tasksPath)
291
+ }
292
+
293
+ if (progressResult.imported) {
294
+ await removeIfExists(progressPath)
295
+ }
296
+
297
+ if (shouldImportNotes) {
298
+ await removeIfExists(notesPath)
299
+ }
300
+
301
+ await removeDirIfEmpty(slugDir)
302
+ }
303
+ }
304
+
305
+ if (cleanupLegacyFiles) {
306
+ await removeDirIfEmpty(legacyStateDir)
307
+ }
308
+ }
309
+
310
+ export async function migrateLegacyStateForRepo(
311
+ repo: RepoConfig,
312
+ options: { cleanupLegacyFiles?: boolean; minFileAgeMs?: number } = {}
313
+ ): Promise<void> {
314
+ const cleanupLegacyFiles = options.cleanupLegacyFiles
315
+ ?? !cleanupCompletedRepoIds.has(repo.id)
316
+ const minFileAgeMs = options.minFileAgeMs ?? LEGACY_STATE_STABLE_MS
317
+
318
+ const inFlight = migrationInFlight.get(repo.id)
319
+ if (inFlight) {
320
+ return inFlight
321
+ }
322
+
323
+ const migrationPromise = runLegacyStateMigration(repo, cleanupLegacyFiles, minFileAgeMs)
324
+ .then(() => {
325
+ if (cleanupLegacyFiles) {
326
+ cleanupCompletedRepoIds.add(repo.id)
327
+ }
328
+ })
329
+ .finally(() => {
330
+ migrationInFlight.delete(repo.id)
331
+ })
332
+
333
+ migrationInFlight.set(repo.id, migrationPromise)
334
+ return migrationPromise
335
+ }
@@ -0,0 +1,322 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import { join, basename, resolve, relative } from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+ import type { RepoConfig, GitRepoInfo } from '../../app/types/repo.js'
5
+ import { dbAll, dbGet, dbRun } from './db.js'
6
+
7
+ const LEGACY_REPOS_FILE = join(process.cwd(), 'server', 'data', 'repos.json')
8
+
9
+ type RepoRow = {
10
+ id: string
11
+ name: string
12
+ path: string
13
+ added_at: string
14
+ git_repos_json: string | null
15
+ }
16
+
17
+ let legacyImportPromise: Promise<void> | null = null
18
+
19
+ function serializeGitRepos(gitRepos?: GitRepoInfo[]): string | null {
20
+ return gitRepos && gitRepos.length > 0 ? JSON.stringify(gitRepos) : null
21
+ }
22
+
23
+ function parseGitRepos(gitReposJson: string | null): GitRepoInfo[] | undefined {
24
+ if (!gitReposJson) {
25
+ return undefined
26
+ }
27
+
28
+ try {
29
+ const parsed = JSON.parse(gitReposJson)
30
+ if (!Array.isArray(parsed)) {
31
+ return undefined
32
+ }
33
+
34
+ const validRepos = parsed.filter((item): item is GitRepoInfo => {
35
+ return !!item
36
+ && typeof item === 'object'
37
+ && typeof (item as { relativePath?: unknown }).relativePath === 'string'
38
+ && typeof (item as { absolutePath?: unknown }).absolutePath === 'string'
39
+ && typeof (item as { name?: unknown }).name === 'string'
40
+ })
41
+
42
+ return validRepos.length > 0 ? validRepos : undefined
43
+ } catch {
44
+ return undefined
45
+ }
46
+ }
47
+
48
+ function rowToRepo(row: RepoRow): RepoConfig {
49
+ const gitRepos = parseGitRepos(row.git_repos_json)
50
+ return {
51
+ id: row.id,
52
+ name: row.name,
53
+ path: row.path,
54
+ addedAt: row.added_at,
55
+ ...(gitRepos && { gitRepos })
56
+ }
57
+ }
58
+
59
+ function isLegacyRepoConfig(value: unknown): value is RepoConfig {
60
+ if (!value || typeof value !== 'object') {
61
+ return false
62
+ }
63
+
64
+ const repo = value as Partial<RepoConfig>
65
+ return typeof repo.id === 'string'
66
+ && typeof repo.name === 'string'
67
+ && typeof repo.path === 'string'
68
+ && typeof repo.addedAt === 'string'
69
+ }
70
+
71
+ async function importLegacyReposIfNeeded(): Promise<void> {
72
+ if (legacyImportPromise) {
73
+ return legacyImportPromise
74
+ }
75
+
76
+ legacyImportPromise = (async () => {
77
+ const row = await dbGet<{ count: number }>('SELECT COUNT(*) as count FROM repos')
78
+ if ((row?.count ?? 0) > 0) {
79
+ return
80
+ }
81
+
82
+ let legacyRepos: unknown
83
+ try {
84
+ const content = await fs.readFile(LEGACY_REPOS_FILE, 'utf-8')
85
+ legacyRepos = JSON.parse(content)
86
+ } catch {
87
+ return
88
+ }
89
+
90
+ if (!Array.isArray(legacyRepos) || legacyRepos.length === 0) {
91
+ return
92
+ }
93
+
94
+ for (const candidate of legacyRepos) {
95
+ if (!isLegacyRepoConfig(candidate)) {
96
+ continue
97
+ }
98
+
99
+ const repo = candidate
100
+ await dbRun(
101
+ `
102
+ INSERT INTO repos (id, name, path, added_at, git_repos_json)
103
+ VALUES (?, ?, ?, ?, ?)
104
+ ON CONFLICT(id) DO UPDATE SET
105
+ name = excluded.name,
106
+ path = excluded.path,
107
+ added_at = excluded.added_at,
108
+ git_repos_json = excluded.git_repos_json
109
+ `,
110
+ [repo.id, repo.name, repo.path, repo.addedAt, serializeGitRepos(repo.gitRepos)]
111
+ )
112
+ }
113
+
114
+ try {
115
+ await fs.unlink(LEGACY_REPOS_FILE)
116
+ } catch {
117
+ // Legacy file may not be removable; DB remains source of truth.
118
+ }
119
+ })()
120
+
121
+ return legacyImportPromise
122
+ }
123
+
124
+ export async function getRepos(): Promise<RepoConfig[]> {
125
+ await importLegacyReposIfNeeded()
126
+ const rows = await dbAll<RepoRow>('SELECT id, name, path, added_at, git_repos_json FROM repos ORDER BY added_at ASC')
127
+ return rows.map(rowToRepo)
128
+ }
129
+
130
+ export async function saveRepos(repos: RepoConfig[]): Promise<void> {
131
+ await importLegacyReposIfNeeded()
132
+
133
+ for (const repo of repos) {
134
+ await dbRun(
135
+ `
136
+ INSERT INTO repos (id, name, path, added_at, git_repos_json)
137
+ VALUES (?, ?, ?, ?, ?)
138
+ ON CONFLICT(id) DO UPDATE SET
139
+ name = excluded.name,
140
+ path = excluded.path,
141
+ added_at = excluded.added_at,
142
+ git_repos_json = excluded.git_repos_json
143
+ `,
144
+ [repo.id, repo.name, repo.path, repo.addedAt, serializeGitRepos(repo.gitRepos)]
145
+ )
146
+ }
147
+
148
+ if (repos.length === 0) {
149
+ await dbRun('DELETE FROM repos')
150
+ return
151
+ }
152
+
153
+ const repoIds = repos.map(repo => repo.id)
154
+ const placeholders = repoIds.map(() => '?').join(', ')
155
+ await dbRun(`DELETE FROM repos WHERE id NOT IN (${placeholders})`, repoIds)
156
+ }
157
+
158
+ export async function addRepo(path: string, name?: string): Promise<RepoConfig> {
159
+ await importLegacyReposIfNeeded()
160
+
161
+ const resolvedPath = resolve(path)
162
+
163
+ const existing = await dbGet<{ id: string }>('SELECT id FROM repos WHERE path = ?', [resolvedPath])
164
+ if (existing) {
165
+ throw new Error('Repository already added')
166
+ }
167
+
168
+ const gitRepos = await discoverGitRepos(resolvedPath)
169
+
170
+ const repo: RepoConfig = {
171
+ id: randomUUID(),
172
+ name: name || basename(resolvedPath),
173
+ path: resolvedPath,
174
+ addedAt: new Date().toISOString(),
175
+ ...(gitRepos.length > 0 && { gitRepos })
176
+ }
177
+
178
+ await dbRun(
179
+ 'INSERT INTO repos (id, name, path, added_at, git_repos_json) VALUES (?, ?, ?, ?, ?)',
180
+ [repo.id, repo.name, repo.path, repo.addedAt, serializeGitRepos(repo.gitRepos)]
181
+ )
182
+
183
+ return repo
184
+ }
185
+
186
+ /**
187
+ * Get a repository by its ID
188
+ */
189
+ export async function getRepoById(id: string): Promise<RepoConfig | undefined> {
190
+ await importLegacyReposIfNeeded()
191
+ const row = await dbGet<RepoRow>(
192
+ 'SELECT id, name, path, added_at, git_repos_json FROM repos WHERE id = ?',
193
+ [id]
194
+ )
195
+ return row ? rowToRepo(row) : undefined
196
+ }
197
+
198
+ export async function removeRepo(id: string): Promise<boolean> {
199
+ await importLegacyReposIfNeeded()
200
+ const result = await dbRun('DELETE FROM repos WHERE id = ?', [id])
201
+ return result.changes > 0
202
+ }
203
+
204
+ /**
205
+ * Directories to skip when scanning for git repos
206
+ */
207
+ const IGNORED_DIRS = new Set([
208
+ 'node_modules',
209
+ '.git',
210
+ 'vendor',
211
+ 'dist',
212
+ 'build',
213
+ '.next',
214
+ '.nuxt',
215
+ '__pycache__',
216
+ '.venv',
217
+ 'venv',
218
+ 'target', // Rust
219
+ 'Pods', // iOS
220
+ ])
221
+
222
+ /**
223
+ * Check if a directory contains a .git folder (is a git repository)
224
+ */
225
+ async function isGitRepo(dirPath: string): Promise<boolean> {
226
+ try {
227
+ const gitPath = join(dirPath, '.git')
228
+ const stats = await fs.stat(gitPath)
229
+ return stats.isDirectory()
230
+ } catch {
231
+ return false
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Discover git repositories within a directory up to a specified depth.
237
+ * Returns empty array if basePath itself is a git repo (standard case).
238
+ *
239
+ * @param basePath - The root directory to scan
240
+ * @param maxDepth - Maximum depth to scan (default: 2)
241
+ * @returns Array of discovered git repository info
242
+ */
243
+ export async function discoverGitRepos(
244
+ basePath: string,
245
+ maxDepth: number = 2
246
+ ): Promise<GitRepoInfo[]> {
247
+ const resolvedBase = resolve(basePath)
248
+
249
+ // If basePath itself is a git repo, return empty (standard repo case)
250
+ if (await isGitRepo(resolvedBase)) {
251
+ return []
252
+ }
253
+
254
+ const discovered: GitRepoInfo[] = []
255
+
256
+ async function scanDirectory(dirPath: string, currentDepth: number): Promise<void> {
257
+ if (currentDepth > maxDepth) return
258
+
259
+ try {
260
+ const entries = await fs.readdir(dirPath, { withFileTypes: true })
261
+
262
+ for (const entry of entries) {
263
+ if (!entry.isDirectory()) continue
264
+ if (IGNORED_DIRS.has(entry.name)) continue
265
+
266
+ const fullPath = join(dirPath, entry.name)
267
+
268
+ // Check if this directory is a git repo
269
+ if (await isGitRepo(fullPath)) {
270
+ const relativePath = relative(resolvedBase, fullPath)
271
+ discovered.push({
272
+ relativePath,
273
+ absolutePath: fullPath,
274
+ name: entry.name,
275
+ })
276
+ // Don't scan inside git repos
277
+ continue
278
+ }
279
+
280
+ // Continue scanning subdirectories
281
+ await scanDirectory(fullPath, currentDepth + 1)
282
+ }
283
+ } catch {
284
+ // Permission denied or other errors - skip this directory
285
+ }
286
+ }
287
+
288
+ await scanDirectory(resolvedBase, 1)
289
+
290
+ return discovered
291
+ }
292
+
293
+ export async function validateRepoPath(path: string): Promise<{ valid: boolean; error?: string }> {
294
+ // Normalize the path
295
+ const resolvedPath = resolve(path)
296
+
297
+ // Ensure path is absolute (starts with / on Unix or drive letter on Windows)
298
+ if (!resolvedPath.startsWith('/') && !/^[A-Za-z]:/.test(resolvedPath)) {
299
+ return { valid: false, error: 'Path must be absolute' }
300
+ }
301
+
302
+ try {
303
+ const stats = await fs.stat(resolvedPath)
304
+ if (!stats.isDirectory()) {
305
+ return { valid: false, error: 'Path is not a directory' }
306
+ }
307
+
308
+ // Check if it looks like a valid repository (has docs/prd directory)
309
+ const hasPrdDir = await fs.stat(join(resolvedPath, 'docs', 'prd')).then(() => true).catch(() => false)
310
+
311
+ if (!hasPrdDir) {
312
+ return {
313
+ valid: false,
314
+ error: 'Directory does not appear to be a valid PRD repository (missing docs/prd directory)'
315
+ }
316
+ }
317
+
318
+ return { valid: true }
319
+ } catch {
320
+ return { valid: false, error: 'Directory does not exist' }
321
+ }
322
+ }