wanas-zcrm-extractor 1.5.2 → 1.6.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 CHANGED
@@ -329,7 +329,7 @@ zcrm audit # last 3 years by default
329
329
  zcrm audit --filter input.json # custom export criteria
330
330
  ```
331
331
 
332
- > **Requires the `ZohoCRM.settings.audit_logs.ALL` OAuth scope.** It's in the default scopes, but if you authenticated *before* it was added you'll see `401 invalid oauth scope` — just re-authenticate to grant it: `zcrm logout` then `zcrm login`.
332
+ > **Requires the audit-log OAuth scopes** — `ZohoCRM.settings.audit_logs.CREATE` and `ZohoCRM.settings.audit_logs.READ` (both in the default scopes). If you authenticated *before* they were added you'll see `401 invalid oauth scope` — just re-authenticate: `zcrm logout` then `zcrm login`. *(If the download step later reports a file-scope error, add `ZohoFiles.files.READ` to `ZOHO_SCOPES` and log in again.)*
333
333
  </details>
334
334
 
335
335
  <br>
@@ -418,6 +418,12 @@ metadata/
418
418
 
419
419
  ---
420
420
 
421
+ ## 🩺 Troubleshooting
422
+
423
+ Every command writes a timestamped **debug log** to `~/.zcrm/logs/` (e.g. `pull-<timestamp>.log`, `audit-<timestamp>.log`) capturing all activity for that run — including the quiet "skipped" lines and the **full API error bodies** that aren't printed to the console. When a command fails, its log path is shown in the output. `zcrm pull` additionally writes a copy into its output folder at `<output>/.zcrm/.logs/`.
424
+
425
+ ---
426
+
421
427
  ## ✉️ Support & Feedback
422
428
 
423
429
  Found a bug, have a feature request, or need custom Zoho integrations? Reach out:
package/bin/cli.js CHANGED
@@ -9,11 +9,43 @@ const http = require('http');
9
9
  const url = require('url');
10
10
  const path = require('path');
11
11
  const { exec, spawn } = require('child_process');
12
+ const os = require('os');
12
13
  const configStore = require('../src/utils/configStore');
13
14
  const authService = require('../src/services/authService');
14
15
  const crmMetadataService = require('../src/services/metadata/crmMetadataService');
15
16
  const auditLogService = require('../src/services/auditLogService');
16
17
  const skillGenerator = require('../src/utils/skillGenerator');
18
+ const { addSessionLog, logInfo, shortErrorMessage } = require('../src/utils/logger');
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Per-command debug log
22
+ // ---------------------------------------------------------------------------
23
+ // Every real command writes a timestamped debug log to ~/.zcrm/logs/ that
24
+ // captures ALL log entries for the run — including the quiet info/skip lines
25
+ // and full API error bodies that are not printed to the console. The path is
26
+ // shown if the command fails. (Command args are intentionally NOT logged, to
27
+ // avoid writing secrets like --client-secret / --refresh-token to disk.)
28
+ const LOGGED_COMMANDS = ['login', 'logout', 'whoami', 'status', 'pull', 'audit', 'llm', 'skill'];
29
+ let debugLogPath = null;
30
+ (function setupDebugLog() {
31
+ try {
32
+ const cmd = process.argv[2];
33
+ if (!LOGGED_COMMANDS.includes(cmd)) return;
34
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
35
+ debugLogPath = path.join(os.homedir(), '.zcrm', 'logs', `${cmd}-${ts}.log`);
36
+ addSessionLog(debugLogPath);
37
+ logInfo(`zcrm ${cmd} started.`, 'cli');
38
+ } catch (err) {
39
+ // Debug-logging setup must never break the command.
40
+ }
41
+ })();
42
+
43
+ // Point the user at the debug log when a command exits with an error.
44
+ process.on('exit', (code) => {
45
+ if (code && debugLogPath) {
46
+ try { process.stderr.write(`\x1b[90mℹ Debug log: ${debugLogPath}\x1b[0m\n`); } catch (e) { /* ignore */ }
47
+ }
48
+ });
17
49
 
18
50
  /**
19
51
  * Opens a URL in the user's default browser in a cross-platform-safe way.
@@ -577,13 +609,15 @@ program
577
609
  console.log('\x1b[1;32m====================================================================\x1b[0m\n');
578
610
  process.exit(0);
579
611
  } catch (error) {
580
- const apiMsg = error.response?.data?.message || error.message;
612
+ const apiMsg = shortErrorMessage(error);
581
613
  const isScope = error.response?.data?.code === 'OAUTH_SCOPE_MISMATCH' || /scope/i.test(apiMsg || '');
582
614
  console.error(`\n❌ Audit log export failed: ${apiMsg}`);
583
615
  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');
616
+ console.error('\n\x1b[33mThis needs the audit-log scopes \x1b[1mZohoCRM.settings.audit_logs.CREATE\x1b[0m\x1b[33m and');
617
+ console.error('\x1b[1mZohoCRM.settings.audit_logs.READ\x1b[0m\x1b[33m, which your current session was not granted.');
618
+ console.error('Re-authenticate to add them:\x1b[0m \x1b[36mzcrm logout\x1b[0m then \x1b[36mzcrm login\x1b[0m');
619
+ console.error('\x1b[90m(If the download step then reports a file-scope error, add ZohoFiles.files.READ');
620
+ console.error('to ZOHO_SCOPES and log in again.)\x1b[0m');
587
621
  }
588
622
  process.exit(1);
589
623
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wanas-zcrm-extractor",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "description": "Local Zoho CRM V8 Metadata Extractor CLI and Web Dashboard Utility",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -11,7 +11,11 @@ async function delay(ms) {
11
11
 
12
12
  async function exportAuditLog(filterFile) {
13
13
  try {
14
- let requestBody = { audit_log_export: [{}] }; // default: all logs up to 3 years
14
+ // For a full export the request body is OPTIONAL and sending an object
15
+ // without a non-empty `criteria` is rejected by Zoho (400). So the default
16
+ // (no filter) sends NO body, and a filter file (which must contain a valid
17
+ // `audit_log_export` criteria object) overrides it when supplied.
18
+ let requestBody = null;
15
19
 
16
20
  if (filterFile) {
17
21
  const filterPath = path.resolve(process.cwd(), filterFile);
@@ -54,7 +54,13 @@ 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,ZohoCRM.settings.audit_logs.ALL';
57
+ // Every scope below is a documented ZohoCRM V8 scope. Audit logs have NO
58
+ // `.ALL` variant — the export flow uses the specific operation scopes
59
+ // (CREATE to initiate, READ to poll). The download step's cross-service
60
+ // ZohoFiles scope is intentionally NOT requested here so a single login
61
+ // stays entirely within ZohoCRM scopes (it can be added via ZOHO_SCOPES if
62
+ // a particular account needs it for the audit download).
63
+ 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.CREATE,ZohoCRM.settings.audit_logs.READ';
58
64
  if (process.env.ZCRM_CLI_MODE === 'true') {
59
65
  return configStore.get('scopes') || process.env.ZOHO_SCOPES || defaultScopes;
60
66
  }
@@ -4,7 +4,7 @@ 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, setSessionLog, clearSessionLog } = require('../../utils/logger');
7
+ const { logError, logInfo, addSessionLog, removeSessionLog } = require('../../utils/logger');
8
8
  const { formatDeluge } = require('../../utils/delugeFormatter');
9
9
  const { buildResultReadme } = require('../../utils/readmeTemplate');
10
10
  const pkg = require('../../../package.json');
@@ -113,6 +113,10 @@ class CrmMetadataService extends BaseMetadataService {
113
113
  // metadata-only with no record-data access.
114
114
  const withCounts = options.withCounts === true;
115
115
 
116
+ // Path of the debug log written into the output folder (set once the run
117
+ // starts; declared here so the finally block can stop mirroring to it).
118
+ let outputLogPath = null;
119
+
116
120
  // Track every file written this run so stale files can be swept afterward.
117
121
  const writtenFiles = new Set();
118
122
  const recordWrite = (p) => writtenFiles.add(normalizePath(p));
@@ -207,13 +211,13 @@ class CrmMetadataService extends BaseMetadataService {
207
211
  const month = String(today.getMonth() + 1).padStart(2, '0');
208
212
  const date = String(today.getDate()).padStart(2, '0');
209
213
  const logFileName = `ZCRM-CLI-${year}-${month}-${date}.log`;
210
- const logFilePath = path.join(finalOutputDir, '.zcrm', '.logs', logFileName);
214
+ outputLogPath = path.join(finalOutputDir, '.zcrm', '.logs', logFileName);
211
215
  await fs.outputJson(path.join(finalOutputDir, '.zcrm', '.logs', '.audit.json'), {}, { spaces: 2 });
212
- await fs.outputFile(logFilePath, `[${today.toISOString()}] zcrm pull started (concurrency ${concurrency}${withCounts ? ', record counts ON' : ''}).\n`, 'utf8');
216
+ await fs.outputFile(outputLogPath, `[${today.toISOString()}] zcrm pull started (concurrency ${concurrency}${withCounts ? ', record counts ON' : ''}).\n`, 'utf8');
213
217
  // Mirror every subsequent log entry (including the quiet info/skip lines
214
218
  // suppressed from the console) into a debug log in the result folder.
215
- setSessionLog(logFilePath);
216
- stats.logFile = logFilePath;
219
+ addSessionLog(outputLogPath);
220
+ stats.logFile = outputLogPath;
217
221
 
218
222
  // 1. Fetch Org Information to get Org ID/Name
219
223
  onProgress('FETCH_ORG', 'Fetching CRM Organization details...');
@@ -900,8 +904,9 @@ class CrmMetadataService extends BaseMetadataService {
900
904
  onProgress('FAILURE', `Extraction failed: ${error.message}`);
901
905
  throw error;
902
906
  } finally {
903
- // Stop mirroring logs to the result folder once the pull is done.
904
- clearSessionLog();
907
+ // Stop mirroring logs to the result folder once the pull is done (the
908
+ // CLI's own per-command debug log stays active).
909
+ if (outputLogPath) removeSessionLog(outputLogPath);
905
910
  }
906
911
  }
907
912
  }
@@ -3,7 +3,7 @@ const path = require('path');
3
3
  const os = require('os');
4
4
 
5
5
  /**
6
- * Dynamically resolves the log file path based on running mode (CLI vs Web).
6
+ * Dynamically resolves the default log file path based on running mode (CLI vs Web).
7
7
  * @returns {string} The resolved absolute file path for logging.
8
8
  */
9
9
  const getLogFile = () => {
@@ -13,71 +13,110 @@ 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;
16
+ // Additional "session" log files that receive a copy of every log entry. The
17
+ // CLI adds one per command run (for debugging), and `zcrm pull` adds another in
18
+ // its output folder. Multiple targets can be active at once.
19
+ let sessionLogPaths = [];
20
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;
21
+ /** Adds an extra log file that mirrors every subsequent log entry. */
22
+ function addSessionLog(filePath) {
23
+ if (filePath && !sessionLogPaths.includes(filePath)) sessionLogPaths.push(filePath);
24
+ }
25
+
26
+ /** Removes a previously-added session log file. */
27
+ function removeSessionLog(filePath) {
28
+ sessionLogPaths = sessionLogPaths.filter((p) => p !== filePath);
28
29
  }
29
30
 
30
- /** Stops mirroring log entries to the session log file. */
31
- function clearSessionLog() {
32
- sessionLogPath = null;
31
+ /** Removes all session log files. */
32
+ function clearSessionLogs() {
33
+ sessionLogPaths = [];
33
34
  }
34
35
 
35
36
  /**
36
- * Appends an entry to the default log file and (if set) the session log file.
37
+ * Appends an entry to the default log file and every active session log file.
37
38
  * @param {string} entry
38
39
  */
39
40
  function writeEntry(entry) {
40
41
  try {
41
42
  fs.outputFileSync(getLogFile(), entry, { flag: 'a' });
42
43
  } catch (ioErr) {
43
- console.error('Failed to write to log file:', ioErr);
44
+ // Last resort — don't crash the command over a logging failure.
44
45
  }
45
- if (sessionLogPath) {
46
+ for (const p of sessionLogPaths) {
46
47
  try {
47
- fs.outputFileSync(sessionLogPath, entry, { flag: 'a' });
48
+ fs.outputFileSync(p, entry, { flag: 'a' });
48
49
  } catch (ioErr) {
49
- // Best-effort; never let session logging break the run.
50
+ // best-effort
50
51
  }
51
52
  }
52
53
  }
53
54
 
54
55
  /**
55
- * Log error message to console and output log file.
56
+ * Builds a detailed, file-friendly description of an error — including the full
57
+ * HTTP response body for Axios errors, which is essential for debugging API
58
+ * failures (e.g. a Zoho 400 whose real reason lives in the response body).
59
+ * @param {Error|object|string} error
60
+ * @returns {string}
61
+ */
62
+ function describeError(error) {
63
+ if (error && error.isAxiosError) {
64
+ const method = error.config?.method?.toUpperCase() || 'GET';
65
+ const url = error.config?.url || '';
66
+ const status = error.response?.status || 'no-status';
67
+ const body = error.response?.data;
68
+ const bodyStr = body == null
69
+ ? '(no response body)'
70
+ : (typeof body === 'string' ? body : JSON.stringify(body, null, 2));
71
+ return [
72
+ `${error.message}`,
73
+ `Request: ${method} ${url}`,
74
+ `Status: ${status}`,
75
+ `Response body: ${bodyStr}`,
76
+ `Stack: ${error.stack}`
77
+ ].join('\n');
78
+ }
79
+ if (error instanceof Error) {
80
+ return `${error.message}\nStack: ${error.stack}`;
81
+ }
82
+ if (typeof error === 'object') {
83
+ return JSON.stringify(error, null, 2);
84
+ }
85
+ return String(error);
86
+ }
87
+
88
+ /**
89
+ * Returns a concise one-line message for console output from any error,
90
+ * preferring the Zoho response message/code when present.
91
+ * @param {Error|object|string} error
92
+ * @returns {string}
93
+ */
94
+ function shortErrorMessage(error) {
95
+ const body = error?.response?.data;
96
+ if (body) {
97
+ if (typeof body === 'string') return body;
98
+ if (body.message) return `${body.message}${body.code ? ` [${body.code}]` : ''}`;
99
+ return JSON.stringify(body);
100
+ }
101
+ return error?.message || String(error);
102
+ }
103
+
104
+ /**
105
+ * Log error message to console and log file(s).
56
106
  * @param {Error|object|string} error - The error to log.
57
107
  * @param {string} [context] - Context name where error occurred.
58
108
  */
59
109
  function logError(error, context = '') {
60
110
  const timestamp = new Date().toISOString();
61
- let errorMessage = '';
62
-
63
- if (error instanceof Error) {
64
- errorMessage = `${error.message}\nStack: ${error.stack}`;
65
- } else if (typeof error === 'object') {
66
- errorMessage = JSON.stringify(error, null, 2);
67
- } else {
68
- errorMessage = String(error);
69
- }
70
-
71
- const logEntry = `[${timestamp}] [ERROR] ${context ? `(${context}) ` : ''}${errorMessage}\n--------------------------------------------------\n`;
111
+ const logEntry = `[${timestamp}] [ERROR] ${context ? `(${context}) ` : ''}${describeError(error)}\n--------------------------------------------------\n`;
72
112
 
73
- // Output cleaner message to CLI instead of dumping the huge Axios error object
113
+ // Output a clean one-liner to the CLI instead of dumping the huge Axios object.
74
114
  if (process.env.ZCRM_CLI_MODE === 'true') {
75
115
  if (error && error.isAxiosError) {
76
116
  const method = error.config?.method?.toUpperCase() || 'GET';
77
117
  const url = error.config?.url || '';
78
118
  const status = error.response?.status || 'Unknown Status';
79
- const msg = error.response?.data?.message || error.message;
80
- console.error(`[ERROR] ${context ? `(${context}) ` : ''}API Request Failed: ${method} ${url} -> Status: ${status} (${msg})`);
119
+ console.error(`[ERROR] ${context ? `(${context}) ` : ''}API Request Failed: ${method} ${url} -> Status: ${status} (${shortErrorMessage(error)})`);
81
120
  } else {
82
121
  console.error(`[ERROR] ${context ? `(${context}) ` : ''}${error.message || error}`);
83
122
  }
@@ -89,7 +128,7 @@ function logError(error, context = '') {
89
128
  }
90
129
 
91
130
  /**
92
- * Log informational message to console and output log file.
131
+ * Log informational message to console and log file(s).
93
132
  * @param {string} message - The message.
94
133
  * @param {string} [context] - Context name.
95
134
  */
@@ -97,7 +136,7 @@ function logInfo(message, context = '') {
97
136
  const timestamp = new Date().toISOString();
98
137
  const logEntry = `[${timestamp}] [INFO] ${context ? `(${context}) ` : ''}${message}\n`;
99
138
 
100
- // Suppress info console logging when running CLI normally (unless verbose mode is enabled)
139
+ // Suppress info console logging when running CLI normally (unless verbose).
101
140
  if (process.env.ZCRM_CLI_MODE !== 'true' || process.env.ZCRM_VERBOSE === 'true') {
102
141
  console.log(`[INFO] ${context ? `(${context}) ` : ''}${message}`);
103
142
  }
@@ -108,6 +147,8 @@ function logInfo(message, context = '') {
108
147
  module.exports = {
109
148
  logError,
110
149
  logInfo,
111
- setSessionLog,
112
- clearSessionLog
150
+ shortErrorMessage,
151
+ addSessionLog,
152
+ removeSessionLog,
153
+ clearSessionLogs
113
154
  };