@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.
package/actions/setup.js CHANGED
@@ -4,6 +4,7 @@ import Config from "../utils/config.js";
4
4
  import { getAccessToken, tryAuthenticate } from "../utils/magentrix/api/auth.js";
5
5
  import { ensureVSCodeFileAssociation } from "../utils/preferences.js";
6
6
  import { EXPORT_ROOT, HASHED_CWD } from "../vars/global.js";
7
+ import { select } from "@inquirer/prompts";
7
8
 
8
9
  const config = new Config();
9
10
 
@@ -35,24 +36,70 @@ export const setup = async (cliOptions = {}) => {
35
36
  }
36
37
 
37
38
  if (cliOptions.instanceUrl) {
38
- const trimmedUrl = cliOptions.instanceUrl.trim();
39
+ let trimmedUrl = cliOptions.instanceUrl.trim();
40
+ // Automatically strip trailing slashes
41
+ trimmedUrl = trimmedUrl.replace(/\/+$/, '');
42
+
39
43
  if (!urlRegex.test(trimmedUrl)) {
40
- throw new Error('--instance-url must be in the form: https://subdomain.domain.com (NO http, NO trailing /, NO extra path)');
44
+ throw new Error('--instance-url must be in the form: https://subdomain.domain.com (no http, no extra path)');
41
45
  }
46
+ // Update the option with the cleaned URL
47
+ cliOptions.instanceUrl = trimmedUrl;
42
48
  }
43
49
 
44
- // Get API key (from CLI or prompt)
45
- const apiKey = cliOptions.apiKey
46
- ? cliOptions.apiKey.trim()
47
- : await ensureApiKey(true);
48
-
49
- // Get instance URL (from CLI or prompt)
50
- const instanceUrl = cliOptions.instanceUrl
51
- ? cliOptions.instanceUrl.trim()
52
- : await ensureInstanceUrl(true);
53
-
54
- // Validate credentials by attempting to fetch an access token
55
- const tokenData = await tryAuthenticate(apiKey, instanceUrl);
50
+ // Retry loop for authentication
51
+ let authSuccess = false;
52
+ let apiKey, instanceUrl, tokenData;
53
+
54
+ while (!authSuccess) {
55
+ // Get API key (from CLI or prompt)
56
+ apiKey = cliOptions.apiKey
57
+ ? cliOptions.apiKey.trim()
58
+ : await ensureApiKey(true);
59
+
60
+ // Get instance URL (from CLI or prompt)
61
+ instanceUrl = cliOptions.instanceUrl
62
+ ? cliOptions.instanceUrl.trim()
63
+ : await ensureInstanceUrl(true);
64
+
65
+ // Validate credentials by attempting to fetch an access token
66
+ try {
67
+ tokenData = await tryAuthenticate(apiKey, instanceUrl);
68
+ authSuccess = true;
69
+ } catch (error) {
70
+ console.log(error.message);
71
+ console.log();
72
+
73
+ // Ask user if they want to retry
74
+ try {
75
+ const retry = await select({
76
+ message: 'What would you like to do?',
77
+ choices: [
78
+ { name: 'Try again', value: 'retry' },
79
+ { name: 'Exit setup', value: 'exit' }
80
+ ],
81
+ default: 'retry'
82
+ });
83
+
84
+ if (retry !== 'retry') {
85
+ console.log('❌ Setup cancelled.');
86
+ process.exit(0);
87
+ }
88
+
89
+ // Reset CLI options so user can re-enter them
90
+ cliOptions.apiKey = null;
91
+ cliOptions.instanceUrl = null;
92
+ console.log(); // Blank line for spacing
93
+ } catch (error) {
94
+ // Handle Ctrl+C or other cancellation
95
+ if (error.name === 'ExitPromptError') {
96
+ console.log('\n❌ Setup cancelled.');
97
+ process.exit(0);
98
+ }
99
+ throw error;
100
+ }
101
+ }
102
+ }
56
103
 
57
104
  console.log(); // Blank line for spacing
58
105
 
@@ -69,7 +116,7 @@ export const setup = async (cliOptions = {}) => {
69
116
  { global: true, pathHash: HASHED_CWD }
70
117
  );
71
118
 
72
- // Set up the editor
119
+ // Set up the editor
73
120
  await ensureVSCodeFileAssociation('./');
74
121
 
75
122
  console.log(); // Blank line for spacing
package/bin/magentrix.js CHANGED
@@ -15,6 +15,7 @@ import { EXPORT_ROOT } from '../vars/global.js';
15
15
  import { publish } from '../actions/publish.js';
16
16
  import { update } from '../actions/update.js';
17
17
  import { configWizard } from '../actions/config.js';
18
+ import { irisLink, irisDev, irisDelete, irisRecover, vueBuildStage } from '../actions/iris/index.js';
18
19
 
19
20
  // ── Middleware ────────────────────────────────
20
21
  async function preMiddleware() {
@@ -60,7 +61,12 @@ program
60
61
  { name: 'status', desc: 'Show file conflicts and sync status', icon: '📊 ' },
61
62
  { name: 'publish', desc: 'Publish pending changes to the remote server', icon: '📤 ' },
62
63
  { name: 'autopublish', desc: 'Watch & sync changes in real time', icon: '🔄 ' },
63
- { name: 'update', desc: 'Update MagentrixCLI to the latest version', icon: '⬆️ ' }
64
+ { name: 'update', desc: 'Update MagentrixCLI to the latest version', icon: '⬆️ ' },
65
+ { name: 'iris-link', desc: 'Link a Vue project to the CLI', icon: '🔗 ' },
66
+ { name: 'vue-build-stage', desc: 'Build Vue project and stage for publish', icon: '🏗️ ' },
67
+ { name: 'iris-dev', desc: 'Start Vue dev server with platform assets', icon: '🌐 ' },
68
+ { name: 'iris-delete', desc: 'Delete an Iris app with backup', icon: '🗑️ ' },
69
+ { name: 'iris-recover', desc: 'Recover a deleted Iris app from backup', icon: '♻️ ' }
64
70
  ];
65
71
 
66
72
  const maxNameLen = Math.max(...commands.map(c => c.name.length));
@@ -191,6 +197,42 @@ program
191
197
  .description('Configure CLI settings')
192
198
  .action(configWizard);
193
199
 
200
+ // Iris commands for Vue.js app management
201
+ program
202
+ .command('iris-link')
203
+ .description('Link a Vue project to the CLI for deployment')
204
+ .option('--path <path>', 'Path to the Vue project')
205
+ .option('--unlink', 'Unlink a project instead of linking')
206
+ .option('--list', 'List all linked projects')
207
+ .option('--cleanup', 'Remove invalid (non-existent) linked projects')
208
+ .action(irisLink);
209
+
210
+ program
211
+ .command('vue-build-stage')
212
+ .description('Build a Vue project and stage it for publishing')
213
+ .option('--path <path>', 'Path to the Vue project')
214
+ .option('--skip-build', 'Skip build step and use existing dist/')
215
+ .action(vueBuildStage);
216
+
217
+ program
218
+ .command('iris-dev')
219
+ .description('Start Vue dev server with platform assets injected')
220
+ .option('--path <path>', 'Path to the Vue project')
221
+ .option('--no-inject', 'Skip asset injection')
222
+ .option('--restore', 'Restore config.ts from backup')
223
+ .action(irisDev);
224
+
225
+ program
226
+ .command('iris-delete')
227
+ .description('Delete a published Iris app with recovery backup')
228
+ .action(irisDelete);
229
+
230
+ program
231
+ .command('iris-recover')
232
+ .description('Recover a deleted Iris app from backup')
233
+ .option('--list', 'List available recovery backups')
234
+ .action(irisRecover);
235
+
194
236
  // ── Unknown Command Handler ──────────────────
195
237
  program.argument('[command]', 'command to run').action((cmd) => {
196
238
  const runMain = async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magentrix-corp/magentrix-cli",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "CLI tool for synchronizing local files with Magentrix cloud platform",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -36,6 +36,7 @@
36
36
  ],
37
37
  "dependencies": {
38
38
  "@inquirer/prompts": "^7.6.0",
39
+ "archiver": "^7.0.1",
39
40
  "chalk": "^5.4.1",
40
41
  "chokidar": "^4.0.3",
41
42
  "commander": "^14.0.0",
@@ -0,0 +1,77 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ const LOCK_FILE_NAME = 'autopublish.lock';
5
+ const LOCK_EXPIRY_MS = 3600000; // 1 hour
6
+
7
+ /**
8
+ * Get the path to the autopublish lock file.
9
+ * @returns {string} - Path to lock file
10
+ */
11
+ export function getLockFilePath() {
12
+ return path.join(process.cwd(), '.magentrix', LOCK_FILE_NAME);
13
+ }
14
+
15
+ /**
16
+ * Check if autopublish is currently running.
17
+ * @returns {boolean} - True if autopublish is running (lock file exists and is not stale)
18
+ */
19
+ export function isAutopublishRunning() {
20
+ const lockFile = getLockFilePath();
21
+
22
+ if (!fs.existsSync(lockFile)) return false;
23
+
24
+ try {
25
+ const lockData = JSON.parse(fs.readFileSync(lockFile, 'utf-8'));
26
+ const lockAge = Date.now() - lockData.timestamp;
27
+
28
+ // If lock is older than 1 hour, consider it stale
29
+ return lockAge < LOCK_EXPIRY_MS;
30
+ } catch {
31
+ // If we can't read the lock file, assume it's not running
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Create a lock file to prevent multiple autopublish instances.
38
+ * @returns {boolean} - True if lock was acquired, false if already locked
39
+ */
40
+ export function acquireAutopublishLock() {
41
+ const lockFile = getLockFilePath();
42
+
43
+ try {
44
+ if (fs.existsSync(lockFile)) {
45
+ // Check if the lock is stale (process might have crashed)
46
+ if (isAutopublishRunning()) {
47
+ return false; // Lock is active
48
+ }
49
+ }
50
+
51
+ // Create lock file
52
+ fs.mkdirSync(path.dirname(lockFile), { recursive: true });
53
+ fs.writeFileSync(lockFile, JSON.stringify({
54
+ pid: process.pid,
55
+ timestamp: Date.now()
56
+ }));
57
+
58
+ return true;
59
+ } catch (err) {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Release the autopublish lock file.
66
+ */
67
+ export function releaseAutopublishLock() {
68
+ const lockFile = getLockFilePath();
69
+
70
+ try {
71
+ if (fs.existsSync(lockFile)) {
72
+ fs.unlinkSync(lockFile);
73
+ }
74
+ } catch {
75
+ // Ignore errors during cleanup
76
+ }
77
+ }
@@ -243,13 +243,12 @@ export async function showCurrentConflicts(rootDir, instanceUrl, token, forceCon
243
243
  export async function promptConflictResolution(fileIssues) {
244
244
  if (!fileIssues.length) return 'skip';
245
245
 
246
- // Clear for better UX (skip in test mode to avoid clearing test output)
247
- if (!process.env.MAGENTRIX_TEST_MODE) {
248
- console.clear();
249
- }
246
+ // Add spacing instead of clearing (clearing causes flickering with progress tracker)
247
+ console.log('\n');
248
+ console.log(chalk.gray('─'.repeat(48)));
250
249
  console.log(
251
250
  chalk.bold.yellow(
252
- `\n${fileIssues.length} file${fileIssues.length > 1 ? 's' : ''} require conflict resolution:\n`
251
+ `${fileIssues.length} file${fileIssues.length > 1 ? 's' : ''} require conflict resolution:\n`
253
252
  )
254
253
  );
255
254
 
@@ -1,4 +1,4 @@
1
- import prompts from "prompts";
1
+ import { password } from "@inquirer/prompts";
2
2
  import Config from "../../config.js";
3
3
  import { HASHED_CWD } from "../../../vars/global.js";
4
4
 
@@ -27,27 +27,33 @@ export const ensureApiKey = async (forcePrompt = false) => {
27
27
  console.log('\n🚀 Magentrix CLI Setup:');
28
28
 
29
29
  while (true) {
30
- const response = await prompts({
31
- type: 'password',
32
- name: 'apiKey',
33
- message: 'Enter your Magentrix API key (no spaces):',
34
- validate: value =>
35
- !value || value.trim().length < 12
36
- ? "API key must be at least 12 characters."
37
- : /\s/.test(value)
38
- ? "API key cannot contain any spaces."
39
- : true,
40
- });
41
-
42
- if (!response.apiKey) {
43
- console.error('❌ API key is required. Exiting.');
44
- process.exit(1);
45
- }
46
-
47
- const trimmed = response.apiKey.trim();
48
-
49
- if (isValid(trimmed)) {
50
- return trimmed;
30
+ try {
31
+ const apiKeyInput = await password({
32
+ message: 'Enter your Magentrix API key (no spaces):',
33
+ mask: '*',
34
+ validate: (value) => {
35
+ if (!value || value.trim().length < 12) {
36
+ return "API key must be at least 12 characters.";
37
+ }
38
+ if (/\s/.test(value)) {
39
+ return "API key cannot contain any spaces.";
40
+ }
41
+ return true;
42
+ }
43
+ });
44
+
45
+ const trimmed = apiKeyInput.trim();
46
+
47
+ if (isValid(trimmed)) {
48
+ return trimmed;
49
+ }
50
+ } catch (error) {
51
+ // Handle Ctrl+C or other cancellation
52
+ if (error.name === 'ExitPromptError') {
53
+ console.error('\n❌ API key is required. Exiting.');
54
+ process.exit(1);
55
+ }
56
+ throw error;
51
57
  }
52
58
  // Invalid: will re-prompt.
53
59
  }
@@ -1,4 +1,4 @@
1
- import prompts from "prompts";
1
+ import { input } from "@inquirer/prompts";
2
2
  import Config from "../../config.js";
3
3
  import { checkInstanceUrl } from "../checkInstanceUrl.js";
4
4
  import { withSpinner } from "../../spinner.js";
@@ -27,37 +27,45 @@ export const ensureInstanceUrl = async (forcePrompt = false) => {
27
27
  console.log('\n🚀 Magentrix CLI Setup: Instance URL');
28
28
 
29
29
  while (true) {
30
- // Prompt: Only validate format, NOT reachability
31
- const { instanceUrl: inputUrl } = await prompts({
32
- type: 'text',
33
- name: 'instanceUrl',
34
- message: 'Enter your Magentrix instance URL (e.g., https://example.magentrix.com):',
35
- validate: value =>
36
- urlRegex.test(value.trim())
37
- ? true
38
- : 'Instance URL must be in the form: https://subdomain.domain.com (NO http, NO trailing /, NO extra path)',
39
- });
40
-
41
- if (!inputUrl) {
42
- console.error('❌ Instance URL is required. Exiting.');
43
- process.exit(1);
44
- }
30
+ try {
31
+ // Prompt: Only validate format, NOT reachability
32
+ const inputUrl = await input({
33
+ message: 'Enter your Magentrix instance URL (e.g., https://example.magentrix.com):',
34
+ validate: (value) => {
35
+ // Automatically strip trailing slashes for validation
36
+ const cleaned = value.trim().replace(/\/+$/, '');
37
+ if (!urlRegex.test(cleaned)) {
38
+ return 'Instance URL must be in the form: https://subdomain.domain.com (no http, no extra path)';
39
+ }
40
+ return true;
41
+ }
42
+ });
45
43
 
46
- const trimmed = inputUrl.trim();
44
+ // Trim and automatically strip trailing slashes
45
+ const trimmed = inputUrl.trim().replace(/\/+$/, '');
47
46
 
48
- // Now check reachability WITH a spinner
49
- try {
50
- await withSpinner('Checking instance URL...', () =>
51
- checkInstanceUrl(trimmed)
52
- );
53
-
54
- return trimmed;
47
+ // Now check reachability WITH a spinner
48
+ try {
49
+ await withSpinner('Checking instance URL...', () =>
50
+ checkInstanceUrl(trimmed)
51
+ );
52
+
53
+ return trimmed;
54
+ } catch (error) {
55
+ // Print error AFTER spinner, then re-prompt
56
+ console.error('❌ Instance URL not reachable. Try again.');
57
+ }
55
58
  } catch (error) {
56
- // Print error AFTER spinner, then re-prompt
57
- console.error('❌ Instance URL not reachable. Try again.');
59
+ // Handle Ctrl+C or other cancellation
60
+ if (error.name === 'ExitPromptError') {
61
+ console.error('\n❌ Instance URL is required. Exiting.');
62
+ process.exit(1);
63
+ }
64
+ throw error;
58
65
  }
59
66
  }
60
67
  }
61
68
 
62
- return instanceUrl.trim();
69
+ // Trim and automatically strip trailing slashes from existing URL
70
+ return instanceUrl.trim().replace(/\/+$/, '');
63
71
  };
@@ -39,7 +39,18 @@ export const mapRecordToFile = (record) => {
39
39
  ? path.join(mapping.directory, foundPathByRecordId.split(mapping.directory)[1] || "")
40
40
  : "";
41
41
 
42
- const filePath = foundPathByRecordId ? relativeFoundPath : path.join(mapping.directory, filename);
42
+ // Fix for Bug 1: If the file exists but the name doesn't match the record name,
43
+ // we should treat it as a rename and use the new name.
44
+ let useExistingPath = false;
45
+ if (foundPathByRecordId) {
46
+ const existingName = path.basename(foundPathByRecordId);
47
+ // Check if existing name matches the expected filename (ignoring extension case if needed, but strict for now)
48
+ if (existingName === filename) {
49
+ useExistingPath = true;
50
+ }
51
+ }
52
+
53
+ const filePath = useExistingPath ? relativeFoundPath : path.join(mapping.directory, filename);
43
54
 
44
55
  return {
45
56
  ...record,
@@ -198,7 +209,7 @@ export const writeRecords = async (records, resolutionMethod, progress = null, l
198
209
  try {
199
210
  // Lookup a matching file dir in our cache, in case the file was renamed
200
211
  const cachedFilePath = findFileByTag(record.Id) || path.resolve(filePath);
201
-
212
+
202
213
  // Ensure the directory exists (paranoia for deeply nested files)
203
214
  fs.mkdirSync(path.dirname(cachedFilePath), { recursive: true });
204
215
  fs.writeFileSync(cachedFilePath, finalContent, "utf8");
package/utils/config.js CHANGED
@@ -200,6 +200,82 @@ class Config {
200
200
  }
201
201
  }
202
202
 
203
+ /**
204
+ * Remove multiple keys from config (global or project) in a single save operation.
205
+ *
206
+ * @param {string[]} keys - Array of keys to remove.
207
+ * @param {Object} opts
208
+ * @param {boolean} opts.global - If true, use global config. Otherwise, use project config.
209
+ * @param {string} [opts.pathHash] - Optional. Hash for namespaced global config.
210
+ * @param {string} [opts.filename] - Optional. Custom filename for project config.
211
+ */
212
+ removeKeys(keys, opts = {}) {
213
+ const isGlobal = opts.global === true;
214
+ const filename = opts.filename;
215
+ const pathHash = opts.pathHash;
216
+
217
+ if (!Array.isArray(keys) || keys.length === 0) return;
218
+
219
+ if (isGlobal) {
220
+ if (this._globalConfig === null) this._globalConfig = this._loadConfig('global');
221
+
222
+ let changed = false;
223
+ if (pathHash) {
224
+ if (!this._globalConfig[pathHash]) return;
225
+ for (const key of keys) {
226
+ if (key in this._globalConfig[pathHash]) {
227
+ delete this._globalConfig[pathHash][key];
228
+ changed = true;
229
+ }
230
+ }
231
+ } else {
232
+ for (const key of keys) {
233
+ if (key in this._globalConfig) {
234
+ delete this._globalConfig[key];
235
+ changed = true;
236
+ }
237
+ }
238
+ }
239
+
240
+ if (changed) {
241
+ this._saveConfig(this._globalConfig, 'global');
242
+ }
243
+ } else {
244
+ if (filename) {
245
+ const projectFolderPath = path.join(this.projectDir, this.projectFolder);
246
+ if (!fs.existsSync(projectFolderPath)) return;
247
+ const customPath = path.join(projectFolderPath, filename);
248
+ const customConfig = this._loadConfig('project', customPath) || {};
249
+
250
+ let changed = false;
251
+ for (const key of keys) {
252
+ if (key in customConfig) {
253
+ delete customConfig[key];
254
+ changed = true;
255
+ }
256
+ }
257
+
258
+ if (changed) {
259
+ this._saveConfig(customConfig, 'project', customPath);
260
+ }
261
+ } else {
262
+ if (this._projectConfig === null) this._projectConfig = this._loadConfig('project');
263
+
264
+ let changed = false;
265
+ for (const key of keys) {
266
+ if (key in this._projectConfig) {
267
+ delete this._projectConfig[key];
268
+ changed = true;
269
+ }
270
+ }
271
+
272
+ if (changed) {
273
+ this._saveConfig(this._projectConfig, 'project');
274
+ }
275
+ }
276
+ }
277
+ }
278
+
203
279
 
204
280
  /**
205
281
  * Save a key-value pair to config (global or project).