@actiondock/core 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.
Files changed (43) hide show
  1. package/README.md +50 -0
  2. package/package.json +51 -0
  3. package/src/build/builder.ts +205 -0
  4. package/src/build/index.ts +2 -0
  5. package/src/build/templates.ts +59 -0
  6. package/src/doctor/doctor.ts +332 -0
  7. package/src/doctor/index.ts +2 -0
  8. package/src/doctor/types.ts +25 -0
  9. package/src/export/index.ts +2 -0
  10. package/src/export/skill.ts +349 -0
  11. package/src/export/templates.ts +258 -0
  12. package/src/filter/index.ts +1 -0
  13. package/src/filter/intent.ts +154 -0
  14. package/src/index.ts +13 -0
  15. package/src/profile/client.ts +302 -0
  16. package/src/profile/index.ts +3 -0
  17. package/src/profile/manager.ts +341 -0
  18. package/src/profile/types.ts +71 -0
  19. package/src/project/index.ts +3 -0
  20. package/src/project/init.ts +194 -0
  21. package/src/project/loader.ts +382 -0
  22. package/src/project/types.ts +62 -0
  23. package/src/registry/index.ts +2 -0
  24. package/src/registry/registry.ts +703 -0
  25. package/src/registry/types.ts +127 -0
  26. package/src/runtime/context.ts +232 -0
  27. package/src/runtime/env.ts +172 -0
  28. package/src/runtime/execution-manager.ts +74 -0
  29. package/src/runtime/index.ts +5 -0
  30. package/src/runtime/runner.ts +368 -0
  31. package/src/runtime/standalone.ts +429 -0
  32. package/src/schema/validator.ts +61 -0
  33. package/src/server/body.ts +112 -0
  34. package/src/server/index.ts +6 -0
  35. package/src/server/runtime-registry.ts +80 -0
  36. package/src/server/security.ts +115 -0
  37. package/src/server/server.ts +572 -0
  38. package/src/server/types.ts +42 -0
  39. package/src/storage/index.ts +64 -0
  40. package/src/storage/mask.ts +34 -0
  41. package/src/storage/sqlite.ts +578 -0
  42. package/src/storage/types.ts +111 -0
  43. package/src/utils/index.ts +60 -0
@@ -0,0 +1,703 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ import { findProjectRoot, loadActions, loadPlaybooks, loadProjectConfig } from "../project/loader";
4
+ import { getActionDockHome, getPackageSlug } from "../utils";
5
+ import type {
6
+ GlobalRegistryData,
7
+ LinkedPackageEntry,
8
+ LinkedWorkspaceEntry,
9
+ LinkResult,
10
+ PruneResult,
11
+ RegistryStatusReport,
12
+ RegistryTreeItem,
13
+ ResolvedActionProject,
14
+ ResolvedPlaybookProject,
15
+ UnlinkResult,
16
+ } from "./types";
17
+
18
+ const IGNORED_SCAN_DIRS = new Set([
19
+ "node_modules",
20
+ ".git",
21
+ "dist",
22
+ "build",
23
+ ".gemini",
24
+ ".actiondock",
25
+ ".claude",
26
+ ".idea",
27
+ ".vscode",
28
+ ]);
29
+
30
+ /**
31
+ * 递归扫描包含 actiondock.json 的子项目根目录
32
+ */
33
+ export function discoverProjects(dir: string, maxDepth: number = 3): string[] {
34
+ const results: string[] = [];
35
+ const resolvedDir = resolve(dir);
36
+
37
+ function walk(currentDir: string, currentDepth: number) {
38
+ if (currentDepth > maxDepth) return;
39
+ try {
40
+ const entries = readdirSync(currentDir, { withFileTypes: true });
41
+ const hasActiondock = entries.some((e) => e.isFile() && e.name === "actiondock.json");
42
+
43
+ if (hasActiondock && currentDir !== resolvedDir) {
44
+ results.push(currentDir);
45
+ return; // 不再向项目内部子目录递归
46
+ }
47
+
48
+ for (const entry of entries) {
49
+ if (entry.isDirectory() && !IGNORED_SCAN_DIRS.has(entry.name)) {
50
+ walk(join(currentDir, entry.name), currentDepth + 1);
51
+ }
52
+ }
53
+ } catch {
54
+ // 忽略无法读取的目录
55
+ }
56
+ }
57
+
58
+ walk(resolvedDir, 1);
59
+ return results;
60
+ }
61
+
62
+ export function getRegistryFilePath(customHome?: string): string {
63
+ const baseDir = getActionDockHome(customHome);
64
+ return join(baseDir, ".actiondock", "registry.json");
65
+ }
66
+
67
+ export function loadRegistry(customHome?: string): GlobalRegistryData {
68
+ const filePath = getRegistryFilePath(customHome);
69
+ if (!existsSync(filePath)) {
70
+ return { version: "2.0.0", packages: {}, workspaces: {} };
71
+ }
72
+ try {
73
+ const raw = readFileSync(filePath, "utf-8");
74
+ const parsed = JSON.parse(raw);
75
+ if (!parsed || typeof parsed !== "object" || !parsed.packages) {
76
+ return { version: "2.0.0", packages: {}, workspaces: {} };
77
+ }
78
+ return {
79
+ version: "2.0.0",
80
+ packages: parsed.packages || {},
81
+ workspaces: parsed.workspaces || {},
82
+ };
83
+ } catch {
84
+ return { version: "2.0.0", packages: {}, workspaces: {} };
85
+ }
86
+ }
87
+
88
+ export function saveRegistry(data: GlobalRegistryData, customHome?: string): void {
89
+ const filePath = getRegistryFilePath(customHome);
90
+ mkdirSync(dirname(filePath), { recursive: true });
91
+ writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
92
+ }
93
+
94
+ export function linkPackage(
95
+ targetPath: string = process.cwd(),
96
+ customHome?: string,
97
+ options?: { recursive?: boolean }
98
+ ): LinkResult {
99
+ const absPath = resolve(targetPath);
100
+ const directHasConfig = existsSync(join(absPath, "actiondock.json"));
101
+
102
+ // 1. 如果当前目录直接包含 actiondock.json 且未强制递归,按单包链接
103
+ if (directHasConfig && !options?.recursive) {
104
+ const config = loadProjectConfig(absPath);
105
+ const registry = loadRegistry(customHome);
106
+
107
+ const entry: LinkedPackageEntry = {
108
+ id: config.id,
109
+ name: config.name,
110
+ version: config.version,
111
+ path: absPath,
112
+ linkedAt: new Date().toISOString(),
113
+ };
114
+
115
+ registry.packages[config.id] = entry;
116
+ saveRegistry(registry, customHome);
117
+
118
+ return {
119
+ id: config.id,
120
+ name: config.name,
121
+ version: config.version,
122
+ path: absPath,
123
+ linkedAt: entry.linkedAt,
124
+ isWorkspace: false,
125
+ entries: [entry],
126
+ };
127
+ }
128
+
129
+ // 2. 尝试扫描子目录发现多个 ActionDock 子项目(Workspace 模式)
130
+ const discoveredRoots = discoverProjects(absPath);
131
+
132
+ if (discoveredRoots.length > 0) {
133
+ const registry = loadRegistry(customHome);
134
+ const now = new Date().toISOString();
135
+ const wsEntry: LinkedWorkspaceEntry = {
136
+ path: absPath,
137
+ linkedAt: now,
138
+ };
139
+
140
+ registry.workspaces = registry.workspaces || {};
141
+ registry.workspaces[absPath] = wsEntry;
142
+
143
+ const linkedEntries: LinkedPackageEntry[] = [];
144
+
145
+ for (const root of discoveredRoots) {
146
+ try {
147
+ const config = loadProjectConfig(root);
148
+ const entry: LinkedPackageEntry = {
149
+ id: config.id,
150
+ name: config.name,
151
+ version: config.version,
152
+ path: root,
153
+ linkedAt: now,
154
+ workspaceRoot: absPath,
155
+ };
156
+ registry.packages[config.id] = entry;
157
+ linkedEntries.push(entry);
158
+ } catch {
159
+ // 忽略异常项目
160
+ }
161
+ }
162
+
163
+ saveRegistry(registry, customHome);
164
+
165
+ const wsName = basename(absPath);
166
+ return {
167
+ id: wsName,
168
+ name: wsName,
169
+ version: "2.0.0",
170
+ path: absPath,
171
+ linkedAt: now,
172
+ isWorkspace: true,
173
+ entries: linkedEntries,
174
+ workspace: wsEntry,
175
+ };
176
+ }
177
+
178
+ // 3. 回退检查:如果在子目录执行(例如在 package 的 actions/ 目录下),查找父级项目根目录
179
+ const parentRoot = findProjectRoot(absPath);
180
+ if (parentRoot) {
181
+ const config = loadProjectConfig(parentRoot);
182
+ const registry = loadRegistry(customHome);
183
+
184
+ const entry: LinkedPackageEntry = {
185
+ id: config.id,
186
+ name: config.name,
187
+ version: config.version,
188
+ path: parentRoot,
189
+ linkedAt: new Date().toISOString(),
190
+ };
191
+
192
+ registry.packages[config.id] = entry;
193
+ saveRegistry(registry, customHome);
194
+
195
+ return {
196
+ id: config.id,
197
+ name: config.name,
198
+ version: config.version,
199
+ path: parentRoot,
200
+ linkedAt: entry.linkedAt,
201
+ isWorkspace: false,
202
+ entries: [entry],
203
+ };
204
+ }
205
+
206
+ throw new Error(`Cannot link: actiondock.json not found in '${absPath}' or its subdirectories`);
207
+ }
208
+
209
+ export function unlinkPackage(
210
+ identifier: string = process.cwd(),
211
+ customHome?: string
212
+ ): UnlinkResult | null {
213
+ const registry = loadRegistry(customHome);
214
+ const absPath = resolve(identifier);
215
+
216
+ // 1. 检查是否匹配 Workspace 绝对路径
217
+ if (registry.workspaces && registry.workspaces[absPath]) {
218
+ const removedWs = registry.workspaces[absPath];
219
+ delete registry.workspaces[absPath];
220
+
221
+ let removedCount = 0;
222
+ for (const [id, entry] of Object.entries(registry.packages)) {
223
+ if (entry.workspaceRoot === absPath || entry.path.startsWith(absPath)) {
224
+ delete registry.packages[id];
225
+ removedCount++;
226
+ }
227
+ }
228
+ saveRegistry(registry, customHome);
229
+ return {
230
+ type: "workspace",
231
+ id: basename(absPath),
232
+ path: absPath,
233
+ packagesCount: removedCount,
234
+ removedWorkspace: removedWs,
235
+ };
236
+ }
237
+
238
+ // 2. 检查是否匹配 Workspace 目录别名
239
+ if (registry.workspaces) {
240
+ for (const [wsPath, wsEntry] of Object.entries(registry.workspaces)) {
241
+ if (basename(wsPath) === identifier) {
242
+ delete registry.workspaces[wsPath];
243
+ let removedCount = 0;
244
+ for (const [id, entry] of Object.entries(registry.packages)) {
245
+ if (entry.workspaceRoot === wsPath || entry.path.startsWith(wsPath)) {
246
+ delete registry.packages[id];
247
+ removedCount++;
248
+ }
249
+ }
250
+ saveRegistry(registry, customHome);
251
+ return {
252
+ type: "workspace",
253
+ id: basename(wsPath),
254
+ path: wsPath,
255
+ packagesCount: removedCount,
256
+ removedWorkspace: wsEntry,
257
+ };
258
+ }
259
+ }
260
+ }
261
+
262
+ // 3. 检查是否直接匹配 Package ID
263
+ let targetKey: string | undefined;
264
+ if (registry.packages[identifier]) {
265
+ targetKey = identifier;
266
+ } else {
267
+ // 匹配路径或短 slug
268
+ for (const [id, entry] of Object.entries(registry.packages)) {
269
+ if (
270
+ entry.path === absPath ||
271
+ entry.id === identifier ||
272
+ getPackageSlug(entry.id) === identifier
273
+ ) {
274
+ targetKey = id;
275
+ break;
276
+ }
277
+ }
278
+ }
279
+
280
+ if (!targetKey) {
281
+ return null;
282
+ }
283
+
284
+ const removed = registry.packages[targetKey];
285
+ delete registry.packages[targetKey];
286
+ saveRegistry(registry, customHome);
287
+ return {
288
+ type: "package",
289
+ id: removed.id,
290
+ path: removed.path,
291
+ packagesCount: 1,
292
+ removedPackage: removed,
293
+ };
294
+ }
295
+
296
+ export function listLinkedPackages(customHome?: string): LinkedPackageEntry[] {
297
+ const registry = loadRegistry(customHome);
298
+ const result: Record<string, LinkedPackageEntry> = { ...registry.packages };
299
+
300
+ // 动态扫描已挂载的 Workspace 目录,确保新拉取/新建的子包即时感知
301
+ if (registry.workspaces) {
302
+ for (const ws of Object.values(registry.workspaces)) {
303
+ if (!existsSync(ws.path)) continue;
304
+ const discovered = discoverProjects(ws.path);
305
+ for (const root of discovered) {
306
+ try {
307
+ const config = loadProjectConfig(root);
308
+ if (!result[config.id] || result[config.id].workspaceRoot === ws.path) {
309
+ result[config.id] = {
310
+ id: config.id,
311
+ name: config.name,
312
+ version: config.version,
313
+ path: root,
314
+ linkedAt: ws.linkedAt,
315
+ workspaceRoot: ws.path,
316
+ };
317
+ }
318
+ } catch {
319
+ // 忽略异常项目
320
+ }
321
+ }
322
+ }
323
+ }
324
+
325
+ return Object.values(result);
326
+ }
327
+
328
+ export function listLinkedWorkspaces(customHome?: string): LinkedWorkspaceEntry[] {
329
+ const registry = loadRegistry(customHome);
330
+ return Object.values(registry.workspaces || {});
331
+ }
332
+
333
+ export async function resolveActionProject(
334
+ actionIdentifier: string,
335
+ cwd: string = process.cwd(),
336
+ customHome?: string
337
+ ): Promise<ResolvedActionProject> {
338
+ // 1. Check current directory / parent project
339
+ const currentRoot = findProjectRoot(cwd);
340
+ if (currentRoot) {
341
+ try {
342
+ const config = loadProjectConfig(currentRoot);
343
+ const actions = await loadActions(currentRoot, config.actionsDir);
344
+ if (actions.has(actionIdentifier)) {
345
+ return {
346
+ projectRoot: currentRoot,
347
+ packageId: config.id,
348
+ actionId: actionIdentifier,
349
+ };
350
+ }
351
+ } catch {
352
+ // Ignore and proceed to registry lookup
353
+ }
354
+ }
355
+
356
+ // 2. Check if scoped format: <package-id>/<action-id> or <package-id>:<action-id>
357
+ let targetPackage: string | undefined;
358
+ let pureActionId = actionIdentifier;
359
+
360
+ if (actionIdentifier.includes("/")) {
361
+ const slashIdx = actionIdentifier.indexOf("/");
362
+ targetPackage = actionIdentifier.slice(0, slashIdx);
363
+ pureActionId = actionIdentifier.slice(slashIdx + 1);
364
+ } else if (actionIdentifier.includes(":")) {
365
+ const colonIdx = actionIdentifier.indexOf(":");
366
+ targetPackage = actionIdentifier.slice(0, colonIdx);
367
+ pureActionId = actionIdentifier.slice(colonIdx + 1);
368
+ }
369
+
370
+ const linkedList = listLinkedPackages(customHome);
371
+
372
+ if (targetPackage) {
373
+ const pkg = linkedList.find(
374
+ (p) => p.id === targetPackage || getPackageSlug(p.id) === targetPackage
375
+ );
376
+
377
+ if (!pkg || !existsSync(pkg.path)) {
378
+ throw new Error(
379
+ `Linked package '${targetPackage}' not found or path no longer exists (${pkg?.path || "unregistered"}). Run 'ac link' in the package directory.`
380
+ );
381
+ }
382
+
383
+ const config = loadProjectConfig(pkg.path);
384
+ const actions = await loadActions(pkg.path, config.actionsDir);
385
+ if (!actions.has(pureActionId)) {
386
+ throw new Error(`Action '${pureActionId}' not found in package '${pkg.id}' (${pkg.path})`);
387
+ }
388
+
389
+ return {
390
+ projectRoot: pkg.path,
391
+ packageId: pkg.id,
392
+ actionId: pureActionId,
393
+ };
394
+ }
395
+
396
+ // 3. Search across all linked packages
397
+ const matches: Array<{ entry: LinkedPackageEntry; actionId: string }> = [];
398
+
399
+ for (const pkg of linkedList) {
400
+ if (!existsSync(pkg.path)) continue;
401
+ try {
402
+ const config = loadProjectConfig(pkg.path);
403
+ const actions = await loadActions(pkg.path, config.actionsDir);
404
+ if (actions.has(actionIdentifier)) {
405
+ matches.push({ entry: pkg, actionId: actionIdentifier });
406
+ }
407
+ } catch {
408
+ // Ignore invalid linked package
409
+ }
410
+ }
411
+
412
+ if (matches.length === 1) {
413
+ return {
414
+ projectRoot: matches[0].entry.path,
415
+ packageId: matches[0].entry.id,
416
+ actionId: matches[0].actionId,
417
+ };
418
+ }
419
+
420
+ if (matches.length > 1) {
421
+ const pkgList = matches.map((m) => `'${m.entry.id}'`).join(", ");
422
+ throw new Error(
423
+ `Action '${actionIdentifier}' is provided by multiple linked packages: ${pkgList}. Please specify using '<package-id>/${actionIdentifier}'.`
424
+ );
425
+ }
426
+
427
+ if (currentRoot) {
428
+ throw new Error(`Action '${actionIdentifier}' not found in current project or any linked packages`);
429
+ } else {
430
+ throw new Error(
431
+ `Action '${actionIdentifier}' not found. You are not in an ActionDock project, and no linked package provides '${actionIdentifier}'. Use 'ac link' to register your package.`
432
+ );
433
+ }
434
+ }
435
+
436
+ export function resolvePackageRoot(
437
+ packageIdOrPath?: string,
438
+ cwd?: string,
439
+ customHome?: string
440
+ ): string | null {
441
+ if (packageIdOrPath) {
442
+ const directRoot = findProjectRoot(packageIdOrPath);
443
+ if (directRoot) return directRoot;
444
+
445
+ const linkedList = listLinkedPackages(customHome);
446
+ const found = linkedList.find(
447
+ (p) =>
448
+ p.id === packageIdOrPath ||
449
+ getPackageSlug(p.id) === packageIdOrPath ||
450
+ p.path === resolve(packageIdOrPath)
451
+ );
452
+ if (found) {
453
+ return found.path;
454
+ }
455
+ return null;
456
+ }
457
+
458
+ return findProjectRoot(cwd);
459
+ }
460
+
461
+ export function resolvePlaybookProject(
462
+ playbookIdentifier: string,
463
+ cwd: string = process.cwd(),
464
+ customHome?: string
465
+ ): ResolvedPlaybookProject {
466
+ // 1. Check current directory / parent project
467
+ const currentRoot = findProjectRoot(cwd);
468
+ if (currentRoot) {
469
+ try {
470
+ const config = loadProjectConfig(currentRoot);
471
+ const playbooks = loadPlaybooks(currentRoot, config.playbooksDir);
472
+ if (playbooks.has(playbookIdentifier)) {
473
+ return {
474
+ projectRoot: currentRoot,
475
+ packageId: config.id,
476
+ playbookId: playbookIdentifier,
477
+ playbook: playbooks.get(playbookIdentifier)!,
478
+ };
479
+ }
480
+ } catch {
481
+ // Ignore and proceed to registry lookup
482
+ }
483
+ }
484
+
485
+ // 2. Check if scoped format: <package-id>/<playbook-id> or <package-id>:<playbook-id>
486
+ let targetPackage: string | undefined;
487
+ let purePlaybookId = playbookIdentifier;
488
+
489
+ if (playbookIdentifier.includes("/")) {
490
+ const slashIdx = playbookIdentifier.indexOf("/");
491
+ targetPackage = playbookIdentifier.slice(0, slashIdx);
492
+ purePlaybookId = playbookIdentifier.slice(slashIdx + 1);
493
+ } else if (playbookIdentifier.includes(":")) {
494
+ const colonIdx = playbookIdentifier.indexOf(":");
495
+ targetPackage = playbookIdentifier.slice(0, colonIdx);
496
+ purePlaybookId = playbookIdentifier.slice(colonIdx + 1);
497
+ }
498
+
499
+ const linkedList = listLinkedPackages(customHome);
500
+
501
+ if (targetPackage) {
502
+ const pkg = linkedList.find(
503
+ (p) => p.id === targetPackage || getPackageSlug(p.id) === targetPackage
504
+ );
505
+
506
+ if (!pkg || !existsSync(pkg.path)) {
507
+ throw new Error(
508
+ `Linked package '${targetPackage}' not found or path no longer exists (${pkg?.path || "unregistered"}). Run 'ac link' in the package directory.`
509
+ );
510
+ }
511
+
512
+ const config = loadProjectConfig(pkg.path);
513
+ const playbooks = loadPlaybooks(pkg.path, config.playbooksDir);
514
+ const pb = playbooks.get(purePlaybookId);
515
+ if (!pb) {
516
+ throw new Error(`Playbook '${purePlaybookId}' not found in package '${pkg.id}' (${pkg.path})`);
517
+ }
518
+
519
+ return {
520
+ projectRoot: pkg.path,
521
+ packageId: pkg.id,
522
+ playbookId: purePlaybookId,
523
+ playbook: pb,
524
+ };
525
+ }
526
+
527
+ // 3. Search across all linked packages
528
+ const matches: Array<{ entry: LinkedPackageEntry; playbookId: string; playbook: import("../project/types").PlaybookDefinition }> = [];
529
+
530
+ for (const pkg of linkedList) {
531
+ if (!existsSync(pkg.path)) continue;
532
+ try {
533
+ const config = loadProjectConfig(pkg.path);
534
+ const playbooks = loadPlaybooks(pkg.path, config.playbooksDir);
535
+ if (playbooks.has(playbookIdentifier)) {
536
+ matches.push({
537
+ entry: pkg,
538
+ playbookId: playbookIdentifier,
539
+ playbook: playbooks.get(playbookIdentifier)!,
540
+ });
541
+ }
542
+ } catch {
543
+ // Ignore invalid linked package
544
+ }
545
+ }
546
+
547
+ if (matches.length === 1) {
548
+ return {
549
+ projectRoot: matches[0].entry.path,
550
+ packageId: matches[0].entry.id,
551
+ playbookId: matches[0].playbookId,
552
+ playbook: matches[0].playbook,
553
+ };
554
+ }
555
+
556
+ if (matches.length > 1) {
557
+ const pkgList = matches.map((m) => `'${m.entry.id}'`).join(", ");
558
+ throw new Error(
559
+ `Playbook '${playbookIdentifier}' is provided by multiple linked packages: ${pkgList}. Please specify using '<package-id>/${playbookIdentifier}'.`
560
+ );
561
+ }
562
+
563
+ if (currentRoot) {
564
+ throw new Error(`Playbook '${playbookIdentifier}' not found in current project or any linked packages`);
565
+ } else {
566
+ throw new Error(
567
+ `Playbook '${playbookIdentifier}' not found. You are not in an ActionDock project, and no linked package provides '${playbookIdentifier}'. Use 'ac link' to register your package.`
568
+ );
569
+ }
570
+ }
571
+
572
+ export function getRegistryStatus(customHome?: string): RegistryStatusReport {
573
+ const registry = loadRegistry(customHome);
574
+ const workspaces: RegistryTreeItem[] = [];
575
+ const packages: RegistryTreeItem[] = [];
576
+ let staleCount = 0;
577
+ const seenPackageIds = new Set<string>();
578
+
579
+ // 1. Process workspaces
580
+ if (registry.workspaces) {
581
+ for (const [wsPath, wsEntry] of Object.entries(registry.workspaces)) {
582
+ const isWsActive = existsSync(wsPath);
583
+ if (!isWsActive) {
584
+ staleCount++;
585
+ workspaces.push({
586
+ type: "workspace",
587
+ id: basename(wsPath),
588
+ path: wsPath,
589
+ status: "stale",
590
+ packagesCount: 0,
591
+ children: [],
592
+ });
593
+ continue;
594
+ }
595
+
596
+ const discoveredRoots = discoverProjects(wsPath);
597
+ const children: NonNullable<RegistryTreeItem["children"]> = [];
598
+
599
+ for (const root of discoveredRoots) {
600
+ try {
601
+ const config = loadProjectConfig(root);
602
+ seenPackageIds.add(config.id);
603
+ children.push({
604
+ id: config.id,
605
+ name: config.name,
606
+ version: config.version,
607
+ path: root,
608
+ status: "active",
609
+ });
610
+ } catch {
611
+ // ignore broken project
612
+ }
613
+ }
614
+
615
+ workspaces.push({
616
+ type: "workspace",
617
+ id: basename(wsPath),
618
+ path: wsPath,
619
+ status: "active",
620
+ packagesCount: children.length,
621
+ children,
622
+ });
623
+ }
624
+ }
625
+
626
+ // 2. Process standalone packages (not part of an active workspace)
627
+ for (const [pkgId, pkgEntry] of Object.entries(registry.packages)) {
628
+ if (pkgEntry.workspaceRoot && registry.workspaces && registry.workspaces[pkgEntry.workspaceRoot]) {
629
+ continue;
630
+ }
631
+ if (seenPackageIds.has(pkgId)) {
632
+ continue;
633
+ }
634
+
635
+ const isPkgActive = existsSync(pkgEntry.path);
636
+ if (!isPkgActive) {
637
+ staleCount++;
638
+ packages.push({
639
+ type: "package",
640
+ id: pkgEntry.id,
641
+ name: pkgEntry.name,
642
+ version: pkgEntry.version,
643
+ path: pkgEntry.path,
644
+ status: "stale",
645
+ });
646
+ } else {
647
+ packages.push({
648
+ type: "package",
649
+ id: pkgEntry.id,
650
+ name: pkgEntry.name,
651
+ version: pkgEntry.version,
652
+ path: pkgEntry.path,
653
+ status: "active",
654
+ });
655
+ }
656
+ }
657
+
658
+ const totalPackagesCount =
659
+ workspaces.reduce((acc, ws) => acc + (ws.children?.length || 0), 0) +
660
+ packages.filter((p) => p.status === "active").length;
661
+
662
+ return {
663
+ workspaces,
664
+ packages,
665
+ staleCount,
666
+ totalPackagesCount,
667
+ };
668
+ }
669
+
670
+ export function pruneRegistry(customHome?: string): PruneResult {
671
+ const registry = loadRegistry(customHome);
672
+ const prunedWorkspaces: LinkedWorkspaceEntry[] = [];
673
+ const prunedPackages: LinkedPackageEntry[] = [];
674
+
675
+ // 1. Prune workspaces
676
+ if (registry.workspaces) {
677
+ for (const [wsPath, wsEntry] of Object.entries(registry.workspaces)) {
678
+ if (!existsSync(wsPath)) {
679
+ prunedWorkspaces.push(wsEntry);
680
+ delete registry.workspaces[wsPath];
681
+ }
682
+ }
683
+ }
684
+
685
+ // 2. Prune packages
686
+ for (const [pkgId, pkgEntry] of Object.entries(registry.packages)) {
687
+ if (!existsSync(pkgEntry.path)) {
688
+ prunedPackages.push(pkgEntry);
689
+ delete registry.packages[pkgId];
690
+ }
691
+ }
692
+
693
+ if (prunedWorkspaces.length > 0 || prunedPackages.length > 0) {
694
+ saveRegistry(registry, customHome);
695
+ }
696
+
697
+ return {
698
+ prunedPackages,
699
+ prunedWorkspaces,
700
+ };
701
+ }
702
+
703
+