@c6fc/spellcraft 0.0.5 → 0.0.6
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/package.json +1 -1
- package/src/index.js +193 -489
package/package.json
CHANGED
package/src/index.js
CHANGED
@@ -9,16 +9,6 @@ const colors = require('@colors/colors');
|
|
9
9
|
const { spawnSync } = require('child_process');
|
10
10
|
const { Jsonnet } = require("@hanazuki/node-jsonnet");
|
11
11
|
|
12
|
-
const baseDir = process.cwd();
|
13
|
-
|
14
|
-
const thisPackage = JSON.parse(fs.readFileSync("./package.json"));
|
15
|
-
|
16
|
-
/**
|
17
|
-
* @constant {object} defaultFileTypeHandlers
|
18
|
-
* @description Default handlers for different file types based on their extensions.
|
19
|
-
* Each key is a regex string to match filenames, and the value is a function
|
20
|
-
* that takes the file content (as a JavaScript object/value) and returns a string.
|
21
|
-
*/
|
22
12
|
const defaultFileTypeHandlers = {
|
23
13
|
// JSON files
|
24
14
|
'.*?\.json$': (content) => JSON.stringify(content, null, 4),
|
@@ -27,147 +17,14 @@ const defaultFileTypeHandlers = {
|
|
27
17
|
'.*?\.yml$': (content) => yaml.dump(content, { indent: 4 }),
|
28
18
|
};
|
29
19
|
|
30
|
-
/**
|
31
|
-
* Default file handler used when no specific handler matches the file type.
|
32
|
-
* @param {*} content - The content to be stringified.
|
33
|
-
* @returns {string} The content stringified as JSON with an indent of 4.
|
34
|
-
*/
|
35
20
|
const defaultFileHandler = (content) => JSON.stringify(content, null, 4);
|
36
21
|
|
37
|
-
/**
|
38
|
-
* Extracts parameter names from a function signature.
|
39
|
-
* Note: This method relies on Function.prototype.toString() and may not be
|
40
|
-
* robust for all JavaScript function syntaxes (e.g., minified code,
|
41
|
-
* complex destructuring in parameters).
|
42
|
-
* @param {Function} func - The function to inspect.
|
43
|
-
* @returns {string[]} An array of parameter names.
|
44
|
-
*/
|
45
|
-
function getFunctionParameterList(func) {
|
46
|
-
let funcStr = func.toString();
|
47
|
-
|
48
|
-
// Remove comments and function body
|
49
|
-
funcStr = funcStr.replace(/\/\*[\s\S]*?\*\//g, '') // Multi-line comments
|
50
|
-
.replace(/\/\/(.)*/g, '') // Single-line comments
|
51
|
-
.replace(/{[\s\S]*}/, '') // Function body
|
52
|
-
.replace(/=>/g, '') // Arrow function syntax
|
53
|
-
.trim();
|
54
|
-
|
55
|
-
const paramStartIndex = funcStr.indexOf("(") + 1;
|
56
|
-
// For arrow functions without parentheses for a single arg, e.g., x => x*x
|
57
|
-
// This regex might not be perfect. A more robust parser would be needed for all cases.
|
58
|
-
const paramEndIndex = funcStr.lastIndexOf(")");
|
59
|
-
|
60
|
-
if (paramStartIndex === 0 || paramEndIndex === -1 || paramStartIndex >= paramEndIndex) {
|
61
|
-
// Handle case for single arg arrow function without parens like `arg => ...`
|
62
|
-
// Or if no parameters are found.
|
63
|
-
const potentialSingleArg = funcStr.split('=>')[0].trim();
|
64
|
-
if (potentialSingleArg && !potentialSingleArg.includes('(') && !potentialSingleArg.includes(')')) {
|
65
|
-
return [potentialSingleArg].filter(p => p.length > 0);
|
66
|
-
}
|
67
|
-
return [];
|
68
|
-
}
|
69
|
-
|
70
|
-
const paramsString = funcStr.substring(paramStartIndex, paramEndIndex);
|
71
|
-
if (!paramsString.trim()) {
|
72
|
-
return [];
|
73
|
-
}
|
74
|
-
|
75
|
-
return paramsString.split(",")
|
76
|
-
.map(param => param.replace(/=[\s\S]*/g, '').trim()) // Remove default values
|
77
|
-
.filter(param => param.length > 0);
|
78
|
-
}
|
79
|
-
|
80
|
-
|
81
|
-
/**
|
82
|
-
* @class SpellFrame
|
83
|
-
* @classdesc Manages the rendering of configurations using Jsonnet,
|
84
|
-
* allowing extension with JavaScript modules and custom file handlers.
|
85
|
-
* @exports SpellFrame
|
86
|
-
*/
|
87
22
|
exports.SpellFrame = class SpellFrame {
|
88
|
-
|
89
|
-
* The path where rendered files will be written.
|
90
|
-
* @type {string}
|
91
|
-
*/
|
92
|
-
renderPath;
|
93
|
-
|
94
|
-
/**
|
95
|
-
* An array of functions to extend CLI argument parsing (e.g., with yargs).
|
96
|
-
* Each function typically takes the yargs instance as an argument.
|
97
|
-
* @type {Array<Function>}
|
98
|
-
*/
|
99
|
-
cliExtensions;
|
100
|
-
|
101
|
-
/**
|
102
|
-
* If true, the renderPath directory will be cleaned of files matching
|
103
|
-
* registered fileTypeHandlers before new files are written.
|
104
|
-
* @type {boolean}
|
105
|
-
*/
|
106
|
-
cleanBeforeRender;
|
107
|
-
|
108
|
-
/**
|
109
|
-
* An object mapping file extension patterns (regex strings) to handler functions.
|
110
|
-
* Handler functions take the evaluated Jsonnet output for a file and return a string.
|
111
|
-
* @type {Object<string, Function>}
|
112
|
-
*/
|
113
|
-
fileTypeHandlers;
|
114
|
-
|
115
|
-
/**
|
116
|
-
* An array of initialization functions to be run (awaited) before rendering.
|
117
|
-
* These are often contributed by SpellCraft modules.
|
118
|
-
* @type {Array<Function>}
|
119
|
-
*/
|
120
|
-
initFn;
|
121
|
-
|
122
|
-
/**
|
123
|
-
* The Jsonnet instance used for evaluation.
|
124
|
-
* @type {Jsonnet}
|
125
|
-
*/
|
126
|
-
jsonnet;
|
127
|
-
|
128
|
-
/**
|
129
|
-
* The result of the last successful Jsonnet evaluation.
|
130
|
-
* @type {object|null}
|
131
|
-
*/
|
132
|
-
lastRender;
|
133
|
-
|
134
|
-
/**
|
135
|
-
* The directory path of the currently active Jsonnet file being processed.
|
136
|
-
* @type {string|null}
|
137
|
-
*/
|
138
|
-
activePath;
|
139
|
-
|
140
|
-
/**
|
141
|
-
* An object that will be used as `this` context for native functions
|
142
|
-
* called from Jsonnet.
|
143
|
-
* @type {object}
|
144
|
-
*/
|
145
|
-
functionContext;
|
146
|
-
|
147
|
-
/**
|
148
|
-
* If true, default file handlers for .json and .yaml/.yml will be used.
|
149
|
-
* @type {boolean}
|
150
|
-
*/
|
151
|
-
useDefaultFileHandlers;
|
152
|
-
|
153
|
-
/**
|
154
|
-
* A cache for the results of synchronous native functions called from Jsonnet.
|
155
|
-
* @private
|
156
|
-
* @type {object}
|
157
|
-
*/
|
158
|
-
_cache;
|
159
|
-
|
160
|
-
|
161
|
-
/**
|
162
|
-
* Creates an instance of SpellFrame.
|
163
|
-
* @param {object} [options] - Configuration options.
|
164
|
-
* @param {string} [options.renderPath="./render"] - The output directory for rendered files.
|
165
|
-
* @param {boolean} [options.cleanBeforeRender=true] - Whether to clean the renderPath before writing.
|
166
|
-
* @param {boolean} [options.useDefaultFileHandlers=true] - Whether to include default .json and .yaml handlers.
|
167
|
-
*/
|
23
|
+
|
168
24
|
constructor(options = {}) {
|
169
25
|
const defaults = {
|
170
26
|
renderPath: "./render",
|
27
|
+
modulePath: "./modules",
|
171
28
|
cleanBeforeRender: true,
|
172
29
|
useDefaultFileHandlers: true
|
173
30
|
};
|
@@ -178,41 +35,52 @@ exports.SpellFrame = class SpellFrame {
|
|
178
35
|
this.initFn = [];
|
179
36
|
this._cache = {}; // Initialize cache
|
180
37
|
this.cliExtensions = [];
|
38
|
+
this.currentPackage = this.getCwdPackage();
|
39
|
+
this.currentPackagePath = this.getCwdPackagePath();
|
181
40
|
this.fileTypeHandlers = (this.useDefaultFileHandlers) ? { ...defaultFileTypeHandlers } : {};
|
182
41
|
this.functionContext = {};
|
183
42
|
this.lastRender = null;
|
184
43
|
this.activePath = null;
|
185
44
|
this.loadedModules = [];
|
45
|
+
this.modulePath = path.resolve(path.join(process.cwd(), this.modulePath));
|
46
|
+
this.magicContent = {}; // { modulefile: [...snippets] }
|
47
|
+
this.registeredFunctions = {}; // { modulefile: [...functionNames] }
|
48
|
+
|
49
|
+
this.renderPath = path.resolve(this.currentPackagePath, this.renderPath);
|
50
|
+
this.modulePath = path.resolve(this.currentPackagePath, this.modulePath);
|
186
51
|
|
187
52
|
this.jsonnet = new Jsonnet()
|
188
53
|
.addJpath(path.join(__dirname, '../lib')) // For core SpellCraft libsonnet files
|
189
|
-
.addJpath(
|
54
|
+
.addJpath(this.modulePath); // For dynamically generated module imports
|
190
55
|
|
191
56
|
// Add built-in native functions
|
192
57
|
this.addNativeFunction("envvar", (name) => process.env[name] || false, "name");
|
193
58
|
this.addNativeFunction("path", () => this.activePath || process.cwd()); // Use activePath if available
|
194
59
|
|
60
|
+
if (!fs.existsSync(this.modulePath)) {
|
61
|
+
fs.mkdirSync(this.modulePath, { recursive: true });
|
62
|
+
}
|
63
|
+
|
64
|
+
// Clean up the modules on init
|
65
|
+
try {
|
66
|
+
fs.readdirSync(this.modulePath)
|
67
|
+
.map(e => path.join(this.modulePath, e))
|
68
|
+
.forEach(e => fs.unlinkSync(e));
|
69
|
+
|
70
|
+
} catch (e) {
|
71
|
+
throw new Error(`[!] Could not create/clean up temporary module folder ${path.dirname(this.modulePath).green}: ${e.message.red}`);
|
72
|
+
}
|
73
|
+
|
195
74
|
this.loadedModules = this.loadModulesFromPackageList();
|
75
|
+
this.loadModulesFromModuleDirectory();
|
76
|
+
|
77
|
+
return this;
|
196
78
|
}
|
197
79
|
|
198
|
-
/**
|
199
|
-
* Generates a cache key for native function calls.
|
200
|
-
* @private
|
201
|
-
* @param {string} functionName - The name of the function.
|
202
|
-
* @param {Array<*>} args - The arguments passed to the function.
|
203
|
-
* @returns {string} A SHA256 hash representing the cache key.
|
204
|
-
*/
|
205
80
|
_generateCacheKey(functionName, args) {
|
206
81
|
return crypto.createHash('sha256').update(JSON.stringify([functionName, ...args])).digest('hex');
|
207
82
|
}
|
208
83
|
|
209
|
-
/**
|
210
|
-
* Adds a file type handler for a given file pattern.
|
211
|
-
* The handler function receives the processed data for a file and should return its string content.
|
212
|
-
* @param {string} pattern - A regex string to match filenames.
|
213
|
-
* @param {Function} handler - The handler function (content: any) => string.
|
214
|
-
* @returns {this} The SpellFrame instance for chaining.
|
215
|
-
*/
|
216
84
|
addFileTypeHandler(pattern, handler) {
|
217
85
|
// Making it writable: false by default is a strong choice.
|
218
86
|
// If flexibility to override is needed later, this could be a simple assignment.
|
@@ -225,14 +93,6 @@ exports.SpellFrame = class SpellFrame {
|
|
225
93
|
return this;
|
226
94
|
}
|
227
95
|
|
228
|
-
/**
|
229
|
-
* Adds a native JavaScript function to be callable from Jsonnet.
|
230
|
-
* Results of synchronous functions are cached.
|
231
|
-
* @param {string} name - The name of the function in Jsonnet.
|
232
|
-
* @param {Function} func - The JavaScript function to execute.
|
233
|
-
* @param {...string} parameters - The names of the parameters the function expects (for Jsonnet signature).
|
234
|
-
* @returns {this} The SpellFrame instance for chaining.
|
235
|
-
*/
|
236
96
|
addNativeFunction(name, func, ...parameters) {
|
237
97
|
this.jsonnet.nativeCallback(name, (...args) => {
|
238
98
|
const key = this._generateCacheKey(name, args);
|
@@ -248,26 +108,6 @@ exports.SpellFrame = class SpellFrame {
|
|
248
108
|
return this;
|
249
109
|
}
|
250
110
|
|
251
|
-
/**
|
252
|
-
* Adds an external string variable to the Jsonnet evaluation context.
|
253
|
-
* If the value is not a string, it will be JSON.stringified.
|
254
|
-
* @param {string} name - The name of the external variable in Jsonnet.
|
255
|
-
* @param {*} value - The value to expose.
|
256
|
-
* @returns {this} The SpellFrame instance for chaining.
|
257
|
-
*/
|
258
|
-
addExternalValue(name, value) {
|
259
|
-
const finalValue = (typeof value === "string") ? value : JSON.stringify(value);
|
260
|
-
this.jsonnet = this.jsonnet.extCode(name, finalValue);
|
261
|
-
return this;
|
262
|
-
}
|
263
|
-
|
264
|
-
/**
|
265
|
-
* Extends SpellFrame's capabilities based on metadata from a module.
|
266
|
-
* (Currently a placeholder - implement as needed)
|
267
|
-
* @param {object} metadata - The metadata object from a SpellCraft module.
|
268
|
-
* @todo Implement the logic for processing module metadata.
|
269
|
-
* @returns {this} The SpellFrame instance for chaining.
|
270
|
-
*/
|
271
111
|
extendWithModuleMetadata(metadata) {
|
272
112
|
if (metadata.fileTypeHandlers) {
|
273
113
|
Object.entries(metadata.fileTypeHandlers).forEach(([pattern, handler]) => {
|
@@ -285,174 +125,155 @@ exports.SpellFrame = class SpellFrame {
|
|
285
125
|
return this;
|
286
126
|
}
|
287
127
|
|
288
|
-
/**
|
289
|
-
* Adds a path to the Jsonnet import search paths (JPATH).
|
290
|
-
* @param {string} jpath - The directory path to add.
|
291
|
-
* @returns {this} The SpellFrame instance for chaining.
|
292
|
-
*/
|
293
128
|
addJpath(jpath) {
|
294
129
|
this.jsonnet = this.jsonnet.addJpath(jpath);
|
295
130
|
return this;
|
296
131
|
}
|
297
132
|
|
298
|
-
/**
|
299
|
-
* Runs all registered initialization functions.
|
300
|
-
* @async
|
301
|
-
* @returns {Promise<void>}
|
302
|
-
*/
|
303
133
|
async init() {
|
304
134
|
for (const step of this.initFn) {
|
305
135
|
await step.call();
|
306
136
|
}
|
307
137
|
}
|
308
138
|
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
* for SpellCraft configuration, and linking it for use.
|
313
|
-
* @param {string} npmPackage - The name of the npm package (e.g., "my-spellcraft-module" or "@scope/my-module").
|
314
|
-
* @param {string} [name=false] - An optional alias name for the module. If false, uses default from package.
|
315
|
-
* @async
|
316
|
-
* @returns {Promise<void>}
|
317
|
-
* @throws {Error} If the package cannot be installed, lacks package.json, or has no import name.
|
318
|
-
*/
|
319
|
-
async importSpellCraftModuleFromNpm(npmPackage, name = false) {
|
320
|
-
const npmPath = path.resolve(baseDir, 'node_modules', npmPackage);
|
321
|
-
if (!fs.existsSync(npmPath)) {
|
322
|
-
console.log(`[+] Attempting to install ${npmPackage}...`);
|
323
|
-
|
324
|
-
const install = spawnSync(`npm`, ['install', '--save', npmPackage], { // Using --save-dev for local project context
|
325
|
-
cwd: baseDir,
|
326
|
-
stdio: 'inherit'
|
327
|
-
});
|
139
|
+
getCwdPackage() {
|
140
|
+
return require(path.resolve(this.getCwdPackagePath(), 'package.json'));
|
141
|
+
}
|
328
142
|
|
329
|
-
|
330
|
-
|
331
|
-
|
143
|
+
getCwdPackagePath() {
|
144
|
+
let depth = 0;
|
145
|
+
const maxdepth = 3
|
146
|
+
let checkPath = process.cwd();
|
332
147
|
|
333
|
-
|
148
|
+
while (!fs.existsSync(path.join(checkPath, 'package.json')) && depth < maxdepth) {
|
149
|
+
path = path.join(checkPath, '..');
|
150
|
+
depth++;
|
334
151
|
}
|
335
152
|
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
const spellcraftConfig = thisPackage.config || thisPackage.spellcraft; // Allow 'spellcraft' key too
|
340
|
-
|
341
|
-
if (!name && !!!moduleConfig?.spellcraft_module_default_name) {
|
342
|
-
console.log("Package config:", moduleJson);
|
343
|
-
throw new Error(`[!] No import name specified for ${npmPackage}, and it has no 'spellcraft_module_default_name' in its package.json config.`.red);
|
153
|
+
if (fs.existsSync(path.join(checkPath, 'package.json'))) {
|
154
|
+
return checkPath;
|
344
155
|
}
|
345
156
|
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
const packagesDirPath = path.join(baseDir, 'spellcraft_modules');
|
350
|
-
if (!fs.existsSync(packagesDirPath)) {
|
351
|
-
fs.mkdirSync(packagesDirPath, { recursive: true });
|
352
|
-
}
|
353
|
-
|
354
|
-
const packagesFilePath = path.join(packagesDirPath, 'packages.json');
|
355
|
-
let packages = {};
|
356
|
-
if (fs.existsSync(packagesFilePath)) {
|
357
|
-
try {
|
358
|
-
packages = JSON.parse(fs.readFileSync(packagesFilePath, 'utf-8'));
|
359
|
-
} catch (e) {
|
360
|
-
console.warn(`[!] Could not parse existing ${packagesFilePath}. Starting fresh. Error: ${e.message}`.red);
|
361
|
-
packages = {};
|
362
|
-
}
|
363
|
-
}
|
157
|
+
return false;
|
158
|
+
}
|
364
159
|
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
|
160
|
+
getModulePackage(name) {
|
161
|
+
// For backwards compatability
|
162
|
+
if (name == '..') {
|
163
|
+
return this.currentPackage;
|
164
|
+
}
|
369
165
|
|
370
|
-
|
371
|
-
|
166
|
+
return require(require.resolve(name, { paths: [this.currentPackagePath] }));
|
167
|
+
}
|
372
168
|
|
373
|
-
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
console.log(`[+] Module installed, but not linked because the current project is also a module.`);
|
378
|
-
console.log(` You can use the module's JS native functions, or import its JSonnet modules.`);
|
169
|
+
getModulePackagePath(name) {
|
170
|
+
// For backwards compatability
|
171
|
+
if (name == '..') {
|
172
|
+
return this.currentPackagePath;
|
379
173
|
}
|
174
|
+
|
175
|
+
return path.dirname(require.resolve(name, { paths: [this.currentPackagePath] }));
|
380
176
|
}
|
381
177
|
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
*/
|
390
|
-
async render(file) {
|
391
|
-
const absoluteFilePath = path.resolve(file);
|
392
|
-
if (!fs.existsSync(absoluteFilePath)) {
|
393
|
-
throw new Error(`SpellCraft Render Error: Input file ${absoluteFilePath} does not exist.`);
|
178
|
+
loadFunctionsFromFile(file, as) {
|
179
|
+
|
180
|
+
const moduleExports = require(file);
|
181
|
+
|
182
|
+
const magicContentSnippets = [];
|
183
|
+
if (moduleExports._spellcraft_metadata) {
|
184
|
+
this.extendWithModuleMetadata(moduleExports._spellcraft_metadata);
|
394
185
|
}
|
395
186
|
|
396
|
-
|
187
|
+
const registeredFunctionNames = Object.keys(moduleExports)
|
188
|
+
.filter(key => key !== '_spellcraft_metadata' && typeof moduleExports[key] !== 'undefined')
|
189
|
+
.map(funcName => {
|
190
|
+
let func, params;
|
397
191
|
|
398
|
-
|
399
|
-
|
192
|
+
if (typeof moduleExports[funcName] === "object" && Array.isArray(moduleExports[funcName])) {
|
193
|
+
// Expects [function, paramName1, paramName2, ...]
|
194
|
+
[func, ...params] = moduleExports[funcName];
|
195
|
+
}
|
400
196
|
|
401
|
-
|
402
|
-
|
403
|
-
|
404
|
-
|
405
|
-
|
197
|
+
if (typeof func !== 'function') {
|
198
|
+
console.warn(`[!] Export '${funcName}' in module ${file} is not a valid function for native binding.`);
|
199
|
+
return null;
|
200
|
+
}
|
201
|
+
|
202
|
+
// For `modules` to provide convenient wrappers:
|
203
|
+
// e.g. myNativeFunc(a,b):: std.native('myNativeFunc')(a,b)
|
204
|
+
const paramString = params.join(', ');
|
205
|
+
magicContentSnippets.push(`\t${funcName}(${paramString}):: std.native('${funcName}')(${paramString})`);
|
406
206
|
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
}
|
207
|
+
this.addNativeFunction(funcName, func, ...params);
|
208
|
+
return funcName;
|
209
|
+
}).filter(Boolean); // Remove nulls from skipped items
|
411
210
|
|
412
|
-
|
413
|
-
|
414
|
-
}));
|
211
|
+
this.registeredFunctions[as] = registeredFunctionNames;
|
212
|
+
this.magicContent[as] = magicContentSnippets;
|
415
213
|
|
416
|
-
|
214
|
+
return this;
|
215
|
+
}
|
417
216
|
|
418
|
-
|
419
|
-
|
420
|
-
}
|
217
|
+
loadModulesFromPackageList() {
|
218
|
+
const packagesConfigPath = path.join(this.currentPackagePath, 'spellcraft_modules', 'packages.json');
|
421
219
|
|
422
|
-
if (
|
423
|
-
console.log(
|
220
|
+
if (!fs.existsSync(packagesConfigPath)) {
|
221
|
+
// console.log('[+] No spellcraft_modules/packages.json file found. Skipping package-based module import.');
|
222
|
+
return [];
|
424
223
|
}
|
425
224
|
|
426
|
-
|
427
|
-
|
428
|
-
|
225
|
+
let packages;
|
226
|
+
try {
|
227
|
+
packages = JSON.parse(fs.readFileSync(packagesConfigPath, 'utf-8'));
|
228
|
+
} catch (e) {
|
229
|
+
console.error(`[!] Error parsing ${packagesConfigPath.green}: ${e.message.red}. Skipping package-based module import.`);
|
230
|
+
return [];
|
429
231
|
}
|
232
|
+
|
233
|
+
return Object.entries(packages).map(([npmPackageName, moduleKey]) => {
|
234
|
+
this.loadModuleByName(moduleKey, npmPackageName);
|
235
|
+
return moduleKey;
|
236
|
+
});
|
237
|
+
}
|
430
238
|
|
431
|
-
|
432
|
-
this.
|
239
|
+
loadCurrentPackageAsModule(moduleKey) {
|
240
|
+
return this.loadModuleByName(moduleKey, '..');
|
241
|
+
}
|
433
242
|
|
434
|
-
|
435
|
-
|
436
|
-
|
437
|
-
const modulePath = path.resolve(__dirname, '../modules');
|
243
|
+
loadModuleByName(moduleKey, npmPackageName) {
|
244
|
+
const importModuleConfig = this.getModulePackage(npmPackageName);
|
245
|
+
const importModulePath = this.getModulePackagePath(npmPackageName);
|
438
246
|
|
439
|
-
|
440
|
-
|
441
|
-
|
247
|
+
this.loadFunctionsFromFile(path.resolve(importModulePath, 'module.js'), moduleKey);
|
248
|
+
|
249
|
+
const sourceLibsonnetPath = path.resolve(importModulePath, 'module.libsonnet');
|
250
|
+
const targetLibsonnetPath = path.resolve(this.modulePath, `${moduleKey}.libsonnet`);
|
442
251
|
|
443
|
-
|
444
|
-
|
252
|
+
if (fs.existsSync(targetLibsonnetPath)) {
|
253
|
+
throw new Error(`[!] Module library ${path.basename(targetLibsonnetPath)} already exists. This means there is a conflict with package link names.`);
|
445
254
|
}
|
446
255
|
|
447
|
-
|
256
|
+
fs.copyFileSync(sourceLibsonnetPath, targetLibsonnetPath);
|
257
|
+
|
258
|
+
console.log(`[+] Linked ${(npmPackageName == '..') ? 'this package'.green : npmPackageName.green} as ${path.basename(targetLibsonnetPath).green}`);
|
259
|
+
|
260
|
+
return this;
|
261
|
+
}
|
262
|
+
|
263
|
+
loadModulesFromFileList(jsModuleFiles, as) {
|
264
|
+
let allRegisteredFunctions = [];
|
265
|
+
let allMagicContent = [];
|
266
|
+
|
267
|
+
jsModuleFiles.forEach(file => {
|
268
|
+
this.loadFunctionsFromFile(file, as);
|
269
|
+
console.log(`[+] Loaded [${this.registeredFunctions.join(', ').cyan}] from ${path.basename(file).green} into module.${as.green}`);
|
270
|
+
});
|
271
|
+
|
272
|
+
return this;
|
448
273
|
}
|
449
274
|
|
450
|
-
/**
|
451
|
-
* Loads SpellCraft modules by scanning the `spellcraft_modules` directory for `.js` files.
|
452
|
-
* @returns {Object} A list of registered function names from the loaded modules.
|
453
|
-
*/
|
454
275
|
loadModulesFromModuleDirectory() {
|
455
|
-
const spellcraftModulesPath = path.join(
|
276
|
+
const spellcraftModulesPath = path.join(this.currentPackagePath, 'spellcraft_modules');
|
456
277
|
if (!fs.existsSync(spellcraftModulesPath)) {
|
457
278
|
return { registeredFunctions: [], magicContent: [] };
|
458
279
|
}
|
@@ -468,209 +289,92 @@ exports.SpellFrame = class SpellFrame {
|
|
468
289
|
.filter(f => f.endsWith('.js')) // Simpler check for .js files
|
469
290
|
.map(f => path.join(spellcraftModulesPath, f));
|
470
291
|
|
471
|
-
return this.loadModulesFromFileList(jsModuleFiles);
|
292
|
+
return this.loadModulesFromFileList(jsModuleFiles, 'modules');
|
472
293
|
}
|
473
294
|
|
295
|
+
async importSpellCraftModuleFromNpm(npmPackage, name = false) {
|
296
|
+
if (!fs.existsSync(this.getModulePackagePath(npmPackage))) {
|
297
|
+
console.log(`[*] Attempting to install ${npmPackage.blue}...`);
|
298
|
+
|
299
|
+
const install = spawnSync(`npm`, ['install', '--save', npmPackage], {
|
300
|
+
cwd: baseDir,
|
301
|
+
stdio: 'inherit'
|
302
|
+
});
|
474
303
|
|
475
|
-
|
476
|
-
|
477
|
-
|
478
|
-
* @returns {Array<string>} A list of module keys that were loaded.
|
479
|
-
*/
|
480
|
-
loadModulesFromPackageList() {
|
481
|
-
const packagesConfigPath = path.join(baseDir, 'spellcraft_modules', 'packages.json');
|
482
|
-
|
483
|
-
if (!fs.existsSync(packagesConfigPath)) {
|
484
|
-
// console.log('[+] No spellcraft_modules/packages.json file found. Skipping package-based module import.');
|
485
|
-
return [];
|
486
|
-
}
|
487
|
-
|
488
|
-
let packages;
|
489
|
-
try {
|
490
|
-
packages = JSON.parse(fs.readFileSync(packagesConfigPath, 'utf-8'));
|
491
|
-
} catch (e) {
|
492
|
-
console.error(`[!] Error parsing ${packagesConfigPath}: ${e.message}. Skipping package-based module import.`);
|
493
|
-
return [];
|
494
|
-
}
|
495
|
-
|
496
|
-
return Object.entries(packages).map(([npmPackageName, moduleKey]) => {
|
497
|
-
return this.loadModuleByName(moduleKey, npmPackageName);
|
498
|
-
}); // Filter out any undefined results if a module fails to load
|
499
|
-
}
|
304
|
+
if (install.error || install.status !== 0) {
|
305
|
+
throw new Error(`Failed to install npm package ${npmPackage.blue}. Error: ${install.error.red || install.stderr.toString().red}`);
|
306
|
+
}
|
500
307
|
|
501
|
-
|
502
|
-
* Loads a specific module by its registered key and npm package name.
|
503
|
-
* @private
|
504
|
-
* @param {string} moduleKey - The key (alias) under which the module is registered.
|
505
|
-
* @param {string} npmPackageName - The actual npm package name.
|
506
|
-
* @returns {string|false} The moduleKey if successful, false otherwise.
|
507
|
-
*/
|
508
|
-
loadModuleByName(moduleKey, npmPackageName) {
|
509
|
-
const packageJsonPath = path.join(baseDir, 'node_modules', npmPackageName, 'package.json');
|
510
|
-
if (!fs.existsSync(packageJsonPath)) {
|
511
|
-
console.trace(`[!] package.json not found for module '${moduleKey}' (package: ${npmPackageName}) at ${packageJsonPath}. Skipping.`);
|
512
|
-
return false;
|
308
|
+
console.log(`[+] Successfully installed ${npmPackage.blue}.`);
|
513
309
|
}
|
514
310
|
|
515
|
-
|
516
|
-
|
517
|
-
packageConfig = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
518
|
-
} catch (e) {
|
519
|
-
console.warn(`[!] Error parsing package.json for module '${moduleKey}' (package: ${npmPackageName}): ${e.message}. Skipping.`);
|
520
|
-
return false;
|
521
|
-
}
|
311
|
+
const importModuleConfig = this.getModulePackage(npmPackage).config;
|
312
|
+
const currentPackageConfig = this.currentPackage.config;
|
522
313
|
|
523
|
-
if (!
|
524
|
-
console.
|
525
|
-
|
526
|
-
const jsMainFilePath = path.resolve(baseDir, 'node_modules', npmPackageName, packageConfig.main);
|
527
|
-
if (fs.existsSync(jsMainFilePath)) {
|
528
|
-
const { functions } = this.loadFunctionsFromFile(jsMainFilePath);
|
529
|
-
if (functions.length > 0) {
|
530
|
-
console.log(`[+] Imported JavaScript native [${functions.join(', ').cyan}] from module ${moduleKey.green}`);
|
531
|
-
}
|
532
|
-
} else {
|
533
|
-
console.warn(`[!] Main JS file '${jsMainFilePath.red}' not found for module '${moduleKey.red}'. Skipping JS function loading.`);
|
534
|
-
}
|
314
|
+
if (!name && !!!importModuleConfig?.spellcraft_module_default_name) {
|
315
|
+
// console.log("Package config:", moduleJson);
|
316
|
+
throw new Error(`[!] No import name specified for ${npmPackage.blue}, and it has no 'spellcraft_module_default_name' in its package.json config.`.red);
|
535
317
|
}
|
536
|
-
|
537
318
|
|
538
|
-
//
|
539
|
-
|
540
|
-
const targetLibsonnetPath = path.resolve(__dirname, '..', 'modules', `${moduleKey}.libsonnet`);
|
541
|
-
|
542
|
-
// Check for `module.libsonnet` or a path specified in package.json (e.g., spellcraft.libsonnet_module)
|
543
|
-
let sourceLibsonnetPath;
|
544
|
-
const spellcraftConfig = packageConfig.config || packageConfig.spellcraft;
|
545
|
-
if (spellcraftConfig?.libsonnet_module) {
|
546
|
-
sourceLibsonnetPath = path.resolve(baseDir, 'node_modules', npmPackageName, spellcraftConfig.libsonnet_module);
|
547
|
-
} else {
|
548
|
-
// Default to 'module.libsonnet' in the package's root or main file's directory
|
549
|
-
const defaultLibsonnetName = 'module.libsonnet';
|
550
|
-
const packageRootDir = path.resolve(baseDir, 'node_modules', npmPackageName);
|
551
|
-
sourceLibsonnetPath = path.join(packageRootDir, defaultLibsonnetName);
|
552
|
-
if (!fs.existsSync(sourceLibsonnetPath) && packageConfig.main) {
|
553
|
-
sourceLibsonnetPath = path.join(path.dirname(path.resolve(packageRootDir, packageConfig.main)), defaultLibsonnetName);
|
554
|
-
}
|
555
|
-
}
|
319
|
+
// Only link if this package is not a module itself.
|
320
|
+
if (!!!currentPackageConfig?.spellcraft_module_default_name) {
|
556
321
|
|
557
|
-
|
558
|
-
|
559
|
-
|
560
|
-
fs.mkdirSync(path.dirname(targetLibsonnetPath), { recursive: true });
|
561
|
-
fs.copyFileSync(sourceLibsonnetPath, targetLibsonnetPath);
|
562
|
-
|
563
|
-
console.log(`[+] Linked libsonnet module for ${npmPackageName == '..' ? 'this package'.blue : npmPackageName.green} as modules.${moduleKey.green}`);
|
564
|
-
} catch (e) {
|
565
|
-
console.warn(`[!] Failed to copy libsonnet module for '${moduleKey}': ${e.message}`);
|
322
|
+
const packagesDirPath = path.join(this.currentPackagePath, 'spellcraft_modules');
|
323
|
+
if (!fs.existsSync(packagesDirPath)) {
|
324
|
+
fs.mkdirSync(packagesDirPath, { recursive: true });
|
566
325
|
}
|
567
|
-
} else {
|
568
|
-
// console.log(`[+] No .libsonnet module found for '${moduleKey}' at expected paths.`);
|
569
|
-
}
|
570
|
-
|
571
|
-
return moduleKey;
|
572
|
-
}
|
573
|
-
|
574
|
-
|
575
|
-
/**
|
576
|
-
* Loads native functions from a list of JavaScript module files and generates
|
577
|
-
* an aggregate Jsonnet import file (`modules` by default convention).
|
578
|
-
* @param {string[]} jsModuleFiles - An array of absolute paths to JS module files.
|
579
|
-
* @param {string} aggregateModuleFile - The path to the Jsonnet file that will aggregate imports.
|
580
|
-
* @returns {{registeredFunctions: string[], magicContent: string[]}}
|
581
|
-
* An object containing lists of registered function names and the Jsonnet "magic" import strings.
|
582
|
-
*/
|
583
|
-
loadModulesFromFileList(jsModuleFiles) {
|
584
|
-
let allRegisteredFunctions = [];
|
585
|
-
let allMagicContent = [];
|
586
326
|
|
587
|
-
|
588
|
-
|
589
|
-
|
590
|
-
|
591
|
-
|
592
|
-
|
593
|
-
|
327
|
+
const packagesFilePath = path.join(packagesDirPath, 'packages.json');
|
328
|
+
let packages = {};
|
329
|
+
if (fs.existsSync(packagesFilePath)) {
|
330
|
+
try {
|
331
|
+
packages = JSON.parse(fs.readFileSync(packagesFilePath, 'utf-8'));
|
332
|
+
} catch (e) {
|
333
|
+
console.warn(`[!] Could not parse existing ${packagesFilePath}. Starting fresh. Error: ${e.message}`.red);
|
334
|
+
packages = {};
|
335
|
+
}
|
594
336
|
}
|
595
|
-
});
|
596
337
|
|
597
|
-
|
598
|
-
|
338
|
+
// Derive the base name to store (e.g., "my-package" from "my-package@1.0.0")
|
339
|
+
const npmPackageBaseName = npmPackage.startsWith("@") ?
|
340
|
+
`@${npmPackage.split('/')[1].split('@')[0]}` : // Handles @scope/name and @scope/name@version
|
341
|
+
npmPackage.split('@')[0]; // Handles name and name@version
|
599
342
|
|
343
|
+
const packagesKey = name || importModuleConfig.spellcraft_module_default_name;
|
344
|
+
packages[npmPackage] = packagesKey; // Store the clean package name
|
600
345
|
|
601
|
-
|
602
|
-
|
603
|
-
|
604
|
-
|
605
|
-
|
606
|
-
|
607
|
-
*/
|
608
|
-
loadFunctionsFromFile(file) {
|
609
|
-
let moduleExports;
|
610
|
-
try {
|
611
|
-
// Bust require cache for potentially updated modules during a session (e.g. dev mode)
|
612
|
-
delete require.cache[require.resolve(file)];
|
613
|
-
moduleExports = require(file);
|
614
|
-
} catch (e) {
|
615
|
-
throw new Error(`SpellCraft Error: Could not require module ${file}. ${e.message}`);
|
346
|
+
fs.writeFileSync(packagesFilePath, JSON.stringify(packages, null, "\t"));
|
347
|
+
console.log(`[+] Linked ${npmPackage} as SpellCraft module '${packagesKey}'`);
|
348
|
+
|
349
|
+
} else {
|
350
|
+
console.log(`[*] Module installed, but not linked because the current project is also a module.`);
|
351
|
+
console.log(`---> You can use the module's JS native functions, or import its JSonnet modules.`);
|
616
352
|
}
|
353
|
+
}
|
617
354
|
|
618
|
-
|
619
|
-
|
620
|
-
|
355
|
+
async render(file) {
|
356
|
+
const absoluteFilePath = path.resolve(file);
|
357
|
+
if (!fs.existsSync(absoluteFilePath)) {
|
358
|
+
throw new Error(`SpellCraft Render Error: Input file ${absoluteFilePath} does not exist.`);
|
621
359
|
}
|
622
360
|
|
623
|
-
|
624
|
-
.filter(key => key !== '_spellcraft_metadata' && typeof moduleExports[key] !== 'undefined')
|
625
|
-
.map(funcName => {
|
626
|
-
let func, params;
|
627
|
-
|
628
|
-
if (typeof moduleExports[funcName] === "object" && Array.isArray(moduleExports[funcName])) {
|
629
|
-
// Expects [function, paramName1, paramName2, ...]
|
630
|
-
[func, ...params] = moduleExports[funcName];
|
631
|
-
} else if (typeof moduleExports[funcName] === "function") {
|
632
|
-
func = moduleExports[funcName];
|
633
|
-
params = getFunctionParameterList(func); // Auto-detect params
|
634
|
-
} else {
|
635
|
-
// console.warn(`[!] Skipping non-function export '${funcName}' in module ${file}.`);
|
636
|
-
return null; // Skip if not a function or recognized array structure
|
637
|
-
}
|
638
|
-
|
639
|
-
if (typeof func !== 'function') {
|
640
|
-
// console.warn(`[!] Export '${funcName}' in module ${file} is not a valid function for native binding.`);
|
641
|
-
return null;
|
642
|
-
}
|
643
|
-
|
644
|
-
// For `modules` to provide convenient wrappers:
|
645
|
-
// e.g. myNativeFunc(a,b):: std.native('myNativeFunc')(a,b)
|
646
|
-
const paramString = params.join(', ');
|
647
|
-
magicContentSnippets.push(` ${funcName}(${paramString}):: std.native('${funcName}')(${paramString})`);
|
648
|
-
|
649
|
-
this.addNativeFunction(funcName, func, ...params);
|
650
|
-
return funcName;
|
651
|
-
}).filter(Boolean); // Remove nulls from skipped items
|
361
|
+
this.activePath = path.dirname(absoluteFilePath); // Set active path for relative 'path()' calls
|
652
362
|
|
653
|
-
|
363
|
+
Object.keys(this.magicContent).forEach(e => {
|
364
|
+
fs.writeFileSync(path.join(this.modulePath, e), `{\n${this.magicContent[e].join(",\n")}\n}`, 'utf-8');
|
365
|
+
console.log(`[+] Registered native functions [${this.registeredFunctions[e].join(', ').cyan}] to modules.${e.green} `);
|
366
|
+
});
|
367
|
+
|
368
|
+
console.log(`[+] Evaluating Jsonnet file ${path.basename(absoluteFilePath).green}`);
|
369
|
+
this.lastRender = JSON.parse(await this.jsonnet.evaluateFile(absoluteFilePath));
|
370
|
+
|
371
|
+
return this.lastRender;
|
654
372
|
}
|
655
373
|
|
656
|
-
/**
|
657
|
-
* Returns the string representation of the last render, or null.
|
658
|
-
* By default, this might return the object itself if `lastRender` is an object.
|
659
|
-
* @returns {object|string|null} The last rendered output, or null if no render has occurred.
|
660
|
-
*/
|
661
374
|
toString() {
|
662
|
-
return this.lastRender ?? null;
|
663
|
-
// If a string is always desired, use JSON.stringify or similar.
|
375
|
+
return this.lastRender ?? null;
|
664
376
|
}
|
665
377
|
|
666
|
-
/**
|
667
|
-
* Writes the rendered files to the configured `renderPath`.
|
668
|
-
* Applies appropriate file handlers based on filename patterns.
|
669
|
-
* @param {object} [filesToWrite=this.lastRender] - An object where keys are filenames
|
670
|
-
* and values are the content (typically from Jsonnet evaluation).
|
671
|
-
* @returns {this} The SpellFrame instance for chaining.
|
672
|
-
* @throws {Error} If `renderPath` cannot be created or files cannot be written.
|
673
|
-
*/
|
674
378
|
write(filesToWrite = this.lastRender) {
|
675
379
|
if (!filesToWrite || typeof filesToWrite !== 'object' || Object.keys(filesToWrite).length === 0) {
|
676
380
|
console.log("[+] No files to write from the last render or provided input.");
|