@leanspec/ui 0.2.5-dev.20251120060929 → 0.2.5-dev.20251120072524

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 (129) hide show
  1. package/.next/standalone/packages/ui/.next/BUILD_ID +1 -1
  2. package/.next/standalone/packages/ui/.next/app-path-routes-manifest.json +3 -0
  3. package/.next/standalone/packages/ui/.next/build-manifest.json +2 -2
  4. package/.next/standalone/packages/ui/.next/prerender-manifest.json +3 -3
  5. package/.next/standalone/packages/ui/.next/routes-manifest.json +20 -0
  6. package/.next/standalone/packages/ui/.next/server/app/_global-error/page.js +1 -1
  7. package/.next/standalone/packages/ui/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/packages/ui/.next/server/app/_global-error.html +2 -2
  9. package/.next/standalone/packages/ui/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/standalone/packages/ui/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  11. package/.next/standalone/packages/ui/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  12. package/.next/standalone/packages/ui/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  13. package/.next/standalone/packages/ui/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/standalone/packages/ui/.next/server/app/_not-found/page.js +2 -2
  15. package/.next/standalone/packages/ui/.next/server/app/_not-found/page.js.nft.json +1 -1
  16. package/.next/standalone/packages/ui/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  17. package/.next/standalone/packages/ui/.next/server/app/_not-found.html +2 -2
  18. package/.next/standalone/packages/ui/.next/server/app/_not-found.rsc +3 -3
  19. package/.next/standalone/packages/ui/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  20. package/.next/standalone/packages/ui/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  21. package/.next/standalone/packages/ui/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  22. package/.next/standalone/packages/ui/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  23. package/.next/standalone/packages/ui/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  24. package/.next/standalone/packages/ui/.next/server/app/api/local-projects/[id]/route/app-paths-manifest.json +3 -0
  25. package/.next/standalone/packages/ui/.next/server/app/api/local-projects/[id]/route/build-manifest.json +11 -0
  26. package/.next/standalone/packages/ui/.next/server/app/api/local-projects/[id]/route/server-reference-manifest.json +4 -0
  27. package/.next/standalone/packages/ui/.next/server/app/api/local-projects/[id]/route.js +7 -0
  28. package/.next/standalone/packages/ui/.next/server/app/api/local-projects/[id]/route.js.map +5 -0
  29. package/.next/standalone/packages/ui/.next/server/app/api/local-projects/[id]/route.js.nft.json +1 -0
  30. package/.next/standalone/packages/ui/.next/server/app/api/local-projects/[id]/route_client-reference-manifest.js +2 -0
  31. package/.next/standalone/packages/ui/.next/server/app/api/local-projects/discover/route/app-paths-manifest.json +3 -0
  32. package/.next/standalone/packages/ui/.next/server/app/api/local-projects/discover/route/build-manifest.json +11 -0
  33. package/.next/standalone/packages/ui/.next/server/app/api/local-projects/discover/route/server-reference-manifest.json +4 -0
  34. package/.next/standalone/packages/ui/.next/server/app/api/local-projects/discover/route.js +7 -0
  35. package/.next/standalone/packages/ui/.next/server/app/api/local-projects/discover/route.js.map +5 -0
  36. package/.next/standalone/packages/ui/.next/server/app/api/local-projects/discover/route.js.nft.json +1 -0
  37. package/.next/standalone/packages/ui/.next/server/app/api/local-projects/discover/route_client-reference-manifest.js +2 -0
  38. package/.next/standalone/packages/ui/.next/server/app/api/local-projects/route/app-paths-manifest.json +3 -0
  39. package/.next/standalone/packages/ui/.next/server/app/api/local-projects/route/build-manifest.json +11 -0
  40. package/.next/standalone/packages/ui/.next/server/app/api/local-projects/route/server-reference-manifest.json +4 -0
  41. package/.next/standalone/packages/ui/.next/server/app/api/local-projects/route.js +7 -0
  42. package/.next/standalone/packages/ui/.next/server/app/api/local-projects/route.js.map +5 -0
  43. package/.next/standalone/packages/ui/.next/server/app/api/local-projects/route.js.nft.json +1 -0
  44. package/.next/standalone/packages/ui/.next/server/app/api/local-projects/route_client-reference-manifest.js +2 -0
  45. package/.next/standalone/packages/ui/.next/server/app/api/projects/[id]/specs/route.js +2 -1
  46. package/.next/standalone/packages/ui/.next/server/app/api/projects/[id]/specs/route.js.nft.json +1 -1
  47. package/.next/standalone/packages/ui/.next/server/app/api/projects/route.js +2 -1
  48. package/.next/standalone/packages/ui/.next/server/app/api/projects/route.js.nft.json +1 -1
  49. package/.next/standalone/packages/ui/.next/server/app/api/revalidate/route.js +5 -2
  50. package/.next/standalone/packages/ui/.next/server/app/api/revalidate/route.js.nft.json +1 -1
  51. package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/dependency-graph/route.js +5 -2
  52. package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/dependency-graph/route.js.nft.json +1 -1
  53. package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/route.js +5 -2
  54. package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/route.js.nft.json +1 -1
  55. package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/status/route.js +4 -2
  56. package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/status/route.js.nft.json +1 -1
  57. package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/subspecs/[file]/route.js +5 -2
  58. package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/subspecs/[file]/route.js.nft.json +1 -1
  59. package/.next/standalone/packages/ui/.next/server/app/api/stats/route.js +5 -2
  60. package/.next/standalone/packages/ui/.next/server/app/api/stats/route.js.nft.json +1 -1
  61. package/.next/standalone/packages/ui/.next/server/app/page.js +2 -2
  62. package/.next/standalone/packages/ui/.next/server/app/page.js.nft.json +1 -1
  63. package/.next/standalone/packages/ui/.next/server/app/page_client-reference-manifest.js +1 -1
  64. package/.next/standalone/packages/ui/.next/server/app/specs/[id]/page.js +2 -2
  65. package/.next/standalone/packages/ui/.next/server/app/specs/[id]/page.js.nft.json +1 -1
  66. package/.next/standalone/packages/ui/.next/server/app/specs/[id]/page_client-reference-manifest.js +1 -1
  67. package/.next/standalone/packages/ui/.next/server/app/specs/page.js +2 -2
  68. package/.next/standalone/packages/ui/.next/server/app/specs/page.js.nft.json +1 -1
  69. package/.next/standalone/packages/ui/.next/server/app/specs/page_client-reference-manifest.js +1 -1
  70. package/.next/standalone/packages/ui/.next/server/app/stats/page.js +2 -2
  71. package/.next/standalone/packages/ui/.next/server/app/stats/page.js.nft.json +1 -1
  72. package/.next/standalone/packages/ui/.next/server/app/stats/page_client-reference-manifest.js +1 -1
  73. package/.next/standalone/packages/ui/.next/server/app-paths-manifest.json +3 -0
  74. package/.next/standalone/packages/ui/.next/server/chunks/730ea_ui__next-internal_server_app_api_local-projects_[id]_route_actions_664abe9c.js +3 -0
  75. package/.next/standalone/packages/ui/.next/server/chunks/730ea_ui__next-internal_server_app_api_local-projects_discover_route_actions_e6ec3fa7.js +3 -0
  76. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__4b3c3001._.js +21 -0
  77. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__4b77d48f._.js +3 -0
  78. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__57791a48._.js +3 -0
  79. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__60b6d106._.js +3 -0
  80. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__a627840f._.js +3 -0
  81. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__beb134c0._.js +3 -0
  82. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__c83721f3._.js +3 -0
  83. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__c8a20942._.js +3 -0
  84. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__cd1fb0a2._.js +2 -2
  85. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__d293d769._.js +3 -0
  86. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__dfa71dcd._.js +3 -0
  87. package/.next/standalone/packages/ui/.next/server/chunks/{[root-of-the-server]__3971eae5._.js → [root-of-the-server]__e9ba3fa9._.js} +2 -2
  88. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__fad83967._.js +3 -0
  89. package/.next/standalone/packages/ui/.next/server/chunks/ecee3_js-yaml_dist_js-yaml_mjs_0775f118._.js +3 -0
  90. package/.next/standalone/packages/ui/.next/server/chunks/node_modules__pnpm_731b55aa._.js +3 -0
  91. package/.next/standalone/packages/ui/.next/server/chunks/packages_ui__next-internal_server_app_api_local-projects_route_actions_9142d5ad.js +3 -0
  92. package/.next/standalone/packages/ui/.next/server/chunks/ssr/[root-of-the-server]__34ab4950._.js +3 -0
  93. package/.next/standalone/packages/ui/.next/server/chunks/ssr/{[root-of-the-server]__5ca2e973._.js → [root-of-the-server]__a9d7fd42._.js} +2 -2
  94. package/.next/standalone/packages/ui/.next/server/chunks/ssr/{[root-of-the-server]__41f5b5c0._.js → [root-of-the-server]__b3633d6e._.js} +2 -2
  95. package/.next/standalone/packages/ui/.next/server/chunks/ssr/_000dd317._.js +1 -1
  96. package/.next/standalone/packages/ui/.next/server/pages/404.html +2 -2
  97. package/.next/standalone/packages/ui/.next/server/pages/500.html +2 -2
  98. package/.next/standalone/packages/ui/.next/server/server-reference-manifest.js +1 -1
  99. package/.next/standalone/packages/ui/.next/server/server-reference-manifest.json +1 -1
  100. package/.next/standalone/packages/ui/.next/static/chunks/c0576ccd1437ac5e.css +1 -0
  101. package/.next/standalone/packages/ui/package.json +2 -1
  102. package/.next/standalone/packages/ui/src/app/api/local-projects/[id]/route.ts +117 -0
  103. package/.next/standalone/packages/ui/src/app/api/local-projects/discover/route.ts +41 -0
  104. package/.next/standalone/packages/ui/src/app/api/local-projects/route.ts +63 -0
  105. package/.next/standalone/packages/ui/src/components/project-switcher.tsx +230 -0
  106. package/.next/standalone/packages/ui/src/contexts/project-context.tsx +225 -0
  107. package/.next/standalone/packages/ui/src/lib/projects/index.ts +7 -0
  108. package/.next/standalone/packages/ui/src/lib/projects/registry.ts +393 -0
  109. package/.next/standalone/packages/ui/src/lib/projects/types.ts +37 -0
  110. package/.next/standalone/packages/ui/src/lib/specs/service.ts +13 -1
  111. package/.next/standalone/packages/ui/src/lib/specs/sources/multi-project-source.ts +258 -0
  112. package/.next/standalone/packages/ui/tsconfig.tsbuildinfo +1 -1
  113. package/.next/static/chunks/c0576ccd1437ac5e.css +1 -0
  114. package/package.json +2 -1
  115. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__482b093a._.js +0 -21
  116. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__6bca1621._.js +0 -3
  117. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__87a3475a._.js +0 -3
  118. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__9f0f4c0b._.js +0 -3
  119. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__c1c9f5f5._.js +0 -3
  120. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__e2071b2e._.js +0 -3
  121. package/.next/standalone/packages/ui/.next/server/chunks/ssr/[root-of-the-server]__299c81cc._.js +0 -3
  122. package/.next/standalone/packages/ui/.next/static/chunks/9a80c22382ddcfaf.css +0 -1
  123. package/.next/static/chunks/9a80c22382ddcfaf.css +0 -1
  124. /package/.next/standalone/packages/ui/.next/static/{9vz3Ow8gSGbexVOa07NBH → fMRsihZys1Dhy9qQRwNrc}/_buildManifest.js +0 -0
  125. /package/.next/standalone/packages/ui/.next/static/{9vz3Ow8gSGbexVOa07NBH → fMRsihZys1Dhy9qQRwNrc}/_clientMiddlewareManifest.json +0 -0
  126. /package/.next/standalone/packages/ui/.next/static/{9vz3Ow8gSGbexVOa07NBH → fMRsihZys1Dhy9qQRwNrc}/_ssgManifest.js +0 -0
  127. /package/.next/static/{9vz3Ow8gSGbexVOa07NBH → fMRsihZys1Dhy9qQRwNrc}/_buildManifest.js +0 -0
  128. /package/.next/static/{9vz3Ow8gSGbexVOa07NBH → fMRsihZys1Dhy9qQRwNrc}/_clientMiddlewareManifest.json +0 -0
  129. /package/.next/static/{9vz3Ow8gSGbexVOa07NBH → fMRsihZys1Dhy9qQRwNrc}/_ssgManifest.js +0 -0
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Project Context
3
+ * Manages the current project state for multi-project mode
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
9
+ import type { LocalProject } from '@/lib/projects/types';
10
+
11
+ interface ProjectContextType {
12
+ currentProject: LocalProject | null;
13
+ projects: LocalProject[];
14
+ recentProjects: LocalProject[];
15
+ favoriteProjects: LocalProject[];
16
+ isLoading: boolean;
17
+ error: string | null;
18
+ switchProject: (projectId: string) => Promise<void>;
19
+ addProject: (path: string, options?: { favorite?: boolean; color?: string }) => Promise<LocalProject>;
20
+ removeProject: (projectId: string) => Promise<void>;
21
+ toggleFavorite: (projectId: string) => Promise<void>;
22
+ updateProject: (projectId: string, updates: Partial<Pick<LocalProject, 'name' | 'color' | 'description'>>) => Promise<void>;
23
+ refreshProjects: () => Promise<void>;
24
+ }
25
+
26
+ const ProjectContext = createContext<ProjectContextType | null>(null);
27
+
28
+ export function useProject() {
29
+ const context = useContext(ProjectContext);
30
+ if (!context) {
31
+ throw new Error('useProject must be used within ProjectProvider');
32
+ }
33
+ return context;
34
+ }
35
+
36
+ interface ProjectProviderProps {
37
+ children: React.ReactNode;
38
+ initialProjectId?: string;
39
+ }
40
+
41
+ export function ProjectProvider({ children, initialProjectId }: ProjectProviderProps) {
42
+ const [currentProject, setCurrentProject] = useState<LocalProject | null>(null);
43
+ const [projects, setProjects] = useState<LocalProject[]>([]);
44
+ const [recentProjects, setRecentProjects] = useState<LocalProject[]>([]);
45
+ const [favoriteProjects, setFavoriteProjects] = useState<LocalProject[]>([]);
46
+ const [isLoading, setIsLoading] = useState(true);
47
+ const [error, setError] = useState<string | null>(null);
48
+
49
+ // Fetch all projects
50
+ const refreshProjects = useCallback(async () => {
51
+ try {
52
+ setIsLoading(true);
53
+ setError(null);
54
+
55
+ const response = await fetch('/api/local-projects');
56
+ if (!response.ok) {
57
+ throw new Error('Failed to fetch projects');
58
+ }
59
+
60
+ const data = await response.json();
61
+ setProjects(data.projects || []);
62
+ setRecentProjects(data.recentProjects || []);
63
+ setFavoriteProjects(data.favoriteProjects || []);
64
+
65
+ // Set current project if not already set
66
+ if (!currentProject && data.projects.length > 0) {
67
+ const projectToSet = initialProjectId
68
+ ? data.projects.find((p: LocalProject) => p.id === initialProjectId)
69
+ : data.recentProjects[0] || data.projects[0];
70
+
71
+ if (projectToSet) {
72
+ setCurrentProject(projectToSet);
73
+ }
74
+ }
75
+ } catch (err: any) {
76
+ console.error('Error fetching projects:', err);
77
+ setError(err.message || 'Failed to load projects');
78
+ } finally {
79
+ setIsLoading(false);
80
+ }
81
+ }, [currentProject, initialProjectId]);
82
+
83
+ // Switch to a different project
84
+ const switchProject = useCallback(async (projectId: string) => {
85
+ try {
86
+ const response = await fetch(`/api/local-projects/${projectId}`);
87
+ if (!response.ok) {
88
+ throw new Error('Failed to fetch project');
89
+ }
90
+
91
+ const data = await response.json();
92
+ setCurrentProject(data.project);
93
+
94
+ // Refresh recent projects list
95
+ await refreshProjects();
96
+ } catch (err: any) {
97
+ console.error('Error switching project:', err);
98
+ setError(err.message || 'Failed to switch project');
99
+ throw err;
100
+ }
101
+ }, [refreshProjects]);
102
+
103
+ // Add a new project
104
+ const addProject = useCallback(async (
105
+ path: string,
106
+ options?: { favorite?: boolean; color?: string }
107
+ ): Promise<LocalProject> => {
108
+ try {
109
+ const response = await fetch('/api/local-projects', {
110
+ method: 'POST',
111
+ headers: { 'Content-Type': 'application/json' },
112
+ body: JSON.stringify({ path, ...options }),
113
+ });
114
+
115
+ if (!response.ok) {
116
+ const error = await response.json();
117
+ throw new Error(error.details || 'Failed to add project');
118
+ }
119
+
120
+ const data = await response.json();
121
+ await refreshProjects();
122
+ return data.project;
123
+ } catch (err: any) {
124
+ console.error('Error adding project:', err);
125
+ setError(err.message || 'Failed to add project');
126
+ throw err;
127
+ }
128
+ }, [refreshProjects]);
129
+
130
+ // Remove a project
131
+ const removeProject = useCallback(async (projectId: string) => {
132
+ try {
133
+ const response = await fetch(`/api/local-projects/${projectId}`, {
134
+ method: 'DELETE',
135
+ });
136
+
137
+ if (!response.ok) {
138
+ throw new Error('Failed to remove project');
139
+ }
140
+
141
+ // If we removed the current project, switch to another
142
+ if (currentProject?.id === projectId) {
143
+ const otherProject = projects.find((p) => p.id !== projectId);
144
+ setCurrentProject(otherProject || null);
145
+ }
146
+
147
+ await refreshProjects();
148
+ } catch (err: any) {
149
+ console.error('Error removing project:', err);
150
+ setError(err.message || 'Failed to remove project');
151
+ throw err;
152
+ }
153
+ }, [currentProject, projects, refreshProjects]);
154
+
155
+ // Toggle favorite status
156
+ const toggleFavorite = useCallback(async (projectId: string) => {
157
+ try {
158
+ const response = await fetch(`/api/local-projects/${projectId}`, {
159
+ method: 'PATCH',
160
+ headers: { 'Content-Type': 'application/json' },
161
+ body: JSON.stringify({ favorite: true }),
162
+ });
163
+
164
+ if (!response.ok) {
165
+ throw new Error('Failed to toggle favorite');
166
+ }
167
+
168
+ await refreshProjects();
169
+ } catch (err: any) {
170
+ console.error('Error toggling favorite:', err);
171
+ setError(err.message || 'Failed to toggle favorite');
172
+ throw err;
173
+ }
174
+ }, [refreshProjects]);
175
+
176
+ // Update project metadata
177
+ const updateProject = useCallback(async (
178
+ projectId: string,
179
+ updates: Partial<Pick<LocalProject, 'name' | 'color' | 'description'>>
180
+ ) => {
181
+ try {
182
+ const response = await fetch(`/api/local-projects/${projectId}`, {
183
+ method: 'PATCH',
184
+ headers: { 'Content-Type': 'application/json' },
185
+ body: JSON.stringify(updates),
186
+ });
187
+
188
+ if (!response.ok) {
189
+ throw new Error('Failed to update project');
190
+ }
191
+
192
+ await refreshProjects();
193
+ } catch (err: any) {
194
+ console.error('Error updating project:', err);
195
+ setError(err.message || 'Failed to update project');
196
+ throw err;
197
+ }
198
+ }, [refreshProjects]);
199
+
200
+ // Load projects on mount
201
+ useEffect(() => {
202
+ refreshProjects();
203
+ }, []);
204
+
205
+ const value: ProjectContextType = {
206
+ currentProject,
207
+ projects,
208
+ recentProjects,
209
+ favoriteProjects,
210
+ isLoading,
211
+ error,
212
+ switchProject,
213
+ addProject,
214
+ removeProject,
215
+ toggleFavorite,
216
+ updateProject,
217
+ refreshProjects,
218
+ };
219
+
220
+ return (
221
+ <ProjectContext.Provider value={value}>
222
+ {children}
223
+ </ProjectContext.Provider>
224
+ );
225
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Local projects module
3
+ * Manages filesystem-based project switching
4
+ */
5
+
6
+ export * from './types';
7
+ export * from './registry';
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Project registry for local filesystem projects
3
+ * Manages discovery, storage, and retrieval of local LeanSpec projects
4
+ */
5
+
6
+ import * as fs from 'node:fs/promises';
7
+ import * as path from 'node:path';
8
+ import { createHash } from 'node:crypto';
9
+ import { homedir } from 'node:os';
10
+ import yaml from 'js-yaml';
11
+ import type { LocalProject, ProjectsConfig, ProjectValidation } from './types';
12
+
13
+ const PROJECTS_CONFIG_FILE = path.join(homedir(), '.lean-spec', 'projects.yaml');
14
+
15
+ /**
16
+ * Project Registry - manages local filesystem projects
17
+ */
18
+ export class ProjectRegistry {
19
+ private config: ProjectsConfig | null = null;
20
+
21
+ /**
22
+ * Generate a unique ID for a project based on its path
23
+ */
24
+ private generateProjectId(projectPath: string): string {
25
+ return createHash('sha256')
26
+ .update(projectPath)
27
+ .digest('hex')
28
+ .substring(0, 12);
29
+ }
30
+
31
+ /**
32
+ * Load projects configuration from disk
33
+ */
34
+ private async loadConfig(): Promise<ProjectsConfig> {
35
+ if (this.config) {
36
+ return this.config;
37
+ }
38
+
39
+ try {
40
+ const configDir = path.dirname(PROJECTS_CONFIG_FILE);
41
+ await fs.mkdir(configDir, { recursive: true });
42
+
43
+ const fileContent = await fs.readFile(PROJECTS_CONFIG_FILE, 'utf-8');
44
+ const parsed = yaml.load(fileContent) as any;
45
+
46
+ // Convert date strings back to Date objects
47
+ const projects = (parsed.projects || []).map((p: any) => ({
48
+ ...p,
49
+ lastAccessed: p.lastAccessed ? new Date(p.lastAccessed) : new Date(),
50
+ }));
51
+
52
+ this.config = {
53
+ projects,
54
+ recentProjects: parsed.recentProjects || [],
55
+ };
56
+ } catch (error: any) {
57
+ if (error.code === 'ENOENT') {
58
+ // File doesn't exist, create empty config
59
+ this.config = {
60
+ projects: [],
61
+ recentProjects: [],
62
+ };
63
+ } else {
64
+ throw error;
65
+ }
66
+ }
67
+
68
+ return this.config;
69
+ }
70
+
71
+ /**
72
+ * Save projects configuration to disk
73
+ */
74
+ private async saveConfig(): Promise<void> {
75
+ if (!this.config) {
76
+ return;
77
+ }
78
+
79
+ const configDir = path.dirname(PROJECTS_CONFIG_FILE);
80
+ await fs.mkdir(configDir, { recursive: true });
81
+
82
+ // Convert Date objects to ISO strings for serialization
83
+ const serializable = {
84
+ projects: this.config.projects.map((p) => ({
85
+ ...p,
86
+ lastAccessed: p.lastAccessed.toISOString(),
87
+ })),
88
+ recentProjects: this.config.recentProjects,
89
+ };
90
+
91
+ const yamlContent = yaml.dump(serializable, {
92
+ indent: 2,
93
+ lineWidth: 100,
94
+ });
95
+
96
+ await fs.writeFile(PROJECTS_CONFIG_FILE, yamlContent, 'utf-8');
97
+ }
98
+
99
+ /**
100
+ * Validate a project path and extract metadata
101
+ */
102
+ async validateProject(projectPath: string): Promise<ProjectValidation> {
103
+ try {
104
+ const normalizedPath = path.resolve(projectPath);
105
+
106
+ // Check if path exists
107
+ const stats = await fs.stat(normalizedPath);
108
+ if (!stats.isDirectory()) {
109
+ return {
110
+ isValid: false,
111
+ path: normalizedPath,
112
+ error: 'Path is not a directory',
113
+ };
114
+ }
115
+
116
+ // Look for .lean-spec directory or specs directory
117
+ let specsDir: string | undefined;
118
+ const leanSpecDir = path.join(normalizedPath, '.lean-spec');
119
+ const specsOnlyDir = path.join(normalizedPath, 'specs');
120
+
121
+ try {
122
+ await fs.access(leanSpecDir);
123
+ // If .lean-spec exists, check for specs inside or alongside
124
+ const specsInLeanSpec = path.join(leanSpecDir, '../specs');
125
+ try {
126
+ await fs.access(specsInLeanSpec);
127
+ specsDir = specsInLeanSpec;
128
+ } catch {
129
+ specsDir = specsOnlyDir;
130
+ }
131
+ } catch {
132
+ // No .lean-spec, check for specs directory
133
+ try {
134
+ await fs.access(specsOnlyDir);
135
+ specsDir = specsOnlyDir;
136
+ } catch {
137
+ return {
138
+ isValid: false,
139
+ path: normalizedPath,
140
+ error: 'No .lean-spec directory or specs directory found',
141
+ };
142
+ }
143
+ }
144
+
145
+ // Try to read project name and description from various sources
146
+ let name = path.basename(normalizedPath);
147
+ let description: string | undefined;
148
+
149
+ // Try leanspec.yaml
150
+ try {
151
+ const leanspecYaml = path.join(normalizedPath, 'leanspec.yaml');
152
+ const content = await fs.readFile(leanspecYaml, 'utf-8');
153
+ const config = yaml.load(content) as any;
154
+ if (config.name) name = config.name;
155
+ if (config.description) description = config.description;
156
+ } catch {
157
+ // Try package.json
158
+ try {
159
+ const packageJson = path.join(normalizedPath, 'package.json');
160
+ const content = await fs.readFile(packageJson, 'utf-8');
161
+ const pkg = JSON.parse(content);
162
+ if (pkg.name) name = pkg.name;
163
+ if (pkg.description) description = pkg.description;
164
+ } catch {
165
+ // Use directory name as fallback
166
+ }
167
+ }
168
+
169
+ return {
170
+ isValid: true,
171
+ path: normalizedPath,
172
+ specsDir: path.resolve(specsDir),
173
+ name,
174
+ description,
175
+ };
176
+ } catch (error: any) {
177
+ return {
178
+ isValid: false,
179
+ path: projectPath,
180
+ error: error.message || 'Unknown error',
181
+ };
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Add a project to the registry
187
+ */
188
+ async addProject(projectPath: string, options?: { favorite?: boolean; color?: string }): Promise<LocalProject> {
189
+ const validation = await this.validateProject(projectPath);
190
+
191
+ if (!validation.isValid) {
192
+ throw new Error(`Invalid project: ${validation.error}`);
193
+ }
194
+
195
+ const config = await this.loadConfig();
196
+ const projectId = this.generateProjectId(validation.path);
197
+
198
+ // Check if project already exists
199
+ const existingIndex = config.projects.findIndex((p) => p.id === projectId);
200
+
201
+ const project: LocalProject = {
202
+ id: projectId,
203
+ name: validation.name!,
204
+ path: validation.path,
205
+ specsDir: validation.specsDir!,
206
+ lastAccessed: new Date(),
207
+ favorite: options?.favorite || false,
208
+ color: options?.color,
209
+ description: validation.description,
210
+ };
211
+
212
+ if (existingIndex >= 0) {
213
+ // Update existing project
214
+ config.projects[existingIndex] = project;
215
+ } else {
216
+ // Add new project
217
+ config.projects.push(project);
218
+ }
219
+
220
+ // Update recent projects
221
+ this.updateRecentProjects(config, projectId);
222
+
223
+ await this.saveConfig();
224
+ return project;
225
+ }
226
+
227
+ /**
228
+ * Remove a project from the registry
229
+ */
230
+ async removeProject(projectId: string): Promise<void> {
231
+ const config = await this.loadConfig();
232
+
233
+ config.projects = config.projects.filter((p) => p.id !== projectId);
234
+ config.recentProjects = config.recentProjects.filter((id) => id !== projectId);
235
+
236
+ await this.saveConfig();
237
+ }
238
+
239
+ /**
240
+ * Get a project by ID
241
+ */
242
+ async getProject(projectId: string): Promise<LocalProject | null> {
243
+ const config = await this.loadConfig();
244
+ return config.projects.find((p) => p.id === projectId) || null;
245
+ }
246
+
247
+ /**
248
+ * Get all projects
249
+ */
250
+ async getProjects(): Promise<LocalProject[]> {
251
+ const config = await this.loadConfig();
252
+ return config.projects;
253
+ }
254
+
255
+ /**
256
+ * Update project lastAccessed timestamp and add to recent projects
257
+ */
258
+ async touchProject(projectId: string): Promise<void> {
259
+ const config = await this.loadConfig();
260
+
261
+ const project = config.projects.find((p) => p.id === projectId);
262
+ if (!project) {
263
+ return;
264
+ }
265
+
266
+ project.lastAccessed = new Date();
267
+ this.updateRecentProjects(config, projectId);
268
+
269
+ await this.saveConfig();
270
+ }
271
+
272
+ /**
273
+ * Toggle favorite status
274
+ */
275
+ async toggleFavorite(projectId: string): Promise<boolean> {
276
+ const config = await this.loadConfig();
277
+
278
+ const project = config.projects.find((p) => p.id === projectId);
279
+ if (!project) {
280
+ throw new Error('Project not found');
281
+ }
282
+
283
+ project.favorite = !project.favorite;
284
+ await this.saveConfig();
285
+
286
+ return project.favorite;
287
+ }
288
+
289
+ /**
290
+ * Update project metadata
291
+ */
292
+ async updateProject(projectId: string, updates: Partial<Pick<LocalProject, 'name' | 'color' | 'description'>>): Promise<LocalProject> {
293
+ const config = await this.loadConfig();
294
+
295
+ const project = config.projects.find((p) => p.id === projectId);
296
+ if (!project) {
297
+ throw new Error('Project not found');
298
+ }
299
+
300
+ Object.assign(project, updates);
301
+ await this.saveConfig();
302
+
303
+ return project;
304
+ }
305
+
306
+ /**
307
+ * Discover projects in a directory tree
308
+ */
309
+ async discoverProjects(rootDir: string, maxDepth: number = 3): Promise<ProjectValidation[]> {
310
+ const discovered: ProjectValidation[] = [];
311
+
312
+ async function scan(dir: string, depth: number) {
313
+ if (depth > maxDepth) {
314
+ return;
315
+ }
316
+
317
+ try {
318
+ const entries = await fs.readdir(dir, { withFileTypes: true });
319
+
320
+ for (const entry of entries) {
321
+ if (!entry.isDirectory()) {
322
+ continue;
323
+ }
324
+
325
+ // Skip common ignore patterns
326
+ if (entry.name === 'node_modules' || entry.name === '.git' || entry.name.startsWith('.')) {
327
+ continue;
328
+ }
329
+
330
+ const fullPath = path.join(dir, entry.name);
331
+
332
+ // Check if this directory is a LeanSpec project
333
+ const validation = await projectRegistry.validateProject(fullPath);
334
+ if (validation.isValid) {
335
+ discovered.push(validation);
336
+ } else {
337
+ // Recurse into subdirectories
338
+ await scan(fullPath, depth + 1);
339
+ }
340
+ }
341
+ } catch (error) {
342
+ // Skip directories we can't read
343
+ }
344
+ }
345
+
346
+ await scan(rootDir, 0);
347
+ return discovered;
348
+ }
349
+
350
+ /**
351
+ * Get recent projects
352
+ */
353
+ async getRecentProjects(limit: number = 10): Promise<LocalProject[]> {
354
+ const config = await this.loadConfig();
355
+
356
+ return config.recentProjects
357
+ .slice(0, limit)
358
+ .map((id) => config.projects.find((p) => p.id === id))
359
+ .filter((p): p is LocalProject => p !== undefined);
360
+ }
361
+
362
+ /**
363
+ * Get favorite projects
364
+ */
365
+ async getFavoriteProjects(): Promise<LocalProject[]> {
366
+ const config = await this.loadConfig();
367
+ return config.projects.filter((p) => p.favorite);
368
+ }
369
+
370
+ /**
371
+ * Update recent projects list
372
+ */
373
+ private updateRecentProjects(config: ProjectsConfig, projectId: string): void {
374
+ // Remove if already in list
375
+ config.recentProjects = config.recentProjects.filter((id) => id !== projectId);
376
+
377
+ // Add to front
378
+ config.recentProjects.unshift(projectId);
379
+
380
+ // Keep only last 10
381
+ config.recentProjects = config.recentProjects.slice(0, 10);
382
+ }
383
+
384
+ /**
385
+ * Invalidate cached config
386
+ */
387
+ invalidateCache(): void {
388
+ this.config = null;
389
+ }
390
+ }
391
+
392
+ // Singleton instance
393
+ export const projectRegistry = new ProjectRegistry();
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Types for local multi-project support
3
+ */
4
+
5
+ /**
6
+ * Local project metadata
7
+ */
8
+ export interface LocalProject {
9
+ id: string; // Unique identifier (hash of path)
10
+ name: string; // Display name (from config or folder name)
11
+ path: string; // Absolute path to project root
12
+ specsDir: string; // Path to specs/ directory
13
+ lastAccessed: Date; // For sorting recent projects
14
+ favorite: boolean; // User can pin favorites
15
+ color?: string; // Optional color coding
16
+ description?: string; // From project README or config
17
+ }
18
+
19
+ /**
20
+ * Projects configuration file structure
21
+ */
22
+ export interface ProjectsConfig {
23
+ projects: LocalProject[];
24
+ recentProjects: string[]; // Project IDs in order (max 10)
25
+ }
26
+
27
+ /**
28
+ * Project validation result
29
+ */
30
+ export interface ProjectValidation {
31
+ isValid: boolean;
32
+ path: string;
33
+ specsDir?: string;
34
+ name?: string;
35
+ description?: string;
36
+ error?: string;
37
+ }
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { FilesystemSource } from './sources/filesystem-source';
7
+ import { MultiProjectFilesystemSource } from './sources/multi-project-source';
7
8
  import type { SpecSource } from './types';
8
9
  import type { Spec } from '../db/schema';
9
10
 
@@ -14,10 +15,11 @@ let DatabaseSourceCtor: DatabaseSourceConstructor | null = null;
14
15
  /**
15
16
  * Service modes
16
17
  * - filesystem: Read from local filesystem only (LeanSpec's own specs)
18
+ * - multi-project: Read from multiple local filesystem projects
17
19
  * - database: Read from database only (external repos)
18
20
  * - both: Support both modes (route based on projectId)
19
21
  */
20
- type SpecsMode = 'filesystem' | 'database' | 'both';
22
+ type SpecsMode = 'filesystem' | 'multi-project' | 'database' | 'both';
21
23
 
22
24
  /**
23
25
  * Unified specs service
@@ -25,6 +27,7 @@ type SpecsMode = 'filesystem' | 'database' | 'both';
25
27
  */
26
28
  export class SpecsService {
27
29
  private filesystemSource?: FilesystemSource;
30
+ private multiProjectSource?: MultiProjectFilesystemSource;
28
31
  private databaseSource?: SpecSource;
29
32
  private mode: SpecsMode;
30
33
 
@@ -36,6 +39,10 @@ export class SpecsService {
36
39
  this.filesystemSource = new FilesystemSource(specsDir);
37
40
  }
38
41
 
42
+ if (this.mode === 'multi-project') {
43
+ this.multiProjectSource = new MultiProjectFilesystemSource();
44
+ }
45
+
39
46
  // Don't instantiate database source here - do it lazily
40
47
  }
41
48
 
@@ -102,6 +109,11 @@ export class SpecsService {
102
109
  * Get the appropriate source based on projectId and mode
103
110
  */
104
111
  private async getSource(projectId?: string): Promise<SpecSource> {
112
+ // Multi-project mode: use multi-project source
113
+ if (this.mode === 'multi-project' && this.multiProjectSource) {
114
+ return this.multiProjectSource;
115
+ }
116
+
105
117
  // If projectId provided, use database (external repo)
106
118
  if (projectId && (this.mode === 'database' || this.mode === 'both')) {
107
119
  return await this.getDatabaseSource();