@ijuantm/simpl-addon 2.0.0 → 2.2.0

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 (3) hide show
  1. package/README.md +1 -1
  2. package/install.js +111 -83
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -56,5 +56,5 @@ The installer:
56
56
 
57
57
  ## Requirements
58
58
 
59
- - **Node.js**: >= 20.x.x
59
+ - **Node.js**: >= 22.x.x
60
60
  - **Simpl Framework**: A (preferably clean) installation of Simpl, if not clean, some manual merging may be required, or the installer may skip files or break things (you have been warned).
package/install.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const https = require('https');
6
+ const readline = require('readline');
6
7
  const {promisify} = require('util');
7
8
  const {exec} = require('child_process');
8
9
 
@@ -13,6 +14,7 @@ const COLORS = {
13
14
  };
14
15
 
15
16
  const CDN_BASE = 'https://cdn.simpl.iwanvanderwal.nl/framework';
17
+ const LOCAL_RELEASES_DIR = process.env.SIMPL_LOCAL_RELEASES || path.join(process.cwd(), 'local-releases');
16
18
 
17
19
  const log = (message, color = 'reset') => console.log(`${COLORS[color]}${message}${COLORS.reset}`);
18
20
 
@@ -56,13 +58,22 @@ const downloadFile = (url, dest) => new Promise((resolve, reject) => {
56
58
  });
57
59
  });
58
60
 
61
+ const promptUser = (question, defaultValue = '') => new Promise(resolve => {
62
+ const rl = readline.createInterface({input: process.stdin, output: process.stdout});
63
+ const prompt = defaultValue ? `${question} ${COLORS.dim}(${defaultValue})${COLORS.reset}: ` : `${question}: `;
64
+
65
+ rl.question(prompt, answer => {
66
+ rl.close();
67
+ resolve(answer.trim() || defaultValue);
68
+ });
69
+ });
70
+
59
71
  const getSimplVersion = () => {
60
72
  const simplFile = path.join(process.cwd(), '.simpl');
61
73
 
62
74
  if (!fs.existsSync(simplFile)) throw new Error('Not a Simpl project. Missing .simpl file in current directory.');
63
75
 
64
- const content = fs.readFileSync(simplFile, 'utf8');
65
- const config = JSON.parse(content);
76
+ const config = JSON.parse(fs.readFileSync(simplFile, 'utf8'));
66
77
 
67
78
  if (!config.version) throw new Error('Invalid .simpl file: missing version field');
68
79
 
@@ -76,20 +87,14 @@ const showHelp = () => {
76
87
  log(` ╰${'─'.repeat(62)}╯`);
77
88
  console.log();
78
89
  log(` ${COLORS.bold}Usage:${COLORS.reset}`, 'blue');
79
- log(` ${COLORS.dim}npx @ijuantm/simpl-addon <addon-name>${COLORS.reset}`);
80
- log(` ${COLORS.dim}npx @ijuantm/simpl-addon --list${COLORS.reset}`);
90
+ log(` ${COLORS.dim}npx @ijuantm/simpl-addon${COLORS.reset}`);
81
91
  log(` ${COLORS.dim}npx @ijuantm/simpl-addon --help${COLORS.reset}`);
82
92
  console.log();
83
- log(` ${COLORS.bold}Arguments:${COLORS.reset}`, 'blue');
84
- log(` ${COLORS.dim}addon-name${COLORS.reset} Name of the add-on to install`);
85
- console.log();
86
93
  log(` ${COLORS.bold}Commands:${COLORS.reset}`, 'blue');
87
- log(` ${COLORS.dim}--list, -l${COLORS.reset} List all available add-ons`);
88
94
  log(` ${COLORS.dim}--help, -h${COLORS.reset} Show this help message`);
89
95
  console.log();
90
96
  log(` ${COLORS.bold}Examples:${COLORS.reset}`, 'blue');
91
- log(` ${COLORS.dim}npx @ijuantm/simpl-addon auth${COLORS.reset}`);
92
- log(` ${COLORS.dim}npx @ijuantm/simpl-addon --list${COLORS.reset}`);
97
+ log(` ${COLORS.dim}npx @ijuantm/simpl-addon${COLORS.reset}`);
93
98
  console.log();
94
99
  log(` ${COLORS.bold}Note:${COLORS.reset}`, 'blue');
95
100
  log(` Run this command from the root of your Simpl project.`);
@@ -97,31 +102,26 @@ const showHelp = () => {
97
102
  console.log();
98
103
  };
99
104
 
100
- const listAddons = async (version) => {
101
- console.log();
102
- log(` ╭${'─'.repeat(62)}╮`);
103
- log(` │ ${COLORS.bold}Available Add-ons${COLORS.reset} ${COLORS.dim}(${version})${COLORS.reset}${' '.repeat(40 - version.length)}│`);
104
- log(` ╰${'─'.repeat(62)}╯`);
105
- console.log();
106
- log(' 📦 Fetching available add-ons...', 'bold');
107
-
108
- try {
109
- const response = await fetchUrl(`${CDN_BASE}/${version}/add-ons/list.json`);
110
- const addons = JSON.parse(String(response))['add-ons'];
105
+ const checkServerAvailability = () => new Promise(resolve => {
106
+ const req = https.get(`${CDN_BASE}/versions.json`, {timeout: 5000}, res => {
107
+ res.resume();
108
+ resolve(res.statusCode === 200);
109
+ });
110
+ req.on('error', () => resolve(false));
111
+ req.on('timeout', () => {
112
+ req.destroy();
113
+ resolve(false);
114
+ });
115
+ });
111
116
 
112
- console.log();
117
+ const getAvailableAddons = async (version) => {
118
+ const localListPath = path.join(LOCAL_RELEASES_DIR, version, 'add-ons', 'list.json');
113
119
 
114
- if (addons.length === 0) log(` ${COLORS.yellow}⚠${COLORS.reset} No add-ons available`);
115
- else addons.forEach(name => log(` ${COLORS.cyan}•${COLORS.reset} ${name}`));
116
- } catch (error) {
117
- console.log();
118
- log(` ${COLORS.red}✗${COLORS.reset} Failed to fetch add-ons: ${error.message}`, 'red');
119
- console.log();
120
+ if (fs.existsSync(localListPath)) return JSON.parse(fs.readFileSync(localListPath, 'utf8'))['add-ons'];
120
121
 
121
- process.exit(1);
122
- }
122
+ if (!await checkServerAvailability()) throw new Error('CDN server is currently unreachable');
123
123
 
124
- console.log();
124
+ return JSON.parse(await fetchUrl(`${CDN_BASE}/${version}/add-ons/list.json`))['add-ons'];
125
125
  };
126
126
 
127
127
  const extractMarkers = (content) => {
@@ -142,13 +142,10 @@ const extractMarkers = (content) => {
142
142
 
143
143
  const collectContentBetweenMarkers = (lines, startIndex) => {
144
144
  const content = [];
145
-
146
145
  for (let i = startIndex + 1; i < lines.length; i++) {
147
146
  if (lines[i].trim().includes('@addon-end')) break;
148
-
149
147
  content.push(lines[i]);
150
148
  }
151
-
152
149
  return content;
153
150
  };
154
151
 
@@ -166,7 +163,6 @@ const processEnvContent = (content, targetContent) => {
166
163
  }
167
164
 
168
165
  const match = line.match(/^([A-Z_][A-Z0-9_]*)=/);
169
-
170
166
  if (match && !new RegExp(`^${match[1]}=`, 'm').test(targetContent)) envVarsToAdd.push(line);
171
167
  });
172
168
 
@@ -182,19 +178,16 @@ const mergeFile = (targetPath, addonContent, markers, isEnv = false) => {
182
178
  const targetContent = fs.readFileSync(targetPath, 'utf8');
183
179
  const addonLines = addonContent.split('\n');
184
180
  const operations = [];
185
-
186
181
  let newContent = targetContent;
187
182
 
188
183
  markers.forEach(marker => {
189
184
  let content = collectContentBetweenMarkers(addonLines, marker.lineIndex);
190
-
191
185
  if (content.length === 0) return;
192
186
 
193
187
  let lineCount = content.length;
194
188
 
195
189
  if (isEnv) {
196
190
  const processed = processEnvContent(content, newContent);
197
-
198
191
  content = processed.content;
199
192
  lineCount = processed.count;
200
193
 
@@ -214,13 +207,10 @@ const mergeFile = (targetPath, addonContent, markers, isEnv = false) => {
214
207
 
215
208
  if (marker.type === 'prepend') {
216
209
  newContent = content.join('\n') + '\n' + newContent;
217
-
218
210
  operations.push({success: true, type: 'prepend', lines: lineCount});
219
211
  } else if (marker.type === 'append') {
220
212
  if (!newContent.endsWith('\n')) newContent += '\n';
221
-
222
213
  newContent += '\n' + content.join('\n') + '\n';
223
-
224
214
  operations.push({success: true, type: 'append', lines: lineCount});
225
215
  } else if ((marker.type === 'after' || marker.type === 'before') && marker.searchText) {
226
216
  const targetLines = newContent.split('\n');
@@ -232,9 +222,7 @@ const mergeFile = (targetPath, addonContent, markers, isEnv = false) => {
232
222
  }
233
223
 
234
224
  targetLines.splice(insertIndex, 0, ...content);
235
-
236
225
  newContent = targetLines.join('\n');
237
-
238
226
  operations.push({success: true, type: marker.type, lines: lineCount, searchText: marker.searchText});
239
227
  }
240
228
  });
@@ -247,13 +235,11 @@ const mergeFile = (targetPath, addonContent, markers, isEnv = false) => {
247
235
  const printMergeResults = (relativePath, isEnv, result) => {
248
236
  const indent = ' ';
249
237
  const varText = isEnv ? 'environment variable' : 'line';
250
-
251
238
  let hasChanges = false;
252
239
 
253
240
  result.operations.forEach(op => {
254
241
  if (op.success) {
255
242
  hasChanges = true;
256
-
257
243
  if (op.type === 'prepend') log(`${indent}${COLORS.green}✓${COLORS.reset} Prepended ${COLORS.bold}${op.lines}${COLORS.reset} ${varText}${op.lines !== 1 ? 's' : ''} to file start`);
258
244
  else if (op.type === 'append') log(`${indent}${COLORS.green}✓${COLORS.reset} Appended ${COLORS.bold}${op.lines}${COLORS.reset} ${varText}${op.lines !== 1 ? 's' : ''} to file end`);
259
245
  else if (op.type === 'after') log(`${indent}${COLORS.green}✓${COLORS.reset} Inserted ${COLORS.bold}${op.lines}${COLORS.reset} ${varText}${op.lines !== 1 ? 's' : ''} ${COLORS.cyan}after${COLORS.reset} "${COLORS.dim}${op.searchText}${COLORS.reset}"`);
@@ -265,19 +251,21 @@ const printMergeResults = (relativePath, isEnv, result) => {
265
251
  return hasChanges;
266
252
  };
267
253
 
268
- const extractZip = async zipPath => {
269
- const tempExtract = path.join(process.cwd(), '__temp_extract_addon__');
254
+ const extractZip = async (zipPath, destDir) => {
255
+ fs.mkdirSync(destDir, {recursive: true});
270
256
 
271
- if (process.platform === 'win32') {
272
- await execAsync(`powershell -command "Expand-Archive -Path '${zipPath}' -DestinationPath '${tempExtract}' -Force"`);
273
- } else {
274
- await execAsync(`unzip -q "${zipPath}" -d "${tempExtract}"`);
275
- }
257
+ if (process.platform === 'win32') await execAsync(`powershell -command "Expand-Archive -Path '${zipPath}' -DestinationPath '${destDir}' -Force"`);
258
+ else await execAsync(`unzip -q "${zipPath}" -d "${destDir}"`);
276
259
 
277
- const entries = fs.readdirSync(tempExtract, {withFileTypes: true});
278
- const sourceDir = entries.length === 1 && entries[0].isDirectory() ? path.join(tempExtract, entries[0].name) : tempExtract;
260
+ const entries = fs.readdirSync(destDir, {withFileTypes: true});
279
261
 
280
- return {sourceDir, tempExtract};
262
+ if (entries.length === 1 && entries[0].isDirectory()) {
263
+ const nestedDir = path.join(destDir, entries[0].name);
264
+ fs.readdirSync(nestedDir).forEach(item => fs.renameSync(path.join(nestedDir, item), path.join(destDir, item)));
265
+ fs.rmdirSync(nestedDir);
266
+ }
267
+
268
+ return destDir;
281
269
  };
282
270
 
283
271
  const processAddonFiles = (addonDir, targetDir) => {
@@ -290,19 +278,18 @@ const processAddonFiles = (addonDir, targetDir) => {
290
278
  const relativePath = path.join(basePath, entry.name).replace(/\\/g, '/');
291
279
  const destPath = path.join(targetDir, relativePath);
292
280
 
293
- if (entry.isDirectory()) processDirectory(srcPath, relativePath);
294
- else {
281
+ if (entry.isDirectory()) {
282
+ processDirectory(srcPath, relativePath);
283
+ } else {
295
284
  const content = fs.readFileSync(srcPath, 'utf8');
296
285
 
297
286
  if (fs.existsSync(destPath)) {
298
287
  const markers = extractMarkers(content);
299
-
300
288
  if (markers.length > 0 || entry.name === '.env') toMerge.push({content, destPath, relativePath, markers});
301
289
  else skipped.push(relativePath);
302
290
  } else {
303
291
  fs.mkdirSync(path.dirname(destPath), {recursive: true});
304
292
  fs.copyFileSync(srcPath, destPath);
305
-
306
293
  copied.push(relativePath);
307
294
  }
308
295
  }
@@ -314,21 +301,30 @@ const processAddonFiles = (addonDir, targetDir) => {
314
301
  };
315
302
 
316
303
  const downloadAddon = async (addonName, version, targetDir) => {
317
- const zipUrl = `${CDN_BASE}/${version}/add-ons/${addonName}.zip`;
318
- const tempZip = path.join(process.cwd(), `temp-addon-${addonName}.zip`);
304
+ const localZipPath = path.join(LOCAL_RELEASES_DIR, version, 'add-ons', `${addonName}.zip`);
305
+ const tempExtract = path.join(process.cwd(), '__temp_extract_addon__');
319
306
 
320
307
  try {
321
- await downloadFile(zipUrl, tempZip);
308
+ if (fs.existsSync(localZipPath)) {
309
+ console.log();
310
+ log(` 💻 Using local add-on files`, 'bold');
311
+ const sourceDir = await extractZip(localZipPath, tempExtract);
312
+ const result = processAddonFiles(sourceDir, targetDir);
313
+ fs.rmSync(tempExtract, {recursive: true, force: true});
314
+ return result;
315
+ }
322
316
 
323
- const {sourceDir, tempExtract} = await extractZip(tempZip);
324
- const result = processAddonFiles(sourceDir, targetDir);
317
+ if (!await checkServerAvailability()) throw new Error('CDN server is currently unreachable');
325
318
 
319
+ const tempZip = path.join(process.cwd(), `temp-addon-${addonName}.zip`);
320
+ await downloadFile(`${CDN_BASE}/${version}/add-ons/${addonName}.zip`, tempZip);
321
+ const sourceDir = await extractZip(tempZip, tempExtract);
322
+ const result = processAddonFiles(sourceDir, targetDir);
326
323
  fs.unlinkSync(tempZip);
327
324
  fs.rmSync(tempExtract, {recursive: true, force: true});
328
-
329
325
  return result;
330
326
  } catch (error) {
331
- if (fs.existsSync(tempZip)) fs.unlinkSync(tempZip);
327
+ if (fs.existsSync(tempExtract)) fs.rmSync(tempExtract, {recursive: true, force: true});
332
328
  throw error;
333
329
  }
334
330
  };
@@ -340,17 +336,14 @@ const mergeFiles = (toMerge) => {
340
336
 
341
337
  toMerge.forEach(({content, destPath, relativePath, markers}) => {
342
338
  const isEnv = path.basename(destPath) === '.env';
343
-
344
339
  log(`\n ${COLORS.cyan}•${COLORS.reset} ${COLORS.dim}${relativePath}${COLORS.reset}`);
345
340
 
346
341
  try {
347
342
  const result = mergeFile(destPath, content, markers, isEnv);
348
-
349
343
  if (printMergeResults(relativePath, isEnv, result)) merged.push(relativePath);
350
344
  else unchanged.push(relativePath);
351
345
  } catch (error) {
352
346
  log(` ${COLORS.red}✗ Error:${COLORS.reset} ${error.message}`, 'red');
353
-
354
347
  failed.push(relativePath);
355
348
  }
356
349
  });
@@ -362,9 +355,8 @@ const main = async () => {
362
355
  const args = process.argv.slice(2);
363
356
  const firstArg = args[0];
364
357
 
365
- if (!firstArg || firstArg === '--help' || firstArg === '-h') {
358
+ if (firstArg === '--help' || firstArg === '-h') {
366
359
  showHelp();
367
-
368
360
  process.exit(0);
369
361
  }
370
362
 
@@ -376,21 +368,60 @@ const main = async () => {
376
368
  console.log();
377
369
  log(` ${COLORS.red}✗${COLORS.reset} ${error.message}`, 'red');
378
370
  console.log();
371
+ process.exit(1);
372
+ }
373
+
374
+ console.log();
375
+ log(` ╭${'─'.repeat(62)}╮`);
376
+ log(` │ ${COLORS.bold}Simpl Add-on Installer${COLORS.reset} ${COLORS.dim}(${version})${COLORS.reset}${' '.repeat(37 - version.length)}│`);
377
+ log(` ╰${'─'.repeat(62)}╯`);
378
+ console.log();
379
+ log(' 📦 Fetching available add-ons...', 'bold');
379
380
 
381
+ let addons;
382
+
383
+ try {
384
+ addons = await getAvailableAddons(version);
385
+ } catch (error) {
386
+ console.log();
387
+ log(` ${COLORS.red}✗${COLORS.reset} Failed to fetch add-ons`, 'red');
388
+ if (error.message === 'CDN server is currently unreachable') log(` ${COLORS.dim}The CDN server is currently unavailable. Please try again later.${COLORS.reset}`);
389
+ console.log();
380
390
  process.exit(1);
381
391
  }
382
392
 
383
- if (firstArg === '--list' || firstArg === '-l') {
384
- await listAddons(version);
393
+ console.log();
385
394
 
395
+ if (addons.length === 0) {
396
+ log(` ${COLORS.yellow}⚠${COLORS.reset} No add-ons available for this version`);
397
+ console.log();
386
398
  process.exit(0);
387
399
  }
388
400
 
389
- const addonName = firstArg;
401
+ log(` ${COLORS.bold}Available add-ons:${COLORS.reset}`, 'blue');
402
+ addons.forEach(name => log(` ${COLORS.cyan}•${COLORS.reset} ${name}`));
403
+ console.log();
404
+
405
+ let addonName;
406
+
407
+ while (true) {
408
+ addonName = await promptUser(' Add-on to install');
409
+ if (!addonName) {
410
+ log(` ${COLORS.red}✗${COLORS.reset} Add-on name cannot be empty`, 'red');
411
+ console.log();
412
+ continue;
413
+ }
414
+ if (!addons.includes(addonName)) {
415
+ log(` ${COLORS.red}✗${COLORS.reset} Add-on "${addonName}" not found`, 'red');
416
+ console.log();
417
+ continue;
418
+ }
419
+ break;
420
+ }
390
421
 
391
422
  console.log();
392
423
  log(` ╭${'─'.repeat(62)}╮`);
393
- log(` │ ${COLORS.bold}Installing Add-on: ${COLORS.cyan}${addonName}${COLORS.reset} ${COLORS.dim}(${version})${COLORS.reset}${' '.repeat(38 - addonName.length - version.length)}│`);
424
+ log(` │ ${COLORS.bold}Installing: ${COLORS.cyan}${addonName}${COLORS.reset} ${COLORS.dim}(${version})${COLORS.reset}${' '.repeat(46 - addonName.length - version.length)}│`);
394
425
  log(` ╰${'─'.repeat(62)}╯`);
395
426
  console.log();
396
427
  log(' 📦 Downloading add-on...', 'bold');
@@ -401,10 +432,10 @@ const main = async () => {
401
432
  ({copied, skipped, toMerge} = await downloadAddon(addonName, version, process.cwd()));
402
433
  } catch (error) {
403
434
  console.log();
404
- log(` ${COLORS.red}✗${COLORS.reset} ${error.message}`, 'red');
405
- log(` ${COLORS.dim}Run ${COLORS.dim}npx @ijuantm/simpl-addon --list${COLORS.reset} to see available add-ons`);
435
+ log(` ${COLORS.red}✗${COLORS.reset} Installation failed`, 'red');
436
+ if (error.message === 'CDN server is currently unreachable') log(` ${COLORS.dim}The CDN server is currently unavailable. Please try again later.${COLORS.reset}`);
437
+ else log(` ${COLORS.dim}Please verify the add-on exists and try again${COLORS.reset}`);
406
438
  console.log();
407
-
408
439
  process.exit(1);
409
440
  }
410
441
 
@@ -416,7 +447,6 @@ const main = async () => {
416
447
  if (skipped.length > 0) {
417
448
  console.log();
418
449
  log(` ${COLORS.gray}○${COLORS.reset} ${COLORS.dim}Skipped ${skipped.length} file${skipped.length !== 1 ? 's' : ''} (no merge markers):${COLORS.reset}`);
419
-
420
450
  skipped.forEach(file => log(` ${COLORS.dim}• ${file}${COLORS.reset}`));
421
451
  }
422
452
 
@@ -436,7 +466,6 @@ const main = async () => {
436
466
  console.log();
437
467
  log(` ${COLORS.yellow}⚠${COLORS.reset} ${COLORS.yellow}${failed.length} file${failed.length !== 1 ? 's' : ''} failed to merge${COLORS.reset}`);
438
468
  log(` ${COLORS.yellow}Please review manually:${COLORS.reset}`);
439
-
440
469
  failed.forEach(file => log(` ${COLORS.cyan}• ${file}${COLORS.reset}`));
441
470
  }
442
471
  }
@@ -446,8 +475,7 @@ const main = async () => {
446
475
  console.log();
447
476
  };
448
477
 
449
- main().catch(err => {
450
- log(`\n ${COLORS.red}✗${COLORS.reset} Fatal error: ${err.message}\n`, 'red');
451
-
478
+ main().catch(() => {
479
+ log(`\n ${COLORS.red}✗${COLORS.reset} Fatal error occurred\n`, 'red');
452
480
  process.exit(1);
453
481
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ijuantm/simpl-addon",
3
3
  "description": "CLI tool to install Simpl framework add-ons.",
4
- "version": "2.0.0",
4
+ "version": "2.2.0",
5
5
  "scripts": {
6
6
  "link": "npm link",
7
7
  "unlink": "npm unlink -g @ijuantm/simpl-addon"
@@ -11,11 +11,11 @@
11
11
  "simpl-addon": "install.js"
12
12
  },
13
13
  "engines": {
14
- "node": ">=20.0.0"
14
+ "node": ">=22"
15
15
  },
16
16
  "repository": {
17
17
  "type": "git",
18
- "url": "https://github.com/IJuanTM/simpl"
18
+ "url": "https://github.com/IJuanTM/simpl/"
19
19
  },
20
20
  "keywords": [
21
21
  "simpl",
@@ -26,5 +26,5 @@
26
26
  ],
27
27
  "author": "Iwan van der Wal",
28
28
  "license": "GPL-3.0-only",
29
- "homepage": "https://simpl.iwanvanderwal.nl"
29
+ "homepage": "https://simpl.iwanvanderwal.nl/"
30
30
  }