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