@magentrix-corp/magentrix-cli 1.3.16 → 1.3.17

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 (68) hide show
  1. package/LICENSE +25 -25
  2. package/README.md +1166 -1166
  3. package/actions/autopublish.old.js +293 -293
  4. package/actions/config.js +182 -182
  5. package/actions/create.js +466 -466
  6. package/actions/help.js +164 -164
  7. package/actions/iris/buildStage.js +874 -874
  8. package/actions/iris/delete.js +256 -256
  9. package/actions/iris/dev.js +391 -391
  10. package/actions/iris/index.js +6 -6
  11. package/actions/iris/link.js +375 -375
  12. package/actions/iris/recover.js +268 -268
  13. package/actions/main.js +80 -80
  14. package/actions/publish.js +1420 -1420
  15. package/actions/pull.js +684 -684
  16. package/actions/setup.js +148 -148
  17. package/actions/status.js +17 -17
  18. package/actions/update.js +248 -248
  19. package/bin/magentrix.js +393 -393
  20. package/package.json +55 -55
  21. package/utils/assetPaths.js +158 -158
  22. package/utils/autopublishLock.js +77 -77
  23. package/utils/cacher.js +206 -206
  24. package/utils/cli/checkInstanceUrl.js +76 -74
  25. package/utils/cli/helpers/compare.js +282 -282
  26. package/utils/cli/helpers/ensureApiKey.js +63 -63
  27. package/utils/cli/helpers/ensureCredentials.js +68 -68
  28. package/utils/cli/helpers/ensureInstanceUrl.js +75 -75
  29. package/utils/cli/writeRecords.js +262 -262
  30. package/utils/compare.js +135 -135
  31. package/utils/compress.js +17 -17
  32. package/utils/config.js +527 -527
  33. package/utils/debug.js +144 -144
  34. package/utils/diagnostics/testPublishLogic.js +96 -96
  35. package/utils/diff.js +49 -49
  36. package/utils/downloadAssets.js +291 -291
  37. package/utils/filetag.js +115 -115
  38. package/utils/hash.js +14 -14
  39. package/utils/iris/backup.js +411 -411
  40. package/utils/iris/builder.js +541 -541
  41. package/utils/iris/config-reader.js +664 -664
  42. package/utils/iris/deleteHelper.js +150 -150
  43. package/utils/iris/errors.js +537 -537
  44. package/utils/iris/linker.js +601 -601
  45. package/utils/iris/lock.js +360 -360
  46. package/utils/iris/validation.js +360 -360
  47. package/utils/iris/validator.js +281 -281
  48. package/utils/iris/zipper.js +248 -248
  49. package/utils/logger.js +291 -291
  50. package/utils/magentrix/api/assets.js +220 -220
  51. package/utils/magentrix/api/auth.js +107 -107
  52. package/utils/magentrix/api/createEntity.js +61 -61
  53. package/utils/magentrix/api/deleteEntity.js +55 -55
  54. package/utils/magentrix/api/iris.js +251 -251
  55. package/utils/magentrix/api/meqlQuery.js +36 -36
  56. package/utils/magentrix/api/retrieveEntity.js +86 -86
  57. package/utils/magentrix/api/updateEntity.js +66 -66
  58. package/utils/magentrix/fetch.js +168 -168
  59. package/utils/merge.js +22 -22
  60. package/utils/permissionError.js +70 -70
  61. package/utils/preferences.js +40 -40
  62. package/utils/progress.js +469 -469
  63. package/utils/spinner.js +43 -43
  64. package/utils/template.js +52 -52
  65. package/utils/updateFileBase.js +121 -121
  66. package/utils/workspaces.js +108 -108
  67. package/vars/config.js +11 -11
  68. package/vars/global.js +50 -50
@@ -1,601 +1,601 @@
1
- import { existsSync } from 'node:fs';
2
- import { resolve, basename } from 'node:path';
3
- import Config from '../config.js';
4
- import { readVueConfig } from './config-reader.js';
5
- import { validateSlug } from './validation.js';
6
- import { formatError, formatCorruptedConfigError, detectErrorType, ErrorTypes } from './errors.js';
7
-
8
- const config = new Config();
9
-
10
- /**
11
- * Safe wrapper for reading config that handles corruption.
12
- *
13
- * @param {string} key - Config key to read
14
- * @param {Object} options - Config options
15
- * @returns {{success: boolean, data: any, error: string | null}}
16
- */
17
- function safeConfigRead(key, options) {
18
- try {
19
- const data = config.read(key, options);
20
- return { success: true, data, error: null };
21
- } catch (err) {
22
- const errorType = detectErrorType(err);
23
- if (errorType === ErrorTypes.CORRUPTED) {
24
- return {
25
- success: false,
26
- data: null,
27
- error: formatCorruptedConfigError({
28
- configPath: config.getGlobalConfigPath?.() || '~/.config/magentrix/config.json',
29
- error: err
30
- })
31
- };
32
- }
33
- return { success: false, data: null, error: err.message };
34
- }
35
- }
36
-
37
- /**
38
- * Safe wrapper for saving config that handles errors.
39
- *
40
- * @param {string} key - Config key to save
41
- * @param {any} value - Value to save
42
- * @param {Object} options - Config options
43
- * @returns {{success: boolean, error: string | null}}
44
- */
45
- function safeConfigSave(key, value, options) {
46
- try {
47
- config.save(key, value, options);
48
- return { success: true, error: null };
49
- } catch (err) {
50
- const errorType = detectErrorType(err);
51
- if (errorType === ErrorTypes.PERMISSION) {
52
- return {
53
- success: false,
54
- error: `Permission denied saving config. Check file permissions for the global config directory.`
55
- };
56
- }
57
- if (errorType === ErrorTypes.DISK_FULL) {
58
- return {
59
- success: false,
60
- error: `Disk full - cannot save config. Free up disk space and try again.`
61
- };
62
- }
63
- return { success: false, error: `Failed to save config: ${err.message}` };
64
- }
65
- }
66
-
67
- /**
68
- * Get all linked Vue projects from global config.
69
- *
70
- * @returns {Array<{
71
- * path: string,
72
- * slug: string,
73
- * appName: string,
74
- * siteUrl: string | null,
75
- * lastBuild: string | null
76
- * }>}
77
- */
78
- export function getLinkedProjects() {
79
- const result = safeConfigRead('linkedVueProjects', {
80
- global: true // Store globally so projects persist across Magentrix workspaces
81
- });
82
-
83
- if (!result.success) {
84
- // Log error but return empty array to allow graceful degradation
85
- console.error(result.error);
86
- return [];
87
- }
88
-
89
- return result.data || [];
90
- }
91
-
92
- /**
93
- * Save linked projects to global config.
94
- *
95
- * @param {Array} projects - Array of linked projects
96
- * @returns {{success: boolean, error: string | null}}
97
- */
98
- function saveLinkedProjects(projects) {
99
- return safeConfigSave('linkedVueProjects', projects, {
100
- global: true
101
- });
102
- }
103
-
104
- /**
105
- * Validate a linked project and return its current status.
106
- *
107
- * @param {{path: string, slug: string, appName: string}} project - Project to validate
108
- * @returns {{
109
- * valid: boolean,
110
- * exists: boolean,
111
- * hasConfig: boolean,
112
- * configValid: boolean,
113
- * currentSlug: string | null,
114
- * currentAppName: string | null,
115
- * errors: string[]
116
- * }}
117
- */
118
- export function validateLinkedProject(project) {
119
- const result = {
120
- valid: false,
121
- exists: false,
122
- hasConfig: false,
123
- configValid: false,
124
- currentSlug: null,
125
- currentAppName: null,
126
- errors: []
127
- };
128
-
129
- // Check if path exists
130
- if (!existsSync(project.path)) {
131
- result.errors.push('Path no longer exists');
132
- return result;
133
- }
134
- result.exists = true;
135
-
136
- // Check if it's still a valid Vue project
137
- const vueConfig = readVueConfig(project.path);
138
- if (!vueConfig.found) {
139
- result.errors.push('No config.ts found');
140
- return result;
141
- }
142
- result.hasConfig = true;
143
-
144
- // Check for config errors
145
- if (vueConfig.errors.length > 0) {
146
- result.errors.push(...vueConfig.errors);
147
- return result;
148
- }
149
- result.configValid = true;
150
-
151
- // Store current values (might have changed)
152
- result.currentSlug = vueConfig.slug;
153
- result.currentAppName = vueConfig.appName;
154
- result.valid = true;
155
-
156
- return result;
157
- }
158
-
159
- /**
160
- * Get all linked projects with their current validation status.
161
- *
162
- * @returns {Array<{
163
- * path: string,
164
- * slug: string,
165
- * appName: string,
166
- * siteUrl: string | null,
167
- * lastBuild: string | null,
168
- * validation: {
169
- * valid: boolean,
170
- * exists: boolean,
171
- * hasConfig: boolean,
172
- * configValid: boolean,
173
- * currentSlug: string | null,
174
- * currentAppName: string | null,
175
- * errors: string[]
176
- * }
177
- * }>}
178
- */
179
- export function getLinkedProjectsWithStatus() {
180
- const projects = getLinkedProjects();
181
-
182
- return projects.map(project => ({
183
- ...project,
184
- validation: validateLinkedProject(project)
185
- }));
186
- }
187
-
188
- /**
189
- * Find a linked project by slug.
190
- *
191
- * @param {string} slug - The app slug to find
192
- * @returns {{
193
- * path: string,
194
- * slug: string,
195
- * appName: string,
196
- * siteUrl: string | null,
197
- * lastBuild: string | null
198
- * } | null}
199
- */
200
- export function findLinkedProject(slug) {
201
- const projects = getLinkedProjects();
202
- return projects.find(p => p.slug === slug) || null;
203
- }
204
-
205
- /**
206
- * Find a linked project by path.
207
- *
208
- * @param {string} projectPath - The project path to find
209
- * @returns {{
210
- * path: string,
211
- * slug: string,
212
- * appName: string,
213
- * siteUrl: string | null,
214
- * lastBuild: string | null
215
- * } | null}
216
- */
217
- export function findLinkedProjectByPath(projectPath) {
218
- const projects = getLinkedProjects();
219
- const normalizedPath = resolve(projectPath);
220
- return projects.find(p => resolve(p.path) === normalizedPath) || null;
221
- }
222
-
223
- /**
224
- * Link a Vue project to the CLI (stored globally).
225
- *
226
- * @param {string} projectPath - Path to the Vue project
227
- * @returns {{
228
- * success: boolean,
229
- * project: {
230
- * path: string,
231
- * slug: string,
232
- * appName: string,
233
- * appDescription: string | null,
234
- * appIconId: string | null,
235
- * siteUrl: string | null,
236
- * lastBuild: string | null
237
- * } | null,
238
- * error: string | null,
239
- * updated: boolean
240
- * }}
241
- */
242
- export function linkVueProject(projectPath) {
243
- const normalizedPath = resolve(projectPath);
244
-
245
- // Validate the path exists
246
- if (!existsSync(normalizedPath)) {
247
- return {
248
- success: false,
249
- project: null,
250
- error: `Path does not exist: ${normalizedPath}`,
251
- updated: false
252
- };
253
- }
254
-
255
- // Check if package.json exists (basic Vue project check)
256
- const packageJsonPath = resolve(normalizedPath, 'package.json');
257
- if (!existsSync(packageJsonPath)) {
258
- return {
259
- success: false,
260
- project: null,
261
- error: `Not a valid project directory (no package.json found): ${normalizedPath}`,
262
- updated: false
263
- };
264
- }
265
-
266
- // Read and validate Vue config
267
- const vueConfig = readVueConfig(normalizedPath);
268
- if (!vueConfig.found) {
269
- return {
270
- success: false,
271
- project: null,
272
- error: vueConfig.errors.join('\n'),
273
- updated: false
274
- };
275
- }
276
-
277
- if (vueConfig.errors.length > 0) {
278
- return {
279
- success: false,
280
- project: null,
281
- error: `Config validation errors:\n${vueConfig.errors.join('\n')}`,
282
- updated: false
283
- };
284
- }
285
-
286
- // Check if already linked
287
- const existing = findLinkedProjectByPath(normalizedPath);
288
- if (existing) {
289
- // Update existing entry
290
- const projects = getLinkedProjects();
291
- const index = projects.findIndex(p => resolve(p.path) === normalizedPath);
292
- projects[index] = {
293
- ...projects[index],
294
- slug: vueConfig.slug,
295
- appName: vueConfig.appName,
296
- appDescription: vueConfig.appDescription,
297
- appIconId: vueConfig.appIconId,
298
- siteUrl: vueConfig.siteUrl
299
- };
300
-
301
- const saveResult = saveLinkedProjects(projects);
302
- if (!saveResult.success) {
303
- return {
304
- success: false,
305
- project: null,
306
- error: saveResult.error,
307
- updated: false
308
- };
309
- }
310
-
311
- return {
312
- success: true,
313
- project: projects[index],
314
- error: null,
315
- updated: true
316
- };
317
- }
318
-
319
- // Check if slug conflicts with another linked project
320
- const conflicting = findLinkedProject(vueConfig.slug);
321
- if (conflicting) {
322
- return {
323
- success: false,
324
- project: null,
325
- error: `Slug '${vueConfig.slug}' is already used by another linked project: ${conflicting.path}`,
326
- updated: false
327
- };
328
- }
329
-
330
- // Create new linked project entry
331
- const newProject = {
332
- path: normalizedPath,
333
- slug: vueConfig.slug,
334
- appName: vueConfig.appName,
335
- appDescription: vueConfig.appDescription,
336
- appIconId: vueConfig.appIconId,
337
- siteUrl: vueConfig.siteUrl,
338
- lastBuild: null
339
- };
340
-
341
- // Add to linked projects
342
- const projects = getLinkedProjects();
343
- projects.push(newProject);
344
-
345
- const saveResult = saveLinkedProjects(projects);
346
- if (!saveResult.success) {
347
- return {
348
- success: false,
349
- project: null,
350
- error: saveResult.error,
351
- updated: false
352
- };
353
- }
354
-
355
- return {
356
- success: true,
357
- project: newProject,
358
- error: null,
359
- updated: false
360
- };
361
- }
362
-
363
- /**
364
- * Unlink a Vue project from the CLI.
365
- *
366
- * @param {string} projectPathOrSlug - Path to the Vue project or its slug
367
- * @returns {{
368
- * success: boolean,
369
- * project: {
370
- * path: string,
371
- * slug: string,
372
- * appName: string
373
- * } | null,
374
- * error: string | null
375
- * }}
376
- */
377
- export function unlinkVueProject(projectPathOrSlug) {
378
- const projects = getLinkedProjects();
379
-
380
- // Try to find by path first
381
- let index = projects.findIndex(p => resolve(p.path) === resolve(projectPathOrSlug));
382
-
383
- // If not found by path, try by slug
384
- if (index === -1) {
385
- index = projects.findIndex(p => p.slug === projectPathOrSlug);
386
- }
387
-
388
- if (index === -1) {
389
- return {
390
- success: false,
391
- project: null,
392
- error: `No linked project found for: ${projectPathOrSlug}`
393
- };
394
- }
395
-
396
- // Remove from list
397
- const [removed] = projects.splice(index, 1);
398
- const saveResult = saveLinkedProjects(projects);
399
-
400
- if (!saveResult.success) {
401
- return {
402
- success: false,
403
- project: null,
404
- error: saveResult.error
405
- };
406
- }
407
-
408
- return {
409
- success: true,
410
- project: removed,
411
- error: null
412
- };
413
- }
414
-
415
- /**
416
- * Remove all invalid (non-existent paths) linked projects.
417
- *
418
- * @returns {{
419
- * success: boolean,
420
- * removed: number,
421
- * projects: Array<{path: string, slug: string, appName: string}>,
422
- * error: string | null
423
- * }}
424
- */
425
- export function cleanupInvalidProjects() {
426
- const projects = getLinkedProjects();
427
- const validProjects = [];
428
- const removedProjects = [];
429
-
430
- for (const project of projects) {
431
- if (existsSync(project.path)) {
432
- validProjects.push(project);
433
- } else {
434
- removedProjects.push(project);
435
- }
436
- }
437
-
438
- if (removedProjects.length > 0) {
439
- const saveResult = saveLinkedProjects(validProjects);
440
- if (!saveResult.success) {
441
- return {
442
- success: false,
443
- removed: 0,
444
- projects: [],
445
- error: saveResult.error
446
- };
447
- }
448
- }
449
-
450
- return {
451
- success: true,
452
- removed: removedProjects.length,
453
- projects: removedProjects,
454
- error: null
455
- };
456
- }
457
-
458
- /**
459
- * Update the last build timestamp for a linked project.
460
- *
461
- * @param {string} slug - The app slug
462
- * @returns {{success: boolean, error: string | null}}
463
- */
464
- export function updateLastBuild(slug) {
465
- const projects = getLinkedProjects();
466
- const index = projects.findIndex(p => p.slug === slug);
467
-
468
- if (index === -1) {
469
- return { success: false, error: 'Project not found' };
470
- }
471
-
472
- projects[index].lastBuild = new Date().toISOString();
473
- const saveResult = saveLinkedProjects(projects);
474
-
475
- return saveResult;
476
- }
477
-
478
- /**
479
- * Format linked projects list for display.
480
- *
481
- * @param {Array} projects - Array of linked projects (with or without validation)
482
- * @returns {string} - Formatted string for display
483
- */
484
- export function formatLinkedProjects(projects) {
485
- if (!projects || projects.length === 0) {
486
- return 'No Vue projects linked.\n\nUse `magentrix iris-app-link` to link a project.';
487
- }
488
-
489
- const lines = [
490
- 'Linked Vue Projects',
491
- '────────────────────────────────────────────────────',
492
- ''
493
- ];
494
-
495
- for (const project of projects) {
496
- const validation = project.validation || validateLinkedProject(project);
497
-
498
- // Status indicator
499
- let statusIcon = '✓';
500
- let statusColor = '\x1b[32m'; // green
501
- if (!validation.valid) {
502
- statusIcon = '⚠';
503
- statusColor = '\x1b[33m'; // yellow
504
- }
505
- if (!validation.exists) {
506
- statusIcon = '✗';
507
- statusColor = '\x1b[31m'; // red
508
- }
509
-
510
- const displayName = validation.currentAppName || project.appName;
511
- const displaySlug = validation.currentSlug || project.slug;
512
-
513
- lines.push(` ${statusColor}${statusIcon}\x1b[0m ${displayName} (${displaySlug})`);
514
- lines.push(` Path: ${project.path}`);
515
-
516
- if (project.siteUrl) {
517
- lines.push(` Site: ${project.siteUrl}`);
518
- }
519
-
520
- if (project.lastBuild) {
521
- const date = new Date(project.lastBuild);
522
- lines.push(` Last build: ${date.toLocaleString()}`);
523
- } else {
524
- lines.push(` Last build: Never`);
525
- }
526
-
527
- // Show validation errors
528
- if (!validation.valid && validation.errors.length > 0) {
529
- lines.push(` \x1b[33mWarning: ${validation.errors.join(', ')}\x1b[0m`);
530
- }
531
-
532
- lines.push('');
533
- }
534
-
535
- return lines.join('\n');
536
- }
537
-
538
- /**
539
- * Build choices array for inquirer select prompt.
540
- *
541
- * @param {Object} options - Options for building choices
542
- * @param {boolean} options.includeManual - Include "Enter path manually" option
543
- * @param {boolean} options.includeCancel - Include "Cancel" option
544
- * @param {boolean} options.showInvalid - Show invalid projects (disabled)
545
- * @returns {Array} - Choices array for select prompt
546
- */
547
- export function buildProjectChoices(options = {}) {
548
- const {
549
- includeManual = true,
550
- includeCancel = true,
551
- showInvalid = true
552
- } = options;
553
-
554
- const projectsWithStatus = getLinkedProjectsWithStatus();
555
- const choices = [];
556
-
557
- // Add valid projects first
558
- for (const project of projectsWithStatus) {
559
- if (project.validation.valid) {
560
- const displayName = project.validation.currentAppName || project.appName;
561
- const displaySlug = project.validation.currentSlug || project.slug;
562
-
563
- choices.push({
564
- name: `${displayName} (${displaySlug})`,
565
- value: { type: 'linked', path: project.path, slug: displaySlug }
566
- });
567
- }
568
- }
569
-
570
- // Add invalid projects (disabled) if requested
571
- if (showInvalid) {
572
- for (const project of projectsWithStatus) {
573
- if (!project.validation.valid) {
574
- const errorMsg = project.validation.errors[0] || 'Invalid';
575
- choices.push({
576
- name: `⚠ ${project.appName} (${project.slug})`,
577
- value: { type: 'invalid', path: project.path },
578
- disabled: errorMsg
579
- });
580
- }
581
- }
582
- }
583
-
584
- // Add manual entry option
585
- if (includeManual) {
586
- choices.push({
587
- name: 'Enter path manually',
588
- value: { type: 'manual' }
589
- });
590
- }
591
-
592
- // Add cancel option
593
- if (includeCancel) {
594
- choices.push({
595
- name: 'Cancel',
596
- value: { type: 'cancel' }
597
- });
598
- }
599
-
600
- return choices;
601
- }
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve, basename } from 'node:path';
3
+ import Config from '../config.js';
4
+ import { readVueConfig } from './config-reader.js';
5
+ import { validateSlug } from './validation.js';
6
+ import { formatError, formatCorruptedConfigError, detectErrorType, ErrorTypes } from './errors.js';
7
+
8
+ const config = new Config();
9
+
10
+ /**
11
+ * Safe wrapper for reading config that handles corruption.
12
+ *
13
+ * @param {string} key - Config key to read
14
+ * @param {Object} options - Config options
15
+ * @returns {{success: boolean, data: any, error: string | null}}
16
+ */
17
+ function safeConfigRead(key, options) {
18
+ try {
19
+ const data = config.read(key, options);
20
+ return { success: true, data, error: null };
21
+ } catch (err) {
22
+ const errorType = detectErrorType(err);
23
+ if (errorType === ErrorTypes.CORRUPTED) {
24
+ return {
25
+ success: false,
26
+ data: null,
27
+ error: formatCorruptedConfigError({
28
+ configPath: config.getGlobalConfigPath?.() || '~/.config/magentrix/config.json',
29
+ error: err
30
+ })
31
+ };
32
+ }
33
+ return { success: false, data: null, error: err.message };
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Safe wrapper for saving config that handles errors.
39
+ *
40
+ * @param {string} key - Config key to save
41
+ * @param {any} value - Value to save
42
+ * @param {Object} options - Config options
43
+ * @returns {{success: boolean, error: string | null}}
44
+ */
45
+ function safeConfigSave(key, value, options) {
46
+ try {
47
+ config.save(key, value, options);
48
+ return { success: true, error: null };
49
+ } catch (err) {
50
+ const errorType = detectErrorType(err);
51
+ if (errorType === ErrorTypes.PERMISSION) {
52
+ return {
53
+ success: false,
54
+ error: `Permission denied saving config. Check file permissions for the global config directory.`
55
+ };
56
+ }
57
+ if (errorType === ErrorTypes.DISK_FULL) {
58
+ return {
59
+ success: false,
60
+ error: `Disk full - cannot save config. Free up disk space and try again.`
61
+ };
62
+ }
63
+ return { success: false, error: `Failed to save config: ${err.message}` };
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Get all linked Vue projects from global config.
69
+ *
70
+ * @returns {Array<{
71
+ * path: string,
72
+ * slug: string,
73
+ * appName: string,
74
+ * siteUrl: string | null,
75
+ * lastBuild: string | null
76
+ * }>}
77
+ */
78
+ export function getLinkedProjects() {
79
+ const result = safeConfigRead('linkedVueProjects', {
80
+ global: true // Store globally so projects persist across Magentrix workspaces
81
+ });
82
+
83
+ if (!result.success) {
84
+ // Log error but return empty array to allow graceful degradation
85
+ console.error(result.error);
86
+ return [];
87
+ }
88
+
89
+ return result.data || [];
90
+ }
91
+
92
+ /**
93
+ * Save linked projects to global config.
94
+ *
95
+ * @param {Array} projects - Array of linked projects
96
+ * @returns {{success: boolean, error: string | null}}
97
+ */
98
+ function saveLinkedProjects(projects) {
99
+ return safeConfigSave('linkedVueProjects', projects, {
100
+ global: true
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Validate a linked project and return its current status.
106
+ *
107
+ * @param {{path: string, slug: string, appName: string}} project - Project to validate
108
+ * @returns {{
109
+ * valid: boolean,
110
+ * exists: boolean,
111
+ * hasConfig: boolean,
112
+ * configValid: boolean,
113
+ * currentSlug: string | null,
114
+ * currentAppName: string | null,
115
+ * errors: string[]
116
+ * }}
117
+ */
118
+ export function validateLinkedProject(project) {
119
+ const result = {
120
+ valid: false,
121
+ exists: false,
122
+ hasConfig: false,
123
+ configValid: false,
124
+ currentSlug: null,
125
+ currentAppName: null,
126
+ errors: []
127
+ };
128
+
129
+ // Check if path exists
130
+ if (!existsSync(project.path)) {
131
+ result.errors.push('Path no longer exists');
132
+ return result;
133
+ }
134
+ result.exists = true;
135
+
136
+ // Check if it's still a valid Vue project
137
+ const vueConfig = readVueConfig(project.path);
138
+ if (!vueConfig.found) {
139
+ result.errors.push('No config.ts found');
140
+ return result;
141
+ }
142
+ result.hasConfig = true;
143
+
144
+ // Check for config errors
145
+ if (vueConfig.errors.length > 0) {
146
+ result.errors.push(...vueConfig.errors);
147
+ return result;
148
+ }
149
+ result.configValid = true;
150
+
151
+ // Store current values (might have changed)
152
+ result.currentSlug = vueConfig.slug;
153
+ result.currentAppName = vueConfig.appName;
154
+ result.valid = true;
155
+
156
+ return result;
157
+ }
158
+
159
+ /**
160
+ * Get all linked projects with their current validation status.
161
+ *
162
+ * @returns {Array<{
163
+ * path: string,
164
+ * slug: string,
165
+ * appName: string,
166
+ * siteUrl: string | null,
167
+ * lastBuild: string | null,
168
+ * validation: {
169
+ * valid: boolean,
170
+ * exists: boolean,
171
+ * hasConfig: boolean,
172
+ * configValid: boolean,
173
+ * currentSlug: string | null,
174
+ * currentAppName: string | null,
175
+ * errors: string[]
176
+ * }
177
+ * }>}
178
+ */
179
+ export function getLinkedProjectsWithStatus() {
180
+ const projects = getLinkedProjects();
181
+
182
+ return projects.map(project => ({
183
+ ...project,
184
+ validation: validateLinkedProject(project)
185
+ }));
186
+ }
187
+
188
+ /**
189
+ * Find a linked project by slug.
190
+ *
191
+ * @param {string} slug - The app slug to find
192
+ * @returns {{
193
+ * path: string,
194
+ * slug: string,
195
+ * appName: string,
196
+ * siteUrl: string | null,
197
+ * lastBuild: string | null
198
+ * } | null}
199
+ */
200
+ export function findLinkedProject(slug) {
201
+ const projects = getLinkedProjects();
202
+ return projects.find(p => p.slug === slug) || null;
203
+ }
204
+
205
+ /**
206
+ * Find a linked project by path.
207
+ *
208
+ * @param {string} projectPath - The project path to find
209
+ * @returns {{
210
+ * path: string,
211
+ * slug: string,
212
+ * appName: string,
213
+ * siteUrl: string | null,
214
+ * lastBuild: string | null
215
+ * } | null}
216
+ */
217
+ export function findLinkedProjectByPath(projectPath) {
218
+ const projects = getLinkedProjects();
219
+ const normalizedPath = resolve(projectPath);
220
+ return projects.find(p => resolve(p.path) === normalizedPath) || null;
221
+ }
222
+
223
+ /**
224
+ * Link a Vue project to the CLI (stored globally).
225
+ *
226
+ * @param {string} projectPath - Path to the Vue project
227
+ * @returns {{
228
+ * success: boolean,
229
+ * project: {
230
+ * path: string,
231
+ * slug: string,
232
+ * appName: string,
233
+ * appDescription: string | null,
234
+ * appIconId: string | null,
235
+ * siteUrl: string | null,
236
+ * lastBuild: string | null
237
+ * } | null,
238
+ * error: string | null,
239
+ * updated: boolean
240
+ * }}
241
+ */
242
+ export function linkVueProject(projectPath) {
243
+ const normalizedPath = resolve(projectPath);
244
+
245
+ // Validate the path exists
246
+ if (!existsSync(normalizedPath)) {
247
+ return {
248
+ success: false,
249
+ project: null,
250
+ error: `Path does not exist: ${normalizedPath}`,
251
+ updated: false
252
+ };
253
+ }
254
+
255
+ // Check if package.json exists (basic Vue project check)
256
+ const packageJsonPath = resolve(normalizedPath, 'package.json');
257
+ if (!existsSync(packageJsonPath)) {
258
+ return {
259
+ success: false,
260
+ project: null,
261
+ error: `Not a valid project directory (no package.json found): ${normalizedPath}`,
262
+ updated: false
263
+ };
264
+ }
265
+
266
+ // Read and validate Vue config
267
+ const vueConfig = readVueConfig(normalizedPath);
268
+ if (!vueConfig.found) {
269
+ return {
270
+ success: false,
271
+ project: null,
272
+ error: vueConfig.errors.join('\n'),
273
+ updated: false
274
+ };
275
+ }
276
+
277
+ if (vueConfig.errors.length > 0) {
278
+ return {
279
+ success: false,
280
+ project: null,
281
+ error: `Config validation errors:\n${vueConfig.errors.join('\n')}`,
282
+ updated: false
283
+ };
284
+ }
285
+
286
+ // Check if already linked
287
+ const existing = findLinkedProjectByPath(normalizedPath);
288
+ if (existing) {
289
+ // Update existing entry
290
+ const projects = getLinkedProjects();
291
+ const index = projects.findIndex(p => resolve(p.path) === normalizedPath);
292
+ projects[index] = {
293
+ ...projects[index],
294
+ slug: vueConfig.slug,
295
+ appName: vueConfig.appName,
296
+ appDescription: vueConfig.appDescription,
297
+ appIconId: vueConfig.appIconId,
298
+ siteUrl: vueConfig.siteUrl
299
+ };
300
+
301
+ const saveResult = saveLinkedProjects(projects);
302
+ if (!saveResult.success) {
303
+ return {
304
+ success: false,
305
+ project: null,
306
+ error: saveResult.error,
307
+ updated: false
308
+ };
309
+ }
310
+
311
+ return {
312
+ success: true,
313
+ project: projects[index],
314
+ error: null,
315
+ updated: true
316
+ };
317
+ }
318
+
319
+ // Check if slug conflicts with another linked project
320
+ const conflicting = findLinkedProject(vueConfig.slug);
321
+ if (conflicting) {
322
+ return {
323
+ success: false,
324
+ project: null,
325
+ error: `Slug '${vueConfig.slug}' is already used by another linked project: ${conflicting.path}`,
326
+ updated: false
327
+ };
328
+ }
329
+
330
+ // Create new linked project entry
331
+ const newProject = {
332
+ path: normalizedPath,
333
+ slug: vueConfig.slug,
334
+ appName: vueConfig.appName,
335
+ appDescription: vueConfig.appDescription,
336
+ appIconId: vueConfig.appIconId,
337
+ siteUrl: vueConfig.siteUrl,
338
+ lastBuild: null
339
+ };
340
+
341
+ // Add to linked projects
342
+ const projects = getLinkedProjects();
343
+ projects.push(newProject);
344
+
345
+ const saveResult = saveLinkedProjects(projects);
346
+ if (!saveResult.success) {
347
+ return {
348
+ success: false,
349
+ project: null,
350
+ error: saveResult.error,
351
+ updated: false
352
+ };
353
+ }
354
+
355
+ return {
356
+ success: true,
357
+ project: newProject,
358
+ error: null,
359
+ updated: false
360
+ };
361
+ }
362
+
363
+ /**
364
+ * Unlink a Vue project from the CLI.
365
+ *
366
+ * @param {string} projectPathOrSlug - Path to the Vue project or its slug
367
+ * @returns {{
368
+ * success: boolean,
369
+ * project: {
370
+ * path: string,
371
+ * slug: string,
372
+ * appName: string
373
+ * } | null,
374
+ * error: string | null
375
+ * }}
376
+ */
377
+ export function unlinkVueProject(projectPathOrSlug) {
378
+ const projects = getLinkedProjects();
379
+
380
+ // Try to find by path first
381
+ let index = projects.findIndex(p => resolve(p.path) === resolve(projectPathOrSlug));
382
+
383
+ // If not found by path, try by slug
384
+ if (index === -1) {
385
+ index = projects.findIndex(p => p.slug === projectPathOrSlug);
386
+ }
387
+
388
+ if (index === -1) {
389
+ return {
390
+ success: false,
391
+ project: null,
392
+ error: `No linked project found for: ${projectPathOrSlug}`
393
+ };
394
+ }
395
+
396
+ // Remove from list
397
+ const [removed] = projects.splice(index, 1);
398
+ const saveResult = saveLinkedProjects(projects);
399
+
400
+ if (!saveResult.success) {
401
+ return {
402
+ success: false,
403
+ project: null,
404
+ error: saveResult.error
405
+ };
406
+ }
407
+
408
+ return {
409
+ success: true,
410
+ project: removed,
411
+ error: null
412
+ };
413
+ }
414
+
415
+ /**
416
+ * Remove all invalid (non-existent paths) linked projects.
417
+ *
418
+ * @returns {{
419
+ * success: boolean,
420
+ * removed: number,
421
+ * projects: Array<{path: string, slug: string, appName: string}>,
422
+ * error: string | null
423
+ * }}
424
+ */
425
+ export function cleanupInvalidProjects() {
426
+ const projects = getLinkedProjects();
427
+ const validProjects = [];
428
+ const removedProjects = [];
429
+
430
+ for (const project of projects) {
431
+ if (existsSync(project.path)) {
432
+ validProjects.push(project);
433
+ } else {
434
+ removedProjects.push(project);
435
+ }
436
+ }
437
+
438
+ if (removedProjects.length > 0) {
439
+ const saveResult = saveLinkedProjects(validProjects);
440
+ if (!saveResult.success) {
441
+ return {
442
+ success: false,
443
+ removed: 0,
444
+ projects: [],
445
+ error: saveResult.error
446
+ };
447
+ }
448
+ }
449
+
450
+ return {
451
+ success: true,
452
+ removed: removedProjects.length,
453
+ projects: removedProjects,
454
+ error: null
455
+ };
456
+ }
457
+
458
+ /**
459
+ * Update the last build timestamp for a linked project.
460
+ *
461
+ * @param {string} slug - The app slug
462
+ * @returns {{success: boolean, error: string | null}}
463
+ */
464
+ export function updateLastBuild(slug) {
465
+ const projects = getLinkedProjects();
466
+ const index = projects.findIndex(p => p.slug === slug);
467
+
468
+ if (index === -1) {
469
+ return { success: false, error: 'Project not found' };
470
+ }
471
+
472
+ projects[index].lastBuild = new Date().toISOString();
473
+ const saveResult = saveLinkedProjects(projects);
474
+
475
+ return saveResult;
476
+ }
477
+
478
+ /**
479
+ * Format linked projects list for display.
480
+ *
481
+ * @param {Array} projects - Array of linked projects (with or without validation)
482
+ * @returns {string} - Formatted string for display
483
+ */
484
+ export function formatLinkedProjects(projects) {
485
+ if (!projects || projects.length === 0) {
486
+ return 'No Vue projects linked.\n\nUse `magentrix iris-app-link` to link a project.';
487
+ }
488
+
489
+ const lines = [
490
+ 'Linked Vue Projects',
491
+ '────────────────────────────────────────────────────',
492
+ ''
493
+ ];
494
+
495
+ for (const project of projects) {
496
+ const validation = project.validation || validateLinkedProject(project);
497
+
498
+ // Status indicator
499
+ let statusIcon = '✓';
500
+ let statusColor = '\x1b[32m'; // green
501
+ if (!validation.valid) {
502
+ statusIcon = '⚠';
503
+ statusColor = '\x1b[33m'; // yellow
504
+ }
505
+ if (!validation.exists) {
506
+ statusIcon = '✗';
507
+ statusColor = '\x1b[31m'; // red
508
+ }
509
+
510
+ const displayName = validation.currentAppName || project.appName;
511
+ const displaySlug = validation.currentSlug || project.slug;
512
+
513
+ lines.push(` ${statusColor}${statusIcon}\x1b[0m ${displayName} (${displaySlug})`);
514
+ lines.push(` Path: ${project.path}`);
515
+
516
+ if (project.siteUrl) {
517
+ lines.push(` Site: ${project.siteUrl}`);
518
+ }
519
+
520
+ if (project.lastBuild) {
521
+ const date = new Date(project.lastBuild);
522
+ lines.push(` Last build: ${date.toLocaleString()}`);
523
+ } else {
524
+ lines.push(` Last build: Never`);
525
+ }
526
+
527
+ // Show validation errors
528
+ if (!validation.valid && validation.errors.length > 0) {
529
+ lines.push(` \x1b[33mWarning: ${validation.errors.join(', ')}\x1b[0m`);
530
+ }
531
+
532
+ lines.push('');
533
+ }
534
+
535
+ return lines.join('\n');
536
+ }
537
+
538
+ /**
539
+ * Build choices array for inquirer select prompt.
540
+ *
541
+ * @param {Object} options - Options for building choices
542
+ * @param {boolean} options.includeManual - Include "Enter path manually" option
543
+ * @param {boolean} options.includeCancel - Include "Cancel" option
544
+ * @param {boolean} options.showInvalid - Show invalid projects (disabled)
545
+ * @returns {Array} - Choices array for select prompt
546
+ */
547
+ export function buildProjectChoices(options = {}) {
548
+ const {
549
+ includeManual = true,
550
+ includeCancel = true,
551
+ showInvalid = true
552
+ } = options;
553
+
554
+ const projectsWithStatus = getLinkedProjectsWithStatus();
555
+ const choices = [];
556
+
557
+ // Add valid projects first
558
+ for (const project of projectsWithStatus) {
559
+ if (project.validation.valid) {
560
+ const displayName = project.validation.currentAppName || project.appName;
561
+ const displaySlug = project.validation.currentSlug || project.slug;
562
+
563
+ choices.push({
564
+ name: `${displayName} (${displaySlug})`,
565
+ value: { type: 'linked', path: project.path, slug: displaySlug }
566
+ });
567
+ }
568
+ }
569
+
570
+ // Add invalid projects (disabled) if requested
571
+ if (showInvalid) {
572
+ for (const project of projectsWithStatus) {
573
+ if (!project.validation.valid) {
574
+ const errorMsg = project.validation.errors[0] || 'Invalid';
575
+ choices.push({
576
+ name: `⚠ ${project.appName} (${project.slug})`,
577
+ value: { type: 'invalid', path: project.path },
578
+ disabled: errorMsg
579
+ });
580
+ }
581
+ }
582
+ }
583
+
584
+ // Add manual entry option
585
+ if (includeManual) {
586
+ choices.push({
587
+ name: 'Enter path manually',
588
+ value: { type: 'manual' }
589
+ });
590
+ }
591
+
592
+ // Add cancel option
593
+ if (includeCancel) {
594
+ choices.push({
595
+ name: 'Cancel',
596
+ value: { type: 'cancel' }
597
+ });
598
+ }
599
+
600
+ return choices;
601
+ }