@c6fc/spellcraft 0.0.5 → 0.0.7
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/bin/spellcraft.js +11 -3
- package/package.json +1 -1
- package/src/index.js +218 -494
package/bin/spellcraft.js
CHANGED
@@ -78,18 +78,26 @@ const spellframe = new SpellFrame();
|
|
78
78
|
describe: 'Jsonnet configuration file to consume',
|
79
79
|
type: 'string',
|
80
80
|
demandOption: true,
|
81
|
+
}).option('skip-module-cleanup', {
|
82
|
+
alias: 's',
|
83
|
+
type: 'boolean',
|
84
|
+
description: 'Leave temporary modules intact after rendering'
|
81
85
|
});
|
82
86
|
},
|
83
87
|
async (argv) => { // No JSDoc for internal handler
|
84
|
-
try {
|
88
|
+
// try {
|
89
|
+
if (argv['s']) {
|
90
|
+
sfInstance.cleanModulesAfterRender = false;
|
91
|
+
}
|
92
|
+
|
85
93
|
await sfInstance.init();
|
86
94
|
await sfInstance.render(argv.filename);
|
87
95
|
await sfInstance.write();
|
88
96
|
console.log("[+] Generation complete.");
|
89
|
-
} catch (error) {
|
97
|
+
/*} catch (error) {
|
90
98
|
console.error(`[!] Error during generation: ${error.message.red}`);
|
91
99
|
process.exit(1);
|
92
|
-
}
|
100
|
+
}*/
|
93
101
|
})
|
94
102
|
|
95
103
|
.command("importModule <npmPackage> [name]", "Configures the current project to use a SpellCraft plugin as an import", (yargsInstance) => {
|
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,148 +17,16 @@ 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
|
-
renderPath: "
|
26
|
+
renderPath: "render",
|
27
|
+
spellcraftModuleRelativePath: ".spellcraft_linked_modules",
|
171
28
|
cleanBeforeRender: true,
|
29
|
+
cleanModulesAfterRender: true,
|
172
30
|
useDefaultFileHandlers: true
|
173
31
|
};
|
174
32
|
|
@@ -178,41 +36,54 @@ exports.SpellFrame = class SpellFrame {
|
|
178
36
|
this.initFn = [];
|
179
37
|
this._cache = {}; // Initialize cache
|
180
38
|
this.cliExtensions = [];
|
39
|
+
this.currentPackage = this.getCwdPackage();
|
40
|
+
this.currentPackagePath = this.getCwdPackagePath();
|
181
41
|
this.fileTypeHandlers = (this.useDefaultFileHandlers) ? { ...defaultFileTypeHandlers } : {};
|
182
42
|
this.functionContext = {};
|
183
43
|
this.lastRender = null;
|
184
44
|
this.activePath = null;
|
185
45
|
this.loadedModules = [];
|
46
|
+
this.magicContent = {}; // { modulefile: [...snippets] }
|
47
|
+
this.registeredFunctions = {}; // { modulefile: [...functionNames] }
|
186
48
|
|
187
|
-
this.
|
188
|
-
|
189
|
-
.addJpath(path.join(__dirname, '../modules')); // For dynamically generated module imports
|
49
|
+
this.renderPath = path.resolve(this.currentPackagePath, this.renderPath);
|
50
|
+
this.modulePath = path.resolve(this.currentPackagePath, this.spellcraftModuleRelativePath);
|
190
51
|
|
191
|
-
|
192
|
-
|
193
|
-
this.
|
52
|
+
this.jsonnet = new Jsonnet();
|
53
|
+
|
54
|
+
this.addJpath(path.join(__dirname, '../lib')) // For core SpellCraft libsonnet files
|
55
|
+
.addJpath(path.join(this.modulePath)) // For dynamically generated module imports
|
56
|
+
.addNativeFunction("envvar", (name) => process.env[name] || false, "name")
|
57
|
+
.addNativeFunction("path", () => this.activePath || process.cwd()) // Use activePath if available
|
58
|
+
.cleanModulePath();
|
194
59
|
|
195
60
|
this.loadedModules = this.loadModulesFromPackageList();
|
61
|
+
this.loadModulesFromModuleDirectory();
|
62
|
+
|
63
|
+
return this;
|
64
|
+
}
|
65
|
+
|
66
|
+
cleanModulePath() {
|
67
|
+
if (!fs.existsSync(this.modulePath)) {
|
68
|
+
fs.mkdirSync(this.modulePath, { recursive: true });
|
69
|
+
}
|
70
|
+
|
71
|
+
try {
|
72
|
+
fs.readdirSync(this.modulePath)
|
73
|
+
.map(e => path.join(this.modulePath, e))
|
74
|
+
.forEach(e => fs.unlinkSync(e));
|
75
|
+
|
76
|
+
} catch (e) {
|
77
|
+
throw new Error(`[!] Could not create/clean up temporary module folder ${path.dirname(this.modulePath).green}: ${e.message.red}`);
|
78
|
+
}
|
79
|
+
|
80
|
+
return this;
|
196
81
|
}
|
197
82
|
|
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
83
|
_generateCacheKey(functionName, args) {
|
206
84
|
return crypto.createHash('sha256').update(JSON.stringify([functionName, ...args])).digest('hex');
|
207
85
|
}
|
208
86
|
|
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
87
|
addFileTypeHandler(pattern, handler) {
|
217
88
|
// Making it writable: false by default is a strong choice.
|
218
89
|
// If flexibility to override is needed later, this could be a simple assignment.
|
@@ -225,14 +96,6 @@ exports.SpellFrame = class SpellFrame {
|
|
225
96
|
return this;
|
226
97
|
}
|
227
98
|
|
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
99
|
addNativeFunction(name, func, ...parameters) {
|
237
100
|
this.jsonnet.nativeCallback(name, (...args) => {
|
238
101
|
const key = this._generateCacheKey(name, args);
|
@@ -248,26 +111,6 @@ exports.SpellFrame = class SpellFrame {
|
|
248
111
|
return this;
|
249
112
|
}
|
250
113
|
|
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
114
|
extendWithModuleMetadata(metadata) {
|
272
115
|
if (metadata.fileTypeHandlers) {
|
273
116
|
Object.entries(metadata.fileTypeHandlers).forEach(([pattern, handler]) => {
|
@@ -285,181 +128,161 @@ exports.SpellFrame = class SpellFrame {
|
|
285
128
|
return this;
|
286
129
|
}
|
287
130
|
|
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
131
|
addJpath(jpath) {
|
294
|
-
|
132
|
+
console.log(`[*] Adding Jpath ${jpath}`);
|
133
|
+
this.jsonnet.addJpath(jpath);
|
295
134
|
return this;
|
296
135
|
}
|
297
136
|
|
298
|
-
/**
|
299
|
-
* Runs all registered initialization functions.
|
300
|
-
* @async
|
301
|
-
* @returns {Promise<void>}
|
302
|
-
*/
|
303
137
|
async init() {
|
304
138
|
for (const step of this.initFn) {
|
305
139
|
await step.call();
|
306
140
|
}
|
307
141
|
}
|
308
142
|
|
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
|
-
});
|
143
|
+
getCwdPackage() {
|
144
|
+
return require(path.resolve(this.getCwdPackagePath(), 'package.json'));
|
145
|
+
}
|
328
146
|
|
329
|
-
|
330
|
-
|
331
|
-
|
147
|
+
getCwdPackagePath() {
|
148
|
+
let depth = 0;
|
149
|
+
const maxdepth = 3
|
150
|
+
let checkPath = process.cwd();
|
332
151
|
|
333
|
-
|
152
|
+
while (!fs.existsSync(path.join(checkPath, 'package.json')) && depth < maxdepth) {
|
153
|
+
path = path.join(checkPath, '..');
|
154
|
+
depth++;
|
334
155
|
}
|
335
156
|
|
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);
|
157
|
+
if (fs.existsSync(path.join(checkPath, 'package.json'))) {
|
158
|
+
return checkPath;
|
344
159
|
}
|
345
160
|
|
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
|
-
}
|
161
|
+
return false;
|
162
|
+
}
|
364
163
|
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
|
164
|
+
getModulePackage(name) {
|
165
|
+
// For backwards compatability
|
166
|
+
if (name == '..') {
|
167
|
+
return this.currentPackage;
|
168
|
+
}
|
369
169
|
|
370
|
-
|
371
|
-
|
170
|
+
return require(require.resolve(name, { paths: [this.currentPackagePath] }));
|
171
|
+
}
|
372
172
|
|
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.`);
|
173
|
+
getModulePackagePath(name) {
|
174
|
+
// For backwards compatability
|
175
|
+
if (name == '..') {
|
176
|
+
return this.currentPackagePath;
|
379
177
|
}
|
178
|
+
|
179
|
+
return path.dirname(require.resolve(name, { paths: [this.currentPackagePath] }));
|
380
180
|
}
|
381
181
|
|
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.`);
|
182
|
+
loadFunctionsFromFile(file, as) {
|
183
|
+
|
184
|
+
const moduleExports = require(file);
|
185
|
+
|
186
|
+
const magicContentSnippets = [];
|
187
|
+
if (moduleExports._spellcraft_metadata) {
|
188
|
+
this.extendWithModuleMetadata(moduleExports._spellcraft_metadata);
|
394
189
|
}
|
395
190
|
|
396
|
-
|
191
|
+
const registeredFunctionNames = Object.keys(moduleExports)
|
192
|
+
.filter(key => key !== '_spellcraft_metadata' && typeof moduleExports[key] !== 'undefined')
|
193
|
+
.map(funcName => {
|
194
|
+
let func, params;
|
397
195
|
|
398
|
-
|
399
|
-
|
196
|
+
if (typeof moduleExports[funcName] === "object" && Array.isArray(moduleExports[funcName])) {
|
197
|
+
// Expects [function, paramName1, paramName2, ...]
|
198
|
+
[func, ...params] = moduleExports[funcName];
|
199
|
+
}
|
400
200
|
|
401
|
-
|
402
|
-
|
403
|
-
|
404
|
-
|
405
|
-
|
201
|
+
if (typeof func !== 'function') {
|
202
|
+
console.warn(`[!] Export '${funcName}' in module ${file} is not a valid function for native binding.`);
|
203
|
+
return null;
|
204
|
+
}
|
205
|
+
|
206
|
+
// For `modules` to provide convenient wrappers:
|
207
|
+
// e.g. myNativeFunc(a,b):: std.native('myNativeFunc')(a,b)
|
208
|
+
const paramString = params.join(', ');
|
209
|
+
magicContentSnippets.push(`\t${funcName}(${paramString}):: std.native('${funcName}')(${paramString})`);
|
406
210
|
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
}
|
211
|
+
this.addNativeFunction(funcName, func, ...params);
|
212
|
+
return funcName;
|
213
|
+
}).filter(Boolean); // Remove nulls from skipped items
|
411
214
|
|
412
|
-
|
413
|
-
|
414
|
-
}));
|
215
|
+
this.registeredFunctions[as] = registeredFunctionNames;
|
216
|
+
this.magicContent[as] = magicContentSnippets;
|
415
217
|
|
416
|
-
|
218
|
+
return this;
|
219
|
+
}
|
417
220
|
|
418
|
-
|
419
|
-
|
420
|
-
}
|
221
|
+
loadModulesFromPackageList() {
|
222
|
+
const packagesConfigPath = path.join(this.currentPackagePath, 'spellcraft_modules', 'packages.json');
|
421
223
|
|
422
|
-
if (
|
423
|
-
console.log(
|
224
|
+
if (!fs.existsSync(packagesConfigPath)) {
|
225
|
+
// console.log('[+] No spellcraft_modules/packages.json file found. Skipping package-based module import.');
|
226
|
+
return [];
|
424
227
|
}
|
425
228
|
|
426
|
-
|
427
|
-
|
428
|
-
|
229
|
+
let packages;
|
230
|
+
try {
|
231
|
+
packages = JSON.parse(fs.readFileSync(packagesConfigPath, 'utf-8'));
|
232
|
+
} catch (e) {
|
233
|
+
console.error(`[!] Error parsing ${packagesConfigPath.green}: ${e.message.red}. Skipping package-based module import.`);
|
234
|
+
return [];
|
429
235
|
}
|
236
|
+
|
237
|
+
return Object.entries(packages).map(([npmPackageName, moduleKey]) => {
|
238
|
+
this.loadModuleByName(moduleKey, npmPackageName);
|
239
|
+
return moduleKey;
|
240
|
+
});
|
241
|
+
}
|
430
242
|
|
431
|
-
|
432
|
-
this.
|
243
|
+
loadCurrentPackageAsModule(moduleKey) {
|
244
|
+
return this.loadModuleByName(moduleKey, '..');
|
245
|
+
}
|
433
246
|
|
434
|
-
|
435
|
-
|
436
|
-
|
437
|
-
const modulePath = path.resolve(__dirname, '../modules');
|
247
|
+
loadModuleByName(moduleKey, npmPackageName) {
|
248
|
+
const importModuleConfig = this.getModulePackage(npmPackageName);
|
249
|
+
const importModulePath = this.getModulePackagePath(npmPackageName);
|
438
250
|
|
439
|
-
|
440
|
-
|
441
|
-
|
251
|
+
this.loadFunctionsFromFile(path.resolve(importModulePath, 'module.js'), moduleKey);
|
252
|
+
|
253
|
+
const sourceLibsonnetPath = path.resolve(importModulePath, 'module.libsonnet');
|
254
|
+
const targetLibsonnetPath = path.resolve(this.modulePath, `${moduleKey}.libsonnet`);
|
442
255
|
|
443
|
-
|
444
|
-
|
256
|
+
if (fs.existsSync(targetLibsonnetPath)) {
|
257
|
+
throw new Error(`[!] Module library ${path.basename(targetLibsonnetPath)} already exists. This means there is a conflict with package link names.`);
|
445
258
|
}
|
446
259
|
|
447
|
-
|
260
|
+
fs.copyFileSync(sourceLibsonnetPath, targetLibsonnetPath);
|
261
|
+
|
262
|
+
console.log(`[+] Linked ${(npmPackageName == '..') ? 'this package'.green : npmPackageName.green} as ${path.basename(targetLibsonnetPath).green}`);
|
263
|
+
|
264
|
+
return this;
|
265
|
+
}
|
266
|
+
|
267
|
+
loadModulesFromFileList(jsModuleFiles, as) {
|
268
|
+
let allRegisteredFunctions = [];
|
269
|
+
let allMagicContent = [];
|
270
|
+
|
271
|
+
jsModuleFiles.forEach(file => {
|
272
|
+
this.loadFunctionsFromFile(file, as);
|
273
|
+
console.log(`[+] Loaded [${this.registeredFunctions[as].join(', ').cyan}] from ${path.basename(file).green} into modules.${as.green}`);
|
274
|
+
});
|
275
|
+
|
276
|
+
return this;
|
448
277
|
}
|
449
278
|
|
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
279
|
loadModulesFromModuleDirectory() {
|
455
|
-
const spellcraftModulesPath = path.join(
|
280
|
+
const spellcraftModulesPath = path.join(this.currentPackagePath, 'spellcraft_modules');
|
456
281
|
if (!fs.existsSync(spellcraftModulesPath)) {
|
457
|
-
return
|
282
|
+
return this;
|
458
283
|
}
|
459
284
|
|
460
|
-
|
461
|
-
|
462
|
-
if (!!spellcraftConfig?.spellcraft_module_default_name) {
|
285
|
+
if (!!this.currentPackage?.config?.spellcraft_module_default_name) {
|
463
286
|
console.log("[-] This package is a SpellCraft module. Skipping directory-based module import.");
|
464
287
|
return { registeredFunctions: [], magicContent: [] };
|
465
288
|
}
|
@@ -468,209 +291,110 @@ exports.SpellFrame = class SpellFrame {
|
|
468
291
|
.filter(f => f.endsWith('.js')) // Simpler check for .js files
|
469
292
|
.map(f => path.join(spellcraftModulesPath, f));
|
470
293
|
|
471
|
-
return this.loadModulesFromFileList(jsModuleFiles);
|
294
|
+
return this.loadModulesFromFileList(jsModuleFiles, 'modules');
|
472
295
|
}
|
473
296
|
|
297
|
+
async importSpellCraftModuleFromNpm(npmPackage, name = false) {
|
298
|
+
if (!fs.existsSync(this.getModulePackagePath(npmPackage))) {
|
299
|
+
console.log(`[*] Attempting to install ${npmPackage.blue}...`);
|
300
|
+
|
301
|
+
const install = spawnSync(`npm`, ['install', '--save', npmPackage], {
|
302
|
+
cwd: baseDir,
|
303
|
+
stdio: 'inherit'
|
304
|
+
});
|
474
305
|
|
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');
|
306
|
+
if (install.error || install.status !== 0) {
|
307
|
+
throw new Error(`Failed to install npm package ${npmPackage.blue}. Error: ${install.error.red || install.stderr.toString().red}`);
|
308
|
+
}
|
482
309
|
|
483
|
-
|
484
|
-
// console.log('[+] No spellcraft_modules/packages.json file found. Skipping package-based module import.');
|
485
|
-
return [];
|
310
|
+
console.log(`[+] Successfully installed ${npmPackage.blue}.`);
|
486
311
|
}
|
487
312
|
|
488
|
-
|
489
|
-
|
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
|
-
}
|
313
|
+
const importModuleConfig = this.getModulePackage(`${npmPackage}/package.json`).config;
|
314
|
+
const currentPackageConfig = this.currentPackage.config;
|
500
315
|
|
501
|
-
|
502
|
-
|
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;
|
316
|
+
if (!name && !!!importModuleConfig?.spellcraft_module_default_name) {
|
317
|
+
throw new Error(`[!] No import name specified for ${npmPackage.blue}, and it has no 'spellcraft_module_default_name' in its package.json config.`.red);
|
513
318
|
}
|
514
319
|
|
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
|
-
}
|
320
|
+
// Only link if this package is not a module itself.
|
321
|
+
if (!!!currentPackageConfig?.spellcraft_module_default_name) {
|
522
322
|
|
523
|
-
|
524
|
-
|
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.`);
|
323
|
+
const packagesDirPath = path.join(this.currentPackagePath, 'spellcraft_modules');
|
324
|
+
if (!fs.existsSync(packagesDirPath)) {
|
325
|
+
fs.mkdirSync(packagesDirPath, { recursive: true });
|
534
326
|
}
|
535
|
-
}
|
536
|
-
|
537
327
|
|
538
|
-
|
539
|
-
|
540
|
-
|
541
|
-
|
542
|
-
|
543
|
-
|
544
|
-
|
545
|
-
|
546
|
-
|
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);
|
328
|
+
const packagesFilePath = path.join(packagesDirPath, 'packages.json');
|
329
|
+
let packages = {};
|
330
|
+
if (fs.existsSync(packagesFilePath)) {
|
331
|
+
try {
|
332
|
+
packages = JSON.parse(fs.readFileSync(packagesFilePath, 'utf-8'));
|
333
|
+
} catch (e) {
|
334
|
+
console.warn(`[!] Could not parse existing ${packagesFilePath}. Starting fresh. Error: ${e.message}`.red);
|
335
|
+
packages = {};
|
336
|
+
}
|
554
337
|
}
|
555
|
-
}
|
556
338
|
|
557
|
-
|
558
|
-
|
559
|
-
//
|
560
|
-
|
561
|
-
|
562
|
-
|
563
|
-
|
564
|
-
|
565
|
-
|
566
|
-
}
|
339
|
+
// Derive the base name to store (e.g., "my-package" from "my-package@1.0.0")
|
340
|
+
const npmPackageBaseName = npmPackage.startsWith("@") ?
|
341
|
+
`@${npmPackage.split('/')[1].split('@')[0]}` : // Handles @scope/name and @scope/name@version
|
342
|
+
npmPackage.split('@')[0]; // Handles name and name@version
|
343
|
+
|
344
|
+
const packagesKey = name || importModuleConfig.spellcraft_module_default_name;
|
345
|
+
packages[npmPackage] = packagesKey; // Store the clean package name
|
346
|
+
|
347
|
+
fs.writeFileSync(packagesFilePath, JSON.stringify(packages, null, "\t"));
|
348
|
+
console.log(`[+] Linked ${npmPackage} as SpellCraft module '${packagesKey}'`);
|
349
|
+
|
567
350
|
} else {
|
568
|
-
|
351
|
+
console.log(`[*] Module installed, but not linked because the current project is also a module.`);
|
352
|
+
console.log(`---> You can use the module's JS native functions, or import its JSonnet modules.`);
|
569
353
|
}
|
570
|
-
|
571
|
-
return moduleKey;
|
572
354
|
}
|
573
355
|
|
356
|
+
async render(file) {
|
357
|
+
const absoluteFilePath = path.resolve(file);
|
358
|
+
if (!fs.existsSync(absoluteFilePath)) {
|
359
|
+
throw new Error(`SpellCraft Render Error: Input file ${absoluteFilePath} does not exist.`);
|
360
|
+
}
|
574
361
|
|
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
|
-
|
587
|
-
jsModuleFiles.forEach(file => {
|
588
|
-
try {
|
589
|
-
const { functions, magic } = this.loadFunctionsFromFile(file);
|
590
|
-
allRegisteredFunctions.push(...functions);
|
591
|
-
allMagicContent.push(...magic);
|
592
|
-
} catch (e) {
|
593
|
-
console.warn(`[!] Failed to load functions from module ${file}: ${e.message}`);
|
594
|
-
}
|
595
|
-
});
|
596
|
-
|
597
|
-
return { registeredFunctions: allRegisteredFunctions, magicContent: allMagicContent };
|
598
|
-
}
|
599
|
-
|
362
|
+
this.activePath = path.dirname(absoluteFilePath); // Set active path for relative 'path()' calls
|
600
363
|
|
601
|
-
|
602
|
-
|
603
|
-
|
604
|
-
* @param {string} file - Absolute path to the JavaScript module file.
|
605
|
-
* @returns {{functions: string[], magic: string[]}} Registered function names and "magic" Jsonnet strings for them.
|
606
|
-
* @throws {Error} If the file cannot be required.
|
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}`);
|
616
|
-
}
|
364
|
+
this.magicContent.modules.push(this.loadedModules.flatMap(e => {
|
365
|
+
return `\t${e}:: import '${e}.libsonnet'`;
|
366
|
+
}));
|
617
367
|
|
618
|
-
|
619
|
-
|
620
|
-
this.
|
368
|
+
if (this.registeredFunctions.modules.length > 0) {
|
369
|
+
fs.writeFileSync(path.join(this.modulePath, `modules`), `{\n${this.magicContent.modules.join(",\n")}\n}`, 'utf-8');
|
370
|
+
console.log(`[+] Registered native functions [${this.registeredFunctions.modules.join(', ').cyan}] to modules.${'modules'.green}`);
|
621
371
|
}
|
622
372
|
|
623
|
-
|
624
|
-
.filter(key => key !== '_spellcraft_metadata' && typeof moduleExports[key] !== 'undefined')
|
625
|
-
.map(funcName => {
|
626
|
-
let func, params;
|
373
|
+
delete this.magicContent.modules;
|
627
374
|
|
628
|
-
|
629
|
-
|
630
|
-
|
631
|
-
|
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
|
-
}
|
375
|
+
Object.keys(this.magicContent).forEach(e => {
|
376
|
+
fs.appendFileSync(path.join(this.modulePath, `${e}.libsonnet`), ` + {\n${this.magicContent[e].join(",\n")}\n}`, 'utf-8');
|
377
|
+
console.log(`[+] Registered native functions [${this.registeredFunctions[e].join(', ').cyan}] to modules.${e.green} `);
|
378
|
+
});
|
638
379
|
|
639
|
-
|
640
|
-
|
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})`);
|
380
|
+
console.log(`[+] Evaluating Jsonnet file ${path.basename(absoluteFilePath).green}`);
|
381
|
+
this.lastRender = JSON.parse(await this.jsonnet.evaluateFile(absoluteFilePath));
|
648
382
|
|
649
|
-
|
650
|
-
|
651
|
-
}).filter(Boolean); // Remove nulls from skipped items
|
383
|
+
if (this.cleanModulesAfterRender) {
|
384
|
+
this.cleanModulePath();
|
652
385
|
|
653
|
-
|
386
|
+
fs.rmdirSync(this.modulePath);
|
387
|
+
} else {
|
388
|
+
console.log(`[*] Leaving ${this.spellcraftModuleRelativePath} in place.`.magenta);
|
389
|
+
}
|
390
|
+
|
391
|
+
return this.lastRender;
|
654
392
|
}
|
655
393
|
|
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
394
|
toString() {
|
662
|
-
return this.lastRender ?? null;
|
663
|
-
// If a string is always desired, use JSON.stringify or similar.
|
395
|
+
return this.lastRender ?? null;
|
664
396
|
}
|
665
397
|
|
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
398
|
write(filesToWrite = this.lastRender) {
|
675
399
|
if (!filesToWrite || typeof filesToWrite !== 'object' || Object.keys(filesToWrite).length === 0) {
|
676
400
|
console.log("[+] No files to write from the last render or provided input.");
|