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