@mnemonik/scanner 1.0.0 → 2.0.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/README.md +90 -0
- package/dist/daemon.d.ts +20 -10
- package/dist/daemon.js +151 -73
- package/dist/daemon.js.map +1 -1
- package/dist/discovery.d.ts +21 -0
- package/dist/discovery.js +107 -0
- package/dist/discovery.js.map +1 -0
- package/dist/index.js +248 -56
- package/dist/index.js.map +1 -1
- package/dist/watcher.d.ts +3 -1
- package/dist/watcher.js +9 -1
- package/dist/watcher.js.map +1 -1
- package/package.json +3 -3
- package/src/daemon.ts +195 -80
- package/src/discovery.ts +125 -0
- package/src/index.ts +271 -58
- package/src/watcher.ts +13 -2
package/src/daemon.ts
CHANGED
|
@@ -4,34 +4,170 @@ import { join } from 'path';
|
|
|
4
4
|
import { CodeScanner, type CodeChunk } from '@mnemonik/shared';
|
|
5
5
|
import { MnemonikClient, type ScanPushFile } from './client.js';
|
|
6
6
|
import { FileWatcher } from './watcher.js';
|
|
7
|
+
import { ProjectDiscovery, type DiscoveredProject } from './discovery.js';
|
|
7
8
|
|
|
8
9
|
export interface DaemonConfig {
|
|
9
|
-
projectId: string;
|
|
10
|
-
projectRoot: string;
|
|
11
10
|
serverUrl: string;
|
|
12
11
|
apiKey: string;
|
|
12
|
+
roots: string[];
|
|
13
|
+
refreshIntervalMs?: number;
|
|
14
|
+
maxConcurrentScans?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface WatchedProject {
|
|
18
|
+
projectId: string;
|
|
19
|
+
path: string;
|
|
20
|
+
name?: string;
|
|
21
|
+
watcher: FileWatcher;
|
|
22
|
+
pendingRetries: Set<string>;
|
|
23
|
+
retryTimer: ReturnType<typeof setInterval> | null;
|
|
13
24
|
}
|
|
14
25
|
|
|
15
26
|
export class ScannerDaemon {
|
|
16
27
|
private client: MnemonikClient;
|
|
17
28
|
private scanner: CodeScanner;
|
|
18
|
-
private
|
|
19
|
-
private
|
|
20
|
-
private
|
|
29
|
+
private projects = new Map<string, WatchedProject>();
|
|
30
|
+
private refreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
31
|
+
private discovery: ProjectDiscovery;
|
|
32
|
+
private refreshIntervalMs: number;
|
|
33
|
+
private maxConcurrentScans: number;
|
|
21
34
|
|
|
22
35
|
constructor(private config: DaemonConfig) {
|
|
23
36
|
this.client = new MnemonikClient(config.serverUrl, config.apiKey);
|
|
24
37
|
this.scanner = new CodeScanner();
|
|
38
|
+
this.discovery = new ProjectDiscovery(config.roots);
|
|
39
|
+
this.refreshIntervalMs = config.refreshIntervalMs ?? 300_000; // 5 min
|
|
40
|
+
this.maxConcurrentScans = config.maxConcurrentScans ?? 5;
|
|
25
41
|
}
|
|
26
42
|
|
|
27
43
|
async start(): Promise<void> {
|
|
28
|
-
console.log(`[scanner] Starting daemon
|
|
44
|
+
console.log(`[scanner] Starting daemon`);
|
|
29
45
|
console.log(`[scanner] Server: ${this.config.serverUrl}`);
|
|
30
|
-
console.log(`[scanner]
|
|
46
|
+
console.log(`[scanner] Roots: ${this.config.roots.join(', ')}`);
|
|
31
47
|
|
|
32
48
|
await this.waitForServer();
|
|
33
|
-
await this.
|
|
34
|
-
|
|
49
|
+
await this.refreshProjects();
|
|
50
|
+
|
|
51
|
+
this.refreshTimer = setInterval(() => {
|
|
52
|
+
this.refreshProjects().catch((err) => {
|
|
53
|
+
console.warn('[scanner] Refresh failed:', (err as Error).message);
|
|
54
|
+
});
|
|
55
|
+
}, this.refreshIntervalMs);
|
|
56
|
+
this.refreshTimer.unref();
|
|
57
|
+
|
|
58
|
+
console.log('[scanner] Watching for changes.');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async stop(): Promise<void> {
|
|
62
|
+
if (this.refreshTimer) {
|
|
63
|
+
clearInterval(this.refreshTimer);
|
|
64
|
+
this.refreshTimer = null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const project of this.projects.values()) {
|
|
68
|
+
project.watcher.stop();
|
|
69
|
+
if (project.retryTimer) clearInterval(project.retryTimer);
|
|
70
|
+
}
|
|
71
|
+
this.projects.clear();
|
|
72
|
+
console.log('[scanner] Daemon stopped');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
getWatchedProjects(): Array<{ projectId: string; path: string; name?: string }> {
|
|
76
|
+
return Array.from(this.projects.values()).map((p) => ({
|
|
77
|
+
projectId: p.projectId,
|
|
78
|
+
path: p.path,
|
|
79
|
+
name: p.name,
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Discover projects from configured roots and reconcile with current watch list.
|
|
85
|
+
*/
|
|
86
|
+
async refreshProjects(): Promise<void> {
|
|
87
|
+
const discovered = await this.discovery.discover();
|
|
88
|
+
const discoveredMap = new Map(discovered.map((d) => [d.projectId, d]));
|
|
89
|
+
|
|
90
|
+
// Remove projects no longer discovered
|
|
91
|
+
for (const [projectId, project] of this.projects) {
|
|
92
|
+
if (!discoveredMap.has(projectId)) {
|
|
93
|
+
console.log(`[scanner] Project removed: ${project.name ?? projectId} (${project.path})`);
|
|
94
|
+
project.watcher.stop();
|
|
95
|
+
if (project.retryTimer) clearInterval(project.retryTimer);
|
|
96
|
+
this.projects.delete(projectId);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Add new projects or handle path changes
|
|
101
|
+
for (const discovered_project of discoveredMap.values()) {
|
|
102
|
+
const existing = this.projects.get(discovered_project.projectId);
|
|
103
|
+
|
|
104
|
+
if (!existing) {
|
|
105
|
+
// New project
|
|
106
|
+
await this.addProject(discovered_project);
|
|
107
|
+
} else if (existing.path !== discovered_project.path) {
|
|
108
|
+
// Path changed (folder renamed/moved)
|
|
109
|
+
console.log(
|
|
110
|
+
`[scanner] Project moved: ${existing.name ?? existing.projectId} ` +
|
|
111
|
+
`${existing.path} → ${discovered_project.path}`
|
|
112
|
+
);
|
|
113
|
+
existing.watcher.stop();
|
|
114
|
+
if (existing.retryTimer) clearInterval(existing.retryTimer);
|
|
115
|
+
this.projects.delete(discovered_project.projectId);
|
|
116
|
+
await this.addProject(discovered_project);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log(`[scanner] Watching ${this.projects.size} project(s)`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private async addProject(discovered: DiscoveredProject): Promise<void> {
|
|
124
|
+
const label = discovered.projectName ?? discovered.projectId.slice(0, 8);
|
|
125
|
+
console.log(`[scanner] Adding project: ${label} (${discovered.path})`);
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
await this.initialScan(discovered.projectId, discovered.path);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.warn(
|
|
131
|
+
`[scanner] Initial scan failed for ${label}:`,
|
|
132
|
+
(err as Error).message
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const watcher = new FileWatcher(
|
|
137
|
+
discovered.path,
|
|
138
|
+
(changedFiles) => this.handleChanges(discovered.projectId, discovered.path, changedFiles),
|
|
139
|
+
500,
|
|
140
|
+
(err) => {
|
|
141
|
+
console.warn(
|
|
142
|
+
`[scanner] Root watcher error for ${label}: ${err.message}. Removing project.`
|
|
143
|
+
);
|
|
144
|
+
const project = this.projects.get(discovered.projectId);
|
|
145
|
+
if (project) {
|
|
146
|
+
project.watcher.stop();
|
|
147
|
+
if (project.retryTimer) clearInterval(project.retryTimer);
|
|
148
|
+
this.projects.delete(discovered.projectId);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
await watcher.start();
|
|
155
|
+
} catch (err) {
|
|
156
|
+
console.warn(
|
|
157
|
+
`[scanner] Failed to start watcher for ${label}:`,
|
|
158
|
+
(err as Error).message
|
|
159
|
+
);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
this.projects.set(discovered.projectId, {
|
|
164
|
+
projectId: discovered.projectId,
|
|
165
|
+
path: discovered.path,
|
|
166
|
+
name: discovered.projectName,
|
|
167
|
+
watcher,
|
|
168
|
+
pendingRetries: new Set(),
|
|
169
|
+
retryTimer: null,
|
|
170
|
+
});
|
|
35
171
|
}
|
|
36
172
|
|
|
37
173
|
private async waitForServer(): Promise<void> {
|
|
@@ -58,83 +194,46 @@ export class ScannerDaemon {
|
|
|
58
194
|
throw new Error(`Server unreachable after ${maxRetries} attempts`);
|
|
59
195
|
}
|
|
60
196
|
|
|
61
|
-
|
|
62
|
-
return this.config.projectRoot;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
getProjectId(): string {
|
|
66
|
-
return this.config.projectId;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
async stop(): Promise<void> {
|
|
70
|
-
this.watcher?.stop();
|
|
71
|
-
if (this.retryTimer) clearInterval(this.retryTimer);
|
|
72
|
-
console.log('[scanner] Daemon stopped');
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
private startRetryLoop(): void {
|
|
76
|
-
if (this.retryTimer) return;
|
|
77
|
-
console.log(`[scanner] ${this.pendingRetries.size} file(s) queued for retry`);
|
|
78
|
-
this.retryTimer = setInterval(async () => {
|
|
79
|
-
if (this.pendingRetries.size === 0) {
|
|
80
|
-
if (this.retryTimer) clearInterval(this.retryTimer);
|
|
81
|
-
this.retryTimer = null;
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
const files = [...this.pendingRetries];
|
|
85
|
-
this.pendingRetries.clear();
|
|
86
|
-
await this.handleChanges(files);
|
|
87
|
-
}, 10_000);
|
|
88
|
-
this.retryTimer.unref();
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
private async initialScan(): Promise<void> {
|
|
92
|
-
console.log('[scanner] Starting initial scan...');
|
|
197
|
+
private async initialScan(projectId: string, projectRoot: string): Promise<void> {
|
|
93
198
|
const startTime = Date.now();
|
|
94
199
|
|
|
95
|
-
const chunks = await this.scanner.scanDirectory(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const serverHashes = await this.client.getStatus(this.config.projectId);
|
|
99
|
-
console.log(`[scanner] Server knows ${serverHashes.size} files`);
|
|
200
|
+
const chunks = await this.scanner.scanDirectory(projectRoot);
|
|
201
|
+
const serverHashes = await this.client.getStatus(projectId);
|
|
100
202
|
|
|
101
|
-
const files = await this.groupChunksByFile(chunks);
|
|
203
|
+
const files = await this.groupChunksByFile(chunks, projectRoot);
|
|
102
204
|
const filesToPush = files.filter((f) => {
|
|
103
205
|
const serverHash = serverHashes.get(f.path);
|
|
104
206
|
return !serverHash || serverHash !== f.hash;
|
|
105
207
|
});
|
|
106
208
|
|
|
107
|
-
if (filesToPush.length
|
|
108
|
-
console.log(
|
|
109
|
-
|
|
110
|
-
console.log(`[scanner] Pushing ${filesToPush.length} changed files...`);
|
|
111
|
-
await this.client.pushFiles(this.config.projectId, filesToPush);
|
|
112
|
-
console.log(`[scanner] Push complete`);
|
|
209
|
+
if (filesToPush.length > 0) {
|
|
210
|
+
console.log(`[scanner] Pushing ${filesToPush.length} changed files for ${projectId.slice(0, 8)}...`);
|
|
211
|
+
await this.client.pushFiles(projectId, filesToPush);
|
|
113
212
|
}
|
|
114
213
|
|
|
115
214
|
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
116
|
-
console.log(
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
private async startWatching(): Promise<void> {
|
|
120
|
-
this.watcher = new FileWatcher(
|
|
121
|
-
this.config.projectRoot,
|
|
122
|
-
(changedFiles) => this.handleChanges(changedFiles),
|
|
123
|
-
500
|
|
215
|
+
console.log(
|
|
216
|
+
`[scanner] Scan complete for ${projectId.slice(0, 8)}: ` +
|
|
217
|
+
`${chunks.length} chunks, ${filesToPush.length} pushed (${duration}s)`
|
|
124
218
|
);
|
|
125
|
-
await this.watcher.start();
|
|
126
219
|
}
|
|
127
220
|
|
|
128
|
-
private async handleChanges(
|
|
221
|
+
private async handleChanges(
|
|
222
|
+
projectId: string,
|
|
223
|
+
projectRoot: string,
|
|
224
|
+
changedFiles: string[]
|
|
225
|
+
): Promise<void> {
|
|
226
|
+
const project = this.projects.get(projectId);
|
|
227
|
+
if (!project) return;
|
|
228
|
+
|
|
129
229
|
try {
|
|
130
|
-
const absPaths = changedFiles.map((rel) => join(
|
|
131
|
-
const chunks = await this.scanner.scanFiles(absPaths,
|
|
230
|
+
const absPaths = changedFiles.map((rel) => join(projectRoot, rel));
|
|
231
|
+
const chunks = await this.scanner.scanFiles(absPaths, projectRoot);
|
|
132
232
|
|
|
133
233
|
if (chunks.length === 0) return;
|
|
134
234
|
|
|
135
|
-
const files = await this.groupChunksByFile(chunks);
|
|
235
|
+
const files = await this.groupChunksByFile(chunks, projectRoot);
|
|
136
236
|
|
|
137
|
-
// Push in batches, only retrying files from failed batches
|
|
138
237
|
const batchSize = 25;
|
|
139
238
|
const succeededPaths = new Set<string>();
|
|
140
239
|
let hadFailure = false;
|
|
@@ -142,39 +241,55 @@ export class ScannerDaemon {
|
|
|
142
241
|
for (let i = 0; i < files.length; i += batchSize) {
|
|
143
242
|
const batch = files.slice(i, i + batchSize);
|
|
144
243
|
try {
|
|
145
|
-
await this.client.pushFiles(
|
|
244
|
+
await this.client.pushFiles(projectId, batch);
|
|
146
245
|
for (const f of batch) succeededPaths.add(f.path);
|
|
147
246
|
} catch {
|
|
148
247
|
hadFailure = true;
|
|
149
|
-
for (const f of batch)
|
|
248
|
+
for (const f of batch) project.pendingRetries.add(f.path);
|
|
150
249
|
}
|
|
151
250
|
}
|
|
152
251
|
|
|
153
252
|
if (succeededPaths.size > 0) {
|
|
154
253
|
console.log(
|
|
155
|
-
`[scanner] Pushed ${succeededPaths.size}
|
|
254
|
+
`[scanner] [${projectId.slice(0, 8)}] Pushed ${succeededPaths.size} file(s): ${[...succeededPaths].join(', ')}`
|
|
156
255
|
);
|
|
157
256
|
}
|
|
158
257
|
if (hadFailure) {
|
|
159
|
-
console.warn(
|
|
160
|
-
|
|
258
|
+
console.warn(
|
|
259
|
+
`[scanner] [${projectId.slice(0, 8)}] ${project.pendingRetries.size} file(s) failed, queued for retry`
|
|
260
|
+
);
|
|
261
|
+
this.startRetryLoop(project);
|
|
161
262
|
}
|
|
162
263
|
} catch (err) {
|
|
163
|
-
console.error(
|
|
264
|
+
console.error(`[scanner] [${projectId.slice(0, 8)}] Error handling changes:`, err);
|
|
164
265
|
}
|
|
165
266
|
}
|
|
166
267
|
|
|
167
|
-
private
|
|
268
|
+
private startRetryLoop(project: WatchedProject): void {
|
|
269
|
+
if (project.retryTimer) return;
|
|
270
|
+
project.retryTimer = setInterval(async () => {
|
|
271
|
+
if (project.pendingRetries.size === 0) {
|
|
272
|
+
if (project.retryTimer) clearInterval(project.retryTimer);
|
|
273
|
+
project.retryTimer = null;
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const files = [...project.pendingRetries];
|
|
277
|
+
project.pendingRetries.clear();
|
|
278
|
+
await this.handleChanges(project.projectId, project.path, files);
|
|
279
|
+
}, 10_000);
|
|
280
|
+
project.retryTimer.unref();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private async groupChunksByFile(
|
|
284
|
+
chunks: CodeChunk[],
|
|
285
|
+
projectRoot: string
|
|
286
|
+
): Promise<ScanPushFile[]> {
|
|
168
287
|
const fileMap = new Map<string, ScanPushFile>();
|
|
169
288
|
|
|
170
289
|
for (const chunk of chunks) {
|
|
171
290
|
const key = chunk.filePath;
|
|
172
291
|
if (!fileMap.has(key)) {
|
|
173
|
-
fileMap.set(key, {
|
|
174
|
-
path: key,
|
|
175
|
-
hash: '',
|
|
176
|
-
chunks: [],
|
|
177
|
-
});
|
|
292
|
+
fileMap.set(key, { path: key, hash: '', chunks: [] });
|
|
178
293
|
}
|
|
179
294
|
const file = fileMap.get(key)!;
|
|
180
295
|
file.chunks.push({
|
|
@@ -190,7 +305,7 @@ export class ScannerDaemon {
|
|
|
190
305
|
|
|
191
306
|
for (const file of fileMap.values()) {
|
|
192
307
|
try {
|
|
193
|
-
const absPath = join(
|
|
308
|
+
const absPath = join(projectRoot, file.path);
|
|
194
309
|
const raw = await readFile(absPath, 'utf-8');
|
|
195
310
|
file.hash = createHash('sha256').update(raw).digest('hex');
|
|
196
311
|
} catch (err) {
|
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { readFile, readdir, stat } from 'fs/promises';
|
|
2
|
+
import { join, resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
/** Directories to skip during discovery walk */
|
|
5
|
+
const SKIP_DIRS = new Set([
|
|
6
|
+
'node_modules',
|
|
7
|
+
'.git',
|
|
8
|
+
'dist',
|
|
9
|
+
'build',
|
|
10
|
+
'.next',
|
|
11
|
+
'.nuxt',
|
|
12
|
+
'.output',
|
|
13
|
+
'__pycache__',
|
|
14
|
+
'.venv',
|
|
15
|
+
'venv',
|
|
16
|
+
'.tox',
|
|
17
|
+
'target',
|
|
18
|
+
'.cache',
|
|
19
|
+
'coverage',
|
|
20
|
+
'.turbo',
|
|
21
|
+
'.vercel',
|
|
22
|
+
'.svelte-kit',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export interface DiscoveredProject {
|
|
26
|
+
projectId: string;
|
|
27
|
+
path: string;
|
|
28
|
+
projectName?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class ProjectDiscovery {
|
|
32
|
+
private maxDepth: number;
|
|
33
|
+
private timeoutMs: number;
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
private roots: string[],
|
|
37
|
+
options?: { maxDepth?: number; timeoutMs?: number }
|
|
38
|
+
) {
|
|
39
|
+
this.maxDepth = options?.maxDepth ?? 3;
|
|
40
|
+
this.timeoutMs = options?.timeoutMs ?? 30_000;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Discover all projects with .mnemonik.json files under the configured roots.
|
|
45
|
+
* Deduplicates by projectId (same project found at multiple paths = first wins).
|
|
46
|
+
*/
|
|
47
|
+
async discover(): Promise<DiscoveredProject[]> {
|
|
48
|
+
const seen = new Map<string, DiscoveredProject>();
|
|
49
|
+
|
|
50
|
+
for (const root of this.roots) {
|
|
51
|
+
const absRoot = resolve(root.replace(/^~/, process.env.HOME || ''));
|
|
52
|
+
try {
|
|
53
|
+
await this.walkWithTimeout(absRoot, 0, seen);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err instanceof DiscoveryTimeoutError) {
|
|
56
|
+
console.warn(`[scanner] Discovery timeout for root: ${absRoot} (>${this.timeoutMs}ms)`);
|
|
57
|
+
} else {
|
|
58
|
+
console.warn(`[scanner] Error scanning root ${absRoot}:`, (err as Error).message);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return Array.from(seen.values());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async walkWithTimeout(
|
|
67
|
+
dir: string,
|
|
68
|
+
depth: number,
|
|
69
|
+
seen: Map<string, DiscoveredProject>
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
const deadline = Date.now() + this.timeoutMs;
|
|
72
|
+
await this.walk(dir, depth, seen, deadline);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async walk(
|
|
76
|
+
dir: string,
|
|
77
|
+
depth: number,
|
|
78
|
+
seen: Map<string, DiscoveredProject>,
|
|
79
|
+
deadline: number
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
if (depth > this.maxDepth) return;
|
|
82
|
+
if (Date.now() > deadline) throw new DiscoveryTimeoutError();
|
|
83
|
+
|
|
84
|
+
// Check for .mnemonik.json in this directory
|
|
85
|
+
const configPath = join(dir, '.mnemonik.json');
|
|
86
|
+
try {
|
|
87
|
+
const raw = await readFile(configPath, 'utf-8');
|
|
88
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
89
|
+
if (typeof parsed.projectId === 'string' && parsed.projectId.length > 0) {
|
|
90
|
+
if (!seen.has(parsed.projectId)) {
|
|
91
|
+
seen.set(parsed.projectId, {
|
|
92
|
+
projectId: parsed.projectId,
|
|
93
|
+
path: dir,
|
|
94
|
+
projectName:
|
|
95
|
+
typeof parsed.projectName === 'string' ? parsed.projectName : undefined,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Don't recurse into subdirectories of a project — the project owns this tree
|
|
100
|
+
return;
|
|
101
|
+
} catch {
|
|
102
|
+
// No .mnemonik.json here, continue walking
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Recurse into subdirectories
|
|
106
|
+
try {
|
|
107
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
if (!entry.isDirectory()) continue;
|
|
110
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
111
|
+
if (entry.name.startsWith('.') && entry.name !== '.mnemonik') continue;
|
|
112
|
+
await this.walk(join(dir, entry.name), depth + 1, seen, deadline);
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// Permission denied or inaccessible directory
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
class DiscoveryTimeoutError extends Error {
|
|
121
|
+
constructor() {
|
|
122
|
+
super('Discovery walk timed out');
|
|
123
|
+
this.name = 'DiscoveryTimeoutError';
|
|
124
|
+
}
|
|
125
|
+
}
|