@mahe_pkm/buzl-capi 0.1.2
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/Buzl_GoogleAppsScript_Template.gs +948 -0
- package/CHANGELOG.md +83 -0
- package/LICENSE +21 -0
- package/README.md +253 -0
- package/assets/js/buzl-tracking.js +390 -0
- package/bin/cli.js +155 -0
- package/package.json +54 -0
- package/src/cli/terminal.js +388 -0
- package/src/core/injector.js +321 -0
- package/src/core/rollback.js +281 -0
- package/src/core/scanner.js +506 -0
- package/src/core/tester.js +357 -0
- package/src/gui/public/app.js +942 -0
- package/src/gui/public/index.html +634 -0
- package/src/gui/public/style.css +1130 -0
- package/src/gui/server.js +232 -0
- package/src/index.js +47 -0
- package/src/templates/GoogleAppsScript.gs +948 -0
- package/src/templates/buzl-tracking.js +475 -0
- package/src/templates/gtm-meta-snippets.js +149 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe HTML & Tracking Injector
|
|
3
|
+
* Injects GTM, Meta Pixel, Form Sync runtime, and configuration
|
|
4
|
+
*/
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const {
|
|
8
|
+
HEAD_START_MARKER,
|
|
9
|
+
HEAD_END_MARKER,
|
|
10
|
+
BODY_START_MARKER,
|
|
11
|
+
BODY_END_MARKER,
|
|
12
|
+
generateHeadSnippet,
|
|
13
|
+
generateBodySnippet
|
|
14
|
+
} = require('../templates/gtm-meta-snippets');
|
|
15
|
+
const { createBackup } = require('./rollback');
|
|
16
|
+
|
|
17
|
+
const RUNTIME_START_MARKER = '<!-- BUZL_TRACKING_RUNTIME_START -->';
|
|
18
|
+
const RUNTIME_END_MARKER = '<!-- BUZL_TRACKING_RUNTIME_END -->';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generate runtime script tag & inline configuration
|
|
22
|
+
*/
|
|
23
|
+
function generateRuntimeSnippet(config, scriptRelPath) {
|
|
24
|
+
const zohoObj = config.zoho || {};
|
|
25
|
+
const zohoXn = (config.zohoXnqsjsdp || zohoObj.xnQsjsdp || '').trim();
|
|
26
|
+
const zohoEndpoint = zohoXn ? (config.zohoEndpoint || zohoObj.endpoint || 'https://crm.zoho.in/crm/WebToLeadForm') : '';
|
|
27
|
+
const zohoXm = zohoXn ? (config.zohoXmiwtld || zohoObj.xmIwtLD || '') : '';
|
|
28
|
+
const zohoFields = zohoXn ? (config.zohoFields || zohoObj.fields || { lastName: 'Last Name', phone: 'Mobile', location: 'City' }) : {};
|
|
29
|
+
|
|
30
|
+
const clientConfig = {
|
|
31
|
+
enableGTM: !!(config.gtmId && config.gtmId.trim()),
|
|
32
|
+
gtmEvent: config.gtmEvent || 'lead_form_submitted',
|
|
33
|
+
enableMeta: !!(config.metaPixelId && config.metaPixelId.trim()),
|
|
34
|
+
trackMetaLeadEvent: !!config.trackMetaLeadEvent,
|
|
35
|
+
googleSheetUrl: config.googleSheetUrl || '',
|
|
36
|
+
siteLocation: config.siteLocation || '',
|
|
37
|
+
zoho: {
|
|
38
|
+
endpoint: zohoEndpoint,
|
|
39
|
+
xnQsjsdp: zohoXn,
|
|
40
|
+
xmIwtLD: zohoXm,
|
|
41
|
+
fields: zohoFields
|
|
42
|
+
},
|
|
43
|
+
buzlCapi: {
|
|
44
|
+
endpoint: (config.buzlCapi && config.buzlCapi.endpoint) || config.buzlCapiEndpoint || '',
|
|
45
|
+
authUser: (config.buzlCapi && config.buzlCapi.authUser) || config.buzlCapiUser || '',
|
|
46
|
+
authPass: (config.buzlCapi && config.buzlCapi.authPass) || config.buzlCapiPass || ''
|
|
47
|
+
},
|
|
48
|
+
whatsapp: {
|
|
49
|
+
number: config.whatsappNumber || (config.whatsapp && config.whatsapp.number) || '',
|
|
50
|
+
template: config.whatsappTemplate || (config.whatsapp && config.whatsapp.template) || 'Hi, I submitted an inquiry from {name} in {location}.'
|
|
51
|
+
},
|
|
52
|
+
safetyTimeoutMs: config.safetyTimeoutMs || 800
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return [
|
|
56
|
+
RUNTIME_START_MARKER,
|
|
57
|
+
' <script id="buzl-tracking-config">',
|
|
58
|
+
` window.__BUZL_CONFIG__ = ${JSON.stringify(clientConfig, null, 2)};`,
|
|
59
|
+
' </script>',
|
|
60
|
+
` <script src="${scriptRelPath}" defer></script>`,
|
|
61
|
+
RUNTIME_END_MARKER
|
|
62
|
+
].join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Injects tracking into a single HTML string
|
|
67
|
+
*/
|
|
68
|
+
function injectHtml(htmlContent, config, scriptRelPath = 'assets/js/buzl-tracking.js') {
|
|
69
|
+
let content = htmlContent;
|
|
70
|
+
|
|
71
|
+
const hasHeadTrackers = !!((config.gtmId && config.gtmId.trim()) || (config.metaPixelId && config.metaPixelId.trim()));
|
|
72
|
+
const headSnippet = hasHeadTrackers ? generateHeadSnippet(config) : '';
|
|
73
|
+
const bodySnippet = hasHeadTrackers ? generateBodySnippet(config) : '';
|
|
74
|
+
const runtimeSnippet = generateRuntimeSnippet(config, scriptRelPath);
|
|
75
|
+
|
|
76
|
+
// 1. Head snippet replacement or injection
|
|
77
|
+
const headMarkerRegex = new RegExp(`[\\t ]*${HEAD_START_MARKER}[\\s\\S]*?${HEAD_END_MARKER}\\r?\\n?`, 'g');
|
|
78
|
+
if (headMarkerRegex.test(content)) {
|
|
79
|
+
content = content.replace(headMarkerRegex, hasHeadTrackers ? `${headSnippet}\n` : '');
|
|
80
|
+
} else if (hasHeadTrackers) {
|
|
81
|
+
if (/<\/head>/i.test(content)) {
|
|
82
|
+
content = content.replace(/<\/head>/i, `${headSnippet}\n</head>`);
|
|
83
|
+
} else if (/<head[\s>]/i.test(content)) {
|
|
84
|
+
content = content.replace(/(<head[\s>][^>]*>)/i, `$1\n${headSnippet}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 2. Body noscript snippet replacement or injection
|
|
89
|
+
const bodyMarkerRegex = new RegExp(`[\\t ]*${BODY_START_MARKER}[\\s\\S]*?${BODY_END_MARKER}\\r?\\n?`, 'g');
|
|
90
|
+
if (bodyMarkerRegex.test(content)) {
|
|
91
|
+
content = content.replace(bodyMarkerRegex, hasHeadTrackers ? `${bodySnippet}\n` : '');
|
|
92
|
+
} else if (hasHeadTrackers) {
|
|
93
|
+
if (/<body[\s>]/i.test(content)) {
|
|
94
|
+
content = content.replace(/(<body[\s>][^>]*>)/i, `$1\n${bodySnippet}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 3. Runtime script replacement or injection
|
|
99
|
+
const runtimeMarkerRegex = new RegExp(`${RUNTIME_START_MARKER}[\\s\\S]*?${RUNTIME_END_MARKER}`, 'g');
|
|
100
|
+
if (runtimeMarkerRegex.test(content)) {
|
|
101
|
+
content = content.replace(runtimeMarkerRegex, runtimeSnippet);
|
|
102
|
+
} else if (/<\/body>/i.test(content)) {
|
|
103
|
+
content = content.replace(/<\/body>/i, `${runtimeSnippet}\n</body>`);
|
|
104
|
+
} else {
|
|
105
|
+
content += `\n${runtimeSnippet}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 4. Form tag attribute injection (adds data-buzl-track="true" to forms if missing)
|
|
109
|
+
content = content.replace(/<form\b(?![^>]*\bdata-buzl-)([^>]*)>/gi, (match, p1) => {
|
|
110
|
+
return `<form data-buzl-track="true"${p1}>`;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return content;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Apply injection across the whole project
|
|
118
|
+
*/
|
|
119
|
+
function applyInjection(rootDir, htmlFiles, config) {
|
|
120
|
+
// If whatsapp number is not explicitly configured, fallback to auto-detected number from buttons
|
|
121
|
+
if (!config.whatsappNumber && !(config.whatsapp && config.whatsapp.number)) {
|
|
122
|
+
try {
|
|
123
|
+
const { scanProject } = require('./scanner');
|
|
124
|
+
const scan = scanProject(rootDir);
|
|
125
|
+
if (scan && scan.detectedWhatsapp) {
|
|
126
|
+
config = Object.assign({}, config, {
|
|
127
|
+
whatsappNumber: scan.detectedWhatsapp,
|
|
128
|
+
whatsapp: Object.assign({}, config.whatsapp || {}, { number: scan.detectedWhatsapp, autoDetected: true })
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
} catch (e) {}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 1. Ensure backup first
|
|
135
|
+
const backup = createBackup(htmlFiles, rootDir);
|
|
136
|
+
|
|
137
|
+
// 2. Deploy runtime SDK script into assets/js/
|
|
138
|
+
const targetJsDir = path.join(rootDir, 'assets', 'js');
|
|
139
|
+
fs.mkdirSync(targetJsDir, { recursive: true });
|
|
140
|
+
|
|
141
|
+
const sdkSourcePath = path.join(__dirname, '..', 'templates', 'buzl-tracking.js');
|
|
142
|
+
const sdkDestPath = path.join(targetJsDir, 'buzl-tracking.js');
|
|
143
|
+
fs.copyFileSync(sdkSourcePath, sdkDestPath);
|
|
144
|
+
|
|
145
|
+
// 3. Deploy GoogleAppsScript.gs to root or reference folder
|
|
146
|
+
const gsSource = path.join(__dirname, '..', 'templates', 'GoogleAppsScript.gs');
|
|
147
|
+
const gsDest = path.join(rootDir, 'Buzl_GoogleAppsScript_Template.gs');
|
|
148
|
+
if (!fs.existsSync(gsDest)) {
|
|
149
|
+
fs.copyFileSync(gsSource, gsDest);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 4. Process each HTML file
|
|
153
|
+
const modifiedFiles = [];
|
|
154
|
+
for (const filePath of htmlFiles) {
|
|
155
|
+
const originalHtml = fs.readFileSync(filePath, 'utf8');
|
|
156
|
+
// Calculate relative path to assets/js/buzl-tracking.js
|
|
157
|
+
let scriptRelPath = 'assets/js/buzl-tracking.js';
|
|
158
|
+
if (config && config.pathMode === 'root-absolute') {
|
|
159
|
+
scriptRelPath = '/assets/js/buzl-tracking.js';
|
|
160
|
+
} else {
|
|
161
|
+
const relToTarget = path.relative(path.dirname(filePath), targetJsDir).replace(/\\/g, '/');
|
|
162
|
+
scriptRelPath = relToTarget ? `${relToTarget}/buzl-tracking.js` : 'assets/js/buzl-tracking.js';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const modifiedHtml = injectHtml(originalHtml, config, scriptRelPath);
|
|
166
|
+
fs.writeFileSync(filePath, modifiedHtml, 'utf8');
|
|
167
|
+
modifiedFiles.push({
|
|
168
|
+
path: filePath,
|
|
169
|
+
relative: path.relative(rootDir, filePath).replace(/\\/g, '/')
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
success: true,
|
|
175
|
+
backupDir: backup.backupDir,
|
|
176
|
+
modifiedFiles,
|
|
177
|
+
runtimeScript: path.relative(rootDir, sdkDestPath),
|
|
178
|
+
googleAppsScriptFile: path.relative(rootDir, gsDest)
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Cleanly remove all Buzl tracking tags and restore pristine HTML files
|
|
184
|
+
*/
|
|
185
|
+
function removeTracking(rootDir, htmlFiles) {
|
|
186
|
+
if (!htmlFiles || !Array.isArray(htmlFiles)) {
|
|
187
|
+
const { findHtmlFiles } = require('./scanner');
|
|
188
|
+
htmlFiles = findHtmlFiles(rootDir);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 1. Create a named safety backup before removal
|
|
192
|
+
const backup = createBackup(htmlFiles, rootDir, 'Pre-Uninstall Clean Backup');
|
|
193
|
+
|
|
194
|
+
const headMarkerRegex = new RegExp(`[\\t ]*${HEAD_START_MARKER}[\\s\\S]*?${HEAD_END_MARKER}\\r?\\n?`, 'g');
|
|
195
|
+
const bodyMarkerRegex = new RegExp(`[\\t ]*${BODY_START_MARKER}[\\s\\S]*?${BODY_END_MARKER}\\r?\\n?`, 'g');
|
|
196
|
+
const runtimeMarkerRegex = new RegExp(`[\\t ]*${RUNTIME_START_MARKER}[\\s\\S]*?${RUNTIME_END_MARKER}\\r?\\n?`, 'g');
|
|
197
|
+
|
|
198
|
+
const cleanedFiles = [];
|
|
199
|
+
|
|
200
|
+
for (const filePath of htmlFiles) {
|
|
201
|
+
let content = fs.readFileSync(filePath, 'utf8');
|
|
202
|
+
|
|
203
|
+
// Strip markers
|
|
204
|
+
content = content.replace(headMarkerRegex, '');
|
|
205
|
+
content = content.replace(bodyMarkerRegex, '');
|
|
206
|
+
content = content.replace(runtimeMarkerRegex, '');
|
|
207
|
+
|
|
208
|
+
// Strip data-buzl-track="true" attribute from forms
|
|
209
|
+
content = content.replace(/\s*data-buzl-track=["']true["']/gi, '');
|
|
210
|
+
|
|
211
|
+
// Clean up any double blank lines before </head> or </body>
|
|
212
|
+
content = content.replace(/(\r?\n\s*){2,}<\/head>/i, '\n</head>');
|
|
213
|
+
content = content.replace(/(\r?\n\s*){2,}<\/body>/i, '\n</body>');
|
|
214
|
+
|
|
215
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
216
|
+
cleanedFiles.push({
|
|
217
|
+
path: filePath,
|
|
218
|
+
relative: path.relative(rootDir, filePath)
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
success: true,
|
|
224
|
+
backupDir: backup.backupDir,
|
|
225
|
+
cleanedFiles,
|
|
226
|
+
message: `Successfully removed all tracking from ${cleanedFiles.length} file(s). Clean safety backup saved in ${path.basename(backup.backupDir)}.`
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Selectively remove a single tracking service or configuration without breaking other services
|
|
232
|
+
*/
|
|
233
|
+
function removeService(rootDir, serviceName) {
|
|
234
|
+
const normService = (serviceName || '').toLowerCase().trim();
|
|
235
|
+
const { scanProject } = require('./scanner');
|
|
236
|
+
const scan = scanProject(rootDir);
|
|
237
|
+
const htmlFiles = scan.files.map(f => f.filePath);
|
|
238
|
+
|
|
239
|
+
if (htmlFiles.length === 0) {
|
|
240
|
+
return { success: false, message: 'No HTML files found in project.' };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// If full uninstall requested
|
|
244
|
+
if (normService === 'all' || normService === 'everything') {
|
|
245
|
+
return removeTracking(rootDir, htmlFiles);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 1. Create safety snapshot backup before selective removal
|
|
249
|
+
const backup = createBackup(htmlFiles, rootDir, `Pre-Remove ${normService.toUpperCase()} Backup`);
|
|
250
|
+
|
|
251
|
+
// 2. Derive new configuration by blanking the selected service
|
|
252
|
+
const current = scan.existingConfig || {};
|
|
253
|
+
const newConfig = {
|
|
254
|
+
gtmId: normService === 'gtm' ? '' : (current.gtmId || (normService !== 'gtm' && scan.liveState.gtm.id) || ''),
|
|
255
|
+
enableDeferred: current.enableDeferred !== false,
|
|
256
|
+
metaPixelId: normService === 'meta' ? '' : (current.metaPixelId || (normService !== 'meta' && scan.liveState.meta.id) || ''),
|
|
257
|
+
trackMetaLeadEvent: normService === 'meta' ? false : !!current.trackMetaLeadEvent,
|
|
258
|
+
googleSheetUrl: normService === 'sheets' ? '' : (current.googleSheetUrl || ''),
|
|
259
|
+
siteLocation: current.siteLocation || scan.detectedLocation || '',
|
|
260
|
+
dynamicFields: current.dynamicFields || [],
|
|
261
|
+
buzlCapi: {
|
|
262
|
+
endpoint: normService === 'capi' ? '' : ((current.buzlCapi && current.buzlCapi.endpoint) || ''),
|
|
263
|
+
authUser: normService === 'capi' ? '' : ((current.buzlCapi && current.buzlCapi.authUser) || ''),
|
|
264
|
+
authPass: normService === 'capi' ? '' : ((current.buzlCapi && current.buzlCapi.authPass) || '')
|
|
265
|
+
},
|
|
266
|
+
zoho: {
|
|
267
|
+
endpoint: normService === 'zoho' ? '' : ((current.zoho && current.zoho.endpoint) || ''),
|
|
268
|
+
xnQsjsdp: normService === 'zoho' ? '' : ((current.zoho && current.zoho.xnQsjsdp) || ''),
|
|
269
|
+
xmIwtLD: normService === 'zoho' ? '' : ((current.zoho && current.zoho.xmIwtLD) || ''),
|
|
270
|
+
fields: normService === 'zoho' ? {} : ((current.zoho && current.zoho.fields) || {})
|
|
271
|
+
},
|
|
272
|
+
whatsappNumber: normService === 'whatsapp' ? '' : (current.whatsappNumber || (current.whatsapp && current.whatsapp.number) || '')
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// 3. Inject updated HTML files
|
|
276
|
+
const targetJsDir = path.join(rootDir, 'assets', 'js');
|
|
277
|
+
const modifiedFiles = [];
|
|
278
|
+
|
|
279
|
+
for (const filePath of htmlFiles) {
|
|
280
|
+
const originalHtml = fs.readFileSync(filePath, 'utf8');
|
|
281
|
+
let scriptRelPath = 'assets/js/buzl-tracking.js';
|
|
282
|
+
if (current && current.pathMode === 'root-absolute') {
|
|
283
|
+
scriptRelPath = '/assets/js/buzl-tracking.js';
|
|
284
|
+
} else {
|
|
285
|
+
const relToTarget = path.relative(path.dirname(filePath), targetJsDir).replace(/\\/g, '/');
|
|
286
|
+
scriptRelPath = relToTarget ? `${relToTarget}/buzl-tracking.js` : 'assets/js/buzl-tracking.js';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const modifiedHtml = injectHtml(originalHtml, newConfig, scriptRelPath);
|
|
290
|
+
fs.writeFileSync(filePath, modifiedHtml, 'utf8');
|
|
291
|
+
modifiedFiles.push({
|
|
292
|
+
path: filePath,
|
|
293
|
+
relative: path.relative(rootDir, filePath).replace(/\\/g, '/')
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const serviceLabels = {
|
|
298
|
+
gtm: 'Google Tag Manager (GTM)',
|
|
299
|
+
meta: 'Meta Pixel & Client CAPI',
|
|
300
|
+
capi: 'Buzl CAPI Server-Side Endpoint',
|
|
301
|
+
sheets: 'Google Sheets Direct Sync',
|
|
302
|
+
zoho: 'Zoho CRM Web-to-Lead',
|
|
303
|
+
whatsapp: 'WhatsApp Handoff'
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
success: true,
|
|
308
|
+
service: normService,
|
|
309
|
+
serviceLabel: serviceLabels[normService] || normService,
|
|
310
|
+
backupDir: backup.backupDir,
|
|
311
|
+
modifiedFiles,
|
|
312
|
+
message: `Successfully removed ${serviceLabels[normService] || normService} from site. Clean safety backup saved in ${path.basename(backup.backupDir)}.`
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
module.exports = {
|
|
317
|
+
injectHtml,
|
|
318
|
+
applyInjection,
|
|
319
|
+
removeTracking,
|
|
320
|
+
removeService
|
|
321
|
+
};
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* BUZL BACKUP & SNAPSHOT MANAGER
|
|
4
|
+
* Provides 100% safe file snapshots with SHA content hashing inside `.buzl/snapshots/`
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) 2026 Buzl Digital Solutions
|
|
7
|
+
* Licensed under the MIT License
|
|
8
|
+
* ============================================================================
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const crypto = require('crypto');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Compute a deterministic SHA-1 content hash across all candidate files
|
|
17
|
+
*/
|
|
18
|
+
function computeFilesHash(files, rootDir) {
|
|
19
|
+
const hashSum = crypto.createHash('sha1');
|
|
20
|
+
for (const filePath of files) {
|
|
21
|
+
if (fs.existsSync(filePath)) {
|
|
22
|
+
hashSum.update(path.relative(rootDir, filePath));
|
|
23
|
+
try {
|
|
24
|
+
hashSum.update(fs.readFileSync(filePath));
|
|
25
|
+
} catch (e) {}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return hashSum.digest('hex').substring(0, 8);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create a point-in-time snapshot backup
|
|
33
|
+
* Stored in `<rootDir>/.buzl/snapshots/<timestamp>_<hash>/`
|
|
34
|
+
*/
|
|
35
|
+
function createBackup(arg1, arg2, arg3 = '') {
|
|
36
|
+
let files;
|
|
37
|
+
let rootDir;
|
|
38
|
+
let backupName = '';
|
|
39
|
+
|
|
40
|
+
if (Array.isArray(arg1)) {
|
|
41
|
+
files = arg1;
|
|
42
|
+
rootDir = arg2;
|
|
43
|
+
backupName = arg3 || '';
|
|
44
|
+
} else if (typeof arg1 === 'string') {
|
|
45
|
+
rootDir = arg1;
|
|
46
|
+
backupName = typeof arg2 === 'string' ? arg2 : '';
|
|
47
|
+
const { findHtmlFiles } = require('./scanner');
|
|
48
|
+
files = findHtmlFiles(rootDir);
|
|
49
|
+
} else {
|
|
50
|
+
files = [];
|
|
51
|
+
rootDir = process.cwd();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
55
|
+
const hash = computeFilesHash(files, rootDir) || '00000000';
|
|
56
|
+
const dirName = `${timestamp}_${hash}`;
|
|
57
|
+
|
|
58
|
+
// Dedicated unified .buzl/snapshots directory
|
|
59
|
+
const snapshotsDir = path.join(rootDir, '.buzl', 'snapshots');
|
|
60
|
+
const backupDir = path.join(snapshotsDir, dirName);
|
|
61
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
62
|
+
|
|
63
|
+
const manifest = {
|
|
64
|
+
id: dirName,
|
|
65
|
+
hash: hash,
|
|
66
|
+
name: (backupName || '').trim() || `Snapshot ${new Date().toLocaleTimeString()} [${hash}]`,
|
|
67
|
+
timestamp: new Date().toISOString(),
|
|
68
|
+
files: []
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
for (const filePath of files) {
|
|
72
|
+
const relPath = path.relative(rootDir, filePath);
|
|
73
|
+
const destPath = path.join(backupDir, relPath);
|
|
74
|
+
const destDir = path.dirname(destPath);
|
|
75
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
76
|
+
fs.copyFileSync(filePath, destPath);
|
|
77
|
+
manifest.files.push(relPath);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fs.writeFileSync(
|
|
81
|
+
path.join(backupDir, 'backup-manifest.json'),
|
|
82
|
+
JSON.stringify(manifest, null, 2),
|
|
83
|
+
'utf8'
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
success: true,
|
|
88
|
+
backupDir,
|
|
89
|
+
dirName,
|
|
90
|
+
hash,
|
|
91
|
+
timestamp: manifest.timestamp,
|
|
92
|
+
name: manifest.name,
|
|
93
|
+
fileCount: manifest.files.length
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* List all available snapshot backups
|
|
99
|
+
* Checks `.buzl/snapshots/` first, plus legacy `.buzl-backup-*` folders
|
|
100
|
+
*/
|
|
101
|
+
function listBackups(rootDir) {
|
|
102
|
+
const allBackups = [];
|
|
103
|
+
|
|
104
|
+
// 1. Primary: Check .buzl/snapshots/
|
|
105
|
+
const snapshotsDir = path.join(rootDir, '.buzl', 'snapshots');
|
|
106
|
+
if (fs.existsSync(snapshotsDir)) {
|
|
107
|
+
try {
|
|
108
|
+
const entries = fs.readdirSync(snapshotsDir);
|
|
109
|
+
for (const e of entries) {
|
|
110
|
+
const dirPath = path.join(snapshotsDir, e);
|
|
111
|
+
if (fs.statSync(dirPath).isDirectory()) {
|
|
112
|
+
let manifest = null;
|
|
113
|
+
try {
|
|
114
|
+
const mPath = path.join(dirPath, 'backup-manifest.json');
|
|
115
|
+
if (fs.existsSync(mPath)) {
|
|
116
|
+
manifest = JSON.parse(fs.readFileSync(mPath, 'utf8'));
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {}
|
|
119
|
+
|
|
120
|
+
allBackups.push({
|
|
121
|
+
id: (manifest && manifest.id) || e,
|
|
122
|
+
dirName: e,
|
|
123
|
+
hash: (manifest && manifest.hash) || (e.split('_')[1] || ''),
|
|
124
|
+
fullPath: dirPath,
|
|
125
|
+
name: (manifest && manifest.name) || e,
|
|
126
|
+
timestamp: (manifest && manifest.timestamp) || '',
|
|
127
|
+
filesCount: (manifest && manifest.files && manifest.files.length) || 0,
|
|
128
|
+
isLegacy: false,
|
|
129
|
+
manifest
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} catch (e) {}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 2. Fallback: Check legacy root-level `.buzl-backup-*` directories
|
|
137
|
+
try {
|
|
138
|
+
const rootEntries = fs.readdirSync(rootDir);
|
|
139
|
+
for (const e of rootEntries) {
|
|
140
|
+
if (e.startsWith('.buzl-backup-')) {
|
|
141
|
+
const dirPath = path.join(rootDir, e);
|
|
142
|
+
if (fs.statSync(dirPath).isDirectory()) {
|
|
143
|
+
let manifest = null;
|
|
144
|
+
try {
|
|
145
|
+
const mPath = path.join(dirPath, 'backup-manifest.json');
|
|
146
|
+
if (fs.existsSync(mPath)) {
|
|
147
|
+
manifest = JSON.parse(fs.readFileSync(mPath, 'utf8'));
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {}
|
|
150
|
+
|
|
151
|
+
allBackups.push({
|
|
152
|
+
id: e,
|
|
153
|
+
dirName: e,
|
|
154
|
+
hash: (manifest && manifest.hash) || '',
|
|
155
|
+
fullPath: dirPath,
|
|
156
|
+
name: (manifest && manifest.name) || e.replace('.buzl-backup-', ''),
|
|
157
|
+
timestamp: (manifest && manifest.timestamp) || '',
|
|
158
|
+
filesCount: (manifest && manifest.files && manifest.files.length) || 0,
|
|
159
|
+
isLegacy: true,
|
|
160
|
+
manifest
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch (e) {}
|
|
166
|
+
|
|
167
|
+
// Sort newest first
|
|
168
|
+
allBackups.sort((a, b) => b.dirName.localeCompare(a.dirName));
|
|
169
|
+
return allBackups;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Restore a specific snapshot or 'latest'
|
|
174
|
+
*/
|
|
175
|
+
function restoreBackup(rootDir, identifier) {
|
|
176
|
+
const backups = listBackups(rootDir);
|
|
177
|
+
if (backups.length === 0) {
|
|
178
|
+
return { success: false, message: 'No backups found to restore.' };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let targetBackup = backups[0];
|
|
182
|
+
if (identifier && identifier !== 'latest') {
|
|
183
|
+
targetBackup = backups.find(b =>
|
|
184
|
+
b.dirName === identifier ||
|
|
185
|
+
b.name === identifier ||
|
|
186
|
+
b.hash === identifier ||
|
|
187
|
+
b.id === identifier
|
|
188
|
+
);
|
|
189
|
+
if (!targetBackup) {
|
|
190
|
+
return { success: false, message: `Backup "${identifier}" not found.` };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const manifest = targetBackup.manifest;
|
|
195
|
+
if (!manifest || !manifest.files) {
|
|
196
|
+
return { success: false, message: 'Backup manifest corrupted or missing.' };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let restoredCount = 0;
|
|
200
|
+
for (const relFile of manifest.files) {
|
|
201
|
+
const src = path.join(targetBackup.fullPath, relFile);
|
|
202
|
+
const dest = path.join(rootDir, relFile);
|
|
203
|
+
if (fs.existsSync(src)) {
|
|
204
|
+
const destDir = path.dirname(dest);
|
|
205
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
206
|
+
fs.copyFileSync(src, dest);
|
|
207
|
+
restoredCount++;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
success: true,
|
|
213
|
+
backupUsed: targetBackup.dirName,
|
|
214
|
+
backupName: targetBackup.name,
|
|
215
|
+
hash: targetBackup.hash,
|
|
216
|
+
restoredCount,
|
|
217
|
+
message: `Successfully restored ${restoredCount} file(s) from "${targetBackup.name}" [${targetBackup.hash || targetBackup.dirName}].`
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Restore the latest snapshot
|
|
223
|
+
*/
|
|
224
|
+
function restoreLatestBackup(rootDir) {
|
|
225
|
+
return restoreBackup(rootDir, 'latest');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Delete a specific backup
|
|
230
|
+
*/
|
|
231
|
+
function deleteBackup(rootDir, identifier) {
|
|
232
|
+
const backups = listBackups(rootDir);
|
|
233
|
+
const target = backups.find(b =>
|
|
234
|
+
b.dirName === identifier ||
|
|
235
|
+
b.name === identifier ||
|
|
236
|
+
b.id === identifier ||
|
|
237
|
+
b.hash === identifier
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
if (!target) {
|
|
241
|
+
return { success: false, message: `Backup "${identifier}" not found.` };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
fs.rmSync(target.fullPath, { recursive: true, force: true });
|
|
246
|
+
return { success: true, message: `Backup "${target.name}" deleted successfully.` };
|
|
247
|
+
} catch (e) {
|
|
248
|
+
return { success: false, message: e.message };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Create a manual named backup
|
|
254
|
+
*/
|
|
255
|
+
function manualBackup(rootDir, backupName = '') {
|
|
256
|
+
const { findHtmlFiles } = require('./scanner');
|
|
257
|
+
const files = findHtmlFiles(rootDir);
|
|
258
|
+
if (files.length === 0) {
|
|
259
|
+
return { success: false, message: 'No HTML files found to backup.' };
|
|
260
|
+
}
|
|
261
|
+
const result = createBackup(files, rootDir, backupName);
|
|
262
|
+
return {
|
|
263
|
+
success: true,
|
|
264
|
+
backupDir: result.backupDir,
|
|
265
|
+
dirName: result.dirName,
|
|
266
|
+
hash: result.hash,
|
|
267
|
+
name: result.name,
|
|
268
|
+
fileCount: result.fileCount,
|
|
269
|
+
message: `Snapshot "${result.name}" created with ${result.fileCount} file(s) [SHA: ${result.hash}].`
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = {
|
|
274
|
+
createBackup,
|
|
275
|
+
computeFilesHash,
|
|
276
|
+
listBackups,
|
|
277
|
+
restoreBackup,
|
|
278
|
+
restoreLatestBackup,
|
|
279
|
+
deleteBackup,
|
|
280
|
+
manualBackup
|
|
281
|
+
};
|