alchemymvc 1.4.0 → 1.4.1

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 (37) hide show
  1. package/lib/app/behaviour/revision_behaviour.js +1 -1
  2. package/lib/app/behaviour/sluggable_behaviour.js +2 -2
  3. package/lib/app/datasource/mongo_datasource.js +19 -3
  4. package/lib/app/helper/cron.js +2 -2
  5. package/lib/app/helper_datasource/00-nosql_datasource.js +9 -3
  6. package/lib/app/helper_datasource/05-fallback_datasource.js +2 -0
  7. package/lib/app/helper_datasource/idb_datasource.js +7 -5
  8. package/lib/app/helper_datasource/remote_datasource.js +1 -1
  9. package/lib/app/helper_field/password_field.js +4 -2
  10. package/lib/app/helper_field/schema_field.js +3 -2
  11. package/lib/app/helper_model/00-base_criteria.js +14 -0
  12. package/lib/app/helper_model/05-criteria_expressions.js +30 -7
  13. package/lib/app/helper_model/10-model_criteria.js +47 -8
  14. package/lib/app/helper_model/document.js +11 -2
  15. package/lib/app/helper_model/model.js +6 -3
  16. package/lib/class/conduit.js +5 -2
  17. package/lib/class/controller.js +1 -0
  18. package/lib/class/document.js +39 -11
  19. package/lib/class/import_stream_parser.js +299 -0
  20. package/lib/class/migration.js +5 -2
  21. package/lib/class/model.js +10 -140
  22. package/lib/class/plugin.js +32 -3
  23. package/lib/class/router.js +24 -27
  24. package/lib/class/schema_client.js +38 -7
  25. package/lib/class/sitemap.js +2 -2
  26. package/lib/core/alchemy.js +110 -162
  27. package/lib/core/alchemy_load_functions.js +64 -5
  28. package/lib/core/base.js +2 -2
  29. package/lib/core/middleware.js +31 -5
  30. package/lib/core/setting.js +12 -9
  31. package/lib/scripts/create_constants.js +5 -1
  32. package/lib/stages/00-load_core.js +8 -2
  33. package/lib/testing/browser.js +1164 -0
  34. package/lib/testing/harness.js +840 -0
  35. package/package.json +10 -3
  36. package/testing/browser.js +27 -0
  37. package/testing.js +37 -0
@@ -353,7 +353,7 @@ Sitemap.setMethod(function addRouteWithParameters(route, config, parameters, for
353
353
  *
354
354
  * @author Jelle De Loecker <jelle@elevenways.be>
355
355
  * @since 1.3.4
356
- * @version 1.3.6
356
+ * @version 1.4.1
357
357
  *
358
358
  * @param {string} prefix
359
359
  *
@@ -378,7 +378,7 @@ Sitemap.setMethod(function getXmlBuilder(prefix) {
378
378
  }
379
379
 
380
380
  for (let info of category.pages) {
381
- let url = urlset.ele(url);
381
+ let url = urlset.ele('url');
382
382
 
383
383
  url.ele('loc').txt(''+info.url);
384
384
 
@@ -1,9 +1,10 @@
1
- let shared_objects = {},
2
- plugModules = null,
3
- usedModules = {},
4
- useErrors = {},
5
- usePaths = {},
6
- ac_entries = {},
1
+ let shared_objects = {},
2
+ plugModules = null,
3
+ extraModulePaths = [],
4
+ usedModules = {},
5
+ useErrors = {},
6
+ usePaths = {},
7
+ ac_entries = {},
7
8
  parseArgs = require('minimist'),
8
9
  libpath = require('path'),
9
10
  colors = require('ansi-256-colors'),
@@ -649,7 +650,7 @@ Alchemy.setMethod(function executeSetting(path) {
649
650
  *
650
651
  * @author Jelle De Loecker <jelle@elevenways.be>
651
652
  * @since 0.4.0
652
- * @version 1.4.0
653
+ * @version 1.4.1
653
654
  */
654
655
  Alchemy.setMethod(function loadSettings() {
655
656
 
@@ -678,18 +679,26 @@ Alchemy.setMethod(function loadSettings() {
678
679
  let local_path = libpath.resolve(PATH_ROOT, 'app', 'config', 'local'),
679
680
  local;
680
681
 
681
- // Get the local settings
682
- try {
683
- local = require(local_path);
684
- } catch(err) {
682
+ // Get the local settings (can be skipped for testing)
683
+ if (process.env.ALCHEMY_SKIP_LOCAL_CONFIG === '1') {
685
684
  local = {};
686
- this.setSetting('no_local_file', local_path);
685
+ this.setSetting('skipped_local_file', local_path);
686
+ } else {
687
+ try {
688
+ local = require(local_path);
689
+ } catch(err) {
690
+ local = {};
691
+ this.setSetting('no_local_file', local_path);
692
+ }
687
693
  }
688
694
 
689
695
  // Default to the environment Protoblast has found
690
696
  if (!local.environment) {
691
697
 
692
- if (process.env.ENV) {
698
+ // ALCHEMY_ENV takes precedence (useful for testing)
699
+ if (process.env.ALCHEMY_ENV) {
700
+ local.environment = process.env.ALCHEMY_ENV;
701
+ } else if (process.env.ENV) {
693
702
  local.environment = Blast.environment;
694
703
  } else {
695
704
  local.environment = 'dev';
@@ -1044,6 +1053,32 @@ Alchemy.setMethod(function pathResolve(...path_to_dirs) {
1044
1053
  }
1045
1054
  });
1046
1055
 
1056
+ /**
1057
+ * Add an additional path for module resolution.
1058
+ * This is useful for test scenarios where PATH_ROOT is a test_root
1059
+ * but modules are installed in the parent plugin's node_modules.
1060
+ *
1061
+ * @author Jelle De Loecker <jelle@elevenways.be>
1062
+ * @since 1.4.1
1063
+ * @version 1.4.1
1064
+ *
1065
+ * @param {string} path The node_modules directory to search
1066
+ */
1067
+ Alchemy.setMethod(function addModuleSearchPath(path) {
1068
+
1069
+ if (!path || typeof path !== 'string') {
1070
+ return;
1071
+ }
1072
+
1073
+ // Normalize the path
1074
+ path = libpath.resolve(path);
1075
+
1076
+ // Don't add duplicates
1077
+ if (!extraModulePaths.includes(path)) {
1078
+ extraModulePaths.push(path);
1079
+ }
1080
+ });
1081
+
1047
1082
  /**
1048
1083
  * A wrapper function for requiring modules
1049
1084
  *
@@ -1142,7 +1177,7 @@ Alchemy.setMethod(function use(module_name, register_as, options) {
1142
1177
  *
1143
1178
  * @author Jelle De Loecker <jelle@elevenways.be>
1144
1179
  * @since 0.0.1
1145
- * @version 1.4.0
1180
+ * @version 1.4.1
1146
1181
  *
1147
1182
  * @param {string} startPath The path to originate the search from
1148
1183
  * @param {string} moduleName
@@ -1242,6 +1277,21 @@ Alchemy.setMethod(function searchModule(startPath, moduleName, recurse) {
1242
1277
  }
1243
1278
  }
1244
1279
 
1280
+ // If still not found, check extra module paths registered via addModuleSearchPath()
1281
+ // This is used by the TestHarness to add the plugin's node_modules directory
1282
+ if (!module_path && extraModulePaths.length > 0) {
1283
+ for (let extra_path of extraModulePaths) {
1284
+ try {
1285
+ module_path = require.resolve(libpath.resolve(extra_path, moduleName));
1286
+ if (module_path) {
1287
+ break;
1288
+ }
1289
+ } catch (err) {
1290
+ // Not found in this path, continue
1291
+ }
1292
+ }
1293
+ }
1294
+
1245
1295
  return module_path;
1246
1296
  });
1247
1297
 
@@ -1502,12 +1552,33 @@ Alchemy.setMethod(function castObjectId(obj) {
1502
1552
  *
1503
1553
  * @author Jelle De Loecker <jelle@elevenways.be>
1504
1554
  * @since 0.2.0
1505
- * @version 1.0.5
1555
+ * @version 1.4.1
1506
1556
  *
1507
1557
  * @return {boolean}
1508
1558
  */
1509
1559
  Alchemy.setMethod(function isStream(obj) {
1510
- return obj && (typeof obj._read == 'function' || typeof obj._write == 'function') && typeof obj.on === 'function';
1560
+
1561
+ if (!obj) {
1562
+ return false;
1563
+ }
1564
+
1565
+ // Check for Node.js Stream instance (includes HTTP responses)
1566
+ // Classes.Stream.Stream is provided by Protoblast (see stream_ns.js)
1567
+ if (obj instanceof Classes.Stream.Stream) {
1568
+ return true;
1569
+ }
1570
+
1571
+ // Fallback to duck-typing for internal stream methods
1572
+ if ((typeof obj._read == 'function' || typeof obj._write == 'function') && typeof obj.on === 'function') {
1573
+ return true;
1574
+ }
1575
+
1576
+ // Additional duck-typing check for writable-like objects
1577
+ if (typeof obj.write == 'function' && typeof obj.end == 'function' && typeof obj.on == 'function') {
1578
+ return true;
1579
+ }
1580
+
1581
+ return false;
1511
1582
  });
1512
1583
 
1513
1584
  /**
@@ -2099,7 +2170,7 @@ Alchemy.setMethod(function createExportStream(options) {
2099
2170
  *
2100
2171
  * @author Jelle De Loecker <jelle@elevenways.be>
2101
2172
  * @since 1.0.5
2102
- * @version 1.0.5
2173
+ * @version 1.4.1
2103
2174
  *
2104
2175
  * @param {Stream} output
2105
2176
  * @param {Object} options
@@ -2126,10 +2197,16 @@ Alchemy.setMethod(function exportToStream(output, options) {
2126
2197
  }
2127
2198
 
2128
2199
  let tasks = [],
2200
+ models = Model.getAllChildren(),
2129
2201
  i;
2130
2202
 
2131
- for (i = 0; i < Model.children.length; i++) {
2132
- let model = Model.children[i];
2203
+ for (i = 0; i < models.length; i++) {
2204
+ let model = models[i];
2205
+
2206
+ // Skip abstract models - they have no records
2207
+ if (model.is_abstract) {
2208
+ continue;
2209
+ }
2133
2210
 
2134
2211
  tasks.push(async function exportModel(next) {
2135
2212
  await (new model).exportToStream(output);
@@ -2152,7 +2229,7 @@ Alchemy.setMethod(function exportToStream(output, options) {
2152
2229
  *
2153
2230
  * @author Jelle De Loecker <jelle@elevenways.be>
2154
2231
  * @since 1.0.5
2155
- * @version 1.0.5
2232
+ * @version 1.4.1
2156
2233
  *
2157
2234
  * @param {Stream} input
2158
2235
  * @param {Object} options
@@ -2178,155 +2255,26 @@ Alchemy.setMethod(function importFromStream(input, options) {
2178
2255
  options = {};
2179
2256
  }
2180
2257
 
2181
- let that = this,
2182
- current_type = null,
2183
- extra_stream,
2184
- pledge = new Pledge(),
2185
- stopped,
2186
- paused,
2187
- buffer,
2188
- model,
2189
- value,
2190
- seen = 0,
2191
- left,
2192
- size,
2193
- doc;
2194
-
2195
- input.on('data', function onData(data) {
2196
-
2197
- if (stopped) {
2198
- return;
2199
- }
2200
-
2201
- if (buffer) {
2202
- buffer = Buffer.concat([buffer, data]);
2203
- } else {
2204
- buffer = data;
2205
- }
2206
-
2207
- handleBuffer();
2208
- });
2209
-
2210
- function handleBuffer() {
2211
-
2212
- if (paused) {
2213
- return;
2214
- }
2215
-
2216
- if (!current_type && buffer.length < 2) {
2217
- return;
2218
- }
2219
-
2220
- if (!current_type) {
2221
- current_type = buffer.readUInt8(0);
2222
-
2223
- if (current_type == 0x01) {
2224
- size = buffer.readUInt8(1);
2225
- buffer = buffer.slice(2);
2226
- } else if (current_type == 0x02 && buffer.length >= 5) {
2227
- size = buffer.readUInt32BE(1);
2228
- buffer = buffer.slice(5);
2229
- } else if (current_type == 0xFF) {
2230
- size = buffer.readUInt32BE(1);
2231
- buffer = buffer.slice(5);
2232
- seen = 0;
2233
-
2234
- if (!doc) {
2235
- stopped = true;
2236
- pledge.reject(new Error('Found extra import data, but no active document'));
2237
- } else {
2238
- extra_stream = new require('stream').PassThrough();
2239
- doc.extraImportFromStream(extra_stream);
2240
- }
2241
- } else {
2242
- // Not enough data? Wait
2243
- current_type = null;
2244
- return;
2245
- }
2246
- }
2247
-
2248
- handleRest();
2249
- }
2250
-
2251
- function handleRest() {
2252
-
2253
- if (current_type == 0xFF) {
2254
- left = size - seen;
2255
- value = buffer.slice(0, left);
2256
-
2257
- seen += value.length;
2258
-
2259
- if (value.length == buffer.length) {
2260
- buffer = null;
2261
- } else if (value.length < buffer.length) {
2262
- buffer = buffer.slice(left);
2263
- }
2264
-
2265
- extra_stream.write(value);
2266
-
2267
- if (value.length == left) {
2268
- extra_stream.end();
2269
- current_type = null;
2270
-
2271
- if (buffer) {
2272
- handleBuffer();
2273
- }
2274
- }
2275
-
2276
- return;
2277
- }
2258
+ let parser = new Classes.Alchemy.ImportStreamParser(input, options);
2278
2259
 
2279
- if (buffer.length >= size) {
2280
- value = buffer.slice(0, size);
2281
- buffer = buffer.slice(size);
2282
- } else {
2283
- // Wait for next call
2284
- return;
2260
+ // Model resolver for full database import:
2261
+ // Switches between models as they appear in the stream
2262
+ parser.setModelResolver(function resolveModel(model_name, current_model) {
2263
+ // Reuse current model if name matches
2264
+ if (current_model && current_model.model_name == model_name) {
2265
+ return current_model;
2285
2266
  }
2286
2267
 
2287
- if (current_type == 0x01) {
2288
- value = value.toString();
2289
-
2290
- if (!model || model.model_name != value) {
2291
- model = Model.get(value);
2292
- doc = null;
2293
- }
2294
-
2295
- if (!model) {
2296
- stopped = true;
2297
- return pledge.reject(new Error('Could not find Model "' + value + '"'));
2298
- }
2268
+ let model = Model.get(model_name);
2299
2269
 
2300
- current_type = null;
2301
- size = 0;
2302
- } else if (current_type == 0x02) {
2303
- doc = model.createDocument();
2304
- input.pause();
2305
- paused = true;
2306
-
2307
- doc.importFromBuffer(value).done(function done(err, result) {
2308
-
2309
- if (err) {
2310
- stopped = true;
2311
- return pledge.reject(err);
2312
- }
2313
-
2314
- current_type = null;
2315
- paused = false;
2316
- input.resume();
2317
-
2318
- handleBuffer();
2319
- });
2320
-
2321
- return;
2270
+ if (!model) {
2271
+ throw new Error('Could not find Model "' + model_name + '"');
2322
2272
  }
2323
2273
 
2324
- if (buffer && buffer.length) {
2325
- handleBuffer();
2326
- }
2327
- }
2274
+ return model;
2275
+ });
2328
2276
 
2329
- return pledge;
2277
+ return parser.parse();
2330
2278
  });
2331
2279
 
2332
2280
  /**
@@ -538,9 +538,55 @@ Alchemy.setMethod(function addViewDirectory(dirPath, weight) {
538
538
  * This immediately executes the plugin's bootstrap.js file,
539
539
  * but the loading of the app tree happens later.
540
540
  *
541
+ /**
542
+ * Handle a fatal plugin error
543
+ * Plugin errors are fatal - the server cannot start with a broken plugin
544
+ *
545
+ * @author Jelle De Loecker <jelle@elevenways.be>
546
+ * @since 1.4.1
547
+ * @version 1.4.1
548
+ *
549
+ * @param {string} plugin_name The name of the plugin
550
+ * @param {string} phase Which phase failed (not_found, settings, bootstrap, start)
551
+ * @param {Error} err The error that occurred
552
+ * @param {Object} extra_info Additional info for error tracking
553
+ */
554
+ Alchemy.setMethod(function handlePluginError(plugin_name, phase, err, extra_info) {
555
+
556
+ let message = 'Failed to load plugin "' + plugin_name + '"';
557
+
558
+ if (phase) {
559
+ message += ' during ' + phase + ' phase';
560
+ }
561
+
562
+ // Log the full error
563
+ log.error(message);
564
+ log.error('Error:', err.message);
565
+
566
+ if (err.stack) {
567
+ log.error('Stack:', err.stack);
568
+ }
569
+
570
+ // Register with error tracking services (Sentry, Glitchtip, etc.)
571
+ let error_info = Object.assign({
572
+ context : message,
573
+ plugin : plugin_name,
574
+ phase : phase,
575
+ fatal : true,
576
+ }, extra_info);
577
+
578
+ this.registerError(err, error_info);
579
+
580
+ // Die with a clear message
581
+ die('Plugin "' + plugin_name + '" failed to load: ' + err.message, {level: 2});
582
+ });
583
+
584
+ /**
585
+ * Load a plugin
586
+ *
541
587
  * @author Jelle De Loecker <jelle@elevenways.be>
542
588
  * @since 0.0.1
543
- * @version 1.4.0
589
+ * @version 1.4.1
544
590
  *
545
591
  * @param {string} name The name of the plugin (which is its path)
546
592
  * @param {Object} options Options to pass to the plugin
@@ -604,7 +650,10 @@ Alchemy.setMethod(function usePlugin(name, options) {
604
650
  }
605
651
 
606
652
  if (!is_dir) {
607
- log.error('Could not find ' + JSON.stringify(name) + ' plugin directory');
653
+ let err = new Error('Plugin directory not found. Searched: ' + possible_paths.join(', '));
654
+ err.searched_paths = possible_paths;
655
+
656
+ alchemy.handlePluginError(name, 'not_found', err, {paths: possible_paths});
608
657
  return false;
609
658
  }
610
659
 
@@ -613,7 +662,15 @@ Alchemy.setMethod(function usePlugin(name, options) {
613
662
  // Set the given options
614
663
  alchemy.plugins[name] = instance;
615
664
 
616
- instance.doPreload();
665
+ // doPreload can fail - if it does, the plugin will call die()
666
+ // but we still need to clean up if for some reason it doesn't
667
+ let result = instance.doPreload();
668
+
669
+ if (result === false) {
670
+ // Plugin failed to load - remove it from plugins
671
+ delete alchemy.plugins[name];
672
+ return false;
673
+ }
617
674
 
618
675
  return instance;
619
676
  });
@@ -623,7 +680,7 @@ Alchemy.setMethod(function usePlugin(name, options) {
623
680
  *
624
681
  * @author Jelle De Loecker <jelle@elevenways.be>
625
682
  * @since 0.0.1
626
- * @version 1.4.0
683
+ * @version 1.4.1
627
684
  *
628
685
  * @param {string|Array} names
629
686
  * @param {boolean} attempt_require
@@ -659,7 +716,9 @@ Alchemy.setMethod(function requirePlugin(names, attempt_require) {
659
716
  if (!plugin_stage || plugin_stage.started) {
660
717
  // If the plugin stage has already started,
661
718
  // manually start this plugin now
662
- alchemy.startPlugins(name);
719
+ if (temp.startPlugin) {
720
+ temp.startPlugin();
721
+ }
663
722
  }
664
723
  continue;
665
724
  }
package/lib/core/base.js CHANGED
@@ -391,7 +391,7 @@ Base.setStatic('starts_new_group', false);
391
391
  *
392
392
  * @author Jelle De Loecker <jelle@elevenways.be>
393
393
  * @since 1.1.8
394
- * @version 1.2.6
394
+ * @version 1.4.1
395
395
  *
396
396
  * @type {Conduit}
397
397
  */
@@ -414,7 +414,7 @@ Base.setProperty(function conduit() {
414
414
  result = renderer.root_renderer.conduit;
415
415
  }
416
416
 
417
- if (!conduit && renderer?.server_var) {
417
+ if (!result && renderer?.server_var) {
418
418
  result = renderer.server_var('conduit');
419
419
  }
420
420
 
@@ -1207,18 +1207,44 @@ async function createAlchemyScss() {
1207
1207
  *
1208
1208
  * @author Jelle De Loecker <jelle@elevenways.be>
1209
1209
  * @since 1.4.0
1210
- * @version 1.4.0
1210
+ * @version 1.4.1
1211
1211
  *
1212
1212
  * @param {string} path_to_import The requested path to import
1213
1213
  * @param {string} current_file_path The path to the current file
1214
1214
  */
1215
- async function customScssImporter(path_to_import, current_file_path) {
1215
+ /**
1216
+ * Create a custom SCSS importer for a specific main file
1217
+ *
1218
+ * @param {string} main_file_path The path to the main SCSS file being compiled
1219
+ */
1220
+ function createScssImporter(main_file_path) {
1221
+ return async function customScssImporter(path_to_import, current_file_path) {
1222
+ return resolveScssImport(path_to_import, current_file_path, main_file_path);
1223
+ };
1224
+ }
1225
+
1226
+ /**
1227
+ * Resolve an SCSS import path
1228
+ *
1229
+ * @param {string} path_to_import The requested path to import
1230
+ * @param {string} current_file_path The path to the file doing the import (may be 'stdin')
1231
+ * @param {string} main_file_path The path to the main SCSS file being compiled
1232
+ */
1233
+ async function resolveScssImport(path_to_import, current_file_path, main_file_path) {
1216
1234
 
1217
1235
  // The alchemy.scss file is a special case
1218
1236
  if (path_to_import === 'alchemy.scss' || path_to_import === 'alchemy') {
1219
1237
  return createAlchemyScss();
1220
1238
  }
1221
1239
 
1240
+ // SASS may pass non-path values for current_file_path:
1241
+ // - 'stdin' when processing the main file
1242
+ // - A simple name like 'alchemy' when processing imports from {contents: ...} returns
1243
+ // In these cases, fall back to main_file_path
1244
+ if (main_file_path && (!current_file_path || !libpath.isAbsolute(current_file_path))) {
1245
+ current_file_path = main_file_path;
1246
+ }
1247
+
1222
1248
  // Get the current directory this SCSS file is in
1223
1249
  let current_dir = libpath.dirname(current_file_path);
1224
1250
 
@@ -1271,7 +1297,7 @@ async function customScssImporter(path_to_import, current_file_path) {
1271
1297
  // Look for files starting with an underscore
1272
1298
  if (filename_to_import[0] != '_') {
1273
1299
  let dashed_filename_to_import = '_' + filename_to_import;
1274
- return customScssImporter(libpath.join(dir_to_import, dashed_filename_to_import), current_file_path);
1300
+ return resolveScssImport(libpath.join(dir_to_import, dashed_filename_to_import), current_file_path, main_file_path);
1275
1301
  }
1276
1302
 
1277
1303
  let extension = libpath.extname(filename_to_import);
@@ -1279,7 +1305,7 @@ async function customScssImporter(path_to_import, current_file_path) {
1279
1305
  // If no extension was given, look for that too
1280
1306
  if (!extension) {
1281
1307
  filename_to_import += '.scss';
1282
- return customScssImporter(libpath.join(dir_to_import, filename_to_import), current_file_path);
1308
+ return resolveScssImport(libpath.join(dir_to_import, filename_to_import), current_file_path, main_file_path);
1283
1309
  }
1284
1310
 
1285
1311
  if (path_to_import.startsWith('/overrides/')) {
@@ -1356,7 +1382,7 @@ Alchemy.setMethod(function getCompiledSassPath(sassPath, options, callback) {
1356
1382
  silenceDeprecations: ['legacy-js-api'],
1357
1383
  includePaths : styleDirs.getSorted(),
1358
1384
  functions : custom_functions,
1359
- importer : customScssImporter,
1385
+ importer : createScssImporter(sassPath),
1360
1386
  logger : {
1361
1387
  warn : logSassWarning,
1362
1388
  debug : logSassDebug,
@@ -8,7 +8,7 @@ const VALUE = Symbol('value');
8
8
  *
9
9
  * @author Jelle De Loecker <jelle@elevenways.be>
10
10
  * @since 1.4.0
11
- * @version 1.4.0
11
+ * @version 1.4.1
12
12
  *
13
13
  * @param {string} name The name of the setting in its group
14
14
  * @param {Object} config The settings of this definition
@@ -32,7 +32,7 @@ const Base = Function.inherits('Alchemy.Base', 'Alchemy.Setting', function Base(
32
32
  this.view_permission = config?.view_permission;
33
33
 
34
34
  // Does this setting require any permission to edit?
35
- this.edit_persmission = config?.edit_persmission;
35
+ this.edit_permission = config?.edit_permission;
36
36
  });
37
37
 
38
38
  /**
@@ -317,7 +317,7 @@ Definition.setMethod(function toJSON() {
317
317
  show_description : this.show_description,
318
318
  target : this.target,
319
319
  view_permission : this.view_permission,
320
- edit_persmission : this.edit_persmission,
320
+ edit_permission : this.edit_permission,
321
321
  requires_restart : this.requires_restart,
322
322
  locked : this.locked,
323
323
  };
@@ -366,7 +366,7 @@ Definition.setMethod(function getEditorConfiguration(root_value, editor_context)
366
366
  result.description = this.description;
367
367
  }
368
368
 
369
- if (this.edit_persmission && editor_context && !editor_context.hasPermission(this.edit_persmission)) {
369
+ if (this.edit_permission && editor_context && !editor_context.hasPermission(this.edit_permission)) {
370
370
  result.locked = true;
371
371
  }
372
372
 
@@ -413,7 +413,7 @@ Definition.setMethod(function canBeEditedBy(permission_context) {
413
413
  }
414
414
 
415
415
  // If no edit permission is required, it can be edited
416
- if (!this.edit_persmission) {
416
+ if (!this.edit_permission) {
417
417
  return true;
418
418
  }
419
419
 
@@ -422,7 +422,7 @@ Definition.setMethod(function canBeEditedBy(permission_context) {
422
422
  return false;
423
423
  }
424
424
 
425
- return permission_context.hasPermission(this.edit_persmission);
425
+ return permission_context.hasPermission(this.edit_permission);
426
426
  });
427
427
 
428
428
  /**
@@ -539,7 +539,7 @@ Group.setMethod(function toDry() {
539
539
  setting_id : this.setting_id,
540
540
  description : this.description,
541
541
  view_permission : this.view_permission,
542
- edit_persmission : this.edit_persmission,
542
+ edit_permission : this.edit_permission,
543
543
  };
544
544
 
545
545
  let children = [...this.children.values()];
@@ -565,7 +565,7 @@ Group.setMethod(function toEnumEntry() {
565
565
  setting_id : this.setting_id,
566
566
  description : this.description,
567
567
  view_permission : this.view_permission,
568
- edit_persmission : this.edit_persmission,
568
+ edit_permission : this.edit_permission,
569
569
  is_group : true,
570
570
  };
571
571
  });
@@ -868,7 +868,10 @@ Group.setMethod(function assign(target, values, default_only, do_actions = true)
868
868
 
869
869
  // Make sure it's correct
870
870
  if (group) {
871
- if (!target[key] || typeof target[key] !== 'object') {
871
+ // We need a GroupValue. If target[key] doesn't exist, isn't an object,
872
+ // or is a non-group Value (e.g., SettingValue from config), generate a new one.
873
+ // This handles the case where config sets `key: null` before bootstrap creates the group.
874
+ if (!target[key] || typeof target[key] !== 'object' || (target[key] instanceof Value && !target[key].is_group)) {
872
875
  target[key] = group.generateValue();
873
876
  }
874
877
 
@@ -115,5 +115,9 @@ DEFINE('die', function die(...args) {
115
115
  // (but blessed can't revert to original state without segfaulting)
116
116
  alchemy.Janeway.print(alchemy.SEVERE, args, {level: 2});
117
117
 
118
- process.exit();
118
+ // Give logs time to flush before exiting
119
+ Blast.sleepSync(1000);
120
+
121
+ // Exit with error code 1 to indicate failure
122
+ process.exit(1);
119
123
  });
@@ -341,11 +341,17 @@ const app_bootstrap = load_core.createStage('app_bootstrap', () => {
341
341
  try {
342
342
  alchemy.useOnce(libpath.resolve(PATH_ROOT, 'app', 'config', 'bootstrap'));
343
343
  } catch (err) {
344
- if (err.message.indexOf('Cannot find') === -1) {
344
+
345
+ // Check if this is a "file not found" error (optional file)
346
+ let is_not_found = err.code === 'ENOENT' || err.message.indexOf('Cannot find') > -1;
347
+
348
+ if (is_not_found) {
349
+ // Bootstrap file is optional - just log a warning
345
350
  alchemy.printLog(alchemy.WARNING, 'Could not load app bootstrap file');
346
- throw err;
347
351
  } else {
352
+ // Actual error in the bootstrap file - log and throw
348
353
  alchemy.printLog(alchemy.SEVERE, 'Could not load config bootstrap file', {err: err});
354
+ throw err;
349
355
  }
350
356
  }
351
357
  });