@magentrix-corp/magentrix-cli 1.0.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/LICENSE +25 -0
- package/README.md +471 -0
- package/actions/autopublish.js +283 -0
- package/actions/autopublish.old.js +293 -0
- package/actions/autopublish.v2.js +447 -0
- package/actions/create.js +329 -0
- package/actions/help.js +165 -0
- package/actions/main.js +81 -0
- package/actions/publish.js +567 -0
- package/actions/pull.js +139 -0
- package/actions/setup.js +61 -0
- package/actions/status.js +17 -0
- package/bin/magentrix.js +159 -0
- package/package.json +61 -0
- package/utils/cacher.js +112 -0
- package/utils/cli/checkInstanceUrl.js +29 -0
- package/utils/cli/helpers/compare.js +281 -0
- package/utils/cli/helpers/ensureApiKey.js +57 -0
- package/utils/cli/helpers/ensureCredentials.js +60 -0
- package/utils/cli/helpers/ensureInstanceUrl.js +63 -0
- package/utils/cli/writeRecords.js +223 -0
- package/utils/compare.js +135 -0
- package/utils/compress.js +18 -0
- package/utils/config.js +451 -0
- package/utils/diff.js +49 -0
- package/utils/downloadAssets.js +75 -0
- package/utils/filetag.js +115 -0
- package/utils/hash.js +14 -0
- package/utils/magentrix/api/assets.js +145 -0
- package/utils/magentrix/api/auth.js +56 -0
- package/utils/magentrix/api/createEntity.js +61 -0
- package/utils/magentrix/api/deleteEntity.js +55 -0
- package/utils/magentrix/api/meqlQuery.js +31 -0
- package/utils/magentrix/api/retrieveEntity.js +32 -0
- package/utils/magentrix/api/updateEntity.js +66 -0
- package/utils/magentrix/fetch.js +154 -0
- package/utils/merge.js +22 -0
- package/utils/preferences.js +40 -0
- package/utils/spinner.js +43 -0
- package/utils/template.js +52 -0
- package/utils/updateFileBase.js +103 -0
- package/vars/config.js +1 -0
- package/vars/global.js +33 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import chalk from 'chalk'; // For pretty colors, install with `npm install chalk`
|
|
3
|
+
import { compareLocalAndRemote } from '../../compare.js';
|
|
4
|
+
import inquirer from 'inquirer';
|
|
5
|
+
import { mapRecordToFile } from '../writeRecords.js';
|
|
6
|
+
import { withSpinner } from '../../spinner.js';
|
|
7
|
+
import { meqlQuery } from '../../magentrix/api/meqlQuery.js';
|
|
8
|
+
import readline from 'readline';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Logs a formatted, user-friendly status message for a single file, given its sync comparison result.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} relativePath - File path relative to root directory.
|
|
14
|
+
* @param {Object} statusResult - Result object from compareLocalAndRemote.
|
|
15
|
+
* @returns {boolean} True if an issue was found (not 'in_sync' or 'missing'), otherwise false.
|
|
16
|
+
*/
|
|
17
|
+
export function logFileStatus(relativePath, statusResult) {
|
|
18
|
+
const fileDisplay = chalk.bold.blue(relativePath);
|
|
19
|
+
|
|
20
|
+
switch (statusResult.status) {
|
|
21
|
+
case 'in_sync':
|
|
22
|
+
// Uncomment to show successes:
|
|
23
|
+
// console.log(chalk.green(`✓ ${fileDisplay} is up to date.`));
|
|
24
|
+
return false;
|
|
25
|
+
case 'behind':
|
|
26
|
+
console.log(
|
|
27
|
+
chalk.yellow(`⚠️ ${fileDisplay} is OUTDATED:`) +
|
|
28
|
+
`\n Local version is older than the server version, but contents are identical.\n` +
|
|
29
|
+
` Consider pulling the latest metadata from the server.`
|
|
30
|
+
);
|
|
31
|
+
return true;
|
|
32
|
+
case 'conflict':
|
|
33
|
+
console.log(
|
|
34
|
+
chalk.red(`🛑 ${fileDisplay} has a CONFLICT:`) +
|
|
35
|
+
`\n Local file is behind the remote, AND both were edited differently.\n` +
|
|
36
|
+
` Resolve this conflict before pushing or pulling.`
|
|
37
|
+
);
|
|
38
|
+
return true;
|
|
39
|
+
case 'ahead':
|
|
40
|
+
console.log(
|
|
41
|
+
chalk.yellow(`⚠️ ${fileDisplay} is AHEAD:`) +
|
|
42
|
+
`\n Local file was modified after the remote version, and contents differ.\n` +
|
|
43
|
+
` You may want to push your changes to the server.`
|
|
44
|
+
);
|
|
45
|
+
return true;
|
|
46
|
+
case 'ahead_identical':
|
|
47
|
+
console.log(
|
|
48
|
+
chalk.yellow(`⚠️ ${fileDisplay} is AHEAD (identical):`) +
|
|
49
|
+
`\n Local file has a newer timestamp, but contents match remote.\n` +
|
|
50
|
+
` This could be a system clock drift.`
|
|
51
|
+
);
|
|
52
|
+
return true;
|
|
53
|
+
case 'content_differs':
|
|
54
|
+
console.log(
|
|
55
|
+
chalk.red(`🛑 ${fileDisplay} CONTENT MISMATCH:`) +
|
|
56
|
+
`\n File timestamps match, but contents differ!\n` +
|
|
57
|
+
` This is unusual—please investigate.`
|
|
58
|
+
);
|
|
59
|
+
return true;
|
|
60
|
+
case 'missing':
|
|
61
|
+
// Not an error, file will be downloaded.
|
|
62
|
+
// Uncomment if you want to notify about downloads:
|
|
63
|
+
console.log(
|
|
64
|
+
chalk.yellow(`⚠️ ${fileDisplay} is MISSING:`) +
|
|
65
|
+
`\n Local file has been removed.`
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// console.log(chalk.cyan(`⬇️ ${fileDisplay} will be downloaded from the server.`));
|
|
69
|
+
return true;
|
|
70
|
+
default:
|
|
71
|
+
console.log(
|
|
72
|
+
chalk.magenta(`❓ ${fileDisplay} has an unknown status: ${statusResult.status}`)
|
|
73
|
+
);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Compares all local files in a specified root directory against their remote versions,
|
|
80
|
+
* then logs user-friendly, formatted status messages for any files that are not in sync.
|
|
81
|
+
*
|
|
82
|
+
* - Uses logFileStatus to handle message formatting for each file.
|
|
83
|
+
* - Only logs files that require attention; in-sync files are silent by default.
|
|
84
|
+
* - If no issues are found, logs a celebratory success message.
|
|
85
|
+
* - Intended for CLI status or sync-check commands.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} rootDir
|
|
88
|
+
* The local root directory where your files are stored (e.g., './export').
|
|
89
|
+
* @param {Array<Object>} fileRecords
|
|
90
|
+
* Array of remote file descriptors to compare. Each object should include:
|
|
91
|
+
* - {string} relativePath: File path relative to rootDir (e.g. 'Controllers/Foo.ctrl')
|
|
92
|
+
* - {string} content: Latest file content from the server
|
|
93
|
+
* - {string} ModifiedOn: Last modified date/time on server (ISO string)
|
|
94
|
+
*
|
|
95
|
+
* @returns {void}
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* const filesFromServer = [
|
|
99
|
+
* {
|
|
100
|
+
* relativePath: "Controllers/AccountController.ctrl",
|
|
101
|
+
* content: "...",
|
|
102
|
+
* ModifiedOn: "2025-07-10T18:00:00.000Z"
|
|
103
|
+
* },
|
|
104
|
+
* // ...
|
|
105
|
+
* ];
|
|
106
|
+
* compareAllFilesAndLogStatus('./export', filesFromServer);
|
|
107
|
+
*/
|
|
108
|
+
export function compareAllFilesAndLogStatus(rootDir, fileRecords) {
|
|
109
|
+
let numIssues = 0;
|
|
110
|
+
|
|
111
|
+
console.log();
|
|
112
|
+
|
|
113
|
+
for (const record of fileRecords) {
|
|
114
|
+
const localFilePath = path.join(rootDir, record.relativePath);
|
|
115
|
+
|
|
116
|
+
const result = compareLocalAndRemote(localFilePath, {
|
|
117
|
+
content: record.Content,
|
|
118
|
+
...record
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Use the single-file logger and aggregate if any issues found
|
|
122
|
+
const hadIssue = logFileStatus(record.relativePath, result);
|
|
123
|
+
if (hadIssue) numIssues++;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (numIssues === 0) {
|
|
127
|
+
console.log(chalk.green.bold('\n🎉 All files are up to date and in sync!\n'));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Checks local vs remote Magentrix file status and warns on conflicts/out-of-sync.
|
|
133
|
+
* If issues are found, blocks execution unless `forceContinue` is true.
|
|
134
|
+
* If not forced, user can press any key to continue, or ESC to abort.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} rootDir
|
|
137
|
+
* @param {string} instanceUrl
|
|
138
|
+
* @param {string} token
|
|
139
|
+
* @param {boolean} [forceContinue=false]
|
|
140
|
+
*/
|
|
141
|
+
export async function showCurrentConflicts(rootDir, instanceUrl, token, forceContinue = false) {
|
|
142
|
+
const queries = [
|
|
143
|
+
{
|
|
144
|
+
name: "ActiveClass",
|
|
145
|
+
query: "SELECT Id,Body,Name,CreatedOn,Description,ModifiedOn,Type FROM ActiveClass",
|
|
146
|
+
contentField: "Body",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: "ActivePage",
|
|
150
|
+
query: "SELECT Id,Content,Name,CreatedOn,Description,ModifiedOn,Type FROM ActivePage",
|
|
151
|
+
contentField: "Content",
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
console.log(chalk.cyan.bold('🔍 Checking local files vs remote Magentrix...'));
|
|
156
|
+
console.log(chalk.gray('------------------------------------------------'));
|
|
157
|
+
|
|
158
|
+
const [activeClassResult, activePageResult] = await withSpinner(
|
|
159
|
+
chalk.gray('Retrieving files from server...'),
|
|
160
|
+
async () => Promise.all(
|
|
161
|
+
queries.map(q => meqlQuery(instanceUrl, token, q.query))
|
|
162
|
+
)
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const activeClassRecords = (activeClassResult.Records || []).map(record => {
|
|
166
|
+
record.Content = record.Body;
|
|
167
|
+
delete record.Body;
|
|
168
|
+
return record;
|
|
169
|
+
});
|
|
170
|
+
const activePageRecords = (activePageResult.Records || []);
|
|
171
|
+
const allRecords = [...activeClassRecords, ...activePageRecords].map(mapRecordToFile);
|
|
172
|
+
|
|
173
|
+
let warningCount = 0;
|
|
174
|
+
console.log();
|
|
175
|
+
|
|
176
|
+
for (const record of allRecords) {
|
|
177
|
+
if (record?.error) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const status = compareLocalAndRemote(
|
|
182
|
+
path.join(rootDir, record.relativePath),
|
|
183
|
+
{ ...record, content: record.Content }
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const hasIssue = logFileStatus(record.relativePath, status);
|
|
187
|
+
if (hasIssue) warningCount++;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (warningCount > 0) {
|
|
191
|
+
console.log();
|
|
192
|
+
console.log(
|
|
193
|
+
chalk.yellow.bold(`⚠️ Conflict${warningCount > 1 ? 's' : ''} detected!`) +
|
|
194
|
+
chalk.yellow('\n These are just warnings, but you should resolve them before continuing.')
|
|
195
|
+
);
|
|
196
|
+
console.log(
|
|
197
|
+
chalk.yellow('\n👉 To sync and resolve conflicts, run: ') +
|
|
198
|
+
chalk.cyan.bold('magentrix pull')
|
|
199
|
+
);
|
|
200
|
+
console.log(chalk.gray('------------------------------------------------'));
|
|
201
|
+
|
|
202
|
+
if (!forceContinue) {
|
|
203
|
+
await new Promise((resolve) => {
|
|
204
|
+
process.stdout.write(
|
|
205
|
+
chalk.yellow(
|
|
206
|
+
'\nPress any key to continue, or press ESC to abort... '
|
|
207
|
+
)
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// Set raw mode so we capture a single key, including ESC
|
|
211
|
+
process.stdin.setRawMode(true);
|
|
212
|
+
process.stdin.resume();
|
|
213
|
+
process.stdin.once('data', (data) => {
|
|
214
|
+
process.stdin.setRawMode(false);
|
|
215
|
+
process.stdin.pause();
|
|
216
|
+
// ESC key is 27 in Buffer
|
|
217
|
+
if (data.length === 1 && data[0] === 27) {
|
|
218
|
+
console.log(chalk.red.bold('\nAborted due to unresolved conflicts.\n'));
|
|
219
|
+
process.exit(1);
|
|
220
|
+
} else {
|
|
221
|
+
// Continue
|
|
222
|
+
process.stdout.write('\n');
|
|
223
|
+
resolve();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
console.log();
|
|
230
|
+
console.log(chalk.green.bold('🎉 All local files are in sync with the server!'));
|
|
231
|
+
console.log(chalk.gray('------------------------------------------------'));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Presents all files with conflicts and prompts the user for a global resolution action.
|
|
239
|
+
*
|
|
240
|
+
* @param {Array<{relativePath: string, status: string}>} fileIssues
|
|
241
|
+
* @returns {Promise<'skip'|'overwrite'|'diff'|'accept_ours'|'accept_theirs'|'manual'>}
|
|
242
|
+
*/
|
|
243
|
+
export async function promptConflictResolution(fileIssues) {
|
|
244
|
+
if (!fileIssues.length) return 'skip';
|
|
245
|
+
|
|
246
|
+
// Clear for better UX
|
|
247
|
+
console.clear();
|
|
248
|
+
console.log(
|
|
249
|
+
chalk.bold.yellow(
|
|
250
|
+
`\n${fileIssues.length} file${fileIssues.length > 1 ? 's' : ''} require conflict resolution:\n`
|
|
251
|
+
)
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
fileIssues.forEach((file, i) => {
|
|
255
|
+
console.log(
|
|
256
|
+
chalk.cyan(`${i + 1}.`) +
|
|
257
|
+
' ' +
|
|
258
|
+
chalk.bold(file.relativePath) +
|
|
259
|
+
chalk.gray(` [${file.status}]`)
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
console.log();
|
|
264
|
+
|
|
265
|
+
// Present actions menu
|
|
266
|
+
const { action } = await inquirer.prompt([
|
|
267
|
+
{
|
|
268
|
+
type: 'list',
|
|
269
|
+
name: 'action',
|
|
270
|
+
message: chalk.green('Choose how to resolve these conflicts:'),
|
|
271
|
+
choices: [
|
|
272
|
+
{ name: 'Replace local files with server versions (discard my changes)', value: 'overwrite' },
|
|
273
|
+
{ name: 'Automatically merge changes (may require manual edits if conflicts remain)', value: 'merge' },
|
|
274
|
+
{ name: 'Review each conflict and choose (see diffs)', value: 'diff' },
|
|
275
|
+
{ name: 'Do nothing for now (skip conflicted files)', value: 'skip' }
|
|
276
|
+
]
|
|
277
|
+
},
|
|
278
|
+
]);
|
|
279
|
+
|
|
280
|
+
return action;
|
|
281
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import prompts from "prompts";
|
|
2
|
+
import Config from "../../config.js";
|
|
3
|
+
import { HASHED_CWD } from "../../../vars/global.js";
|
|
4
|
+
|
|
5
|
+
const config = new Config();
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Ensures a valid Magentrix API key is available in the global config.
|
|
9
|
+
* - If a key is missing, invalid, or forcePrompt is true, prompts the user to enter one.
|
|
10
|
+
* - Validates that the key is at least 12 characters long and contains no spaces.
|
|
11
|
+
* - Exits the process if the user fails to provide a valid key.
|
|
12
|
+
*
|
|
13
|
+
* @async
|
|
14
|
+
* @param {boolean} [forcePrompt=false] - If true, always prompt for API key, even if one exists.
|
|
15
|
+
* @returns {Promise<string>} The valid API key, either from config or user input.
|
|
16
|
+
*/
|
|
17
|
+
export const ensureApiKey = async (forcePrompt = false) => {
|
|
18
|
+
let apiKey = config.read('apiKey', { global: true, pathHash: HASHED_CWD });
|
|
19
|
+
|
|
20
|
+
// Helper: checks for min length and no spaces
|
|
21
|
+
const isValid = (key) =>
|
|
22
|
+
typeof key === "string" &&
|
|
23
|
+
key.trim().length >= 12 &&
|
|
24
|
+
!/\s/.test(key);
|
|
25
|
+
|
|
26
|
+
if (forcePrompt || !isValid(apiKey)) {
|
|
27
|
+
console.log('\n🚀 Magentrix CLI Setup:');
|
|
28
|
+
|
|
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;
|
|
51
|
+
}
|
|
52
|
+
// Invalid: will re-prompt.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return apiKey;
|
|
57
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import Config from "../../config.js";
|
|
2
|
+
import { setup } from "../../../actions/setup.js";
|
|
3
|
+
import { HASHED_CWD } from "../../../vars/global.js";
|
|
4
|
+
import { tryAuthenticate } from "../../magentrix/api/auth.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns true if the token is present and not expired (60 seconds buffer).
|
|
8
|
+
* @param {{ value?: string, validUntil?: string } | undefined} tokenObj
|
|
9
|
+
* @returns {boolean}
|
|
10
|
+
*/
|
|
11
|
+
export function isTokenValid(tokenObj) {
|
|
12
|
+
if (!tokenObj || !tokenObj.value || !tokenObj.validUntil) return false;
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
const expiresAt = Date.parse(tokenObj.validUntil);
|
|
15
|
+
if (isNaN(expiresAt)) return false;
|
|
16
|
+
const SECONDS_BEFORE_EXPIRY = 60 * 1000;
|
|
17
|
+
return expiresAt - now > SECONDS_BEFORE_EXPIRY;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Ensures Magentrix credentials are present and valid, refreshing the token if needed.
|
|
22
|
+
* Prompts user only when needed.
|
|
23
|
+
*
|
|
24
|
+
* @returns {Promise<{ apiKey: string, instanceUrl: string, token: { value: string, validUntil: string } }>}
|
|
25
|
+
*/
|
|
26
|
+
export async function ensureValidCredentials() {
|
|
27
|
+
const config = new Config();
|
|
28
|
+
|
|
29
|
+
// Load from config
|
|
30
|
+
const apiKey = config.read('apiKey', { global: true, pathHash: HASHED_CWD });
|
|
31
|
+
const instanceUrl = config.read('instanceUrl', { global: true, pathHash: HASHED_CWD });
|
|
32
|
+
const token = config.read('token', { global: true, pathHash: HASHED_CWD });
|
|
33
|
+
|
|
34
|
+
// If missing API key or URL, prompt/setup immediately
|
|
35
|
+
if (!apiKey || !instanceUrl) {
|
|
36
|
+
return setup();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// If token is present and valid, return immediately
|
|
40
|
+
if (isTokenValid(token)) {
|
|
41
|
+
return { apiKey, instanceUrl, token };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// If we have API key & URL but no valid token, try to refresh
|
|
45
|
+
try {
|
|
46
|
+
const result = await tryAuthenticate(apiKey, instanceUrl);
|
|
47
|
+
|
|
48
|
+
if (!result || !result.token || !result.validUntil) {
|
|
49
|
+
throw new Error("Invalid token refresh response");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Save new token and return
|
|
53
|
+
const newToken = { value: result.token, validUntil: result.validUntil };
|
|
54
|
+
config.save('token', newToken, { global: true, pathHash: HASHED_CWD });
|
|
55
|
+
return { apiKey, instanceUrl, token: newToken };
|
|
56
|
+
} catch (err) {
|
|
57
|
+
// Failed to refresh, fall back to prompting the user
|
|
58
|
+
return setup();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import prompts from "prompts";
|
|
2
|
+
import Config from "../../config.js";
|
|
3
|
+
import { checkInstanceUrl } from "../checkInstanceUrl.js";
|
|
4
|
+
import { withSpinner } from "../../spinner.js";
|
|
5
|
+
import { HASHED_CWD } from "../../../vars/global.js";
|
|
6
|
+
|
|
7
|
+
const config = new Config();
|
|
8
|
+
const urlRegex = /^https:\/\/[a-zA-Z0-9-]+\.magentrix(cloud)?\.com$/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Ensures a valid Magentrix instance URL is available in the global config.
|
|
12
|
+
* Prompts until user provides a valid, reachable URL or cancels.
|
|
13
|
+
*
|
|
14
|
+
* @async
|
|
15
|
+
* @param {boolean} [forcePrompt=false] - If true, always prompt for instance URL, even if one exists.
|
|
16
|
+
* @returns {Promise<string>} The valid instance URL, either from config or user input.
|
|
17
|
+
*/
|
|
18
|
+
export const ensureInstanceUrl = async (forcePrompt = false) => {
|
|
19
|
+
let instanceUrl = config.read('instanceUrl', { global: true, pathHash: HASHED_CWD });
|
|
20
|
+
|
|
21
|
+
if (
|
|
22
|
+
forcePrompt ||
|
|
23
|
+
!instanceUrl ||
|
|
24
|
+
typeof instanceUrl !== 'string' ||
|
|
25
|
+
!urlRegex.test(instanceUrl.trim())
|
|
26
|
+
) {
|
|
27
|
+
console.log('\n🚀 Magentrix CLI Setup: Instance URL');
|
|
28
|
+
|
|
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.magentrixcloud.com):',
|
|
35
|
+
validate: value =>
|
|
36
|
+
urlRegex.test(value.trim())
|
|
37
|
+
? true
|
|
38
|
+
: 'Instance URL must be in the form: https://subdomain.magentrixcloud.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
|
+
}
|
|
45
|
+
|
|
46
|
+
const trimmed = inputUrl.trim();
|
|
47
|
+
|
|
48
|
+
// Now check reachability WITH a spinner
|
|
49
|
+
try {
|
|
50
|
+
await withSpinner('Checking instance URL...', () =>
|
|
51
|
+
checkInstanceUrl(trimmed)
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
return trimmed;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
// Print error AFTER spinner, then re-prompt
|
|
57
|
+
console.error('❌ Instance URL not reachable. Try again.');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return instanceUrl.trim();
|
|
63
|
+
};
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { EXPORT_ROOT, TYPE_DIR_MAP } from "../../vars/global.js";
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { compareLocalAndRemote } from "../compare.js";
|
|
5
|
+
import { mergeFiles } from "../merge.js";
|
|
6
|
+
import Config from "../config.js";
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import { diffLines } from 'diff';
|
|
9
|
+
import readlineSync from "readline-sync";
|
|
10
|
+
import { updateBase } from "../updateFileBase.js";
|
|
11
|
+
import { sha256 } from "../hash.js";
|
|
12
|
+
import { openDiffInVSCode } from "../diff.js";
|
|
13
|
+
import { tmpdir } from "os";
|
|
14
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
15
|
+
import { findFileByTag, getFileTag, setFileTag } from "../filetag.js";
|
|
16
|
+
import { decompressString } from "../compress.js";
|
|
17
|
+
|
|
18
|
+
const config = new Config();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Maps an ActivePage record to its local file representation.
|
|
22
|
+
* @param {Object} record - The ActivePage record from the API.
|
|
23
|
+
* @returns {Object} The enriched record with 'relativePath' property.
|
|
24
|
+
*/
|
|
25
|
+
export const mapRecordToFile = (record) => {
|
|
26
|
+
const { Type, Name, Id } = record;
|
|
27
|
+
const mapping = TYPE_DIR_MAP[Type];
|
|
28
|
+
|
|
29
|
+
if (!mapping) {
|
|
30
|
+
const errMsg = `Unrecognized type "${Type}" for record "${Name || 'UNKNOWN'}". This can be resolved in the Magentrix IDE and is likely due to a misconfiguration.`;
|
|
31
|
+
return { ...record, error: errMsg };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const safeName = Name.replace(/[<>:"/\\|?*]+/g, "_");
|
|
35
|
+
const filename = `${safeName}.${mapping.extension}`;
|
|
36
|
+
|
|
37
|
+
const foundPathByRecordId = findFileByTag(Id) || "";
|
|
38
|
+
const relativeFoundPath = foundPathByRecordId
|
|
39
|
+
? path.join(mapping.directory, foundPathByRecordId.split(mapping.directory)[1] || "")
|
|
40
|
+
: "";
|
|
41
|
+
|
|
42
|
+
const filePath = foundPathByRecordId ? relativeFoundPath : path.join(mapping.directory, filename);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
...record,
|
|
46
|
+
relativePath: filePath
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Writes records to disk, handling conflicts per the chosen resolution method.
|
|
52
|
+
* @param {Array} records - List of entity records (ActiveClass or ActivePage)
|
|
53
|
+
* @param {string} resolutionMethod - One of: "overwrite", "merge", "diff", "skip"
|
|
54
|
+
*/
|
|
55
|
+
export const writeRecords = async (records, resolutionMethod) => {
|
|
56
|
+
for (const record of records) {
|
|
57
|
+
const mapping = TYPE_DIR_MAP[record.Type];
|
|
58
|
+
if (!mapping) {
|
|
59
|
+
console.warn(chalk.gray(`⚠️ Skipping unknown type: ${record.Type} (record: ${record.Name})`));
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Build output path and filename
|
|
64
|
+
const outputDir = path.join(EXPORT_ROOT, mapping.directory);
|
|
65
|
+
fs.mkdirSync(outputDir, { recursive: true }); // Ensure dir exists
|
|
66
|
+
|
|
67
|
+
const safeName = record.Name.replace(/[<>:"/\\|?*]+/g, "_");
|
|
68
|
+
const filename = `${safeName}.${mapping.extension}`;
|
|
69
|
+
const filePath = path.join(outputDir, filename);
|
|
70
|
+
|
|
71
|
+
const content = record.Content;
|
|
72
|
+
if (!content) {
|
|
73
|
+
console.warn(`⚠️ No content found for ${record.Name} (${record.Type}), skipping file.`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Compare local and remote to determine sync status/conflict
|
|
78
|
+
const comparison = compareLocalAndRemote(filePath, { content, ...record });
|
|
79
|
+
|
|
80
|
+
// --- Conflict Handling ---
|
|
81
|
+
let finalContent = null; // Will hold the string to write if we should write the file
|
|
82
|
+
|
|
83
|
+
if (['missing', 'in_sync'].includes(comparison.status)) {
|
|
84
|
+
// No conflict, just write server version (create if missing)
|
|
85
|
+
finalContent = content;
|
|
86
|
+
} else {
|
|
87
|
+
// Attempt to read local file (may not exist)
|
|
88
|
+
let local = "";
|
|
89
|
+
|
|
90
|
+
const base = config.read(record.Id, { global: false, filename: 'base.json' });
|
|
91
|
+
const baseContent = decompressString(base?.compressedContent) || '';
|
|
92
|
+
|
|
93
|
+
// const base = config.read(filePath);
|
|
94
|
+
try { local = fs.readFileSync(filePath, "utf8"); } catch { }
|
|
95
|
+
|
|
96
|
+
if (resolutionMethod === 'skip') {
|
|
97
|
+
// Do nothing, never create or update file
|
|
98
|
+
console.log(chalk.yellow(`⏭️ Skipped ${record.Name} (${record.Type}) due to conflict or mismatch.`));
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
else if (resolutionMethod === 'overwrite') {
|
|
102
|
+
// Always accept server version, creating file if missing
|
|
103
|
+
console.log(chalk.redBright(`⚠️ Overwriting local file: ${filePath} with server version.`));
|
|
104
|
+
finalContent = content;
|
|
105
|
+
}
|
|
106
|
+
else if (resolutionMethod === 'diff') {
|
|
107
|
+
// Use mergeFiles, show diff, ask user
|
|
108
|
+
let merged;
|
|
109
|
+
try {
|
|
110
|
+
merged = mergeFiles(baseContent, local, content);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
console.log(chalk.red(`❌ Error merging: ${e.message}`));
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Attempt to open the diff in vs code
|
|
117
|
+
const randomStr = (length) => Math.random().toString(36).slice(2, 2 + length);
|
|
118
|
+
const tempFileServer = path.join(tmpdir(), `${randomStr(4)}.Server${filename}`);
|
|
119
|
+
const tempFileLocal = path.join(tmpdir(), `${randomStr(4)}.Local${filename}`);
|
|
120
|
+
fs.writeFileSync(tempFileServer, content);
|
|
121
|
+
fs.writeFileSync(tempFileLocal, local);
|
|
122
|
+
|
|
123
|
+
const vsCodeDiffDisplayed = openDiffInVSCode(tempFileServer, tempFileLocal);
|
|
124
|
+
|
|
125
|
+
// If the VS Code diff failed, show in terminal
|
|
126
|
+
if (!vsCodeDiffDisplayed) {
|
|
127
|
+
// Show diff (local vs merged)
|
|
128
|
+
const diff = diffLines(local, merged);
|
|
129
|
+
console.log(chalk.magenta.bold(`\n======= REVIEW MERGED for ${filename} =======`));
|
|
130
|
+
diff.forEach(part => {
|
|
131
|
+
const color = part.added ? 'green' :
|
|
132
|
+
part.removed ? 'red' : 'gray';
|
|
133
|
+
process.stdout.write(chalk[color](part.value));
|
|
134
|
+
});
|
|
135
|
+
console.log(chalk.magenta.bold("\n======= END REVIEW ======="));
|
|
136
|
+
|
|
137
|
+
// Warn if there are still unresolved conflict markers
|
|
138
|
+
if (merged.includes('<<<<<<<')) {
|
|
139
|
+
console.log(chalk.red.bold("\n⚠️ Merge conflict markers present. Please resolve manually after accepting."));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Prompt user to accept or skip writing merged content
|
|
144
|
+
const choice = readlineSync.question(
|
|
145
|
+
chalk.yellow("\nAccept merged changes and write to file? (y/n): ")
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// Delete the temp files
|
|
149
|
+
fs.unlinkSync(tempFileLocal);
|
|
150
|
+
fs.unlinkSync(tempFileServer);
|
|
151
|
+
|
|
152
|
+
if (choice.trim().toLowerCase() === 'y') {
|
|
153
|
+
finalContent = merged;
|
|
154
|
+
} else {
|
|
155
|
+
console.log(chalk.yellow("⏭️ Skipped due to user choice."));
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
else if (resolutionMethod === 'merge') {
|
|
160
|
+
// No prompt: always write merged result (may contain conflict markers)
|
|
161
|
+
let merged;
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
merged = mergeFiles(baseContent, local, content);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
console.log(chalk.red(`❌ Error merging: ${e.message}`));
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (merged.includes('<<<<<<<')) {
|
|
170
|
+
console.log(chalk.red.bold(`⚠️ Merge conflict markers written to ${filename}. Manual review required.`));
|
|
171
|
+
} else {
|
|
172
|
+
console.log(chalk.green(`✔️ Merged ${filename} automatically.`));
|
|
173
|
+
}
|
|
174
|
+
finalContent = merged;
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
// Unknown or unsupported mode (should not occur)
|
|
178
|
+
console.log(chalk.red(`❌ Unknown resolution method: ${resolutionMethod}`));
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --- Actually Write File (if required by resolution) ---
|
|
184
|
+
if (finalContent !== null) {
|
|
185
|
+
try {
|
|
186
|
+
// Lookup a matching file dir in our cache, in case the file was renamed
|
|
187
|
+
const cachedFilePath = findFileByTag(record.Id) || path.resolve(filePath);
|
|
188
|
+
|
|
189
|
+
// Ensure the directory exists (paranoia for deeply nested files)
|
|
190
|
+
fs.mkdirSync(path.dirname(cachedFilePath), { recursive: true });
|
|
191
|
+
fs.writeFileSync(cachedFilePath, finalContent, "utf8");
|
|
192
|
+
|
|
193
|
+
// Add a file tag so we can keep track of file changes
|
|
194
|
+
await setFileTag(cachedFilePath, record.Id);
|
|
195
|
+
|
|
196
|
+
// Set mtime/atime for reproducible syncs (optional, best effort)
|
|
197
|
+
let mtimeMs = null;
|
|
198
|
+
if (record.ModifiedOn) {
|
|
199
|
+
mtimeMs = new Date(record.ModifiedOn).getTime();
|
|
200
|
+
} else if (record.CreatedOn) {
|
|
201
|
+
mtimeMs = new Date(record.CreatedOn).getTime();
|
|
202
|
+
}
|
|
203
|
+
if (mtimeMs) {
|
|
204
|
+
const mtimeSeconds = mtimeMs / 1000;
|
|
205
|
+
try {
|
|
206
|
+
fs.utimesSync(cachedFilePath, mtimeSeconds, mtimeSeconds);
|
|
207
|
+
} catch (e) {
|
|
208
|
+
console.warn(`⚠️ Could not set times for ${cachedFilePath}: ${e.message}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
// If the server records content and the local files content match then we can update the base
|
|
214
|
+
if (sha256(finalContent) === sha256(record.Content)) {
|
|
215
|
+
// We still need to use the expected filePath as the rename might be local only
|
|
216
|
+
updateBase(filePath, record, cachedFilePath);
|
|
217
|
+
}
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.error(chalk.red(`❌ Failed to write file ${cachedFilePath}: ${err.message}`));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
};
|