wanas-zcrm-extractor 1.4.0 → 1.5.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 +7 -3
- package/bin/cli.js +11 -1
- package/package.json +1 -1
- package/src/services/authService.js +1 -1
- package/src/services/metadata/crmMetadataService.js +52 -3
- package/src/utils/apiClient.js +6 -1
- package/src/utils/logger.js +45 -13
- package/src/utils/readmeTemplate.js +166 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<p align="center">
|
|
2
2
|
<a href="https://www.wanasapps.com">
|
|
3
|
-
<img src="
|
|
3
|
+
<img src="./WanasApps-logo.svg" alt="Wanas Apps Logo" width="320">
|
|
4
4
|
</a>
|
|
5
5
|
</p>
|
|
6
6
|
|
|
@@ -135,7 +135,9 @@ zcrm pull [options]
|
|
|
135
135
|
4. Fetches Profiles, Roles, and Webhooks settings.
|
|
136
136
|
5. Downloads all custom Deluge function scripts.
|
|
137
137
|
6. Compiles a consolidated database of schemas and summaries.
|
|
138
|
-
7.
|
|
138
|
+
7. Generates a **`README.md`** in the output folder describing the snapshot (org, counts, a per-module table, and the directory layout) — regenerated on every pull from the real data.
|
|
139
|
+
8. Writes a **debug log** for the run to `<output>/.zcrm/.logs/ZCRM-CLI-<date>.log` (includes the quiet "skipped" entries that don't print to the console).
|
|
140
|
+
9. Prints a rich-text CLI statistics summary showing modules, layouts, fields, stale files removed, and any skipped/errored requests.
|
|
139
141
|
|
|
140
142
|
#### ⚡ Concurrency (faster pulls, safely)
|
|
141
143
|
A pull makes many independent metadata requests, so they are run **in parallel** with a global cap on simultaneous calls. Zoho enforces a per-org **concurrency limit** based on your edition — **5** (Free), **10** (Standard/Starter), **15** (Professional), **20** (Enterprise/Zoho One), **25** (Ultimate/CRM Plus). The metadata endpoints used here are 1 credit each and are *not* subject to Zoho's stricter sub-concurrency limits.
|
|
@@ -234,7 +236,9 @@ zcrm skill --ide claude --metadata-dir ./crm-meta
|
|
|
234
236
|
---
|
|
235
237
|
|
|
236
238
|
### `zcrm audit`
|
|
237
|
-
Triggers an asynchronous export of the Zoho CRM audit log. By default, it exports all audit logs for the last 3 years. It automatically polls the API until the job is complete and downloads the resulting CSV or ZIP file to `storage/audit_logs/`.
|
|
239
|
+
Triggers an asynchronous export of the Zoho CRM audit log. By default, it exports all audit logs for the last 3 years. It automatically polls the API (with a bounded timeout) until the job is complete and downloads the resulting CSV or ZIP file to `storage/audit_logs/`.
|
|
240
|
+
|
|
241
|
+
> **Requires the `ZohoCRM.settings.audit_logs.ALL` OAuth scope.** It is included in the default scopes, but if you authenticated *before* this scope was added you'll get a `401 invalid oauth scope` — just re-authenticate to grant it: `zcrm logout` then `zcrm login`.
|
|
238
242
|
|
|
239
243
|
```bash
|
|
240
244
|
# Export all audit logs
|
package/bin/cli.js
CHANGED
|
@@ -521,6 +521,9 @@ program
|
|
|
521
521
|
console.log(` \x1b[1mCustom Views:\x1b[0m \x1b[35m${details.totalCustomViews || 0}\x1b[0m (saved inside crm/meta/modules/)`);
|
|
522
522
|
console.log(` \x1b[1mStale Files Removed:\x1b[0m \x1b[33m${details.removedStale || 0}\x1b[0m (deleted on Zoho CRM since last pull)`);
|
|
523
523
|
console.log(` \x1b[1mOutput Directory:\x1b[0m \x1b[34m${outputDir}\x1b[0m`);
|
|
524
|
+
if (details.logFile) {
|
|
525
|
+
console.log(` \x1b[1mDebug Log:\x1b[0m \x1b[34m${details.logFile}\x1b[0m`);
|
|
526
|
+
}
|
|
524
527
|
|
|
525
528
|
if (details.skipped > 0) {
|
|
526
529
|
console.log(` \x1b[1mSkipped (optional):\x1b[0m \x1b[90m${details.skipped}\x1b[0m settings not available for some modules/org (normal).`);
|
|
@@ -574,7 +577,14 @@ program
|
|
|
574
577
|
console.log('\x1b[1;32m====================================================================\x1b[0m\n');
|
|
575
578
|
process.exit(0);
|
|
576
579
|
} catch (error) {
|
|
577
|
-
|
|
580
|
+
const apiMsg = error.response?.data?.message || error.message;
|
|
581
|
+
const isScope = error.response?.data?.code === 'OAUTH_SCOPE_MISMATCH' || /scope/i.test(apiMsg || '');
|
|
582
|
+
console.error(`\n❌ Audit log export failed: ${apiMsg}`);
|
|
583
|
+
if (isScope) {
|
|
584
|
+
console.error('\n\x1b[33mThis needs the \x1b[1mZohoCRM.settings.audit_logs.ALL\x1b[0m\x1b[33m OAuth scope, which your current');
|
|
585
|
+
console.error('session was not granted. Re-authenticate to add it:\x1b[0m');
|
|
586
|
+
console.error(' \x1b[36mzcrm logout\x1b[0m then \x1b[36mzcrm login\x1b[0m');
|
|
587
|
+
}
|
|
578
588
|
process.exit(1);
|
|
579
589
|
}
|
|
580
590
|
});
|
package/package.json
CHANGED
|
@@ -54,7 +54,7 @@ class AuthService {
|
|
|
54
54
|
* @returns {string} Commas-separated list of scopes.
|
|
55
55
|
*/
|
|
56
56
|
getScopes() {
|
|
57
|
-
const defaultScopes = 'ZohoCRM.modules.ALL,ZohoCRM.settings.ALL,ZohoCRM.users.ALL,ZohoCRM.org.ALL,ZohoCRM.bulk.ALL,ZohoCRM.notifications.ALL,ZohoCRM.coql.READ,ZohoCRM.settings.functions.ALL';
|
|
57
|
+
const defaultScopes = 'ZohoCRM.modules.ALL,ZohoCRM.settings.ALL,ZohoCRM.users.ALL,ZohoCRM.org.ALL,ZohoCRM.bulk.ALL,ZohoCRM.notifications.ALL,ZohoCRM.coql.READ,ZohoCRM.settings.functions.ALL,ZohoCRM.settings.audit_logs.ALL';
|
|
58
58
|
if (process.env.ZCRM_CLI_MODE === 'true') {
|
|
59
59
|
return configStore.get('scopes') || process.env.ZOHO_SCOPES || defaultScopes;
|
|
60
60
|
}
|
|
@@ -4,8 +4,10 @@ const BaseMetadataService = require('./baseMetadataService');
|
|
|
4
4
|
const apiClient = require('../../utils/apiClient');
|
|
5
5
|
const { mapWithConcurrency } = require('../../utils/concurrency');
|
|
6
6
|
const { sweepDir, sweepDeletedDirs, normalizePath } = require('../../utils/fsSync');
|
|
7
|
-
const { logError, logInfo } = require('../../utils/logger');
|
|
7
|
+
const { logError, logInfo, setSessionLog, clearSessionLog } = require('../../utils/logger');
|
|
8
8
|
const { formatDeluge } = require('../../utils/delugeFormatter');
|
|
9
|
+
const { buildResultReadme } = require('../../utils/readmeTemplate');
|
|
10
|
+
const pkg = require('../../../package.json');
|
|
9
11
|
|
|
10
12
|
const STORAGE_DIR = path.join(process.cwd(), 'storage/metadata');
|
|
11
13
|
|
|
@@ -158,6 +160,13 @@ class CrmMetadataService extends BaseMetadataService {
|
|
|
158
160
|
|
|
159
161
|
const fieldMap = {};
|
|
160
162
|
|
|
163
|
+
// Collected for the dynamically-generated result README.
|
|
164
|
+
const moduleSummaries = [];
|
|
165
|
+
const globalSettingsExtracted = [];
|
|
166
|
+
let functionsCount = 0;
|
|
167
|
+
let profilesCount = 0;
|
|
168
|
+
let rolesCount = 0;
|
|
169
|
+
|
|
161
170
|
// Records a failed sub-resource fetch. Many sub-resources simply don't apply
|
|
162
171
|
// to every module/org — Zoho returns 400 ("module not supported", "layout
|
|
163
172
|
// deactivated", "feature not enabled", etc.). Those are EXPECTED: they are
|
|
@@ -198,8 +207,13 @@ class CrmMetadataService extends BaseMetadataService {
|
|
|
198
207
|
const month = String(today.getMonth() + 1).padStart(2, '0');
|
|
199
208
|
const date = String(today.getDate()).padStart(2, '0');
|
|
200
209
|
const logFileName = `ZCRM-CLI-${year}-${month}-${date}.log`;
|
|
210
|
+
const logFilePath = path.join(finalOutputDir, '.zcrm', '.logs', logFileName);
|
|
201
211
|
await fs.outputJson(path.join(finalOutputDir, '.zcrm', '.logs', '.audit.json'), {}, { spaces: 2 });
|
|
202
|
-
await fs.outputFile(
|
|
212
|
+
await fs.outputFile(logFilePath, `[${today.toISOString()}] zcrm pull started (concurrency ${concurrency}${withCounts ? ', record counts ON' : ''}).\n`, 'utf8');
|
|
213
|
+
// Mirror every subsequent log entry (including the quiet info/skip lines
|
|
214
|
+
// suppressed from the console) into a debug log in the result folder.
|
|
215
|
+
setSessionLog(logFilePath);
|
|
216
|
+
stats.logFile = logFilePath;
|
|
203
217
|
|
|
204
218
|
// 1. Fetch Org Information to get Org ID/Name
|
|
205
219
|
onProgress('FETCH_ORG', 'Fetching CRM Organization details...');
|
|
@@ -238,7 +252,7 @@ class CrmMetadataService extends BaseMetadataService {
|
|
|
238
252
|
|
|
239
253
|
await outJson(path.join(finalOutputDir, 'meta.json'), { resource_type: 'all' }, { spaces: 2 });
|
|
240
254
|
await outFile(path.join(finalOutputDir, '.zcrmignore'), '# ZCRM CLI ignore file\n.zcrm/\n', 'utf8');
|
|
241
|
-
|
|
255
|
+
// README.md is generated from the real pulled data at the end of extraction.
|
|
242
256
|
|
|
243
257
|
// 2. Fetch Modules
|
|
244
258
|
onProgress('FETCH_MODULES', 'Fetching list of CRM modules...');
|
|
@@ -578,6 +592,14 @@ class CrmMetadataService extends BaseMetadataService {
|
|
|
578
592
|
if (customLinksHasItems) sweptInModule += await sweepDir(path.join(moduleSubDir, 'custom_links'), writtenFiles);
|
|
579
593
|
stats.removedStale += sweptInModule;
|
|
580
594
|
|
|
595
|
+
moduleSummaries.push({
|
|
596
|
+
api_name: apiName,
|
|
597
|
+
label: singularLabel,
|
|
598
|
+
custom: module.custom === true,
|
|
599
|
+
fields: summary.fields_count,
|
|
600
|
+
layouts: summary.layouts_count,
|
|
601
|
+
related_lists: summary.related_lists_count
|
|
602
|
+
});
|
|
581
603
|
stats.modulesProcessed++;
|
|
582
604
|
} catch (modErr) {
|
|
583
605
|
stats.errors.push({ endpoint: `module:${apiName}`, error: modErr.message });
|
|
@@ -619,6 +641,7 @@ class CrmMetadataService extends BaseMetadataService {
|
|
|
619
641
|
const profilesResponse = await apiClient.get('/crm/v8/settings/profiles');
|
|
620
642
|
const profiles = profilesResponse.profiles || [];
|
|
621
643
|
completeMetadata.profiles = profiles;
|
|
644
|
+
profilesCount = profiles.length;
|
|
622
645
|
const profilesDir = path.join(zcrmMetaDir, 'profiles');
|
|
623
646
|
await fs.ensureDir(profilesDir);
|
|
624
647
|
for (const profile of profiles) {
|
|
@@ -644,6 +667,7 @@ class CrmMetadataService extends BaseMetadataService {
|
|
|
644
667
|
const rolesResponse = await apiClient.get('/crm/v8/settings/roles');
|
|
645
668
|
const roles = rolesResponse.roles || [];
|
|
646
669
|
completeMetadata.roles = roles;
|
|
670
|
+
rolesCount = roles.length;
|
|
647
671
|
const rolesDir = path.join(zcrmMetaDir, 'roles');
|
|
648
672
|
await fs.ensureDir(rolesDir);
|
|
649
673
|
for (const role of roles) {
|
|
@@ -712,6 +736,7 @@ class CrmMetadataService extends BaseMetadataService {
|
|
|
712
736
|
const items = response[reqConfig.key] || [];
|
|
713
737
|
const outputKey = reqConfig.metaKey || reqConfig.key;
|
|
714
738
|
completeMetadata[outputKey] = items;
|
|
739
|
+
if (items.length > 0) globalSettingsExtracted.push({ key: outputKey, label: reqConfig.label, count: items.length });
|
|
715
740
|
const itemsDir = path.join(zcrmMetaDir, outputKey);
|
|
716
741
|
await fs.ensureDir(itemsDir);
|
|
717
742
|
|
|
@@ -753,6 +778,7 @@ class CrmMetadataService extends BaseMetadataService {
|
|
|
753
778
|
await outJson(path.join(zcrmStoreDir, 'functions.json'), functionsResponse, { spaces: 2 });
|
|
754
779
|
|
|
755
780
|
const functionsList = functionsResponse.functions || [];
|
|
781
|
+
functionsCount = functionsList.length;
|
|
756
782
|
await fs.ensureDir(functionsDir);
|
|
757
783
|
await outJson(path.join(functionsDir, 'functions.json'), functionsResponse, { spaces: 2 });
|
|
758
784
|
|
|
@@ -843,6 +869,26 @@ class CrmMetadataService extends BaseMetadataService {
|
|
|
843
869
|
await outJson(path.join(zcrmStoreDir, 'field_map.json'), fieldMap, { spaces: 2 });
|
|
844
870
|
await outJson(path.join(zcrmStoreDir, 'complete_metadata.json'), completeMetadata);
|
|
845
871
|
|
|
872
|
+
// 8. Generate the human-readable result README from the real pulled data.
|
|
873
|
+
onProgress('GENERATE_README', 'Writing result README...');
|
|
874
|
+
try {
|
|
875
|
+
const readme = buildResultReadme({
|
|
876
|
+
orgName,
|
|
877
|
+
generatedAt: indexData.generated_at,
|
|
878
|
+
version: pkg.version,
|
|
879
|
+
dc,
|
|
880
|
+
stats,
|
|
881
|
+
modules: moduleSummaries,
|
|
882
|
+
functionsCount,
|
|
883
|
+
profilesCount,
|
|
884
|
+
rolesCount,
|
|
885
|
+
globalSettings: globalSettingsExtracted
|
|
886
|
+
});
|
|
887
|
+
await outFile(path.join(finalOutputDir, 'README.md'), readme, 'utf8');
|
|
888
|
+
} catch (readmeErr) {
|
|
889
|
+
logInfo(`Could not generate result README: ${readmeErr.message}`, 'CrmMetadataService.extract');
|
|
890
|
+
}
|
|
891
|
+
|
|
846
892
|
stats.completedAt = new Date().toISOString();
|
|
847
893
|
stats.crm_org = orgName;
|
|
848
894
|
stats.modulesCount = modules.length;
|
|
@@ -853,6 +899,9 @@ class CrmMetadataService extends BaseMetadataService {
|
|
|
853
899
|
logError(error, 'CrmMetadataService.extract - Global Extraction Failure');
|
|
854
900
|
onProgress('FAILURE', `Extraction failed: ${error.message}`);
|
|
855
901
|
throw error;
|
|
902
|
+
} finally {
|
|
903
|
+
// Stop mirroring logs to the result folder once the pull is done.
|
|
904
|
+
clearSessionLog();
|
|
856
905
|
}
|
|
857
906
|
}
|
|
858
907
|
}
|
package/src/utils/apiClient.js
CHANGED
|
@@ -95,8 +95,13 @@ async function request(method, endpoint, params = {}, data = null, retryCount =
|
|
|
95
95
|
|
|
96
96
|
logInfo(`Request to ${endpoint} failed. Status: ${status}, Message: ${JSON.stringify(errorData || error.message)}`, 'apiClient.get');
|
|
97
97
|
|
|
98
|
+
// A 401 caused by a missing OAuth scope cannot be fixed by refreshing the
|
|
99
|
+
// token (the new token has the same scopes), so fail fast instead of
|
|
100
|
+
// refreshing and retrying twice (which just produced duplicate errors).
|
|
101
|
+
const isScopeError = errorData?.code === 'OAUTH_SCOPE_MISMATCH' || /scope/i.test(errorData?.message || '');
|
|
102
|
+
|
|
98
103
|
// Handle 401 Unauthorized (Expired token or session)
|
|
99
|
-
if (status === 401 && retryCount < 2) {
|
|
104
|
+
if (status === 401 && !isScopeError && retryCount < 2) {
|
|
100
105
|
logInfo('Received 401 Unauthorized. Forcing token refresh and retrying...', 'apiClient.request');
|
|
101
106
|
try {
|
|
102
107
|
await authService.refreshAccessToken();
|
package/src/utils/logger.js
CHANGED
|
@@ -13,6 +13,44 @@ const getLogFile = () => {
|
|
|
13
13
|
return path.join(process.cwd(), 'storage/logs/errors.log');
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
+
// Optional per-run "session" log file. When set (e.g. during `zcrm pull`), every
|
|
17
|
+
// log entry is ALSO appended here so the result folder gets a self-contained
|
|
18
|
+
// debug log — including the quiet info/skip entries suppressed from the console.
|
|
19
|
+
let sessionLogPath = null;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Sets an additional log file that receives a copy of every log entry until
|
|
23
|
+
* cleared. Used to drop a debug log into the pull's output folder.
|
|
24
|
+
* @param {string|null} filePath
|
|
25
|
+
*/
|
|
26
|
+
function setSessionLog(filePath) {
|
|
27
|
+
sessionLogPath = filePath || null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Stops mirroring log entries to the session log file. */
|
|
31
|
+
function clearSessionLog() {
|
|
32
|
+
sessionLogPath = null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Appends an entry to the default log file and (if set) the session log file.
|
|
37
|
+
* @param {string} entry
|
|
38
|
+
*/
|
|
39
|
+
function writeEntry(entry) {
|
|
40
|
+
try {
|
|
41
|
+
fs.outputFileSync(getLogFile(), entry, { flag: 'a' });
|
|
42
|
+
} catch (ioErr) {
|
|
43
|
+
console.error('Failed to write to log file:', ioErr);
|
|
44
|
+
}
|
|
45
|
+
if (sessionLogPath) {
|
|
46
|
+
try {
|
|
47
|
+
fs.outputFileSync(sessionLogPath, entry, { flag: 'a' });
|
|
48
|
+
} catch (ioErr) {
|
|
49
|
+
// Best-effort; never let session logging break the run.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
16
54
|
/**
|
|
17
55
|
* Log error message to console and output log file.
|
|
18
56
|
* @param {Error|object|string} error - The error to log.
|
|
@@ -47,11 +85,7 @@ function logError(error, context = '') {
|
|
|
47
85
|
console.error(`[ERROR] ${context ? `(${context}) ` : ''}`, error);
|
|
48
86
|
}
|
|
49
87
|
|
|
50
|
-
|
|
51
|
-
fs.outputFileSync(getLogFile(), logEntry, { flag: 'a' });
|
|
52
|
-
} catch (ioErr) {
|
|
53
|
-
console.error('Failed to write to log file:', ioErr);
|
|
54
|
-
}
|
|
88
|
+
writeEntry(logEntry);
|
|
55
89
|
}
|
|
56
90
|
|
|
57
91
|
/**
|
|
@@ -62,20 +96,18 @@ function logError(error, context = '') {
|
|
|
62
96
|
function logInfo(message, context = '') {
|
|
63
97
|
const timestamp = new Date().toISOString();
|
|
64
98
|
const logEntry = `[${timestamp}] [INFO] ${context ? `(${context}) ` : ''}${message}\n`;
|
|
65
|
-
|
|
99
|
+
|
|
66
100
|
// Suppress info console logging when running CLI normally (unless verbose mode is enabled)
|
|
67
101
|
if (process.env.ZCRM_CLI_MODE !== 'true' || process.env.ZCRM_VERBOSE === 'true') {
|
|
68
102
|
console.log(`[INFO] ${context ? `(${context}) ` : ''}${message}`);
|
|
69
103
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
fs.outputFileSync(getLogFile(), logEntry, { flag: 'a' });
|
|
73
|
-
} catch (ioErr) {
|
|
74
|
-
console.error('Failed to write to log file:', ioErr);
|
|
75
|
-
}
|
|
104
|
+
|
|
105
|
+
writeEntry(logEntry);
|
|
76
106
|
}
|
|
77
107
|
|
|
78
108
|
module.exports = {
|
|
79
109
|
logError,
|
|
80
|
-
logInfo
|
|
110
|
+
logInfo,
|
|
111
|
+
setSessionLog,
|
|
112
|
+
clearSessionLog
|
|
81
113
|
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the README.md that documents a metadata extraction's output folder.
|
|
3
|
+
* The content is generated dynamically from the data actually pulled, so it
|
|
4
|
+
* always reflects the real modules, counts, and settings in the snapshot.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Formats an ISO timestamp into a readable UTC string. */
|
|
8
|
+
function fmtDate(iso) {
|
|
9
|
+
try {
|
|
10
|
+
return new Date(iso).toUTCString();
|
|
11
|
+
} catch (e) {
|
|
12
|
+
return iso || 'unknown';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} info
|
|
18
|
+
* @param {string} info.orgName
|
|
19
|
+
* @param {string} info.generatedAt - ISO timestamp.
|
|
20
|
+
* @param {string} info.version - CLI version.
|
|
21
|
+
* @param {string} info.dc - Zoho data center.
|
|
22
|
+
* @param {object} info.stats - Extraction stats (totals, skipped, errors[]).
|
|
23
|
+
* @param {Array<{api_name:string,label:string,custom:boolean,fields:number,layouts:number,related_lists:number}>} info.modules
|
|
24
|
+
* @param {number} info.functionsCount
|
|
25
|
+
* @param {number} info.profilesCount
|
|
26
|
+
* @param {number} info.rolesCount
|
|
27
|
+
* @param {Array<{key:string,label:string,count:number}>} info.globalSettings
|
|
28
|
+
* @returns {string} Markdown.
|
|
29
|
+
*/
|
|
30
|
+
function buildResultReadme(info) {
|
|
31
|
+
const {
|
|
32
|
+
orgName = 'Unknown Org',
|
|
33
|
+
generatedAt = new Date(0).toISOString(),
|
|
34
|
+
version = '',
|
|
35
|
+
dc = 'com',
|
|
36
|
+
stats = {},
|
|
37
|
+
modules = [],
|
|
38
|
+
functionsCount = 0,
|
|
39
|
+
profilesCount = 0,
|
|
40
|
+
rolesCount = 0,
|
|
41
|
+
globalSettings = []
|
|
42
|
+
} = info || {};
|
|
43
|
+
|
|
44
|
+
const errorsCount = Array.isArray(stats.errors) ? stats.errors.length : 0;
|
|
45
|
+
const sortedModules = [...modules].sort((a, b) => (b.fields || 0) - (a.fields || 0));
|
|
46
|
+
const customCount = modules.filter(m => m.custom).length;
|
|
47
|
+
const standardCount = modules.length - customCount;
|
|
48
|
+
|
|
49
|
+
const lines = [];
|
|
50
|
+
lines.push(`# Zoho CRM Metadata Snapshot — ${orgName}`);
|
|
51
|
+
lines.push('');
|
|
52
|
+
lines.push(`> Generated by the \`zcrm\` CLI (Wanas Apps — Zoho CRM V8 Metadata Extractor${version ? ` v${version}` : ''}) on **${fmtDate(generatedAt)}**.`);
|
|
53
|
+
lines.push('');
|
|
54
|
+
lines.push('> ⚠️ **Read-only schema snapshot.** This folder describes the *structure* of the CRM');
|
|
55
|
+
lines.push('> (modules, fields, layouts, picklists, functions, settings). It contains **no CRM record');
|
|
56
|
+
lines.push('> data** and was produced by a tool that **never creates, updates, or deletes** anything in');
|
|
57
|
+
lines.push('> Zoho CRM. Use it to look up exact API names and constraints when writing Deluge or REST code.');
|
|
58
|
+
lines.push('');
|
|
59
|
+
lines.push('---');
|
|
60
|
+
lines.push('');
|
|
61
|
+
|
|
62
|
+
// ---- Summary ----
|
|
63
|
+
lines.push('## 📊 Snapshot summary');
|
|
64
|
+
lines.push('');
|
|
65
|
+
lines.push('| Resource | Count |');
|
|
66
|
+
lines.push('| :--- | ---: |');
|
|
67
|
+
lines.push(`| Organization | ${orgName} |`);
|
|
68
|
+
lines.push(`| Data center | \`${dc}\` |`);
|
|
69
|
+
lines.push(`| Modules (standard / custom) | **${modules.length}** (${standardCount} / ${customCount}) |`);
|
|
70
|
+
lines.push(`| Fields | ${stats.totalFields || 0} |`);
|
|
71
|
+
lines.push(`| Layouts | ${stats.totalLayouts || 0} |`);
|
|
72
|
+
lines.push(`| Related lists | ${stats.totalRelatedLists || 0} |`);
|
|
73
|
+
lines.push(`| Custom views | ${stats.totalCustomViews || 0} |`);
|
|
74
|
+
lines.push(`| Profiles | ${profilesCount} |`);
|
|
75
|
+
lines.push(`| Roles | ${rolesCount} |`);
|
|
76
|
+
lines.push(`| Deluge functions | ${functionsCount} |`);
|
|
77
|
+
lines.push(`| Stale files removed this pull | ${stats.removedStale || 0} |`);
|
|
78
|
+
lines.push(`| Skipped (optional, not available) | ${stats.skipped || 0} |`);
|
|
79
|
+
lines.push(`| Errors (unexpected) | ${errorsCount} |`);
|
|
80
|
+
lines.push('');
|
|
81
|
+
if (stats.skipped) {
|
|
82
|
+
lines.push(`> ℹ️ "Skipped" are optional settings that some modules/this org simply don't have (e.g. tags or duplicate-check on system modules). That is normal and not an error.`);
|
|
83
|
+
lines.push('');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---- Directory layout ----
|
|
87
|
+
lines.push('## 📂 What\'s in this folder');
|
|
88
|
+
lines.push('');
|
|
89
|
+
lines.push('```text');
|
|
90
|
+
lines.push('.');
|
|
91
|
+
lines.push('├── crm/');
|
|
92
|
+
lines.push('│ ├── meta/');
|
|
93
|
+
lines.push('│ │ ├── modules/<Module_API_Name>/ # per-module schema (see table below)');
|
|
94
|
+
lines.push('│ │ │ ├── <Module>.modules-meta.json # module definition');
|
|
95
|
+
lines.push('│ │ │ ├── summary.json # quick counts + field classifications');
|
|
96
|
+
lines.push('│ │ │ ├── fields/ # every field (.fields-meta.json)');
|
|
97
|
+
lines.push('│ │ │ ├── layouts/ # page layouts (+ map_dependencies/)');
|
|
98
|
+
lines.push('│ │ │ ├── custom_views/ # custom views');
|
|
99
|
+
lines.push('│ │ │ ├── custom_links/ # module links/buttons');
|
|
100
|
+
lines.push('│ │ │ ├── related_lists/ # related lists');
|
|
101
|
+
lines.push('│ │ │ ├── tags/ # module tags');
|
|
102
|
+
lines.push('│ │ │ ├── record_locking_configurations/');
|
|
103
|
+
lines.push('│ │ │ └── workflow_configurations/');
|
|
104
|
+
lines.push('│ │ ├── profiles/ # permission profiles');
|
|
105
|
+
lines.push('│ │ ├── roles/ # role hierarchy');
|
|
106
|
+
lines.push('│ │ ├── email_templates/<Module>/ # .meta.json + .html (body)');
|
|
107
|
+
lines.push('│ │ ├── inventory_templates/<Module>/ # .meta.json + .html (body)');
|
|
108
|
+
lines.push('│ │ ├── workflow_rules/<Trigger_Module>/<Rule>.json');
|
|
109
|
+
lines.push('│ │ └── ... # other org-wide settings (see below)');
|
|
110
|
+
lines.push('│ └── functions/ # custom Deluge scripts (.ds), by namespace');
|
|
111
|
+
lines.push('├── .zcrm/');
|
|
112
|
+
lines.push('│ ├── .store/ # consolidated indexes (index.json, field_map.json, ...)');
|
|
113
|
+
lines.push('│ ├── .org/org.json # organization metadata');
|
|
114
|
+
lines.push('│ └── .logs/ # debug log for this pull');
|
|
115
|
+
lines.push('├── zcrm-project.json # ZCRM project config');
|
|
116
|
+
lines.push('└── README.md # this file');
|
|
117
|
+
lines.push('```');
|
|
118
|
+
lines.push('');
|
|
119
|
+
lines.push('Handy starting points for an AI assistant or a developer:');
|
|
120
|
+
lines.push('');
|
|
121
|
+
lines.push('- **`.zcrm/.store/index.json`** — master list of module API names.');
|
|
122
|
+
lines.push('- **`.zcrm/.store/field_map.json`** — `{ Module: { Field: data_type } }` lookup.');
|
|
123
|
+
lines.push('- **`crm/meta/modules/<Module>/summary.json`** — required fields, picklists, lookups, subforms.');
|
|
124
|
+
lines.push('');
|
|
125
|
+
|
|
126
|
+
// ---- Modules table ----
|
|
127
|
+
if (sortedModules.length > 0) {
|
|
128
|
+
lines.push('## 🧩 Modules');
|
|
129
|
+
lines.push('');
|
|
130
|
+
lines.push('| Module (API name) | Label | Type | Fields | Layouts | Related lists |');
|
|
131
|
+
lines.push('| :--- | :--- | :--- | ---: | ---: | ---: |');
|
|
132
|
+
for (const m of sortedModules) {
|
|
133
|
+
const type = m.custom ? 'Custom' : 'Standard';
|
|
134
|
+
lines.push(`| \`${m.api_name}\` | ${m.label || ''} | ${type} | ${m.fields || 0} | ${m.layouts || 0} | ${m.related_lists || 0} |`);
|
|
135
|
+
}
|
|
136
|
+
lines.push('');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---- Global settings ----
|
|
140
|
+
const presentSettings = globalSettings.filter(g => g && g.count > 0);
|
|
141
|
+
if (presentSettings.length > 0) {
|
|
142
|
+
lines.push('## ⚙️ Org-wide settings extracted');
|
|
143
|
+
lines.push('');
|
|
144
|
+
lines.push('| Setting | Items |');
|
|
145
|
+
lines.push('| :--- | ---: |');
|
|
146
|
+
for (const g of presentSettings) {
|
|
147
|
+
lines.push(`| ${g.label || g.key} | ${g.count} |`);
|
|
148
|
+
}
|
|
149
|
+
lines.push('');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---- Usage ----
|
|
153
|
+
lines.push('## 🚀 Using this snapshot');
|
|
154
|
+
lines.push('');
|
|
155
|
+
lines.push('- Generate an AI-assistant context file from this snapshot: **`zcrm skill`**.');
|
|
156
|
+
lines.push('- Read the AI ingestion guide: **`zcrm llm`**.');
|
|
157
|
+
lines.push('- Browse it in a local web UI: **`zcrm dashboard`**.');
|
|
158
|
+
lines.push('- Refresh it any time: **`zcrm pull`** (overwrites changed files, removes data deleted on Zoho).');
|
|
159
|
+
lines.push('');
|
|
160
|
+
lines.push('_This file is regenerated on every `zcrm pull`._');
|
|
161
|
+
lines.push('');
|
|
162
|
+
|
|
163
|
+
return lines.join('\n');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = { buildResultReadme };
|