@magentrix-corp/magentrix-cli 1.2.1 → 1.3.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.
@@ -0,0 +1,281 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Required file patterns for a valid Iris build.
6
+ */
7
+ const REQUIRED_FILES = {
8
+ remoteEntry: {
9
+ pattern: /^remoteEntry\.js$/,
10
+ location: 'root',
11
+ description: 'remoteEntry.js (required at dist root)'
12
+ },
13
+ hostInit: {
14
+ pattern: /^hostInit.*\.js$/,
15
+ location: 'assets',
16
+ description: 'assets/hostInit*.js (required in assets folder)'
17
+ },
18
+ main: {
19
+ pattern: /^main.*\.js$/,
20
+ location: 'assets',
21
+ description: 'assets/main*.js (required in assets folder)'
22
+ },
23
+ index: {
24
+ pattern: /^index.*\.js$/,
25
+ location: 'assets',
26
+ description: 'assets/index*.js (required in assets folder)'
27
+ }
28
+ };
29
+
30
+ /**
31
+ * Validate that a build output directory contains all required Iris files.
32
+ *
33
+ * @param {string} distPath - Path to the build output directory (e.g., ./dist)
34
+ * @returns {{
35
+ * valid: boolean,
36
+ * errors: string[],
37
+ * warnings: string[],
38
+ * files: {
39
+ * remoteEntry: string | null,
40
+ * hostInit: string | null,
41
+ * main: string | null,
42
+ * index: string | null
43
+ * },
44
+ * assetsPath: string | null
45
+ * }}
46
+ */
47
+ export function validateIrisBuild(distPath) {
48
+ const result = {
49
+ valid: true,
50
+ errors: [],
51
+ warnings: [],
52
+ files: {
53
+ remoteEntry: null,
54
+ hostInit: null,
55
+ main: null,
56
+ index: null
57
+ },
58
+ assetsPath: null
59
+ };
60
+
61
+ // Check if dist path exists
62
+ if (!existsSync(distPath)) {
63
+ result.valid = false;
64
+ result.errors.push(`Build output directory not found: ${distPath}`);
65
+ return result;
66
+ }
67
+
68
+ // Check if it's a directory
69
+ const distStat = statSync(distPath);
70
+ if (!distStat.isDirectory()) {
71
+ result.valid = false;
72
+ result.errors.push(`Build output path is not a directory: ${distPath}`);
73
+ return result;
74
+ }
75
+
76
+ // Get root files
77
+ let rootFiles;
78
+ try {
79
+ rootFiles = readdirSync(distPath);
80
+ } catch (err) {
81
+ result.valid = false;
82
+ result.errors.push(`Failed to read build directory: ${err.message}`);
83
+ return result;
84
+ }
85
+
86
+ // Check for remoteEntry.js in root
87
+ const remoteEntryFile = rootFiles.find(f => REQUIRED_FILES.remoteEntry.pattern.test(f));
88
+ if (remoteEntryFile) {
89
+ result.files.remoteEntry = path.join(distPath, remoteEntryFile);
90
+ } else {
91
+ result.valid = false;
92
+ result.errors.push(REQUIRED_FILES.remoteEntry.description);
93
+ }
94
+
95
+ // Check for assets directory
96
+ const assetsDir = rootFiles.find(f => f.toLowerCase() === 'assets');
97
+ if (!assetsDir) {
98
+ result.valid = false;
99
+ result.errors.push('assets/ directory not found in build output');
100
+ return result;
101
+ }
102
+
103
+ const assetsPath = path.join(distPath, assetsDir);
104
+ result.assetsPath = assetsPath;
105
+
106
+ // Check if assets is a directory
107
+ const assetsStat = statSync(assetsPath);
108
+ if (!assetsStat.isDirectory()) {
109
+ result.valid = false;
110
+ result.errors.push('assets is not a directory');
111
+ return result;
112
+ }
113
+
114
+ // Get assets files
115
+ let assetsFiles;
116
+ try {
117
+ assetsFiles = readdirSync(assetsPath);
118
+ } catch (err) {
119
+ result.valid = false;
120
+ result.errors.push(`Failed to read assets directory: ${err.message}`);
121
+ return result;
122
+ }
123
+
124
+ // Check for required files in assets
125
+ const hostInitFile = assetsFiles.find(f => REQUIRED_FILES.hostInit.pattern.test(f));
126
+ if (hostInitFile) {
127
+ result.files.hostInit = path.join(assetsPath, hostInitFile);
128
+ } else {
129
+ result.valid = false;
130
+ result.errors.push(REQUIRED_FILES.hostInit.description);
131
+ }
132
+
133
+ const mainFile = assetsFiles.find(f => REQUIRED_FILES.main.pattern.test(f));
134
+ if (mainFile) {
135
+ result.files.main = path.join(assetsPath, mainFile);
136
+ } else {
137
+ result.valid = false;
138
+ result.errors.push(REQUIRED_FILES.main.description);
139
+ }
140
+
141
+ const indexFile = assetsFiles.find(f => REQUIRED_FILES.index.pattern.test(f));
142
+ if (indexFile) {
143
+ result.files.index = path.join(assetsPath, indexFile);
144
+ } else {
145
+ result.valid = false;
146
+ result.errors.push(REQUIRED_FILES.index.description);
147
+ }
148
+
149
+ // Warnings for potential issues
150
+ if (rootFiles.includes('index.html')) {
151
+ result.warnings.push('Found index.html in root - this file is typically not needed for Iris apps');
152
+ }
153
+
154
+ return result;
155
+ }
156
+
157
+ /**
158
+ * Format validation errors for display to the user.
159
+ *
160
+ * @param {ReturnType<typeof validateIrisBuild>} validation - Validation result
161
+ * @returns {string} - Formatted error message
162
+ */
163
+ export function formatValidationErrors(validation) {
164
+ const lines = [
165
+ 'Invalid Build Output',
166
+ '────────────────────────────────────────────────────',
167
+ '',
168
+ 'The build output is missing required Iris files.',
169
+ '',
170
+ 'Status:'
171
+ ];
172
+
173
+ // Show status of each required file
174
+ const fileStatus = [
175
+ {
176
+ name: 'remoteEntry.js',
177
+ found: validation.files.remoteEntry !== null,
178
+ foundName: validation.files.remoteEntry ? path.basename(validation.files.remoteEntry) : null
179
+ },
180
+ {
181
+ name: 'assets/hostInit*.js',
182
+ found: validation.files.hostInit !== null,
183
+ foundName: validation.files.hostInit ? path.basename(validation.files.hostInit) : null
184
+ },
185
+ {
186
+ name: 'assets/main*.js',
187
+ found: validation.files.main !== null,
188
+ foundName: validation.files.main ? path.basename(validation.files.main) : null
189
+ },
190
+ {
191
+ name: 'assets/index*.js',
192
+ found: validation.files.index !== null,
193
+ foundName: validation.files.index ? path.basename(validation.files.index) : null
194
+ }
195
+ ];
196
+
197
+ for (const file of fileStatus) {
198
+ if (file.found) {
199
+ lines.push(` ✓ ${file.name} (found: ${file.foundName})`);
200
+ } else {
201
+ lines.push(` ✗ ${file.name}`);
202
+ }
203
+ }
204
+
205
+ lines.push('');
206
+ lines.push('Expected structure:');
207
+ lines.push(' dist/');
208
+ lines.push(' ├── remoteEntry.js');
209
+ lines.push(' └── assets/');
210
+ lines.push(' ├── hostInit-[hash].js');
211
+ lines.push(' ├── main-[hash].js');
212
+ lines.push(' └── index-[hash].js');
213
+ lines.push('');
214
+ lines.push('Make sure your Vue.js project is configured for Module Federation.');
215
+
216
+ if (validation.warnings.length > 0) {
217
+ lines.push('');
218
+ lines.push('Warnings:');
219
+ for (const warning of validation.warnings) {
220
+ lines.push(` ⚠ ${warning}`);
221
+ }
222
+ }
223
+
224
+ return lines.join('\n');
225
+ }
226
+
227
+ /**
228
+ * Get a summary of found files for successful validation.
229
+ *
230
+ * @param {ReturnType<typeof validateIrisBuild>} validation - Validation result
231
+ * @returns {string} - Summary of found files
232
+ */
233
+ export function getValidationSummary(validation) {
234
+ const files = [];
235
+
236
+ if (validation.files.remoteEntry) {
237
+ files.push(` • ${path.basename(validation.files.remoteEntry)}`);
238
+ }
239
+ if (validation.files.hostInit) {
240
+ files.push(` • assets/${path.basename(validation.files.hostInit)}`);
241
+ }
242
+ if (validation.files.main) {
243
+ files.push(` • assets/${path.basename(validation.files.main)}`);
244
+ }
245
+ if (validation.files.index) {
246
+ files.push(` • assets/${path.basename(validation.files.index)}`);
247
+ }
248
+
249
+ return `Found ${files.length} required files:\n${files.join('\n')}`;
250
+ }
251
+
252
+ /**
253
+ * Validate an Iris app folder (already staged, not a dist folder).
254
+ * This is used to validate apps in src/iris-apps/<slug>/.
255
+ *
256
+ * @param {string} appPath - Path to the app folder
257
+ * @returns {{valid: boolean, errors: string[], slug: string | null}}
258
+ */
259
+ export function validateIrisAppFolder(appPath) {
260
+ const result = {
261
+ valid: true,
262
+ errors: [],
263
+ slug: null
264
+ };
265
+
266
+ if (!existsSync(appPath)) {
267
+ result.valid = false;
268
+ result.errors.push(`App folder not found: ${appPath}`);
269
+ return result;
270
+ }
271
+
272
+ result.slug = path.basename(appPath);
273
+
274
+ // Use the same validation as build output
275
+ const validation = validateIrisBuild(appPath);
276
+
277
+ result.valid = validation.valid;
278
+ result.errors = validation.errors;
279
+
280
+ return result;
281
+ }
@@ -0,0 +1,239 @@
1
+ import { mkdirSync, rmSync, writeFileSync, readFileSync, existsSync, readdirSync, statSync, chmodSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join, basename } from 'node:path';
4
+ import { randomUUID, createHash } from 'node:crypto';
5
+ import archiver from 'archiver';
6
+ import extractZip from 'extract-zip';
7
+
8
+ /**
9
+ * Recursively fix permissions on extracted files.
10
+ * Sets directories to 0o755 and files to 0o644.
11
+ * This ensures the current user can read/write/delete the files.
12
+ *
13
+ * @param {string} dir - Directory to fix permissions for
14
+ */
15
+ function fixPermissions(dir) {
16
+ if (!existsSync(dir)) return;
17
+
18
+ try {
19
+ const stat = statSync(dir);
20
+ if (stat.isDirectory()) {
21
+ chmodSync(dir, 0o755);
22
+ const entries = readdirSync(dir);
23
+ for (const entry of entries) {
24
+ fixPermissions(join(dir, entry));
25
+ }
26
+ } else {
27
+ chmodSync(dir, 0o644);
28
+ }
29
+ } catch {
30
+ // Ignore permission errors - best effort
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Create a zip file from a build directory with proper Iris structure.
36
+ * The zip will contain a single root folder with the app slug.
37
+ *
38
+ * @param {string} distPath - Path to the build output directory (e.g., ./dist)
39
+ * @param {string} appSlug - Slug for the app (becomes the root folder in zip)
40
+ * @returns {Promise<Buffer>} - The zip file as a Buffer
41
+ */
42
+ export async function createIrisZip(distPath, appSlug) {
43
+ return new Promise((resolve, reject) => {
44
+ const chunks = [];
45
+
46
+ const archive = archiver('zip', {
47
+ zlib: { level: 9 } // Maximum compression
48
+ });
49
+
50
+ archive.on('data', (chunk) => {
51
+ chunks.push(chunk);
52
+ });
53
+
54
+ archive.on('error', (err) => {
55
+ reject(err);
56
+ });
57
+
58
+ archive.on('end', () => {
59
+ resolve(Buffer.concat(chunks));
60
+ });
61
+
62
+ // Add the dist directory contents under the app slug folder
63
+ // This creates the structure: appSlug/remoteEntry.js, appSlug/assets/...
64
+ archive.directory(distPath, appSlug);
65
+
66
+ archive.finalize();
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Extract a zip buffer to a directory.
72
+ *
73
+ * @param {Buffer} zipBuffer - The zip file as a Buffer
74
+ * @param {string} outputDir - Directory to extract to
75
+ * @returns {Promise<string>} - Path to the extracted directory
76
+ */
77
+ export async function extractIrisZip(zipBuffer, outputDir) {
78
+ // Create a temporary file to hold the zip
79
+ const tempDir = join(tmpdir(), `iris-extract-${randomUUID()}`);
80
+ const tempZipPath = join(tempDir, 'temp.zip');
81
+
82
+ try {
83
+ // Create temp directory
84
+ mkdirSync(tempDir, { recursive: true });
85
+
86
+ // Write buffer to temp file
87
+ writeFileSync(tempZipPath, zipBuffer);
88
+
89
+ // Ensure output directory exists
90
+ mkdirSync(outputDir, { recursive: true });
91
+
92
+ // Extract
93
+ await extractZip(tempZipPath, { dir: outputDir });
94
+
95
+ // Fix permissions so files can be deleted/modified later
96
+ fixPermissions(outputDir);
97
+
98
+ return outputDir;
99
+ } finally {
100
+ // Cleanup temp directory
101
+ try {
102
+ rmSync(tempDir, { recursive: true, force: true });
103
+ } catch {
104
+ // Ignore cleanup errors
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Get the size of a buffer in human-readable format.
111
+ *
112
+ * @param {number} bytes - Size in bytes
113
+ * @returns {string} - Human-readable size (e.g., "1.2 MB")
114
+ */
115
+ export function formatFileSize(bytes) {
116
+ if (bytes === 0) return '0 B';
117
+
118
+ const units = ['B', 'KB', 'MB', 'GB'];
119
+ const k = 1024;
120
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
121
+
122
+ if (i === 0) return `${bytes} ${units[0]}`;
123
+
124
+ return `${(bytes / Math.pow(k, i)).toFixed(1)} ${units[i]}`;
125
+ }
126
+
127
+ /**
128
+ * Count files in a directory recursively.
129
+ *
130
+ * @param {string} dir - Directory path
131
+ * @returns {number} - Number of files
132
+ */
133
+ export function countFiles(dir) {
134
+ if (!existsSync(dir)) return 0;
135
+
136
+ let count = 0;
137
+
138
+ function walk(currentDir) {
139
+ const entries = readdirSync(currentDir);
140
+ for (const entry of entries) {
141
+ const fullPath = join(currentDir, entry);
142
+ const stat = statSync(fullPath);
143
+ if (stat.isDirectory()) {
144
+ walk(fullPath);
145
+ } else {
146
+ count++;
147
+ }
148
+ }
149
+ }
150
+
151
+ walk(dir);
152
+ return count;
153
+ }
154
+
155
+ /**
156
+ * Calculate total size of files in a directory recursively.
157
+ *
158
+ * @param {string} dir - Directory path
159
+ * @returns {number} - Total size in bytes
160
+ */
161
+ export function calculateDirSize(dir) {
162
+ if (!existsSync(dir)) return 0;
163
+
164
+ let totalSize = 0;
165
+
166
+ function walk(currentDir) {
167
+ const entries = readdirSync(currentDir);
168
+ for (const entry of entries) {
169
+ const fullPath = join(currentDir, entry);
170
+ const stat = statSync(fullPath);
171
+ if (stat.isDirectory()) {
172
+ walk(fullPath);
173
+ } else {
174
+ totalSize += stat.size;
175
+ }
176
+ }
177
+ }
178
+
179
+ walk(dir);
180
+ return totalSize;
181
+ }
182
+
183
+ /**
184
+ * Get list of all files in a directory recursively with relative paths.
185
+ *
186
+ * @param {string} dir - Directory path
187
+ * @param {string} basePath - Base path for relative path calculation
188
+ * @returns {string[]} - Array of relative file paths
189
+ */
190
+ export function getFilesRecursive(dir, basePath = dir) {
191
+ if (!existsSync(dir)) return [];
192
+
193
+ const files = [];
194
+
195
+ function walk(currentDir) {
196
+ const entries = readdirSync(currentDir);
197
+ for (const entry of entries) {
198
+ const fullPath = join(currentDir, entry);
199
+ const stat = statSync(fullPath);
200
+ if (stat.isDirectory()) {
201
+ walk(fullPath);
202
+ } else {
203
+ const relativePath = fullPath.slice(basePath.length + 1);
204
+ files.push(relativePath);
205
+ }
206
+ }
207
+ }
208
+
209
+ walk(dir);
210
+ return files;
211
+ }
212
+
213
+ /**
214
+ * Calculate a content hash for an entire Iris app folder.
215
+ * Hashes all file contents and their relative paths to detect any changes.
216
+ *
217
+ * @param {string} dir - Directory path to the Iris app
218
+ * @returns {string} - SHA256 hash of all files
219
+ */
220
+ export function hashIrisAppFolder(dir) {
221
+ if (!existsSync(dir)) return '';
222
+
223
+ const hash = createHash('sha256');
224
+ const files = getFilesRecursive(dir).sort(); // Sort for consistent ordering
225
+
226
+ for (const relativePath of files) {
227
+ const fullPath = join(dir, relativePath);
228
+ try {
229
+ const content = readFileSync(fullPath);
230
+ // Include the relative path in the hash so renames are detected
231
+ hash.update(relativePath);
232
+ hash.update(content);
233
+ } catch {
234
+ // Skip files we can't read
235
+ }
236
+ }
237
+
238
+ return hash.digest('hex');
239
+ }
package/utils/logger.js CHANGED
@@ -129,8 +129,12 @@ export class Logger {
129
129
 
130
130
  /**
131
131
  * Log an error message
132
+ * @param {string} message - Error message
133
+ * @param {Error|null} error - Error object
134
+ * @param {object|null} details - Additional details
135
+ * @param {string|null} hint - Helpful hint for resolving the error
132
136
  */
133
- error(message, error = null, details = null) {
137
+ error(message, error = null, details = null, hint = null) {
134
138
  const timestamp = new Date().toISOString();
135
139
  const entry = {
136
140
  timestamp,
@@ -138,7 +142,8 @@ export class Logger {
138
142
  message,
139
143
  error: error ? error.toString() : null,
140
144
  stack: error?.stack || null,
141
- details
145
+ details,
146
+ hint
142
147
  };
143
148
  this.errors.push(entry);
144
149
 
@@ -194,13 +199,16 @@ export class Logger {
194
199
  console.log(chalk.red(` ✗ ${summary.errors} error(s)`));
195
200
 
196
201
  // Show preview of recent errors
197
- const recentErrors = this.getRecentErrors(2);
202
+ const recentErrors = this.getRecentErrors(3);
198
203
  recentErrors.forEach((err, idx) => {
199
204
  console.log(chalk.red(` ${idx + 1}. ${err.message}`));
205
+ if (err.hint) {
206
+ console.log(chalk.yellow(` ${err.hint}`));
207
+ }
200
208
  });
201
209
 
202
- if (summary.errors > 2) {
203
- console.log(chalk.gray(` ... and ${summary.errors - 2} more`));
210
+ if (summary.errors > 3) {
211
+ console.log(chalk.gray(` ... and ${summary.errors - 3} more`));
204
212
  }
205
213
  }
206
214