adfinem 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/CODE_OF_CONDUCT.md +21 -0
  3. package/CONTRIBUTING.md +29 -0
  4. package/LICENSE +21 -0
  5. package/README.md +86 -2
  6. package/SECURITY.md +13 -0
  7. package/catalogs/.gitkeep +0 -0
  8. package/catalogs/api-operations.yaml +21 -0
  9. package/catalogs/batches.yaml +74 -0
  10. package/catalogs/queries.yaml +75 -0
  11. package/config/environments.yaml +13 -0
  12. package/dist/actions/assert-db.js +3 -0
  13. package/dist/actions/run-eod.js +3 -0
  14. package/dist/adapters/api/api-collections.js +296 -0
  15. package/dist/adapters/api/body-utils.js +9 -0
  16. package/dist/adapters/api/rest-client.js +557 -0
  17. package/dist/adapters/api/soap-client.js +5 -0
  18. package/dist/adapters/db/assertions.js +87 -0
  19. package/dist/adapters/db/oracle-client.js +115 -0
  20. package/dist/adapters/db/query-catalog.js +75 -0
  21. package/dist/adapters/unix/batch-catalog.js +71 -0
  22. package/dist/adapters/unix/batch-input-files.js +36 -0
  23. package/dist/adapters/unix/batch-runner.js +382 -0
  24. package/dist/adapters/unix/ssh-client.js +228 -0
  25. package/dist/app/server.js +826 -0
  26. package/dist/cli.js +465 -0
  27. package/dist/config/environments.js +138 -0
  28. package/dist/config/registry.js +18 -0
  29. package/dist/config/secrets.js +123 -0
  30. package/dist/dsl/parser.js +20 -0
  31. package/dist/dsl/schema.js +182 -0
  32. package/dist/dsl/types.js +1 -0
  33. package/dist/dsl/validator.js +264 -0
  34. package/dist/engine/captures.js +68 -0
  35. package/dist/engine/context.js +69 -0
  36. package/dist/engine/evidence.js +33 -0
  37. package/dist/engine/known-errors.js +129 -0
  38. package/dist/engine/retry.js +13 -0
  39. package/dist/engine/runner.js +710 -0
  40. package/dist/engine/step-result.js +58 -0
  41. package/dist/flows/catalog-normalizer.js +72 -0
  42. package/dist/flows/compiler.js +237 -0
  43. package/dist/flows/concat.js +130 -0
  44. package/dist/flows/parser.js +21 -0
  45. package/dist/flows/schema.js +142 -0
  46. package/dist/flows/types.js +1 -0
  47. package/dist/flows/validator.js +470 -0
  48. package/dist/reports/html-report.js +112 -0
  49. package/dist/reports/junit-report.js +48 -0
  50. package/docs/.gitkeep +0 -0
  51. package/docs/DB_UNIX_OPERATIONS.md +118 -0
  52. package/docs/FLOW_BUILDER.md +87 -0
  53. package/flows/account_processing_cycle.flow.yaml +88 -0
  54. package/flows/new_flow.flow.yaml +22 -0
  55. package/package.json +92 -7
  56. package/scenarios/smoke/account-processing-smoke.yaml +44 -0
  57. package/scenarios/smoke/api-db-batch-check.yaml +40 -0
  58. package/src/actions/assert-db.ts +6 -0
  59. package/src/actions/run-eod.ts +6 -0
  60. package/src/adapters/api/api-collections.ts +375 -0
  61. package/src/adapters/api/body-utils.ts +10 -0
  62. package/src/adapters/api/rest-client.ts +587 -0
  63. package/src/adapters/api/soap-client.ts +7 -0
  64. package/src/adapters/db/assertions.ts +83 -0
  65. package/src/adapters/db/oracle-client.ts +133 -0
  66. package/src/adapters/db/query-catalog.ts +80 -0
  67. package/src/adapters/unix/batch-catalog.ts +81 -0
  68. package/src/adapters/unix/batch-input-files.ts +39 -0
  69. package/src/adapters/unix/batch-runner.ts +456 -0
  70. package/src/adapters/unix/ssh-client.ts +248 -0
  71. package/src/app/server.ts +913 -0
  72. package/src/cli.ts +466 -0
  73. package/src/config/environments.ts +193 -0
  74. package/src/config/registry.ts +23 -0
  75. package/src/config/secrets.ts +128 -0
  76. package/src/dsl/parser.ts +24 -0
  77. package/src/dsl/schema.ts +189 -0
  78. package/src/dsl/types.ts +371 -0
  79. package/src/dsl/validator.ts +282 -0
  80. package/src/engine/captures.ts +66 -0
  81. package/src/engine/context.ts +76 -0
  82. package/src/engine/evidence.ts +35 -0
  83. package/src/engine/known-errors.ts +145 -0
  84. package/src/engine/retry.ts +11 -0
  85. package/src/engine/runner.ts +746 -0
  86. package/src/engine/step-result.ts +64 -0
  87. package/src/flows/catalog-normalizer.ts +86 -0
  88. package/src/flows/compiler.ts +247 -0
  89. package/src/flows/concat.ts +149 -0
  90. package/src/flows/parser.ts +27 -0
  91. package/src/flows/schema.ts +154 -0
  92. package/src/flows/types.ts +130 -0
  93. package/src/flows/validator.ts +468 -0
  94. package/src/llm/system-prompt.md +9 -0
  95. package/src/reports/html-report.ts +113 -0
  96. package/src/reports/junit-report.ts +55 -0
  97. package/src/types/oracledb.d.ts +1 -0
  98. package/templates/.gitkeep +0 -0
  99. package/templates/api/create-test-case.json +5 -0
  100. package/templates/api/record-test-activity.json +6 -0
  101. package/tsconfig.json +15 -0
  102. package/vite.config.ts +17 -0
  103. package/web/index.html +12 -0
  104. package/web/src/App.tsx +6588 -0
  105. package/web/src/main.tsx +10 -0
  106. package/web/src/styles.css +3147 -0
  107. package/index.js +0 -1
@@ -0,0 +1,456 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import type { BatchCatalogEntry, BatchFileDownloadEvidence, BatchFileUploadEvidence, BatchInputFileSpec, BatchInputFileValue, BatchOutputFileSpec } from "../../dsl/types.js";
4
+ import { buildBatchCommandDetails } from "./batch-catalog.js";
5
+ import { hasBatchInputFilePayload, isBatchInputFileValue } from "./batch-input-files.js";
6
+ import { SshClient } from "./ssh-client.js";
7
+ import { cancellationError, isCancellationError } from "../../engine/step-result.js";
8
+
9
+ export interface BatchAttemptResult {
10
+ attempt: number;
11
+ startedAt: string;
12
+ endedAt: string;
13
+ command: string;
14
+ displayCommand: string;
15
+ stdout: string;
16
+ stderr: string;
17
+ exitCode?: number;
18
+ tracePath?: string;
19
+ errno?: string;
20
+ stdoutTruncated?: boolean;
21
+ stderrTruncated?: boolean;
22
+ status: "passed" | "failed";
23
+ error?: string;
24
+ }
25
+
26
+ export interface BatchRunOptions {
27
+ attempts?: number;
28
+ delayMs?: number;
29
+ signal?: AbortSignal;
30
+ downloadDir?: string;
31
+ }
32
+
33
+ export interface BatchRunResult {
34
+ command: string;
35
+ displayCommand: string;
36
+ status: "passed" | "failed";
37
+ fileUploads?: BatchFileUploadEvidence[];
38
+ fileDownloads?: BatchFileDownloadEvidence[];
39
+ attempts: BatchAttemptResult[];
40
+ stdout: string;
41
+ stderr: string;
42
+ exitCode?: number;
43
+ tracePath?: string;
44
+ errno?: string;
45
+ stdoutTruncated?: boolean;
46
+ stderrTruncated?: boolean;
47
+ }
48
+
49
+ export class BatchRunner {
50
+ constructor(private readonly ssh: SshClient, private readonly rootDir = process.cwd()) {}
51
+
52
+ async run(entry: BatchCatalogEntry, params: Record<string, unknown>, options: BatchRunOptions = {}): Promise<BatchRunResult> {
53
+ const uploadPlan = await this.uploadInputFiles(entry, params, options);
54
+ const { command, displayCommand } = buildBatchCommandDetails(entry, uploadPlan.params, uploadPlan.appendedArgs);
55
+ const maxAttempts = Math.max(1, options.attempts ?? 1);
56
+ const delayMs = Math.max(0, options.delayMs ?? 0);
57
+ const attempts: BatchAttemptResult[] = [];
58
+
59
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
60
+ if (options.signal?.aborted) throw cancellationError();
61
+ const startedAt = new Date().toISOString();
62
+ try {
63
+ const result = await this.ssh.execute(entry.hostRef, command, (entry.timeoutSeconds ?? 3600) * 1000, options.signal);
64
+ const endedAt = new Date().toISOString();
65
+ const status = batchSucceeded(entry, result) ? "passed" : "failed";
66
+ const diagnostics = extractBatchDiagnostics(result.stdout, result.stderr);
67
+ const attemptResult: BatchAttemptResult = {
68
+ attempt,
69
+ startedAt,
70
+ endedAt,
71
+ command,
72
+ displayCommand,
73
+ stdout: result.stdout,
74
+ stderr: result.stderr,
75
+ exitCode: result.exitCode,
76
+ tracePath: diagnostics.tracePath,
77
+ errno: diagnostics.errno,
78
+ stdoutTruncated: result.stdoutTruncated,
79
+ stderrTruncated: result.stderrTruncated,
80
+ status,
81
+ error: status === "failed" ? batchFailureMessage(entry, result) : undefined
82
+ };
83
+ attempts.push(attemptResult);
84
+ if (status === "passed") break;
85
+ } catch (error) {
86
+ if (isCancellationError(error)) throw error;
87
+ const err = error instanceof Error ? error : new Error(String(error));
88
+ attempts.push({
89
+ attempt,
90
+ startedAt,
91
+ endedAt: new Date().toISOString(),
92
+ command,
93
+ displayCommand,
94
+ stdout: "",
95
+ stderr: "",
96
+ status: "failed",
97
+ error: err.message
98
+ });
99
+ }
100
+
101
+ if (attempt < maxAttempts && delayMs > 0) await sleep(delayMs, options.signal);
102
+ }
103
+
104
+ const summary = summarizeBatch(command, displayCommand, attempts, uploadPlan.fileUploads);
105
+ return await this.withOutputFiles(entry, uploadPlan.params, summary, options);
106
+ }
107
+
108
+ private async uploadInputFiles(
109
+ entry: BatchCatalogEntry,
110
+ params: Record<string, unknown>,
111
+ options: BatchRunOptions
112
+ ): Promise<{ params: Record<string, unknown>; appendedArgs: string[]; fileUploads: BatchFileUploadEvidence[] | undefined }> {
113
+ const inputFiles = entry.inputFiles ?? [];
114
+ if (inputFiles.length === 0) return { params, appendedArgs: [], fileUploads: undefined };
115
+
116
+ const commandParams = { ...params };
117
+ const appendedArgs: string[] = [];
118
+ const fileUploads: BatchFileUploadEvidence[] = [];
119
+ const timeoutMs = Math.max(30_000, (entry.timeoutSeconds ?? 3600) * 1000);
120
+
121
+ for (const spec of inputFiles) {
122
+ const value = params[spec.name];
123
+ if (!hasBatchInputFilePayload(value)) {
124
+ if (spec.required !== false) throw new Error(`Batch input file '${spec.name}' is required.`);
125
+ continue;
126
+ }
127
+
128
+ const file = normalizeInputFileValue(value);
129
+ const fileName = file.fileName || (file.localPath ? basename(file.localPath) : spec.name);
130
+ const remotePath = resolveRemotePath(spec, file, commandParams, fileName);
131
+ const content = await readInputFileContent(this.rootDir, file);
132
+ const upload: BatchFileUploadEvidence = {
133
+ name: spec.name,
134
+ fileName,
135
+ localPath: file.localPath,
136
+ remotePath,
137
+ sizeBytes: content.byteLength,
138
+ paramName: spec.paramName || spec.name,
139
+ appendedAsArg: spec.appendAsArg === true || undefined,
140
+ status: "uploaded"
141
+ };
142
+
143
+ try {
144
+ await this.ssh.uploadFile(entry.hostRef, remotePath, content, timeoutMs, options.signal);
145
+ } catch (error) {
146
+ const err = error instanceof Error ? error : new Error(String(error));
147
+ fileUploads.push({ ...upload, status: "failed", error: err.message });
148
+ throw new Error(`SFTP upload failed for batch input file '${spec.name}' to '${remotePath}': ${err.message}`);
149
+ }
150
+
151
+ fileUploads.push(upload);
152
+ commandParams[spec.paramName || spec.name] = remotePath;
153
+ if (spec.appendAsArg) appendedArgs.push(remotePath);
154
+ }
155
+
156
+ return { params: commandParams, appendedArgs, fileUploads };
157
+ }
158
+
159
+ private async withOutputFiles(
160
+ entry: BatchCatalogEntry,
161
+ params: Record<string, unknown>,
162
+ summary: BatchRunResult,
163
+ options: BatchRunOptions
164
+ ): Promise<BatchRunResult> {
165
+ const outputFiles = entry.outputFiles ?? [];
166
+ if (outputFiles.length === 0) return summary;
167
+
168
+ const timeoutMs = Math.max(30_000, (entry.timeoutSeconds ?? 3600) * 1000);
169
+ const last = summary.attempts[summary.attempts.length - 1];
170
+ const fileDownloads: BatchFileDownloadEvidence[] = [];
171
+
172
+ for (const spec of outputFiles) {
173
+ fileDownloads.push(await this.retrieveOutputFile(entry, spec, params, last, timeoutMs, options));
174
+ }
175
+
176
+ const requiredFailure = fileDownloads.find((file) => file.status === "failed" && fileRequired(file.name, outputFiles));
177
+ if (requiredFailure && last) {
178
+ last.status = "failed";
179
+ last.error = requiredFailure.error ?? `Batch output file '${requiredFailure.name}' was not retrieved.`;
180
+ summary.status = "failed";
181
+ }
182
+
183
+ return { ...summary, fileDownloads };
184
+ }
185
+
186
+ private async retrieveOutputFile(
187
+ entry: BatchCatalogEntry,
188
+ spec: BatchOutputFileSpec,
189
+ params: Record<string, unknown>,
190
+ last: BatchAttemptResult | undefined,
191
+ timeoutMs: number,
192
+ options: BatchRunOptions
193
+ ): Promise<BatchFileDownloadEvidence> {
194
+ const source = spec.source ?? (spec.remotePath ? "explicit" : "stderr");
195
+ const baseEvidence: BatchFileDownloadEvidence = { name: spec.name, source, status: "skipped" };
196
+ try {
197
+ const remotePath = resolveOutputRemotePath(spec, params, last);
198
+ if (!remotePath) {
199
+ if (spec.required === false) return { ...baseEvidence, status: "skipped", error: "No generated file path was found." };
200
+ return { ...baseEvidence, status: "failed", error: `Batch output file '${spec.name}' path was not found in ${source}.` };
201
+ }
202
+
203
+ const templateVars = outputTemplateVars(spec, remotePath, params);
204
+ const decryptCommandTemplate = spec.decrypt?.command?.trim();
205
+ let decryptCommand: string | undefined;
206
+ let downloadRemotePath = remotePath;
207
+ let decryptExitCode: number | undefined;
208
+ let decryptStdout: string | undefined;
209
+ let decryptStderr: string | undefined;
210
+ let decryptedRemotePath: string | undefined;
211
+
212
+ if (decryptCommandTemplate) {
213
+ decryptedRemotePath = resolveTemplate(spec.decrypt?.outputRemotePath || "${remotePath}.dec", {
214
+ ...templateVars,
215
+ decryptedRemotePath: `${remotePath}.dec`
216
+ });
217
+ decryptCommand = resolveTemplate(decryptCommandTemplate, {
218
+ ...templateVars,
219
+ decryptedRemotePath
220
+ });
221
+ const decrypt = await this.ssh.execute(entry.hostRef, decryptCommand, timeoutMs, options.signal);
222
+ decryptExitCode = decrypt.exitCode;
223
+ decryptStdout = decrypt.stdout;
224
+ decryptStderr = decrypt.stderr;
225
+ if (decrypt.exitCode !== 0 && spec.decrypt?.required !== false) {
226
+ return {
227
+ ...baseEvidence,
228
+ remotePath,
229
+ decryptCommand,
230
+ decryptedRemotePath,
231
+ decryptExitCode,
232
+ decryptStdout,
233
+ decryptStderr,
234
+ status: "failed",
235
+ error: `Decrypt command failed with exit code ${decrypt.exitCode}.`
236
+ };
237
+ }
238
+ if (decrypt.exitCode === 0) downloadRemotePath = decryptedRemotePath;
239
+ }
240
+
241
+ if (spec.download === false) {
242
+ return {
243
+ ...baseEvidence,
244
+ remotePath,
245
+ decryptCommand,
246
+ decryptedRemotePath,
247
+ decryptExitCode,
248
+ decryptStdout,
249
+ decryptStderr,
250
+ status: "skipped"
251
+ };
252
+ }
253
+
254
+ if (!options.downloadDir) {
255
+ return { ...baseEvidence, remotePath: downloadRemotePath, status: "failed", error: "No local evidence download directory was configured." };
256
+ }
257
+
258
+ const content = await this.ssh.downloadFile(entry.hostRef, downloadRemotePath, timeoutMs, options.signal);
259
+ const localPath = await writeDownloadedFile(options.downloadDir, spec.name, downloadRemotePath, content);
260
+ return {
261
+ ...baseEvidence,
262
+ remotePath,
263
+ localPath,
264
+ sizeBytes: content.byteLength,
265
+ decryptCommand,
266
+ decryptedRemotePath,
267
+ decryptExitCode,
268
+ decryptStdout,
269
+ decryptStderr,
270
+ status: "downloaded"
271
+ };
272
+ } catch (error) {
273
+ const err = error instanceof Error ? error : new Error(String(error));
274
+ return { ...baseEvidence, status: "failed", error: err.message };
275
+ }
276
+ }
277
+ }
278
+
279
+ function batchSucceeded(entry: BatchCatalogEntry, result: { stdout: string; stderr: string; exitCode: number }): boolean {
280
+ const allowedExitCodes = entry.success?.exitCodes ?? [0];
281
+ if (!allowedExitCodes.includes(result.exitCode)) return false;
282
+ for (const required of entry.success?.requiredOutput ?? []) {
283
+ if (!result.stdout.includes(required) && !result.stderr.includes(required)) {
284
+ return false;
285
+ }
286
+ }
287
+ return true;
288
+ }
289
+
290
+ function batchFailureMessage(entry: BatchCatalogEntry, result: { stdout: string; stderr: string; exitCode: number }): string {
291
+ const allowedExitCodes = entry.success?.exitCodes ?? [0];
292
+ if (!allowedExitCodes.includes(result.exitCode)) {
293
+ const output = summarizeCommandOutput(result.stderr || result.stdout);
294
+ return output
295
+ ? `Batch failed with exit code ${result.exitCode}: ${output}`
296
+ : `Batch failed with exit code ${result.exitCode}.`;
297
+ }
298
+ for (const required of entry.success?.requiredOutput ?? []) {
299
+ if (!result.stdout.includes(required) && !result.stderr.includes(required)) {
300
+ return `Batch output did not contain required text '${required}'.`;
301
+ }
302
+ }
303
+ return "Batch did not satisfy success criteria.";
304
+ }
305
+
306
+ function summarizeCommandOutput(value: string): string {
307
+ const normalized = value.replace(/\s+/g, " ").trim();
308
+ if (normalized.length <= 300) return normalized;
309
+ return `${normalized.slice(0, 297)}...`;
310
+ }
311
+
312
+ function summarizeBatch(command: string, displayCommand: string, attempts: BatchAttemptResult[], fileUploads?: BatchFileUploadEvidence[]): BatchRunResult {
313
+ const last = attempts[attempts.length - 1];
314
+ return {
315
+ command,
316
+ displayCommand,
317
+ status: last?.status ?? "failed",
318
+ fileUploads,
319
+ attempts,
320
+ stdout: last?.stdout ?? "",
321
+ stderr: last?.stderr ?? "",
322
+ exitCode: last?.exitCode,
323
+ tracePath: last?.tracePath,
324
+ errno: last?.errno,
325
+ stdoutTruncated: last?.stdoutTruncated,
326
+ stderrTruncated: last?.stderrTruncated
327
+ };
328
+ }
329
+
330
+ function fileRequired(name: string, specs: BatchOutputFileSpec[]): boolean {
331
+ return specs.find((spec) => spec.name === name)?.required !== false;
332
+ }
333
+
334
+ function normalizeInputFileValue(value: unknown): BatchInputFileValue {
335
+ if (typeof value === "string") return { localPath: value, fileName: basename(value) };
336
+ if (isBatchInputFileValue(value)) return value;
337
+ throw new Error("Batch input file value must be a selected file object.");
338
+ }
339
+
340
+ function resolveRemotePath(
341
+ spec: BatchInputFileSpec,
342
+ value: BatchInputFileValue,
343
+ params: Record<string, unknown>,
344
+ fileName: string
345
+ ): string {
346
+ const template = value.remotePath || spec.remotePath;
347
+ if (!template?.trim()) throw new Error(`Batch input file '${spec.name}' needs a remote path.`);
348
+ const baseName = fileName.includes(".") ? fileName.slice(0, fileName.lastIndexOf(".")) : fileName;
349
+ const extension = fileName.includes(".") ? fileName.slice(fileName.lastIndexOf(".") + 1) : "";
350
+ const rendered = template.replace(/\$\{([A-Za-z0-9_.-]+)\}/g, (_match, name: string) => {
351
+ if (name === "fileName") return fileName;
352
+ if (name === "baseName") return baseName;
353
+ if (name === "extension") return extension;
354
+ if (name === "inputName") return spec.name;
355
+ const value = params[name] ?? process.env[name];
356
+ if (value === undefined || value === null) throw new Error(`Batch input file '${spec.name}' remote path references unknown value '${name}'.`);
357
+ return String(value);
358
+ });
359
+ return /[\\/]$/.test(rendered) ? `${rendered}${fileName}` : rendered;
360
+ }
361
+
362
+ function resolveOutputRemotePath(
363
+ spec: BatchOutputFileSpec,
364
+ params: Record<string, unknown>,
365
+ last: BatchAttemptResult | undefined
366
+ ): string | undefined {
367
+ if (spec.remotePath?.trim()) {
368
+ return resolveTemplate(spec.remotePath, outputTemplateVars(spec, spec.remotePath, params));
369
+ }
370
+ if (!last) return undefined;
371
+ const source = spec.source ?? "stderr";
372
+ const output = source === "stdout"
373
+ ? last.stdout
374
+ : source === "both"
375
+ ? `${last.stdout}\n${last.stderr}`
376
+ : last.stderr;
377
+ const pattern = spec.pathPattern?.trim() || "(\\/[^\\s'\"<>]+)";
378
+ const match = new RegExp(pattern, "m").exec(output);
379
+ return (match?.[1] ?? match?.[0])?.trim();
380
+ }
381
+
382
+ function outputTemplateVars(spec: BatchOutputFileSpec, remotePath: string, params: Record<string, unknown>): Record<string, unknown> {
383
+ const fileName = remoteBaseName(remotePath) || `${spec.name}.out`;
384
+ const dot = fileName.lastIndexOf(".");
385
+ return {
386
+ ...params,
387
+ outputName: spec.name,
388
+ remotePath,
389
+ fileName,
390
+ baseName: dot > 0 ? fileName.slice(0, dot) : fileName,
391
+ extension: dot > 0 ? fileName.slice(dot + 1) : ""
392
+ };
393
+ }
394
+
395
+ function resolveTemplate(value: string, params: Record<string, unknown>): string {
396
+ return value.replace(/\$\{([A-Za-z0-9_.-]+)\}/g, (_match, name: string) => {
397
+ const next = params[name] ?? process.env[name];
398
+ if (next === undefined || next === null) throw new Error(`Batch output file template references unknown value '${name}'.`);
399
+ return String(next);
400
+ });
401
+ }
402
+
403
+ async function writeDownloadedFile(downloadDir: string, outputName: string, remotePath: string, content: Buffer): Promise<string> {
404
+ const safeOutputName = sanitizePathPart(outputName || "output");
405
+ const safeFileName = sanitizePathPart(remoteBaseName(remotePath) || `${safeOutputName}.out`);
406
+ const localPath = join(downloadDir, safeOutputName, safeFileName);
407
+ await mkdir(dirname(localPath), { recursive: true });
408
+ await writeFile(localPath, content);
409
+ return localPath;
410
+ }
411
+
412
+ function remoteBaseName(remotePath: string): string {
413
+ return remotePath.split(/[\\/]/).filter(Boolean).pop() ?? "";
414
+ }
415
+
416
+ function sanitizePathPart(value: string): string {
417
+ return value.replace(/[^A-Za-z0-9_.-]/g, "_") || "file";
418
+ }
419
+
420
+ async function readInputFileContent(rootDir: string, value: BatchInputFileValue): Promise<Buffer> {
421
+ if (value.contentBase64) {
422
+ const base64 = value.contentBase64.includes(",") ? value.contentBase64.slice(value.contentBase64.indexOf(",") + 1) : value.contentBase64;
423
+ return Buffer.from(base64, "base64");
424
+ }
425
+ if (!value.localPath) throw new Error("Batch input file has no local file path.");
426
+ const path = isAbsolute(value.localPath) ? resolve(value.localPath) : resolve(rootDir, value.localPath);
427
+ const uploadsRoot = resolve(rootDir, "data", "batch-input-files");
428
+ const rel = relative(uploadsRoot, path);
429
+ if (rel.startsWith("..") || isAbsolute(rel)) {
430
+ throw new Error("Batch input file path must be under data/batch-input-files.");
431
+ }
432
+ return await readFile(path);
433
+ }
434
+
435
+ function extractBatchDiagnostics(stdout: string, stderr: string): { tracePath?: string; errno?: string } {
436
+ const output = `${stdout}\n${stderr}`;
437
+ const tracePath = output.match(/FICHIER\s*:\s*(.+)/i)?.[1]?.trim();
438
+ const errno = output.match(/ERRNO\s*:\s*([^\s]+)/i)?.[1]?.trim();
439
+ return { tracePath, errno };
440
+ }
441
+
442
+ async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
443
+ if (signal?.aborted) throw cancellationError();
444
+ await new Promise<void>((resolve, reject) => {
445
+ const timer = setTimeout(done, ms);
446
+ const abort = () => {
447
+ clearTimeout(timer);
448
+ reject(cancellationError());
449
+ };
450
+ function done() {
451
+ signal?.removeEventListener("abort", abort);
452
+ resolve();
453
+ }
454
+ signal?.addEventListener("abort", abort, { once: true });
455
+ });
456
+ }
@@ -0,0 +1,248 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { posix as posixPath } from "node:path";
3
+ import { Client, type SFTPWrapper } from "ssh2";
4
+ import type { EnvironmentConfig } from "../../config/environments.js";
5
+ import { cancellationError } from "../../engine/step-result.js";
6
+
7
+ export interface SshExecutionResult {
8
+ stdout: string;
9
+ stderr: string;
10
+ exitCode: number;
11
+ stdoutTruncated?: boolean;
12
+ stderrTruncated?: boolean;
13
+ }
14
+
15
+ const MAX_CAPTURE_BYTES = 5 * 1024 * 1024;
16
+
17
+ export class SshClient {
18
+ constructor(private readonly env: EnvironmentConfig) {}
19
+
20
+ async execute(hostRef: string, command: string, timeoutMs: number, signal?: AbortSignal): Promise<SshExecutionResult> {
21
+ const host = this.hostConfig(hostRef);
22
+ const privateKey = await this.privateKey(host);
23
+ if (signal?.aborted) throw cancellationError();
24
+
25
+ const execCommand = buildSshExecCommand(host, command);
26
+
27
+ return await new Promise<SshExecutionResult>((resolve, reject) => {
28
+ const client = new Client();
29
+ let timer: NodeJS.Timeout | undefined;
30
+ let settled = false;
31
+ const abort = () => finish(() => reject(cancellationError()));
32
+
33
+ const finish = (fn: () => void) => {
34
+ if (settled) return;
35
+ settled = true;
36
+ if (timer) clearTimeout(timer);
37
+ signal?.removeEventListener("abort", abort);
38
+ client.end();
39
+ fn();
40
+ };
41
+
42
+ signal?.addEventListener("abort", abort, { once: true });
43
+
44
+ client.on("ready", () => {
45
+ timer = setTimeout(() => {
46
+ finish(() => reject(new Error(`SSH command timed out after ${timeoutMs}ms.`)));
47
+ }, timeoutMs);
48
+
49
+ client.exec(execCommand, (err, stream) => {
50
+ if (err) {
51
+ finish(() => reject(err));
52
+ return;
53
+ }
54
+
55
+ let stdout = "";
56
+ let stderr = "";
57
+ let stdoutTruncated = false;
58
+ let stderrTruncated = false;
59
+ let exitCode = 0;
60
+ stream.on("close", (code: number | null) => {
61
+ exitCode = code ?? 0;
62
+ finish(() => resolve({ stdout, stderr, exitCode, stdoutTruncated, stderrTruncated }));
63
+ });
64
+ stream.on("data", (data: Buffer) => {
65
+ const result = appendLimited(stdout, data);
66
+ stdout = result.value;
67
+ stdoutTruncated = stdoutTruncated || result.truncated;
68
+ });
69
+ stream.stderr.on("data", (data: Buffer) => {
70
+ const result = appendLimited(stderr, data);
71
+ stderr = result.value;
72
+ stderrTruncated = stderrTruncated || result.truncated;
73
+ });
74
+ });
75
+ });
76
+
77
+ client.on("error", (error) => finish(() => reject(error)));
78
+ client.connect({
79
+ host: host.host,
80
+ username: host.username,
81
+ password: host.password,
82
+ privateKey,
83
+ readyTimeout: Math.min(timeoutMs, 30_000)
84
+ });
85
+ });
86
+ }
87
+
88
+ async uploadFile(hostRef: string, remotePath: string, content: Buffer, timeoutMs: number, signal?: AbortSignal): Promise<void> {
89
+ const host = this.hostConfig(hostRef);
90
+ const privateKey = await this.privateKey(host);
91
+ if (signal?.aborted) throw cancellationError();
92
+
93
+ await new Promise<void>((resolve, reject) => {
94
+ const client = new Client();
95
+ let timer: NodeJS.Timeout | undefined;
96
+ let settled = false;
97
+ const abort = () => finish(() => reject(cancellationError()));
98
+
99
+ const finish = (fn: () => void) => {
100
+ if (settled) return;
101
+ settled = true;
102
+ if (timer) clearTimeout(timer);
103
+ signal?.removeEventListener("abort", abort);
104
+ client.end();
105
+ fn();
106
+ };
107
+
108
+ signal?.addEventListener("abort", abort, { once: true });
109
+
110
+ client.on("ready", () => {
111
+ timer = setTimeout(() => {
112
+ finish(() => reject(new Error(`SFTP upload timed out after ${timeoutMs}ms.`)));
113
+ }, timeoutMs);
114
+
115
+ client.sftp((err, sftp) => {
116
+ if (err) {
117
+ finish(() => reject(err));
118
+ return;
119
+ }
120
+ void mkdirpSftp(sftp, posixPath.dirname(remotePath))
121
+ .then(() => new Promise<void>((writeResolve, writeReject) => {
122
+ sftp.writeFile(remotePath, content, (writeError) => {
123
+ if (writeError) writeReject(writeError);
124
+ else writeResolve();
125
+ });
126
+ }))
127
+ .then(() => finish(resolve))
128
+ .catch((uploadError) => finish(() => reject(uploadError)));
129
+ });
130
+ });
131
+
132
+ client.on("error", (error) => finish(() => reject(error)));
133
+ client.connect({
134
+ host: host.host,
135
+ username: host.username,
136
+ password: host.password,
137
+ privateKey,
138
+ readyTimeout: Math.min(timeoutMs, 30_000)
139
+ });
140
+ });
141
+ }
142
+
143
+ async downloadFile(hostRef: string, remotePath: string, timeoutMs: number, signal?: AbortSignal): Promise<Buffer> {
144
+ const host = this.hostConfig(hostRef);
145
+ const privateKey = await this.privateKey(host);
146
+ if (signal?.aborted) throw cancellationError();
147
+
148
+ return await new Promise<Buffer>((resolve, reject) => {
149
+ const client = new Client();
150
+ let timer: NodeJS.Timeout | undefined;
151
+ let settled = false;
152
+ const abort = () => finish(() => reject(cancellationError()));
153
+
154
+ const finish = (fn: () => void) => {
155
+ if (settled) return;
156
+ settled = true;
157
+ if (timer) clearTimeout(timer);
158
+ signal?.removeEventListener("abort", abort);
159
+ client.end();
160
+ fn();
161
+ };
162
+
163
+ signal?.addEventListener("abort", abort, { once: true });
164
+
165
+ client.on("ready", () => {
166
+ timer = setTimeout(() => {
167
+ finish(() => reject(new Error(`SFTP download timed out after ${timeoutMs}ms.`)));
168
+ }, timeoutMs);
169
+
170
+ client.sftp((err, sftp) => {
171
+ if (err) {
172
+ finish(() => reject(err));
173
+ return;
174
+ }
175
+ sftp.readFile(remotePath, (readError, content) => {
176
+ if (readError) finish(() => reject(readError));
177
+ else finish(() => resolve(content));
178
+ });
179
+ });
180
+ });
181
+
182
+ client.on("error", (error) => finish(() => reject(error)));
183
+ client.connect({
184
+ host: host.host,
185
+ username: host.username,
186
+ password: host.password,
187
+ privateKey,
188
+ readyTimeout: Math.min(timeoutMs, 30_000)
189
+ });
190
+ });
191
+ }
192
+
193
+ private hostConfig(hostRef: string): NonNullable<EnvironmentConfig["sshHosts"][string]> {
194
+ const host = this.env.sshHosts[hostRef];
195
+ if (!host) throw new Error(`Unknown SSH hostRef '${hostRef}'.`);
196
+ if (!host.host || !host.username) throw new Error(`SSH host '${hostRef}' requires host and username.`);
197
+ return host;
198
+ }
199
+
200
+ private async privateKey(host: NonNullable<EnvironmentConfig["sshHosts"][string]>): Promise<string | undefined> {
201
+ return host.privateKeyPath ? await readFile(host.privateKeyPath, "utf8") : undefined;
202
+ }
203
+ }
204
+
205
+ export function buildSshExecCommand(
206
+ host: { shell?: string; loginShell?: boolean },
207
+ command: string
208
+ ): string {
209
+ if (!host.loginShell) return command;
210
+ const shell = host.shell?.trim() || "bash";
211
+ return `${shellQuote(shell)} -lc ${shellQuote(command)}`;
212
+ }
213
+
214
+ function appendLimited(current: string, chunk: Buffer): { value: string; truncated: boolean } {
215
+ const next = current + chunk.toString("utf8");
216
+ const nextBytes = Buffer.byteLength(next, "utf8");
217
+ if (nextBytes <= MAX_CAPTURE_BYTES) return { value: next, truncated: false };
218
+ const buffer = Buffer.from(next, "utf8");
219
+ const tail = buffer.subarray(buffer.length - MAX_CAPTURE_BYTES);
220
+ return { value: tail.toString("utf8"), truncated: true };
221
+ }
222
+
223
+ async function mkdirpSftp(sftp: SFTPWrapper, directory: string): Promise<void> {
224
+ if (!sftp || !directory || directory === "." || directory === "/") return;
225
+ const absolute = directory.startsWith("/");
226
+ const parts = directory.split("/").filter(Boolean);
227
+ let current = absolute ? "/" : "";
228
+ for (const part of parts) {
229
+ current = current === "/" ? `/${part}` : current ? `${current}/${part}` : part;
230
+ if (await sftpPathExists(sftp, current)) continue;
231
+ await new Promise<void>((resolve, reject) => {
232
+ sftp.mkdir(current, (error) => {
233
+ if (error && (error as NodeJS.ErrnoException).code !== "EEXIST") reject(error);
234
+ else resolve();
235
+ });
236
+ });
237
+ }
238
+ }
239
+
240
+ async function sftpPathExists(sftp: SFTPWrapper, path: string): Promise<boolean> {
241
+ return await new Promise<boolean>((resolve) => {
242
+ sftp.stat(path, (error) => resolve(!error));
243
+ });
244
+ }
245
+
246
+ function shellQuote(value: string): string {
247
+ return `'${value.replace(/'/g, "'\\''")}'`;
248
+ }