busy-cli 0.1.2

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 (128) hide show
  1. package/README.md +129 -0
  2. package/dist/builders/context.d.ts +50 -0
  3. package/dist/builders/context.d.ts.map +1 -0
  4. package/dist/builders/context.js +190 -0
  5. package/dist/cache/index.d.ts +100 -0
  6. package/dist/cache/index.d.ts.map +1 -0
  7. package/dist/cache/index.js +270 -0
  8. package/dist/cli/index.d.ts +3 -0
  9. package/dist/cli/index.d.ts.map +1 -0
  10. package/dist/cli/index.js +463 -0
  11. package/dist/commands/package.d.ts +96 -0
  12. package/dist/commands/package.d.ts.map +1 -0
  13. package/dist/commands/package.js +285 -0
  14. package/dist/index.d.ts +7 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +7 -0
  17. package/dist/loader.d.ts +6 -0
  18. package/dist/loader.d.ts.map +1 -0
  19. package/dist/loader.js +361 -0
  20. package/dist/merge.d.ts +16 -0
  21. package/dist/merge.d.ts.map +1 -0
  22. package/dist/merge.js +102 -0
  23. package/dist/package/manifest.d.ts +59 -0
  24. package/dist/package/manifest.d.ts.map +1 -0
  25. package/dist/package/manifest.js +265 -0
  26. package/dist/parser.d.ts +28 -0
  27. package/dist/parser.d.ts.map +1 -0
  28. package/dist/parser.js +220 -0
  29. package/dist/parsers/frontmatter.d.ts +14 -0
  30. package/dist/parsers/frontmatter.d.ts.map +1 -0
  31. package/dist/parsers/frontmatter.js +110 -0
  32. package/dist/parsers/imports.d.ts +48 -0
  33. package/dist/parsers/imports.d.ts.map +1 -0
  34. package/dist/parsers/imports.js +147 -0
  35. package/dist/parsers/links.d.ts +12 -0
  36. package/dist/parsers/links.d.ts.map +1 -0
  37. package/dist/parsers/links.js +79 -0
  38. package/dist/parsers/localdefs.d.ts +6 -0
  39. package/dist/parsers/localdefs.d.ts.map +1 -0
  40. package/dist/parsers/localdefs.js +132 -0
  41. package/dist/parsers/operations.d.ts +32 -0
  42. package/dist/parsers/operations.d.ts.map +1 -0
  43. package/dist/parsers/operations.js +313 -0
  44. package/dist/parsers/sections.d.ts +15 -0
  45. package/dist/parsers/sections.d.ts.map +1 -0
  46. package/dist/parsers/sections.js +173 -0
  47. package/dist/parsers/tools.d.ts +30 -0
  48. package/dist/parsers/tools.d.ts.map +1 -0
  49. package/dist/parsers/tools.js +178 -0
  50. package/dist/parsers/triggers.d.ts +35 -0
  51. package/dist/parsers/triggers.d.ts.map +1 -0
  52. package/dist/parsers/triggers.js +219 -0
  53. package/dist/providers/base.d.ts +60 -0
  54. package/dist/providers/base.d.ts.map +1 -0
  55. package/dist/providers/base.js +34 -0
  56. package/dist/providers/github.d.ts +18 -0
  57. package/dist/providers/github.d.ts.map +1 -0
  58. package/dist/providers/github.js +109 -0
  59. package/dist/providers/gitlab.d.ts +18 -0
  60. package/dist/providers/gitlab.d.ts.map +1 -0
  61. package/dist/providers/gitlab.js +101 -0
  62. package/dist/providers/index.d.ts +13 -0
  63. package/dist/providers/index.d.ts.map +1 -0
  64. package/dist/providers/index.js +17 -0
  65. package/dist/providers/local.d.ts +31 -0
  66. package/dist/providers/local.d.ts.map +1 -0
  67. package/dist/providers/local.js +116 -0
  68. package/dist/providers/url.d.ts +16 -0
  69. package/dist/providers/url.d.ts.map +1 -0
  70. package/dist/providers/url.js +45 -0
  71. package/dist/registry/index.d.ts +99 -0
  72. package/dist/registry/index.d.ts.map +1 -0
  73. package/dist/registry/index.js +320 -0
  74. package/dist/types/schema.d.ts +3259 -0
  75. package/dist/types/schema.d.ts.map +1 -0
  76. package/dist/types/schema.js +258 -0
  77. package/dist/utils/logger.d.ts +19 -0
  78. package/dist/utils/logger.d.ts.map +1 -0
  79. package/dist/utils/logger.js +23 -0
  80. package/dist/utils/slugify.d.ts +14 -0
  81. package/dist/utils/slugify.d.ts.map +1 -0
  82. package/dist/utils/slugify.js +28 -0
  83. package/package.json +61 -0
  84. package/src/__tests__/cache.test.ts +393 -0
  85. package/src/__tests__/cli-package.test.ts +667 -0
  86. package/src/__tests__/fixtures/automated-workflow.busy.md +84 -0
  87. package/src/__tests__/fixtures/concept.busy.md +30 -0
  88. package/src/__tests__/fixtures/document.busy.md +44 -0
  89. package/src/__tests__/fixtures/simple-operation.busy.md +45 -0
  90. package/src/__tests__/fixtures/tool-document.busy.md +71 -0
  91. package/src/__tests__/fixtures/tool.busy.md +54 -0
  92. package/src/__tests__/imports.test.ts +244 -0
  93. package/src/__tests__/integration.test.ts +432 -0
  94. package/src/__tests__/operations.test.ts +408 -0
  95. package/src/__tests__/package-manifest.test.ts +455 -0
  96. package/src/__tests__/providers.test.ts +672 -0
  97. package/src/__tests__/registry.test.ts +402 -0
  98. package/src/__tests__/schema.test.ts +467 -0
  99. package/src/__tests__/tools.test.ts +376 -0
  100. package/src/__tests__/triggers.test.ts +312 -0
  101. package/src/builders/context.ts +294 -0
  102. package/src/cache/index.ts +312 -0
  103. package/src/cli/index.ts +514 -0
  104. package/src/commands/package.ts +392 -0
  105. package/src/index.ts +46 -0
  106. package/src/loader.ts +474 -0
  107. package/src/merge.ts +126 -0
  108. package/src/package/manifest.ts +349 -0
  109. package/src/parser.ts +278 -0
  110. package/src/parsers/frontmatter.ts +135 -0
  111. package/src/parsers/imports.ts +196 -0
  112. package/src/parsers/links.ts +108 -0
  113. package/src/parsers/localdefs.ts +166 -0
  114. package/src/parsers/operations.ts +404 -0
  115. package/src/parsers/sections.ts +230 -0
  116. package/src/parsers/tools.ts +215 -0
  117. package/src/parsers/triggers.ts +252 -0
  118. package/src/providers/base.ts +77 -0
  119. package/src/providers/github.ts +129 -0
  120. package/src/providers/gitlab.ts +121 -0
  121. package/src/providers/index.ts +25 -0
  122. package/src/providers/local.ts +129 -0
  123. package/src/providers/url.ts +56 -0
  124. package/src/registry/index.ts +408 -0
  125. package/src/types/schema.ts +369 -0
  126. package/src/utils/logger.ts +25 -0
  127. package/src/utils/slugify.ts +31 -0
  128. package/tsconfig.json +21 -0
@@ -0,0 +1,294 @@
1
+ import { Repo, ContextPayload, Section, LocalDef, Operation, ConceptBase } from '../types/schema.js';
2
+ import { debug, warn } from '../utils/logger.js';
3
+
4
+ export interface BuildOpts {
5
+ includeChildren?: boolean;
6
+ maxDefChars?: number;
7
+ }
8
+
9
+ /**
10
+ * Build minimal execution context for an operation
11
+ */
12
+ export function buildContext(
13
+ repo: Repo,
14
+ opRef: string,
15
+ opts: BuildOpts = {}
16
+ ): ContextPayload {
17
+ debug.context('Building context for operation: %s', opRef);
18
+
19
+ const { includeChildren = false, maxDefChars } = opts;
20
+
21
+ // 1. Seed: resolve opRef to a Section or Operation
22
+ const opNode = repo.byId[opRef] as Section | Operation | undefined;
23
+
24
+ if (!opNode || (opNode.kind !== 'section' && opNode.kind !== 'operation')) {
25
+ throw new Error(`Operation not found: ${opRef}`);
26
+ }
27
+
28
+ // Get the full Operation object
29
+ let operation: Operation;
30
+ if (opNode.kind === 'operation') {
31
+ operation = opNode;
32
+ } else {
33
+ // If it's a section, we need to find the operation or throw
34
+ throw new Error(`Expected operation but got section: ${opRef}`);
35
+ }
36
+
37
+ // 2. Collect outgoing edges from the operation section
38
+ const edges = repo.edges.filter((edge) => {
39
+ if (edge.from === opNode.id) {
40
+ return true;
41
+ }
42
+
43
+ // Optionally include edges from child sections
44
+ if (includeChildren) {
45
+ return edge.from.startsWith(`${opNode.docId}#`);
46
+ }
47
+
48
+ return false;
49
+ });
50
+
51
+ debug.context('Found %d outgoing edges', edges.length);
52
+
53
+ // 3. Calls (just array of concept IDs)
54
+ const calls: string[] = [];
55
+
56
+ for (const edge of edges) {
57
+ if (edge.role === 'calls') {
58
+ calls.push(edge.to);
59
+ }
60
+ }
61
+
62
+ debug.context('Found %d calls', calls.length);
63
+
64
+ // 4. Symbols
65
+ // Get the document's import symbol table
66
+ const fileInfo = repo.byFile[opNode.docId];
67
+ const docImports = repo.imports.filter((imp) => imp.docId === opNode.docId);
68
+
69
+ const symbols: Record<string, { docId?: string; slug?: string }> = {};
70
+
71
+ for (const imp of docImports) {
72
+ if (imp.resolved) {
73
+ // Parse ConceptId string back to { docId, slug }
74
+ const parts = imp.resolved.split('#');
75
+ symbols[imp.label] = {
76
+ docId: parts[0],
77
+ slug: parts[1],
78
+ };
79
+ }
80
+ }
81
+
82
+ const payload: ContextPayload = {
83
+ operation,
84
+ calls,
85
+ symbols,
86
+ };
87
+
88
+ debug.context('Context built successfully');
89
+
90
+ return payload;
91
+ }
92
+
93
+ /**
94
+ * Trim content to max characters, preserving structure
95
+ */
96
+ function trimContent(content: string, maxChars: number): string {
97
+ if (content.length <= maxChars) {
98
+ return content;
99
+ }
100
+
101
+ // Try to preserve headings and code fences
102
+ const lines = content.split('\n');
103
+ let result = '';
104
+ let charCount = 0;
105
+
106
+ for (const line of lines) {
107
+ if (charCount + line.length > maxChars) {
108
+ result += '\n\n[... trimmed ...]';
109
+ break;
110
+ }
111
+
112
+ result += line + '\n';
113
+ charCount += line.length + 1;
114
+ }
115
+
116
+ return result.trim();
117
+ }
118
+
119
+ /**
120
+ * Lookup helpers
121
+ */
122
+ export function get(
123
+ repo: Repo,
124
+ ref: string
125
+ ): Section | LocalDef | Operation | ConceptBase | undefined {
126
+ return repo.byId[ref];
127
+ }
128
+
129
+ export function parentsOf(repo: Repo, nameOrRef: string): string[] {
130
+ const edges = repo.edges.filter(
131
+ (edge) => edge.from === nameOrRef && edge.role === 'extends'
132
+ );
133
+ return edges.map((edge) => edge.to);
134
+ }
135
+
136
+ export function childrenOf(repo: Repo, nameOrRef: string): string[] {
137
+ const edges = repo.edges.filter(
138
+ (edge) => edge.to === nameOrRef && edge.role === 'extends'
139
+ );
140
+ return edges.map((edge) => edge.from);
141
+ }
142
+
143
+ /**
144
+ * Context for a specific concept
145
+ */
146
+ export interface ConceptContext {
147
+ concept: Section | LocalDef | Operation | ConceptBase;
148
+ // Outgoing edges grouped by role
149
+ calls: string[]; // concepts this concept calls
150
+ extends: string[]; // concepts this concept extends
151
+ imports: string[]; // concepts this concept imports
152
+ refs: string[]; // concepts this concept references
153
+ // Incoming edges grouped by role
154
+ calledBy: string[]; // concepts that call this concept
155
+ extendedBy: string[]; // concepts that extend this concept
156
+ importedBy: string[]; // concepts that import this concept
157
+ referencedBy: string[]; // concepts that reference this concept
158
+ // All edges for advanced usage
159
+ allEdges: {
160
+ outgoing: Array<{ to: string; role: string }>;
161
+ incoming: Array<{ from: string; role: string }>;
162
+ };
163
+ // Content dictionary for imported and referenced concepts
164
+ contentMap: Record<string, string>; // conceptId -> content
165
+ }
166
+
167
+ /**
168
+ * Get comprehensive context for any concept by ID
169
+ * Returns all relationships (calls, extends, imports, refs) both outgoing and incoming
170
+ */
171
+ export function getConceptContext(
172
+ repo: Repo,
173
+ conceptId: string
174
+ ): ConceptContext {
175
+ debug.context('Building concept context for: %s', conceptId);
176
+
177
+ // 1. Get the concept
178
+ const concept = repo.byId[conceptId];
179
+ if (!concept) {
180
+ throw new Error(`Concept not found: ${conceptId}`);
181
+ }
182
+
183
+ // 2. Get all outgoing edges
184
+ // For operations and localdefs, also include edges from their parent section and document
185
+ let edgeSources = [conceptId];
186
+
187
+ if (concept.kind === 'operation' || concept.kind === 'localdef') {
188
+ // Add the section
189
+ if ('sectionRef' in concept && concept.sectionRef) {
190
+ edgeSources.push(concept.sectionRef);
191
+ }
192
+ // Add the document
193
+ if ('docId' in concept && concept.docId) {
194
+ edgeSources.push(concept.docId);
195
+ }
196
+ }
197
+
198
+ const outgoingEdges = repo.edges.filter((edge) => edgeSources.includes(edge.from));
199
+
200
+ // 3. Get all incoming edges
201
+ const incomingEdges = repo.edges.filter((edge) => edge.to === conceptId);
202
+
203
+ // 4. Group outgoing edges by role
204
+ const calls = outgoingEdges
205
+ .filter((e) => e.role === 'calls')
206
+ .map((e) => e.to);
207
+
208
+ const extendsEdges = outgoingEdges
209
+ .filter((e) => e.role === 'extends')
210
+ .map((e) => e.to);
211
+
212
+ const importsEdges = outgoingEdges
213
+ .filter((e) => e.role === 'imports')
214
+ .map((e) => e.to);
215
+
216
+ const refs = outgoingEdges
217
+ .filter((e) => e.role === 'ref')
218
+ .map((e) => e.to);
219
+
220
+ // 5. Group incoming edges by role
221
+ const calledBy = incomingEdges
222
+ .filter((e) => e.role === 'calls')
223
+ .map((e) => e.from);
224
+
225
+ const extendedBy = incomingEdges
226
+ .filter((e) => e.role === 'extends')
227
+ .map((e) => e.from);
228
+
229
+ const importedBy = incomingEdges
230
+ .filter((e) => e.role === 'imports')
231
+ .map((e) => e.from);
232
+
233
+ const referencedBy = incomingEdges
234
+ .filter((e) => e.role === 'ref')
235
+ .map((e) => e.from);
236
+
237
+ debug.context(
238
+ 'Found: %d calls, %d extends, %d imports, %d refs (outgoing)',
239
+ calls.length,
240
+ extendsEdges.length,
241
+ importsEdges.length,
242
+ refs.length
243
+ );
244
+
245
+ debug.context(
246
+ 'Found: %d calledBy, %d extendedBy, %d importedBy, %d referencedBy (incoming)',
247
+ calledBy.length,
248
+ extendedBy.length,
249
+ importedBy.length,
250
+ referencedBy.length
251
+ );
252
+
253
+ // 6. Build content map for imports and refs
254
+ const contentMap: Record<string, string> = {};
255
+ const contentConceptIds = new Set([...importsEdges, ...refs]);
256
+
257
+ for (const id of contentConceptIds) {
258
+ const relatedConcept = repo.byId[id];
259
+ if (relatedConcept && 'content' in relatedConcept) {
260
+ contentMap[id] = relatedConcept.content;
261
+ }
262
+ }
263
+
264
+ debug.context('Built content map with %d entries', Object.keys(contentMap).length);
265
+
266
+ return {
267
+ concept,
268
+ calls,
269
+ extends: extendsEdges,
270
+ imports: importsEdges,
271
+ refs,
272
+ calledBy,
273
+ extendedBy,
274
+ importedBy,
275
+ referencedBy,
276
+ allEdges: {
277
+ outgoing: outgoingEdges.map((e) => ({ to: e.to, role: e.role })),
278
+ incoming: incomingEdges.map((e) => ({ from: e.from, role: e.role })),
279
+ },
280
+ contentMap,
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Write context to JSON file
286
+ */
287
+ export async function writeContext(
288
+ file: string,
289
+ ctx: ContextPayload
290
+ ): Promise<void> {
291
+ const { writeFile } = await import('fs/promises');
292
+ await writeFile(file, JSON.stringify(ctx, null, 2), 'utf-8');
293
+ debug.context('Context written to %s', file);
294
+ }
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Cache Manager for .libraries/
3
+ *
4
+ * Manages local cache of fetched packages.
5
+ */
6
+
7
+ import { promises as fs } from 'node:fs';
8
+ import * as path from 'node:path';
9
+ import * as crypto from 'node:crypto';
10
+ import type { ParsedURL } from '../providers/base.js';
11
+
12
+ /**
13
+ * Result of saving a file to cache
14
+ */
15
+ export interface CachedFile {
16
+ path: string;
17
+ fullPath: string;
18
+ integrity: string;
19
+ }
20
+
21
+ /**
22
+ * Calculate SHA256 integrity hash of content
23
+ */
24
+ export function calculateIntegrity(content: string): string {
25
+ const hash = crypto.createHash('sha256').update(content, 'utf-8').digest('hex');
26
+ return `sha256:${hash}`;
27
+ }
28
+
29
+ /**
30
+ * Verify content matches integrity hash
31
+ */
32
+ export function verifyIntegrity(content: string, integrity: string): boolean {
33
+ if (!integrity.startsWith('sha256:')) {
34
+ return false;
35
+ }
36
+
37
+ const calculated = calculateIntegrity(content);
38
+ return calculated === integrity;
39
+ }
40
+
41
+ /**
42
+ * Derive cache path from parsed URL
43
+ *
44
+ * For GitHub/GitLab: {repo}/{path-from-blob}
45
+ * For generic URLs: {domain}/{path}
46
+ */
47
+ export function deriveCachePath(parsed: ParsedURL): string {
48
+ if (parsed.provider === 'github' || parsed.provider === 'gitlab') {
49
+ // Use repo name + path
50
+ return `${parsed.repo}/${parsed.path}`;
51
+ }
52
+
53
+ // Generic URL - extract domain and path
54
+ if (parsed.rawUrl) {
55
+ try {
56
+ const url = new URL(parsed.rawUrl);
57
+ // Combine hostname with pathname (remove leading slash)
58
+ const pathname = parsed.path.startsWith('/') ? parsed.path.slice(1) : parsed.path;
59
+ return `${url.hostname}/${pathname}`;
60
+ } catch {
61
+ // Fallback to just using the path
62
+ return parsed.path.startsWith('/') ? parsed.path.slice(1) : parsed.path;
63
+ }
64
+ }
65
+
66
+ return parsed.path.startsWith('/') ? parsed.path.slice(1) : parsed.path;
67
+ }
68
+
69
+ /**
70
+ * Cache Manager
71
+ *
72
+ * Manages the .libraries/ cache directory.
73
+ */
74
+ export class CacheManager {
75
+ private _workspaceRoot: string;
76
+ private _librariesPath: string;
77
+
78
+ constructor(workspaceRoot: string) {
79
+ this._workspaceRoot = workspaceRoot;
80
+ this._librariesPath = path.join(workspaceRoot, '.libraries');
81
+ }
82
+
83
+ /**
84
+ * Get workspace root path
85
+ */
86
+ get workspaceRoot(): string {
87
+ return this._workspaceRoot;
88
+ }
89
+
90
+ /**
91
+ * Get .libraries path
92
+ */
93
+ get librariesPath(): string {
94
+ return this._librariesPath;
95
+ }
96
+
97
+ /**
98
+ * Initialize cache directory
99
+ */
100
+ async init(): Promise<void> {
101
+ await fs.mkdir(this._librariesPath, { recursive: true });
102
+ }
103
+
104
+ /**
105
+ * Save content to cache
106
+ */
107
+ async save(cachePath: string, content: string): Promise<CachedFile> {
108
+ const fullPath = this.getFullPath(cachePath);
109
+
110
+ // Create parent directories
111
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
112
+
113
+ // Write content
114
+ await fs.writeFile(fullPath, content, 'utf-8');
115
+
116
+ // Calculate integrity
117
+ const integrity = calculateIntegrity(content);
118
+
119
+ return {
120
+ path: cachePath,
121
+ fullPath,
122
+ integrity,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Read content from cache
128
+ */
129
+ async read(cachePath: string): Promise<string> {
130
+ const fullPath = this.getFullPath(cachePath);
131
+ return fs.readFile(fullPath, 'utf-8');
132
+ }
133
+
134
+ /**
135
+ * Check if file exists in cache
136
+ */
137
+ async exists(cachePath: string): Promise<boolean> {
138
+ const fullPath = this.getFullPath(cachePath);
139
+ try {
140
+ await fs.stat(fullPath);
141
+ return true;
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Delete file from cache
149
+ */
150
+ async delete(cachePath: string): Promise<void> {
151
+ const fullPath = this.getFullPath(cachePath);
152
+
153
+ try {
154
+ await fs.unlink(fullPath);
155
+ } catch (error: unknown) {
156
+ // Ignore if file doesn't exist
157
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
158
+ throw error;
159
+ }
160
+ return;
161
+ }
162
+
163
+ // Clean up empty parent directories
164
+ await this.cleanEmptyDirs(path.dirname(fullPath));
165
+ }
166
+
167
+ /**
168
+ * List all cached files
169
+ */
170
+ async list(): Promise<string[]> {
171
+ const files: string[] = [];
172
+
173
+ try {
174
+ await this.walkDir(this._librariesPath, files);
175
+ } catch (error: unknown) {
176
+ // Return empty if .libraries doesn't exist
177
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
178
+ return [];
179
+ }
180
+ throw error;
181
+ }
182
+
183
+ return files;
184
+ }
185
+
186
+ /**
187
+ * Remove all cached files
188
+ */
189
+ async clean(): Promise<number> {
190
+ const files = await this.list();
191
+
192
+ for (const file of files) {
193
+ const fullPath = this.getFullPath(file);
194
+ await fs.unlink(fullPath);
195
+ }
196
+
197
+ // Clean up all empty directories
198
+ try {
199
+ await this.cleanAllEmptyDirs(this._librariesPath);
200
+ } catch {
201
+ // Ignore errors during cleanup
202
+ }
203
+
204
+ return files.length;
205
+ }
206
+
207
+ /**
208
+ * Verify integrity of cached file
209
+ */
210
+ async verifyIntegrity(cachePath: string, integrity: string): Promise<boolean> {
211
+ try {
212
+ const content = await this.read(cachePath);
213
+ return verifyIntegrity(content, integrity);
214
+ } catch {
215
+ return false;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Get full filesystem path from cache path
221
+ */
222
+ getFullPath(cachePath: string): string {
223
+ return path.join(this._librariesPath, cachePath);
224
+ }
225
+
226
+ /**
227
+ * Get cache path from full filesystem path
228
+ */
229
+ getCachePath(fullPath: string): string | null {
230
+ if (!fullPath.startsWith(this._librariesPath)) {
231
+ return null;
232
+ }
233
+
234
+ const relativePath = path.relative(this._librariesPath, fullPath);
235
+
236
+ // Make sure it's not outside (no .. components)
237
+ if (relativePath.startsWith('..')) {
238
+ return null;
239
+ }
240
+
241
+ return relativePath;
242
+ }
243
+
244
+ /**
245
+ * Walk directory recursively to collect files
246
+ */
247
+ private async walkDir(dir: string, files: string[]): Promise<void> {
248
+ const entries = await fs.readdir(dir, { withFileTypes: true });
249
+
250
+ for (const entry of entries) {
251
+ const fullPath = path.join(dir, entry.name);
252
+
253
+ if (entry.isDirectory()) {
254
+ await this.walkDir(fullPath, files);
255
+ } else if (entry.isFile()) {
256
+ const cachePath = this.getCachePath(fullPath);
257
+ if (cachePath) {
258
+ files.push(cachePath);
259
+ }
260
+ }
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Clean up empty directories from a path up to .libraries
266
+ */
267
+ private async cleanEmptyDirs(dir: string): Promise<void> {
268
+ // Don't go above .libraries
269
+ if (dir === this._librariesPath || !dir.startsWith(this._librariesPath)) {
270
+ return;
271
+ }
272
+
273
+ try {
274
+ const entries = await fs.readdir(dir);
275
+ if (entries.length === 0) {
276
+ await fs.rmdir(dir);
277
+ // Recurse to parent
278
+ await this.cleanEmptyDirs(path.dirname(dir));
279
+ }
280
+ } catch {
281
+ // Ignore errors
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Clean all empty directories under .libraries
287
+ */
288
+ private async cleanAllEmptyDirs(dir: string): Promise<void> {
289
+ try {
290
+ const entries = await fs.readdir(dir, { withFileTypes: true });
291
+
292
+ for (const entry of entries) {
293
+ if (entry.isDirectory()) {
294
+ const subDir = path.join(dir, entry.name);
295
+ await this.cleanAllEmptyDirs(subDir);
296
+
297
+ // Try to remove if empty
298
+ try {
299
+ const subEntries = await fs.readdir(subDir);
300
+ if (subEntries.length === 0) {
301
+ await fs.rmdir(subDir);
302
+ }
303
+ } catch {
304
+ // Ignore
305
+ }
306
+ }
307
+ }
308
+ } catch {
309
+ // Ignore errors
310
+ }
311
+ }
312
+ }