@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,377 @@
1
+ import chalk from 'chalk';
2
+ import { select, input, confirm } from '@inquirer/prompts';
3
+ import { existsSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import {
6
+ linkVueProject,
7
+ unlinkVueProject,
8
+ getLinkedProjectsWithStatus,
9
+ formatLinkedProjects,
10
+ cleanupInvalidProjects
11
+ } from '../../utils/iris/linker.js';
12
+ import { formatMissingConfigError, formatConfigErrors, readVueConfig } from '../../utils/iris/config-reader.js';
13
+
14
+ /**
15
+ * iris-link command - Link a Vue project to the CLI.
16
+ *
17
+ * Options:
18
+ * --path <dir> Specify Vue project path directly
19
+ * --unlink Remove a linked project
20
+ * --list Show all linked projects
21
+ * --cleanup Remove invalid (non-existent) linked projects
22
+ */
23
+ export const irisLink = async (options = {}) => {
24
+ process.stdout.write('\x1Bc'); // Clear console
25
+
26
+ const { path: pathOption, unlink, list, cleanup } = options;
27
+
28
+ // Handle --list option
29
+ if (list) {
30
+ const projects = getLinkedProjectsWithStatus();
31
+ console.log(formatLinkedProjects(projects));
32
+ return;
33
+ }
34
+
35
+ // Handle --cleanup option
36
+ if (cleanup) {
37
+ await handleCleanup();
38
+ return;
39
+ }
40
+
41
+ // Handle --unlink option
42
+ if (unlink) {
43
+ await handleUnlink(pathOption);
44
+ return;
45
+ }
46
+
47
+ // If path provided directly, link it
48
+ if (pathOption) {
49
+ await linkProjectDirect(pathOption);
50
+ return;
51
+ }
52
+
53
+ // Interactive mode - show main menu
54
+ await showMainMenu();
55
+ };
56
+
57
+ /**
58
+ * Show the main menu for managing linked projects.
59
+ */
60
+ async function showMainMenu() {
61
+ const projects = getLinkedProjectsWithStatus();
62
+ const validCount = projects.filter(p => p.validation.valid).length;
63
+ const invalidCount = projects.filter(p => !p.validation.valid).length;
64
+
65
+ console.log(chalk.blue.bold('Iris Vue Project Manager'));
66
+ console.log(chalk.gray('─'.repeat(48)));
67
+
68
+ if (projects.length > 0) {
69
+ console.log(chalk.white(`Linked projects: ${validCount} valid`));
70
+ if (invalidCount > 0) {
71
+ console.log(chalk.yellow(` ${invalidCount} with issues`));
72
+ }
73
+ } else {
74
+ console.log(chalk.gray('No Vue projects linked yet.'));
75
+ }
76
+ console.log();
77
+
78
+ const choices = [
79
+ {
80
+ name: 'Link a new Vue project',
81
+ value: 'link',
82
+ description: 'Add a Vue project to the CLI'
83
+ }
84
+ ];
85
+
86
+ if (projects.length > 0) {
87
+ choices.push({
88
+ name: 'View linked projects',
89
+ value: 'list',
90
+ description: 'Show all linked Vue projects with status'
91
+ });
92
+ choices.push({
93
+ name: 'Unlink a project',
94
+ value: 'unlink',
95
+ description: 'Remove a Vue project from the CLI'
96
+ });
97
+ }
98
+
99
+ if (invalidCount > 0) {
100
+ choices.push({
101
+ name: `Clean up invalid projects (${invalidCount})`,
102
+ value: 'cleanup',
103
+ description: 'Remove projects with missing paths'
104
+ });
105
+ }
106
+
107
+ choices.push({
108
+ name: 'Exit',
109
+ value: 'exit'
110
+ });
111
+
112
+ const action = await select({
113
+ message: 'What would you like to do?',
114
+ choices
115
+ });
116
+
117
+ switch (action) {
118
+ case 'link':
119
+ await handleLink();
120
+ break;
121
+ case 'list':
122
+ console.log();
123
+ console.log(formatLinkedProjects(projects));
124
+ break;
125
+ case 'unlink':
126
+ await handleUnlink();
127
+ break;
128
+ case 'cleanup':
129
+ await handleCleanup();
130
+ break;
131
+ case 'exit':
132
+ console.log(chalk.gray('Goodbye!'));
133
+ break;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Handle linking a new Vue project.
139
+ */
140
+ async function handleLink(pathOption) {
141
+ let projectPath = pathOption;
142
+
143
+ // If no path provided, prompt for one
144
+ if (!projectPath) {
145
+ // Check if we're in a Vue project directory
146
+ const cwdConfig = readVueConfig(process.cwd());
147
+ const cwdPath = process.cwd();
148
+
149
+ const choices = [];
150
+
151
+ // Always show current directory option, but disable if not a valid Vue project
152
+ if (cwdConfig.found && cwdConfig.errors.length === 0) {
153
+ // Valid Vue project
154
+ choices.push({
155
+ name: `Current directory - ${cwdConfig.appName} (${cwdConfig.slug})`,
156
+ value: cwdPath,
157
+ description: cwdPath
158
+ });
159
+ } else if (cwdConfig.found && cwdConfig.errors.length > 0) {
160
+ // Has config.ts but with errors
161
+ const errorMsg = cwdConfig.errors[0] || 'Invalid config';
162
+ choices.push({
163
+ name: `Current directory`,
164
+ value: '__disabled__',
165
+ disabled: `Config error: ${errorMsg}`,
166
+ description: cwdPath
167
+ });
168
+ } else {
169
+ // No config.ts found
170
+ choices.push({
171
+ name: `Current directory`,
172
+ value: '__disabled__',
173
+ disabled: 'No config.ts found',
174
+ description: cwdPath
175
+ });
176
+ }
177
+
178
+ choices.push({
179
+ name: 'Enter path manually',
180
+ value: '__manual__',
181
+ description: 'Specify the full path to a Vue project'
182
+ });
183
+
184
+ choices.push({
185
+ name: 'Cancel',
186
+ value: '__cancel__'
187
+ });
188
+
189
+ const choice = await select({
190
+ message: 'Which Vue project do you want to link?',
191
+ choices
192
+ });
193
+
194
+ if (choice === '__cancel__') {
195
+ console.log(chalk.gray('Cancelled.'));
196
+ return;
197
+ }
198
+
199
+ if (choice === '__manual__') {
200
+ projectPath = await input({
201
+ message: 'Enter the path to your Vue project:',
202
+ validate: (value) => {
203
+ if (!value.trim()) {
204
+ return 'Path is required';
205
+ }
206
+ const resolved = resolve(value);
207
+ if (!existsSync(resolved)) {
208
+ return `Path does not exist: ${resolved}`;
209
+ }
210
+ return true;
211
+ }
212
+ });
213
+ } else {
214
+ projectPath = choice;
215
+ }
216
+ }
217
+
218
+ await linkProjectDirect(projectPath);
219
+ }
220
+
221
+ /**
222
+ * Link a project directly by path.
223
+ */
224
+ async function linkProjectDirect(projectPath) {
225
+ // Resolve the path
226
+ projectPath = resolve(projectPath);
227
+
228
+ // Validate path exists
229
+ if (!existsSync(projectPath)) {
230
+ console.log(chalk.red(`Error: Path does not exist: ${projectPath}`));
231
+ return;
232
+ }
233
+
234
+ // Check Vue config
235
+ const vueConfig = readVueConfig(projectPath);
236
+ if (!vueConfig.found) {
237
+ console.log(chalk.red(formatMissingConfigError(projectPath)));
238
+ return;
239
+ }
240
+
241
+ if (vueConfig.errors.length > 0) {
242
+ console.log(chalk.red(formatConfigErrors(vueConfig)));
243
+ return;
244
+ }
245
+
246
+ // Link the project
247
+ console.log(chalk.blue(`\nLinking Vue project...`));
248
+ console.log(chalk.gray(` Path: ${projectPath}`));
249
+ console.log(chalk.gray(` Slug: ${vueConfig.slug}`));
250
+ console.log(chalk.gray(` Name: ${vueConfig.appName}`));
251
+ if (vueConfig.siteUrl) {
252
+ console.log(chalk.gray(` Site: ${vueConfig.siteUrl}`));
253
+ }
254
+ console.log();
255
+
256
+ const result = linkVueProject(projectPath);
257
+
258
+ if (!result.success) {
259
+ console.log(chalk.red(`Failed to link project: ${result.error}`));
260
+ return;
261
+ }
262
+
263
+ if (result.updated) {
264
+ console.log(chalk.green(`\u2713 Project updated successfully!`));
265
+ console.log(chalk.gray(` The linked project configuration has been updated.`));
266
+ } else {
267
+ console.log(chalk.green(`\u2713 Project linked successfully!`));
268
+ console.log();
269
+ console.log(chalk.cyan('Next steps:'));
270
+ console.log(chalk.white(` 1. Build and stage: ${chalk.yellow('magentrix vue-build-stage')}`));
271
+ console.log(chalk.white(` 2. Publish to server: ${chalk.yellow('magentrix publish')}`));
272
+ console.log(chalk.white(` Or use ${chalk.yellow('magentrix autopublish')} for automatic publishing`));
273
+ }
274
+
275
+ console.log();
276
+ console.log(chalk.gray('Note: Linked projects are stored globally and available across all Magentrix workspaces.'));
277
+ }
278
+
279
+ /**
280
+ * Handle unlinking a Vue project.
281
+ */
282
+ async function handleUnlink(pathOption) {
283
+ const linkedProjects = getLinkedProjectsWithStatus();
284
+
285
+ if (linkedProjects.length === 0) {
286
+ console.log(chalk.yellow('No Vue projects are currently linked.'));
287
+ return;
288
+ }
289
+
290
+ let projectToUnlink = pathOption;
291
+
292
+ // If no path provided, prompt user to select
293
+ if (!projectToUnlink) {
294
+ const choices = linkedProjects.map(p => {
295
+ const validation = p.validation;
296
+ let prefix = '';
297
+ if (!validation.valid) {
298
+ prefix = validation.exists ? '⚠ ' : '✗ ';
299
+ }
300
+ const displayName = validation.currentAppName || p.appName;
301
+ const displaySlug = validation.currentSlug || p.slug;
302
+
303
+ return {
304
+ name: `${prefix}${displayName} (${displaySlug})`,
305
+ value: p.slug,
306
+ description: p.path
307
+ };
308
+ });
309
+
310
+ choices.push({
311
+ name: 'Cancel',
312
+ value: '__cancel__'
313
+ });
314
+
315
+ projectToUnlink = await select({
316
+ message: 'Which project do you want to unlink?',
317
+ choices
318
+ });
319
+
320
+ if (projectToUnlink === '__cancel__') {
321
+ console.log(chalk.gray('Cancelled.'));
322
+ return;
323
+ }
324
+ }
325
+
326
+ // Unlink the project
327
+ const result = unlinkVueProject(projectToUnlink);
328
+
329
+ if (!result.success) {
330
+ console.log(chalk.red(`Failed to unlink: ${result.error}`));
331
+ return;
332
+ }
333
+
334
+ console.log(chalk.green(`\u2713 Project '${result.project.appName}' (${result.project.slug}) has been unlinked.`));
335
+ console.log(chalk.gray(` Path: ${result.project.path}`));
336
+ console.log();
337
+ console.log(chalk.gray('Note: This only removes the link from CLI tracking.'));
338
+ console.log(chalk.gray('The Vue project and any deployed Iris app are unchanged.'));
339
+ }
340
+
341
+ /**
342
+ * Handle cleanup of invalid projects.
343
+ */
344
+ async function handleCleanup() {
345
+ const projectsWithStatus = getLinkedProjectsWithStatus();
346
+ const invalidProjects = projectsWithStatus.filter(p => !p.validation.exists);
347
+
348
+ if (invalidProjects.length === 0) {
349
+ console.log(chalk.green('All linked projects are valid. No cleanup needed.'));
350
+ return;
351
+ }
352
+
353
+ console.log(chalk.yellow(`Found ${invalidProjects.length} project(s) with invalid paths:\n`));
354
+
355
+ for (const project of invalidProjects) {
356
+ console.log(chalk.red(` ✗ ${project.appName} (${project.slug})`));
357
+ console.log(chalk.gray(` Path: ${project.path}`));
358
+ console.log(chalk.gray(` Error: ${project.validation.errors.join(', ')}`));
359
+ console.log();
360
+ }
361
+
362
+ const shouldCleanup = await confirm({
363
+ message: `Remove these ${invalidProjects.length} invalid project(s)?`,
364
+ default: true
365
+ });
366
+
367
+ if (!shouldCleanup) {
368
+ console.log(chalk.gray('Cleanup cancelled.'));
369
+ return;
370
+ }
371
+
372
+ const result = cleanupInvalidProjects();
373
+
374
+ console.log(chalk.green(`\u2713 Removed ${result.removed} invalid project(s).`));
375
+ }
376
+
377
+ export default irisLink;
@@ -0,0 +1,228 @@
1
+ import chalk from 'chalk';
2
+ import { select, confirm } from '@inquirer/prompts';
3
+ import { existsSync } from 'node:fs';
4
+ import path from 'path';
5
+ import { listBackups, restoreIrisApp, deleteBackup } from '../../utils/iris/backup.js';
6
+ import { linkVueProject } from '../../utils/iris/linker.js';
7
+ import { showPermissionError } from '../../utils/permissionError.js';
8
+ import { EXPORT_ROOT, IRIS_APPS_DIR } from '../../vars/global.js';
9
+
10
+ /**
11
+ * iris-recover command - Restore a deleted Iris app from backup.
12
+ *
13
+ * Options:
14
+ * --list List all available backups
15
+ */
16
+ export const irisRecover = async (options = {}) => {
17
+ process.stdout.write('\x1Bc'); // Clear console
18
+
19
+ const { list } = options;
20
+
21
+ console.log(chalk.blue.bold('\n♻ Recover Iris App'));
22
+ console.log(chalk.gray('─'.repeat(48)));
23
+ console.log();
24
+
25
+ // Get available backups
26
+ const backups = listBackups();
27
+
28
+ if (backups.length === 0) {
29
+ console.log(chalk.yellow('No recovery backups found.'));
30
+ console.log();
31
+ console.log(chalk.gray('Backups are created automatically when you delete an Iris app.'));
32
+ console.log(chalk.white(`Use: ${chalk.cyan('magentrix iris-delete')}`));
33
+ console.log();
34
+ return;
35
+ }
36
+
37
+ // If --list flag, just show and exit
38
+ if (list) {
39
+ console.log(chalk.white('Available Recovery Backups:'));
40
+ console.log();
41
+
42
+ backups.forEach((backup, i) => {
43
+ const date = new Date(backup.deletedAt);
44
+ console.log(chalk.white(`${i + 1}. ${chalk.cyan(backup.appName)} (${backup.slug})`));
45
+ console.log(chalk.gray(` Deleted: ${date.toLocaleString()}`));
46
+ if (backup.linkedProject) {
47
+ console.log(chalk.gray(` Linked: ${backup.linkedProject.path}`));
48
+ }
49
+ console.log(chalk.gray(` Backup: ${backup.backupPath}`));
50
+ console.log();
51
+ });
52
+
53
+ console.log(chalk.white(`To recover, run: ${chalk.cyan('magentrix iris-recover')}`));
54
+ console.log();
55
+ return;
56
+ }
57
+
58
+ // Build choices for selection
59
+ const choices = backups.map((backup) => {
60
+ const date = new Date(backup.deletedAt);
61
+ const timeAgo = getTimeAgo(date);
62
+ return {
63
+ name: `${backup.appName} (${backup.slug}) - Deleted ${timeAgo}`,
64
+ value: backup
65
+ };
66
+ });
67
+
68
+ choices.push({
69
+ name: 'Cancel',
70
+ value: null
71
+ });
72
+
73
+ // Select backup to restore
74
+ const selectedBackup = await select({
75
+ message: 'Which backup do you want to restore?',
76
+ choices
77
+ });
78
+
79
+ if (!selectedBackup) {
80
+ console.log(chalk.gray('Cancelled.'));
81
+ return;
82
+ }
83
+
84
+ const { slug, appName, linkedProject, backupPath } = selectedBackup;
85
+
86
+ // Show recovery info
87
+ console.log();
88
+ console.log(chalk.white('Recovery Details:'));
89
+ console.log(chalk.gray('─'.repeat(48)));
90
+ console.log(chalk.white(` App: ${chalk.cyan(appName)} (${slug})`));
91
+ console.log(chalk.white(` Backup: ${chalk.gray(backupPath)}`));
92
+
93
+ if (linkedProject) {
94
+ const pathExists = existsSync(linkedProject.path);
95
+ if (pathExists) {
96
+ console.log(chalk.green(` ✓ Linked project: ${linkedProject.path}`));
97
+ } else {
98
+ console.log(chalk.yellow(` ⚠ Linked project path no longer exists:`));
99
+ console.log(chalk.gray(` ${linkedProject.path}`));
100
+ }
101
+ } else {
102
+ console.log(chalk.gray(' (No linked Vue project)'));
103
+ }
104
+
105
+ console.log();
106
+
107
+ // Check for warnings
108
+ const warnings = [];
109
+ if (linkedProject && !existsSync(linkedProject.path)) {
110
+ warnings.push('The linked Vue project path no longer exists. Only local files will be restored.');
111
+ }
112
+
113
+ // Check if app already exists locally
114
+ const appPath = path.join(EXPORT_ROOT, IRIS_APPS_DIR, slug);
115
+ if (existsSync(appPath)) {
116
+ console.log(chalk.yellow(`⚠ Warning: App folder already exists at ${appPath}`));
117
+ console.log(chalk.yellow(' Recovery will overwrite existing files.'));
118
+ console.log();
119
+ }
120
+
121
+ if (warnings.length > 0) {
122
+ console.log(chalk.yellow('⚠ Warnings:'));
123
+ warnings.forEach(w => console.log(chalk.yellow(` • ${w}`)));
124
+ console.log();
125
+ }
126
+
127
+ // Confirm recovery
128
+ const shouldRecover = await confirm({
129
+ message: 'Do you want to restore this app?',
130
+ default: true
131
+ });
132
+
133
+ if (!shouldRecover) {
134
+ console.log(chalk.gray('Cancelled.'));
135
+ return;
136
+ }
137
+
138
+ // Restore files
139
+ console.log();
140
+ console.log(chalk.blue('Restoring files...'));
141
+
142
+ const restoreResult = await restoreIrisApp(backupPath, {
143
+ restoreLocal: true,
144
+ restoreLink: true
145
+ });
146
+
147
+ if (!restoreResult.success) {
148
+ if (restoreResult.isPermissionError) {
149
+ const targetDir = path.join(process.cwd(), EXPORT_ROOT, IRIS_APPS_DIR);
150
+ showPermissionError({
151
+ operation: 'restore',
152
+ targetPath: targetDir,
153
+ backupPath,
154
+ slug
155
+ });
156
+ } else {
157
+ console.log(chalk.red(`Failed to restore: ${restoreResult.error}`));
158
+ }
159
+ return;
160
+ }
161
+
162
+ console.log(chalk.green(`✓ Restored files to ${EXPORT_ROOT}/${IRIS_APPS_DIR}/${slug}/`));
163
+
164
+ // Re-link Vue project if needed
165
+ if (linkedProject && restoreResult.linkedProjectPathExists) {
166
+ console.log(chalk.blue('Re-linking Vue project...'));
167
+
168
+ const linkResult = linkVueProject(linkedProject.path);
169
+ if (linkResult.success) {
170
+ console.log(chalk.green('✓ Vue project re-linked'));
171
+ } else {
172
+ console.log(chalk.yellow(`⚠ Could not re-link Vue project: ${linkResult.error}`));
173
+ }
174
+ }
175
+
176
+ // Show warnings
177
+ if (restoreResult.warnings.length > 0) {
178
+ console.log();
179
+ console.log(chalk.yellow('Warnings:'));
180
+ restoreResult.warnings.forEach(w => console.log(chalk.yellow(` • ${w}`)));
181
+ }
182
+
183
+ // Summary
184
+ console.log();
185
+ console.log(chalk.green('─'.repeat(48)));
186
+ console.log(chalk.green.bold('✓ Recovery Complete!'));
187
+ console.log();
188
+ console.log(chalk.cyan('Next steps:'));
189
+ console.log(chalk.white(` • Run ${chalk.yellow('magentrix publish')} to sync the app back to the server`));
190
+ console.log();
191
+
192
+ // Ask if they want to delete the backup
193
+ const deleteBackupConfirm = await confirm({
194
+ message: 'Delete the recovery backup now?',
195
+ default: false
196
+ });
197
+
198
+ if (deleteBackupConfirm) {
199
+ deleteBackup(backupPath);
200
+ console.log(chalk.green('✓ Recovery backup deleted'));
201
+ } else {
202
+ console.log(chalk.gray(`Backup preserved at: ${backupPath}`));
203
+ }
204
+
205
+ console.log();
206
+ };
207
+
208
+ /**
209
+ * Get human-readable time ago string.
210
+ * @param {Date} date - Date to compare
211
+ * @returns {string} - Time ago string
212
+ */
213
+ function getTimeAgo(date) {
214
+ const now = new Date();
215
+ const diffMs = now - date;
216
+ const diffMins = Math.floor(diffMs / 60000);
217
+ const diffHours = Math.floor(diffMs / 3600000);
218
+ const diffDays = Math.floor(diffMs / 86400000);
219
+
220
+ if (diffMins < 1) return 'just now';
221
+ if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
222
+ if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
223
+ if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
224
+
225
+ return date.toLocaleDateString();
226
+ }
227
+
228
+ export default irisRecover;