@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 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
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "name": "@c6fc/spellcraft",
10
10
  "description": "Extensible JSonnet CLI platform",
11
- "version": "0.0.5",
11
+ "version": "0.0.7",
12
12
  "main": "src/index.js",
13
13
  "directories": {
14
14
  "lib": "lib"
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: "./render",
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.jsonnet = new Jsonnet()
188
- .addJpath(path.join(__dirname, '../lib')) // For core SpellCraft libsonnet files
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
- // Add built-in native functions
192
- this.addNativeFunction("envvar", (name) => process.env[name] || false, "name");
193
- this.addNativeFunction("path", () => this.activePath || process.cwd()); // Use activePath if available
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
- this.jsonnet = this.jsonnet.addJpath(jpath);
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
- * Imports a SpellCraft module from an npm package.
311
- * This involves installing the package if not present, reading its package.json
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
- if (install.error || install.status !== 0) {
330
- throw new Error(`Failed to install npm package ${npmPackage.blue}. Error: ${install.error.red || install.stderr.toString().red}`);
331
- }
147
+ getCwdPackagePath() {
148
+ let depth = 0;
149
+ const maxdepth = 3
150
+ let checkPath = process.cwd();
332
151
 
333
- console.log(`[+] Successfully installed ${npmPackage.blue}.`);
152
+ while (!fs.existsSync(path.join(checkPath, 'package.json')) && depth < maxdepth) {
153
+ path = path.join(checkPath, '..');
154
+ depth++;
334
155
  }
335
156
 
336
- const moduleJson = JSON.parse(fs.readFileSync(path.join(npmPath, 'package.json')));
337
- const moduleConfig = moduleJson.config || moduleJson.spellcraft; // Allow 'spellcraft' key too
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
- // Only link if this package is not a module itself.
347
- if (!!!spellcraftConfig?.spellcraft_module_default_name) {
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
- // Derive the base name to store (e.g., "my-package" from "my-package@1.0.0")
366
- const npmPackageBaseName = npmPackage.startsWith("@") ?
367
- `@${npmPackage.split('/')[1].split('@')[0]}` : // Handles @scope/name and @scope/name@version
368
- npmPackage.split('@')[0]; // Handles name and name@version
164
+ getModulePackage(name) {
165
+ // For backwards compatability
166
+ if (name == '..') {
167
+ return this.currentPackage;
168
+ }
369
169
 
370
- const packagesKey = name || moduleConfig.spellcraft_module_default_name;
371
- packages[npmPackage] = packagesKey; // Store the clean package name
170
+ return require(require.resolve(name, { paths: [this.currentPackagePath] }));
171
+ }
372
172
 
373
- fs.writeFileSync(packagesFilePath, JSON.stringify(packages, null, "\t"));
374
- console.log(`[+] Linked ${npmPackage} as SpellCraft module '${packagesKey}'`);
375
-
376
- } else {
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
- * Evaluates a Jsonnet file and stores the result.
384
- * This also sets up the dynamic `modules/modules` import aggregator.
385
- * @param {string} file - The path to the main Jsonnet file to evaluate.
386
- * @async
387
- * @returns {Promise<object>} The parsed JSON object from the Jsonnet evaluation.
388
- * @throws {Error} If the file doesn't exist or if Jsonnet parsing fails.
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
- this.activePath = path.dirname(absoluteFilePath); // Set active path for relative 'path()' calls
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
- // Path to the dynamically generated libsonnet file that imports all modules
399
- const dynamicModulesImportFile = path.resolve(__dirname, '../modules/modules');
196
+ if (typeof moduleExports[funcName] === "object" && Array.isArray(moduleExports[funcName])) {
197
+ // Expects [function, paramName1, paramName2, ...]
198
+ [func, ...params] = moduleExports[funcName];
199
+ }
400
200
 
401
- if (fs.existsSync(dynamicModulesImportFile)) {
402
- fs.unlinkSync(dynamicModulesImportFile);
403
- }
404
-
405
- const { magicContent, registeredFunctions } = this.loadModulesFromModuleDirectory(dynamicModulesImportFile);
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
- const aggregateFileDir = path.dirname(dynamicModulesImportFile);
408
- if (!fs.existsSync(aggregateFileDir)) {
409
- fs.mkdirSync(aggregateFileDir, { recursive: true });
410
- }
211
+ this.addNativeFunction(funcName, func, ...params);
212
+ return funcName;
213
+ }).filter(Boolean); // Remove nulls from skipped items
411
214
 
412
- magicContent.push(this.loadedModules.flatMap(e => {
413
- return `\t${e}: import '${e}.libsonnet',`
414
- }));
215
+ this.registeredFunctions[as] = registeredFunctionNames;
216
+ this.magicContent[as] = magicContentSnippets;
415
217
 
416
- fs.writeFileSync(dynamicModulesImportFile, `{\n${magicContent.join(",\n")}\n}`, 'utf-8');
218
+ return this;
219
+ }
417
220
 
418
- if (registeredFunctions.length > 0) {
419
- console.log(`[+] Registered ${registeredFunctions.length} native function(s): [ ${registeredFunctions.sort().join(', ').cyan} ]`);
420
- }
221
+ loadModulesFromPackageList() {
222
+ const packagesConfigPath = path.join(this.currentPackagePath, 'spellcraft_modules', 'packages.json');
421
223
 
422
- if (magicContent.length > 0) {
423
- console.log(`[+] Aggregated modules written to '${path.basename(dynamicModulesImportFile).green}'`);
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
- // Ensure renderPath does not have a trailing slash for consistency
427
- if (this.renderPath.endsWith(path.sep)) {
428
- this.renderPath = this.renderPath.slice(0, -1);
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
- console.log(`[+] Evaluating Jsonnet file ${path.basename(absoluteFilePath).green}`);
432
- this.lastRender = JSON.parse(await this.jsonnet.evaluateFile(absoluteFilePath));
243
+ loadCurrentPackageAsModule(moduleKey) {
244
+ return this.loadModuleByName(moduleKey, '..');
245
+ }
433
246
 
434
- // Clean up the modules folder after rendering,
435
- // as it's specific to this render pass and its discovered modules.
436
- try {
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
- fs.readdirSync(modulePath)
440
- .map(e => path.join(modulePath, e))
441
- .forEach(e => fs.unlinkSync(e));
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
- } catch (e) {
444
- console.warn(`[!] Could not clean up temporary module file ${dynamicModulesImportFile}: ${e.message}`);
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
- return this.lastRender;
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(baseDir, 'spellcraft_modules');
280
+ const spellcraftModulesPath = path.join(this.currentPackagePath, 'spellcraft_modules');
456
281
  if (!fs.existsSync(spellcraftModulesPath)) {
457
- return { registeredFunctions: [], magicContent: [] };
282
+ return this;
458
283
  }
459
284
 
460
- const spellcraftConfig = thisPackage.config || thisPackage.spellcraft; // Allow 'spellcraft' key too
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
- * Loads SpellCraft module configurations and functions from `spellcraft_modules/packages.json`.
477
- * This involves reading linked npm packages and setting them up.
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
- if (!fs.existsSync(packagesConfigPath)) {
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
- 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
- }
313
+ const importModuleConfig = this.getModulePackage(`${npmPackage}/package.json`).config;
314
+ const currentPackageConfig = this.currentPackage.config;
500
315
 
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;
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
- let packageConfig;
516
- try {
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
- if (!packageConfig.main) {
524
- console.warn(`[!] 'main' field missing in package.json for module '${moduleKey}' (package: ${npmPackageName}). Skipping JS function loading.`.red);
525
- } else {
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
- // Define where the module's .libsonnet file (if any) should be copied to be found by Jsonnet
539
- // It will be copied to `../modules/<moduleKey>.libsonnet` (relative to this file)
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);
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
- if (fs.existsSync(sourceLibsonnetPath)) {
558
- try {
559
- // Ensure the target directory exists
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}`);
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
- // console.log(`[+] No .libsonnet module found for '${moduleKey}' at expected paths.`);
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
- * Loads functions and metadata from a single JavaScript module file.
603
- * Registers functions as native callbacks in Jsonnet and processes SpellCraft metadata.
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
- const magicContentSnippets = [];
619
- if (moduleExports._spellcraft_metadata) {
620
- this.extendWithModuleMetadata(moduleExports._spellcraft_metadata);
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
- const registeredFunctionNames = Object.keys(moduleExports)
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
- 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
- }
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
- 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})`);
380
+ console.log(`[+] Evaluating Jsonnet file ${path.basename(absoluteFilePath).green}`);
381
+ this.lastRender = JSON.parse(await this.jsonnet.evaluateFile(absoluteFilePath));
648
382
 
649
- this.addNativeFunction(funcName, func, ...params);
650
- return funcName;
651
- }).filter(Boolean); // Remove nulls from skipped items
383
+ if (this.cleanModulesAfterRender) {
384
+ this.cleanModulePath();
652
385
 
653
- return { functions: registeredFunctionNames, magic: magicContentSnippets };
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; // If lastRender is an object, it returns the object.
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.");