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