@c6fc/spellcraft 0.0.5 → 0.0.6

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