@leadcms/sdk 1.3.0-pre → 2.1.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/README.md +82 -3
- package/dist/cli/index.js +55 -62
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -18
- package/dist/index.js.map +1 -1
- package/dist/lib/cms.d.ts +1 -1
- package/dist/lib/cms.d.ts.map +1 -1
- package/dist/lib/cms.js +49 -72
- package/dist/lib/cms.js.map +1 -1
- package/dist/lib/config.d.ts +0 -8
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +13 -40
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/console-colors.d.ts +49 -0
- package/dist/lib/console-colors.d.ts.map +1 -0
- package/dist/lib/console-colors.js +121 -0
- package/dist/lib/console-colors.js.map +1 -0
- package/dist/lib/content-transformation.d.ts +90 -0
- package/dist/lib/content-transformation.d.ts.map +1 -0
- package/dist/lib/content-transformation.js +335 -0
- package/dist/lib/content-transformation.js.map +1 -0
- package/dist/lib/data-service.d.ts +97 -0
- package/dist/lib/data-service.d.ts.map +1 -0
- package/dist/lib/data-service.js +389 -0
- package/dist/lib/data-service.js.map +1 -0
- package/dist/scripts/fetch-leadcms-content.d.ts +19 -0
- package/dist/scripts/fetch-leadcms-content.d.ts.map +1 -0
- package/dist/scripts/fetch-leadcms-content.js +301 -0
- package/dist/scripts/fetch-leadcms-content.js.map +1 -0
- package/dist/scripts/generate-env-js.d.ts +2 -0
- package/dist/scripts/generate-env-js.d.ts.map +1 -0
- package/dist/scripts/generate-env-js.js +22 -0
- package/dist/scripts/generate-env-js.js.map +1 -0
- package/dist/scripts/leadcms-helpers.d.ts +25 -0
- package/dist/scripts/leadcms-helpers.d.ts.map +1 -0
- package/dist/scripts/leadcms-helpers.js +78 -0
- package/dist/scripts/leadcms-helpers.js.map +1 -0
- package/dist/scripts/push-leadcms-content.d.ts +50 -0
- package/dist/scripts/push-leadcms-content.d.ts.map +1 -0
- package/dist/scripts/push-leadcms-content.js +1022 -0
- package/dist/scripts/push-leadcms-content.js.map +1 -0
- package/dist/scripts/sse-watcher.d.ts +20 -0
- package/dist/scripts/sse-watcher.d.ts.map +1 -0
- package/dist/scripts/sse-watcher.js +268 -0
- package/dist/scripts/sse-watcher.js.map +1 -0
- package/dist/scripts/status-leadcms-content.d.ts +4 -0
- package/dist/scripts/status-leadcms-content.d.ts.map +1 -0
- package/dist/scripts/status-leadcms-content.js +36 -0
- package/dist/scripts/status-leadcms-content.js.map +1 -0
- package/package.json +14 -12
- package/dist/scripts/fetch-leadcms-content.mjs +0 -367
- package/dist/scripts/generate-env-js.mjs +0 -24
- package/dist/scripts/leadcms-helpers.mjs +0 -208
- package/dist/scripts/sse-watcher.mjs +0 -300
|
@@ -0,0 +1,1022 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import readline from "readline";
|
|
5
|
+
import matter from "gray-matter";
|
|
6
|
+
import * as Diff from "diff";
|
|
7
|
+
import { defaultLanguage, CONTENT_DIR, } from "./leadcms-helpers.js";
|
|
8
|
+
import { leadCMSDataService } from "../lib/data-service.js";
|
|
9
|
+
import { transformRemoteToLocalFormat, transformRemoteForComparison, hasContentDifferences, replaceLocalMediaPaths } from "../lib/content-transformation.js";
|
|
10
|
+
import { colorConsole, statusColors, diffColors } from '../lib/console-colors.js';
|
|
11
|
+
// Create readline interface for user prompts
|
|
12
|
+
const rl = readline.createInterface({
|
|
13
|
+
input: process.stdin,
|
|
14
|
+
output: process.stdout
|
|
15
|
+
});
|
|
16
|
+
// Promisify readline question
|
|
17
|
+
function question(prompt) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
rl.question(prompt, resolve);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Check if a directory is a locale directory
|
|
24
|
+
* Only immediate children of CONTENT_DIR with 2-5 letter language codes are considered locales
|
|
25
|
+
*/
|
|
26
|
+
async function isLocaleDirectory(dirPath, parentDir) {
|
|
27
|
+
try {
|
|
28
|
+
// Only consider directories that are immediate children of CONTENT_DIR
|
|
29
|
+
if (parentDir !== CONTENT_DIR) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const dirName = path.basename(dirPath);
|
|
33
|
+
// Check if it matches language code pattern (2-5 letters, optionally with region codes)
|
|
34
|
+
// Examples: en, da, ru, en-US, pt-BR, zh-CN
|
|
35
|
+
const isLanguageCode = /^[a-z]{2}(-[A-Z]{2})?$/.test(dirName);
|
|
36
|
+
return isLanguageCode;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
} /**
|
|
42
|
+
* Read and parse all local content files
|
|
43
|
+
*/
|
|
44
|
+
async function readLocalContent() {
|
|
45
|
+
console.log(`[LOCAL] Reading content from: ${CONTENT_DIR}`);
|
|
46
|
+
const localContent = [];
|
|
47
|
+
async function walkDirectory(dir, locale = defaultLanguage, baseContentDir = CONTENT_DIR) {
|
|
48
|
+
try {
|
|
49
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
const fullPath = path.join(dir, entry.name);
|
|
52
|
+
if (entry.isDirectory()) {
|
|
53
|
+
// Check if this is a locale directory (only immediate children of content dir)
|
|
54
|
+
if (entry.name !== defaultLanguage && await isLocaleDirectory(fullPath, dir)) {
|
|
55
|
+
// This is a language directory
|
|
56
|
+
await walkDirectory(fullPath, entry.name, fullPath);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
// Regular directory, keep current locale and baseContentDir
|
|
60
|
+
await walkDirectory(fullPath, locale, baseContentDir);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else if (entry.isFile() && (entry.name.endsWith('.mdx') || entry.name.endsWith('.json'))) {
|
|
64
|
+
try {
|
|
65
|
+
const content = await parseContentFile(fullPath, locale, baseContentDir);
|
|
66
|
+
if (content) {
|
|
67
|
+
localContent.push(content);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
console.warn(`[LOCAL] Failed to parse ${fullPath}:`, error.message);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
console.warn(`[LOCAL] Failed to read directory ${dir}:`, error.message);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
await walkDirectory(CONTENT_DIR);
|
|
81
|
+
console.log(`[LOCAL] Found ${localContent.length} local content files`);
|
|
82
|
+
return localContent;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Parse a single content file (MDX or JSON)
|
|
86
|
+
*/
|
|
87
|
+
async function parseContentFile(filePath, locale, baseContentDir = CONTENT_DIR) {
|
|
88
|
+
const fileContent = await fs.readFile(filePath, 'utf-8');
|
|
89
|
+
const ext = path.extname(filePath);
|
|
90
|
+
const basename = path.basename(filePath, ext);
|
|
91
|
+
let metadata;
|
|
92
|
+
let body = '';
|
|
93
|
+
if (ext === '.mdx') {
|
|
94
|
+
const parsed = matter(fileContent);
|
|
95
|
+
metadata = parsed.data;
|
|
96
|
+
body = parsed.content.trim();
|
|
97
|
+
}
|
|
98
|
+
else if (ext === '.json') {
|
|
99
|
+
const jsonData = JSON.parse(fileContent);
|
|
100
|
+
body = jsonData.body || '';
|
|
101
|
+
metadata = { ...jsonData };
|
|
102
|
+
delete metadata.body;
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
// Calculate slug from relative path within the content directory
|
|
108
|
+
// This includes subdirectories like blog/, docs/, legal/ as part of the slug
|
|
109
|
+
const relativePath = path.relative(baseContentDir, filePath);
|
|
110
|
+
const relativeDir = path.dirname(relativePath);
|
|
111
|
+
let slug;
|
|
112
|
+
if (relativeDir === '.' || relativeDir === '') {
|
|
113
|
+
// File is directly in the content/locale directory
|
|
114
|
+
slug = basename;
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
// File is in a subdirectory, include the path
|
|
118
|
+
slug = path.join(relativeDir, basename).replace(/\\/g, '/'); // Normalize to forward slashes
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
filePath,
|
|
122
|
+
slug,
|
|
123
|
+
locale,
|
|
124
|
+
type: metadata.type,
|
|
125
|
+
metadata,
|
|
126
|
+
body,
|
|
127
|
+
isLocal: true
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Get all unique content types from local content
|
|
132
|
+
*/
|
|
133
|
+
function getLocalContentTypes(localContent) {
|
|
134
|
+
const types = new Set();
|
|
135
|
+
for (const content of localContent) {
|
|
136
|
+
if (content.type) {
|
|
137
|
+
types.add(content.type);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return types;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Fetch all remote content using sync API without token (full fetch)
|
|
144
|
+
*/
|
|
145
|
+
async function fetchRemoteContent() {
|
|
146
|
+
const allItems = await leadCMSDataService.getAllContent();
|
|
147
|
+
// Ensure we have an array
|
|
148
|
+
if (!Array.isArray(allItems)) {
|
|
149
|
+
console.warn(`[${leadCMSDataService.isMockMode() ? 'MOCK' : 'REMOTE'}] Retrieved invalid data (not an array):`, typeof allItems);
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
console.log(`[${leadCMSDataService.isMockMode() ? 'MOCK' : 'REMOTE'}] Retrieved ${allItems.length} content items`);
|
|
153
|
+
return allItems.map(item => ({ ...item, isLocal: false }));
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Compare local and remote content by transforming remote to local format
|
|
157
|
+
* Returns true if there are meaningful differences in content
|
|
158
|
+
* This new approach compares normalized file content directly instead of parsed objects
|
|
159
|
+
*/
|
|
160
|
+
async function hasActualContentChanges(local, remote, typeMap) {
|
|
161
|
+
try {
|
|
162
|
+
// Read the local file content as-is
|
|
163
|
+
const localFileContent = await fs.readFile(local.filePath, 'utf-8');
|
|
164
|
+
// Transform remote content for comparison, only including fields that exist in local content
|
|
165
|
+
// This prevents false positives when remote has additional fields like updatedAt
|
|
166
|
+
const transformedRemoteContent = await transformRemoteForComparison(remote, localFileContent, typeMap);
|
|
167
|
+
// Compare the raw file contents using shared normalization logic
|
|
168
|
+
const hasFileContentChanges = hasContentDifferences(localFileContent, transformedRemoteContent);
|
|
169
|
+
return hasFileContentChanges;
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
console.warn(`[COMPARE] Failed to compare content for ${local.slug}:`, error.message);
|
|
173
|
+
// Fallback to true to err on the side of showing changes
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Match local content with remote content
|
|
179
|
+
*/
|
|
180
|
+
async function matchContent(localContent, remoteContent, typeMap) {
|
|
181
|
+
const operations = {
|
|
182
|
+
create: [],
|
|
183
|
+
update: [],
|
|
184
|
+
rename: [],
|
|
185
|
+
typeChange: [],
|
|
186
|
+
conflict: []
|
|
187
|
+
};
|
|
188
|
+
for (const local of localContent) {
|
|
189
|
+
let match = undefined;
|
|
190
|
+
// First try to match by ID if local content has one
|
|
191
|
+
if (local.metadata.id) {
|
|
192
|
+
match = remoteContent.find(remote => remote.id === local.metadata.id);
|
|
193
|
+
}
|
|
194
|
+
// If no ID match, try to match by current filename slug and locale
|
|
195
|
+
if (!match) {
|
|
196
|
+
match = remoteContent.find(remote => remote.slug === local.slug &&
|
|
197
|
+
(remote.language || defaultLanguage) === local.locale);
|
|
198
|
+
}
|
|
199
|
+
// If still no match, try by the slug in metadata (could be old slug for renames)
|
|
200
|
+
if (!match && local.metadata.slug && local.metadata.slug !== local.slug) {
|
|
201
|
+
match = remoteContent.find(remote => remote.slug === local.metadata.slug &&
|
|
202
|
+
(remote.language || defaultLanguage) === local.locale);
|
|
203
|
+
}
|
|
204
|
+
// If still no match, try by title and locale (if title exists)
|
|
205
|
+
if (!match && local.metadata.title) {
|
|
206
|
+
match = remoteContent.find(remote => remote.title === local.metadata.title &&
|
|
207
|
+
(remote.language || defaultLanguage) === local.locale);
|
|
208
|
+
}
|
|
209
|
+
if (match) {
|
|
210
|
+
// Check for conflicts by comparing updatedAt timestamps from content metadata
|
|
211
|
+
const localUpdated = local.metadata.updatedAt ? new Date(local.metadata.updatedAt) : new Date(0);
|
|
212
|
+
const remoteUpdated = match.updatedAt ? new Date(match.updatedAt) : new Date(0);
|
|
213
|
+
// Detect different types of changes
|
|
214
|
+
const slugChanged = match.slug !== local.slug;
|
|
215
|
+
const typeChanged = match.type !== local.type;
|
|
216
|
+
if (remoteUpdated > localUpdated) {
|
|
217
|
+
let conflictReason = 'Remote content was updated after local content';
|
|
218
|
+
if (slugChanged && typeChanged) {
|
|
219
|
+
conflictReason = 'Both slug and content type changed remotely';
|
|
220
|
+
}
|
|
221
|
+
else if (slugChanged) {
|
|
222
|
+
conflictReason = 'Slug changed remotely after local changes';
|
|
223
|
+
}
|
|
224
|
+
else if (typeChanged) {
|
|
225
|
+
conflictReason = 'Content type changed remotely after local changes';
|
|
226
|
+
}
|
|
227
|
+
operations.conflict.push({
|
|
228
|
+
local,
|
|
229
|
+
remote: match,
|
|
230
|
+
reason: conflictReason
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
else if (slugChanged && typeChanged) {
|
|
234
|
+
// Both slug and type changed - this is a complex update
|
|
235
|
+
operations.typeChange.push({
|
|
236
|
+
local,
|
|
237
|
+
remote: match,
|
|
238
|
+
oldSlug: match.slug,
|
|
239
|
+
oldType: match.type,
|
|
240
|
+
newType: local.type
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
else if (slugChanged) {
|
|
244
|
+
// Slug changed - this is a rename
|
|
245
|
+
operations.rename.push({
|
|
246
|
+
local,
|
|
247
|
+
remote: match,
|
|
248
|
+
oldSlug: match.slug
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
else if (typeChanged) {
|
|
252
|
+
// Content type changed
|
|
253
|
+
operations.typeChange.push({
|
|
254
|
+
local,
|
|
255
|
+
remote: match,
|
|
256
|
+
oldType: match.type,
|
|
257
|
+
newType: local.type
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
// Check if content actually changed by comparing all fields
|
|
262
|
+
const hasContentChanges = await hasActualContentChanges(local, match, typeMap);
|
|
263
|
+
if (hasContentChanges) {
|
|
264
|
+
// Regular update - content modified but slug and type same
|
|
265
|
+
operations.update.push({
|
|
266
|
+
local,
|
|
267
|
+
remote: match
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
// If no content changes, don't add to any operation (content is in sync)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
// No match found, this is a new content item
|
|
275
|
+
operations.create.push({
|
|
276
|
+
local
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return operations;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Validate that all required content types exist remotely
|
|
284
|
+
*/
|
|
285
|
+
async function validateContentTypes(localTypes, remoteTypeMap, dryRun = false) {
|
|
286
|
+
const missingTypes = [];
|
|
287
|
+
for (const type of localTypes) {
|
|
288
|
+
if (!remoteTypeMap[type]) {
|
|
289
|
+
missingTypes.push(type);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (missingTypes.length > 0) {
|
|
293
|
+
colorConsole.error(`\n❌ Missing content types in remote LeadCMS: ${colorConsole.highlight(missingTypes.join(', '))}`);
|
|
294
|
+
colorConsole.warn(`\nYou need to create these content types in your LeadCMS instance before pushing content.`);
|
|
295
|
+
if (dryRun) {
|
|
296
|
+
colorConsole.info('\n🧪 In dry run mode - showing what content type creation would look like:');
|
|
297
|
+
for (const type of missingTypes) {
|
|
298
|
+
colorConsole.progress(`\n📋 CREATE CONTENT TYPE (Dry Run):`);
|
|
299
|
+
colorConsole.log(`\n${colorConsole.cyan('POST')} ${colorConsole.highlight('/api/content-types')}`);
|
|
300
|
+
colorConsole.log(`${colorConsole.gray('Content-Type:')} application/json`);
|
|
301
|
+
colorConsole.log(`\n${colorConsole.gray('Request Body:')}`);
|
|
302
|
+
const sampleContentTypeData = {
|
|
303
|
+
uid: type,
|
|
304
|
+
name: type.charAt(0).toUpperCase() + type.slice(1),
|
|
305
|
+
format: 'MDX',
|
|
306
|
+
supportsCoverImage: false,
|
|
307
|
+
supportsComments: false
|
|
308
|
+
};
|
|
309
|
+
colorConsole.log(JSON.stringify(sampleContentTypeData, null, 2));
|
|
310
|
+
colorConsole.success(`✅ Would create content type: ${colorConsole.highlight(type)}`);
|
|
311
|
+
}
|
|
312
|
+
return; // Skip interactive creation in dry run mode
|
|
313
|
+
}
|
|
314
|
+
const createChoice = await question('\nWould you like me to create these content types automatically? (y/N): ');
|
|
315
|
+
if (createChoice.toLowerCase() === 'y' || createChoice.toLowerCase() === 'yes') {
|
|
316
|
+
for (const type of missingTypes) {
|
|
317
|
+
await createContentTypeInteractive(type, dryRun);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
colorConsole.info('\nPlease create the missing content types manually in your LeadCMS instance and try again.');
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Create a content type in remote LeadCMS
|
|
328
|
+
*/
|
|
329
|
+
async function createContentTypeInteractive(typeName, dryRun = false) {
|
|
330
|
+
colorConsole.progress(`\n📝 Creating content type: ${colorConsole.highlight(typeName)}`);
|
|
331
|
+
const format = await question(`What format should '${colorConsole.highlight(typeName)}' use? (MDX/JSON) [MDX]: `) || 'MDX';
|
|
332
|
+
const supportsCoverImage = await question(`Should '${colorConsole.highlight(typeName)}' support cover images? (y/N): `);
|
|
333
|
+
const supportsComments = await question(`Should '${colorConsole.highlight(typeName)}' support comments? (y/N): `);
|
|
334
|
+
const contentTypeData = {
|
|
335
|
+
uid: typeName,
|
|
336
|
+
name: typeName.charAt(0).toUpperCase() + typeName.slice(1),
|
|
337
|
+
format: format.toUpperCase(),
|
|
338
|
+
supportsCoverImage: supportsCoverImage.toLowerCase() === 'y',
|
|
339
|
+
supportsComments: supportsComments.toLowerCase() === 'y'
|
|
340
|
+
};
|
|
341
|
+
if (dryRun) {
|
|
342
|
+
colorConsole.progress(`\n📋 CREATE CONTENT TYPE (Dry Run):`);
|
|
343
|
+
colorConsole.log(`\n${colorConsole.cyan('POST')} ${colorConsole.highlight('/api/content-types')}`);
|
|
344
|
+
colorConsole.log(`${colorConsole.gray('Content-Type:')} application/json`);
|
|
345
|
+
colorConsole.log(`\n${colorConsole.gray('Request Body:')}`);
|
|
346
|
+
colorConsole.log(JSON.stringify(contentTypeData, null, 2));
|
|
347
|
+
colorConsole.success(`✅ Would create content type: ${colorConsole.highlight(typeName)}`);
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
try {
|
|
351
|
+
await leadCMSDataService.createContentType(contentTypeData);
|
|
352
|
+
colorConsole.success(`✅ Created content type: ${colorConsole.highlight(typeName)}`);
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
colorConsole.error(`❌ Failed to create content type '${colorConsole.highlight(typeName)}':`, error.message);
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Filter content operations to only include specific content by ID or slug
|
|
362
|
+
*/
|
|
363
|
+
function filterContentOperations(operations, targetId, targetSlug) {
|
|
364
|
+
if (!targetId && !targetSlug) {
|
|
365
|
+
return operations; // No filtering needed
|
|
366
|
+
}
|
|
367
|
+
const matchesTarget = (op) => {
|
|
368
|
+
if (targetId) {
|
|
369
|
+
// Check if local content has the target ID
|
|
370
|
+
if (op.local.metadata.id?.toString() === targetId)
|
|
371
|
+
return true;
|
|
372
|
+
// Check if remote content has the target ID
|
|
373
|
+
if (op.remote?.id?.toString() === targetId)
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
if (targetSlug) {
|
|
377
|
+
// Check if local content has the target slug
|
|
378
|
+
if (op.local.slug === targetSlug)
|
|
379
|
+
return true;
|
|
380
|
+
// Check if remote content has the target slug
|
|
381
|
+
if (op.remote?.slug === targetSlug)
|
|
382
|
+
return true;
|
|
383
|
+
// Check if this is a rename and the old slug matches
|
|
384
|
+
if (op.oldSlug === targetSlug)
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
return false;
|
|
388
|
+
};
|
|
389
|
+
return {
|
|
390
|
+
create: operations.create.filter(matchesTarget),
|
|
391
|
+
update: operations.update.filter(matchesTarget),
|
|
392
|
+
rename: operations.rename.filter(matchesTarget),
|
|
393
|
+
typeChange: operations.typeChange.filter(matchesTarget),
|
|
394
|
+
conflict: operations.conflict.filter(matchesTarget)
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Display detailed diff for a single content item
|
|
399
|
+
*/
|
|
400
|
+
async function displayDetailedDiff(operation, operationType, typeMap) {
|
|
401
|
+
const { local, remote } = operation;
|
|
402
|
+
console.log(`\n📄 Detailed Changes for: ${local.slug} [${local.locale}]`);
|
|
403
|
+
console.log(` Operation: ${operationType}`);
|
|
404
|
+
console.log(` Content Type: ${local.type}`);
|
|
405
|
+
if (remote?.id) {
|
|
406
|
+
console.log(` Remote ID: ${remote.id}`);
|
|
407
|
+
}
|
|
408
|
+
console.log('');
|
|
409
|
+
// Compare content using the new transformation approach
|
|
410
|
+
console.log('\n📝 Content Changes:');
|
|
411
|
+
try {
|
|
412
|
+
// Read local file content as-is
|
|
413
|
+
const localFileContent = await fs.readFile(local.filePath, 'utf-8');
|
|
414
|
+
// Transform remote content to local format for comparison
|
|
415
|
+
const transformedRemoteContent = remote ? await transformRemoteToLocalFormat(remote, typeMap) : '';
|
|
416
|
+
if (localFileContent.trim() === transformedRemoteContent.trim()) {
|
|
417
|
+
console.log(' No content changes detected');
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
// Use line-by-line diff for detailed comparison
|
|
421
|
+
const diff = Diff.diffLines(transformedRemoteContent, localFileContent);
|
|
422
|
+
let addedLines = 0;
|
|
423
|
+
let removedLines = 0;
|
|
424
|
+
let unchangedLines = 0;
|
|
425
|
+
// Show diff preview and count changes
|
|
426
|
+
colorConsole.info(' Content diff preview:');
|
|
427
|
+
let previewLines = 0;
|
|
428
|
+
const maxPreviewLines = 10;
|
|
429
|
+
for (const part of diff) {
|
|
430
|
+
// Count non-empty lines only for more accurate statistics
|
|
431
|
+
const lines = part.value.split('\n').filter((line) => line.trim() !== '');
|
|
432
|
+
if (part.added) {
|
|
433
|
+
addedLines += lines.length;
|
|
434
|
+
if (previewLines < maxPreviewLines) {
|
|
435
|
+
for (const line of lines.slice(0, Math.min(lines.length, maxPreviewLines - previewLines))) {
|
|
436
|
+
colorConsole.log(` ${diffColors.added(`+ ${line}`)}`);
|
|
437
|
+
previewLines++;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
else if (part.removed) {
|
|
442
|
+
removedLines += lines.length;
|
|
443
|
+
if (previewLines < maxPreviewLines) {
|
|
444
|
+
for (const line of lines.slice(0, Math.min(lines.length, maxPreviewLines - previewLines))) {
|
|
445
|
+
colorConsole.log(` ${diffColors.removed(`- ${line}`)}`);
|
|
446
|
+
previewLines++;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
unchangedLines += lines.length;
|
|
452
|
+
}
|
|
453
|
+
if (previewLines >= maxPreviewLines)
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
if (previewLines >= maxPreviewLines && (addedLines + removedLines > previewLines)) {
|
|
457
|
+
colorConsole.gray(` ... (${addedLines + removedLines - previewLines} more changes)`);
|
|
458
|
+
}
|
|
459
|
+
const summaryText = `\n 📊 Change Summary: ${colorConsole.green(`+${addedLines} lines added`)}, ${colorConsole.red(`-${removedLines} lines removed`)}, ${unchangedLines} lines unchanged`;
|
|
460
|
+
colorConsole.log(summaryText);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
console.warn(`[DIFF] Failed to generate detailed diff for ${local.slug}:`, error.message);
|
|
465
|
+
console.log(' Unable to show content comparison');
|
|
466
|
+
}
|
|
467
|
+
console.log('');
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Display status/preview of changes
|
|
471
|
+
*/
|
|
472
|
+
async function displayStatus(operations, isStatusOnly = false, isSingleFile = false, showDetailedPreview = false, typeMap) {
|
|
473
|
+
if (isSingleFile) {
|
|
474
|
+
colorConsole.important('\n📄 LeadCMS File Status');
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
colorConsole.important('\n📊 LeadCMS Status');
|
|
478
|
+
}
|
|
479
|
+
colorConsole.log('');
|
|
480
|
+
// Summary line like git
|
|
481
|
+
const totalChanges = operations.create.length + operations.update.length + operations.rename.length + operations.typeChange.length + operations.conflict.length;
|
|
482
|
+
if (totalChanges === 0) {
|
|
483
|
+
if (isSingleFile) {
|
|
484
|
+
colorConsole.success('✅ File is in sync with remote content!');
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
colorConsole.success('✅ No changes detected. Everything is in sync!');
|
|
488
|
+
}
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
// For single file mode, show detailed diff information
|
|
492
|
+
if (isSingleFile) {
|
|
493
|
+
// Show detailed diff for each operation
|
|
494
|
+
for (const op of operations.create) {
|
|
495
|
+
await displayDetailedDiff(op, 'New file', typeMap);
|
|
496
|
+
}
|
|
497
|
+
for (const op of operations.update) {
|
|
498
|
+
await displayDetailedDiff(op, 'Modified', typeMap);
|
|
499
|
+
}
|
|
500
|
+
for (const op of operations.rename) {
|
|
501
|
+
await displayDetailedDiff(op, `Renamed (${op.oldSlug} → ${op.local.slug})`, typeMap);
|
|
502
|
+
}
|
|
503
|
+
for (const op of operations.typeChange) {
|
|
504
|
+
await displayDetailedDiff(op, `Type changed (${op.oldType} → ${op.newType})`, typeMap);
|
|
505
|
+
}
|
|
506
|
+
for (const op of operations.conflict) {
|
|
507
|
+
await displayDetailedDiff(op, `Conflict: ${op.reason}`, typeMap);
|
|
508
|
+
}
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
// Changes to be synced (like git's "Changes to be committed")
|
|
512
|
+
if (operations.create.length > 0 || operations.update.length > 0 || operations.rename.length > 0 || operations.typeChange.length > 0) {
|
|
513
|
+
const syncableChanges = operations.create.length + operations.update.length + operations.rename.length + operations.typeChange.length;
|
|
514
|
+
console.log(`Changes to be synced (${syncableChanges} files):`);
|
|
515
|
+
if (!isStatusOnly) {
|
|
516
|
+
console.log(' (use "leadcms status" to see sync status)');
|
|
517
|
+
}
|
|
518
|
+
console.log('');
|
|
519
|
+
// Helper function to sort operations by locale (ASC) then slug (ASC)
|
|
520
|
+
const sortOperations = (ops) => {
|
|
521
|
+
return ops.sort((a, b) => {
|
|
522
|
+
// First sort by locale
|
|
523
|
+
if (a.local.locale !== b.local.locale) {
|
|
524
|
+
return a.local.locale.localeCompare(b.local.locale);
|
|
525
|
+
}
|
|
526
|
+
// Then sort by slug within the same locale
|
|
527
|
+
return a.local.slug.localeCompare(b.local.slug);
|
|
528
|
+
});
|
|
529
|
+
};
|
|
530
|
+
// New content
|
|
531
|
+
for (const op of sortOperations([...operations.create])) {
|
|
532
|
+
const typeLabel = op.local.type.padEnd(12);
|
|
533
|
+
const localeLabel = `[${op.local.locale}]`.padEnd(6);
|
|
534
|
+
colorConsole.log(` ${statusColors.created('new file:')} ${typeLabel} ${localeLabel} ${colorConsole.highlight(op.local.slug)}`);
|
|
535
|
+
}
|
|
536
|
+
// Modified content
|
|
537
|
+
for (const op of sortOperations([...operations.update])) {
|
|
538
|
+
const typeLabel = op.local.type.padEnd(12);
|
|
539
|
+
const localeLabel = `[${op.local.locale}]`.padEnd(6);
|
|
540
|
+
const idLabel = op.remote?.id ? `(ID: ${op.remote.id})` : '';
|
|
541
|
+
colorConsole.log(` ${statusColors.modified('modified:')} ${typeLabel} ${localeLabel} ${colorConsole.highlight(op.local.slug)} ${colorConsole.gray(idLabel)}`);
|
|
542
|
+
}
|
|
543
|
+
// Renamed content (slug changed)
|
|
544
|
+
for (const op of sortOperations([...operations.rename])) {
|
|
545
|
+
const typeLabel = op.local.type.padEnd(12);
|
|
546
|
+
const localeLabel = `[${op.local.locale}]`.padEnd(6);
|
|
547
|
+
const idLabel = op.remote?.id ? `(ID: ${op.remote.id})` : '';
|
|
548
|
+
colorConsole.log(` ${statusColors.renamed('renamed:')} ${typeLabel} ${localeLabel} ${colorConsole.gray(op.oldSlug || 'unknown')} -> ${colorConsole.highlight(op.local.slug)} ${colorConsole.gray(idLabel)}`);
|
|
549
|
+
}
|
|
550
|
+
// Type changed content
|
|
551
|
+
for (const op of sortOperations([...operations.typeChange])) {
|
|
552
|
+
const typeLabel = op.local.type.padEnd(12);
|
|
553
|
+
const localeLabel = `[${op.local.locale}]`.padEnd(6);
|
|
554
|
+
const idLabel = op.remote?.id ? `(ID: ${op.remote.id})` : '';
|
|
555
|
+
const typeChangeLabel = `(${colorConsole.gray(op.oldType || 'unknown')} -> ${colorConsole.highlight(op.newType || 'unknown')})`;
|
|
556
|
+
colorConsole.log(` ${statusColors.typeChange('type change:')}${typeLabel} ${localeLabel} ${colorConsole.highlight(op.local.slug)} ${typeChangeLabel} ${colorConsole.gray(idLabel)}`);
|
|
557
|
+
}
|
|
558
|
+
// Show detailed previews if requested (and not in single file mode which already shows them)
|
|
559
|
+
if (showDetailedPreview && !isSingleFile) {
|
|
560
|
+
console.log('');
|
|
561
|
+
console.log('📋 Detailed Change Previews:');
|
|
562
|
+
console.log('');
|
|
563
|
+
// Show detailed diff for each operation (same as single file mode)
|
|
564
|
+
for (const op of sortOperations([...operations.create])) {
|
|
565
|
+
await displayDetailedDiff(op, 'New file', typeMap);
|
|
566
|
+
}
|
|
567
|
+
for (const op of sortOperations([...operations.update])) {
|
|
568
|
+
await displayDetailedDiff(op, 'Modified', typeMap);
|
|
569
|
+
}
|
|
570
|
+
for (const op of sortOperations([...operations.rename])) {
|
|
571
|
+
await displayDetailedDiff(op, `Renamed (${op.oldSlug} → ${op.local.slug})`, typeMap);
|
|
572
|
+
}
|
|
573
|
+
for (const op of sortOperations([...operations.typeChange])) {
|
|
574
|
+
await displayDetailedDiff(op, `Type changed (${op.oldType} → ${op.newType})`, typeMap);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
console.log('');
|
|
578
|
+
} // Conflicts (like git's merge conflicts)
|
|
579
|
+
if (operations.conflict.length > 0) {
|
|
580
|
+
colorConsole.warn(`⚠️ Unmerged conflicts (${operations.conflict.length} files):`);
|
|
581
|
+
colorConsole.info(' (use "leadcms pull" to merge remote changes)');
|
|
582
|
+
colorConsole.log('');
|
|
583
|
+
// Sort conflicts by locale then slug as well
|
|
584
|
+
const sortedConflicts = [...operations.conflict].sort((a, b) => {
|
|
585
|
+
if (a.local.locale !== b.local.locale) {
|
|
586
|
+
return a.local.locale.localeCompare(b.local.locale);
|
|
587
|
+
}
|
|
588
|
+
return a.local.slug.localeCompare(b.local.slug);
|
|
589
|
+
});
|
|
590
|
+
for (const op of sortedConflicts) {
|
|
591
|
+
const typeLabel = op.local.type.padEnd(12);
|
|
592
|
+
const localeLabel = `[${op.local.locale}]`.padEnd(6);
|
|
593
|
+
colorConsole.log(` ${statusColors.conflict('conflict:')} ${typeLabel} ${localeLabel} ${colorConsole.highlight(op.local.slug)}`);
|
|
594
|
+
colorConsole.log(` ${colorConsole.gray(op.reason || 'Unknown conflict')}`);
|
|
595
|
+
}
|
|
596
|
+
colorConsole.log('');
|
|
597
|
+
// Show detailed previews for conflicts if requested (and not in single file mode)
|
|
598
|
+
if (showDetailedPreview && !isSingleFile && operations.conflict.length > 0) {
|
|
599
|
+
console.log('');
|
|
600
|
+
console.log('📋 Detailed Conflict Previews:');
|
|
601
|
+
console.log('');
|
|
602
|
+
for (const op of sortedConflicts) {
|
|
603
|
+
await displayDetailedDiff(op, `Conflict: ${op.reason}`, typeMap);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (!isStatusOnly) {
|
|
607
|
+
colorConsole.important('💡 To resolve conflicts:');
|
|
608
|
+
colorConsole.info(' • Run "leadcms pull" to fetch latest changes');
|
|
609
|
+
colorConsole.info(' • Resolve conflicts in local files');
|
|
610
|
+
colorConsole.info(' • Run "leadcms push" again');
|
|
611
|
+
colorConsole.warn(' • Or use "leadcms push --force" to override remote changes (⚠️ data loss risk)');
|
|
612
|
+
colorConsole.log('');
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Display what API calls would be made without executing them
|
|
618
|
+
*/
|
|
619
|
+
async function showDryRunOperations(operations) {
|
|
620
|
+
// Check if there are any operations to show
|
|
621
|
+
const totalOperations = operations.create.length + operations.update.length +
|
|
622
|
+
operations.rename.length + operations.typeChange.length;
|
|
623
|
+
if (totalOperations === 0) {
|
|
624
|
+
return; // Don't show dry run preview if there are no operations
|
|
625
|
+
}
|
|
626
|
+
colorConsole.important('\n🧪 Dry Run Mode - API Calls Preview');
|
|
627
|
+
colorConsole.info('The following API calls would be made:\n');
|
|
628
|
+
// Create operations
|
|
629
|
+
if (operations.create.length > 0) {
|
|
630
|
+
colorConsole.progress(`\n📤 CREATE Operations (${operations.create.length}):`);
|
|
631
|
+
for (const op of operations.create) {
|
|
632
|
+
const contentData = formatContentForAPI(op.local);
|
|
633
|
+
colorConsole.log(`\n${colorConsole.cyan('POST')} ${colorConsole.highlight('/api/content')}`);
|
|
634
|
+
colorConsole.log(`${colorConsole.gray('Content-Type:')} application/json`);
|
|
635
|
+
colorConsole.log(`\n${colorConsole.gray('Request Body:')}`);
|
|
636
|
+
colorConsole.log(JSON.stringify(contentData, null, 2));
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
// Update operations
|
|
640
|
+
if (operations.update.length > 0) {
|
|
641
|
+
colorConsole.progress(`\n🔄 UPDATE Operations (${operations.update.length}):`);
|
|
642
|
+
for (const op of operations.update) {
|
|
643
|
+
if (op.remote?.id) {
|
|
644
|
+
const contentData = formatContentForAPI(op.local);
|
|
645
|
+
colorConsole.log(`\n${colorConsole.yellow('PUT')} ${colorConsole.highlight(`/api/content/${op.remote.id}`)}`);
|
|
646
|
+
colorConsole.log(`${colorConsole.gray('Content-Type:')} application/json`);
|
|
647
|
+
colorConsole.log(`\n${colorConsole.gray('Request Body:')}`);
|
|
648
|
+
colorConsole.log(JSON.stringify(contentData, null, 2));
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
// Rename operations (implemented as updates)
|
|
653
|
+
if (operations.rename.length > 0) {
|
|
654
|
+
colorConsole.progress(`\n📝 RENAME Operations (${operations.rename.length}):`);
|
|
655
|
+
for (const op of operations.rename) {
|
|
656
|
+
if (op.remote?.id) {
|
|
657
|
+
const contentData = formatContentForAPI(op.local);
|
|
658
|
+
colorConsole.log(`\n${colorConsole.yellow('PUT')} ${colorConsole.highlight(`/api/content/${op.remote.id}`)}`);
|
|
659
|
+
colorConsole.log(`${colorConsole.gray('Content-Type:')} application/json`);
|
|
660
|
+
colorConsole.log(`${colorConsole.gray('Note:')} Renaming ${colorConsole.gray(op.oldSlug || 'unknown')} → ${colorConsole.highlight(op.local.slug)}`);
|
|
661
|
+
colorConsole.log(`\n${colorConsole.gray('Request Body:')}`);
|
|
662
|
+
colorConsole.log(JSON.stringify(contentData, null, 2));
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
// Type change operations (implemented as updates)
|
|
667
|
+
if (operations.typeChange.length > 0) {
|
|
668
|
+
colorConsole.progress(`\n🔀 TYPE CHANGE Operations (${operations.typeChange.length}):`);
|
|
669
|
+
for (const op of operations.typeChange) {
|
|
670
|
+
if (op.remote?.id) {
|
|
671
|
+
const contentData = formatContentForAPI(op.local);
|
|
672
|
+
colorConsole.log(`\n${colorConsole.yellow('PUT')} ${colorConsole.highlight(`/api/content/${op.remote.id}`)}`);
|
|
673
|
+
colorConsole.log(`${colorConsole.gray('Content-Type:')} application/json`);
|
|
674
|
+
colorConsole.log(`${colorConsole.gray('Note:')} Type change ${colorConsole.gray(op.oldType || 'unknown')} → ${colorConsole.highlight(op.newType || 'unknown')}`);
|
|
675
|
+
colorConsole.log(`\n${colorConsole.gray('Request Body:')}`);
|
|
676
|
+
colorConsole.log(JSON.stringify(contentData, null, 2));
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
colorConsole.log('\n');
|
|
681
|
+
colorConsole.important('💡 No actual API calls were made. Use without --dry-run to execute.');
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Main function for push command
|
|
685
|
+
*/
|
|
686
|
+
async function pushMain(options = {}) {
|
|
687
|
+
const { statusOnly = false, force = false, targetId, targetSlug, showDetailedPreview = false, dryRun = false } = options;
|
|
688
|
+
try {
|
|
689
|
+
const isSingleFileMode = !!(targetId || targetSlug);
|
|
690
|
+
const actionDescription = statusOnly ? 'status check' : 'push';
|
|
691
|
+
const targetDescription = targetId ? `ID ${targetId}` : targetSlug ? `slug "${targetSlug}"` : 'all content';
|
|
692
|
+
console.log(`[PUSH] Starting ${actionDescription} for ${targetDescription}...`);
|
|
693
|
+
// Read local content
|
|
694
|
+
const localContent = await readLocalContent();
|
|
695
|
+
if (localContent.length === 0) {
|
|
696
|
+
console.log('📂 No local content found. Nothing to sync.');
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
// Fetch remote content types for content transformation
|
|
700
|
+
const remoteTypes = await leadCMSDataService.getContentTypes();
|
|
701
|
+
const remoteTypeMap = {};
|
|
702
|
+
remoteTypes.forEach(type => {
|
|
703
|
+
remoteTypeMap[type.uid] = type.format;
|
|
704
|
+
});
|
|
705
|
+
// Filter local content if targeting specific content
|
|
706
|
+
let filteredLocalContent = localContent;
|
|
707
|
+
if (isSingleFileMode) {
|
|
708
|
+
filteredLocalContent = localContent.filter(content => {
|
|
709
|
+
if (targetId && content.metadata.id?.toString() === targetId)
|
|
710
|
+
return true;
|
|
711
|
+
if (targetSlug && content.slug === targetSlug)
|
|
712
|
+
return true;
|
|
713
|
+
return false;
|
|
714
|
+
});
|
|
715
|
+
if (filteredLocalContent.length === 0) {
|
|
716
|
+
console.log(`❌ No local content found with ${targetId ? `ID ${targetId}` : `slug "${targetSlug}"`}`);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
console.log(`[LOCAL] Found ${filteredLocalContent.length} matching local file(s)`);
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
// Get local content types and validate them
|
|
723
|
+
const localTypes = getLocalContentTypes(localContent);
|
|
724
|
+
console.log(`[LOCAL] Found content types: ${Array.from(localTypes).join(', ')}`);
|
|
725
|
+
await validateContentTypes(localTypes, remoteTypeMap, dryRun);
|
|
726
|
+
}
|
|
727
|
+
// Fetch remote content for comparison
|
|
728
|
+
const remoteContent = await fetchRemoteContent();
|
|
729
|
+
// Match local vs remote content with type mapping for proper content transformation
|
|
730
|
+
const operations = await matchContent(filteredLocalContent, remoteContent, remoteTypeMap);
|
|
731
|
+
// Filter operations if targeting specific content
|
|
732
|
+
const finalOperations = isSingleFileMode ?
|
|
733
|
+
filterContentOperations(operations, targetId, targetSlug) :
|
|
734
|
+
operations;
|
|
735
|
+
// Check if we found the target content
|
|
736
|
+
if (isSingleFileMode) {
|
|
737
|
+
const totalChanges = finalOperations.create.length + finalOperations.update.length +
|
|
738
|
+
finalOperations.rename.length + finalOperations.typeChange.length +
|
|
739
|
+
finalOperations.conflict.length;
|
|
740
|
+
if (totalChanges === 0 && filteredLocalContent.length > 0) {
|
|
741
|
+
// We have local content but no operations - it's in sync
|
|
742
|
+
console.log(`✅ Content with ${targetId ? `ID ${targetId}` : `slug "${targetSlug}"`} is in sync`);
|
|
743
|
+
}
|
|
744
|
+
else if (totalChanges === 0) {
|
|
745
|
+
console.log(`❌ No content found with ${targetId ? `ID ${targetId}` : `slug "${targetSlug}"`} in remote or local`);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
// Display status
|
|
750
|
+
await displayStatus(finalOperations, statusOnly, isSingleFileMode, showDetailedPreview, remoteTypeMap);
|
|
751
|
+
// If status only, we're done
|
|
752
|
+
if (statusOnly) {
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
// If dry run mode, show API calls without executing
|
|
756
|
+
if (dryRun) {
|
|
757
|
+
await showDryRunOperations(finalOperations);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
// Handle conflicts
|
|
761
|
+
if (finalOperations.conflict.length > 0 && !force) {
|
|
762
|
+
console.log('\n❌ Cannot proceed due to conflicts. Use --force to override or resolve conflicts first.');
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
const totalChanges = finalOperations.create.length + finalOperations.update.length + finalOperations.rename.length + finalOperations.typeChange.length;
|
|
766
|
+
if (totalChanges === 0) {
|
|
767
|
+
if (isSingleFileMode) {
|
|
768
|
+
console.log('✅ File is already in sync.');
|
|
769
|
+
}
|
|
770
|
+
else {
|
|
771
|
+
console.log('✅ Nothing to sync.');
|
|
772
|
+
}
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
// Confirm changes
|
|
776
|
+
const itemDescription = isSingleFileMode ? 'file change' : 'changes';
|
|
777
|
+
const confirmMsg = `\nProceed with syncing ${totalChanges} ${itemDescription} to LeadCMS? (y/N): `;
|
|
778
|
+
const confirmation = await question(confirmMsg);
|
|
779
|
+
if (confirmation.toLowerCase() !== 'y' && confirmation.toLowerCase() !== 'yes') {
|
|
780
|
+
console.log('🚫 Push cancelled.');
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
// Execute the sync
|
|
784
|
+
await executePush(finalOperations, { force });
|
|
785
|
+
colorConsole.success('\n🎉 Content push completed successfully!');
|
|
786
|
+
}
|
|
787
|
+
catch (error) {
|
|
788
|
+
const operation = statusOnly ? 'Status check' : 'Push';
|
|
789
|
+
console.error(`❌ ${operation} failed:`, error.message);
|
|
790
|
+
process.exit(1);
|
|
791
|
+
}
|
|
792
|
+
finally {
|
|
793
|
+
rl.close();
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Execute the actual push operations
|
|
798
|
+
*/
|
|
799
|
+
async function executePush(operations, options = {}) {
|
|
800
|
+
const { force = false } = options;
|
|
801
|
+
// Handle force updates for conflicts
|
|
802
|
+
if (force && operations.conflict.length > 0) {
|
|
803
|
+
console.log(`\n🔄 Force updating ${operations.conflict.length} conflicted items...`);
|
|
804
|
+
for (const conflict of operations.conflict) {
|
|
805
|
+
operations.update.push({
|
|
806
|
+
local: conflict.local,
|
|
807
|
+
remote: conflict.remote
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
// Use individual operations
|
|
812
|
+
await executeIndividualOperations(operations, { force });
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Execute operations individually (one by one)
|
|
816
|
+
*/
|
|
817
|
+
async function executeIndividualOperations(operations, options = {}) {
|
|
818
|
+
const { force = false } = options;
|
|
819
|
+
let successful = 0;
|
|
820
|
+
let failed = 0;
|
|
821
|
+
// Create new content
|
|
822
|
+
if (operations.create.length > 0) {
|
|
823
|
+
console.log(`\n🆕 Creating ${operations.create.length} new items...`);
|
|
824
|
+
for (const op of operations.create) {
|
|
825
|
+
try {
|
|
826
|
+
const result = await leadCMSDataService.createContent(formatContentForAPI(op.local));
|
|
827
|
+
if (result) {
|
|
828
|
+
await updateLocalMetadata(op.local, result);
|
|
829
|
+
successful++;
|
|
830
|
+
colorConsole.success(`✅ Created: ${colorConsole.highlight(`${op.local.type}/${op.local.slug}`)}`);
|
|
831
|
+
}
|
|
832
|
+
else {
|
|
833
|
+
failed++;
|
|
834
|
+
colorConsole.error(`❌ Failed to create: ${colorConsole.highlight(`${op.local.type}/${op.local.slug}`)}`);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
catch (error) {
|
|
838
|
+
failed++;
|
|
839
|
+
colorConsole.error(`❌ Failed to create ${colorConsole.highlight(`${op.local.type}/${op.local.slug}`)}:`, error.message);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
// Update existing content
|
|
844
|
+
if (operations.update.length > 0) {
|
|
845
|
+
console.log(`\n🔄 Updating ${operations.update.length} existing items...`);
|
|
846
|
+
for (const op of operations.update) {
|
|
847
|
+
try {
|
|
848
|
+
if (op.remote?.id) {
|
|
849
|
+
const result = await leadCMSDataService.updateContent(op.remote.id, formatContentForAPI(op.local));
|
|
850
|
+
if (result) {
|
|
851
|
+
await updateLocalMetadata(op.local, result);
|
|
852
|
+
successful++;
|
|
853
|
+
colorConsole.success(`✅ Updated: ${colorConsole.highlight(`${op.local.type}/${op.local.slug}`)}`);
|
|
854
|
+
}
|
|
855
|
+
else {
|
|
856
|
+
failed++;
|
|
857
|
+
colorConsole.error(`❌ Failed to update: ${colorConsole.highlight(`${op.local.type}/${op.local.slug}`)}`);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
else {
|
|
861
|
+
failed++;
|
|
862
|
+
colorConsole.error(`❌ Failed to update ${colorConsole.highlight(`${op.local.type}/${op.local.slug}`)}: No remote ID`);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
catch (error) {
|
|
866
|
+
failed++;
|
|
867
|
+
console.log(`❌ Failed to update ${op.local.type}/${op.local.slug}:`, error.message);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
// Handle renamed content (slug changed)
|
|
872
|
+
if (operations.rename.length > 0) {
|
|
873
|
+
console.log(`\n📝 Renaming ${operations.rename.length} items...`);
|
|
874
|
+
for (const op of operations.rename) {
|
|
875
|
+
try {
|
|
876
|
+
if (op.remote?.id) {
|
|
877
|
+
const result = await leadCMSDataService.updateContent(op.remote.id, formatContentForAPI(op.local));
|
|
878
|
+
if (result) {
|
|
879
|
+
await updateLocalMetadata(op.local, result);
|
|
880
|
+
successful++;
|
|
881
|
+
console.log(`✅ Renamed: ${op.oldSlug} -> ${op.local.slug}`);
|
|
882
|
+
}
|
|
883
|
+
else {
|
|
884
|
+
failed++;
|
|
885
|
+
console.log(`❌ Failed to rename: ${op.oldSlug} -> ${op.local.slug}`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
else {
|
|
889
|
+
failed++;
|
|
890
|
+
console.log(`❌ Failed to rename ${op.oldSlug}: No remote ID`);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
catch (error) {
|
|
894
|
+
failed++;
|
|
895
|
+
console.log(`❌ Failed to rename ${op.oldSlug}:`, error.message);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
// Handle content type changes
|
|
900
|
+
if (operations.typeChange.length > 0) {
|
|
901
|
+
console.log(`\n🔄 Changing content types for ${operations.typeChange.length} items...`);
|
|
902
|
+
for (const op of operations.typeChange) {
|
|
903
|
+
try {
|
|
904
|
+
if (op.remote?.id) {
|
|
905
|
+
const result = await leadCMSDataService.updateContent(op.remote.id, formatContentForAPI(op.local));
|
|
906
|
+
if (result) {
|
|
907
|
+
await updateLocalMetadata(op.local, result);
|
|
908
|
+
successful++;
|
|
909
|
+
console.log(`✅ Type changed: ${op.local.slug} (${op.oldType} -> ${op.newType})`);
|
|
910
|
+
}
|
|
911
|
+
else {
|
|
912
|
+
failed++;
|
|
913
|
+
console.log(`❌ Failed to change type: ${op.local.slug} (${op.oldType} -> ${op.newType})`);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
else {
|
|
917
|
+
failed++;
|
|
918
|
+
console.log(`❌ Failed to change type for ${op.local.slug}: No remote ID`);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
catch (error) {
|
|
922
|
+
failed++;
|
|
923
|
+
console.log(`❌ Failed to change type for ${op.local.slug}:`, error.message);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
console.log(`\n📊 Results: ${successful} successful, ${failed} failed`);
|
|
928
|
+
// If any updates were successful, automatically pull latest changes to sync local store
|
|
929
|
+
if (successful > 0) {
|
|
930
|
+
console.log(`\n🔄 Syncing latest changes from LeadCMS to local store...`);
|
|
931
|
+
try {
|
|
932
|
+
const { fetchLeadCMSContent } = await import('./fetch-leadcms-content.js');
|
|
933
|
+
await fetchLeadCMSContent();
|
|
934
|
+
console.log('✅ Local content store synchronized with latest changes');
|
|
935
|
+
}
|
|
936
|
+
catch (error) {
|
|
937
|
+
console.warn('⚠️ Failed to automatically sync local content:', error.message);
|
|
938
|
+
console.log('💡 You may want to manually run the pull command to sync latest changes');
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Format local content for API submission
|
|
944
|
+
*/
|
|
945
|
+
function formatContentForAPI(localContent) {
|
|
946
|
+
const contentData = {
|
|
947
|
+
slug: localContent.slug,
|
|
948
|
+
type: localContent.type,
|
|
949
|
+
language: localContent.locale,
|
|
950
|
+
body: localContent.body,
|
|
951
|
+
...localContent.metadata
|
|
952
|
+
};
|
|
953
|
+
// Preserve the file-based slug (from localContent.slug) over metadata slug
|
|
954
|
+
// This is crucial for rename operations where the file has been renamed
|
|
955
|
+
// but the frontmatter still contains the old slug
|
|
956
|
+
if (localContent.slug !== localContent.metadata?.slug) {
|
|
957
|
+
contentData.slug = localContent.slug;
|
|
958
|
+
}
|
|
959
|
+
// Remove local-only fields
|
|
960
|
+
delete contentData.filePath;
|
|
961
|
+
delete contentData.isLocal;
|
|
962
|
+
// Apply backward URL transformation: convert /media/ paths back to /api/media/ for API
|
|
963
|
+
return replaceLocalMediaPaths(contentData);
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Update local file with metadata from LeadCMS response
|
|
967
|
+
*/
|
|
968
|
+
async function updateLocalMetadata(localContent, remoteResponse) {
|
|
969
|
+
const { filePath } = localContent;
|
|
970
|
+
const ext = path.extname(filePath);
|
|
971
|
+
try {
|
|
972
|
+
if (ext === '.mdx') {
|
|
973
|
+
const fileContent = await fs.readFile(filePath, 'utf-8');
|
|
974
|
+
const parsed = matter(fileContent);
|
|
975
|
+
// Update metadata with response data (only non-system fields)
|
|
976
|
+
parsed.data.id = remoteResponse.id;
|
|
977
|
+
// Do not add system fields (createdAt, updatedAt, publishedAt) to local files
|
|
978
|
+
// Rebuild the file
|
|
979
|
+
const newContent = matter.stringify(parsed.content, parsed.data);
|
|
980
|
+
await fs.writeFile(filePath, newContent, 'utf-8');
|
|
981
|
+
}
|
|
982
|
+
else if (ext === '.json') {
|
|
983
|
+
const jsonData = JSON.parse(await fs.readFile(filePath, 'utf-8'));
|
|
984
|
+
// Update metadata (only non-system fields)
|
|
985
|
+
jsonData.id = remoteResponse.id;
|
|
986
|
+
// Do not add system fields (createdAt, updatedAt, publishedAt) to local files
|
|
987
|
+
await fs.writeFile(filePath, JSON.stringify(jsonData, null, 2), 'utf-8');
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
catch (error) {
|
|
991
|
+
console.warn(`Failed to update local metadata for ${filePath}:`, error.message);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
// Export functions for CLI usage
|
|
995
|
+
export { pushMain as pushLeadCMSContent };
|
|
996
|
+
// Export internal functions for testing
|
|
997
|
+
export { hasActualContentChanges };
|
|
998
|
+
// Re-export the new comparison function for consistency
|
|
999
|
+
export { transformRemoteForComparison } from "../lib/content-transformation.js";
|
|
1000
|
+
// Handle direct script execution only in ESM environment
|
|
1001
|
+
if (typeof import.meta !== 'undefined' && process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
|
|
1002
|
+
const args = process.argv.slice(2);
|
|
1003
|
+
const statusOnly = args.includes('--status');
|
|
1004
|
+
const force = args.includes('--force');
|
|
1005
|
+
const dryRun = args.includes('--dry-run');
|
|
1006
|
+
// Parse target ID or slug
|
|
1007
|
+
let targetId;
|
|
1008
|
+
let targetSlug;
|
|
1009
|
+
const idIndex = args.findIndex(arg => arg === '--id');
|
|
1010
|
+
if (idIndex !== -1 && args[idIndex + 1]) {
|
|
1011
|
+
targetId = args[idIndex + 1];
|
|
1012
|
+
}
|
|
1013
|
+
const slugIndex = args.findIndex(arg => arg === '--slug');
|
|
1014
|
+
if (slugIndex !== -1 && args[slugIndex + 1]) {
|
|
1015
|
+
targetSlug = args[slugIndex + 1];
|
|
1016
|
+
}
|
|
1017
|
+
pushMain({ statusOnly, force, targetId, targetSlug, dryRun }).catch((error) => {
|
|
1018
|
+
console.error('Error running LeadCMS push:', error.message);
|
|
1019
|
+
process.exit(1);
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
//# sourceMappingURL=push-leadcms-content.js.map
|