@arela/uploader 1.1.4 → 1.2.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.
Files changed (95) hide show
  1. package/.claude/worktrees/agent-multi-profile/.env.template +224 -0
  2. package/.claude/worktrees/agent-multi-profile/.prettierrc +13 -0
  3. package/.claude/worktrees/agent-multi-profile/README.md +405 -0
  4. package/.claude/worktrees/agent-multi-profile/package-lock.json +7096 -0
  5. package/.claude/worktrees/agent-multi-profile/package.json +78 -0
  6. package/.claude/worktrees/agent-multi-profile/scripts/cleanup-ds-store.js +109 -0
  7. package/.claude/worktrees/agent-multi-profile/scripts/cleanup-system-files.js +69 -0
  8. package/.claude/worktrees/agent-multi-profile/scripts/scoring-compare.js +243 -0
  9. package/.claude/worktrees/agent-multi-profile/scripts/scoring-phase4-check.js +96 -0
  10. package/.claude/worktrees/agent-multi-profile/scripts/tests/phase-7-features.test.js +415 -0
  11. package/.claude/worktrees/agent-multi-profile/scripts/tests/signal-handling.test.js +275 -0
  12. package/.claude/worktrees/agent-multi-profile/scripts/tests/smart-watch-integration.test.js +554 -0
  13. package/.claude/worktrees/agent-multi-profile/scripts/tests/watch-service-integration.test.js +584 -0
  14. package/.claude/worktrees/agent-multi-profile/src/commands/AgentCommand.js +229 -0
  15. package/.claude/worktrees/agent-multi-profile/src/commands/AgentInitCommand.js +316 -0
  16. package/.claude/worktrees/agent-multi-profile/src/commands/DatastageCommand.js +164 -0
  17. package/.claude/worktrees/agent-multi-profile/src/commands/GDriveSyncCommand.js +475 -0
  18. package/.claude/worktrees/agent-multi-profile/src/commands/IdentifyCommand.js +708 -0
  19. package/.claude/worktrees/agent-multi-profile/src/commands/PollWorkerCommand.js +169 -0
  20. package/.claude/worktrees/agent-multi-profile/src/commands/PropagateCommand.js +636 -0
  21. package/.claude/worktrees/agent-multi-profile/src/commands/PushCommand.js +743 -0
  22. package/.claude/worktrees/agent-multi-profile/src/commands/ScanCommand.js +722 -0
  23. package/.claude/worktrees/agent-multi-profile/src/commands/UploadCommand.js +587 -0
  24. package/.claude/worktrees/agent-multi-profile/src/commands/WatchCommand.js +1342 -0
  25. package/.claude/worktrees/agent-multi-profile/src/commands/WorkerCommand.js +337 -0
  26. package/.claude/worktrees/agent-multi-profile/src/config/config.js +862 -0
  27. package/.claude/worktrees/agent-multi-profile/src/document-type-shared.js +131 -0
  28. package/.claude/worktrees/agent-multi-profile/src/document-types/_pedimento-shared-extractors.js +348 -0
  29. package/.claude/worktrees/agent-multi-profile/src/document-types/doda-pdf.js +121 -0
  30. package/.claude/worktrees/agent-multi-profile/src/document-types/doda-xml.js +118 -0
  31. package/.claude/worktrees/agent-multi-profile/src/document-types/factura-inter-agencia.js +186 -0
  32. package/.claude/worktrees/agent-multi-profile/src/document-types/facturas-comerciales.js +233 -0
  33. package/.claude/worktrees/agent-multi-profile/src/document-types/pedimento-completo-xml.js +372 -0
  34. package/.claude/worktrees/agent-multi-profile/src/document-types/pedimento-completo.js +108 -0
  35. package/.claude/worktrees/agent-multi-profile/src/document-types/pedimento-simplificado.js +76 -0
  36. package/.claude/worktrees/agent-multi-profile/src/document-types/proforma.js +29 -0
  37. package/.claude/worktrees/agent-multi-profile/src/document-types/support-document.js +200 -0
  38. package/.claude/worktrees/agent-multi-profile/src/errors/ErrorHandler.js +278 -0
  39. package/.claude/worktrees/agent-multi-profile/src/errors/ErrorTypes.js +104 -0
  40. package/.claude/worktrees/agent-multi-profile/src/file-detection.js +338 -0
  41. package/.claude/worktrees/agent-multi-profile/src/index.js +890 -0
  42. package/.claude/worktrees/agent-multi-profile/src/scoring/db-matcher-adapter.js +98 -0
  43. package/.claude/worktrees/agent-multi-profile/src/scoring/matchers-seed.js +386 -0
  44. package/.claude/worktrees/agent-multi-profile/src/scoring/scoring-engine.js +251 -0
  45. package/.claude/worktrees/agent-multi-profile/src/services/AdvancedFilterService.js +505 -0
  46. package/.claude/worktrees/agent-multi-profile/src/services/AutoProcessingService.js +749 -0
  47. package/.claude/worktrees/agent-multi-profile/src/services/BenchmarkingService.js +381 -0
  48. package/.claude/worktrees/agent-multi-profile/src/services/DatabaseService.js +2173 -0
  49. package/.claude/worktrees/agent-multi-profile/src/services/DatastageApiService.js +240 -0
  50. package/.claude/worktrees/agent-multi-profile/src/services/ErrorMonitor.js +275 -0
  51. package/.claude/worktrees/agent-multi-profile/src/services/GoogleDriveService.js +217 -0
  52. package/.claude/worktrees/agent-multi-profile/src/services/LoggingService.js +649 -0
  53. package/.claude/worktrees/agent-multi-profile/src/services/MonitoringService.js +401 -0
  54. package/.claude/worktrees/agent-multi-profile/src/services/PerformanceOptimizer.js +511 -0
  55. package/.claude/worktrees/agent-multi-profile/src/services/PipelineApiService.js +274 -0
  56. package/.claude/worktrees/agent-multi-profile/src/services/PipelineJobRunner.js +389 -0
  57. package/.claude/worktrees/agent-multi-profile/src/services/ProfileManager.js +164 -0
  58. package/.claude/worktrees/agent-multi-profile/src/services/ReportingService.js +511 -0
  59. package/.claude/worktrees/agent-multi-profile/src/services/ScanApiService.js +775 -0
  60. package/.claude/worktrees/agent-multi-profile/src/services/SignalHandler.js +255 -0
  61. package/.claude/worktrees/agent-multi-profile/src/services/SmartWatchDatabaseService.js +527 -0
  62. package/.claude/worktrees/agent-multi-profile/src/services/WatchService.js +783 -0
  63. package/.claude/worktrees/agent-multi-profile/src/services/upload/ApiUploadService.js +676 -0
  64. package/.claude/worktrees/agent-multi-profile/src/services/upload/BaseUploadService.js +36 -0
  65. package/.claude/worktrees/agent-multi-profile/src/services/upload/MultiApiUploadService.js +233 -0
  66. package/.claude/worktrees/agent-multi-profile/src/services/upload/SupabaseUploadService.js +148 -0
  67. package/.claude/worktrees/agent-multi-profile/src/services/upload/UploadServiceFactory.js +100 -0
  68. package/.claude/worktrees/agent-multi-profile/src/utils/CleanupManager.js +262 -0
  69. package/.claude/worktrees/agent-multi-profile/src/utils/FileOperations.js +192 -0
  70. package/.claude/worktrees/agent-multi-profile/src/utils/FileSanitizer.js +99 -0
  71. package/.claude/worktrees/agent-multi-profile/src/utils/PathDetector.js +198 -0
  72. package/.claude/worktrees/agent-multi-profile/src/utils/PathNormalizer.js +274 -0
  73. package/.claude/worktrees/agent-multi-profile/src/utils/WatchEventHandler.js +522 -0
  74. package/.claude/worktrees/agent-multi-profile/supabase/migrations/001_create_initial_schema.sql +366 -0
  75. package/.claude/worktrees/agent-multi-profile/supabase/migrations/002_align_with_arela_api_schema.sql +145 -0
  76. package/.claude/worktrees/agent-multi-profile/tests/commands/IdentifyCommand.test.js +570 -0
  77. package/.claude/worktrees/agent-multi-profile/tests/commands/PropagateCommand.test.js +568 -0
  78. package/.claude/worktrees/agent-multi-profile/tests/commands/PushCommand.test.js +754 -0
  79. package/.claude/worktrees/agent-multi-profile/tests/commands/ScanCommand.test.js +382 -0
  80. package/.claude/worktrees/agent-multi-profile/tests/unit/PathAndTableNameGeneration.test.js +1211 -0
  81. package/.claude/worktrees/agent-multi-profile/tests/unit/factura-inter-agencia.test.js +218 -0
  82. package/.claude/worktrees/agent-multi-profile/tests/unit/pedimento-completo-xml-matcher.test.js +271 -0
  83. package/.claude/worktrees/agent-multi-profile/tests/unit/pedimento-simplificado-matcher.test.js +185 -0
  84. package/.claude/worktrees/agent-multi-profile/tests/unit/scoring-engine.test.js +221 -0
  85. package/README.md +65 -0
  86. package/package.json +1 -1
  87. package/src/commands/AgentCommand.js +210 -0
  88. package/src/commands/AgentInitCommand.js +316 -0
  89. package/src/commands/PollWorkerCommand.js +11 -322
  90. package/src/config/config.js +44 -6
  91. package/src/index.js +55 -0
  92. package/src/services/LoggingService.js +35 -0
  93. package/src/services/PipelineApiService.js +7 -1
  94. package/src/services/PipelineJobRunner.js +389 -0
  95. package/src/services/ProfileManager.js +164 -0
@@ -0,0 +1,229 @@
1
+ import logger from '../services/LoggingService.js';
2
+ import { PipelineApiService } from '../services/PipelineApiService.js';
3
+ import { PipelineJobRunner } from '../services/PipelineJobRunner.js';
4
+ import {
5
+ DEFAULT_PROFILES_PATH,
6
+ ProfileManager,
7
+ } from '../services/ProfileManager.js';
8
+
9
+ import appConfig from '../config/config.js';
10
+
11
+ const BACKOFF_BASE_MS = 30_000;
12
+ const BACKOFF_MAX_MS = 600_000;
13
+ const SHUTDOWN_JOB_TIMEOUT_MS = 300_000;
14
+
15
+ /**
16
+ * Agent Command Handler
17
+ * Multi-profile polling agent: ONE process (one terminal) replaces the N
18
+ * `arela worker --poll` terminals (one per RFC×source folder with its own .env).
19
+ * Profiles (tenant API url + token) come from profiles.json; everything else
20
+ * (scan directories, tuning, schedule) lives server-side in pipeline_config.
21
+ *
22
+ * Jobs are processed ONE AT A TIME globally: appConfig is a singleton and the
23
+ * job runner mutates process.env per job, so running two jobs concurrently
24
+ * would corrupt each other's configuration. If parallelism is ever needed,
25
+ * spawn child processes — never threads/promises inside this process.
26
+ *
27
+ * Usage:
28
+ * arela agent
29
+ * arela agent --config ./profiles.json --profile ktj,cem
30
+ */
31
+ export class AgentCommand {
32
+ constructor() {
33
+ this.isShuttingDown = false;
34
+ this.activeJobPromise = null;
35
+ this.pollTimer = null;
36
+ }
37
+
38
+ /**
39
+ * Execute the agent command
40
+ * @param {Object} options - Command options
41
+ * @param {string} options.config - Path to profiles.json
42
+ * @param {string} options.interval - Base poll interval per round (ms)
43
+ * @param {string} options.profile - Comma-separated subset of profiles to run
44
+ */
45
+ async execute(options = {}) {
46
+ const agentConfig = ProfileManager.loadProfiles(
47
+ options.config || DEFAULT_PROFILES_PATH,
48
+ );
49
+
50
+ let profiles = agentConfig.profiles;
51
+ if (options.profile) {
52
+ const requested = options.profile
53
+ .split(',')
54
+ .map((name) => name.trim().toLowerCase())
55
+ .filter(Boolean);
56
+ const known = new Set(profiles.map((p) => p.name));
57
+ const unknown = requested.filter((name) => !known.has(name));
58
+ if (unknown.length > 0) {
59
+ throw new Error(
60
+ `Unknown profile(s): ${unknown.join(', ')}. Available: ${[...known].join(', ')}`,
61
+ );
62
+ }
63
+ profiles = profiles.filter((p) => requested.includes(p.name));
64
+ }
65
+
66
+ const interval =
67
+ parseInt(options.interval) > 0
68
+ ? parseInt(options.interval)
69
+ : agentConfig.pollIntervalMs;
70
+
71
+ logger.setLogFilePath(agentConfig.logFile);
72
+
73
+ logger.info('🤖 Starting Arela Agent (multi-profile poll worker)');
74
+ logger.info(`🖥️ Server ID: ${agentConfig.serverId}`);
75
+ logger.info(`⏱️ Poll Interval: ${interval}ms`);
76
+ logger.info(`📒 Log file: ${agentConfig.logFile}`);
77
+
78
+ // One API client + job runner per profile, built once. PipelineApiService
79
+ // copies baseUrl/token at construction, so each instance stays bound to its
80
+ // profile even while applyProfile() reconfigures the singleton for others.
81
+ for (const profile of profiles) {
82
+ appConfig.applyProfile(profile);
83
+ profile.api = new PipelineApiService(profile.name);
84
+ profile.runner = new PipelineJobRunner(profile.api, agentConfig.serverId);
85
+ profile.failures = 0;
86
+ profile.backoffUntil = 0;
87
+ logger.info(`📡 Profile '${profile.name}' → ${profile.url}`);
88
+ }
89
+
90
+ this.#setupShutdownHandlers(profiles);
91
+
92
+ logger.success('Agent is running. Press Ctrl+C to stop.');
93
+
94
+ await this.#runLoop(profiles, agentConfig.serverId, interval);
95
+ }
96
+
97
+ /**
98
+ * Round-robin polling loop over all profiles
99
+ * @private
100
+ */
101
+ async #runLoop(profiles, serverId, interval) {
102
+ let round = 0;
103
+
104
+ while (!this.isShuttingDown) {
105
+ let gotAnyJob = false;
106
+ let polled = 0;
107
+ let jobs = 0;
108
+ round += 1;
109
+
110
+ for (const profile of profiles) {
111
+ if (this.isShuttingDown) break;
112
+ if (Date.now() < profile.backoffUntil) continue;
113
+
114
+ try {
115
+ // Reconfigure the singleton for THIS profile before polling/processing
116
+ appConfig.applyProfile(profile);
117
+ logger.setContext(profile.name);
118
+
119
+ const job = await profile.api.getNextJob(serverId, {
120
+ throwOnError: true,
121
+ });
122
+ profile.failures = 0;
123
+ profile.backoffUntil = 0;
124
+ polled += 1;
125
+
126
+ if (job && job.id) {
127
+ gotAnyJob = true;
128
+ jobs += 1;
129
+ logger.info(
130
+ `📥 Got job: ${job.id} - ${job.type} for RFC ${job.rfc}`,
131
+ );
132
+ this.activeJobPromise = profile.runner.processJob(job);
133
+ await this.activeJobPromise;
134
+ this.activeJobPromise = null;
135
+ } else {
136
+ logger.debug('📭 No jobs available');
137
+ }
138
+ } catch (error) {
139
+ // A failing profile backs off exponentially but never kills the loop
140
+ profile.failures += 1;
141
+ const backoffMs = Math.min(
142
+ BACKOFF_BASE_MS * 2 ** (profile.failures - 1),
143
+ BACKOFF_MAX_MS,
144
+ );
145
+ profile.backoffUntil = Date.now() + backoffMs;
146
+ logger.error(
147
+ `Poll failed (attempt ${profile.failures}): ${error.message} — backing off ${Math.round(backoffMs / 1000)}s`,
148
+ );
149
+ } finally {
150
+ logger.setContext(null);
151
+ }
152
+ }
153
+
154
+ // Heartbeat line: first round always, then every ~5 min of idle rounds,
155
+ // so a quiet log is distinguishable from a stalled one
156
+ const summaryEvery = Math.max(1, Math.round(300_000 / interval));
157
+ if (round === 1 || jobs > 0 || round % summaryEvery === 0) {
158
+ const inBackoff = profiles.filter(
159
+ (p) => Date.now() < p.backoffUntil,
160
+ ).length;
161
+ logger.info(
162
+ `🔁 Round ${round}: polled ${polled}/${profiles.length} profiles, ${jobs} job(s), ${inBackoff} in backoff`,
163
+ );
164
+ }
165
+
166
+ // One sleep per idle round (not one per profile)
167
+ if (!gotAnyJob && !this.isShuttingDown) {
168
+ await this.#sleep(interval);
169
+ }
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Sleep for specified milliseconds
175
+ * @private
176
+ */
177
+ #sleep(ms) {
178
+ return new Promise((resolve) => {
179
+ this.pollTimer = setTimeout(resolve, ms);
180
+ });
181
+ }
182
+
183
+ /**
184
+ * Setup graceful shutdown handlers
185
+ * @private
186
+ */
187
+ #setupShutdownHandlers(profiles) {
188
+ const shutdown = async (signal) => {
189
+ if (this.isShuttingDown) return;
190
+ this.isShuttingDown = true;
191
+
192
+ logger.setContext(null);
193
+ logger.info(`\n👋 Received ${signal}. Shutting down agent...`);
194
+
195
+ if (this.pollTimer) {
196
+ clearTimeout(this.pollTimer);
197
+ }
198
+
199
+ // Wait for the in-flight job so it ends COMPLETED/FAILED, never orphaned RUNNING
200
+ if (this.activeJobPromise) {
201
+ logger.info('⏳ Waiting for the current job to finish...');
202
+ await Promise.race([
203
+ this.activeJobPromise,
204
+ new Promise((resolve) =>
205
+ setTimeout(resolve, SHUTDOWN_JOB_TIMEOUT_MS),
206
+ ),
207
+ ]);
208
+ }
209
+
210
+ for (const profile of profiles) {
211
+ profile.api?.destroy();
212
+ }
213
+
214
+ logger.info('✅ Agent stopped gracefully');
215
+ logger.flush();
216
+ process.exit(0);
217
+ };
218
+
219
+ // The global SIGINT/SIGTERM handlers (index.js and LoggingService) call
220
+ // process.exit(0) immediately, which would kill the in-flight job mid-write.
221
+ // Agent mode owns shutdown: replace them with the graceful version.
222
+ process.removeAllListeners('SIGINT');
223
+ process.removeAllListeners('SIGTERM');
224
+ process.on('SIGINT', () => shutdown('SIGINT'));
225
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
226
+ }
227
+ }
228
+
229
+ export default new AgentCommand();
@@ -0,0 +1,316 @@
1
+ import { parse as parseEnv } from 'dotenv';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+
6
+ import {
7
+ DEFAULT_AGENT_LOG_PATH,
8
+ expandHome,
9
+ } from '../services/ProfileManager.js';
10
+
11
+ const TUNING_ENV_KEYS = [
12
+ 'SCAN_EXCLUDE_PATTERNS',
13
+ 'SCAN_BATCH_SIZE',
14
+ 'PUSH_BATCH_SIZE',
15
+ 'PUSH_UPLOAD_BATCH_SIZE',
16
+ 'MAX_CONCURRENT_SOURCES',
17
+ 'MAX_API_CONNECTIONS',
18
+ 'API_CONNECTION_TIMEOUT',
19
+ 'BATCH_DELAY',
20
+ 'BATCH_SIZE',
21
+ ];
22
+
23
+ /**
24
+ * Agent Init Command Handler
25
+ * Migration helper: reads the legacy per-folder .env files (one folder per
26
+ * RFC×source) and generates, WITHOUT calling any API:
27
+ * - profiles.json → minimal agent config (folders on the same tenant API
28
+ * collapse into one profile)
29
+ * - seed-configs.json → one pipeline_config payload per folder (the data that
30
+ * moves server-side: scan directories, tuning, schedule)
31
+ * - seed-configs.sh → ready-to-run curls to POST those payloads
32
+ *
33
+ * Tokens never leave the machine: everything is generated locally for review.
34
+ *
35
+ * Usage:
36
+ * arela agent init --from ../ktj-cli-maya ../ktj-cli-matro --server-id PALCO-01
37
+ */
38
+ export class AgentInitCommand {
39
+ /**
40
+ * Execute the init command
41
+ * @param {string[]} dirs - Folders containing legacy .env files
42
+ * @param {Object} options
43
+ * @param {string} options.serverId - Machine server id (default: ARELA_SERVER_ID from first .env, else hostname)
44
+ * @param {string} options.out - Output directory (default ~/.arela)
45
+ * @param {boolean} options.force - Overwrite an existing profiles.json
46
+ */
47
+ async execute(dirs, options = {}) {
48
+ if (!dirs || dirs.length === 0) {
49
+ throw new Error(
50
+ 'At least one folder is required: arela agent init --from <dir1> <dir2> …',
51
+ );
52
+ }
53
+
54
+ const outDir = path.resolve(expandHome(options.out || '~/.arela'));
55
+ fs.mkdirSync(outDir, { recursive: true });
56
+
57
+ const profilesPath = path.join(outDir, 'profiles.json');
58
+ if (fs.existsSync(profilesPath) && !options.force) {
59
+ throw new Error(
60
+ `${profilesPath} already exists. Use --force to overwrite it.`,
61
+ );
62
+ }
63
+
64
+ const parsedFolders = [];
65
+ for (const dir of dirs) {
66
+ const resolvedDir = path.resolve(expandHome(dir));
67
+ const envPath = path.join(resolvedDir, '.env');
68
+ if (!fs.existsSync(envPath)) {
69
+ console.warn(`⚠️ Skipping ${resolvedDir}: no .env file found`);
70
+ continue;
71
+ }
72
+ const env = parseEnv(fs.readFileSync(envPath));
73
+ const clienteUrl = env.ARELA_API_CLIENTE_URL || env.ARELA_API_URL || null;
74
+ const clienteToken =
75
+ env.ARELA_API_CLIENTE_TOKEN || env.ARELA_API_TOKEN || null;
76
+ if (!clienteUrl || !clienteToken) {
77
+ console.warn(
78
+ `⚠️ Skipping ${resolvedDir}: no ARELA_API_CLIENTE_URL/_TOKEN (nor ARELA_API_URL/_TOKEN)`,
79
+ );
80
+ continue;
81
+ }
82
+ parsedFolders.push({ dir: resolvedDir, env, clienteUrl, clienteToken });
83
+ }
84
+
85
+ if (parsedFolders.length === 0) {
86
+ throw new Error('No usable .env files found in the given folders');
87
+ }
88
+
89
+ const serverId =
90
+ options.serverId ||
91
+ parsedFolders.find((f) => f.env.ARELA_SERVER_ID)?.env.ARELA_SERVER_ID ||
92
+ os.hostname();
93
+
94
+ // Group folders by tenant API URL → one profile per tenant
95
+ const profilesByUrl = new Map();
96
+ for (const folder of parsedFolders) {
97
+ const key = folder.clienteUrl.replace(/\/+$/, '').toLowerCase();
98
+ if (!profilesByUrl.has(key)) {
99
+ const profile = {
100
+ name: this.#deriveProfileName(folder, profilesByUrl),
101
+ url: folder.clienteUrl.replace(/\/+$/, ''),
102
+ token: folder.clienteToken,
103
+ };
104
+ const agenciaUrl = folder.env.ARELA_API_AGENCIA_URL;
105
+ if (
106
+ agenciaUrl &&
107
+ agenciaUrl.replace(/\/+$/, '').toLowerCase() !== key
108
+ ) {
109
+ profile.targets = {
110
+ agencia: {
111
+ baseUrl: agenciaUrl.replace(/\/+$/, ''),
112
+ token: folder.env.ARELA_API_AGENCIA_TOKEN || folder.clienteToken,
113
+ },
114
+ };
115
+ }
116
+ profilesByUrl.set(key, profile);
117
+ }
118
+ folder.profile = profilesByUrl.get(key);
119
+ }
120
+
121
+ const profilesJson = {
122
+ version: 1,
123
+ serverId,
124
+ pollIntervalMs: 5000,
125
+ logFile: DEFAULT_AGENT_LOG_PATH,
126
+ profiles: [...profilesByUrl.values()],
127
+ };
128
+
129
+ // One pipeline_config payload per folder×RFC
130
+ const seeds = [];
131
+ for (const folder of parsedFolders) {
132
+ const rfcs = this.#splitPipe(
133
+ folder.env.UPLOAD_RFCS || folder.env.PUSH_RFCS,
134
+ );
135
+ if (rfcs.length === 0) {
136
+ console.warn(
137
+ `⚠️ ${folder.dir}: no UPLOAD_RFCS/PUSH_RFCS — using placeholder RFC_PENDIENTE (edit before seeding)`,
138
+ );
139
+ rfcs.push('RFC_PENDIENTE');
140
+ }
141
+ for (const rfc of rfcs) {
142
+ seeds.push({
143
+ profile: folder.profile.name,
144
+ apiUrl: folder.profile.url,
145
+ sourceFolder: folder.dir,
146
+ config: this.#buildPipelineConfig(folder, rfc, serverId),
147
+ });
148
+ }
149
+ }
150
+
151
+ const seedsJsonPath = path.join(outDir, 'seed-configs.json');
152
+ const seedsShPath = path.join(outDir, 'seed-configs.sh');
153
+
154
+ fs.writeFileSync(profilesPath, JSON.stringify(profilesJson, null, 2));
155
+ try {
156
+ fs.chmodSync(profilesPath, 0o600);
157
+ } catch {
158
+ // chmod is best-effort on Windows
159
+ }
160
+ fs.writeFileSync(seedsJsonPath, JSON.stringify(seeds, null, 2));
161
+ fs.writeFileSync(seedsShPath, this.#buildSeedScript(seeds, profilesJson));
162
+ try {
163
+ fs.chmodSync(seedsShPath, 0o755);
164
+ } catch {
165
+ // chmod is best-effort on Windows
166
+ }
167
+
168
+ console.log(`\n✅ Generated from ${parsedFolders.length} folder(s):`);
169
+ console.log(
170
+ ` 📄 ${profilesPath} (${profilesJson.profiles.length} profile(s))`,
171
+ );
172
+ console.log(
173
+ ` 📄 ${seedsJsonPath} (${seeds.length} pipeline_config payload(s))`,
174
+ );
175
+ console.log(` 📄 ${seedsShPath}`);
176
+ console.log('\nNext steps:');
177
+ console.log(' 1. Review both files (RFCs, scan directories, slugs)');
178
+ console.log(` 2. Seed the server configs: bash ${seedsShPath}`);
179
+ console.log(' 3. Start the agent: arela agent');
180
+ console.log(
181
+ ' 4. Once stable, enable scheduling per config (scheduleEnabled) and the PIPELINE_SCHEDULER cronjob per tenant',
182
+ );
183
+ }
184
+
185
+ /**
186
+ * Profile name from the API hostname's first label (ktj.api… → 'ktj'),
187
+ * falling back to the folder basename; deduped with numeric suffixes.
188
+ * @private
189
+ */
190
+ #deriveProfileName(folder, profilesByUrl) {
191
+ let base;
192
+ try {
193
+ base = new URL(folder.clienteUrl).hostname.split('.')[0];
194
+ } catch {
195
+ base = path.basename(folder.dir);
196
+ }
197
+ base = base.toLowerCase();
198
+ const taken = new Set(
199
+ [...profilesByUrl.values()].map((profile) => profile.name),
200
+ );
201
+ let name = base;
202
+ let suffix = 2;
203
+ while (taken.has(name)) {
204
+ name = `${base}-${suffix++}`;
205
+ }
206
+ return name;
207
+ }
208
+
209
+ /**
210
+ * Build the POST /api/uploader/pipeline/config payload for one folder×RFC
211
+ * @private
212
+ */
213
+ #buildPipelineConfig(folder, rfc, serverId) {
214
+ const { env, dir } = folder;
215
+
216
+ // Folder naming convention 'ktj-cli-maya' → source slug 'maya'
217
+ const basenameParts = path.basename(dir).split('-');
218
+ const agenciaSlug =
219
+ basenameParts.length > 1
220
+ ? basenameParts[basenameParts.length - 1]
221
+ : env.ARELA_COMPANY_SLUG || path.basename(dir);
222
+
223
+ const basePath = env.UPLOAD_BASE_PATH;
224
+ const sources = this.#splitPipe(env.UPLOAD_SOURCES);
225
+ let scanDirectories = [];
226
+ if (basePath && basePath !== '*' && sources.length > 0) {
227
+ const trimmedBase = basePath.replace(/[\\/]+$/, '');
228
+ const separator = trimmedBase.includes('\\') ? '\\' : '/';
229
+ scanDirectories = sources.map(
230
+ (source) => `${trimmedBase}${separator}${source}`,
231
+ );
232
+ } else if (basePath && basePath !== '*') {
233
+ scanDirectories = [basePath];
234
+ } else if (sources.length > 0) {
235
+ scanDirectories = sources;
236
+ }
237
+
238
+ const extraOptions = {};
239
+ if (env.ARELA_COMPANY_SLUG) {
240
+ extraOptions.companySlug = env.ARELA_COMPANY_SLUG;
241
+ }
242
+ if (env.SCAN_DIRECTORY_LEVEL !== undefined) {
243
+ extraOptions.directoryLevel = parseInt(env.SCAN_DIRECTORY_LEVEL) || 0;
244
+ }
245
+ for (const key of TUNING_ENV_KEYS) {
246
+ if (env[key] !== undefined) {
247
+ extraOptions[key] = env[key];
248
+ }
249
+ }
250
+
251
+ const crossTenant = !!folder.profile.targets?.agencia;
252
+
253
+ return {
254
+ rfc,
255
+ agenciaSlug: agenciaSlug.toLowerCase(),
256
+ displayName: `${rfc} — ${agenciaSlug}`,
257
+ scanDirectories,
258
+ ...(env.UPLOAD_FILE_EXTENSIONS && {
259
+ fileExtensions: env.UPLOAD_FILE_EXTENSIONS.split(',')
260
+ .map((extension) => extension.trim())
261
+ .filter(Boolean),
262
+ }),
263
+ ...(env.PUSH_BUCKET && { pushBucket: env.PUSH_BUCKET }),
264
+ folderStructure: env.PUSH_FOLDER_STRUCTURE || 'pedimento',
265
+ serverId,
266
+ sourceApi: crossTenant ? 'agencia' : 'cliente',
267
+ targetApi: 'cliente',
268
+ active: true,
269
+ extraOptions,
270
+ // Enable after the agent runs stable (see README)
271
+ scheduleEnabled: false,
272
+ scheduleIntervalHours: 24,
273
+ scheduleStartTime: '02:00',
274
+ scheduleJobType: 'full',
275
+ };
276
+ }
277
+
278
+ /**
279
+ * Build the curl script that seeds every pipeline_config
280
+ * @private
281
+ */
282
+ #buildSeedScript(seeds, profilesJson) {
283
+ const tokenByProfile = Object.fromEntries(
284
+ profilesJson.profiles.map((profile) => [profile.name, profile.token]),
285
+ );
286
+
287
+ let script = '#!/usr/bin/env bash\n';
288
+ script += '# Generated by `arela agent init` — review before running.\n';
289
+ script += '# Seeds one pipeline_config row per legacy folder×RFC.\n';
290
+ script += 'set -euo pipefail\n\n';
291
+
292
+ for (const seed of seeds) {
293
+ script += `# ${seed.config.rfc} / ${seed.config.agenciaSlug} (from ${seed.sourceFolder})\n`;
294
+ script += `curl -sS -X POST '${seed.apiUrl}/api/uploader/pipeline/config' \\\n`;
295
+ script += ` -H 'x-api-key: ${tokenByProfile[seed.profile]}' \\\n`;
296
+ script += ` -H 'Content-Type: application/json' \\\n`;
297
+ script += ` --data '${JSON.stringify(seed.config).replace(/'/g, `'\\''`)}'\n`;
298
+ script += 'echo\n\n';
299
+ }
300
+
301
+ return script;
302
+ }
303
+
304
+ /**
305
+ * Split a pipe-separated env value into trimmed parts
306
+ * @private
307
+ */
308
+ #splitPipe(value) {
309
+ return (value || '')
310
+ .split('|')
311
+ .map((part) => part.trim())
312
+ .filter(Boolean);
313
+ }
314
+ }
315
+
316
+ export default new AgentInitCommand();
@@ -0,0 +1,164 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ import { DatastageApiService } from '../services/DatastageApiService.js';
5
+ import logger from '../services/LoggingService.js';
6
+
7
+ import appConfig from '../config/config.js';
8
+ import ErrorHandler from '../errors/ErrorHandler.js';
9
+
10
+ /**
11
+ * Datastage Command Handler
12
+ * Uploads monthly Datastage *.zip files from a directory to the API.
13
+ * Sequential, idempotent via the cli `datastage_uploads` tracking table.
14
+ */
15
+ export class DatastageCommand {
16
+ constructor() {
17
+ this.errorHandler = new ErrorHandler(logger);
18
+ }
19
+
20
+ /**
21
+ * @param {Object} options
22
+ * @param {string} options.dir - directory containing *.zip files (required)
23
+ * @param {string} [options.api] - 'default'|'agencia'|'cliente'
24
+ * @param {boolean} [options.retryFailed] - re-attempt files in 'failed' status
25
+ * @param {boolean} [options.showStats] - print final stats from API
26
+ */
27
+ async execute(options = {}) {
28
+ const startTime = Date.now();
29
+
30
+ if (!options.dir) {
31
+ throw new Error('--dir <path> is required');
32
+ }
33
+ const sourceDirectory = path.resolve(options.dir);
34
+ if (!fs.existsSync(sourceDirectory)) {
35
+ throw new Error(`Directory not found: ${sourceDirectory}`);
36
+ }
37
+ const dirStat = fs.statSync(sourceDirectory);
38
+ if (!dirStat.isDirectory()) {
39
+ throw new Error(`Not a directory: ${sourceDirectory}`);
40
+ }
41
+
42
+ const apiTarget = options.api || 'default';
43
+ const api = new DatastageApiService(apiTarget);
44
+
45
+ logger.info('📦 Starting arela datastage command');
46
+ logger.info(`🎯 API Target: ${apiTarget}`);
47
+ logger.info(`📂 Source: ${sourceDirectory}`);
48
+
49
+ // 1. Enumerate *.zip in root directory (non-recursive)
50
+ const entries = fs.readdirSync(sourceDirectory, { withFileTypes: true });
51
+ const zipFiles = entries
52
+ .filter((e) => e.isFile() && /\.zip$/i.test(e.name))
53
+ .map((e) => path.join(sourceDirectory, e.name));
54
+
55
+ if (zipFiles.length === 0) {
56
+ logger.warn('No *.zip files found in directory. Nothing to do.');
57
+ return { uploaded: 0, failed: 0, skipped: 0 };
58
+ }
59
+ logger.info(`🗂 Found ${zipFiles.length} zip file(s)`);
60
+
61
+ // 2. Register each file (idempotent upsert)
62
+ logger.info('📝 Registering files...');
63
+ for (const zipPath of zipFiles) {
64
+ const stats = fs.statSync(zipPath);
65
+ try {
66
+ await api.registerUpload({
67
+ absolutePath: zipPath,
68
+ fileName: path.basename(zipPath),
69
+ sizeBytes: stats.size,
70
+ fileModifiedAt: stats.mtime.toISOString(),
71
+ sourceDirectory,
72
+ });
73
+ } catch (err) {
74
+ logger.error(
75
+ ` ✗ register failed for ${path.basename(zipPath)}: ${err.message}`,
76
+ );
77
+ throw err;
78
+ }
79
+ }
80
+
81
+ // 3. Fetch pending list scoped to this directory
82
+ const pending = await api.getPending(sourceDirectory);
83
+ const pendingPaths = new Set(pending.map((p) => p.absolutePath));
84
+
85
+ const alreadyUploaded = zipFiles.length - pendingPaths.size;
86
+ if (alreadyUploaded > 0) {
87
+ logger.info(`⏭ Skipping ${alreadyUploaded} already uploaded file(s)`);
88
+ }
89
+
90
+ if (pending.length === 0) {
91
+ logger.success('✅ All files already uploaded. Nothing to do.');
92
+ if (options.showStats) {
93
+ const s = await api.getStats(sourceDirectory);
94
+ logger.info(`📊 Stats: ${JSON.stringify(s)}`);
95
+ }
96
+ return { uploaded: 0, failed: 0, skipped: alreadyUploaded };
97
+ }
98
+
99
+ // 4. Sequential upload loop
100
+ logger.info(`🚀 Uploading ${pending.length} file(s) sequentially...`);
101
+ let uploaded = 0;
102
+ let failed = 0;
103
+
104
+ for (let i = 0; i < pending.length; i++) {
105
+ const row = pending[i];
106
+ const localPath = row.absolutePath;
107
+ const label = `[${i + 1}/${pending.length}] ${row.fileName}`;
108
+
109
+ if (!fs.existsSync(localPath)) {
110
+ const err = `File missing on disk: ${localPath}`;
111
+ logger.error(`✗ ${label}: ${err}`);
112
+ try {
113
+ await api.markFailed(row.id, err);
114
+ } catch (e) {
115
+ logger.error(` mark-failed error: ${e.message}`);
116
+ }
117
+ failed++;
118
+ continue;
119
+ }
120
+
121
+ try {
122
+ logger.info(`⬆ ${label}: uploading...`);
123
+ const result = await api.uploadZip(localPath);
124
+ const datastageId = result?.id || result?.data?.id;
125
+ const folio = result?.folio || result?.data?.folio;
126
+ if (!datastageId) {
127
+ throw new Error(
128
+ 'API returned no datastage id in response: ' +
129
+ JSON.stringify(result).slice(0, 300),
130
+ );
131
+ }
132
+ await api.markUploaded(row.id, { datastageId, folio });
133
+ logger.success(
134
+ `✓ ${label}: folio=${folio || 'n/a'} datastageId=${datastageId}`,
135
+ );
136
+ uploaded++;
137
+ } catch (err) {
138
+ logger.error(`✗ ${label}: ${err.message}`);
139
+ try {
140
+ await api.markFailed(row.id, err.message);
141
+ } catch (e) {
142
+ logger.error(` mark-failed error: ${e.message}`);
143
+ }
144
+ failed++;
145
+ }
146
+ }
147
+
148
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
149
+ logger.info('—'.repeat(60));
150
+ logger.info(
151
+ `Done in ${elapsed}s — uploaded=${uploaded} failed=${failed} skipped=${alreadyUploaded}`,
152
+ );
153
+
154
+ if (options.showStats) {
155
+ const s = await api.getStats(sourceDirectory);
156
+ logger.info(`📊 Final stats: ${JSON.stringify(s)}`);
157
+ }
158
+
159
+ return { uploaded, failed, skipped: alreadyUploaded };
160
+ }
161
+ }
162
+
163
+ const datastageCommand = new DatastageCommand();
164
+ export default datastageCommand;