@ijuantm/simpl-addon 2.6.6 → 2.8.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/.editorconfig +9 -9
  2. package/install.js +627 -632
  3. package/package.json +30 -30
package/install.js CHANGED
@@ -1,632 +1,627 @@
1
- #!/usr/bin/env node
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
- const https = require('https');
6
- const readline = require('readline');
7
- const {promisify} = require('util');
8
- const {exec} = require('child_process');
9
-
10
- const execAsync = promisify(exec);
11
-
12
- const COLORS = {
13
- reset: '\x1b[0m', green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m',
14
- cyan: '\x1b[36m', blue: '\x1b[34m', gray: '\x1b[90m', bold: '\x1b[1m', dim: '\x1b[2m'
15
- };
16
-
17
- const CDN_BASE = 'https://cdn.simpl.iwanvanderwal.nl/framework';
18
- const LOCAL_RELEASES_DIR = process.env.SIMPL_LOCAL_RELEASES || path.join(process.cwd(), 'local-releases');
19
- const BOX_WIDTH = 62;
20
-
21
- const log = (message = '', color = 'reset') => console.log(`${COLORS[color]}${message}${COLORS.reset}`);
22
-
23
- const box = (title) => {
24
- log();
25
- log(` ╭${''.repeat(BOX_WIDTH)}╮`);
26
- const contentWidth = BOX_WIDTH - 2; // Remove the two padding characters on each side
27
- const remainingSpace = contentWidth - (typeof title === 'string' ? title.length : 0);
28
- const padding = remainingSpace > 0 ? ' '.repeat(remainingSpace) : '';
29
- log(` ${COLORS.bold}${title}${COLORS.reset}${padding}│`);
30
- log(` ╰${''.repeat(BOX_WIDTH)}╯`);
31
- log();
32
- };
33
-
34
- const fetchUrl = (url) => new Promise((resolve, reject) => {
35
- https.get(url, res => {
36
- if (res.statusCode === 302 || res.statusCode === 301) return fetchUrl(res.headers.location).then(resolve).catch(reject);
37
- if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage || 'Request failed'}`));
38
-
39
- let data = '';
40
- res.on('data', chunk => data += chunk);
41
- res.on('end', () => resolve(data));
42
- }).on('error', reject);
43
- });
44
-
45
- const downloadFile = (url, dest) => new Promise((resolve, reject) => {
46
- const file = fs.createWriteStream(dest);
47
-
48
- https.get(url, res => {
49
- if (res.statusCode === 302 || res.statusCode === 301) {
50
- fs.unlinkSync(dest);
51
- return downloadFile(res.headers.location, dest).then(resolve).catch(reject);
52
- }
53
- if (res.statusCode !== 200) {
54
- fs.unlinkSync(dest);
55
- return reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage || 'Request failed'}`));
56
- }
57
-
58
- res.pipe(file);
59
- file.on('finish', () => {
60
- file.close();
61
- resolve();
62
- });
63
- }).on('error', err => {
64
- fs.unlinkSync(dest);
65
- reject(err);
66
- });
67
-
68
- file.on('error', err => {
69
- fs.unlinkSync(dest);
70
- reject(err);
71
- });
72
- });
73
-
74
- const promptUser = (question, defaultValue = '') => new Promise(resolve => {
75
- const rl = readline.createInterface({input: process.stdin, output: process.stdout});
76
- const prompt = defaultValue ? `${question} ${COLORS.dim}(${defaultValue})${COLORS.reset}: ` : `${question}: `;
77
- rl.question(prompt, answer => {
78
- rl.close();
79
- resolve(answer.trim() || defaultValue);
80
- });
81
- });
82
-
83
- const printAnswer = (question, value) => console.log(`${question}: ${COLORS.cyan}${value}${COLORS.reset}`);
84
-
85
- const getSimplVersion = () => {
86
- const simplFile = path.join(process.cwd(), '.simpl');
87
- if (!fs.existsSync(simplFile)) throw new Error('Not a Simpl project. Missing .simpl file in current directory.');
88
- const config = JSON.parse(fs.readFileSync(simplFile, 'utf8'));
89
- if (!config.version) throw new Error('Invalid .simpl file: missing version field');
90
- return config.version;
91
- };
92
-
93
- const parseArgs = (args) => {
94
- const result = {addon: null, unknownFlags: [], help: false, list: false};
95
- for (const arg of args) {
96
- if (arg === '--help' || arg === '-h') result.help = true;
97
- else if (arg === '--list' || arg === '-l') result.list = true;
98
- else if (arg.startsWith('--addon=')) result.addon = arg.slice(8).trim() || null;
99
- else if (arg.startsWith('-a=')) result.addon = arg.slice(3).trim() || null;
100
- else if (arg.startsWith('-') && !arg.startsWith('--addon') && !arg.startsWith('-a')) result.unknownFlags.push(arg);
101
- else if (!arg.startsWith('-') && !result.addon) result.addon = arg;
102
- }
103
- return result;
104
- };
105
-
106
- const KNOWN_FLAGS = ['--addon', '-a', '--help', '-h', '--list', '-l'];
107
-
108
- const levenshtein = (a, b) => {
109
- const m = a.length, n = b.length;
110
- const dp = Array.from({length: m + 1}, (_, i) => Array.from({length: n + 1}, (_, j) => i === 0 ? j : j === 0 ? i : 0));
111
- for (let i = 1; i <= m; i++) for (let j = 1; j <= n; j++) dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
112
- return dp[m][n];
113
- };
114
-
115
- const closestMatch = (input, options) => {
116
- let best = null, bestDist = Infinity;
117
- for (const opt of options) {
118
- const dist = levenshtein(input.toLowerCase(), opt.toLowerCase());
119
- if (dist < bestDist) {
120
- bestDist = dist;
121
- best = opt;
122
- }
123
- }
124
- return bestDist <= Math.max(3, Math.floor(input.length / 2)) ? best : null;
125
- };
126
-
127
- const promptAddon = async (addons, firstInput = null) => {
128
- const askSuggestion = async (input) => {
129
- const suggestion = closestMatch(input, addons);
130
- log();
131
- log(` ${COLORS.red}✗${COLORS.reset} Add-on ${COLORS.bold}${input}${COLORS.reset} not found`);
132
-
133
- if (suggestion) {
134
- log(` Did you mean: ${COLORS.blue}${suggestion}${COLORS.reset}?`);
135
- log();
136
-
137
- while (true) {
138
- const answer = await promptUser(` Use "${suggestion}"? ${COLORS.dim}(yes / no (lists available add-ons))${COLORS.reset}`);
139
- const a = answer.toLowerCase();
140
- if (a === 'yes' || a === 'y') return suggestion;
141
- if (a === 'no' || a === 'n') break;
142
- log(` ${COLORS.yellow}⚠${COLORS.reset} ${COLORS.dim}Please answer yes or no${COLORS.reset}`);
143
- }
144
- }
145
-
146
- log();
147
- log(` ${COLORS.bold}Available add-ons:${COLORS.reset}`, 'blue');
148
- listAddons(addons);
149
- log();
150
- return null;
151
- };
152
-
153
- let pending = firstInput;
154
- while (true) {
155
- const input = pending || await promptUser(` Add-on to install ${COLORS.dim}(name or number)${COLORS.reset}`);
156
- pending = null;
157
-
158
- if (!input) {
159
- log(` ${COLORS.yellow}⚠${COLORS.reset} ${COLORS.dim}Selection cannot be empty${COLORS.reset}`);
160
- log();
161
- continue;
162
- }
163
-
164
- const numInput = parseInt(input, 10);
165
- if (!isNaN(numInput) && numInput >= 1 && numInput <= addons.length) return addons[numInput - 1];
166
- if (addons.includes(input)) return input;
167
-
168
- const resolved = await askSuggestion(input);
169
- if (resolved) return resolved;
170
- }
171
- };
172
-
173
- const listAddons = (addons) => addons.forEach((name, i) => log(` ${COLORS.cyan}${i + 1}.${COLORS.reset} ${name}`));
174
-
175
- const showHelp = () => {
176
- box('Simpl Add-on Installer');
177
- log(` ${COLORS.bold}Usage:${COLORS.reset}`, 'blue');
178
- log(` ${COLORS.dim}npx @ijuantm/simpl-addon${COLORS.reset}`);
179
- log(` ${COLORS.dim}npx @ijuantm/simpl-addon --addon=<name>${COLORS.reset}`);
180
- log(` ${COLORS.dim}npx @ijuantm/simpl-addon --help${COLORS.reset}`);
181
- log();
182
- log(` ${COLORS.bold}Options:${COLORS.reset}`, 'blue');
183
- log(` ${COLORS.dim}--addon=<name>, -a=<name>${COLORS.reset} Add-on to install`);
184
- log(` ${COLORS.dim}--list, -l${COLORS.reset} List available add-ons`);
185
- log(` ${COLORS.dim}--help, -h${COLORS.reset} Show this help message`);
186
- log();
187
- log(` ${COLORS.bold}Note:${COLORS.reset}`, 'blue');
188
- log(` Run this command from the root of your Simpl project.`);
189
- log(` The add-on version will match your Simpl framework version.`);
190
- log();
191
- };
192
-
193
- const checkServerAvailability = () => new Promise(resolve => {
194
- https.get(`${CDN_BASE}/versions.json`, {timeout: 5000}, res => {
195
- res.resume();
196
- resolve(res.statusCode === 200);
197
- }).on('error', () => resolve(false)).on('timeout', () => resolve(false));
198
- });
199
-
200
- const getVersionsData = async () => {
201
- if (!await checkServerAvailability()) throw new Error('CDN server is currently unreachable');
202
- return JSON.parse(await fetchUrl(`${CDN_BASE}/versions.json`));
203
- };
204
-
205
- const getAvailableAddons = async (version) => {
206
- const localAddonsDir = path.join(LOCAL_RELEASES_DIR, version, 'add-ons');
207
-
208
- if (fs.existsSync(localAddonsDir)) return fs.readdirSync(localAddonsDir, {withFileTypes: true})
209
- .filter(entry => entry.isFile() && entry.name.endsWith('.zip'))
210
- .map(entry => entry.name.replace('.zip', ''))
211
- .sort();
212
-
213
- const versionMeta = (await getVersionsData()).versions[version];
214
- return (versionMeta?.['add-ons'] || []).sort();
215
- };
216
-
217
- const extractMarkers = (content) => {
218
- const markers = [];
219
- content.split('\n').forEach((line, i) => {
220
- const afterMatch = line.match(/@addon-insert:after\s*\(\s*(["'])(.*?)\1\s*\)/);
221
- const beforeMatch = line.match(/@addon-insert:before\s*\(\s*(["'])(.*?)\1\s*\)/);
222
- const replaceMatch = line.match(/@addon-insert:replace\s*\(\s*(["'])(.*?)\1\s*\)/);
223
-
224
- if (afterMatch) markers.push({type: 'after', lineIndex: i, searchText: afterMatch[2]});
225
- else if (beforeMatch) markers.push({type: 'before', lineIndex: i, searchText: beforeMatch[2]});
226
- else if (replaceMatch) markers.push({type: 'replace', lineIndex: i, markerName: replaceMatch[2]});
227
- else if (line.includes('@addon-insert:prepend')) markers.push({type: 'prepend', lineIndex: i});
228
- else if (line.includes('@addon-insert:append')) markers.push({type: 'append', lineIndex: i});
229
- });
230
- return markers;
231
- };
232
-
233
- const collectContentBetweenMarkers = (lines, startIndex) => {
234
- const content = [];
235
- for (let i = startIndex + 1; i < lines.length; i++) {
236
- if (lines[i].trim().includes('@addon-end')) break;
237
- content.push(lines[i]);
238
- }
239
- return content;
240
- };
241
-
242
- const normalizeContent = (lines) => lines.map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('#') && !l.startsWith('/*') && !l.startsWith('*')).join('|');
243
-
244
- const processEnvContent = (content, targetContent) => {
245
- const envVarsToAdd = [], comments = [];
246
- content.forEach(line => {
247
- const trimmed = line.trim();
248
- if (trimmed.startsWith('#') || !trimmed) {
249
- comments.push(line);
250
- return;
251
- }
252
-
253
- const match = line.match(/^([A-Z_][A-Z0-9_]*)=/);
254
- if (match && !new RegExp(`^${match[1]}=`, 'm').test(targetContent)) envVarsToAdd.push(line);
255
- });
256
- return {content: [...comments, ...envVarsToAdd], count: envVarsToAdd.length};
257
- };
258
-
259
- const findInsertIndex = (lines, searchText, type) => {
260
- for (let i = 0; i < lines.length; i++) if (lines[i].includes(searchText)) return type === 'before' ? i : i + 1;
261
- return -1;
262
- };
263
-
264
- const mergeFile = (targetPath, addonContent, markers, isEnv = false) => {
265
- const targetContent = fs.readFileSync(targetPath, 'utf8');
266
- const addonLines = addonContent.split('\n');
267
- const operations = [];
268
- let newContent = targetContent;
269
-
270
- markers.forEach(marker => {
271
- let content = collectContentBetweenMarkers(addonLines, marker.lineIndex);
272
- if (content.length === 0) return;
273
-
274
- let lineCount = content.length;
275
-
276
- if (isEnv) {
277
- const processed = processEnvContent(content, newContent);
278
- content = processed.content;
279
- lineCount = processed.count;
280
-
281
- if (content.length === 0) {
282
- operations.push({success: false, type: marker.type, lines: 0, searchText: marker.searchText});
283
- return;
284
- }
285
- } else {
286
- const signature = normalizeContent(content);
287
- const targetSignature = normalizeContent(newContent.split('\n'));
288
-
289
- if (signature && targetSignature.includes(signature)) {
290
- operations.push({success: false, type: marker.type, lines: content.length, searchText: marker.searchText || marker.markerName});
291
- return;
292
- }
293
- }
294
-
295
- if (marker.type === 'prepend') {
296
- newContent = content.join('\n') + '\n' + newContent;
297
- operations.push({success: true, type: 'prepend', lines: lineCount});
298
- } else if (marker.type === 'append') {
299
- if (!newContent.endsWith('\n')) newContent += '\n';
300
- newContent += '\n' + content.join('\n') + '\n';
301
- operations.push({success: true, type: 'append', lines: lineCount});
302
- } else if (marker.type === 'replace' && marker.markerName) {
303
- const targetLines = newContent.split('\n');
304
- const replaceIndex = findInsertIndex(targetLines, marker.markerName, 'before');
305
-
306
- if (replaceIndex === -1) {
307
- operations.push({success: false, type: 'notfound', searchText: marker.markerName});
308
- return;
309
- }
310
-
311
- targetLines.splice(replaceIndex, 1, ...content);
312
- newContent = targetLines.join('\n');
313
- operations.push({success: true, type: 'replace', lines: lineCount, markerName: marker.markerName});
314
- } else if ((marker.type === 'after' || marker.type === 'before') && marker.searchText) {
315
- const targetLines = newContent.split('\n');
316
- const insertIndex = findInsertIndex(targetLines, marker.searchText, marker.type);
317
-
318
- if (insertIndex === -1) {
319
- operations.push({success: false, type: 'notfound', searchText: marker.searchText});
320
- return;
321
- }
322
-
323
- targetLines.splice(insertIndex, 0, ...content);
324
- newContent = targetLines.join('\n');
325
- operations.push({success: true, type: marker.type, lines: lineCount, searchText: marker.searchText});
326
- }
327
- });
328
-
329
- if (newContent !== targetContent) fs.writeFileSync(targetPath, newContent, 'utf8');
330
- return {modified: newContent !== targetContent, operations};
331
- };
332
-
333
- const printMergeResults = (relativePath, isEnv, result) => {
334
- const indent = ' ';
335
- const varText = isEnv ? 'environment variable' : 'line';
336
- let hasChanges = false;
337
-
338
- result.operations.forEach(op => {
339
- if (op.success) {
340
- hasChanges = true;
341
- 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`);
342
- 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`);
343
- else if (op.type === 'replace') log(`${indent}${COLORS.green}✓${COLORS.reset} Replaced marker ${COLORS.cyan}${op.markerName}${COLORS.reset} with ${COLORS.bold}${op.lines}${COLORS.reset} ${varText}${op.lines !== 1 ? 's' : ''}`);
344
- 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}`);
345
- else if (op.type === 'before') log(`${indent}${COLORS.green}✓${COLORS.reset} Inserted ${COLORS.bold}${op.lines}${COLORS.reset} ${varText}${op.lines !== 1 ? 's' : ''} ${COLORS.cyan}before${COLORS.reset} ${COLORS.dim}${op.searchText}${COLORS.reset}`);
346
- } else if (op.type === 'notfound') {
347
- const target = op.markerName ? `marker ${COLORS.dim}${op.markerName}${COLORS.reset}` : `${COLORS.dim}${op.searchText}${COLORS.reset}`;
348
- log(`${indent}${COLORS.yellow}⚠${COLORS.reset} ${COLORS.yellow}Could not find target:${COLORS.reset} ${target}`);
349
- } else log(`${indent}${COLORS.gray}○${COLORS.reset} ${COLORS.dim}Content already exists (${op.type})${COLORS.reset}`);
350
- });
351
-
352
- return hasChanges;
353
- };
354
-
355
- const extractZip = async (zipPath, destDir) => {
356
- fs.mkdirSync(destDir, {recursive: true});
357
- const cmd = process.platform === 'win32' ? `powershell -command "Expand-Archive -Path '${zipPath}' -DestinationPath '${destDir}' -Force"` : `unzip -q "${zipPath}" -d "${destDir}"`;
358
- await execAsync(cmd);
359
-
360
- const entries = fs.readdirSync(destDir, {withFileTypes: true});
361
- if (entries.length === 1 && entries[0].isDirectory()) {
362
- const nestedDir = path.join(destDir, entries[0].name);
363
- fs.readdirSync(nestedDir).forEach(item => fs.renameSync(path.join(nestedDir, item), path.join(destDir, item)));
364
- fs.rmdirSync(nestedDir);
365
- }
366
- return destDir;
367
- };
368
-
369
- const processAddonFiles = (addonDir, targetDir) => {
370
- const copied = [], skipped = [], toMerge = [];
371
-
372
- const processDirectory = (dir, basePath = '') => fs.readdirSync(dir, {withFileTypes: true}).forEach(entry => {
373
- if (entry.name === 'README.md') return;
374
-
375
- const srcPath = path.join(dir, entry.name);
376
- const relativePath = path.join(basePath, entry.name).replace(/\\/g, '/');
377
- const destPath = path.join(targetDir, relativePath);
378
-
379
- if (entry.isDirectory()) {
380
- processDirectory(srcPath, relativePath);
381
- } else {
382
- const content = fs.readFileSync(srcPath, 'utf8');
383
-
384
- if (fs.existsSync(destPath)) {
385
- const markers = extractMarkers(content);
386
- if (markers.length > 0 || entry.name === '.env') toMerge.push({content, destPath, relativePath, markers});
387
- else skipped.push(relativePath);
388
- } else {
389
- fs.mkdirSync(path.dirname(destPath), {recursive: true});
390
- fs.copyFileSync(srcPath, destPath);
391
- copied.push(relativePath);
392
- }
393
- }
394
- });
395
-
396
- processDirectory(addonDir);
397
- return {copied, skipped, toMerge};
398
- };
399
-
400
- const downloadAddon = async (addonName, version, targetDir) => {
401
- const localZipPath = path.join(LOCAL_RELEASES_DIR, version, 'add-ons', `${addonName}.zip`);
402
- const tempExtract = path.join(process.cwd(), '__temp_extract_addon__');
403
-
404
- try {
405
- if (fs.existsSync(localZipPath)) {
406
- log();
407
- log(` 💻 Using local add-on files`, 'bold');
408
- const sourceDir = await extractZip(localZipPath, tempExtract);
409
- const result = processAddonFiles(sourceDir, targetDir);
410
- fs.rmSync(tempExtract, {recursive: true, force: true});
411
- return result;
412
- }
413
-
414
- if (!await checkServerAvailability()) throw new Error('CDN server is currently unreachable');
415
-
416
- const tempZip = path.join(process.cwd(), `temp-addon-${addonName}.zip`);
417
- await downloadFile(`${CDN_BASE}/${version}/add-ons/${addonName}.zip`, tempZip);
418
- const sourceDir = await extractZip(tempZip, tempExtract);
419
- const result = processAddonFiles(sourceDir, targetDir);
420
- fs.unlinkSync(tempZip);
421
- fs.rmSync(tempExtract, {recursive: true, force: true});
422
- return result;
423
- } catch (error) {
424
- if (fs.existsSync(tempExtract)) fs.rmSync(tempExtract, {recursive: true, force: true});
425
- throw error;
426
- }
427
- };
428
-
429
- const mergeFiles = (toMerge) => {
430
- if (toMerge.length === 0) return {merged: [], failed: [], unchanged: []};
431
-
432
- const merged = [], failed = [], unchanged = [];
433
-
434
- toMerge.forEach(({content, destPath, relativePath, markers}) => {
435
- const isEnv = path.basename(destPath) === '.env';
436
- log(`\n ${COLORS.cyan}•${COLORS.reset} ${COLORS.dim}${relativePath}${COLORS.reset}`);
437
-
438
- try {
439
- const result = mergeFile(destPath, content, markers, isEnv);
440
- if (printMergeResults(relativePath, isEnv, result)) merged.push(relativePath);
441
- else unchanged.push(relativePath);
442
- } catch (error) {
443
- log(` ${COLORS.red}✗ Error:${COLORS.reset} ${error.message}`);
444
- failed.push(relativePath);
445
- }
446
- });
447
-
448
- return {merged, failed, unchanged};
449
- };
450
-
451
- const main = async () => {
452
- const args = process.argv.slice(2);
453
- const parsed = parseArgs(args);
454
-
455
- if (parsed.help) {
456
- showHelp();
457
- process.exit(0);
458
- }
459
-
460
- // Warn about unknown flags and suggest closest known ones
461
- for (const flag of parsed.unknownFlags) {
462
- const flagName = flag.includes('=') ? flag.slice(0, flag.indexOf('=')) : flag;
463
- const suggestion = closestMatch(flagName, KNOWN_FLAGS);
464
- log();
465
- log(` ${COLORS.yellow}⚠${COLORS.reset} Unknown option: ${COLORS.bold}${flag}${COLORS.reset}`, 'yellow');
466
- if (suggestion) log(` ${COLORS.dim}Did you mean ${COLORS.reset}${COLORS.cyan}${suggestion}${COLORS.reset}${COLORS.dim}?${COLORS.reset}`);
467
- }
468
-
469
- if (parsed.unknownFlags.length > 0) {
470
- log(` ${COLORS.dim}Run with --help to see all available options.${COLORS.reset}`);
471
- log();
472
- process.exit(1);
473
- }
474
-
475
- let version;
476
-
477
- try {
478
- version = getSimplVersion();
479
- } catch (error) {
480
- log();
481
- log(` ${COLORS.red}✗${COLORS.reset} ${error.message}`);
482
- log();
483
- process.exit(1);
484
- }
485
-
486
- log();
487
- box(`Simpl Add-on Installer${COLORS.dim}(v${version})${COLORS.reset}`);
488
-
489
- let versionsData;
490
-
491
- try {
492
- versionsData = await getVersionsData();
493
- } catch (error) {
494
- log();
495
- log(` ${COLORS.red}✗${COLORS.reset} Failed to fetch version data`);
496
- if (error.message === 'CDN server is currently unreachable') log(` ${COLORS.dim}The CDN server is currently unavailable. Please try again later.${COLORS.reset}`);
497
- log();
498
- process.exit(1);
499
- }
500
-
501
- const versionMeta = versionsData.versions[version];
502
- if (!versionMeta) {
503
- log();
504
- log(` ${COLORS.red}✗${COLORS.reset} Version ${COLORS.bold}${version}${COLORS.reset} not found`);
505
- log();
506
- process.exit(1);
507
- }
508
-
509
- if (versionMeta['script-compatible'] === false) {
510
- log();
511
- log(` ${COLORS.red}✗${COLORS.reset} Version ${COLORS.bold}${version}${COLORS.reset} is not compatible with this installer`);
512
- log();
513
- log(` ${COLORS.bold}Manual download:${COLORS.reset}`, 'blue');
514
- log(` ${COLORS.cyan}${CDN_BASE}/${version}/add-ons/`, 'cyan');
515
- log();
516
- log(` ${COLORS.bold}Available add-ons for this version:${COLORS.reset}`, 'blue');
517
-
518
- const addons = versionMeta['add-ons'] || [];
519
- if (addons.length === 0) log(` ${COLORS.dim}No add-ons available${COLORS.reset}`);
520
- else addons.forEach(name => {
521
- log(` ${COLORS.cyan}•${COLORS.reset} ${name}: ${COLORS.dim}${CDN_BASE}/${version}/add-ons/${name}.zip${COLORS.reset}`);
522
- });
523
-
524
- log();
525
- process.exit(1);
526
- }
527
-
528
- if (!parsed.addon) {
529
- log();
530
- log(' 🗄️ Fetching available add-ons...', 'bold');
531
- }
532
-
533
- let addons;
534
-
535
- try {
536
- addons = await getAvailableAddons(version);
537
- } catch (error) {
538
- log();
539
- log(` ${COLORS.red}✗${COLORS.reset} Failed to fetch add-ons`);
540
- if (error.message === 'CDN server is currently unreachable') log(` ${COLORS.dim}The CDN server is currently unavailable. Please try again later.${COLORS.reset}`);
541
- log();
542
- process.exit(1);
543
- }
544
-
545
- if (addons.length === 0) {
546
- log();
547
- log(` ${COLORS.yellow}⚠${COLORS.reset} No add-ons available for this version`);
548
- log();
549
- process.exit(0);
550
- }
551
-
552
- if (parsed.list) {
553
- log();
554
- log(` ${COLORS.bold}Available add-ons:${COLORS.reset}`, 'blue');
555
- listAddons(addons);
556
- log();
557
- process.exit(0);
558
- }
559
-
560
- let addonName;
561
-
562
- if (parsed.addon) {
563
- addonName = await promptAddon(addons, parsed.addon);
564
- printAnswer(' Add-on to install', addonName);
565
- } else {
566
- log();
567
- log(` ${COLORS.bold}Available add-ons:${COLORS.reset}`, 'blue');
568
- listAddons(addons);
569
- log();
570
-
571
- addonName = await promptAddon(addons);
572
- }
573
-
574
- log();
575
- box(`Installing: ${COLORS.cyan}${addonName}${COLORS.reset} ${COLORS.dim}(v${version})${COLORS.reset}`);
576
- log(` 📦 Downloading ${COLORS.cyan}${addonName}${COLORS.reset} add-on...`, 'bold');
577
-
578
- let copied, skipped, toMerge;
579
-
580
- try {
581
- ({copied, skipped, toMerge} = await downloadAddon(addonName, version, process.cwd()));
582
- } catch (error) {
583
- log();
584
- log(` ${COLORS.red}✗${COLORS.reset} Installation failed`);
585
- if (error.message === 'CDN server is currently unreachable') log(` ${COLORS.dim}The CDN server is currently unavailable. Please try again later.${COLORS.reset}`);
586
- else log(` ${COLORS.dim}Please verify the add-on exists and try again${COLORS.reset}`);
587
- log();
588
- process.exit(1);
589
- }
590
-
591
- if (copied.length > 0) {
592
- log();
593
- log(` ${COLORS.green}✓${COLORS.reset} Copied ${COLORS.bold}${copied.length}${COLORS.reset} new file${copied.length !== 1 ? 's' : ''}`);
594
- }
595
-
596
- if (skipped.length > 0) {
597
- log();
598
- log(` ${COLORS.gray}○${COLORS.reset} ${COLORS.dim}Skipped ${skipped.length} file${skipped.length !== 1 ? 's' : ''} (no merge markers):${COLORS.reset}`);
599
- skipped.forEach(file => log(` ${COLORS.dim}• ${file}${COLORS.reset}`));
600
- }
601
-
602
- if (toMerge.length > 0) {
603
- log();
604
- log(' 🔀 Merging existing files...', 'bold');
605
- const {merged, failed, unchanged} = mergeFiles(toMerge);
606
-
607
- log();
608
- log(' ' + ''.repeat(16), 'gray');
609
- log();
610
-
611
- if (merged.length > 0) log(` ${COLORS.green}✓${COLORS.reset} Successfully merged ${COLORS.bold}${merged.length}${COLORS.reset} file${merged.length !== 1 ? 's' : ''}`);
612
- if (unchanged.length > 0) log(` ${COLORS.gray}○${COLORS.reset} ${COLORS.dim}${unchanged.length} file${unchanged.length !== 1 ? 's' : ''} unchanged (content already exists)${COLORS.reset}`);
613
-
614
- if (failed.length > 0) {
615
- log();
616
- log(` ${COLORS.yellow}⚠${COLORS.reset} ${COLORS.yellow}${failed.length} file${failed.length !== 1 ? 's' : ''} failed to merge${COLORS.reset}`);
617
- log(` ${COLORS.yellow}Please review manually:${COLORS.reset}`);
618
- failed.forEach(file => log(` ${COLORS.cyan}• ${file}${COLORS.reset}`));
619
- }
620
- }
621
-
622
- log();
623
- log(` ${COLORS.green}✓${COLORS.reset} ${COLORS.bold}${COLORS.green}Installation complete!${COLORS.reset}`, 'green');
624
- log();
625
- };
626
-
627
- main().catch(() => {
628
- log();
629
- log(` ${COLORS.red}✗${COLORS.reset} Fatal error occurred`);
630
- log();
631
- process.exit(1);
632
- });
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const https = require('https');
7
+ const readline = require('readline');
8
+ const {promisify} = require('util');
9
+ const {exec} = require('child_process');
10
+
11
+ const execAsync = promisify(exec);
12
+
13
+ const C = {
14
+ reset: '\x1b[0m', green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m',
15
+ cyan: '\x1b[36m', blue: '\x1b[34m', gray: '\x1b[90m', bold: '\x1b[1m', dim: '\x1b[2m',
16
+ };
17
+
18
+ const CDN_BASE = 'https://cdn.simpl.iwanvanderwal.nl/framework';
19
+ const LOCAL_RELEASES_DIR = process.env.SIMPL_LOCAL_RELEASES || path.join(process.cwd(), 'local-releases');
20
+ const BOX_WIDTH = 62;
21
+ const PAD = ' ';
22
+ const TEMP_DIR_PREFIX = 'simpl-addon-';
23
+
24
+ const styled = (msg, ...styles) => styles.join('') + msg + C.reset;
25
+ const line = (msg = '') => console.log(msg);
26
+ const out = (msg, color = C.reset) => console.log(color + msg + C.reset);
27
+ const prefixed = (symbol, color, msg, bold = false, dim = false) => out(PAD + color + symbol + C.reset + ' ' + (bold ? styled(msg, C.bold) : dim ? styled(msg, C.dim) : msg));
28
+
29
+ const success = (msg, bold = false) => prefixed('✓', C.green, msg, bold);
30
+ const error = (msg, bold = false) => prefixed('', C.red, msg, bold);
31
+ const warn = (msg, bold = false) => prefixed('⚠', C.yellow, msg, bold);
32
+ const info = (msg) => prefixed('◌', C.cyan, msg, false, true);
33
+ const task = (msg) => out(PAD + msg);
34
+ const item = (msg, dim = false) => out(PAD + C.cyan + '•' + C.reset + ' ' + (dim ? styled(msg, C.dim) : msg));
35
+
36
+ const box = (title) => {
37
+ const plain = title.replace(/\x1b\[[0-9;]*m/g, '');
38
+ const truncated = plain.length > BOX_WIDTH - 2 ? plain.slice(0, BOX_WIDTH - 5) + '...' : plain;
39
+ const displayTitle = title.replace(plain, truncated);
40
+ const spaces = ' '.repeat(BOX_WIDTH - 2 - truncated.length);
41
+ line();
42
+ out(PAD + '' + '─'.repeat(BOX_WIDTH) + '╮');
43
+ out(PAD + '│ ' + styled(displayTitle, C.bold) + spaces + ' │');
44
+ out(PAD + '╰' + '─'.repeat(BOX_WIDTH) + '╯');
45
+ };
46
+
47
+ const divider = () => {
48
+ line();
49
+ out(PAD + '─'.repeat(16), C.dim);
50
+ line();
51
+ };
52
+
53
+ const printAnswer = (question, value) => out(`${question}: ${C.cyan}${value}${C.reset}`);
54
+
55
+ const cleanupPath = (targetPath) => {
56
+ try {
57
+ fs.rmSync(targetPath, {recursive: true, force: true});
58
+ } catch {
59
+ }
60
+ };
61
+
62
+ const resolveRedirectUrl = (baseUrl, location) => new URL(location, baseUrl).toString();
63
+
64
+ const isRedirect = (statusCode) => [301, 302].includes(statusCode);
65
+ const checkStatus = (statusCode, statusMessage, location, url) => {
66
+ if (isRedirect(statusCode)) {
67
+ if (!location) throw new Error(`HTTP ${statusCode}: Redirect missing location`);
68
+ return resolveRedirectUrl(url, location);
69
+ }
70
+ if (statusCode !== 200) throw new Error(`HTTP ${statusCode}: ${statusMessage || 'Request failed'}`);
71
+ return null;
72
+ };
73
+
74
+ const fetchUrl = (url) => new Promise((resolve, reject) => {
75
+ https.get(url, res => {
76
+ try {
77
+ const redirect = checkStatus(res.statusCode, res.statusMessage, res.headers.location, url);
78
+ if (redirect) return fetchUrl(redirect).then(resolve).catch(reject);
79
+ } catch (err) {
80
+ return reject(err);
81
+ }
82
+ let data = '';
83
+ res.on('data', chunk => data += chunk);
84
+ res.on('end', () => resolve(data));
85
+ }).on('error', reject).setTimeout(10000, () => reject(new Error('Request timed out')));
86
+ });
87
+
88
+ const downloadFile = (url, dest) => new Promise((resolve, reject) => {
89
+ const file = fs.createWriteStream(dest);
90
+ let settled = false;
91
+ const fail = (err) => {
92
+ if (settled) return;
93
+ settled = true;
94
+ cleanupPath(dest);
95
+ reject(err);
96
+ };
97
+
98
+ https.get(url, res => {
99
+ try {
100
+ const redirect = checkStatus(res.statusCode, res.statusMessage, res.headers.location, url);
101
+ if (redirect) return downloadFile(redirect, dest).then(resolve).catch(reject);
102
+ } catch (err) {
103
+ return fail(err);
104
+ }
105
+ res.pipe(file);
106
+ file.on('finish', () => file.close(err => err ? fail(err) : resolve()));
107
+ }).on('error', fail);
108
+ file.on('error', fail);
109
+ });
110
+
111
+ const promptUser = (question, defaultValue = '') => new Promise(resolve => {
112
+ const rl = readline.createInterface({input: process.stdin, output: process.stdout});
113
+ const prompt = defaultValue ? `${question} ${C.dim}(${defaultValue})${C.reset}: ` : `${question}: `;
114
+ rl.question(prompt, answer => {
115
+ rl.close();
116
+ resolve(answer.trim() || defaultValue);
117
+ });
118
+ });
119
+
120
+ const parseArgs = (args) => {
121
+ const result = {addon: null, unknownFlags: [], help: false, list: false};
122
+ for (const arg of args) {
123
+ if (arg === '--help' || arg === '-h') result.help = true;
124
+ else if (arg === '--list' || arg === '-l') result.list = true;
125
+ else if (arg.startsWith('--addon=')) result.addon = arg.slice(8).trim() || null;
126
+ else if (arg.startsWith('-a=')) result.addon = arg.slice(3).trim() || null;
127
+ else if (arg.startsWith('-') && !arg.startsWith('--addon') && !arg.startsWith('-a')) result.unknownFlags.push(arg);
128
+ else if (!arg.startsWith('-') && !result.addon) result.addon = arg;
129
+ }
130
+ return result;
131
+ };
132
+
133
+ const KNOWN_FLAGS = ['--addon', '-a', '--help', '-h', '--list', '-l'];
134
+
135
+ const levenshtein = (a, b) => {
136
+ const m = a.length, n = b.length;
137
+ const dp = Array.from({length: m + 1}, (_, i) => Array.from({length: n + 1}, (_, j) => i === 0 ? j : j === 0 ? i : 0));
138
+ for (let i = 1; i <= m; i++) for (let j = 1; j <= n; j++) dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
139
+ return dp[m][n];
140
+ };
141
+
142
+ const closestMatch = (input, options) => {
143
+ let best = null, bestDist = Infinity;
144
+ for (const opt of options) {
145
+ const dist = levenshtein(input.toLowerCase(), opt.toLowerCase());
146
+ if (dist < bestDist) {
147
+ bestDist = dist;
148
+ best = opt;
149
+ }
150
+ }
151
+ return bestDist <= Math.max(3, Math.floor(input.length / 2)) ? best : null;
152
+ };
153
+
154
+ const listAddons = (addons) => addons.forEach((name, i) => out(PAD + C.cyan + `${i + 1}.` + C.reset + ' ' + name));
155
+
156
+ const confirmSuggestion = async (suggestion) => {
157
+ line();
158
+ while (true) {
159
+ const a = (await promptUser(PAD + `${C.cyan}◌${C.reset} ${C.dim}Did you mean${C.reset} ${C.cyan}${suggestion}${C.reset}${C.dim}?${C.reset} ([Y] Yes / [N] No)`)).toLowerCase();
160
+ if (['y', 'yes'].includes(a)) return true;
161
+ if (['n', 'no'].includes(a)) return false;
162
+ warn('Please answer [Y] Yes or [N] No)');
163
+ line();
164
+ }
165
+ };
166
+
167
+ const promptAddon = async (addons, firstInput = null) => {
168
+ const askSuggestion = async (input) => {
169
+ line();
170
+ error(`Add-on ${styled(input, C.bold)} not found`);
171
+ const suggestion = closestMatch(input, addons);
172
+ if (suggestion && await confirmSuggestion(suggestion)) return suggestion;
173
+ line();
174
+ out(PAD + styled('Available add-ons:', C.bold), C.blue);
175
+ listAddons(addons);
176
+ line();
177
+ return null;
178
+ };
179
+
180
+ let pending = firstInput;
181
+ while (true) {
182
+ const input = pending || await promptUser(PAD + `Add-on to install ${C.dim}(name or number)${C.reset}`);
183
+ pending = null;
184
+ if (!input) {
185
+ warn('Selection cannot be empty');
186
+ line();
187
+ continue;
188
+ }
189
+ const num = parseInt(input, 10);
190
+ if (!isNaN(num) && num >= 1 && num <= addons.length) return addons[num - 1];
191
+ if (addons.includes(input)) return input;
192
+ const resolved = await askSuggestion(input);
193
+ if (resolved) return resolved;
194
+ }
195
+ };
196
+
197
+ const showHelp = () => {
198
+ box('Simpl Add-on Installer');
199
+ line();
200
+ out(PAD + styled('Usage:', C.bold), C.blue);
201
+ out(PAD + styled('npx @ijuantm/simpl-addon', C.dim));
202
+ out(PAD + styled('npx @ijuantm/simpl-addon --addon=<name>', C.dim));
203
+ out(PAD + styled('npx @ijuantm/simpl-addon --help', C.dim));
204
+ line();
205
+ out(PAD + styled('Options:', C.bold), C.blue);
206
+ out(PAD + styled('--addon=<name>, -a=<name>', C.dim) + ' Add-on to install');
207
+ out(PAD + styled('--list, -l', C.dim) + ' List available add-ons');
208
+ out(PAD + styled('--help, -h', C.dim) + ' Show this help message');
209
+ line();
210
+ out(PAD + styled('Note:', C.bold), C.blue);
211
+ item('Run this command from the root of your Simpl project.');
212
+ item('The add-on version will match your Simpl framework version.');
213
+ line();
214
+ };
215
+
216
+ const checkServerAvailability = () => new Promise(resolve => {
217
+ https.get(`${CDN_BASE}/versions.json`, {timeout: 5000}, res => {
218
+ res.resume();
219
+ resolve(res.statusCode === 200);
220
+ }).on('error', () => resolve(false)).on('timeout', () => resolve(false));
221
+ });
222
+
223
+ const getVersionsData = async () => {
224
+ try {
225
+ return JSON.parse(await fetchUrl(`${CDN_BASE}/versions.json`));
226
+ } catch {
227
+ return {versions: {}};
228
+ }
229
+ };
230
+
231
+ const getSimplVersion = () => {
232
+ const simplFile = path.join(process.cwd(), '.simpl');
233
+ if (!fs.existsSync(simplFile)) throw new Error('Not a Simpl project. Missing .simpl file in current directory.');
234
+ const config = JSON.parse(fs.readFileSync(simplFile, 'utf8'));
235
+ if (!config.version) throw new Error('Invalid .simpl file: missing version field');
236
+ return config.version;
237
+ };
238
+
239
+ const getAvailableAddons = async (version) => {
240
+ const localAddonsDir = path.join(LOCAL_RELEASES_DIR, version, 'add-ons');
241
+ if (fs.existsSync(localAddonsDir)) return fs.readdirSync(localAddonsDir, {withFileTypes: true})
242
+ .filter(e => e.isFile() && e.name.endsWith('.zip'))
243
+ .map(e => e.name.slice(0, -4))
244
+ .sort();
245
+ return ((await getVersionsData()).versions[version]?.['add-ons'] || []).sort();
246
+ };
247
+
248
+ const extractMarkers = (content) => {
249
+ const markers = [];
250
+ content.split('\n').forEach((line, i) => {
251
+ const after = line.match(/@addon-insert:after\s*\(\s*(["'])(.*?)\1\s*\)/);
252
+ const before = line.match(/@addon-insert:before\s*\(\s*(["'])(.*?)\1\s*\)/);
253
+ const replace = line.match(/@addon-insert:replace\s*\(\s*(["'])(.*?)\1\s*\)/);
254
+ if (after) markers.push({type: 'after', lineIndex: i, searchText: after[2]});
255
+ else if (before) markers.push({type: 'before', lineIndex: i, searchText: before[2]});
256
+ else if (replace) markers.push({type: 'replace', lineIndex: i, markerName: replace[2]});
257
+ else if (line.includes('@addon-insert:prepend')) markers.push({type: 'prepend', lineIndex: i});
258
+ else if (line.includes('@addon-insert:append')) markers.push({type: 'append', lineIndex: i});
259
+ });
260
+ return markers;
261
+ };
262
+
263
+ const collectContentBetweenMarkers = (lines, startIndex) => {
264
+ const content = [];
265
+ for (let i = startIndex + 1; i < lines.length; i++) {
266
+ if (lines[i].trim().includes('@addon-end')) break;
267
+ content.push(lines[i]);
268
+ }
269
+ return content;
270
+ };
271
+
272
+ const normalizeContent = (lines) => lines.map(l => l.trim())
273
+ .filter(l => l && !l.startsWith('//') && !l.startsWith('#') && !l.startsWith('/*') && !l.startsWith('*'))
274
+ .join('|');
275
+
276
+ const processEnvContent = (content, targetContent) => {
277
+ const envVarsToAdd = [], comments = [];
278
+ for (const line of content) {
279
+ const trimmed = line.trim();
280
+ if (trimmed.startsWith('#') || !trimmed) {
281
+ comments.push(line);
282
+ continue;
283
+ }
284
+ const match = line.match(/^([A-Z_][A-Z0-9_]*)=/);
285
+ if (match && !new RegExp(`^${match[1]}=`, 'm').test(targetContent)) envVarsToAdd.push(line);
286
+ }
287
+ return {content: [...comments, ...envVarsToAdd], count: envVarsToAdd.length};
288
+ };
289
+
290
+ const findInsertIndex = (lines, searchText, type) => {
291
+ for (let i = 0; i < lines.length; i++) if (lines[i].includes(searchText)) return type === 'before' ? i : i + 1;
292
+ return -1;
293
+ };
294
+
295
+ const mergeFile = (targetPath, addonContent, markers, isEnv = false) => {
296
+ const targetContent = fs.readFileSync(targetPath, 'utf8');
297
+ const addonLines = addonContent.split('\n');
298
+ const operations = [];
299
+ let newContent = targetContent;
300
+
301
+ for (const marker of markers) {
302
+ let content = collectContentBetweenMarkers(addonLines, marker.lineIndex);
303
+ if (!content.length) continue;
304
+ let lineCount = content.length;
305
+
306
+ if (isEnv) {
307
+ const processed = processEnvContent(content, newContent);
308
+ content = processed.content;
309
+ lineCount = processed.count;
310
+ if (!content.length) {
311
+ operations.push({success: false, type: marker.type, lines: 0, searchText: marker.searchText});
312
+ continue;
313
+ }
314
+ } else {
315
+ const signature = normalizeContent(content);
316
+ if (signature && normalizeContent(newContent.split('\n')).includes(signature)) {
317
+ operations.push({success: false, type: marker.type, lines: content.length, searchText: marker.searchText || marker.markerName});
318
+ continue;
319
+ }
320
+ }
321
+
322
+ if (marker.type === 'prepend') {
323
+ newContent = content.join('\n') + '\n' + newContent;
324
+ operations.push({success: true, type: 'prepend', lines: lineCount});
325
+ } else if (marker.type === 'append') {
326
+ if (!newContent.endsWith('\n')) newContent += '\n';
327
+ newContent += '\n' + content.join('\n') + '\n';
328
+ operations.push({success: true, type: 'append', lines: lineCount});
329
+ } else if (marker.type === 'replace' && marker.markerName) {
330
+ const targetLines = newContent.split('\n');
331
+ const replaceIndex = findInsertIndex(targetLines, marker.markerName, 'before');
332
+ if (replaceIndex === -1) {
333
+ operations.push({success: false, type: 'notfound', searchText: marker.markerName});
334
+ continue;
335
+ }
336
+ targetLines.splice(replaceIndex, 1, ...content);
337
+ newContent = targetLines.join('\n');
338
+ operations.push({success: true, type: 'replace', lines: lineCount, markerName: marker.markerName});
339
+ } else if ((marker.type === 'after' || marker.type === 'before') && marker.searchText) {
340
+ const targetLines = newContent.split('\n');
341
+ const insertIndex = findInsertIndex(targetLines, marker.searchText, marker.type);
342
+ if (insertIndex === -1) {
343
+ operations.push({success: false, type: 'notfound', searchText: marker.searchText});
344
+ continue;
345
+ }
346
+ targetLines.splice(insertIndex, 0, ...content);
347
+ newContent = targetLines.join('\n');
348
+ operations.push({success: true, type: marker.type, lines: lineCount, searchText: marker.searchText});
349
+ }
350
+ }
351
+
352
+ if (newContent !== targetContent) fs.writeFileSync(targetPath, newContent, 'utf8');
353
+ return {modified: newContent !== targetContent, operations};
354
+ };
355
+
356
+ const printMergeResults = (relativePath, isEnv, result) => {
357
+ const varText = isEnv ? 'environment variable' : 'line';
358
+ let hasChanges = false;
359
+
360
+ for (const op of result.operations) {
361
+ if (op.success) {
362
+ hasChanges = true;
363
+ const count = `${styled(String(op.lines), C.bold)} ${varText}${op.lines !== 1 ? 's' : ''}`;
364
+ if (op.type === 'prepend') success(`Prepended ${count} to file start`);
365
+ else if (op.type === 'append') success(`Appended ${count} to file end`);
366
+ else if (op.type === 'replace') success(`Replaced marker ${C.cyan}${op.markerName}${C.reset} with ${count}`);
367
+ else if (op.type === 'after') success(`Inserted ${count} ${C.cyan}after${C.reset} ${styled(op.searchText, C.dim)}`);
368
+ else if (op.type === 'before') success(`Inserted ${count} ${C.cyan}before${C.reset} ${styled(op.searchText, C.dim)}`);
369
+ } else if (op.type === 'notfound') {
370
+ warn(`Could not find target: ${op.markerName ? `marker ${styled(op.markerName, C.dim)}` : styled(op.searchText, C.dim)}`);
371
+ } else {
372
+ info(`Content already exists (${op.type})`);
373
+ }
374
+ }
375
+
376
+ return hasChanges;
377
+ };
378
+
379
+ const extractZip = async (zipPath, destDir) => {
380
+ fs.mkdirSync(destDir, {recursive: true});
381
+ const cmd = process.platform === 'win32'
382
+ ? `powershell -command "Expand-Archive -Path '${zipPath}' -DestinationPath '${destDir}' -Force"`
383
+ : `unzip -q "${zipPath}" -d "${destDir}"`;
384
+ await execAsync(cmd);
385
+ const entries = fs.readdirSync(destDir, {withFileTypes: true});
386
+ if (entries.length === 1 && entries[0].isDirectory()) {
387
+ const nestedDir = path.join(destDir, entries[0].name);
388
+ for (const item of fs.readdirSync(nestedDir, {withFileTypes: true}))
389
+ fs.cpSync(path.join(nestedDir, item.name), path.join(destDir, item.name), {recursive: true});
390
+ fs.rmSync(nestedDir, {recursive: true, force: true});
391
+ }
392
+ };
393
+
394
+ const processAddonFiles = (addonDir, targetDir) => {
395
+ const copied = [], skipped = [], toMerge = [];
396
+
397
+ const processDirectory = (dir, basePath = '') => {
398
+ for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
399
+ if (entry.name === 'README.md') continue;
400
+ const srcPath = path.join(dir, entry.name);
401
+ const relativePath = path.join(basePath, entry.name).replace(/\\/g, '/');
402
+ const destPath = path.join(targetDir, relativePath);
403
+
404
+ if (entry.isDirectory()) {
405
+ processDirectory(srcPath, relativePath);
406
+ continue;
407
+ }
408
+
409
+ const content = fs.readFileSync(srcPath, 'utf8');
410
+ if (fs.existsSync(destPath)) {
411
+ const markers = extractMarkers(content);
412
+ if (markers.length > 0 || entry.name === '.env') toMerge.push({content, destPath, relativePath, markers});
413
+ else skipped.push(relativePath);
414
+ } else {
415
+ fs.mkdirSync(path.dirname(destPath), {recursive: true});
416
+ fs.copyFileSync(srcPath, destPath);
417
+ copied.push(relativePath);
418
+ }
419
+ }
420
+ };
421
+
422
+ processDirectory(addonDir);
423
+ return {copied, skipped, toMerge};
424
+ };
425
+
426
+ const downloadAddon = async (addonName, version, targetDir) => {
427
+ const localZipPath = path.join(LOCAL_RELEASES_DIR, version, 'add-ons', `${addonName}.zip`);
428
+
429
+ if (fs.existsSync(localZipPath)) {
430
+ line();
431
+ task('💻 Using local add-on files');
432
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), TEMP_DIR_PREFIX));
433
+ try {
434
+ await extractZip(localZipPath, tempDir);
435
+ return processAddonFiles(tempDir, targetDir);
436
+ } finally {
437
+ cleanupPath(tempDir);
438
+ }
439
+ }
440
+
441
+ if (!await checkServerAvailability()) throw new Error('CDN server is currently unreachable');
442
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), TEMP_DIR_PREFIX));
443
+ const tempZip = path.join(tempDir, `${addonName}.zip`);
444
+ try {
445
+ await downloadFile(`${CDN_BASE}/${version}/add-ons/${addonName}.zip`, tempZip);
446
+ await extractZip(tempZip, tempDir);
447
+ return processAddonFiles(tempDir, targetDir);
448
+ } finally {
449
+ cleanupPath(tempDir);
450
+ }
451
+ };
452
+
453
+ const mergeFiles = (toMerge) => {
454
+ if (!toMerge.length) return {merged: [], failed: [], unchanged: []};
455
+ const merged = [], failed = [], unchanged = [];
456
+
457
+ for (const {content, destPath, relativePath, markers} of toMerge) {
458
+ const isEnv = path.basename(destPath) === '.env';
459
+ line();
460
+ info(styled(relativePath, C.dim));
461
+ try {
462
+ if (printMergeResults(relativePath, isEnv, mergeFile(destPath, content, markers, isEnv))) merged.push(relativePath);
463
+ else unchanged.push(relativePath);
464
+ } catch (err) {
465
+ error(`Error: ${err.message}`);
466
+ failed.push(relativePath);
467
+ }
468
+ }
469
+
470
+ return {merged, failed, unchanged};
471
+ };
472
+
473
+ const main = async () => {
474
+ const parsed = parseArgs(process.argv.slice(2));
475
+
476
+ if (parsed.help) {
477
+ showHelp();
478
+ process.exit(0);
479
+ }
480
+
481
+ if (parsed.unknownFlags.length) {
482
+ for (const flag of parsed.unknownFlags) {
483
+ const flagName = flag.includes('=') ? flag.slice(0, flag.indexOf('=')) : flag;
484
+ line();
485
+ warn(`Unknown option: ${styled(flag, C.bold)}`);
486
+ line();
487
+ const suggestion = closestMatch(flagName, KNOWN_FLAGS);
488
+ if (suggestion) info(`Did you mean ${C.cyan}${suggestion}${C.reset}${C.dim}?${C.reset}`);
489
+ }
490
+ info('Run with --help to see all available options.');
491
+ line();
492
+ process.exit(1);
493
+ }
494
+
495
+ let version;
496
+ try {
497
+ version = getSimplVersion();
498
+ } catch (err) {
499
+ line();
500
+ error(err.message);
501
+ line();
502
+ process.exit(1);
503
+ }
504
+
505
+ box(`Simpl Add-on Installer ${C.dim}(v${version})${C.reset}`);
506
+
507
+ const {versions} = await getVersionsData();
508
+
509
+ const versionMeta = versions[version];
510
+ if (!versionMeta) {
511
+ line();
512
+ error(`Version ${styled(version, C.bold)} not found`);
513
+ line();
514
+ process.exit(1);
515
+ }
516
+
517
+ if (versionMeta['script-compatible'] === false) {
518
+ line();
519
+ error(`Version ${styled(version, C.bold)} is not compatible with this installer`);
520
+ line();
521
+ out(PAD + styled('Manual download:', C.bold), C.blue);
522
+ item(`${C.cyan}${CDN_BASE}/${version}/add-ons/`);
523
+ line();
524
+ out(PAD + styled('Available add-ons for this version:', C.bold), C.blue);
525
+ const addons = versionMeta['add-ons'] || [];
526
+ if (!addons.length) info('No add-ons available');
527
+ else for (const name of addons) item(`${name}: ${styled(`${CDN_BASE}/${version}/add-ons/${name}.zip`, C.dim)}`);
528
+ line();
529
+ process.exit(1);
530
+ }
531
+
532
+ if (!parsed.addon) {
533
+ line();
534
+ task('🗄️ Fetching available add-ons...');
535
+ }
536
+
537
+ let addons;
538
+ try {
539
+ addons = await getAvailableAddons(version);
540
+ } catch {
541
+ line();
542
+ error('Failed to fetch add-ons');
543
+ info('The CDN server is currently unavailable. Please try again later.');
544
+ line();
545
+ process.exit(1);
546
+ }
547
+
548
+ if (!addons.length) {
549
+ line();
550
+ warn('No add-ons available for this version');
551
+ line();
552
+ process.exit(0);
553
+ }
554
+
555
+ if (parsed.list) {
556
+ line();
557
+ out(PAD + styled('Available add-ons:', C.bold), C.blue);
558
+ listAddons(addons);
559
+ line();
560
+ process.exit(0);
561
+ }
562
+
563
+ let addonName;
564
+ if (parsed.addon) {
565
+ addonName = await promptAddon(addons, parsed.addon);
566
+ printAnswer(PAD + 'Add-on to install', addonName);
567
+ } else {
568
+ line();
569
+ out(PAD + styled('Available add-ons:', C.bold), C.blue);
570
+ listAddons(addons);
571
+ line();
572
+ addonName = await promptAddon(addons);
573
+ }
574
+
575
+ box(`Installing: ${C.cyan}${addonName}${C.reset} ${C.dim}(v${version})${C.reset}`);
576
+ line();
577
+ task(`📦 Downloading ${C.cyan}${addonName}${C.reset} add-on...`);
578
+
579
+ let copied, skipped, toMerge;
580
+ try {
581
+ ({copied, skipped, toMerge} = await downloadAddon(addonName, version, process.cwd()));
582
+ } catch (err) {
583
+ line();
584
+ error('Installation failed');
585
+ if (err.message === 'CDN server is currently unreachable') info('The CDN server is currently unavailable. Please try again later.');
586
+ else info('Please verify the add-on exists and try again');
587
+ line();
588
+ process.exit(1);
589
+ }
590
+
591
+ if (copied.length) {
592
+ line();
593
+ success(`Copied ${styled(String(copied.length), C.bold)} new file${copied.length !== 1 ? 's' : ''}`);
594
+ }
595
+
596
+ if (skipped.length) {
597
+ line();
598
+ info(`Skipped ${skipped.length} file${skipped.length !== 1 ? 's' : ''} (no merge markers):`);
599
+ for (const file of skipped) item(file, true);
600
+ }
601
+
602
+ if (toMerge.length) {
603
+ line();
604
+ task('🔀 Merging existing files...');
605
+ const {merged, failed, unchanged} = mergeFiles(toMerge);
606
+ divider();
607
+ if (merged.length) success(`Successfully merged ${styled(String(merged.length), C.bold)} file${merged.length !== 1 ? 's' : ''}`);
608
+ if (unchanged.length) info(`${unchanged.length} file${unchanged.length !== 1 ? 's' : ''} unchanged (content already exists)`);
609
+ if (failed.length) {
610
+ line();
611
+ warn(`${failed.length} file${failed.length !== 1 ? 's' : ''} failed to merge`);
612
+ warn('Please review manually:');
613
+ for (const file of failed) item(file);
614
+ }
615
+ }
616
+
617
+ line();
618
+ success(styled('Installation complete!', C.bold, C.green), true);
619
+ line();
620
+ };
621
+
622
+ main().catch(() => {
623
+ line();
624
+ error('Fatal error occurred');
625
+ line();
626
+ process.exit(1);
627
+ });