@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 +5 -10
- package/package.json +2 -1
- package/src/index.js +194 -493
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) => {
|
109
|
-
|
110
|
-
|
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.
|
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(
|
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.
|
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
|
-
|
309
|
-
|
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
|
-
|
143
|
+
getCwdPackagePath() {
|
144
|
+
let depth = 0;
|
145
|
+
const maxdepth = 3
|
146
|
+
let checkPath = process.cwd();
|
334
147
|
|
335
|
-
|
336
|
-
|
337
|
-
|
148
|
+
while (!fs.existsSync(path.join(checkPath, 'package.json')) && depth < maxdepth) {
|
149
|
+
path = path.join(checkPath, '..');
|
150
|
+
depth++;
|
338
151
|
}
|
339
152
|
|
340
|
-
|
341
|
-
|
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
|
-
|
378
|
-
|
379
|
-
|
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
|
-
|
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
|
-
|
396
|
-
|
169
|
+
getModulePackagePath(name) {
|
170
|
+
// For backwards compatability
|
171
|
+
if (name == '..') {
|
172
|
+
return this.currentPackagePath;
|
397
173
|
}
|
398
|
-
|
399
174
|
|
400
|
-
this.
|
175
|
+
return path.dirname(require.resolve(name, { paths: [this.currentPackagePath] }));
|
176
|
+
}
|
401
177
|
|
402
|
-
|
403
|
-
|
404
|
-
|
405
|
-
}
|
178
|
+
loadFunctionsFromFile(file, as) {
|
179
|
+
|
180
|
+
const moduleExports = require(file);
|
406
181
|
|
407
|
-
|
408
|
-
|
409
|
-
this.
|
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
|
-
|
415
|
-
|
416
|
-
|
417
|
-
|
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
|
-
|
432
|
-
|
433
|
-
|
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
|
-
|
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
|
-
|
447
|
-
|
448
|
-
|
449
|
-
}
|
207
|
+
this.addNativeFunction(funcName, func, ...params);
|
208
|
+
return funcName;
|
209
|
+
}).filter(Boolean); // Remove nulls from skipped items
|
450
210
|
|
451
|
-
|
452
|
-
|
453
|
-
.map(f => path.join(spellcraftModulesPath, f));
|
211
|
+
this.registeredFunctions[as] = registeredFunctionNames;
|
212
|
+
this.magicContent[as] = magicContentSnippets;
|
454
213
|
|
455
|
-
return this
|
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(
|
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
|
-
|
482
|
-
|
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
|
494
|
-
|
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
|
-
|
500
|
-
|
501
|
-
|
502
|
-
|
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 (
|
508
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
574
|
-
|
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
|
-
|
583
|
-
|
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
|
-
|
590
|
-
|
591
|
-
|
592
|
-
|
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
|
-
|
596
|
-
|
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
|
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
|
-
|
606
|
-
|
607
|
-
|
608
|
-
|
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
|
622
|
-
|
623
|
-
|
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
|
-
|
627
|
-
|
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
|
-
|
632
|
-
|
633
|
-
|
634
|
-
|
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
|
-
|
643
|
-
|
644
|
-
|
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
|
-
|
653
|
-
|
654
|
-
|
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
|
-
|
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;
|
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(
|
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
|