@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,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
|
+
}
|