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
package/src/cli.ts ADDED
@@ -0,0 +1,466 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { writeFile } from "node:fs/promises";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { ZodError } from "zod";
7
+ import { getEnvironment, listEnvironmentNames } from "./config/environments.js";
8
+ import { loadCatalogs, loadScenario } from "./dsl/parser.js";
9
+ import { validateScenarioReferences } from "./dsl/validator.js";
10
+ import { ensureEvidenceRoot, ScenarioRunner } from "./engine/runner.js";
11
+ import { OracleClient } from "./adapters/db/oracle-client.js";
12
+ import { RestClient } from "./adapters/api/rest-client.js";
13
+ import { SoapClient } from "./adapters/api/soap-client.js";
14
+ import { BatchRunner } from "./adapters/unix/batch-runner.js";
15
+ import { SshClient } from "./adapters/unix/ssh-client.js";
16
+ import { assertDb } from "./actions/assert-db.js";
17
+ import { runBatch } from "./actions/run-eod.js";
18
+ import { extractCaptures, mergeCaptureSpecs } from "./engine/captures.js";
19
+ import { formatKnownError } from "./engine/known-errors.js";
20
+ import { compileFlow } from "./flows/compiler.js";
21
+ import { loadFlow, readFlowSource, writeFlow } from "./flows/parser.js";
22
+ import { validateFlow } from "./flows/validator.js";
23
+ import { concatFlows, type FlowNodePrefixMode } from "./flows/concat.js";
24
+
25
+ const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
26
+ const program = new Command();
27
+
28
+ program
29
+ .name("adfinem")
30
+ .description("Adfinem deterministic API, database, and Unix scenario runner")
31
+ .version("0.1.0");
32
+
33
+ program
34
+ .command("validate")
35
+ .argument("<scenario>", "Scenario YAML path")
36
+ .action(async (scenarioPath: string) => {
37
+ await handleErrors(async () => {
38
+ const { scenario, errors } = await validateScenario(scenarioPath);
39
+ if (errors.length > 0) {
40
+ console.error(`Scenario '${scenario.id}' is invalid:`);
41
+ for (const error of errors) console.error(`- ${error}`);
42
+ process.exitCode = 1;
43
+ return;
44
+ }
45
+ console.log(`Scenario '${scenario.id}' is valid.`);
46
+ });
47
+ });
48
+
49
+ program
50
+ .command("run")
51
+ .argument("<scenario>", "Scenario YAML path")
52
+ .option("--env <env>", "Environment name; defaults to scenario.environment")
53
+ .option("--dry-run", "Validate and record planned execution without external side effects")
54
+ .action(async (scenarioPath: string, options: { env?: string; dryRun?: boolean }) => {
55
+ await handleErrors(async () => {
56
+ const { scenario, catalogs, errors } = await validateScenario(scenarioPath);
57
+ if (errors.length > 0) {
58
+ console.error(`Scenario '${scenario.id}' is invalid:`);
59
+ for (const error of errors) console.error(`- ${error}`);
60
+ process.exitCode = 1;
61
+ return;
62
+ }
63
+ await ensureEvidenceRoot(rootDir);
64
+ const env = getEnvironment(options.env ?? scenario.environment, rootDir);
65
+ const runner = new ScenarioRunner(scenario, catalogs, env, { rootDir, dryRun: options.dryRun });
66
+ const result = await runner.run();
67
+ console.log(`Run ${result.status}: ${result.scenarioId}`);
68
+ console.log(`Evidence: ${result.evidenceDir}`);
69
+ if (result.status === "failed") process.exitCode = 1;
70
+ });
71
+ });
72
+
73
+ program
74
+ .command("validate-flow")
75
+ .argument("<flow>", "Flow YAML path")
76
+ .description("Validate a flow artifact without executing it")
77
+ .action(async (flowPath: string) => {
78
+ await handleErrors(async () => {
79
+ const fullFlowPath = resolve(process.cwd(), flowPath);
80
+ const flow = await loadFlow(fullFlowPath);
81
+ const catalogs = await loadCatalogs(rootDir);
82
+ const validation = await validateFlow(flow, catalogs, rootDir);
83
+ if (validation.warnings.length > 0) {
84
+ console.warn(`Flow '${flow.id}' warnings:`);
85
+ for (const warning of validation.warnings) console.warn(`- ${warning}`);
86
+ }
87
+ if (!validation.ok) {
88
+ console.error(`Flow '${flow.id}' is invalid:`);
89
+ for (const error of validation.errors) console.error(`- ${error}`);
90
+ process.exitCode = 1;
91
+ return;
92
+ }
93
+ console.log(`Flow '${flow.id}' is valid.`);
94
+ });
95
+ });
96
+
97
+ program
98
+ .command("compile-flow")
99
+ .argument("<flow>", "Flow YAML path")
100
+ .description("Compile a flow artifact to the scenario structure used by the runner")
101
+ .option("--env <env>", "Override the flow environment in the compiled scenario")
102
+ .option("--output <file>", "Optional output path for the compiled scenario JSON")
103
+ .action(async (flowPath: string, options: { env?: string; output?: string }) => {
104
+ await handleErrors(async () => {
105
+ const flow = await loadFlow(resolve(process.cwd(), flowPath));
106
+ const catalogs = await loadCatalogs(rootDir);
107
+ const validation = await validateFlow(flow, catalogs, rootDir, options.env ?? flow.environment);
108
+ if (!validation.ok) {
109
+ console.error(`Flow '${flow.id}' is invalid:`);
110
+ for (const error of validation.errors) console.error(`- ${error}`);
111
+ process.exitCode = 1;
112
+ return;
113
+ }
114
+ const compiled = compileFlow(flow, { environment: options.env });
115
+ const payload = {
116
+ flow: { id: flow.id, name: flow.name, version: flow.version },
117
+ stepMap: compiled.stepMap,
118
+ scenario: compiled.scenario
119
+ };
120
+ if (options.output) {
121
+ const outputPath = resolve(process.cwd(), options.output);
122
+ await writeFile(outputPath, JSON.stringify(payload, null, 2), "utf8");
123
+ console.log(`Compiled flow written: ${outputPath}`);
124
+ return;
125
+ }
126
+ console.log(JSON.stringify(payload, null, 2));
127
+ });
128
+ });
129
+
130
+ program
131
+ .command("concat-flows")
132
+ .argument("<output>", "Output flow YAML path")
133
+ .argument("<flows...>", "Two or more input flow YAML paths")
134
+ .description("Generate a new flow by concatenating existing flow artifacts")
135
+ .option("--id <id>", "Generated flow id; defaults to the output file name")
136
+ .option("--name <name>", "Generated flow display name")
137
+ .option("--env <env>", "Generated flow environment; required when input flows use different environments")
138
+ .option("--prefix-node-ids <mode>", "Node id prefix mode: auto, always, or never", "auto")
139
+ .option("--allow-variable-overrides", "Allow later input flows to overwrite conflicting variables")
140
+ .action(async (output: string, flowPaths: string[], options: {
141
+ id?: string;
142
+ name?: string;
143
+ env?: string;
144
+ prefixNodeIds: FlowNodePrefixMode;
145
+ allowVariableOverrides?: boolean;
146
+ }) => {
147
+ await handleErrors(async () => {
148
+ if (flowPaths.length < 2) throw new Error("concat-flows requires at least two input flow files.");
149
+ const prefixMode = parsePrefixMode(options.prefixNodeIds);
150
+ const inputFlows = await Promise.all(flowPaths.map((path) => loadFlow(resolve(process.cwd(), path))));
151
+ const outputPath = resolve(process.cwd(), output);
152
+ const flow = concatFlows(inputFlows, {
153
+ id: options.id ?? flowIdFromOutput(outputPath),
154
+ name: options.name,
155
+ environment: options.env,
156
+ nodePrefixMode: prefixMode,
157
+ allowVariableOverrides: options.allowVariableOverrides
158
+ });
159
+ await writeFlow(outputPath, flow);
160
+
161
+ const catalogs = await loadCatalogs(rootDir);
162
+ const validation = await validateFlow(flow, catalogs, rootDir, options.env ?? flow.environment);
163
+ if (validation.warnings.length > 0) {
164
+ console.warn(`Generated flow '${flow.id}' warnings:`);
165
+ for (const warning of validation.warnings) console.warn(`- ${warning}`);
166
+ }
167
+ if (!validation.ok) {
168
+ console.error(`Generated flow '${flow.id}' was written but is invalid:`);
169
+ for (const error of validation.errors) console.error(`- ${error}`);
170
+ process.exitCode = 1;
171
+ return;
172
+ }
173
+
174
+ console.log(`Concatenated flow written: ${outputPath}`);
175
+ console.log(`Flow '${flow.id}' is valid.`);
176
+ });
177
+ });
178
+
179
+ program
180
+ .command("run-flow")
181
+ .argument("<flow>", "Flow YAML path")
182
+ .description("Compile and execute a flow artifact")
183
+ .option("--env <env>", "Override the flow environment")
184
+ .option("--dry-run", "Validate and record planned execution without external side effects")
185
+ .action(async (flowPath: string, options: { env?: string; dryRun?: boolean }) => {
186
+ await handleErrors(async () => {
187
+ const fullFlowPath = resolve(process.cwd(), flowPath);
188
+ const flow = await loadFlow(fullFlowPath);
189
+ const catalogs = await loadCatalogs(rootDir);
190
+ const validation = await validateFlow(flow, catalogs, rootDir);
191
+ if (validation.warnings.length > 0) {
192
+ console.warn(`Flow '${flow.id}' warnings:`);
193
+ for (const warning of validation.warnings) console.warn(`- ${warning}`);
194
+ }
195
+ if (!validation.ok) {
196
+ console.error(`Flow '${flow.id}' is invalid:`);
197
+ for (const error of validation.errors) console.error(`- ${error}`);
198
+ process.exitCode = 1;
199
+ return;
200
+ }
201
+ const compiled = compileFlow(flow, { environment: options.env });
202
+ await ensureEvidenceRoot(rootDir);
203
+ const env = getEnvironment(options.env ?? compiled.scenario.environment, rootDir);
204
+ const runner = new ScenarioRunner(compiled.scenario, catalogs, env, { rootDir, dryRun: options.dryRun });
205
+ const result = await runner.run();
206
+ await writeFile(join(result.evidenceDir, "flow.yaml"), await readFlowSource(fullFlowPath), "utf8");
207
+ await writeFile(join(result.evidenceDir, "compiled-flow.json"), JSON.stringify({
208
+ flow: compiled.flow,
209
+ stepMap: compiled.stepMap,
210
+ scenario: compiled.scenario
211
+ }, null, 2), "utf8");
212
+ console.log(`Flow run ${result.status}: ${flow.id}`);
213
+ console.log(`Evidence: ${result.evidenceDir}`);
214
+ if (result.status === "failed") process.exitCode = 1;
215
+ });
216
+ });
217
+
218
+ program
219
+ .command("compile")
220
+ .argument("<text>", "Business scenario text")
221
+ .option("--env <env>", "Environment name", "local")
222
+ .action((text: string, options: { env: string }) => {
223
+ console.log("# LLM compiler placeholder");
224
+ console.log("# Deterministic API/DB/Unix runner is implemented first; wire an LLM provider after the Action Library is stable.");
225
+ console.log(`environment: ${options.env}`);
226
+ console.log(`description: ${JSON.stringify(text)}`);
227
+ });
228
+
229
+ program
230
+ .command("api-call")
231
+ .argument("<operation>", "API operation key from the Action Library")
232
+ .description("Run an allowlisted API operation and print response/captures")
233
+ .option("--env <env>", "Environment name; defaults to ADFINEM_ENV or local")
234
+ .option("--params <json>", "JSON object with API parameters", "{}")
235
+ .option("--param <name=value>", "API parameter; repeat to pass multiple values", collectOption, [])
236
+ .option("--capture <name=expr>", "Additional capture expression; repeatable", collectOption, [])
237
+ .action(async (operationName: string, options: { env?: string; params: string; param: string[]; capture: string[] }) => {
238
+ await handleErrors(async () => {
239
+ const env = getEnvironment(options.env, rootDir);
240
+ const catalogs = await loadCatalogs(rootDir);
241
+ const operation = catalogs.apiOperations[operationName];
242
+ if (!operation) throw new Error(`Unknown API operation '${operationName}'.`);
243
+ const params = parseParamsOptions(options.params, options.param);
244
+ const result = operation.type === "soap"
245
+ ? await new SoapClient().execute(operation, params)
246
+ : await new RestClient(env, rootDir).execute(operation, params);
247
+ const captures = {
248
+ ...result.captures,
249
+ ...extractCaptures(result.response, parseCaptureOptions(options.capture))
250
+ };
251
+ console.log(JSON.stringify({ operation: operationName, response: result.response, captures }, null, 2));
252
+ });
253
+ });
254
+
255
+ program
256
+ .command("db-query")
257
+ .argument("<query>", "Query template key from the Action Library")
258
+ .description("Run an allowlisted Oracle query template and print rows/captures")
259
+ .option("--env <env>", "Environment name; defaults to ADFINEM_ENV or local")
260
+ .option("--params <json>", "JSON object with query bind parameters", "{}")
261
+ .option("--param <name=value>", "Bind parameter; repeat for arrays, for example --param case_id=CASE-1001 --param case_id=CASE-1002", collectOption, [])
262
+ .option("--capture <name=expr>", "Additional capture expression; repeatable", collectOption, [])
263
+ .action(async (queryName: string, options: { env?: string; params: string; param: string[]; capture: string[] }) => {
264
+ await handleErrors(async () => {
265
+ const env = getEnvironment(options.env, rootDir);
266
+ const catalogs = await loadCatalogs(rootDir);
267
+ const entry = catalogs.queries[queryName];
268
+ if (!entry) throw new Error(`Unknown query '${queryName}'.`);
269
+ const params = parseParamsOptions(options.params, options.param);
270
+ const rows = await new OracleClient(env).query(entry, params);
271
+ const payload = { query: queryName, rowCount: rows.length, rows };
272
+ const captures = extractCaptures(payload, mergeCaptureSpecs(entry.captures, parseCaptureOptions(options.capture)));
273
+ console.log(JSON.stringify({ ...payload, captures }, null, 2));
274
+ });
275
+ });
276
+
277
+ program
278
+ .command("db-execute")
279
+ .argument("<query>", "Executable DB template key from the Action Library")
280
+ .description("Run an allowlisted Oracle execute/PLSQL template")
281
+ .option("--env <env>", "Environment name; defaults to ADFINEM_ENV or local")
282
+ .option("--params <json>", "JSON object with bind parameters", "{}")
283
+ .option("--param <name=value>", "Bind parameter; repeat to pass multiple values", collectOption, [])
284
+ .option("--capture <name=expr>", "Additional capture expression; repeatable", collectOption, [])
285
+ .action(async (queryName: string, options: { env?: string; params: string; param: string[]; capture: string[] }) => {
286
+ await handleErrors(async () => {
287
+ const env = getEnvironment(options.env, rootDir);
288
+ const catalogs = await loadCatalogs(rootDir);
289
+ const entry = catalogs.queries[queryName];
290
+ if (!entry) throw new Error(`Unknown DB executable '${queryName}'.`);
291
+ if (entry.mode !== "execute") throw new Error(`DB Action Library template '${queryName}' must be marked mode: execute.`);
292
+ const params = parseParamsOptions(options.params, options.param);
293
+ const result = await new OracleClient(env).execute(entry, params);
294
+ const payload = { query: queryName, ...result };
295
+ const captures = extractCaptures(payload, mergeCaptureSpecs(entry.captures, parseCaptureOptions(options.capture)));
296
+ console.log(JSON.stringify({ ...payload, captures }, null, 2));
297
+ });
298
+ });
299
+
300
+ program
301
+ .command("db-assert")
302
+ .argument("<query>", "Query template key from the Action Library")
303
+ .description("Run an allowlisted Oracle query template and enforce its expect block")
304
+ .option("--env <env>", "Environment name; defaults to ADFINEM_ENV or local")
305
+ .option("--params <json>", "JSON object with query bind parameters", "{}")
306
+ .option("--param <name=value>", "Bind parameter; repeat for arrays, for example --param case_id=CASE-1001 --param case_id=CASE-1002", collectOption, [])
307
+ .action(async (queryName: string, options: { env?: string; params: string; param: string[] }) => {
308
+ await handleErrors(async () => {
309
+ const env = getEnvironment(options.env, rootDir);
310
+ const catalogs = await loadCatalogs(rootDir);
311
+ const entry = catalogs.queries[queryName];
312
+ if (!entry) throw new Error(`Unknown query '${queryName}'.`);
313
+ if (!entry.expect) throw new Error(`Query '${queryName}' has no expect block.`);
314
+ const params = parseParamsOptions(options.params, options.param);
315
+ const rows = await assertDb(new OracleClient(env), entry, params);
316
+ console.log(JSON.stringify({ query: queryName, rowCount: rows.length, rows }, null, 2));
317
+ });
318
+ });
319
+
320
+ program
321
+ .command("run-batch")
322
+ .argument("<batch>", "Batch template key from the Action Library")
323
+ .description("Run an allowlisted Unix batch template over SSH")
324
+ .option("--env <env>", "Environment name; defaults to ADFINEM_ENV or local")
325
+ .option("--params <json>", "JSON object with batch parameters", "{}")
326
+ .option("--param <name=value>", "Batch parameter; repeat to pass multiple values", collectOption, [])
327
+ .option("--attempts <count>", "Retry attempts", parseInteger)
328
+ .option("--delay-seconds <seconds>", "Delay between attempts", parseInteger)
329
+ .option("--capture <name=expr>", "Additional capture expression; repeatable", collectOption, [])
330
+ .action(async (batchName: string, options: { env?: string; params: string; param: string[]; attempts?: number; delaySeconds?: number; capture: string[] }) => {
331
+ await handleErrors(async () => {
332
+ const env = getEnvironment(options.env, rootDir);
333
+ const catalogs = await loadCatalogs(rootDir);
334
+ const entry = catalogs.batches[batchName];
335
+ if (!entry) throw new Error(`Unknown batch '${batchName}'.`);
336
+ const params = parseParamsOptions(options.params, options.param);
337
+ const result = await runBatch(new BatchRunner(new SshClient(env)), entry, params, {
338
+ attempts: options.attempts,
339
+ delayMs: options.delaySeconds === undefined ? undefined : options.delaySeconds * 1000
340
+ });
341
+ const captures = extractCaptures(result, mergeCaptureSpecs(entry.captures, parseCaptureOptions(options.capture)));
342
+ console.log(JSON.stringify({ ...result, captures }, null, 2));
343
+ if (result.status === "failed") process.exitCode = 1;
344
+ });
345
+ });
346
+
347
+ await program.parseAsync(process.argv);
348
+
349
+ async function validateScenario(scenarioPath: string) {
350
+ const fullScenarioPath = resolve(process.cwd(), scenarioPath);
351
+ const scenario = await loadScenario(fullScenarioPath);
352
+ const catalogs = await loadCatalogs(rootDir);
353
+ const knownEnvironments = listEnvironmentNames(rootDir);
354
+ const validation = validateScenarioReferences(scenario, catalogs, { knownEnvironments });
355
+ if (validation.warnings) {
356
+ for (const warning of validation.warnings) console.warn(`Warning: ${warning}`);
357
+ }
358
+ return { scenario, catalogs, errors: validation.errors };
359
+ }
360
+
361
+ function flowIdFromOutput(path: string): string {
362
+ const fileName = basename(path)
363
+ .replace(/\.flow\.ya?ml$/i, "")
364
+ .replace(/\.ya?ml$/i, "");
365
+ return fileName.replace(/[^A-Za-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "") || "combined_flow";
366
+ }
367
+
368
+ function parsePrefixMode(value: string): FlowNodePrefixMode {
369
+ if (value === "auto" || value === "always" || value === "never") return value;
370
+ throw new Error(`Invalid --prefix-node-ids '${value}'. Use auto, always, or never.`);
371
+ }
372
+
373
+ async function handleErrors(fn: () => Promise<void>): Promise<void> {
374
+ try {
375
+ await fn();
376
+ } catch (error) {
377
+ if (error instanceof ZodError) {
378
+ console.error("Schema validation failed:");
379
+ for (const issue of error.issues) console.error(`- ${issue.path.join(".")}: ${issue.message}`);
380
+ } else {
381
+ const err = error instanceof Error ? error : new Error(String(error));
382
+ console.error(formatKnownError(err));
383
+ if (process.env.DEBUG) console.error(err.stack);
384
+ }
385
+ process.exitCode = 1;
386
+ }
387
+ }
388
+
389
+ function parseInteger(value: string): number {
390
+ const parsed = Number.parseInt(value, 10);
391
+ if (!Number.isFinite(parsed) || parsed < 0) throw new Error(`Invalid integer: ${value}`);
392
+ return parsed;
393
+ }
394
+
395
+ function collectOption(value: string, previous: string[]): string[] {
396
+ return [...previous, value];
397
+ }
398
+
399
+ function parseJsonObject(value: string, label: string): Record<string, unknown> {
400
+ let parsed: unknown;
401
+ try {
402
+ parsed = JSON.parse(value) as unknown;
403
+ } catch (error) {
404
+ const err = error instanceof Error ? error : new Error(String(error));
405
+ throw new Error(`Invalid JSON for ${label}: ${err.message}. ${jsonArgumentHint(label)}`);
406
+ }
407
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
408
+ throw new Error(`${label} must be a JSON object.`);
409
+ }
410
+ return parsed as Record<string, unknown>;
411
+ }
412
+
413
+ function jsonArgumentHint(label: string): string {
414
+ if (label === "--params") {
415
+ return "In PowerShell, prefer repeated --param name=value options instead of --params JSON.";
416
+ }
417
+ return "In PowerShell, wrap JSON in single quotes or escape double quotes.";
418
+ }
419
+
420
+ function parseParamsOptions(jsonValue: string | undefined, paramValues: string[] | undefined): Record<string, unknown> {
421
+ const params = parseJsonObject(jsonValue || "{}", "--params");
422
+ for (const value of paramValues ?? []) {
423
+ const separator = value.indexOf("=");
424
+ if (separator <= 0) throw new Error(`Invalid --param '${value}'. Use name=value.`);
425
+ const name = value.slice(0, separator).trim();
426
+ const rawValue = value.slice(separator + 1).trim();
427
+ if (!name || !rawValue) throw new Error(`Invalid --param '${value}'. Both name and value are required.`);
428
+ appendParamValue(params, name, parseParamValue(rawValue));
429
+ }
430
+ return params;
431
+ }
432
+
433
+ function appendParamValue(params: Record<string, unknown>, name: string, value: unknown): void {
434
+ if (name in params) {
435
+ const existing = params[name];
436
+ params[name] = Array.isArray(existing) ? [...existing, value] : [existing, value];
437
+ return;
438
+ }
439
+ params[name] = value;
440
+ }
441
+
442
+ function parseParamValue(value: string): unknown {
443
+ if (value.startsWith("json:")) {
444
+ try {
445
+ return JSON.parse(value.slice("json:".length)) as unknown;
446
+ } catch (error) {
447
+ const err = error instanceof Error ? error : new Error(String(error));
448
+ throw new Error(`Invalid JSON value for --param '${value}': ${err.message}. Use json: only for valid JSON scalars/arrays/objects.`);
449
+ }
450
+ }
451
+ if (value.includes(",")) return value.split(",").map((entry) => entry.trim()).filter(Boolean);
452
+ return value;
453
+ }
454
+
455
+ function parseCaptureOptions(values: string[] | undefined): Record<string, string> {
456
+ const captures: Record<string, string> = {};
457
+ for (const value of values ?? []) {
458
+ const separator = value.indexOf("=");
459
+ if (separator <= 0) throw new Error(`Invalid --capture '${value}'. Use name=expression.`);
460
+ const name = value.slice(0, separator).trim();
461
+ const expression = value.slice(separator + 1).trim();
462
+ if (!name || !expression) throw new Error(`Invalid --capture '${value}'. Both name and expression are required.`);
463
+ captures[name] = expression;
464
+ }
465
+ return captures;
466
+ }
@@ -0,0 +1,193 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { config as loadDotEnv } from "dotenv";
5
+ import YAML from "yaml";
6
+
7
+ export interface EnvironmentConfig {
8
+ name: string;
9
+ apiBaseUrl?: string;
10
+ apiTlsInsecure?: boolean;
11
+ oracle: {
12
+ user?: string;
13
+ password?: string;
14
+ connectString?: string;
15
+ };
16
+ sshHosts: Record<string, {
17
+ host?: string;
18
+ username?: string;
19
+ password?: string;
20
+ privateKeyPath?: string;
21
+ shell?: string;
22
+ loginShell?: boolean;
23
+ }>;
24
+ }
25
+
26
+ export interface EditableEnvironmentConfig {
27
+ apiBaseUrl?: string;
28
+ apiTlsInsecure?: boolean;
29
+ oracle?: {
30
+ user?: string;
31
+ password?: string;
32
+ connectString?: string;
33
+ };
34
+ sshHosts?: Record<string, {
35
+ host?: string;
36
+ username?: string;
37
+ password?: string;
38
+ privateKeyPath?: string;
39
+ shell?: string;
40
+ loginShell?: boolean;
41
+ }>;
42
+ }
43
+
44
+ export type EnvironmentFile = Record<string, EditableEnvironmentConfig>;
45
+
46
+ export function getEnvironment(name?: string, rootDir = process.cwd()): EnvironmentConfig {
47
+ loadWorkspaceDotEnv(rootDir);
48
+ const resolvedName = name || process.env.ADFINEM_ENV || "local";
49
+ const raw = readFileSync(environmentConfigPath(rootDir), "utf8");
50
+ const configs = interpolateEnv(YAML.parse(raw)) as Record<string, EnvironmentConfig>;
51
+ const config = configs[resolvedName];
52
+
53
+ if (!config) {
54
+ const available = Object.keys(configs).filter((key) => key && typeof configs[key] === "object");
55
+ const suffix = available.length
56
+ ? ` Available: ${available.join(", ")}.`
57
+ : " No environments are defined yet.";
58
+ throw new Error(`Unknown environment '${resolvedName}'.${suffix} Add it to config/environments.yaml or pass --env.`);
59
+ }
60
+
61
+ return {
62
+ name: resolvedName,
63
+ apiBaseUrl: blankToUndefined(config.apiBaseUrl),
64
+ apiTlsInsecure: booleanValue(config.apiTlsInsecure),
65
+ oracle: {
66
+ user: blankToUndefined(config.oracle?.user),
67
+ password: blankToUndefined(config.oracle?.password),
68
+ connectString: blankToUndefined(config.oracle?.connectString)
69
+ },
70
+ sshHosts: Object.fromEntries(Object.entries(config.sshHosts ?? {}).map(([hostRef, host]) => [hostRef, {
71
+ host: blankToUndefined(host.host),
72
+ username: blankToUndefined(host.username),
73
+ password: blankToUndefined(host.password),
74
+ privateKeyPath: blankToUndefined(host.privateKeyPath),
75
+ shell: blankToUndefined(host.shell),
76
+ loginShell: booleanValue(host.loginShell)
77
+ }]))
78
+ };
79
+ }
80
+
81
+ export function listEnvironmentNames(rootDir = process.cwd()): string[] {
82
+ try {
83
+ return Object.keys(loadEnvironmentFile(rootDir));
84
+ } catch {
85
+ return [];
86
+ }
87
+ }
88
+
89
+ export function environmentConfigPath(rootDir = process.cwd()): string {
90
+ return join(rootDir, "config", "environments.yaml");
91
+ }
92
+
93
+ export function loadEnvironmentFile(rootDir = process.cwd()): EnvironmentFile {
94
+ loadWorkspaceDotEnv(rootDir);
95
+ const raw = readFileSync(environmentConfigPath(rootDir), "utf8");
96
+ const parsed = YAML.parse(raw) as Record<string, unknown> | null;
97
+ if (!parsed || typeof parsed !== "object") return {};
98
+ return Object.fromEntries(
99
+ Object.entries(parsed)
100
+ .filter(([name, value]) => name && value && typeof value === "object" && !Array.isArray(value))
101
+ .map(([name, value]) => [name, normalizeEditableEnvironment(value as Record<string, unknown>)])
102
+ );
103
+ }
104
+
105
+ export async function writeEnvironmentFile(rootDir: string, environments: EnvironmentFile): Promise<void> {
106
+ const outputPath = environmentConfigPath(rootDir);
107
+ const normalized = Object.fromEntries(
108
+ Object.entries(environments)
109
+ .filter(([name]) => isValidEnvironmentName(name))
110
+ .map(([name, config]) => [name, normalizeEditableEnvironment(config as Record<string, unknown>)])
111
+ );
112
+ await mkdir(dirname(outputPath), { recursive: true });
113
+ await writeFile(outputPath, YAML.stringify(normalized, { defaultKeyType: "PLAIN", defaultStringType: "QUOTE_DOUBLE" }), "utf8");
114
+ }
115
+
116
+ export function isValidEnvironmentName(name: string): boolean {
117
+ return /^[A-Za-z][A-Za-z0-9_-]*$/.test(name);
118
+ }
119
+
120
+ export function assertValidEnvironmentName(name: string): void {
121
+ if (!isValidEnvironmentName(name)) {
122
+ throw new Error("Environment name must start with a letter and contain only letters, numbers, underscore, or hyphen.");
123
+ }
124
+ }
125
+
126
+ function interpolateEnv(value: unknown): unknown {
127
+ if (typeof value === "string") {
128
+ return value.replace(/\$\{([A-Za-z0-9_]+)\}/g, (_match, name: string) => process.env[name] ?? "");
129
+ }
130
+ if (Array.isArray(value)) return value.map(interpolateEnv);
131
+ if (value && typeof value === "object") {
132
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, interpolateEnv(entry)]));
133
+ }
134
+ return value;
135
+ }
136
+
137
+ function loadWorkspaceDotEnv(rootDir: string): void {
138
+ loadDotEnv({ path: join(rootDir, ".env"), override: true });
139
+ }
140
+
141
+ function blankToUndefined(value: string | undefined): string | undefined {
142
+ return value && value.trim() ? value : undefined;
143
+ }
144
+
145
+ function normalizeEditableEnvironment(value: Record<string, unknown>): EditableEnvironmentConfig {
146
+ const oracle = objectValue(value.oracle);
147
+ const sshHosts = objectValue(value.sshHosts);
148
+ return {
149
+ apiBaseUrl: optionalString(value.apiBaseUrl),
150
+ apiTlsInsecure: optionalBoolean(value.apiTlsInsecure),
151
+ oracle: {
152
+ user: optionalString(oracle?.user),
153
+ password: optionalString(oracle?.password),
154
+ connectString: optionalString(oracle?.connectString)
155
+ },
156
+ sshHosts: Object.fromEntries(
157
+ Object.entries(sshHosts ?? {})
158
+ .filter(([, entry]) => entry && typeof entry === "object" && !Array.isArray(entry))
159
+ .map(([hostRef, entry]) => {
160
+ const host = entry as Record<string, unknown>;
161
+ return [hostRef, {
162
+ host: optionalString(host.host),
163
+ username: optionalString(host.username),
164
+ password: optionalString(host.password),
165
+ privateKeyPath: optionalString(host.privateKeyPath),
166
+ shell: optionalString(host.shell),
167
+ loginShell: optionalBoolean(host.loginShell)
168
+ }];
169
+ })
170
+ )
171
+ };
172
+ }
173
+
174
+ function objectValue(value: unknown): Record<string, unknown> | undefined {
175
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
176
+ }
177
+
178
+ function optionalString(value: unknown): string | undefined {
179
+ return value === undefined || value === null ? undefined : String(value);
180
+ }
181
+
182
+ function optionalBoolean(value: unknown): boolean | undefined {
183
+ if (value === undefined || value === null || value === "") return undefined;
184
+ if (typeof value === "boolean") return value;
185
+ const normalized = String(value).trim().toLowerCase();
186
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
187
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
188
+ return undefined;
189
+ }
190
+
191
+ function booleanValue(value: unknown): boolean | undefined {
192
+ return optionalBoolean(value);
193
+ }
@@ -0,0 +1,23 @@
1
+ export interface RegisteredAction {
2
+ layer: "api" | "db" | "unix" | "engine";
3
+ supportedVia: string[];
4
+ }
5
+
6
+ export const registeredActions: Record<string, RegisteredAction> = {
7
+ db_assert: {
8
+ layer: "db",
9
+ supportedVia: ["db"]
10
+ },
11
+ db_query: {
12
+ layer: "db",
13
+ supportedVia: ["db"]
14
+ },
15
+ db_execute: {
16
+ layer: "db",
17
+ supportedVia: ["db"]
18
+ },
19
+ unix_batch: {
20
+ layer: "unix",
21
+ supportedVia: ["unix"]
22
+ }
23
+ };