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.
- package/README.md +129 -0
- package/dist/builders/context.d.ts +50 -0
- package/dist/builders/context.d.ts.map +1 -0
- package/dist/builders/context.js +190 -0
- package/dist/cache/index.d.ts +100 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/index.js +270 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +463 -0
- package/dist/commands/package.d.ts +96 -0
- package/dist/commands/package.d.ts.map +1 -0
- package/dist/commands/package.js +285 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/loader.d.ts +6 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +361 -0
- package/dist/merge.d.ts +16 -0
- package/dist/merge.d.ts.map +1 -0
- package/dist/merge.js +102 -0
- package/dist/package/manifest.d.ts +59 -0
- package/dist/package/manifest.d.ts.map +1 -0
- package/dist/package/manifest.js +265 -0
- package/dist/parser.d.ts +28 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +220 -0
- package/dist/parsers/frontmatter.d.ts +14 -0
- package/dist/parsers/frontmatter.d.ts.map +1 -0
- package/dist/parsers/frontmatter.js +110 -0
- package/dist/parsers/imports.d.ts +48 -0
- package/dist/parsers/imports.d.ts.map +1 -0
- package/dist/parsers/imports.js +147 -0
- package/dist/parsers/links.d.ts +12 -0
- package/dist/parsers/links.d.ts.map +1 -0
- package/dist/parsers/links.js +79 -0
- package/dist/parsers/localdefs.d.ts +6 -0
- package/dist/parsers/localdefs.d.ts.map +1 -0
- package/dist/parsers/localdefs.js +132 -0
- package/dist/parsers/operations.d.ts +32 -0
- package/dist/parsers/operations.d.ts.map +1 -0
- package/dist/parsers/operations.js +313 -0
- package/dist/parsers/sections.d.ts +15 -0
- package/dist/parsers/sections.d.ts.map +1 -0
- package/dist/parsers/sections.js +173 -0
- package/dist/parsers/tools.d.ts +30 -0
- package/dist/parsers/tools.d.ts.map +1 -0
- package/dist/parsers/tools.js +178 -0
- package/dist/parsers/triggers.d.ts +35 -0
- package/dist/parsers/triggers.d.ts.map +1 -0
- package/dist/parsers/triggers.js +219 -0
- package/dist/providers/base.d.ts +60 -0
- package/dist/providers/base.d.ts.map +1 -0
- package/dist/providers/base.js +34 -0
- package/dist/providers/github.d.ts +18 -0
- package/dist/providers/github.d.ts.map +1 -0
- package/dist/providers/github.js +109 -0
- package/dist/providers/gitlab.d.ts +18 -0
- package/dist/providers/gitlab.d.ts.map +1 -0
- package/dist/providers/gitlab.js +101 -0
- package/dist/providers/index.d.ts +13 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +17 -0
- package/dist/providers/local.d.ts +31 -0
- package/dist/providers/local.d.ts.map +1 -0
- package/dist/providers/local.js +116 -0
- package/dist/providers/url.d.ts +16 -0
- package/dist/providers/url.d.ts.map +1 -0
- package/dist/providers/url.js +45 -0
- package/dist/registry/index.d.ts +99 -0
- package/dist/registry/index.d.ts.map +1 -0
- package/dist/registry/index.js +320 -0
- package/dist/types/schema.d.ts +3259 -0
- package/dist/types/schema.d.ts.map +1 -0
- package/dist/types/schema.js +258 -0
- package/dist/utils/logger.d.ts +19 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +23 -0
- package/dist/utils/slugify.d.ts +14 -0
- package/dist/utils/slugify.d.ts.map +1 -0
- package/dist/utils/slugify.js +28 -0
- package/package.json +61 -0
- package/src/__tests__/cache.test.ts +393 -0
- package/src/__tests__/cli-package.test.ts +667 -0
- package/src/__tests__/fixtures/automated-workflow.busy.md +84 -0
- package/src/__tests__/fixtures/concept.busy.md +30 -0
- package/src/__tests__/fixtures/document.busy.md +44 -0
- package/src/__tests__/fixtures/simple-operation.busy.md +45 -0
- package/src/__tests__/fixtures/tool-document.busy.md +71 -0
- package/src/__tests__/fixtures/tool.busy.md +54 -0
- package/src/__tests__/imports.test.ts +244 -0
- package/src/__tests__/integration.test.ts +432 -0
- package/src/__tests__/operations.test.ts +408 -0
- package/src/__tests__/package-manifest.test.ts +455 -0
- package/src/__tests__/providers.test.ts +672 -0
- package/src/__tests__/registry.test.ts +402 -0
- package/src/__tests__/schema.test.ts +467 -0
- package/src/__tests__/tools.test.ts +376 -0
- package/src/__tests__/triggers.test.ts +312 -0
- package/src/builders/context.ts +294 -0
- package/src/cache/index.ts +312 -0
- package/src/cli/index.ts +514 -0
- package/src/commands/package.ts +392 -0
- package/src/index.ts +46 -0
- package/src/loader.ts +474 -0
- package/src/merge.ts +126 -0
- package/src/package/manifest.ts +349 -0
- package/src/parser.ts +278 -0
- package/src/parsers/frontmatter.ts +135 -0
- package/src/parsers/imports.ts +196 -0
- package/src/parsers/links.ts +108 -0
- package/src/parsers/localdefs.ts +166 -0
- package/src/parsers/operations.ts +404 -0
- package/src/parsers/sections.ts +230 -0
- package/src/parsers/tools.ts +215 -0
- package/src/parsers/triggers.ts +252 -0
- package/src/providers/base.ts +77 -0
- package/src/providers/github.ts +129 -0
- package/src/providers/gitlab.ts +121 -0
- package/src/providers/index.ts +25 -0
- package/src/providers/local.ts +129 -0
- package/src/providers/url.ts +56 -0
- package/src/registry/index.ts +408 -0
- package/src/types/schema.ts +369 -0
- package/src/utils/logger.ts +25 -0
- package/src/utils/slugify.ts +31 -0
- 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
|
+
}
|