@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,96 @@
1
+ import { getCommitDiff, getCommitInfo, getFileContent, getFileDiff, isGitRepo, validatePathInRepo } from '../../../server/utils/git.js';
2
+ import { getRepoById } from '../../../server/utils/repos.js';
3
+ async function requireRepo(repoId) {
4
+ const repo = await getRepoById(repoId);
5
+ if (!repo) {
6
+ throw new Error('Repository not found');
7
+ }
8
+ return repo;
9
+ }
10
+ function resolveGitRepoPath(repo, repoPath) {
11
+ if (!repoPath) {
12
+ return repo.path;
13
+ }
14
+ if (!repo.gitRepos || repo.gitRepos.length === 0) {
15
+ throw new Error('repo parameter provided but no git repos discovered in this repository');
16
+ }
17
+ const matchedRepo = repo.gitRepos.find((gitRepo) => gitRepo.relativePath === repoPath);
18
+ if (!matchedRepo) {
19
+ const available = repo.gitRepos.map((gitRepo) => gitRepo.relativePath).join(', ');
20
+ throw new Error(`repo "${repoPath}" is not a discovered git repo. Available: ${available}`);
21
+ }
22
+ return matchedRepo.absolutePath;
23
+ }
24
+ export const git = {
25
+ async getCommits(repoId, shas, repoPath) {
26
+ if (!Array.isArray(shas) || shas.length === 0) {
27
+ throw new Error('At least one SHA is required');
28
+ }
29
+ const repo = await requireRepo(repoId);
30
+ const gitRepoPath = resolveGitRepoPath(repo, repoPath);
31
+ if (!await isGitRepo(gitRepoPath)) {
32
+ throw new Error('Resolved path is not a git repository');
33
+ }
34
+ const commits = [];
35
+ const errors = [];
36
+ for (const sha of shas) {
37
+ try {
38
+ const commit = await getCommitInfo(gitRepoPath, sha);
39
+ commits.push({
40
+ ...commit,
41
+ repoPath: repoPath || ''
42
+ });
43
+ }
44
+ catch (error) {
45
+ const message = error instanceof Error ? error.message : String(error);
46
+ errors.push(`${sha}: ${message}`);
47
+ }
48
+ }
49
+ if (commits.length === 0 && errors.length > 0) {
50
+ throw new Error(`No valid commits found: ${errors.join('; ')}`);
51
+ }
52
+ return commits;
53
+ },
54
+ async getDiff(repoId, commit, repoPath) {
55
+ if (!commit) {
56
+ throw new Error('commit is required');
57
+ }
58
+ const repo = await requireRepo(repoId);
59
+ const gitRepoPath = resolveGitRepoPath(repo, repoPath);
60
+ if (!await isGitRepo(gitRepoPath)) {
61
+ throw new Error('Resolved path is not a git repository');
62
+ }
63
+ return await getCommitDiff(gitRepoPath, commit);
64
+ },
65
+ async getFileDiff(repoId, commit, file, repoPath) {
66
+ if (!commit) {
67
+ throw new Error('commit is required');
68
+ }
69
+ if (!file) {
70
+ throw new Error('file is required');
71
+ }
72
+ const repo = await requireRepo(repoId);
73
+ const gitRepoPath = resolveGitRepoPath(repo, repoPath);
74
+ if (!await isGitRepo(gitRepoPath)) {
75
+ throw new Error('Resolved path is not a git repository');
76
+ }
77
+ if (!validatePathInRepo(gitRepoPath, file)) {
78
+ throw new Error('Invalid file path: path traversal not allowed');
79
+ }
80
+ return await getFileDiff(gitRepoPath, commit, file);
81
+ },
82
+ async getFileContent(repoId, commit, file, repoPath) {
83
+ if (!commit) {
84
+ throw new Error('commit is required');
85
+ }
86
+ if (!file) {
87
+ throw new Error('file is required');
88
+ }
89
+ const repo = await requireRepo(repoId);
90
+ const gitRepoPath = resolveGitRepoPath(repo, repoPath);
91
+ if (!await isGitRepo(gitRepoPath)) {
92
+ throw new Error('Resolved path is not a git repository');
93
+ }
94
+ return await getFileContent(gitRepoPath, commit, file);
95
+ }
96
+ };
@@ -0,0 +1,4 @@
1
+ export { repos } from './repos.js';
2
+ export { prds } from './prds.js';
3
+ export { git } from './git.js';
4
+ export { state } from './state.js';
@@ -0,0 +1,195 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { resolveCommitRepo } from '../../../server/utils/git.js';
4
+ import { discoverGitRepos, getRepoById, getRepos, saveRepos } from '../../../server/utils/repos.js';
5
+ import { getPrdState, getPrdStateSummaries, migrateLegacyStateForRepo } from '../../../server/utils/prd-state.js';
6
+ function parseMetadata(content) {
7
+ const metadata = {};
8
+ const authorMatch = content.match(/\*{0,2}Author\*{0,2}:\*{0,2}\s*(.+?)(?:\n|$)/i);
9
+ if (authorMatch && authorMatch[1]) {
10
+ metadata.author = authorMatch[1].trim();
11
+ }
12
+ const dateMatch = content.match(/\*{0,2}Date\*{0,2}:\*{0,2}\s*(.+?)(?:\n|$)/i);
13
+ if (dateMatch && dateMatch[1]) {
14
+ metadata.date = dateMatch[1].trim();
15
+ }
16
+ const statusMatch = content.match(/\*{0,2}Status\*{0,2}:\*{0,2}\s*(.+?)(?:\n|$)/i);
17
+ if (statusMatch && statusMatch[1]) {
18
+ metadata.status = statusMatch[1].trim();
19
+ }
20
+ const shortcutLinkMatch = content.match(/\[([Ss][Cc]-\d+)\]\(([^)]+)\)/);
21
+ if (shortcutLinkMatch && shortcutLinkMatch[1] && shortcutLinkMatch[2]) {
22
+ metadata.shortcutStory = shortcutLinkMatch[1];
23
+ metadata.shortcutUrl = shortcutLinkMatch[2];
24
+ }
25
+ else {
26
+ const shortcutIdMatch = content.match(/\*{0,2}Shortcut(?:\s+Story)?\*{0,2}:\*{0,2}\s*([Ss][Cc]-\d+)/i);
27
+ if (shortcutIdMatch && shortcutIdMatch[1]) {
28
+ metadata.shortcutStory = shortcutIdMatch[1];
29
+ }
30
+ }
31
+ return metadata;
32
+ }
33
+ async function requireRepo(repoId) {
34
+ const repo = await getRepoById(repoId);
35
+ if (!repo) {
36
+ throw new Error('Repository not found');
37
+ }
38
+ return repo;
39
+ }
40
+ async function readPrdFile(repo, prdSlug) {
41
+ const prdPath = join(repo.path, 'docs', 'prd', `${prdSlug}.md`);
42
+ try {
43
+ return await fs.readFile(prdPath, 'utf-8');
44
+ }
45
+ catch {
46
+ throw new Error('PRD not found');
47
+ }
48
+ }
49
+ export const prds = {
50
+ async list(repoId) {
51
+ const repo = await requireRepo(repoId);
52
+ await migrateLegacyStateForRepo(repo);
53
+ const prdDir = join(repo.path, 'docs', 'prd');
54
+ let prdFiles = [];
55
+ try {
56
+ const files = await fs.readdir(prdDir);
57
+ prdFiles = files.filter((file) => file.endsWith('.md'));
58
+ }
59
+ catch {
60
+ return [];
61
+ }
62
+ const stateSummaries = await getPrdStateSummaries(repo.id);
63
+ const items = await Promise.all(prdFiles.map(async (filename) => {
64
+ const slug = basename(filename, '.md');
65
+ const filePath = join(prdDir, filename);
66
+ let name = slug;
67
+ let modifiedAt = 0;
68
+ try {
69
+ const [content, stat] = await Promise.all([
70
+ fs.readFile(filePath, 'utf-8'),
71
+ fs.stat(filePath)
72
+ ]);
73
+ modifiedAt = stat.mtime.getTime();
74
+ const h1Match = content.match(/^#\s+(.+)$/m);
75
+ if (h1Match && h1Match[1]) {
76
+ name = h1Match[1].trim();
77
+ }
78
+ }
79
+ catch {
80
+ // Keep default values when a file cannot be read.
81
+ }
82
+ const stateSummary = stateSummaries.get(slug);
83
+ return {
84
+ slug,
85
+ name,
86
+ source: `docs/prd/${filename}`,
87
+ hasState: !!stateSummary?.hasState,
88
+ modifiedAt,
89
+ ...(stateSummary?.taskCount !== undefined && { taskCount: stateSummary.taskCount }),
90
+ ...(stateSummary?.completedCount !== undefined && { completedCount: stateSummary.completedCount })
91
+ };
92
+ }));
93
+ items.sort((a, b) => b.modifiedAt - a.modifiedAt);
94
+ return items;
95
+ },
96
+ async getDocument(repoId, prdSlug) {
97
+ const repo = await requireRepo(repoId);
98
+ const content = await readPrdFile(repo, prdSlug);
99
+ let name = prdSlug;
100
+ const h1Match = content.match(/^#\s+(.+)$/m);
101
+ if (h1Match && h1Match[1]) {
102
+ name = h1Match[1].trim();
103
+ }
104
+ return {
105
+ slug: prdSlug,
106
+ name,
107
+ content,
108
+ metadata: parseMetadata(content)
109
+ };
110
+ },
111
+ async getTasks(repoId, prdSlug) {
112
+ const repo = await requireRepo(repoId);
113
+ await migrateLegacyStateForRepo(repo);
114
+ const state = await getPrdState(repo.id, prdSlug);
115
+ return state?.tasks ?? null;
116
+ },
117
+ async getProgress(repoId, prdSlug) {
118
+ const repo = await requireRepo(repoId);
119
+ await migrateLegacyStateForRepo(repo);
120
+ const state = await getPrdState(repo.id, prdSlug);
121
+ return state?.progress ?? null;
122
+ },
123
+ async getTaskCommits(repoId, prdSlug, taskId) {
124
+ const repo = await requireRepo(repoId);
125
+ await migrateLegacyStateForRepo(repo);
126
+ const state = await getPrdState(repo.id, prdSlug);
127
+ const progress = state?.progress ?? null;
128
+ if (!progress) {
129
+ return [];
130
+ }
131
+ const taskLog = progress.taskLogs.find((log) => log.taskId === taskId);
132
+ if (!taskLog) {
133
+ throw new Error(`Task "${taskId}" not found in progress state`);
134
+ }
135
+ if (!taskLog.commits || taskLog.commits.length === 0) {
136
+ return [];
137
+ }
138
+ const resolvedCommits = [];
139
+ const failedEntries = [];
140
+ for (const commitEntry of taskLog.commits) {
141
+ try {
142
+ const resolved = await resolveCommitRepo(repo, commitEntry);
143
+ resolvedCommits.push({
144
+ sha: resolved.sha,
145
+ repo: resolved.repoPath
146
+ });
147
+ }
148
+ catch {
149
+ failedEntries.push(commitEntry);
150
+ }
151
+ }
152
+ if (failedEntries.length > 0) {
153
+ const newGitRepos = await discoverGitRepos(repo.path);
154
+ const existingPaths = new Set((repo.gitRepos || []).map((gitRepo) => gitRepo.relativePath));
155
+ const hasNewRepos = newGitRepos.some((gitRepo) => !existingPaths.has(gitRepo.relativePath));
156
+ let resolvedWithUpdatedRepo = false;
157
+ if (hasNewRepos) {
158
+ const allRepos = await getRepos();
159
+ const repoIndex = allRepos.findIndex((candidate) => candidate.id === repoId);
160
+ if (repoIndex !== -1) {
161
+ const updatedRepo = {
162
+ ...allRepos[repoIndex],
163
+ gitRepos: newGitRepos.length > 0 ? newGitRepos : undefined
164
+ };
165
+ allRepos[repoIndex] = updatedRepo;
166
+ await saveRepos(allRepos);
167
+ resolvedWithUpdatedRepo = true;
168
+ for (const commitEntry of failedEntries) {
169
+ try {
170
+ const resolved = await resolveCommitRepo(updatedRepo, commitEntry);
171
+ resolvedCommits.push({
172
+ sha: resolved.sha,
173
+ repo: resolved.repoPath
174
+ });
175
+ }
176
+ catch {
177
+ const sha = typeof commitEntry === 'string' ? commitEntry : commitEntry.sha;
178
+ resolvedCommits.push({ sha, repo: '' });
179
+ }
180
+ }
181
+ }
182
+ }
183
+ if (!resolvedWithUpdatedRepo) {
184
+ for (const commitEntry of failedEntries) {
185
+ const sha = typeof commitEntry === 'string' ? commitEntry : commitEntry.sha;
186
+ resolvedCommits.push({
187
+ sha,
188
+ repo: ''
189
+ });
190
+ }
191
+ }
192
+ }
193
+ return resolvedCommits;
194
+ }
195
+ };
@@ -0,0 +1,47 @@
1
+ import { addRepo, discoverGitRepos, getRepoById, getRepos, removeRepo, saveRepos, validateRepoPath } from '../../../server/utils/repos.js';
2
+ import { migrateLegacyStateForRepo } from '../../../server/utils/prd-state.js';
3
+ export const repos = {
4
+ async list() {
5
+ return await getRepos();
6
+ },
7
+ async get(repoId) {
8
+ return await getRepoById(repoId) ?? null;
9
+ },
10
+ async add(path, name) {
11
+ const validation = await validateRepoPath(path);
12
+ if (!validation.valid) {
13
+ throw new Error(validation.error || 'Invalid repository path');
14
+ }
15
+ const repo = await addRepo(path, name);
16
+ await migrateLegacyStateForRepo(repo);
17
+ return repo;
18
+ },
19
+ async remove(repoId) {
20
+ const removed = await removeRepo(repoId);
21
+ if (!removed) {
22
+ throw new Error('Repository not found');
23
+ }
24
+ return { removed: true };
25
+ },
26
+ async refreshGitRepos(repoId) {
27
+ const allRepos = await getRepos();
28
+ const repoIndex = allRepos.findIndex((repo) => repo.id === repoId);
29
+ if (repoIndex === -1) {
30
+ throw new Error('Repository not found');
31
+ }
32
+ const repo = allRepos[repoIndex];
33
+ const gitRepos = await discoverGitRepos(repo.path);
34
+ if (gitRepos.length > 0) {
35
+ repo.gitRepos = gitRepos;
36
+ }
37
+ else {
38
+ delete repo.gitRepos;
39
+ }
40
+ allRepos[repoIndex] = repo;
41
+ await saveRepos(allRepos);
42
+ return {
43
+ discovered: gitRepos.length,
44
+ gitRepos
45
+ };
46
+ }
47
+ };
@@ -0,0 +1,63 @@
1
+ import { resolve } from 'node:path';
2
+ import { getPrdState, getPrdStateSummaries, migrateLegacyStateForRepo, upsertPrdState } from '../../../server/utils/prd-state.js';
3
+ import { getRepoById, getRepos } from '../../../server/utils/repos.js';
4
+ async function requireRepo(repoId) {
5
+ const repo = await getRepoById(repoId);
6
+ if (!repo) {
7
+ throw new Error('Repository not found');
8
+ }
9
+ return repo;
10
+ }
11
+ async function findRepoByPath(repoPath) {
12
+ const absolutePath = resolve(repoPath);
13
+ const repos = await getRepos();
14
+ const repo = repos.find((candidate) => resolve(candidate.path) === absolutePath);
15
+ if (!repo) {
16
+ throw new Error(`No registered repository found for path: ${absolutePath}`);
17
+ }
18
+ return repo;
19
+ }
20
+ function mapStateUpdate(payload) {
21
+ return {
22
+ ...(payload.tasks !== undefined && { tasks: payload.tasks }),
23
+ ...(payload.progress !== undefined && { progress: payload.progress }),
24
+ ...(payload.notes !== undefined && { notes: payload.notes })
25
+ };
26
+ }
27
+ function mapSummaryMap(summaries) {
28
+ return Object.fromEntries(summaries.entries());
29
+ }
30
+ export const state = {
31
+ async get(repoId, slug) {
32
+ const repo = await requireRepo(repoId);
33
+ await migrateLegacyStateForRepo(repo);
34
+ return await getPrdState(repo.id, slug);
35
+ },
36
+ async getByPath(repoPath, slug) {
37
+ const repo = await findRepoByPath(repoPath);
38
+ await migrateLegacyStateForRepo(repo);
39
+ return await getPrdState(repo.id, slug);
40
+ },
41
+ async summaries(repoId) {
42
+ const repo = await requireRepo(repoId);
43
+ await migrateLegacyStateForRepo(repo);
44
+ const summaries = await getPrdStateSummaries(repo.id);
45
+ return mapSummaryMap(summaries);
46
+ },
47
+ async summariesByPath(repoPath) {
48
+ const repo = await findRepoByPath(repoPath);
49
+ await migrateLegacyStateForRepo(repo);
50
+ const summaries = await getPrdStateSummaries(repo.id);
51
+ return mapSummaryMap(summaries);
52
+ },
53
+ async upsert(repoId, slug, payload) {
54
+ const repo = await requireRepo(repoId);
55
+ await upsertPrdState(repo.id, slug, mapStateUpdate(payload));
56
+ return { saved: true };
57
+ },
58
+ async upsertByPath(repoPath, slug, payload) {
59
+ const repo = await findRepoByPath(repoPath);
60
+ await upsertPrdState(repo.id, slug, mapStateUpdate(payload));
61
+ return { saved: true };
62
+ }
63
+ };
@@ -0,0 +1,109 @@
1
+ import vm from 'node:vm';
2
+ import { git, prds, repos, state } from './api/index.js';
3
+ const MAX_OUTPUT_SIZE = 50_000;
4
+ const EXECUTION_TIMEOUT_MS = 30_000;
5
+ const MAX_TIMERS = 100;
6
+ export class ExecutionError extends Error {
7
+ stackTrace;
8
+ constructor(message, stackTrace) {
9
+ super(message);
10
+ this.stackTrace = stackTrace;
11
+ this.name = 'ExecutionError';
12
+ }
13
+ }
14
+ function truncateOutput(result) {
15
+ if (result === undefined) {
16
+ return undefined;
17
+ }
18
+ let json;
19
+ try {
20
+ json = JSON.stringify(result, null, 2);
21
+ }
22
+ catch {
23
+ return {
24
+ _unserializable: true,
25
+ preview: String(result)
26
+ };
27
+ }
28
+ if (json.length <= MAX_OUTPUT_SIZE) {
29
+ return result;
30
+ }
31
+ return {
32
+ _truncated: true,
33
+ size: json.length,
34
+ preview: json.slice(0, MAX_OUTPUT_SIZE),
35
+ message: `Output truncated (${json.length} chars, showing first ${MAX_OUTPUT_SIZE})`
36
+ };
37
+ }
38
+ export async function execute(code) {
39
+ if (!code || !code.trim()) {
40
+ throw new ExecutionError('Code cannot be empty');
41
+ }
42
+ const timers = new Set();
43
+ const sandbox = {
44
+ repos,
45
+ prds,
46
+ git,
47
+ state,
48
+ console: {
49
+ log: (...args) => console.log('[codemode]', ...args),
50
+ error: (...args) => console.error('[codemode]', ...args)
51
+ },
52
+ setTimeout: (handler, timeout) => {
53
+ if (timers.size >= MAX_TIMERS) {
54
+ throw new Error(`Timer limit exceeded (max ${MAX_TIMERS})`);
55
+ }
56
+ const timer = setTimeout(() => {
57
+ timers.delete(timer);
58
+ handler();
59
+ }, timeout);
60
+ timers.add(timer);
61
+ return timer;
62
+ },
63
+ clearTimeout: (timer) => {
64
+ timers.delete(timer);
65
+ clearTimeout(timer);
66
+ },
67
+ setInterval: (handler, timeout) => {
68
+ if (timers.size >= MAX_TIMERS) {
69
+ throw new Error(`Timer limit exceeded (max ${MAX_TIMERS})`);
70
+ }
71
+ const timer = setInterval(handler, timeout);
72
+ timers.add(timer);
73
+ return timer;
74
+ },
75
+ clearInterval: (timer) => {
76
+ timers.delete(timer);
77
+ clearInterval(timer);
78
+ },
79
+ Promise
80
+ };
81
+ const wrappedCode = `
82
+ (async () => {
83
+ ${code}
84
+ })()
85
+ `;
86
+ try {
87
+ const script = new vm.Script(wrappedCode, {
88
+ filename: 'codemode.js'
89
+ });
90
+ const context = vm.createContext(sandbox);
91
+ const result = await script.runInContext(context, {
92
+ timeout: EXECUTION_TIMEOUT_MS
93
+ });
94
+ return truncateOutput(result);
95
+ }
96
+ catch (error) {
97
+ if (error instanceof Error) {
98
+ throw new ExecutionError(error.message, error.stack);
99
+ }
100
+ throw new ExecutionError(String(error));
101
+ }
102
+ finally {
103
+ timers.forEach((timer) => {
104
+ clearTimeout(timer);
105
+ clearInterval(timer);
106
+ });
107
+ timers.clear();
108
+ }
109
+ }
@@ -0,0 +1,95 @@
1
+ import { runMcpServer } from './mcp.js';
2
+ import { runUi } from './ui.js';
3
+ function printUsage() {
4
+ console.log(`prd - Steward CLI
5
+
6
+ Usage:
7
+ prd ui [--preview] [--port <port>] [--host <host>]
8
+ prd mcp
9
+
10
+ Commands:
11
+ ui Launch the PRD web UI (dev mode by default)
12
+ mcp Start MCP server over stdio (codemode)
13
+
14
+ Options:
15
+ --preview Run Nuxt preview server instead of dev server
16
+ --port <port> Port for ui mode
17
+ --host <host> Host for ui mode
18
+ -h, --help Show this help message
19
+ `);
20
+ }
21
+ function parsePort(value) {
22
+ const parsed = Number.parseInt(value, 10);
23
+ if (!Number.isFinite(parsed) || parsed <= 0) {
24
+ throw new Error(`Invalid port: ${value}`);
25
+ }
26
+ return parsed;
27
+ }
28
+ function parseUiArgs(args) {
29
+ const options = { preview: false };
30
+ for (let i = 0; i < args.length; i++) {
31
+ const arg = args[i];
32
+ if (arg === '--preview') {
33
+ options.preview = true;
34
+ continue;
35
+ }
36
+ if (arg === '--port') {
37
+ const next = args[i + 1];
38
+ if (!next) {
39
+ throw new Error('--port requires a value');
40
+ }
41
+ options.port = parsePort(next);
42
+ i += 1;
43
+ continue;
44
+ }
45
+ if (arg === '--host') {
46
+ const next = args[i + 1];
47
+ if (!next) {
48
+ throw new Error('--host requires a value');
49
+ }
50
+ options.host = next;
51
+ i += 1;
52
+ continue;
53
+ }
54
+ throw new Error(`Unknown option for ui: ${arg}`);
55
+ }
56
+ return options;
57
+ }
58
+ export async function main(argv = process.argv.slice(2)) {
59
+ const [command, ...rest] = argv;
60
+ if (!command || command === '-h' || command === '--help') {
61
+ printUsage();
62
+ return;
63
+ }
64
+ if (command === 'mcp') {
65
+ if (rest.includes('-h') || rest.includes('--help')) {
66
+ printUsage();
67
+ return;
68
+ }
69
+ if (rest.length > 0) {
70
+ throw new Error(`Unexpected arguments for mcp: ${rest.join(' ')}`);
71
+ }
72
+ await runMcpServer();
73
+ return;
74
+ }
75
+ if (command === 'ui') {
76
+ if (rest.includes('-h') || rest.includes('--help')) {
77
+ printUsage();
78
+ return;
79
+ }
80
+ const options = parseUiArgs(rest);
81
+ const exitCode = await runUi(options);
82
+ if (exitCode !== 0) {
83
+ process.exitCode = exitCode;
84
+ }
85
+ return;
86
+ }
87
+ throw new Error(`Unknown command: ${command}`);
88
+ }
89
+ if (import.meta.main) {
90
+ await main().catch((error) => {
91
+ const message = error instanceof Error ? error.message : String(error);
92
+ console.error(`Error: ${message}`);
93
+ process.exit(1);
94
+ });
95
+ }
@@ -0,0 +1,62 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { z } from 'zod';
4
+ import { execute, ExecutionError } from './executor.js';
5
+ function formatError(error) {
6
+ if (error instanceof ExecutionError) {
7
+ return error.stackTrace
8
+ ? `${error.message}\n\n${error.stackTrace}`
9
+ : error.message;
10
+ }
11
+ if (error instanceof Error) {
12
+ return error.stack
13
+ ? `${error.message}\n\n${error.stack}`
14
+ : error.message;
15
+ }
16
+ return String(error);
17
+ }
18
+ function serializeResult(result) {
19
+ if (result === undefined) {
20
+ return 'undefined';
21
+ }
22
+ try {
23
+ return JSON.stringify(result, null, 2);
24
+ }
25
+ catch {
26
+ return String(result);
27
+ }
28
+ }
29
+ export async function runMcpServer() {
30
+ const server = new McpServer({
31
+ name: 'steward',
32
+ version: '0.1.0'
33
+ });
34
+ server.tool('execute', 'Run codemode JavaScript with repos, prds, git, and state APIs.', {
35
+ code: z.string().min(1)
36
+ }, async ({ code }) => {
37
+ try {
38
+ const result = await execute(code);
39
+ return {
40
+ content: [
41
+ {
42
+ type: 'text',
43
+ text: serializeResult(result)
44
+ }
45
+ ]
46
+ };
47
+ }
48
+ catch (error) {
49
+ return {
50
+ isError: true,
51
+ content: [
52
+ {
53
+ type: 'text',
54
+ text: formatError(error)
55
+ }
56
+ ]
57
+ };
58
+ }
59
+ });
60
+ const transport = new StdioServerTransport();
61
+ await server.connect(transport);
62
+ }