@ijuantm/simpl-addon 1.0.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 +60 -0
  2. package/install.js +349 -0
  3. package/package.json +26 -0
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # Simpl Add-on Installer
2
+
3
+ CLI tool for installing Simpl framework add-ons automatically using npx.
4
+
5
+ ## Usage
6
+
7
+ ### List Available Add-ons
8
+
9
+ ```bash
10
+ npx @ijuantm/simpl-addon --list
11
+ ```
12
+
13
+ ### Install an Add-on
14
+
15
+ Navigate to your Simpl project directory and run, for example, to install the "auth" add-on:
16
+
17
+ ```bash
18
+ npx @ijuantm/simpl-addon auth
19
+ ```
20
+
21
+ The installer will:
22
+
23
+ 1. Download the add-on
24
+ 2. Copy new files to your project
25
+ 3. Merge existing files using markers
26
+
27
+ ### Get Help
28
+
29
+ ```bash
30
+ npx @ijuantm/simpl-addon --help
31
+ ```
32
+
33
+ ## How It Works
34
+
35
+ The installer uses special markers in add-on files to safely merge content:
36
+
37
+ ```php
38
+ // @addon-insert:after('existing line')
39
+ new AuthController();
40
+ // @addon-end
41
+ ```
42
+
43
+ **Supported Markers:**
44
+
45
+ - `@addon-insert:after('text')` - Insert content after matching line
46
+ - `@addon-insert:before('text')` - Insert content before matching line
47
+ - `@addon-insert:prepend` - Add content at the beginning of the file
48
+ - `@addon-insert:append` - Add content at the end of the file
49
+
50
+ The installer:
51
+
52
+ - Creates new files that don't exist
53
+ - Merges files with markers automatically
54
+ - Skips files without markers (no overwriting)
55
+ - Detects duplicate content (won't add twice)
56
+
57
+ ## Requirements
58
+
59
+ - **Node.js**: >= 20.x.x
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 ADDED
@@ -0,0 +1,349 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const https = require('https');
6
+
7
+ const COLORS = {
8
+ reset: '\x1b[0m', green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m', cyan: '\x1b[36m', blue: '\x1b[34m', gray: '\x1b[90m', bold: '\x1b[1m', dim: '\x1b[2m'
9
+ };
10
+
11
+ const BRANCH = 'master';
12
+ const REPO_BASE = 'https://api.github.com/repos/IJuanTM/simpl/contents/add-ons';
13
+ const RAW_BASE = `https://raw.githubusercontent.com/IJuanTM/simpl/${BRANCH}/add-ons`;
14
+
15
+ const log = (message, color = 'reset') => console.log(`${COLORS[color]}${message}${COLORS.reset}`);
16
+
17
+ const fetchUrl = (url) => new Promise((resolve, reject) => {
18
+ const headers = {'User-Agent': 'simpl-installer'};
19
+ if (process.env.GITHUB_TOKEN) headers['Authorization'] = `Bearer ${process.env.GITHUB_TOKEN}`;
20
+
21
+ https.get(url, {headers}, res => {
22
+ if (res.statusCode === 302 || res.statusCode === 301) return fetchUrl(res.headers.location).then(resolve).catch(reject);
23
+ if (res.statusCode === 403) {
24
+ const resetTime = res.headers['x-ratelimit-reset'];
25
+ const resetDate = resetTime ? new Date(resetTime * 1000).toLocaleTimeString() : 'unknown';
26
+ return reject(new Error(`GitHub API rate limit exceeded. Resets at ${resetDate}. ${process.env.GITHUB_TOKEN ? 'Token is set but may be invalid.' : 'Set GITHUB_TOKEN environment variable to increase limit.'}`));
27
+ }
28
+ if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage || 'Request failed'}`));
29
+
30
+ let data = '';
31
+ res.on('data', chunk => data += chunk);
32
+ res.on('end', () => resolve(data));
33
+ }).on('error', reject);
34
+ });
35
+
36
+ const showHelp = () => {
37
+ console.log();
38
+ log(` ╭${'─'.repeat(62)}╮`);
39
+ log(` │ ${COLORS.bold}Simpl Add-on Installer${COLORS.reset}${' '.repeat(38)}│`);
40
+ log(` ╰${'─'.repeat(62)}╯`);
41
+ console.log();
42
+ log(' Usage:', 'cyan');
43
+ log(' npx @ijuantm/simpl-addon <addon-name>');
44
+ log(' npx @ijuantm/simpl-addon --list');
45
+ log(' npx @ijuantm/simpl-addon --help');
46
+ console.log();
47
+ log(' Commands:', 'cyan');
48
+ log(' <addon-name> Install the specified add-on');
49
+ log(' --list, -l List all available add-ons');
50
+ log(' --help, -h Show this help message');
51
+ console.log();
52
+ log(' Examples:', 'cyan');
53
+ log(' npx @ijuantm/simpl-addon auth');
54
+ log(' npx @ijuantm/simpl-addon --list');
55
+ console.log();
56
+ };
57
+
58
+ const listAddons = async () => {
59
+ console.log();
60
+ log(` ╭${'─'.repeat(62)}╮`);
61
+ log(` │ ${COLORS.bold}Available Add-ons${COLORS.reset}${' '.repeat(43)}│`);
62
+ log(` ╰${'─'.repeat(62)}╯`);
63
+ console.log();
64
+ log(' 📦 Fetching add-ons from GitHub...', 'bold');
65
+
66
+ try {
67
+ const response = await fetchUrl(`${REPO_BASE}?ref=${BRANCH}`);
68
+ const addons = JSON.parse(response).filter(item => item.type === 'dir').map(item => item.name);
69
+
70
+ console.log();
71
+
72
+ if (addons.length === 0) {
73
+ log(` ${COLORS.yellow}⚠${COLORS.reset} No add-ons available`);
74
+ } else {
75
+ addons.forEach(name => log(` ${COLORS.cyan}•${COLORS.reset} ${name}`));
76
+ }
77
+ } catch (error) {
78
+ console.log();
79
+ log(` ${COLORS.red}✗${COLORS.reset} Failed to fetch add-ons: ${error.message}`, 'red');
80
+ console.log();
81
+ process.exit(1);
82
+ }
83
+
84
+ console.log();
85
+ };
86
+
87
+ const extractMarkers = (content) => {
88
+ const markers = [];
89
+
90
+ content.split('\n').forEach((line, i) => {
91
+ const afterMatch = line.match(/@addon-insert:after\s*\(\s*["'](.+?)["']\s*\)/);
92
+ const beforeMatch = line.match(/@addon-insert:before\s*\(\s*["'](.+?)["']\s*\)/);
93
+
94
+ if (afterMatch) markers.push({type: 'after', lineIndex: i, searchText: afterMatch[1]}); else if (beforeMatch) markers.push({type: 'before', lineIndex: i, searchText: beforeMatch[1]}); else if (line.includes('@addon-insert:prepend')) markers.push({type: 'prepend', lineIndex: i}); else if (line.includes('@addon-insert:append')) markers.push({type: 'append', lineIndex: i});
95
+ });
96
+
97
+ return markers;
98
+ };
99
+
100
+ const collectContentBetweenMarkers = (lines, startIndex) => {
101
+ const content = [];
102
+
103
+ for (let i = startIndex + 1; i < lines.length; i++) {
104
+ if (lines[i].trim().includes('@addon-end')) break;
105
+ content.push(lines[i]);
106
+ }
107
+
108
+ return content;
109
+ };
110
+
111
+ const normalizeContent = (lines) => lines.map(l => l.trim())
112
+ .filter(l => l && !l.startsWith('//') && !l.startsWith('#') && !l.startsWith('/*') && !l.startsWith('*'))
113
+ .join('|');
114
+
115
+ const processEnvContent = (content, targetContent) => {
116
+ const envVarsToAdd = [], comments = [];
117
+
118
+ content.forEach(line => {
119
+ const trimmed = line.trim();
120
+
121
+ if (trimmed.startsWith('#') || !trimmed) {
122
+ comments.push(line);
123
+ return;
124
+ }
125
+
126
+ const match = line.match(/^([A-Z_][A-Z0-9_]*)=/);
127
+ if (match && !new RegExp(`^${match[1]}=`, 'm').test(targetContent)) envVarsToAdd.push(line);
128
+ });
129
+
130
+ return {content: [...comments, ...envVarsToAdd], count: envVarsToAdd.length};
131
+ };
132
+
133
+ const findInsertIndex = (lines, searchText, type) => {
134
+ for (let i = 0; i < lines.length; i++) {
135
+ if (lines[i].includes(searchText)) return type === 'before' ? i : i + 1;
136
+ }
137
+
138
+ return -1;
139
+ };
140
+
141
+ const mergeFile = (targetPath, addonContent, markers, isEnv = false) => {
142
+ const targetContent = fs.readFileSync(targetPath, 'utf8');
143
+ const addonLines = addonContent.split('\n');
144
+ const operations = [];
145
+ let newContent = targetContent;
146
+
147
+ markers.forEach(marker => {
148
+ let content = collectContentBetweenMarkers(addonLines, marker.lineIndex);
149
+ if (content.length === 0) return;
150
+
151
+ let lineCount = content.length;
152
+
153
+ if (isEnv) {
154
+ const processed = processEnvContent(content, newContent);
155
+ content = processed.content;
156
+ lineCount = processed.count;
157
+
158
+ if (content.length === 0) {
159
+ operations.push({success: false, type: marker.type, lines: 0, searchText: marker.searchText});
160
+ return;
161
+ }
162
+ } else {
163
+ const signature = normalizeContent(content);
164
+ const targetSignature = normalizeContent(newContent.split('\n'));
165
+
166
+ if (signature && targetSignature.includes(signature)) {
167
+ operations.push({success: false, type: marker.type, lines: content.length, searchText: marker.searchText});
168
+ return;
169
+ }
170
+ }
171
+
172
+ if (marker.type === 'prepend') {
173
+ newContent = content.join('\n') + '\n' + newContent;
174
+ operations.push({success: true, type: 'prepend', lines: lineCount});
175
+ } else if (marker.type === 'append') {
176
+ if (!newContent.endsWith('\n')) newContent += '\n';
177
+ newContent += '\n' + content.join('\n') + '\n';
178
+ operations.push({success: true, type: 'append', lines: lineCount});
179
+ } else if ((marker.type === 'after' || marker.type === 'before') && marker.searchText) {
180
+ const targetLines = newContent.split('\n');
181
+ const insertIndex = findInsertIndex(targetLines, marker.searchText, marker.type);
182
+
183
+ if (insertIndex === -1) {
184
+ operations.push({success: false, type: 'notfound', searchText: marker.searchText});
185
+ return;
186
+ }
187
+
188
+ targetLines.splice(insertIndex, 0, ...content);
189
+ newContent = targetLines.join('\n');
190
+ operations.push({success: true, type: marker.type, lines: lineCount, searchText: marker.searchText});
191
+ }
192
+ });
193
+
194
+ if (newContent !== targetContent) fs.writeFileSync(targetPath, newContent, 'utf8');
195
+ return {modified: newContent !== targetContent, operations};
196
+ };
197
+
198
+ const printMergeResults = (relativePath, isEnv, result) => {
199
+ const indent = ' ';
200
+ const varText = isEnv ? 'environment variable' : 'line';
201
+ let hasChanges = false;
202
+
203
+ result.operations.forEach(op => {
204
+ if (op.success) {
205
+ hasChanges = true;
206
+
207
+ 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`); 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`); 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}"`); 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}"`);
208
+ } else if (op.type === 'notfound') log(`${indent}${COLORS.yellow}⚠${COLORS.reset} ${COLORS.yellow}Could not find target:${COLORS.reset} "${COLORS.dim}${op.searchText}${COLORS.reset}"`); else log(`${indent}${COLORS.gray}○${COLORS.reset} ${COLORS.dim}Content already exists (${op.type})${COLORS.reset}`);
209
+ });
210
+
211
+ return hasChanges;
212
+ };
213
+
214
+ const downloadAddonFiles = async (addonName, targetDir) => {
215
+ const addonUrl = `${REPO_BASE}/${addonName}?ref=${BRANCH}`;
216
+ let files;
217
+
218
+ try {
219
+ files = JSON.parse(await fetchUrl(addonUrl));
220
+ } catch (error) {
221
+ throw new Error(`Add-on "${addonName}" not found`);
222
+ }
223
+
224
+ const copied = [], skipped = [], toMerge = [];
225
+
226
+ const processFiles = async (fileList, basePath = '') => {
227
+ for (const file of fileList) {
228
+ if (file.name === 'README.md') continue;
229
+
230
+ const relativePath = path.join(basePath, file.name).replace(/\\/g, '/');
231
+ const destPath = path.join(targetDir, relativePath);
232
+
233
+ if (file.type === 'dir') {
234
+ const subUrl = file.url.includes('?') ? `${file.url}&ref=${BRANCH}` : `${file.url}?ref=${BRANCH}`;
235
+ const subFiles = JSON.parse(await fetchUrl(subUrl));
236
+ await processFiles(subFiles, relativePath);
237
+ } else {
238
+ const content = await fetchUrl(`${RAW_BASE}/${addonName}/${relativePath}`);
239
+
240
+ if (fs.existsSync(destPath)) {
241
+ const markers = extractMarkers(content);
242
+
243
+ if (markers.length > 0 || file.name === '.env') toMerge.push({content, destPath, relativePath, markers}); else skipped.push(relativePath);
244
+ } else {
245
+ fs.mkdirSync(path.dirname(destPath), {recursive: true});
246
+ fs.writeFileSync(destPath, content, 'utf8');
247
+ copied.push(relativePath);
248
+ }
249
+ }
250
+ }
251
+ };
252
+
253
+ await processFiles(files);
254
+ return {copied, skipped, toMerge};
255
+ };
256
+
257
+ const mergeFiles = (toMerge) => {
258
+ if (toMerge.length === 0) return {merged: [], failed: [], unchanged: []};
259
+
260
+ const merged = [], failed = [], unchanged = [];
261
+
262
+ toMerge.forEach(({content, destPath, relativePath, markers}) => {
263
+ const isEnv = path.basename(destPath) === '.env';
264
+ log(`\n ${COLORS.cyan}•${COLORS.reset} ${COLORS.bold}${relativePath}${COLORS.reset}`);
265
+
266
+ try {
267
+ const result = mergeFile(destPath, content, markers, isEnv);
268
+ if (printMergeResults(relativePath, isEnv, result)) merged.push(relativePath); else unchanged.push(relativePath);
269
+ } catch (error) {
270
+ log(` ${COLORS.red}✗ Error:${COLORS.reset} ${error.message}`, 'red');
271
+ failed.push(relativePath);
272
+ }
273
+ });
274
+
275
+ return {merged, failed, unchanged};
276
+ };
277
+
278
+ const main = async () => {
279
+ const command = process.argv[2];
280
+
281
+ if (!command || command === '--help' || command === '-h') {
282
+ showHelp();
283
+ process.exit(0);
284
+ }
285
+
286
+ if (command === '--list' || command === '-l') {
287
+ await listAddons();
288
+ process.exit(0);
289
+ }
290
+
291
+ console.log();
292
+ log(` ╭${'─'.repeat(62)}╮`);
293
+ log(` │ ${COLORS.bold}Installing add-on: ${COLORS.cyan}${command}${COLORS.reset}${' '.repeat(41 - command.length)}│`);
294
+ log(` ╰${'─'.repeat(62)}╯`);
295
+ console.log();
296
+ log(' 📦 Downloading add-on from GitHub...', 'bold');
297
+
298
+ let copied, skipped, toMerge;
299
+
300
+ try {
301
+ ({copied, skipped, toMerge} = await downloadAddonFiles(command, process.cwd()));
302
+ } catch (error) {
303
+ console.log();
304
+ log(` ${COLORS.red}✗${COLORS.reset} ${error.message}`, 'red');
305
+ log(` ${COLORS.dim}Run ${COLORS.cyan}npx @ijuantm/simpl-addon --list${COLORS.reset}${COLORS.dim} to see available add-ons${COLORS.reset}`);
306
+ console.log();
307
+ process.exit(1);
308
+ }
309
+
310
+ if (copied.length > 0) {
311
+ console.log();
312
+ log(` ${COLORS.green}✓${COLORS.reset} Copied ${COLORS.bold}${copied.length}${COLORS.reset} new file${copied.length !== 1 ? 's' : ''}`);
313
+ }
314
+
315
+ if (skipped.length > 0) {
316
+ console.log();
317
+ log(` ${COLORS.gray}○${COLORS.reset} ${COLORS.dim}Skipped ${skipped.length} file${skipped.length !== 1 ? 's' : ''} (no merge markers):${COLORS.reset}`);
318
+ skipped.forEach(file => log(` ${COLORS.dim}• ${file}${COLORS.reset}`));
319
+ }
320
+
321
+ if (toMerge.length > 0) {
322
+ console.log();
323
+ log(' 🔀 Merging existing files...', 'bold');
324
+ const {merged, failed, unchanged} = mergeFiles(toMerge);
325
+
326
+ console.log();
327
+ log(' ' + '─'.repeat(16), 'gray');
328
+ console.log();
329
+
330
+ if (merged.length > 0) log(` ${COLORS.green}✓${COLORS.reset} Successfully merged ${COLORS.bold}${merged.length}${COLORS.reset} file${merged.length !== 1 ? 's' : ''}`);
331
+ if (unchanged.length > 0) log(` ${COLORS.gray}○${COLORS.reset} ${COLORS.dim}${unchanged.length} file${unchanged.length !== 1 ? 's' : ''} unchanged (content already exists)${COLORS.reset}`);
332
+
333
+ if (failed.length > 0) {
334
+ console.log();
335
+ log(` ${COLORS.yellow}⚠${COLORS.reset} ${COLORS.yellow}${failed.length} file${failed.length !== 1 ? 's' : ''} failed to merge${COLORS.reset}`);
336
+ log(` ${COLORS.yellow}Please review manually:${COLORS.reset}`);
337
+ failed.forEach(file => log(` ${COLORS.cyan}• ${file}${COLORS.reset}`));
338
+ }
339
+ }
340
+
341
+ console.log();
342
+ log(` ${COLORS.green}✓${COLORS.reset} ${COLORS.bold}${COLORS.green}Installation complete!${COLORS.reset}`, 'green');
343
+ console.log();
344
+ };
345
+
346
+ main().catch(err => {
347
+ log(`\n ${COLORS.red}✗${COLORS.reset} Fatal error: ${err.message}\n`, 'red');
348
+ process.exit(1);
349
+ });
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@ijuantm/simpl-addon",
3
+ "version": "1.0.0",
4
+ "description": "CLI tool to install Simpl framework add-ons.",
5
+ "main": "install.js",
6
+ "bin": {
7
+ "simpl-addon": "install.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=20.0.0"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/IJuanTM/simpl"
15
+ },
16
+ "keywords": [
17
+ "simpl",
18
+ "addon",
19
+ "installer",
20
+ "cli",
21
+ "php-framework"
22
+ ],
23
+ "author": "Iwan van der Wal",
24
+ "license": "GPL-3.0-only",
25
+ "homepage": "https://simpl.iwanvanderwal.nl/"
26
+ }