@magentrix-corp/magentrix-cli 1.2.0 → 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,201 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { createIrisZip } from './zipper.js';
4
+ import { extractIrisZip } from './zipper.js';
5
+
6
+ const BACKUP_DIR = '.magentrix/iris-backups';
7
+
8
+ /**
9
+ * Create a backup of an Iris app before deletion.
10
+ *
11
+ * @param {string} appPath - Path to the Iris app folder (e.g., src/iris-apps/my-app)
12
+ * @param {object} metadata - Additional metadata to save
13
+ * @param {string} metadata.slug - App slug
14
+ * @param {string} metadata.appName - App display name
15
+ * @param {object} [metadata.linkedProject] - Linked Vue project info (if any)
16
+ * @returns {Promise<{success: boolean, backupPath: string | null, error: string | null}>}
17
+ */
18
+ export async function backupIrisApp(appPath, metadata) {
19
+ try {
20
+ const { slug } = metadata;
21
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
22
+ const backupName = `${slug}-${timestamp}`;
23
+ const backupPath = path.join(process.cwd(), BACKUP_DIR, backupName);
24
+
25
+ // Create backup directory
26
+ fs.mkdirSync(backupPath, { recursive: true });
27
+
28
+ // Create ZIP of app files
29
+ const zipBuffer = await createIrisZip(appPath, slug);
30
+ const zipPath = path.join(backupPath, `${slug}.zip`);
31
+ fs.writeFileSync(zipPath, zipBuffer);
32
+
33
+ // Save metadata
34
+ const metadataPath = path.join(backupPath, 'metadata.json');
35
+ fs.writeFileSync(metadataPath, JSON.stringify({
36
+ ...metadata,
37
+ deletedAt: new Date().toISOString(),
38
+ backupName,
39
+ originalPath: appPath
40
+ }, null, 2));
41
+
42
+ return {
43
+ success: true,
44
+ backupPath,
45
+ error: null
46
+ };
47
+ } catch (error) {
48
+ return {
49
+ success: false,
50
+ backupPath: null,
51
+ error: error.message
52
+ };
53
+ }
54
+ }
55
+
56
+ /**
57
+ * List all available Iris app backups.
58
+ *
59
+ * @returns {Array<{
60
+ * backupName: string,
61
+ * slug: string,
62
+ * appName: string,
63
+ * deletedAt: string,
64
+ * linkedProject: object | null,
65
+ * backupPath: string
66
+ * }>}
67
+ */
68
+ export function listBackups() {
69
+ const backupsDir = path.join(process.cwd(), BACKUP_DIR);
70
+
71
+ if (!fs.existsSync(backupsDir)) {
72
+ return [];
73
+ }
74
+
75
+ const backups = [];
76
+ const entries = fs.readdirSync(backupsDir, { withFileTypes: true });
77
+
78
+ for (const entry of entries) {
79
+ if (!entry.isDirectory()) continue;
80
+
81
+ const backupPath = path.join(backupsDir, entry.name);
82
+ const metadataPath = path.join(backupPath, 'metadata.json');
83
+
84
+ if (!fs.existsSync(metadataPath)) continue;
85
+
86
+ try {
87
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
88
+ backups.push({
89
+ ...metadata,
90
+ backupPath
91
+ });
92
+ } catch {
93
+ // Skip invalid backups
94
+ }
95
+ }
96
+
97
+ // Sort by deletedAt (newest first)
98
+ return backups.sort((a, b) =>
99
+ new Date(b.deletedAt).getTime() - new Date(a.deletedAt).getTime()
100
+ );
101
+ }
102
+
103
+ /**
104
+ * Restore an Iris app from backup.
105
+ *
106
+ * @param {string} backupPath - Path to the backup folder
107
+ * @param {object} options - Recovery options
108
+ * @param {boolean} options.restoreLink - Whether to restore the linked project (default: true)
109
+ * @param {boolean} options.restoreLocal - Whether to restore local files (default: true)
110
+ * @returns {Promise<{
111
+ * success: boolean,
112
+ * restoredFiles: boolean,
113
+ * restoredLink: boolean,
114
+ * linkedProjectPathExists: boolean,
115
+ * warnings: string[],
116
+ * error: string | null,
117
+ * isPermissionError: boolean,
118
+ * targetPath: string | null
119
+ * }>}
120
+ */
121
+ export async function restoreIrisApp(backupPath, options = {}) {
122
+ const { restoreLink = true, restoreLocal = true } = options;
123
+ const result = {
124
+ success: false,
125
+ restoredFiles: false,
126
+ restoredLink: false,
127
+ linkedProjectPathExists: true,
128
+ warnings: [],
129
+ error: null,
130
+ isPermissionError: false,
131
+ targetPath: null
132
+ };
133
+
134
+ try {
135
+ // Read metadata
136
+ const metadataPath = path.join(backupPath, 'metadata.json');
137
+ if (!fs.existsSync(metadataPath)) {
138
+ result.error = 'Backup metadata not found';
139
+ return result;
140
+ }
141
+
142
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
143
+ const { slug, linkedProject } = metadata;
144
+
145
+ // Restore local files
146
+ if (restoreLocal) {
147
+ const zipPath = path.join(backupPath, `${slug}.zip`);
148
+ if (!fs.existsSync(zipPath)) {
149
+ result.error = 'Backup ZIP file not found';
150
+ return result;
151
+ }
152
+
153
+ const zipBuffer = fs.readFileSync(zipPath);
154
+ const targetDir = path.join(process.cwd(), 'src', 'iris-apps');
155
+ const appTargetDir = path.join(targetDir, slug);
156
+ result.targetPath = appTargetDir;
157
+
158
+ // Ensure target directory exists
159
+ fs.mkdirSync(targetDir, { recursive: true });
160
+
161
+ // Extract backup
162
+ await extractIrisZip(zipBuffer, targetDir);
163
+ result.restoredFiles = true;
164
+ }
165
+
166
+ // Restore linked project
167
+ if (restoreLink && linkedProject) {
168
+ // Check if the linked project path still exists
169
+ if (!fs.existsSync(linkedProject.path)) {
170
+ result.linkedProjectPathExists = false;
171
+ result.warnings.push(
172
+ `Linked Vue project no longer exists at: ${linkedProject.path}`
173
+ );
174
+ } else {
175
+ result.restoredLink = true;
176
+ }
177
+ }
178
+
179
+ result.success = true;
180
+ return result;
181
+ } catch (error) {
182
+ result.error = error.message;
183
+ result.isPermissionError = error.code === 'EACCES' || error.code === 'EPERM';
184
+ return result;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Delete a backup folder.
190
+ *
191
+ * @param {string} backupPath - Path to the backup folder
192
+ */
193
+ export function deleteBackup(backupPath) {
194
+ try {
195
+ if (fs.existsSync(backupPath)) {
196
+ fs.rmSync(backupPath, { recursive: true, force: true });
197
+ }
198
+ } catch {
199
+ // Ignore errors
200
+ }
201
+ }
@@ -0,0 +1,304 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, mkdirSync, cpSync, rmSync, readdirSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import { validateIrisBuild } from './validator.js';
5
+ import { updateLastBuild } from './linker.js';
6
+ import { EXPORT_ROOT, IRIS_APPS_DIR } from '../../vars/global.js';
7
+
8
+ /**
9
+ * Build a Vue project using npm run build.
10
+ *
11
+ * @param {string} projectPath - Path to the Vue project
12
+ * @param {Object} options - Build options
13
+ * @param {boolean} options.silent - Suppress build output
14
+ * @returns {Promise<{
15
+ * success: boolean,
16
+ * output: string,
17
+ * error: string | null,
18
+ * distPath: string | null
19
+ * }>}
20
+ */
21
+ export async function buildVueProject(projectPath, options = {}) {
22
+ const { silent = false } = options;
23
+ const resolvedPath = resolve(projectPath);
24
+
25
+ // Check for package.json
26
+ if (!existsSync(join(resolvedPath, 'package.json'))) {
27
+ return {
28
+ success: false,
29
+ output: '',
30
+ error: `No package.json found in ${resolvedPath}`,
31
+ distPath: null
32
+ };
33
+ }
34
+
35
+ // Check for node_modules
36
+ if (!existsSync(join(resolvedPath, 'node_modules'))) {
37
+ return {
38
+ success: false,
39
+ output: '',
40
+ error: `No node_modules found. Run 'npm install' in ${resolvedPath} first.`,
41
+ distPath: null
42
+ };
43
+ }
44
+
45
+ return new Promise((resolvePromise) => {
46
+ const output = [];
47
+ const errorOutput = [];
48
+
49
+ // Determine npm command based on platform
50
+ const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
51
+
52
+ const child = spawn(npmCmd, ['run', 'build'], {
53
+ cwd: resolvedPath,
54
+ shell: false,
55
+ env: { ...process.env, FORCE_COLOR: '1' }
56
+ });
57
+
58
+ child.stdout.on('data', (data) => {
59
+ const text = data.toString();
60
+ output.push(text);
61
+ if (!silent) {
62
+ process.stdout.write(text);
63
+ }
64
+ });
65
+
66
+ child.stderr.on('data', (data) => {
67
+ const text = data.toString();
68
+ errorOutput.push(text);
69
+ if (!silent) {
70
+ process.stderr.write(text);
71
+ }
72
+ });
73
+
74
+ child.on('close', (code) => {
75
+ const fullOutput = output.join('');
76
+ const fullError = errorOutput.join('');
77
+
78
+ if (code !== 0) {
79
+ resolvePromise({
80
+ success: false,
81
+ output: fullOutput,
82
+ error: fullError || `Build process exited with code ${code}`,
83
+ distPath: null
84
+ });
85
+ return;
86
+ }
87
+
88
+ // Find dist directory (Vue typically outputs to 'dist')
89
+ const possibleDistPaths = ['dist', 'build', 'output'];
90
+ let distPath = null;
91
+
92
+ for (const distDir of possibleDistPaths) {
93
+ const fullPath = join(resolvedPath, distDir);
94
+ if (existsSync(fullPath)) {
95
+ distPath = fullPath;
96
+ break;
97
+ }
98
+ }
99
+
100
+ if (!distPath) {
101
+ resolvePromise({
102
+ success: false,
103
+ output: fullOutput,
104
+ error: 'Build completed but no dist/build directory found',
105
+ distPath: null
106
+ });
107
+ return;
108
+ }
109
+
110
+ resolvePromise({
111
+ success: true,
112
+ output: fullOutput,
113
+ error: null,
114
+ distPath
115
+ });
116
+ });
117
+
118
+ child.on('error', (err) => {
119
+ resolvePromise({
120
+ success: false,
121
+ output: output.join(''),
122
+ error: `Failed to start build process: ${err.message}`,
123
+ distPath: null
124
+ });
125
+ });
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Stage build output to the CLI project's iris-apps directory.
131
+ *
132
+ * @param {string} distPath - Path to the build output (dist directory)
133
+ * @param {string} slug - The app slug (folder name)
134
+ * @param {string} cliProjectPath - Path to the CLI project root (defaults to CWD)
135
+ * @returns {{
136
+ * success: boolean,
137
+ * stagedPath: string | null,
138
+ * error: string | null,
139
+ * fileCount: number
140
+ * }}
141
+ */
142
+ export function stageToCliProject(distPath, slug, cliProjectPath = process.cwd()) {
143
+ const resolvedDistPath = resolve(distPath);
144
+ const irisAppsDir = join(cliProjectPath, EXPORT_ROOT, IRIS_APPS_DIR);
145
+ const targetDir = join(irisAppsDir, slug);
146
+
147
+ // Validate dist path exists
148
+ if (!existsSync(resolvedDistPath)) {
149
+ return {
150
+ success: false,
151
+ stagedPath: null,
152
+ error: `Dist path does not exist: ${resolvedDistPath}`,
153
+ fileCount: 0
154
+ };
155
+ }
156
+
157
+ // Validate build output
158
+ const validation = validateIrisBuild(resolvedDistPath);
159
+ if (!validation.valid) {
160
+ return {
161
+ success: false,
162
+ stagedPath: null,
163
+ error: `Invalid build output:\n${validation.errors.join('\n')}`,
164
+ fileCount: 0
165
+ };
166
+ }
167
+
168
+ // Ensure iris-apps directory exists
169
+ if (!existsSync(irisAppsDir)) {
170
+ mkdirSync(irisAppsDir, { recursive: true });
171
+ }
172
+
173
+ // Remove existing app directory if it exists
174
+ if (existsSync(targetDir)) {
175
+ rmSync(targetDir, { recursive: true, force: true });
176
+ }
177
+
178
+ // Copy dist contents to target
179
+ try {
180
+ cpSync(resolvedDistPath, targetDir, { recursive: true });
181
+ } catch (err) {
182
+ return {
183
+ success: false,
184
+ stagedPath: null,
185
+ error: `Failed to copy build output: ${err.message}`,
186
+ fileCount: 0
187
+ };
188
+ }
189
+
190
+ // Count files copied
191
+ const fileCount = countFiles(targetDir);
192
+
193
+ // Update last build timestamp for linked project
194
+ updateLastBuild(slug);
195
+
196
+ return {
197
+ success: true,
198
+ stagedPath: targetDir,
199
+ error: null,
200
+ fileCount
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Count files recursively in a directory.
206
+ *
207
+ * @param {string} dir - Directory to count files in
208
+ * @returns {number} - Number of files
209
+ */
210
+ function countFiles(dir) {
211
+ if (!existsSync(dir)) return 0;
212
+
213
+ let count = 0;
214
+ const items = readdirSync(dir, { withFileTypes: true });
215
+
216
+ for (const item of items) {
217
+ if (item.isDirectory()) {
218
+ count += countFiles(join(dir, item.name));
219
+ } else {
220
+ count++;
221
+ }
222
+ }
223
+
224
+ return count;
225
+ }
226
+
227
+ /**
228
+ * Find an existing dist directory in a Vue project.
229
+ *
230
+ * @param {string} projectPath - Path to the Vue project
231
+ * @returns {string | null} - Path to dist directory or null if not found
232
+ */
233
+ export function findDistDirectory(projectPath) {
234
+ const possibleDistPaths = ['dist', 'build', 'output'];
235
+
236
+ for (const distDir of possibleDistPaths) {
237
+ const fullPath = join(projectPath, distDir);
238
+ if (existsSync(fullPath)) {
239
+ return fullPath;
240
+ }
241
+ }
242
+
243
+ return null;
244
+ }
245
+
246
+ /**
247
+ * Format build error for display.
248
+ *
249
+ * @param {string} projectPath - Path to the Vue project
250
+ * @param {string} errorOutput - Error output from build
251
+ * @returns {string} - Formatted error message
252
+ */
253
+ export function formatBuildError(projectPath, errorOutput) {
254
+ return `Build Failed
255
+ ────────────────────────────────────────────────────
256
+
257
+ The Vue.js build process failed. This is a project issue, not a CLI issue.
258
+
259
+ Error output:
260
+ ${errorOutput.split('\n').map(line => ` ${line}`).join('\n')}
261
+
262
+ To fix this:
263
+ 1. Run 'npm run build' manually in your Vue project:
264
+ cd ${projectPath}
265
+ npm run build
266
+
267
+ 2. Fix any compilation errors shown above
268
+
269
+ 3. Run 'magentrix vue-build-stage' again
270
+
271
+ Alternatively, use --skip-build to stage an existing dist/ folder.`;
272
+ }
273
+
274
+ /**
275
+ * Format validation error for display.
276
+ *
277
+ * @param {string} distPath - Path to the dist directory
278
+ * @param {string[]} errors - Validation errors
279
+ * @returns {string} - Formatted error message
280
+ */
281
+ export function formatValidationError(distPath, errors) {
282
+ return `Invalid Build Output
283
+ ────────────────────────────────────────────────────
284
+
285
+ The build output at ${distPath} is missing required files.
286
+
287
+ Missing:
288
+ ${errors.map(e => ` ✗ ${e}`).join('\n')}
289
+
290
+ Required files for an Iris app:
291
+ - remoteEntry.js (Module Federation entry point)
292
+ - assets/hostInit*.js (Host initialization script)
293
+ - assets/main*.js (Main entry point)
294
+ - assets/index*.js (Index entry)
295
+
296
+ This usually means:
297
+ 1. The project is not configured as a Module Federation remote
298
+ 2. The build did not complete successfully
299
+ 3. The dist directory contains an old or incorrect build
300
+
301
+ Try rebuilding the project:
302
+ cd ${distPath.replace('/dist', '')}
303
+ npm run build`;
304
+ }