packages feed

hurl-workbench-cli (empty) → 0.1.0.0

raw patch · 100 files changed

+5274/−0 lines, 100 filesdep +asyncdep +basedep +bytestring

Dependencies added: async, base, bytestring, containers, directory, filepath, generic-lens, githash, http-types, hurl-workbench-cli, hurl-workbench-core, lens, optparse-applicative, tasty, tasty-hunit, temporary, terminal-size, text, unix, wai, warp

Files

+ CHANGELOG.md view
@@ -0,0 +1,51 @@+# Changelog++All notable changes to hurl-workbench are recorded here.++The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and+this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).++## [Unreleased]++## [0.1.0.0] - 2026-11-12++### Added++- Versioned Dhall workspaces with compatible record completion, upward discovery, accumulated+  semantic validation, and category-specific names.+- Byte-preserving fragment composition, source-mapped Hurlfmt validation, and inspectable rendering.+- Secure Hurl 8.x execution with separate plain/secret files, filtered ambient configuration,+  exact child statuses, and typed Hurl option passthrough.+- Read-only/mutating recipes, bounded matrices, isolated client artifacts, managed services, suite+  preflight, and isolated JUnit, HTML, JSON, and TAP reports.+- Revision-aware version output and generated Bash, Zsh, and Fish completions.+- Local vendor-exploration and managed integration-service examples.+- Per-execution UUID4, UUID7, TypeID, UTC timestamp, and alphanumeric generated parameters with+  reproducible runtime overrides.+- Caller-owned Hurl file roots for file bodies, validated before subprocess work and protected from+  output preparation.+- Ordered multi-service suites with bounded setup/teardown commands and named Stop/Start/Restart+  barriers between Hurl batches; compatible old manifests and redacted ordered lifecycle results.+- Executable checks as ordered suite barriers, with workspace-contained executables, Plain argv+  templates, bounded process groups, and private captures; checks-only suites with no Hurl cases.+- Process-group cleanup for Hurl and managed commands, including bounded capture cancellation,+  plus production Rei/Hinagata command/read and lease-release acceptance drivers.+- Compatible suite isolation declarations, with a shared-state default and early refusal of+  parallel mutation or unclassified suite runs; standalone mutating matrices require serial jobs.+- Private versioned per-scenario runtime inputs for caller-owned independent suites, with unique+  fixture/origin assignments, channel/conflict validation, and individually resolved bindings.+- Safe isolation/scenario summaries for success, refusal, setup failure, and cancellation, plus+  real two-database Hinagata acceptance covering fail-fast and caller-owned cleanup.+- Explicit inherited or clean environment policies for managed service and readiness commands,+  with required-name preflight, compatible old manifests, and a caller-owned lease check driver.+- Local CLI/API parity examples and real application acceptance using executable checks.++### Security++- Secrets never enter child argv; temporary inputs and output artifacts use owner-only permissions.+- Managed services are cleaned up as POSIX process groups, including on timeout and interruption.++### Compatibility++- Hurl and Hurlfmt 8.0 or newer are required. Later major versions are reported as untested by+  `doctor` rather than rejected automatically.
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright (c) 2026 Nadeem Bitar++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++    * Redistributions of source code must retain the above copyright+      notice, this list of conditions and the following disclaimer.++    * Redistributions in binary form must reproduce the above+      copyright notice, this list of conditions and the following+      disclaimer in the documentation and/or other materials provided+      with the distribution.++    * Neither the name of Nadeem Bitar nor the names of other+      contributors may be used to endorse or promote products derived+      from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,113 @@+# hurl-workbench++`hurl-workbench` composes complete Hurl entries into reusable workflows, supplies plain and secret+parameters securely, and executes recipes, matrices, and integration suites through the official+Hurl binary. Suites can manage several services, generate fresh scenario inputs, read caller-owned+body files, and run application-owned executable assertions between Hurl batches. Hurl remains+responsible for HTTP, captures, assertions, cookies, retries, and reports.++Suites default to shared application state: parallel Hurl runs require ReadOnly recipes, and shared+writes require `--jobs 1 --allow-mutating`. Independent suites validate caller-provisioned+fixture/endpoint assignments through `--scenario-inputs`; the caller establishes actual isolation.+See the [integration guide](docs/guides/integration-testing.md).++## Five-minute quick start++Enter the pinned development environment and inspect the interface:++```bash+nix develop+cabal run hurl-workbench -- --help+cabal run hurl-workbench -- doctor+```++The small workspace in [`examples/quick-start/`](examples/quick-start/) defines one `health`+fragment and workflow against the bundled Dhall schema. Its token is a secret parameter sourced+from `QUICKSTART_TOKEN`; no credential is committed. Inspect, validate, and render it:++```bash+sed -n '1,240p' examples/quick-start/hurl-workbench.dhall+cabal run hurl-workbench -- --workspace examples/quick-start/hurl-workbench.dhall validate+cabal run hurl-workbench -- --workspace examples/quick-start/hurl-workbench.dhall render workflow health+```++Start the local fixture API in one terminal:++```bash+cabal run hurl-workbench-fixture-server -- --port 18080+```++Run the workflow from another terminal with an environment-backed fixture secret:++```bash+QUICKSTART_TOKEN=fixture-secret cabal run hurl-workbench -- \+  --workspace examples/quick-start/hurl-workbench.dhall \+  test workflow health+```++The expected result is a passing Hurl test summary. For full workflows, continue with:++- [`examples/vendor-odata/`](examples/vendor-odata/) — one OAuth capture reused by OData-shaped+  recipes and a bounded matrix, plus bespoke capture and raw-response investigations;+- [`examples/integration-service/`](examples/integration-service/) — managed fixture-service+  lifecycle, safe and mutating suites, clean command environments, and isolated reports;+- [`examples/multi-service/`](examples/multi-service/) — ordered stub/API/worker startup,+  setup/teardown, and worker restart barriers;+- [`examples/generated-parameters/`](examples/generated-parameters/) — fresh UUID, TypeID,+  timestamp, and random inputs with reproducible overrides;+- [`examples/file-body/`](examples/file-body/) — caller-generated UTF-8/CRLF request bodies+  passed unchanged through `--file-root`;+- [`examples/executable-checks/`](examples/executable-checks/) — CLI/API parity assertions+  between Hurl runs, including a deliberately failing comparison.++## Core model++A `hurl-workbench.dhall` manifest imports [`schema/package.dhall`](schema/package.dhall). Fragments+are ordinary `.hurl` files containing one or more complete entries. A workflow names fragments in+order; rendering preserves their bytes and inserts only the line feeds needed between them.+Recipes add committed plain bindings and an explicit safety class. Matrices layer named cases over+a recipe. Suites execute bounded Hurl batches separated by executable checks or named service+Stop/Start/Restart barriers. Services become ready in declaration order and stop in reverse+acquisition order before bounded teardown, including on failure and cancellation.++Plain, Secret, and Generated parameters have separate binding rules. Generated values resolve+once per prepared Hurl execution; explicit runtime values reproduce a prior run. Use Plain inputs+when setup, checks, and several Hurl runs must share an identity. Command environments can inherit+ambient variables or explicitly select names through `EnvironmentPolicy.Clean`.++Execution commands accept `--file-root DIR`; suites can also name a Plain `fileRootParameter`.+The caller keeps body files alive until execution ends. Keep report and output trees outside an+explicit body root. A suite with executable checks requires `--report-dir`; captured check output+stays in private files while the summary records named outcomes and relative paths.++Without `--workspace`, discovery starts at the current directory and walks upward looking for+`hurl-workbench.dhall`. See the [workspace reference](docs/reference/workspace.md), [CLI+reference](docs/reference/cli.md), [architecture](docs/architecture.md), and [security+model](docs/security.md). The [guide index](docs/guides/index.md) maps tasks to examples, and the+[OKF capability catalog](docs/capabilities/index.md) records implemented contracts, evidence, and+limits with stable `CAP-N` handles. These capabilities are currently unreleased and experimental.++## Development++The repository contains `hurl-workbench-core` and `hurl-workbench-cli`, targets GHC 9.12.4, and+uses a Nix flake for the complete toolchain.++```bash+nix develop+just check+```++`just check` initializes Cabal's Hackage index, whose solver view is pinned by+`cabal.project`, then runs formatting, builds, tests, local examples, source-distribution+checks, and the Nix flake gate. On a Mac with Apple `container` installed and its system+service running, `just linux-check` runs that same gate in a disposable ARM64 Linux VM.+Local examples include multi-service orchestration, file-body byte preservation, generated-input+overrides, and matching/failing executable checks. Real application and database lease acceptance+uses opt-in caller harnesses documented in the integration guide.++Release source archives use `bash scripts/build-sdist.sh`; this wrapper preserves executable modes+for the declared check helpers. `just sdist` verifies the unpacked archives and their examples.++## License++[BSD-3-Clause](LICENSE) — © 2026 Nadeem Bitar.
+ app/Main.hs view
@@ -0,0 +1,6 @@+module Main (main) where++import HurlWorkbench.Cli (runCli)++main :: IO ()+main = runCli
+ examples/executable-checks/README.md view
@@ -0,0 +1,43 @@+# Executable check barriers++`parity` runs a read-only Hurl recipe, the application-owned `parity.py` assertion,+and another read-only recipe. The assertion invokes the example application's+supported CLI and HTTP facts interfaces against separately provisioned fixtures.+Plain inputs identify the fixtures; a Secret parameter reaches the helper only as+`PARITY_TOKEN`. Its Clean policy selects PATH and excludes ambient database and+service settings. `parity-mismatch` changes only the HTTP fixture through its+supported API, then proves that the same assertion fails.++From the repository root, inside `nix develop`, run:++```bash+cabal build all+HURL_WORKBENCH_EXECUTABLE="$(cabal list-bin hurl-workbench)" \+  python3 scripts/check-command-parity-local.py+```++The caller creates two private fixture directories, starts their HTTP service,+runs the matching suite at jobs=2 and the mutating mismatch suite serially, verifies+named summary outcomes and a secret sentinel, then stops the service and removes+the fixtures. This also runs under `just examples`.++For manual use, provision separate CLI and HTTP fixture roots containing+`facts.json` arrays of `{ "key": "...", "value": "..." }` facts. Start the API+with `PARITY_TOKEN` supplied privately in its environment:++```bash+python3 examples/executable-checks/app.py serve /private/http-fixture 18081+hurl-workbench --workspace examples/executable-checks/hurl-workbench.dhall \+  test suite parity --jobs 2 --report-dir /private/new-reports \+  --variable cliFixture=/private/cli-fixture --variable apiUrl=http://127.0.0.1:18081 \+  --variable cliFixtureId=cli-scope --variable apiFixtureId=http-scope+```++The workbench invocation needs the same private `PARITY_TOKEN` environment source.+It refuses an existing suite report tree unless overwrite is explicit. Private+stdout/stderr live at `REPORT/SUITE/checks/parity/`; they may contain credentials+printed by a helper. Only logical status, timing, exit codes and relative paths+enter the summary. Checks-only suites are also supported.++The [Rei consumer](rei/README.md) supplies actual application evidence beyond this+small local example. Neither check replaces package/schema/client release gates.
+ examples/executable-checks/after.hurl view
@@ -0,0 +1,3 @@+GET {{apiUrl}}/facts+Authorization: Bearer {{token}}+HTTP 200
+ examples/executable-checks/app.py view
@@ -0,0 +1,61 @@+#!/usr/bin/env python3+"""Disposable example application's supported CLI and HTTP facts interface."""+import json+import os+from pathlib import Path+import sys+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer+++def facts(root):+    return json.loads((Path(root) / "facts.json").read_text())+++def put(root, value):+    path = Path(root) / "facts.json"+    path.write_text(json.dumps(value))+    path.chmod(0o600)+++def serve(root, port):+    class Handler(BaseHTTPRequestHandler):+        def log_message(self, *_):+            pass++        def do_GET(self):+            if self.path == "/health":+                self.send_response(200)+                self.end_headers()+                return+            if self.headers.get("Authorization") != "Bearer " + os.environ["PARITY_TOKEN"]:+                self.send_error(401)+                return+            if self.path != "/facts":+                self.send_error(404)+                return+            data = json.dumps(facts(root)).encode()+            self.send_response(200)+            self.send_header("Content-Type", "application/json")+            self.send_header("Content-Length", str(len(data)))+            self.end_headers()+            self.wfile.write(data)++        def do_POST(self):+            if self.path != "/facts" or self.headers.get("Authorization") != "Bearer " + os.environ["PARITY_TOKEN"]:+                self.send_error(403)+                return+            value = json.loads(self.rfile.read(int(self.headers["Content-Length"])))+            put(root, value)+            self.send_response(204)+            self.end_headers()++    ThreadingHTTPServer(("127.0.0.1", int(port)), Handler).serve_forever()+++if __name__ == "__main__":+    if sys.argv[1] == "observe":+        print(json.dumps(facts(sys.argv[2])))+    elif sys.argv[1] == "serve":+        serve(*sys.argv[2:4])+    else:+        raise SystemExit("expected observe FIXTURE or serve FIXTURE PORT")
+ examples/executable-checks/before.hurl view
@@ -0,0 +1,2 @@+GET {{apiUrl}}/health+HTTP 200
+ examples/executable-checks/hurl-workbench.dhall view
@@ -0,0 +1,11 @@+let S = ../../schema/package.dhall+let p = \(name : Text) -> S.Parameter::{ name = name }+let binding = \(variable : Text) -> \(parameter : Text) -> S.EnvironmentBinding::{ variable = variable, parameter = parameter }+in S.Workspace::{ schemaVersion = 1+, parameters = [ p "cliFixture", p "apiUrl", p "cliFixtureId", p "apiFixtureId", S.Parameter::{ name = "token", kind = S.ParameterKind.Secret, environment = Some "PARITY_TOKEN" } ]+, fragments = [ S.Fragment::{ name = "before", path = "before.hurl" }, S.Fragment::{ name = "after", path = "after.hurl" }, S.Fragment::{ name = "mismatch", path = "mismatch.hurl" } ]+, workflows = [ S.Workflow::{ name = "before", fragments = [ "before" ], parameters = [ "apiUrl" ] }, S.Workflow::{ name = "after", fragments = [ "after" ], parameters = [ "apiUrl", "token" ] }, S.Workflow::{ name = "mismatch", fragments = [ "mismatch" ], parameters = [ "apiUrl", "token" ] } ]+, recipes = [ S.Recipe::{ name = "before", workflow = "before", safety = S.Safety.ReadOnly }, S.Recipe::{ name = "after", workflow = "after", safety = S.Safety.ReadOnly }, S.Recipe::{ name = "mismatch", workflow = "mismatch", safety = S.Safety.Mutating } ]+, checks = [ S.Check::{ name = "parity", command = S.CommandSpec::{ executable = "parity.py", arguments = [ "{{cliFixture}}", "{{apiUrl}}", "{{cliFixtureId}}", "{{apiFixtureId}}" ], environmentPolicy = S.EnvironmentPolicy.Clean [ "PATH" ], environment = [ binding "PARITY_TOKEN" "token" ] } } ]+, suites = [ S.Suite::{ name = "parity", runs = [ S.RunReference.Recipe "before", S.RunReference.Check "parity", S.RunReference.Recipe "after" ] }, S.Suite::{ name = "parity-mismatch", runs = [ S.RunReference.Recipe "mismatch", S.RunReference.Check "parity" ] } ]+}
+ examples/executable-checks/mismatch.hurl view
@@ -0,0 +1,7 @@+POST {{apiUrl}}/facts+Authorization: Bearer {{token}}+Content-Type: application/json+[+  {"key": "parity", "value": "deliberately different"}+]+HTTP 204
+ examples/executable-checks/parity.py view
@@ -0,0 +1,21 @@+#!/usr/bin/env python3+"""Application-owned comparison of independent supported CLI/HTTP observations."""+import json+import os+from pathlib import Path+import subprocess+import sys+from urllib.request import Request, urlopen++cli_fixture, api_url, cli_id, api_id = sys.argv[1:]+if not cli_id or not api_id or cli_id == api_id:+    raise SystemExit("independent logical fixture identities are required")+assert not any(name in os.environ for name in ("REI_PG_CONNECTION_STRING", "MORI_API_URL", "PGHOST"))+cli = json.loads(subprocess.check_output([sys.executable, str(Path(__file__).with_name("app.py")), "observe", cli_fixture]))+request = Request(api_url + "/facts", headers={"Authorization": "Bearer " + os.environ["PARITY_TOKEN"]})+with urlopen(request, timeout=5) as response:+    api = json.load(response)+normalize = lambda values: sorted((item["key"], item["value"]) for item in values)+if normalize(cli) != normalize(api):+    raise SystemExit("CLI/API persisted facts differ")+print("CLI/API persisted facts match")
+ examples/executable-checks/rei/README.md view
@@ -0,0 +1,48 @@+# Real Rei CLI/API category parity++This consumer-specific assertion exercises the supported category CLI and paged+HTTP interfaces from `mori://shinzui/rei`, as requested by+`mori://shinzui/rei/masterplans/29-close-rei-cli-and-http-api-gaps-on-the-haskell-jitsurei-contract`.+`parity.py` compares slug, name, status, description and context, omitting generated+identities, timestamps and stream metadata. It neither reads internal tables nor+implements framework persistence. The helper belongs to this application fixture+workspace; workbench knows only its declared executable and process result.++`scripts/check-rei-command-parity.py` is the separate caller. It starts a disposable+SCRAM-authenticated PostgreSQL cluster, uses Hinagata's supported prepare/acquire+commands to obtain two distinct clones, and starts one Rei API per clone. It+creates equivalent categories through the real CLI and HTTP POST, then invokes+`scripts/check-command-parity.sh` inside those independently provisioned scopes.+The mismatch suite creates one extra category only through the HTTP twin's API.+The named `parity` check must then fail with status 1. Two API processes stop before+either caller-owned lease is released. No production configuration or database is+used or modified.++Discover the consumer and provider with Mori and build a matching API/CLI/schema+hook cohort in their repositories. From this repository, with absolute executable+paths in the named variables, run:++```bash+export hinagata_postgres_datadir="$(mori path mori://shinzui/hinagata)/hinagata-postgres"+nix develop -c python3 scripts/check-rei-command-parity.py \+  --api "$REI_API_EXECUTABLE" --rei "$REI_CLI_EXECUTABLE" \+  --schema-hook "$REI_LIVE_TEST_EXECUTABLE" --hinagata "$HINAGATA_EXECUTABLE" \+  --workbench "$HURL_WORKBENCH_EXECUTABLE" --evidence-root /tmp/new-rei-parity-evidence+```++The evidence directory must be new. It contains private logs, binary SHA-256+identities, match/mismatch summaries and caller release observations. Acceptance+used Rei 8.0.0.0, CLI revision+`mori://shinzui/rei/commits/8c5e11258f97b66600ce53269bd9675d9459db52`, with the+same built cohort as the existing multi-service consumer. This is no claim about+a newer consumer cohort. API tokens and database connection inputs use declared+Secret environment bindings; argv and ordinary summaries contain none of them.++The generic driver takes `HURL_WORKBENCH_CONSUMER_WORKSPACE` and+`HURL_WORKBENCH_CONSUMER_SUITE`, plus distinct `HURL_WORKBENCH_CLI_FIXTURE_ID` /+`HURL_WORKBENCH_API_FIXTURE_ID`, `HURL_WORKBENCH_CLI_FIXTURE`,+`HURL_WORKBENCH_API_URL`, and a new `HURL_WORKBENCH_CONSUMER_EVIDENCE_ROOT`.+It runs the named matching suite with jobs=2 and the `SUITE-mismatch` suite with+jobs=1 and explicit mutation authorization. Set+`HURL_WORKBENCH_CONSUMER_MISMATCH_SUITE` to override that name. The application+owns the mismatch operation; the driver provisions or releases no fixtures.
+ examples/executable-checks/rei/after.hurl view
@@ -0,0 +1,5 @@+GET {{apiUrl}}/api/v2/categories?first=100&status=all+Authorization: Bearer {{token}}+HTTP 200+[Asserts]+jsonpath "$.edges" count >= 1
+ examples/executable-checks/rei/before.hurl view
@@ -0,0 +1,2 @@+GET {{apiUrl}}/health/ready+HTTP 200
+ examples/executable-checks/rei/hurl-workbench.dhall view
@@ -0,0 +1,11 @@+let S = ../../../schema/package.dhall+let p = \(name : Text) -> S.Parameter::{ name = name }+let b = \(variable : Text) -> \(parameter : Text) -> S.EnvironmentBinding::{ variable = variable, parameter = parameter }+in S.Workspace::{ schemaVersion = 1+, parameters = [ p "cliFixture", p "apiUrl", p "cliFixtureId", p "apiFixtureId", S.Parameter::{ name = "rei", environment = Some "HURL_WORKBENCH_PARITY_REI" }, S.Parameter::{ name = "token", kind = S.ParameterKind.Secret, environment = Some "PARITY_TOKEN" }, S.Parameter::{ name = "cliDatabase", kind = S.ParameterKind.Secret, environment = Some "REI_PARITY_CLI_DATABASE" } ]+, fragments = [ S.Fragment::{ name = "before", path = "before.hurl" }, S.Fragment::{ name = "after", path = "after.hurl" }, S.Fragment::{ name = "mismatch", path = "mismatch.hurl" } ]+, workflows = [ S.Workflow::{ name = "before", fragments = [ "before" ], parameters = [ "apiUrl" ] }, S.Workflow::{ name = "after", fragments = [ "after" ], parameters = [ "apiUrl", "token" ] }, S.Workflow::{ name = "mismatch", fragments = [ "mismatch" ], parameters = [ "apiUrl", "token" ] } ]+, recipes = [ S.Recipe::{ name = "before", workflow = "before", safety = S.Safety.ReadOnly }, S.Recipe::{ name = "after", workflow = "after", safety = S.Safety.ReadOnly }, S.Recipe::{ name = "mismatch", workflow = "mismatch", safety = S.Safety.Mutating } ]+, checks = [ S.Check::{ name = "parity", timeoutSeconds = Some 30, command = S.CommandSpec::{ executable = "parity.py", arguments = [ "{{cliFixture}}", "{{apiUrl}}", "{{cliFixtureId}}", "{{apiFixtureId}}" ], environmentPolicy = S.EnvironmentPolicy.Clean [ "PATH" ], environment = [ b "REI_CLI" "rei", b "PARITY_TOKEN" "token", b "CLI_DATABASE" "cliDatabase" ] } } ]+, suites = [ S.Suite::{ name = "parity", runs = [ S.RunReference.Recipe "before", S.RunReference.Check "parity", S.RunReference.Recipe "after" ] }, S.Suite::{ name = "parity-mismatch", runs = [ S.RunReference.Recipe "mismatch", S.RunReference.Check "parity" ] } ]+}
+ examples/executable-checks/rei/mismatch.hurl view
@@ -0,0 +1,8 @@+POST {{apiUrl}}/api/v2/categories+Authorization: Bearer {{token}}+Content-Type: application/json+{+  "name": "Workbench deliberate mismatch",+  "description": "Present only in the independent HTTP fixture"+}+HTTP 201
+ examples/executable-checks/rei/parity.py view
@@ -0,0 +1,54 @@+#!/usr/bin/env python3+"""Rei-owned assertion over supported category CLI/HTTP contracts (MP29)."""+import json+import os+from pathlib import Path+import subprocess+import sys+from urllib.parse import urlencode, urlsplit+from urllib.request import Request, urlopen++cli_root, api_url, cli_id, api_id = sys.argv[1:]+cli_root = Path(cli_root)+cli_url = (cli_root / 'endpoint').read_text().strip()+assert cli_id and api_id and cli_id != api_id, 'fixture identities must differ'+assert urlsplit(cli_url).netloc != urlsplit(api_url).netloc, 'fixture endpoints must differ'+assert not any(key in os.environ for key in ('REI_PG_CONNECTION_STRING', 'MORI_API_URL', 'PGHOST')), 'ambient settings crossed Clean boundary'+env = dict(PATH=os.environ['PATH'], HOME=str(cli_root / 'home'),+           REI_CONFIG=str(cli_root / 'rei.yaml'), REI_API_URL=cli_url,+           REI_API_TOKEN=os.environ['PARITY_TOKEN'], REI_PG_CONNECTION_STRING=os.environ['CLI_DATABASE'],+           OTEL_SDK_DISABLED='true')+cli = subprocess.run([os.environ['REI_CLI'], 'category', 'list', '--all', '--json'],+                     env=env, stdin=subprocess.DEVNULL, capture_output=True, timeout=20)+assert cli.returncode == 0, 'supported category CLI failed'+cli_rows = json.loads(cli.stdout)+api_rows = []+after = None+# Complete bounded pages; a fixture accidentally larger than this is a refusal.+for _ in range(100):+    query = dict(first=100, status='all')+    if after is not None:+        query['after'] = after+    request = Request(api_url + '/api/v2/categories?' + urlencode(query), headers={'Authorization': 'Bearer ' + os.environ['PARITY_TOKEN']})+    with urlopen(request, timeout=10) as response:+        page = json.load(response)+    api_rows.extend(edge['node'] for edge in page['edges'])+    if not page['pageInfo']['hasNextPage']:+        break+    next_cursor = page['pageInfo']['endCursor']+    assert next_cursor and next_cursor != after, 'category paging did not advance'+    after = next_cursor+else:+    raise SystemExit('fixture exceeds bounded parity scope')+++def normalize(rows):+    # Only generated identity/time/stream metadata is omitted. Compare persisted+    # domain meaning and discoverability, preserving None versus empty text.+    return sorted((row['slug'], row['name'], row['status'].lower(), row.get('description'), row.get('context')) for row in rows)+++if normalize(cli_rows) != normalize(api_rows):+    raise SystemExit(f'persisted category parity differs: CLI {len(cli_rows)}, HTTP {len(api_rows)}')+assert cli_rows, 'empty fixture is not parity evidence'+print(f'persisted category parity matches: {len(cli_rows)} categories')
+ examples/file-body/README.md view
@@ -0,0 +1,38 @@+# Caller-owned request body files++`request.hurl` reads `file,request.json;` without generating files in the workspace.+Start the repository fixture server on loopback port 18080, then run from the repository root:++```bash+payload_root="$(mktemp -d)"+trap 'rm -rf "$payload_root"' EXIT+printf '{"message":"雪"}\r\n' > "$payload_root/request.json"+hurl-workbench --workspace examples/file-body/hurl-workbench.dhall \+  run workflow send-body --file-root "$payload_root" > "$payload_root.response"+cmp "$payload_root/request.json" "$payload_root.response"+hurl-workbench --workspace examples/file-body/hurl-workbench.dhall \+  test workflow send-body --file-root "$payload_root"+rm "$payload_root.response"+```++The echo endpoint returns the raw bytes, so `cmp` verifies UTF-8, CRLF, and the final newline.+`nix develop -c just examples` starts the fixture automatically and runs this comparison.++The caller creates, keeps readable/searchable, and eventually deletes the body directory.+Keep it alive until workbench and its Hurl children exit. Workbench never creates, changes+permissions on, or removes these inputs, including after failure or interruption. Separate+invocations can use the same filename with different bytes in separate roots.++`--file-root DIR` is relative to the invocation directory and overrides any suite setting.+For a suite alternative, run `test suite parameter --allow-mutating --variable+payloadRoot=/absolute/payload/directory`. Its `fileRootParameter = Some "payloadRoot"`+resolves a declared Plain parameter once from suite-wide CLI values, variable files,+environment, or defaults. Relative parameter values use the workspace directory.+Inspection requires no runtime value. The CLI override also works without supplying the+shadowed parameter.++Explicit roots and response/report/curl output trees must be disjoint, including aliases.+Keep reports outside the body root. Explicitly selecting the workspace itself applies this+stronger rule; omitting the option preserves ordinary workspace-local reports.+Fragment sources still resolve inside the workspace. This option changes Hurl's body-file+base and does not confine arbitrary paths authored in Hurl.
+ examples/file-body/hurl-workbench.dhall view
@@ -0,0 +1,13 @@+let S = ../../schema/package.dhall+in S.Workspace::{+ , schemaVersion = S.schemaVersion+ , parameters = [ S.Parameter::{ name = "baseUrl", defaultValue = Some "http://127.0.0.1:18080" }, S.Parameter::{ name = "payloadRoot" } ]+ , fragments = [ S.Fragment::{ name = "request", path = "request.hurl" } ]+ , workflows = [ S.Workflow::{ name = "send-body", fragments = [ "request" ], parameters = [ "baseUrl" ] } ]+ , recipes = [ S.Recipe::{ name = "send-body", workflow = "send-body", safety = S.Safety.Mutating } ]+ , matrices = [ S.Matrix::{ name = "bodies", recipe = "send-body", cases = [ S.MatrixCase::{ name = "one" }, S.MatrixCase::{ name = "two" } ] } ]+ , suites =+   [ S.Suite::{ name = "bodies", runs = [ S.RunReference.Recipe "send-body" ] }+   , S.Suite::{ name = "parameter", fileRootParameter = Some "payloadRoot", runs = [ S.RunReference.Recipe "send-body" ] }+   ]+ }
+ examples/file-body/request.hurl view
@@ -0,0 +1,4 @@+POST {{baseUrl}}/mutating+Content-Type: application/json+file,request.json;+HTTP 201
+ examples/generated-parameters/README.md view
@@ -0,0 +1,15 @@+# Generated parameters++With the fixture server on port 18080, run from the repository root:++```bash+hurl-workbench --workspace examples/generated-parameters/hurl-workbench.dhall test recipe recipient+hurl-workbench --workspace examples/generated-parameters/hurl-workbench.dhall test recipe recipient --variable recipientId=rcp_01h2xcejqtf2nbrexx3vqjhp41+hurl-workbench --workspace examples/generated-parameters/hurl-workbench.dhall render recipe recipient --explain+```++The manifest shows all five generators. Each execution resolves its own values;+its two fragments share the same recipient ID. Runtime variables and variable+files override generation. Generated inputs cannot have committed defaults,+recipe bindings, or matrix bindings. Matrix `recipients` and suite `recipients`+can run with `--jobs 2` against the read-only echo endpoint.
+ examples/generated-parameters/first.hurl view
@@ -0,0 +1,6 @@+GET {{baseUrl}}/odata/echo?id={{recipientId}}+HTTP 200+[Captures]+firstId: jsonpath "$.query"+[Asserts]+jsonpath "$.query" == "id={{recipientId}}"
+ examples/generated-parameters/hurl-workbench.dhall view
@@ -0,0 +1,19 @@+let S = ../../schema/package.dhall+let workflow = S.Workflow::{ name = "recipient", fragments = [ "first", "second" ], parameters = [ "baseUrl", "recipientId", "uuid4", "uuid7", "now", "random" ] }+let recipe = S.Recipe::{ name = "recipient", workflow = "recipient", safety = S.Safety.ReadOnly }+in S.Workspace::{+ , schemaVersion = S.schemaVersion+ , parameters =+   [ S.Parameter::{ name = "baseUrl", defaultValue = Some "http://127.0.0.1:18080" }+   , S.Parameter::{ name = "recipientId", kind = S.ParameterKind.Generated (S.Generator.TypeId { prefix = "rcp" }) }+   , S.Parameter::{ name = "uuid4", kind = S.ParameterKind.Generated S.Generator.Uuid4 }+   , S.Parameter::{ name = "uuid7", kind = S.ParameterKind.Generated S.Generator.Uuid7 }+   , S.Parameter::{ name = "now", kind = S.ParameterKind.Generated S.Generator.Rfc3339Now }+   , S.Parameter::{ name = "random", kind = S.ParameterKind.Generated (S.Generator.RandomAlphanumeric { length = 24 }) }+   ]+ , fragments = [ S.Fragment::{ name = "first", path = "first.hurl" }, S.Fragment::{ name = "second", path = "second.hurl" } ]+ , workflows = [ workflow ]+ , recipes = [ recipe, recipe // { name = "recipient-second" } ]+ , matrices = [ S.Matrix::{ name = "recipients", recipe = "recipient", cases = [ S.MatrixCase::{ name = "one" }, S.MatrixCase::{ name = "two" } ] } ]+ , suites = [ S.Suite::{ name = "recipients", runs = [ S.RunReference.Recipe "recipient", S.RunReference.Recipe "recipient-second" ] } ]+ }
+ examples/generated-parameters/second.hurl view
@@ -0,0 +1,5 @@+GET {{baseUrl}}/odata/echo?id={{recipientId}}+HTTP 200+[Asserts]+jsonpath "$.query" == "{{firstId}}"+jsonpath "$.query" == "id={{recipientId}}"
+ examples/integration-service/README.md view
@@ -0,0 +1,103 @@+# Managed integration-service example+++This workspace uses the repository fixture API to demonstrate the complete suite lifecycle. The+`default` suite contains only explicit read-only recipes, `writes` contains the opt-in mutating+recipe, and `perimeter` keeps a negative boundary check separate from ordinary functional tests.++From the repository root:++```bash+cabal run hurl-workbench -- \+  --workspace examples/integration-service/hurl-workbench.dhall \+  test suite default --report junit --report json --report-dir build/reports+```++The workbench starts the fixture server through Cabal, waits for `/health`, runs both safe recipes,+writes isolated reports plus `summary.json`, and terminates the service process group. The write+suite is refused unless authorization is explicit:++The stderr result has this shape (durations and absolute paths vary):++```text+PASS  health+PASS  read-echo+SERVICE stopped+SUMMARY .../build/reports/default/summary.json+```++```bash+cabal run hurl-workbench -- \+  --workspace examples/integration-service/hurl-workbench.dhall test suite writes++cabal run hurl-workbench -- \+  --workspace examples/integration-service/hurl-workbench.dhall \+  test suite writes --allow-mutating+```++To use an already-running fixture server, start `hurl-workbench-fixture-server` separately and add+`--external-service`. See [the integration-testing guide](../../docs/guides/integration-testing.md)+for report layout, generated variable files, and troubleshooting.++The `perimeter` suite is deliberately separate from normal functional checks:++```bash+cabal run hurl-workbench -- \+  --workspace examples/integration-service/hurl-workbench.dhall \+  test suite perimeter+```++The `clean` suite opts into explicit environment selection. Build the workbench and fixture,+then run:++```bash+export HURL_WORKBENCH_FIXTURE_EXECUTABLE="$(cabal list-bin hurl-workbench-fixture-server)"+export HURL_WORKBENCH_CURL_EXECUTABLE="$(command -v curl)"+PORT=1 cabal run hurl-workbench -- \+  --workspace examples/integration-service/hurl-workbench.dhall test suite clean+```++The service selects PORT and overrides it through the `service_port` binding; the fixture+asserts PORT is its only inherited variable. Readiness has its own empty clean policy. Neither+command implicitly receives PATH or HOME. Use absolute executable paths; select any additional+names explicitly. Missing selected names fail before service startup, even if overridden by a+binding. Other suites retain default inheritance. Child output and Hurl artifacts can still+contain data intentionally emitted by those programs.++For a caller-owned database lease, explicitly select its PG variables and protected PGPASSFILE+when needed; keep the password file until consumer cleanup and lease release finish.++## Independent scenarios++Shared-state writes use `--jobs 1 --allow-mutating`; requesting more jobs is refused even+with one expanded run. The read-only default suite can use `--jobs 2`.++`independent.dhall` declares an `IndependentState` suite with two mutating cases. A trusted+caller must provision one ready service and fixture per scenario, hold both until workbench+returns, and generate a private version-1 JSON handoff. Each assignment names `writes/first`+or `writes/second`, a distinct fixture ID, a distinct HTTP(S) endpoint origin, `baseUrl` as the+endpoint parameter, a plain `marker` binding, and a `token` secret-environment reference.+All channels are required, including empty lists when unused. See the+[integration guide](../../docs/guides/integration-testing.md#independent-scenario-handoff) for the full JSON contract.++After provisioning and generating `scenario-inputs.json` with mode `0600` in a private directory:++```bash+cabal run hurl-workbench -- \+  --workspace examples/integration-service/independent.dhall \+  test suite independent-writes --external-service \+  --scenario-inputs scenario-inputs.json --allow-mutating --jobs 2 \+  --report json --report-dir build/independent-reports+```++This fixture expects a POST/GET `/markers` service. The acceptance driver provisions two+fresh real Hinagata databases and one such service per database, then proves concurrent writes,+read isolation, refusal, fail-fast, cancellation, and caller-owned release:++```bash+bash scripts/check-scenario-isolation.sh+```++Run it in a development shell with PostgreSQL tools, built workbench and Hinagata executables,+and the provider's packaged SQL resources. The [integration guide](../../docs/guides/integration-testing.md#real-provider-acceptance)+gives the complete setup. Workbench never acquires or releases the databases.
+ examples/integration-service/hurl-workbench.dhall view
@@ -0,0 +1,117 @@+let Schema = ../../schema/package.dhall++in  Schema.Workspace::{+    , schemaVersion = Schema.schemaVersion+    , parameters =+      [ Schema.Parameter::{ name = "base_url", defaultValue = Some "http://127.0.0.1:18080" }+      , Schema.Parameter::{ name = "service_port", defaultValue = Some "18080" }+      ]+    , fragments =+      [ Schema.Fragment::{ name = "health", path = "hurl/health.hurl" }+      , Schema.Fragment::{ name = "read-echo", path = "hurl/read-echo.hurl" }+      , Schema.Fragment::{ name = "create", path = "hurl/create.hurl" }+      , Schema.Fragment::{ name = "perimeter", path = "hurl/perimeter.hurl" }+      ]+    , workflows =+      [ Schema.Workflow::{+        , name = "health"+        , fragments = [ "health" ]+        , parameters = [ "base_url" ]+        }+      , Schema.Workflow::{+        , name = "read-echo"+        , fragments = [ "read-echo" ]+        , parameters = [ "base_url" ]+        }+      , Schema.Workflow::{+        , name = "create"+        , fragments = [ "create" ]+        , parameters = [ "base_url" ]+        }+      , Schema.Workflow::{+        , name = "perimeter"+        , fragments = [ "perimeter" ]+        , parameters = [ "base_url" ]+        }+      ]+    , recipes =+      [ Schema.Recipe::{+        , name = "health"+        , workflow = "health"+        , safety = Schema.Safety.ReadOnly+        }+      , Schema.Recipe::{+        , name = "read-echo"+        , workflow = "read-echo"+        , safety = Schema.Safety.ReadOnly+        }+      , Schema.Recipe::{+        , name = "create"+        , workflow = "create"+        , safety = Schema.Safety.Mutating+        }+      , Schema.Recipe::{+        , name = "perimeter"+        , workflow = "perimeter"+        , safety = Schema.Safety.ReadOnly+        }+      ]+    , services =+      [ Schema.Service::{+        , name = "fixture-api"+        , command = Schema.CommandSpec::{+          , executable = "cabal"+          , arguments = [ "--project-dir=../..", "run", "hurl-workbench-fixture-server" ]+          , environment =+            [ Schema.EnvironmentBinding::{ variable = "PORT", parameter = "service_port" } ]+          }+        , readiness =+            Schema.Readiness.Http+              Schema.HttpReadiness::{ url = "http://127.0.0.1:{{service_port}}/health" }+        }+      , Schema.Service::{+        , name = "clean-fixture-api"+        , command = Schema.CommandSpec::{+          , executable = env:HURL_WORKBENCH_FIXTURE_EXECUTABLE as Text ? "hurl-workbench-fixture-server"+          , arguments = [ "--assert-clean" ]+          , environmentPolicy = Schema.EnvironmentPolicy.Clean [ "PORT" ]+          , environment = [ Schema.EnvironmentBinding::{ variable = "PORT", parameter = "service_port" } ]+          }+        , readiness = Schema.Readiness.Command Schema.CommandReadiness::{+          , command = Schema.CommandSpec::{+            , executable = env:HURL_WORKBENCH_CURL_EXECUTABLE as Text ? "curl"+            , arguments = [ "--fail", "--silent", "http://127.0.0.1:18080/health" ]+            , environmentPolicy = Schema.EnvironmentPolicy.Clean ([] : List Text)+            }+          }+        }++      ]+    , suites =+      [ Schema.Suite::{+        , name = "default"+        , isolation = Schema.IsolationPolicy.SharedState+        , runs =+          [ Schema.RunReference.Recipe "health"+          , Schema.RunReference.Recipe "read-echo"+          ]+        , service = Some "fixture-api"+        , failFast = True+        }+      , Schema.Suite::{+        , name = "writes"+        , isolation = Schema.IsolationPolicy.SharedState+        , runs = [ Schema.RunReference.Recipe "create" ]+        , service = Some "fixture-api"+        , failFast = True+        }+      , Schema.Suite::{+        , name = "perimeter"+        , runs = [ Schema.RunReference.Recipe "perimeter" ]+        , service = Some "fixture-api"+        , failFast = True+        }+      , Schema.Suite::{ name = "clean", runs = [ Schema.RunReference.Recipe "health" ], service = Some "clean-fixture-api" }++      ]+    }
+ examples/integration-service/hurl/create.hurl view
@@ -0,0 +1,9 @@+POST {{base_url}}/mutating+Content-Type: application/json+{+  "fixture": "write"+}+HTTP 201+[Asserts]+header "Content-Type" contains "application/octet-stream"+body contains "\"fixture\": \"write\""
+ examples/integration-service/hurl/health.hurl view
@@ -0,0 +1,5 @@+GET {{base_url}}/health+HTTP 200+[Asserts]+header "Content-Type" contains "text/plain"+body contains "ok"
+ examples/integration-service/hurl/isolated-write.hurl view
@@ -0,0 +1,14 @@+POST {{baseUrl}}/markers+Content-Type: application/json+X-Test-Token: {{token}}+{+  "marker": "{{marker}}"+}+HTTP 201++GET {{baseUrl}}/markers+HTTP 200+[Asserts]+header "Content-Type" contains "application/json"+jsonpath "$.marker" == "{{marker}}"+jsonpath "$.count" == 1
+ examples/integration-service/hurl/perimeter.hurl view
@@ -0,0 +1,5 @@+GET {{base_url}}/not-found+HTTP 404+[Asserts]+header "Content-Type" contains "text/plain"+body contains "not found"
+ examples/integration-service/hurl/read-echo.hurl view
@@ -0,0 +1,5 @@+GET {{base_url}}/odata/echo?scope=default-safe+HTTP 200+[Asserts]+header "Content-Type" contains "application/json"+jsonpath "$.query" == "scope=default-safe"
+ examples/integration-service/independent.dhall view
@@ -0,0 +1,19 @@+let S = ../../schema/package.dhall++in  S.Workspace::{+    , schemaVersion = 1+    , parameters =+      [ S.Parameter::{ name = "baseUrl" }+      , S.Parameter::{ name = "marker" }+      , S.Parameter::{ name = "token", kind = S.ParameterKind.Secret }+      ]+    , fragments = [ S.Fragment::{ name = "write", path = "hurl/isolated-write.hurl" } ]+    , workflows =+      [ S.Workflow::{ name = "write", fragments = [ "write" ], parameters = [ "baseUrl", "marker", "token" ] } ]+    , recipes =+      [ S.Recipe::{ name = "write", workflow = "write", safety = S.Safety.Mutating } ]+    , matrices =+      [ S.Matrix::{ name = "writes", recipe = "write", cases = [ S.MatrixCase::{ name = "first" }, S.MatrixCase::{ name = "second" } ] } ]+    , suites =+      [ S.Suite::{ name = "independent-writes", runs = [ S.RunReference.Matrix "writes" ], isolation = S.IsolationPolicy.IndependentState } ]+    }
+ examples/multi-service/README.md view
@@ -0,0 +1,52 @@+# Ordered services and worker restart++Run `nix develop -c just examples` from the repository root. The multi-service+check uses the built fixture executable, private temporary state, and three+available loopback ports. It starts a stub, API, and projection worker, waiting+for each readiness check before starting the next service.++The `command-read` suite runs at jobs=1 with `--allow-mutating`. It submits exactly+one POST, polls GET until the worker has made that command visible, restarts the+worker, then observes the second worker generation through GET. The POST has no+retry option. The separate `read-barriers` suite uses pre-existing accepted state+and jobs=2; its two read-only batches are separated by the restart barrier.+Parallel mutation is refused even with mutation permission.++`launch.py` passes suite-level environment bindings to the fixture executable;+it also implements setup/teardown markers. Teardown checks every recorded PID is+gone. The caller owns temporary state. Workbench owns child process groups and+stops services in reverse acquisition order before teardown. A restarted service+gets the latest acquisition position. Commands and readiness use explicit Clean+environments. The summary contains logical names, statuses, durations and relative+artifact references; phase stdout/stderr remain sensitive, owner-only files.++These roles prove orchestration without a database. They do not prove actual+application projection behavior or lease release. The lease provider is+`mori://shinzui/hinagata`; the production consumer requirement is+`mori://shinzui/rei/masterplans/29-close-rei-cli-and-http-api-gaps-on-the-haskell-jitsurei-contract`.+Rei exposes `rei worker query-projections` for its production Note/assignment/+definition owners, alongside the production `rei-api` executable. Its live-test+executable has `--hinagata-schema` and `--hinagata-verify` hooks for isolated+baselines. The optional [production Rei consumer](rei/README.md) and its separate caller harness provide real application/lease acceptance.++The opt-in `scripts/check-multi-service-lease.sh` runs **inside an already active+caller lease**. Supply absolute `HURL_WORKBENCH_CONSUMER_WORKSPACE`, the+`HURL_WORKBENCH_CONSUMER_SUITE` name, built `HURL_WORKBENCH_EXECUTABLE`, and a new+absolute `HURL_WORKBENCH_CONSUMER_EVIDENCE_ROOT`. An optional+`HURL_WORKBENCH_CONSUMER_VARIABLES_FILE` carries ordinary runtime bindings; secret+bindings use declared environment channels. The driver never acquires or releases+a database.++The consumer manifest must consume `HURL_WORKBENCH_CONSUMER_VARIANT` (`success`,+`readiness-failure`, `unexpected-exit`, `cancellation`) and the private+`HURL_WORKBENCH_CONSUMER_EVIDENCE_DIR`. Each variant writes `pids.json` as an array+of all owned leader/descendant PIDs and appends complete JSON lines to+`events.jsonl`. Success records one `{"event":"mutation"}`, one or more+`{"event":"observation","owner":"LOGICAL-PRODUCTION-OWNER"}`, and a successful+worker restart in the workbench summary. Cancellation records+`{"event":"cancel-ready"}` only while execution is active. Teardown's last event+is `{"event":"consumers-stopped"}` after confirming the real API/stub/owner have+stopped. The driver rejects jobs=2 mutation before setup, checks every PID and preserves private reports. The caller+then releases its lease and records provider evidence proving release followed+consumer shutdown. Use fresh isolated fixtures for each retry; teardown must+handle partially completed setup.
+ examples/multi-service/hurl-workbench.dhall view
@@ -0,0 +1,26 @@+let S = ../../schema/package.dhall+let parameter = \(name : Text) -> \(environment : Text) -> S.Parameter::{ name = name, environment = Some environment }+let binding = \(variable : Text) -> \(parameter : Text) -> S.EnvironmentBinding::{ variable = variable, parameter = parameter }+let command = \(role : Text) -> \(port : Text) -> S.CommandSpec::{+    executable = "/usr/bin/env"+  , arguments = [ "python3", "launch.py", role ]+  , workingDirectory = None Text+  , environmentPolicy = S.EnvironmentPolicy.Clean [ "PATH" ]+  , environment = [ binding "FIXTURE_EXECUTABLE" "fixtureExecutable", binding "STATE_DIR" "stateDir", binding "ROLE_PORT" port ]+  }+let service = \(role : Text) -> \(port : Text) -> \(url : Text) -> S.Service::{+    name = role+  , command = command role port+  , readiness = S.Readiness.Http S.HttpReadiness::{ url = url, intervalMilliseconds = 10, timeoutSeconds = 5 }+  , shutdownTimeoutSeconds = 2+  }+let recipe = \(name : Text) -> \(workflow : Text) -> S.Recipe::{ name = name, workflow = workflow, safety = S.Safety.ReadOnly }+in S.Workspace::{+    schemaVersion = S.schemaVersion+  , parameters = [ parameter "fixtureExecutable" "HURL_WORKBENCH_FIXTURE_EXECUTABLE", parameter "stateDir" "HURL_WORKBENCH_STATE_DIR", parameter "apiPort" "HURL_WORKBENCH_API_PORT", parameter "stubPort" "HURL_WORKBENCH_STUB_PORT", parameter "workerPort" "HURL_WORKBENCH_WORKER_PORT" ]+  , fragments = [ S.Fragment::{ name = "command", path = "hurl/command.hurl" }, S.Fragment::{ name = "before", path = "hurl/before.hurl" }, S.Fragment::{ name = "after", path = "hurl/after.hurl" } ]+  , workflows = [ S.Workflow::{ name = "command", fragments = [ "command" ], parameters = [ "apiPort" ] }, S.Workflow::{ name = "before", fragments = [ "before" ], parameters = [ "apiPort" ] }, S.Workflow::{ name = "after", fragments = [ "after" ], parameters = [ "apiPort" ] } ]+  , recipes = [ S.Recipe::{ name = "submit", workflow = "command", safety = S.Safety.Mutating }, recipe "before" "before", recipe "after" "after", recipe "read1" "before", recipe "read2" "before", recipe "read3" "after", recipe "read4" "after" ]+  , services = [ service "stub" "stubPort" "http://127.0.0.1:{{stubPort}}/health", service "api" "apiPort" "http://127.0.0.1:{{apiPort}}/health", service "worker" "workerPort" "http://127.0.0.1:{{workerPort}}/health" ]+  , suites = [ S.Suite::{ name = "command-read", services = [ "stub", "api", "worker" ], setup = [ command "setup" "apiPort" ], teardown = [ command "teardown" "apiPort" ], runs = [ S.RunReference.Recipe "submit", S.RunReference.Recipe "before", S.RunReference.Service (S.ServiceStep.Restart "worker"), S.RunReference.Recipe "after" ] }, S.Suite::{ name = "read-barriers", services = [ "stub", "api", "worker" ], setup = [ command "setup" "apiPort" ], teardown = [ command "teardown" "apiPort" ], runs = [ S.RunReference.Recipe "read1", S.RunReference.Recipe "read2", S.RunReference.Service (S.ServiceStep.Restart "worker"), S.RunReference.Recipe "read3", S.RunReference.Recipe "read4" ] } ]+  }
+ examples/multi-service/hurl/after.hurl view
@@ -0,0 +1,7 @@+GET http://127.0.0.1:{{apiPort}}/read+[Options]+retry: 100+retry-interval: 10ms+HTTP 200+[Asserts]+body == "visible:2"
+ examples/multi-service/hurl/before.hurl view
@@ -0,0 +1,7 @@+GET http://127.0.0.1:{{apiPort}}/read+[Options]+retry: 100+retry-interval: 10ms+HTTP 200+[Asserts]+body == "visible:1"
+ examples/multi-service/hurl/command.hurl view
@@ -0,0 +1,4 @@+POST http://127.0.0.1:{{apiPort}}/command+HTTP 201+[Asserts]+body == "accepted"
+ examples/multi-service/launch.py view
@@ -0,0 +1,21 @@+#!/usr/bin/env python3+"""Pass resolved environment values to the test fixture executable."""+import os+import sys++role = sys.argv[1]+if role in ("setup", "teardown"):+    from pathlib import Path+    root = Path(os.environ["STATE_DIR"])+    if role == "teardown" and (root / "pids").exists():+        for pid in map(int, (root / "pids").read_text().splitlines()):+            try:+                os.kill(pid, 0)+            except ProcessLookupError:+                continue+            raise RuntimeError("owned consumer survived before teardown")+    with (root / "events").open("a") as events:+        events.write(role + "\n")+    raise SystemExit(0)++os.execv(os.environ["FIXTURE_EXECUTABLE"], [os.environ["FIXTURE_EXECUTABLE"], "--role", sys.argv[1], "--port", os.environ["ROLE_PORT"], "--state-dir", os.environ["STATE_DIR"]])
+ examples/multi-service/rei/README.md view
@@ -0,0 +1,47 @@+# Production Rei consumer inside a Hinagata lease++This opt-in fixture uses the actual `rei-api` application and+`rei worker query-projections` Note owner from `mori://shinzui/rei`, with a local+HTTP stub/proxy for deterministic request recording. It implements the contract of+`scripts/check-multi-service-lease.sh`. The generic driver accepts an already+leased fixture and never allocates or releases a database.++The separate caller `scripts/check-rei-multi-service.py` starts its own disposable+SCRAM-authenticated PostgreSQL cluster, asks `mori://shinzui/hinagata` to prepare a+baseline using Rei's existing `rei-api-live-test --hinagata-schema` and+`--hinagata-verify` hooks, then acquires one clone. A static Topic read row is an+independent anchor fixture. The Note under observation is created only by one+production POST and appears only through the production asynchronous query owner;+no Note projection rows or runtime tables are seeded by this harness.++Success polls GET on the Note tag, restarts the production worker and observes it+again. Other variants fail worker readiness, terminate the worker during active+HTTP work, and interrupt a held request. Every adapter and child PID is recorded,+reverse cleanup precedes teardown, and the caller verifies Hinagata reports+`Released` only after all four consumer scopes have stopped. Private timestamps+corroborate that order. Parallel mutation is refused before setup, and every+adapter asserts Clean policy excluded ambient database/service configuration.++Build matching API, CLI and schema-hook executables from the same Rei cohort.+Discover repositories with Mori; do not point these hooks at a developer database.+From the workbench repository, after building the workbench and provider, run:++```bash+export hinagata_postgres_datadir="$(mori path mori://shinzui/hinagata)/hinagata-postgres"+nix develop -c python3 scripts/check-rei-multi-service.py \+  --api "$REI_API_EXECUTABLE" --rei "$REI_CLI_EXECUTABLE" \+  --schema-hook "$REI_LIVE_TEST_EXECUTABLE" --hinagata "$HINAGATA_EXECUTABLE" \+  --workbench "$HURL_WORKBENCH_EXECUTABLE" --evidence-root /tmp/new-rei-lifecycle-evidence+```++Each option is an absolute built executable path. `cabal list-bin` in the owning+repository's development shell supplies those paths (`rei-api:exe:rei-api`,+`rei-cli:exe:rei`, `rei-api:test:rei-api-live-test`, and `hinagata`). The evidence root+must be new. Reports, capture files and caller diagnostics are private; retain+only sanitized aggregate evidence in durable documents.++Acceptance was exercised with the Rei 8.0.0.0 binary cohort (CLI build revision+`8c5e112`), discovered under `mori://shinzui/rei`. The current 9.0.0.0 checkout's live+schema hook has a generated Note-symbol linker failure (FRIC-7 in the workbench+friction log); this fixture makes no Rei 9 acceptance claim. No consumer source,+production database or lease configuration is modified.
+ examples/multi-service/rei/after.hurl view
@@ -0,0 +1,7 @@+GET http://127.0.0.1:{{stubPort}}/after+[Options]+retry: 100+retry-interval: 50ms+HTTP 200+[Asserts]+jsonpath "$.edges" count == 1
+ examples/multi-service/rei/hurl-workbench.dhall view
@@ -0,0 +1,12 @@+let S = ../../../schema/package.dhall+let p = \(name : Text) -> \(env : Text) -> S.Parameter::{ name = name, environment = Some env }+let b = \(variable : Text) -> \(parameter : Text) -> S.EnvironmentBinding::{ variable = variable, parameter = parameter }+let command = \(role : Text) -> S.CommandSpec::{ executable = "/usr/bin/env", arguments = [ "python3", "launch.py", role ], environmentPolicy = S.EnvironmentPolicy.Clean [ "PATH" ], environment = [ b "EVIDENCE" "evidence", b "VARIANT" "variant", b "DB" "database", b "TOKEN" "token", b "API" "api", b "REI" "rei", b "API_PORT" "apiPort", b "STUB_PORT" "stubPort" ] }+let http = \(url : Text) -> S.Readiness.Http S.HttpReadiness::{ url = url, intervalMilliseconds = 20, timeoutSeconds = 20 }+in S.Workspace::{ schemaVersion = 1+, parameters = [ p "evidence" "HURL_WORKBENCH_CONSUMER_EVIDENCE_DIR", p "variant" "HURL_WORKBENCH_CONSUMER_VARIANT", p "api" "REI_LIFECYCLE_API", p "rei" "REI_LIFECYCLE_CLI", p "apiPort" "REI_LIFECYCLE_API_PORT", p "stubPort" "REI_LIFECYCLE_STUB_PORT", p "topic" "REI_LIFECYCLE_TOPIC", S.Parameter::{ name = "database", kind = S.ParameterKind.Secret, environment = Some "REI_LIFECYCLE_DB" }, S.Parameter::{ name = "token", kind = S.ParameterKind.Secret, environment = Some "REI_LIFECYCLE_TOKEN" } ]+, fragments = [ S.Fragment::{ name = "submit", path = "submit.hurl" }, S.Fragment::{ name = "after", path = "after.hurl" } ]+, workflows = [ S.Workflow::{ name = "submit", fragments = [ "submit" ], parameters = [ "stubPort", "topic", "variant", "token" ] }, S.Workflow::{ name = "after", fragments = [ "after" ], parameters = [ "stubPort" ] } ]+, recipes = [ S.Recipe::{ name = "submit", workflow = "submit", safety = S.Safety.Mutating }, S.Recipe::{ name = "after", workflow = "after", safety = S.Safety.ReadOnly } ]+, services = [ S.Service::{ name = "stub", command = command "stub", readiness = http "http://127.0.0.1:{{stubPort}}/health", shutdownTimeoutSeconds = 8 }, S.Service::{ name = "api", command = command "api", readiness = http "http://127.0.0.1:{{apiPort}}/health/ready", shutdownTimeoutSeconds = 8 }, S.Service::{ name = "worker", command = command "worker", readiness = S.Readiness.Command S.CommandReadiness::{ command = command "probe-worker", intervalMilliseconds = 20, timeoutSeconds = 3 }, shutdownTimeoutSeconds = 8 } ]+, suites = [ S.Suite::{ name = "production", services = [ "stub", "api", "worker" ], setup = [ command "setup" ], teardown = [ command "teardown" ], runs = [ S.RunReference.Recipe "submit", S.RunReference.Service (S.ServiceStep.Restart "worker"), S.RunReference.Recipe "after" ] } ] }
+ examples/multi-service/rei/launch.py view
@@ -0,0 +1,169 @@+#!/usr/bin/env python3+"""Adapters for real Rei executables; database allocation belongs to the caller."""+import http.client+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer+import json+import os+from pathlib import Path+import pty+import select+import signal+import subprocess+import sys+import time++assert not any(name in os.environ for name in ["REI_PG_CONNECTION_STRING", "MORI_API_URL", "PGHOST", "PGDATABASE", "PGPASSFILE"]), "managed command inherited ambient database configuration"+root = Path(os.environ["EVIDENCE"])+variant = os.environ["VARIANT"]+role = sys.argv[1]+++def event(name, **fields):+    data = (json.dumps(dict(event=name, at=time.time_ns(), **fields)) + "\n").encode()+    fd = os.open(root / "events.jsonl", os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)+    try:+        os.write(fd, data)+    finally:+        os.close(fd)+++def pid(value):+    path = root / "pids.json"+    values = json.loads(path.read_text()) if path.exists() else []+    path.write_text(json.dumps(values + [value]))+    path.chmod(0o600)+++def environment():+    return {"PATH": os.environ["PATH"], "HOME": str(root / "home"),+            "REI_CONFIG": str(root / "rei.yaml"), "REI_PG_CONNECTION_STRING": os.environ["DB"],+            "REI_API_PORT": os.environ["API_PORT"], "REI_API_TOKENS": os.environ["TOKEN"] + "=human",+            "MORI_API_URL": "http://127.0.0.1:" + os.environ["STUB_PORT"], "OTEL_SDK_DISABLED": "true"}+++if role == "setup":+    (root / "home").mkdir(mode=0o700)+    (root / "workspace").mkdir(mode=0o700)+    (root / "rei.yaml").write_text("workspace:\n  path: " + str(root / "workspace") + "\n")+    (root / "rei.yaml").chmod(0o600)+    event("setup")+    raise SystemExit(0)+if role == "teardown":+    for value in json.loads((root / "pids.json").read_text()) if (root / "pids.json").exists() else []:+        try:+            os.kill(value, 0)+        except ProcessLookupError:+            continue+        raise RuntimeError("owned process survived cleanup")+    event("consumers-stopped")+    raise SystemExit(0)+if role == "probe-worker":+    raise SystemExit(0 if variant != "readiness-failure" and (root / "worker-ready").exists() else 1)++pid(os.getpid())+event("start", service=role)+if role == "stub":+    class Proxy(BaseHTTPRequestHandler):+        def log_message(self, *_):+            pass++        def request(self):+            if self.path == "/health":+                self.send_response(200)+                self.end_headers()+                self.wfile.write(b"ok")+                return+            if self.path == "/after" and variant == "unexpected-exit":+                time.sleep(2)+            if self.path == "/after" and variant == "cancellation":+                event("cancel-ready")+                time.sleep(60)+                return+            if self.path == "/after":+                self.path = "/api/v2/notes?tag=workbench-" + variant + "&first=10"+            body = self.rfile.read(int(self.headers.get("Content-Length", "0")))+            connection = http.client.HTTPConnection("127.0.0.1", int(os.environ["API_PORT"]), timeout=15)+            headers = {key: value for key, value in self.headers.items() if key.lower() not in ("host", "content-length")}+            connection.request(self.command, self.path, body=body, headers=headers)+            response = connection.getresponse()+            data = response.read()+            if self.command == "POST" and response.status == 201:+                event("mutation")+            if self.command == "GET" and response.status == 200:+                parsed = json.loads(data)+                if parsed.get("edges"):+                    event("observation", owner="rei-note-query-projection")+                    if variant == "unexpected-exit":+                        (root / "worker-exit").touch()+            self.send_response(response.status)+            self.send_header("Content-Type", "application/json")+            self.send_header("Content-Length", str(len(data)))+            self.end_headers()+            self.wfile.write(data)+            connection.close()++        do_GET = request+        do_POST = request++    server = ThreadingHTTPServer(("127.0.0.1", int(os.environ["STUB_PORT"])), Proxy)+    server.daemon_threads = True+    signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))+    try:+        server.serve_forever(poll_interval=0.02)+    finally:+        server.server_close()+        event("stop", service=role)+    raise SystemExit(0)++argv = [os.environ["API"]] if role == "api" else [os.environ["REI"], "worker", "query-projections"]+master = slave = None+with (root / (role + ".log")).open("wb") as output:+    (root / (role + ".log")).chmod(0o600)+    if role == "worker":+        master, slave = pty.openpty()+    child = subprocess.Popen(argv, env=environment(), stdin=subprocess.DEVNULL,+                             stdout=slave if slave is not None else output, stderr=subprocess.STDOUT)+    pid(child.pid)+    if slave is not None:+        os.close(slave)+    stopping = False++    def stop(*_):+        global stopping+        stopping = True+        if child.poll() is None:+            child.terminate()++    signal.signal(signal.SIGTERM, stop)+    try:+        buffer = b""+        death_at = None+        while child.poll() is None:+            if master is not None and select.select([master], [], [], 0.02)[0]:+                try:+                    data = os.read(master, 65536)+                except OSError:+                    data = b""+                output.write(data)+                output.flush()+                buffer = (buffer + data)[-65536:]+                if b"Starting Note, assignment and definition query projection workers." in buffer:+                    (root / "worker-ready").touch()+            else:+                time.sleep(0.02)+            if role == "worker" and (root / "worker-exit").exists():+                death_at = death_at or time.monotonic() + 0.15+                if time.monotonic() >= death_at:+                    child.terminate()+                    child.wait(timeout=4)+                    raise SystemExit(9)+        raise SystemExit(0 if stopping else child.returncode)+    finally:+        if child.poll() is None:+            child.terminate()+        child.wait(timeout=4)+        if master is not None:+            os.close(master)+        if role == "worker":+            (root / "worker-ready").unlink(missing_ok=True)+        event("stop", service=role)
+ examples/multi-service/rei/submit.hurl view
@@ -0,0 +1,12 @@+POST http://127.0.0.1:{{stubPort}}/api/v2/notes+Authorization: Bearer {{token}}+{"anchor":{"target":{"type":"topic","id":"{{topic}}"},"parent":null},"format":"markdown","content":"# Workbench lifecycle\n","tags":["workbench-{{variant}}"]}+HTTP 201++GET http://127.0.0.1:{{stubPort}}/api/v2/notes?tag=workbench-{{variant}}&first=10+[Options]+retry: 100+retry-interval: 50ms+HTTP 200+[Asserts]+jsonpath "$.edges" count == 1
+ examples/quick-start/health.hurl view
@@ -0,0 +1,6 @@+GET {{base_url}}/health+Authorization: Bearer {{token}}+HTTP 200+[Asserts]+header "Content-Type" contains "text/plain"+body contains "ok"
+ examples/quick-start/hurl-workbench.dhall view
@@ -0,0 +1,21 @@+let Schema = ../../schema/package.dhall++in  Schema.Workspace::{+    , schemaVersion = Schema.schemaVersion+    , parameters =+      [ Schema.Parameter::{ name = "base_url", defaultValue = Some "http://127.0.0.1:18080" }+      , Schema.Parameter::{+        , name = "token"+        , kind = Schema.ParameterKind.Secret+        , environment = Some "QUICKSTART_TOKEN"+        }+      ]+    , fragments = [ Schema.Fragment::{ name = "health", path = "health.hurl" } ]+    , workflows =+      [ Schema.Workflow::{+        , name = "health"+        , fragments = [ "health" ]+        , parameters = [ "base_url", "token" ]+        }+      ]+    }
+ examples/vendor-odata/README.md view
@@ -0,0 +1,53 @@+# Vendor OData exploration example++This workspace mirrors a common third-party API investigation without containing vendor+credentials, hostnames, schemas, or response data. One OAuth client-credentials fragment feeds one+reusable OData-shaped query workflow. Recipes hold resource-family defaults, and the+`property-by-mls` matrix changes only market-specific data. The MLSPIN case intentionally uses a+sold-status filter instead of the generic closed-status filter.++The example targets the repository's local fixture server. From the repository root, start it:++```bash+cabal run hurl-workbench-fixture-server -- --port 18080+```++In another shell, inspect and execute the workspace:++```bash+cabal run hurl-workbench -- --workspace examples/vendor-odata/hurl-workbench.dhall validate+cabal run hurl-workbench -- --workspace examples/vendor-odata/hurl-workbench.dhall render recipe properties --explain+VENDOR_ODATA_CLIENT_SECRET=fixture-secret cabal run hurl-workbench -- \+  --workspace examples/vendor-odata/hurl-workbench.dhall \+  matrix property-by-mls --mode run --jobs 2 --output-dir build/vendor-odata \+  --variable baseUrl=http://127.0.0.1:18080+```++Responses are written beneath `build/vendor-odata/property-by-mls/` with owner-only permissions.+They are not guaranteed to be redacted; a remote service can echo secrets in a response body.+The matrix prints ordered summaries like:++```text+PASS  property-by-mls/northwest -> .../property-by-mls/northwest.response+PASS  property-by-mls/metro -> .../property-by-mls/metro.response+PASS  property-by-mls/mlspin-sold -> .../property-by-mls/mlspin-sold.response+```++`co-buyer-investigation` retains a custom capture/assertion chain, while+`raw-decoding-investigation` preserves an opaque response used to diagnose below a typed client.+They remain ordinary native Hurl workflows because turning one-off diagnostic semantics into schema+would make both the workbench and the investigation harder to review.++Run those special workflows directly against the same fixture service:++```bash+VENDOR_ODATA_CLIENT_SECRET=fixture-secret cabal run hurl-workbench -- \+  --workspace examples/vendor-odata/hurl-workbench.dhall \+  test workflow co-buyer-investigation \+  --variable baseUrl=http://127.0.0.1:18080 --variable clientId=fixture-client++cabal run hurl-workbench -- \+  --workspace examples/vendor-odata/hurl-workbench.dhall \+  test workflow raw-decoding-investigation \+  --variable baseUrl=http://127.0.0.1:18080+```
+ examples/vendor-odata/fragments/co-buyer-capture.hurl view
@@ -0,0 +1,14 @@+# A bespoke two-entry investigation is clearer as native Hurl than as matrix data.+GET {{baseUrl}}/capture+Authorization: Bearer {{accessToken}}+HTTP 200+[Captures]+captureMarker: header "X-Fixture-Capture"+[Asserts]+body contains "capture-body"++GET {{baseUrl}}/odata/echo?scenario=co-buyer&marker={{captureMarker}}+Authorization: Bearer {{accessToken}}+HTTP 200+[Asserts]+jsonpath "$.query" contains "marker=captured-value"
+ examples/vendor-odata/fragments/oauth-client-credentials.hurl view
@@ -0,0 +1,12 @@+# Shared authentication entry. The captured token remains in this Hurl session.+POST {{baseUrl}}/oauth/token+[FormParams]+grant_type: client_credentials+client_id: {{clientId}}+client_secret: {{clientSecret}}+HTTP 200+[Captures]+accessToken: jsonpath "$.access_token"+[Asserts]+header "Content-Type" contains "application/json"+jsonpath "$.token_type" == "Bearer"
+ examples/vendor-odata/fragments/odata-query.hurl view
@@ -0,0 +1,8 @@+# One reusable OData-shaped query entry. Query values come from recipes and cases.+GET {{baseUrl}}/odata/echo?mls={{mls}}&resource={{resource}}&filter={{filter}}&select={{select}}&orderby={{orderby}}&top={{top}}+Authorization: Bearer {{accessToken}}+HTTP 200+[Asserts]+header "Content-Type" contains "application/json"+jsonpath "$.query" contains "mls="+jsonpath "$.query" contains "resource="
+ examples/vendor-odata/fragments/raw-decoding-response.hurl view
@@ -0,0 +1,6 @@+# Preserve a response whose bytes deliberately do not fit a typed JSON model.+GET {{baseUrl}}/raw-response+HTTP 200+[Asserts]+header "Content-Type" contains "application/octet-stream"+header "X-Raw" == "yes"
+ examples/vendor-odata/hurl-workbench.dhall view
@@ -0,0 +1,111 @@+let Schema = ../../schema/package.dhall++let authParameters = [ "baseUrl", "clientId", "clientSecret" ]++let queryParameters = [ "mls", "resource", "filter", "select", "orderby", "top" ]++in  Schema.Workspace::{+    , schemaVersion = Schema.schemaVersion+    , parameters =+      [ Schema.Parameter::{ name = "baseUrl" }+      , Schema.Parameter::{ name = "clientId", defaultValue = Some "fixture-client" }+      , Schema.Parameter::{+        , name = "clientSecret"+        , kind = Schema.ParameterKind.Secret+        , environment = Some "VENDOR_ODATA_CLIENT_SECRET"+        }+      , Schema.Parameter::{ name = "mls" }+      , Schema.Parameter::{ name = "resource" }+      , Schema.Parameter::{ name = "filter" }+      , Schema.Parameter::{ name = "select" }+      , Schema.Parameter::{ name = "orderby" }+      , Schema.Parameter::{ name = "top", defaultValue = Some "10" }+      ]+    , fragments =+      [ Schema.Fragment::{+        , name = "oauth-client-credentials"+        , path = "fragments/oauth-client-credentials.hurl"+        }+      , Schema.Fragment::{ name = "odata-query", path = "fragments/odata-query.hurl" }+      , Schema.Fragment::{ name = "co-buyer-capture", path = "fragments/co-buyer-capture.hurl" }+      , Schema.Fragment::{ name = "raw-decoding-response", path = "fragments/raw-decoding-response.hurl" }+      ]+    , workflows =+      [ Schema.Workflow::{+        , name = "odata-query"+        , fragments = [ "oauth-client-credentials", "odata-query" ]+        , parameters = authParameters # queryParameters+        }+      , Schema.Workflow::{+        , name = "co-buyer-investigation"+        , fragments = [ "oauth-client-credentials", "co-buyer-capture" ]+        , parameters = authParameters+        }+      , Schema.Workflow::{+        , name = "raw-decoding-investigation"+        , fragments = [ "raw-decoding-response" ]+        , parameters = [ "baseUrl" ]+        }+      ]+    , recipes =+      [ Schema.Recipe::{+        , name = "properties"+        , workflow = "odata-query"+        , bindings =+          [ Schema.Binding::{ parameter = "mls", value = "NORTHWEST" }+          , Schema.Binding::{ parameter = "resource", value = "Property" }+          , Schema.Binding::{ parameter = "filter", value = "Status%20eq%20Closed" }+          , Schema.Binding::{ parameter = "select", value = "ListingKey,ListPrice,Status" }+          , Schema.Binding::{ parameter = "orderby", value = "ModificationTimestamp%20desc" }+          ]+        , safety = Schema.Safety.ReadOnly+        }+      , Schema.Recipe::{+        , name = "members"+        , workflow = "odata-query"+        , bindings =+          [ Schema.Binding::{ parameter = "mls", value = "NORTHWEST" }+          , Schema.Binding::{ parameter = "resource", value = "Member" }+          , Schema.Binding::{ parameter = "filter", value = "MemberStatus%20eq%20Active" }+          , Schema.Binding::{ parameter = "select", value = "MemberKey,MemberFullName" }+          , Schema.Binding::{ parameter = "orderby", value = "MemberFullName" }+          ]+        , safety = Schema.Safety.ReadOnly+        }+      , Schema.Recipe::{+        , name = "offices"+        , workflow = "odata-query"+        , bindings =+          [ Schema.Binding::{ parameter = "mls", value = "NORTHWEST" }+          , Schema.Binding::{ parameter = "resource", value = "Office" }+          , Schema.Binding::{ parameter = "filter", value = "OfficeStatus%20eq%20Active" }+          , Schema.Binding::{ parameter = "select", value = "OfficeKey,OfficeName" }+          , Schema.Binding::{ parameter = "orderby", value = "OfficeName" }+          ]+        , safety = Schema.Safety.ReadOnly+        }+      ]+    , matrices =+      [ Schema.Matrix::{+        , name = "property-by-mls"+        , recipe = "properties"+        , cases =+          [ Schema.MatrixCase::{+            , name = "northwest"+            , bindings = [ Schema.Binding::{ parameter = "mls", value = "NORTHWEST" } ]+            }+          , Schema.MatrixCase::{+            , name = "metro"+            , bindings = [ Schema.Binding::{ parameter = "mls", value = "METRO" } ]+            }+          , Schema.MatrixCase::{+            , name = "mlspin-sold"+            , bindings =+              [ Schema.Binding::{ parameter = "mls", value = "MLSPIN" }+              , Schema.Binding::{ parameter = "filter", value = "Status%20eq%20Sold" }+              ]+            }+          ]+        }+      ]+    }
+ hurl-workbench-cli.cabal view
@@ -0,0 +1,163 @@+cabal-version: 3.4+name: hurl-workbench-cli+version: 0.1.0.0+synopsis: Command-line interface for hurl-workbench+description:+  A Haskell-powered Hurl workbench for composing, exploring, executing, and+  testing reusable API workflows without duplicating request templates.+  .+  This package provides the @hurl-workbench@ executable (validate, list,+  render, run, test, matrix, doctor, and shell completions) for recipes,+  matrices, and integration suites, built on @hurl-workbench-core@, together+  with runnable example workspaces.++category: Web, Testing+license: BSD-3-Clause+license-file: LICENSE+author: Nadeem Bitar+maintainer: nadeem@gmail.com+copyright: (c) 2026 Nadeem Bitar+build-type: Simple+extra-doc-files:+  CHANGELOG.md+  README.md++extra-source-files:+  examples/**/*.dhall+  examples/**/*.hurl+  examples/**/*.md+  examples/**/*.py+  schema/*.dhall+  test/fixtures/scenario-isolation/*.dhall+  test/fixtures/scenario-isolation/*.py+  test/fixtures/scenario-isolation/hurl/*.hurl+  test/golden/top-level-help.txt+  test/golden/unknown-workflow.txt++common common-options+  ghc-options:+    -Wall+    -Wcompat+    -Widentities+    -Wincomplete-uni-patterns+    -Wincomplete-record-updates+    -Wredundant-constraints+    -fhide-source-paths+    -Wmissing-export-lists+    -Wpartial-fields+    -Wmissing-deriving-strategies++  default-language: GHC2024+  default-extensions:+    DeriveAnyClass+    DuplicateRecordFields+    OverloadedLabels+    OverloadedStrings++library+  import: common-options+  hs-source-dirs: src+  autogen-modules: Paths_hurl_workbench_cli+  other-modules: Paths_hurl_workbench_cli+  exposed-modules:+    HurlWorkbench.Cli+    HurlWorkbench.Cli.Command.Doctor+    HurlWorkbench.Cli.Command.List+    HurlWorkbench.Cli.Command.Matrix+    HurlWorkbench.Cli.Command.Render+    HurlWorkbench.Cli.Command.Run+    HurlWorkbench.Cli.Command.Suite+    HurlWorkbench.Cli.Command.Validate+    HurlWorkbench.Cli.Error+    HurlWorkbench.Cli.Help+    HurlWorkbench.Cli.Options+    HurlWorkbench.Cli.Output+    HurlWorkbench.Cli.Version+    HurlWorkbench.Cli.Workspace++  build-depends:+    base >=4.20 && <5,+    bytestring >=0.11 && <0.13,+    containers >=0.6 && <0.8,+    directory ^>=1.3,+    filepath >=1.4 && <1.6,+    generic-lens ^>=2.3,+    githash >=0.1.7 && <0.2,+    hurl-workbench-core ^>=0.1.0.0,+    lens ^>=5.3,+    optparse-applicative >=0.19 && <0.20,+    temporary ^>=1.3,+    terminal-size >=0.3.4 && <0.4,+    text ^>=2.1,++executable hurl-workbench+  import: common-options+  main-is: Main.hs+  hs-source-dirs: app+  ghc-options:+    -threaded+    -rtsopts+    -with-rtsopts=-N++  build-depends:+    base >=4.20 && <5,+    hurl-workbench-cli,++executable hurl-workbench-fixture-server+  import: common-options+  main-is: Main.hs+  hs-source-dirs: test/fixture-server+  other-modules:+    FixtureServer++  ghc-options:+    -threaded+    -rtsopts+    -with-rtsopts=-N++  build-depends:+    base >=4.20 && <5,+    bytestring >=0.11 && <0.13,+    directory ^>=1.3,+    filepath >=1.4 && <1.6,+    http-types >=0.12 && <0.13,+    unix >=2.8.8 && <2.9,+    wai >=3.2.5 && <3.3,+    warp >=3.4.16 && <3.5,++test-suite hurl-workbench-cli-test+  import: common-options+  type: exitcode-stdio-1.0+  hs-source-dirs:+    test+    test/fixture-server++  main-is: Main.hs+  other-modules:+    CheckTest+    FileRootTest+    FixtureServer++  ghc-options:+    -threaded++  build-depends:+    async,+    base >=4.20 && <5,+    bytestring,+    containers,+    directory,+    filepath,+    generic-lens,+    http-types,+    hurl-workbench-cli,+    hurl-workbench-core ^>=0.1.0.0,+    lens,+    optparse-applicative,+    tasty ^>=1.5,+    tasty-hunit ^>=0.10,+    temporary,+    text,+    unix,+    wai,+    warp,
+ schema/Binding.dhall view
@@ -0,0 +1,2 @@+{- A plain value bound to a parameter declared by the selected workflow. -}+{ Type = { parameter : Text, value : Text }, default = {=} }
+ schema/Check.dhall view
@@ -0,0 +1,16 @@+{- A single application-owned executable assertion, run as a suite barrier. -}+let CommandSpec = ./CommandSpec.dhall++in  { Type =+        { name : Text+        , command : CommandSpec.Type+        , expectedStatus : Natural+        , timeoutSeconds : Optional Natural+        , description : Optional Text+        }+    , default =+      { expectedStatus = 0+      , timeoutSeconds = None Natural+      , description = None Text+      }+    }
+ schema/CommandReadiness.dhall view
@@ -0,0 +1,10 @@+{- Run `command` until it exits successfully. -}+let CommandSpec = ./CommandSpec.dhall++in  { Type =+        { command : CommandSpec.Type+        , intervalMilliseconds : Natural+        , timeoutSeconds : Natural+        }+    , default = { intervalMilliseconds = 500, timeoutSeconds = 30 }+    }
+ schema/CommandSpec.dhall view
@@ -0,0 +1,21 @@+{- A process invocation as an executable plus argv; there is no shell-string form.+   `workingDirectory` is relative to the workspace root.+-}+let EnvironmentBinding = ./EnvironmentBinding.dhall++let EnvironmentPolicy = ./EnvironmentPolicy.dhall++in  { Type =+        { executable : Text+        , arguments : List Text+        , workingDirectory : Optional Text+        , environmentPolicy : EnvironmentPolicy+        , environment : List EnvironmentBinding.Type+        }+    , default =+      { arguments = [] : List Text+      , workingDirectory = None Text+      , environmentPolicy = EnvironmentPolicy.Inherit+      , environment = [] : List EnvironmentBinding.Type+      }+    }
+ schema/EnvironmentBinding.dhall view
@@ -0,0 +1,2 @@+{- Export the workspace parameter `parameter` to a child process as `variable`. -}+{ Type = { variable : Text, parameter : Text }, default = {=} }
+ schema/EnvironmentPolicy.dhall view
@@ -0,0 +1,1 @@+< Inherit | Clean : List Text >
+ schema/Fragment.dhall view
@@ -0,0 +1,6 @@+{- A file of one or more complete Hurl entries.+   `path` is relative to the directory containing `hurl-workbench.dhall`.+-}+{ Type = { name : Text, path : Text, description : Optional Text }+, default.description = None Text+}
+ schema/Generator.dhall view
@@ -0,0 +1,1 @@+< Uuid4 | Uuid7 | TypeId : { prefix : Text } | Rfc3339Now | RandomAlphanumeric : { length : Natural } >
+ schema/HttpReadiness.dhall view
@@ -0,0 +1,9 @@+{- Poll `url` until it answers with `expectedStatus`. -}+{ Type =+    { url : Text+    , expectedStatus : Natural+    , intervalMilliseconds : Natural+    , timeoutSeconds : Natural+    }+, default = { expectedStatus = 200, intervalMilliseconds = 500, timeoutSeconds = 30 }+}
+ schema/IsolationPolicy.dhall view
@@ -0,0 +1,2 @@+{- Whether expanded scenarios share application state. -}+< SharedState | IndependentState >
+ schema/Matrix.dhall view
@@ -0,0 +1,12 @@+{- A recipe applied to a non-empty list of uniquely named cases. -}+let MatrixCase = ./MatrixCase.dhall++in  { Type =+        { name : Text+        , recipe : Text+        , cases : List MatrixCase.Type+        , failFast : Bool+        , description : Optional Text+        }+    , default = { failFast = False, description = None Text }+    }
+ schema/MatrixCase.dhall view
@@ -0,0 +1,6 @@+{- One named set of bindings layered over a matrix's recipe. -}+let Binding = ./Binding.dhall++in  { Type = { name : Text, bindings : List Binding.Type }+    , default.bindings = [] : List Binding.Type+    }
+ schema/Parameter.dhall view
@@ -0,0 +1,21 @@+{- An externally injected Hurl variable.++   `defaultValue` is a committed plain value and is rejected for secrets.+   `environment` names an environment variable that supplies the value at run time.+-}+let ParameterKind = ./ParameterKind.dhall++in  { Type =+        { name : Text+        , description : Optional Text+        , kind : ParameterKind+        , defaultValue : Optional Text+        , environment : Optional Text+        }+    , default =+      { description = None Text+      , kind = ParameterKind.Plain+      , defaultValue = None Text+      , environment = None Text+      }+    }
+ schema/ParameterKind.dhall view
@@ -0,0 +1,2 @@+let Generator = ./Generator.dhall+in < Plain | Secret | Generated : Generator >
+ schema/Readiness.dhall view
@@ -0,0 +1,6 @@+{- How to decide that a service is ready, for example+   `Schema.Readiness.Http Schema.HttpReadiness::{ url = "http://127.0.0.1:8080/health" }`.+-}+< Http : (./HttpReadiness.dhall).Type+| Command : (./CommandReadiness.dhall).Type+>
+ schema/Recipe.dhall view
@@ -0,0 +1,16 @@+{- A workflow plus committed plain bindings and an explicit safety class.+   `safety` has no default so that a mutating recipe is never silently read-only.+-}+let Binding = ./Binding.dhall++let Safety = ./Safety.dhall++in  { Type =+        { name : Text+        , workflow : Text+        , bindings : List Binding.Type+        , safety : Safety+        , description : Optional Text+        }+    , default = { bindings = [] : List Binding.Type, description = None Text }+    }
+ schema/RunReference.dhall view
@@ -0,0 +1,4 @@+{- Hurl runs and named service lifecycle barriers. -}+let ServiceStep = ./ServiceStep.dhall++in < Workflow : Text | Recipe : Text | Matrix : Text | Check : Text | Service : ServiceStep >
+ schema/Safety.dhall view
@@ -0,0 +1,4 @@+{- Whether running a recipe may change remote state.+   `Mutating` recipes only run when explicitly allowed at execution time.+-}+< ReadOnly | Mutating >
+ schema/Service.dhall view
@@ -0,0 +1,14 @@+{- A managed process that suites can start, wait for, and stop. -}+let CommandSpec = ./CommandSpec.dhall++let Readiness = ./Readiness.dhall++in  { Type =+        { name : Text+        , command : CommandSpec.Type+        , readiness : Readiness+        , shutdownTimeoutSeconds : Natural+        , description : Optional Text+        }+    , default = { shutdownTimeoutSeconds = 10, description = None Text }+    }
+ schema/ServiceStep.dhall view
@@ -0,0 +1,2 @@+{- An ordered lifecycle barrier for a service belonging to this suite. -}+< Stop : Text | Start : Text | Restart : Text >
+ schema/Suite.dhall view
@@ -0,0 +1,34 @@+{- A non-empty collection of runs, optionally against a managed service. -}+let RunReference = ./RunReference.dhall++let CommandSpec = ./CommandSpec.dhall++let IsolationPolicy = ./IsolationPolicy.dhall++in  { Type =+        { name : Text+        , runs : List RunReference+        , fileRootParameter : Optional Text+        , service : Optional Text+        , services : List Text+        , setup : List CommandSpec.Type+        , teardown : List CommandSpec.Type+        , commandTimeoutSeconds : Natural+        , cleanupTimeoutSeconds : Natural+        , isolation : IsolationPolicy+        , failFast : Bool+        , description : Optional Text+        }+    , default =+        { fileRootParameter = None Text+        , service = None Text+        , services = [] : List Text+        , setup = [] : List CommandSpec.Type+        , teardown = [] : List CommandSpec.Type+        , commandTimeoutSeconds = 60+        , cleanupTimeoutSeconds = 30+        , isolation = IsolationPolicy.SharedState+        , failFast = False+        , description = None Text+        }+    }
+ schema/Workflow.dhall view
@@ -0,0 +1,9 @@+{- An ordered, non-empty list of fragment names and the parameters they consume. -}+{ Type =+    { name : Text+    , fragments : List Text+    , parameters : List Text+    , description : Optional Text+    }+, default = { parameters = [] : List Text, description = None Text }+}
+ schema/Workspace.dhall view
@@ -0,0 +1,44 @@+{- The top-level value of `hurl-workbench.dhall`.++   `schemaVersion` is required and has no default: a manifest states the schema+   version it was written for, and the workbench rejects versions it does not know.+   Every list defaults to empty so new categories never break existing manifests.+-}+let Parameter = ./Parameter.dhall++let Fragment = ./Fragment.dhall++let Workflow = ./Workflow.dhall++let Recipe = ./Recipe.dhall++let Matrix = ./Matrix.dhall++let Check = ./Check.dhall++let Service = ./Service.dhall++let Suite = ./Suite.dhall++in  { Type =+        { schemaVersion : Natural+        , parameters : List Parameter.Type+        , fragments : List Fragment.Type+        , workflows : List Workflow.Type+        , recipes : List Recipe.Type+        , matrices : List Matrix.Type+        , checks : List Check.Type+        , services : List Service.Type+        , suites : List Suite.Type+        }+    , default =+      { parameters = [] : List Parameter.Type+      , fragments = [] : List Fragment.Type+      , workflows = [] : List Workflow.Type+      , recipes = [] : List Recipe.Type+      , matrices = [] : List Matrix.Type+      , checks = [] : List Check.Type+      , services = [] : List Service.Type+      , suites = [] : List Suite.Type+      }+    }
+ schema/package.dhall view
@@ -0,0 +1,36 @@+{- The hurl-workbench workspace schema, version 1.++   Import it by relative path from a workspace manifest:++       let Schema = ../schema/package.dhall+       in  Schema.Workspace::{ schemaVersion = Schema.schemaVersion, ... }++   Record modules expose `Type` and `default` for record completion (`::`).+   Union modules (`ParameterKind`, `Safety`, `Readiness`, `RunReference`) are the+   union types themselves.+-}+{ schemaVersion = 1+, Workspace = ./Workspace.dhall+, Parameter = ./Parameter.dhall+, Generator = ./Generator.dhall+, ParameterKind = ./ParameterKind.dhall+, Fragment = ./Fragment.dhall+, Workflow = ./Workflow.dhall+, Binding = ./Binding.dhall+, Safety = ./Safety.dhall+, Recipe = ./Recipe.dhall+, MatrixCase = ./MatrixCase.dhall+, Matrix = ./Matrix.dhall+, EnvironmentBinding = ./EnvironmentBinding.dhall+, EnvironmentPolicy = ./EnvironmentPolicy.dhall+, CommandSpec = ./CommandSpec.dhall+, HttpReadiness = ./HttpReadiness.dhall+, CommandReadiness = ./CommandReadiness.dhall+, Readiness = ./Readiness.dhall+, Check = ./Check.dhall+, Service = ./Service.dhall+, ServiceStep = ./ServiceStep.dhall+, RunReference = ./RunReference.dhall+, IsolationPolicy = ./IsolationPolicy.dhall+, Suite = ./Suite.dhall+}
+ src/HurlWorkbench/Cli.hs view
@@ -0,0 +1,109 @@+-- | Top-level CLI entry point: parse argv and dispatch to a command.+module HurlWorkbench.Cli+  ( runCli,+    runCommand,+    runCommandWithHurlfmt,+    runCommandWithDependencies,+  )+where++import Data.Text qualified as Text+import HurlWorkbench.Cli.Command.Doctor (runDoctorWith)+import HurlWorkbench.Cli.Command.List (runList)+import HurlWorkbench.Cli.Command.Matrix (runMatrixWith)+import HurlWorkbench.Cli.Command.Render (runRenderWith)+import HurlWorkbench.Cli.Command.Run (runExecuteWith)+import HurlWorkbench.Cli.Command.Suite (runSuiteCommandWith)+import HurlWorkbench.Cli.Command.Validate (runValidateWith)+import HurlWorkbench.Cli.Help (helpPreferences, resolveHelpColumns)+import HurlWorkbench.Cli.Options (Command (..), CompletionShell (..), GlobalOptions, Options (..), parserInfo)+import HurlWorkbench.Cli.Output (CommandResult, emitResult, success)+import HurlWorkbench.Hurl.Capabilities (HurlCapabilities, detectHurlCapabilities)+import HurlWorkbench.Hurl.Format+  ( DependencyError,+    HurlfmtCapabilities,+    HurlfmtError,+    WorkflowSyntaxError,+    detectHurlfmtCapabilities,+    validateRenderedWorkflow,+    validateWorkspaceSyntax,+  )+import HurlWorkbench.Hurl.Run (HurlRunMode (..))+import HurlWorkbench.Prelude (Text)+import HurlWorkbench.Workflow.Render (RenderedWorkflow)+import HurlWorkbench.Workspace.Context (ValidatedWorkspace)+import Options.Applicative (customExecParser)+import Options.Applicative.BashCompletion+  ( bashCompletionScript,+    fishCompletionScript,+    zshCompletionScript,+  )+import System.Directory (getCurrentDirectory)++-- | Parse argv, run the command from the current directory, print its+--   output, and exit with its status.+runCli :: IO ()+runCli = do+  helpColumns <- resolveHelpColumns+  Options {global, cmd} <- customExecParser (helpPreferences helpColumns) parserInfo+  currentDirectory <- getCurrentDirectory+  runCommand global currentDirectory cmd >>= emitResult++-- | Run a parsed command as if started in the given directory.+runCommand :: GlobalOptions -> FilePath -> Command -> IO CommandResult+runCommand =+  runCommandWithDependencies+    detectHurlfmtCapabilities+    detectHurlCapabilities+    validateRenderedWorkflow+    validateWorkspaceSyntax++-- | Test seam for supplying a deterministic Hurlfmt capability probe.+runCommandWithHurlfmt ::+  IO (Either DependencyError HurlfmtCapabilities) ->+  (HurlfmtCapabilities -> RenderedWorkflow -> IO (Either HurlfmtError ())) ->+  (HurlfmtCapabilities -> ValidatedWorkspace -> IO (Either DependencyError [WorkflowSyntaxError])) ->+  GlobalOptions ->+  FilePath ->+  Command ->+  IO CommandResult+runCommandWithHurlfmt detectCapabilities validateRendered validateSyntax global currentDirectory = \case+  command ->+    runCommandWithDependencies+      detectCapabilities+      detectHurlCapabilities+      validateRendered+      validateSyntax+      global+      currentDirectory+      command++runCommandWithDependencies ::+  IO (Either DependencyError HurlfmtCapabilities) ->+  IO (Either DependencyError HurlCapabilities) ->+  (HurlfmtCapabilities -> RenderedWorkflow -> IO (Either HurlfmtError ())) ->+  (HurlfmtCapabilities -> ValidatedWorkspace -> IO (Either DependencyError [WorkflowSyntaxError])) ->+  GlobalOptions ->+  FilePath ->+  Command ->+  IO CommandResult+runCommandWithDependencies detectHurlfmt detectHurl validateRendered validateSyntax global currentDirectory = \case+  ValidateCommand -> runValidateWith detectHurlfmt validateSyntax global currentDirectory+  ListCommand category -> runList global currentDirectory category+  RenderCommand options -> runRenderWith detectHurlfmt validateRendered global currentDirectory options+  RunCommand options -> runExecuteWith detectHurl validateRendered global currentDirectory ClientMode options+  TestCommand options -> runExecuteWith detectHurl validateRendered global currentDirectory TestMode options+  MatrixCommand options -> runMatrixWith detectHurl validateRendered global currentDirectory options+  SuiteCommand options -> runSuiteCommandWith detectHurl validateRendered global currentDirectory options+  CompletionsCommand shell -> pure (success (completionLines shell))+  DoctorCommand -> runDoctorWith detectHurl++completionLines :: CompletionShell -> [Text]+completionLines shell =+  Text.lines . Text.pack $+    case shell of+      Bash -> bashCompletionScript commandName commandName+      Zsh -> zshCompletionScript commandName commandName+      Fish -> fishCompletionScript commandName commandName+  where+    commandName = "hurl-workbench"
+ src/HurlWorkbench/Cli/Command/Doctor.hs view
@@ -0,0 +1,44 @@+module HurlWorkbench.Cli.Command.Doctor+  ( runDoctor,+    runDoctorWith,+  )+where++import Data.Generics.Labels ()+import Data.Text qualified as Text+import Data.Version (showVersion)+import HurlWorkbench.Cli.Output (CommandResult (..))+import HurlWorkbench.Hurl.Capabilities+import HurlWorkbench.Hurl.Format+import HurlWorkbench.Prelude+import System.Exit (ExitCode (..))++runDoctor :: IO CommandResult+runDoctor = runDoctorWith detectHurlCapabilities++runDoctorWith :: IO (Either DependencyError HurlCapabilities) -> IO CommandResult+runDoctorWith detectCapabilities =+  detectCapabilities <&> \case+    Left err ->+      CommandResult+        { stdoutLines = [],+          stderrLines = ["error: " <> renderDependencyError err],+          exitCode = ExitFailure 3+        }+    Right capabilities ->+      CommandResult+        { stdoutLines =+            [ "hurl: "+                <> Text.pack (capabilities ^. #hurlExecutable)+                <> " ("+                <> Text.pack (showVersion (capabilities ^. #hurlVersion))+                <> ", supported)",+              "hurlfmt: "+                <> Text.pack (capabilities ^. #hurlfmt . #executable . #unHurlfmtExecutable)+                <> " ("+                <> Text.pack (showVersion (capabilities ^. #hurlfmt . #version))+                <> ", supported)"+            ],+          stderrLines = hurlCapabilityWarnings capabilities,+          exitCode = ExitSuccess+        }
+ src/HurlWorkbench/Cli/Command/List.hs view
@@ -0,0 +1,178 @@+-- | @hurl-workbench list@: print workspace entities and their relationships+--   as aligned, line-oriented tables in declaration order.+--+--   Listing never reads parameter environments and never prints secret+--   values; an @ENVIRONMENT@ column names the variable, not its value.+module HurlWorkbench.Cli.Command.List+  ( runList,+    listLines,+  )+where++import Data.Generics.Labels ()+import Data.Text qualified as Text+import HurlWorkbench.Cli.Options (GlobalOptions, ListCategory (..))+import HurlWorkbench.Cli.Output (CommandResult, renderTable, success)+import HurlWorkbench.Cli.Workspace (withValidatedWorkspace)+import HurlWorkbench.Prelude+import HurlWorkbench.Workspace.Context (ValidatedWorkspace, lookupMatrix, lookupRecipe, validatedWorkspace)+import HurlWorkbench.Workspace.Types++runList :: GlobalOptions -> FilePath -> ListCategory -> IO CommandResult+runList options currentDirectory category =+  withValidatedWorkspace options currentDirectory (success . listLines category)++listLines :: ListCategory -> ValidatedWorkspace -> [Text]+listLines category vw = case category of+  AllCategories ->+    intercalateBlank+      [ heading title : section c+      | c <- [ParametersCategory .. SuitesCategory],+        let title = categoryTitle c+      ]+  single -> section single+  where+    ws = validatedWorkspace vw+    heading title = title <> ":"+    intercalateBlank = concat . zipWith (\i block -> [Text.empty | i > (0 :: Int)] <> block) [0 ..]+    section = \case+      AllCategories -> []+      ParametersCategory -> table ["NAME", "KIND", "DEFAULT", "ENVIRONMENT", "DESCRIPTION"] (map parameterRow (ws ^. #parameters))+      FragmentsCategory -> table ["NAME", "PATH", "DESCRIPTION"] (map fragmentRow (ws ^. #fragments))+      WorkflowsCategory -> table ["NAME", "FRAGMENTS", "PARAMETERS", "DESCRIPTION"] (map workflowRow (ws ^. #workflows))+      RecipesCategory -> table ["NAME", "WORKFLOW", "SAFETY", "BINDINGS", "DESCRIPTION"] (map recipeRow (ws ^. #recipes))+      MatricesCategory -> table ["NAME", "RECIPE", "CASES", "FAIL-FAST", "DESCRIPTION"] (map matrixRow (ws ^. #matrices))+      ChecksCategory -> table ["NAME", "EXPECTED-STATUS", "TIMEOUT", "DESCRIPTION"] (map checkRow (ws ^. #checks))+      ServicesCategory -> table ["NAME", "COMMAND", "READINESS", "DESCRIPTION"] (map serviceRow (ws ^. #services))+      SuitesCategory -> table ["NAME", "RUNS", "SERVICE", "SAFETY", "FAIL-FAST", "DESCRIPTION"] (map (suiteRow vw) (ws ^. #suites))++table :: [Text] -> [[Text]] -> [Text]+table _ [] = ["(none)"]+table headings rows = renderTable headings rows++categoryTitle :: ListCategory -> Text+categoryTitle = \case+  AllCategories -> "All"+  ParametersCategory -> "Parameters"+  FragmentsCategory -> "Fragments"+  WorkflowsCategory -> "Workflows"+  RecipesCategory -> "Recipes"+  MatricesCategory -> "Matrices"+  ChecksCategory -> "Checks"+  ServicesCategory -> "Services"+  SuitesCategory -> "Suites"++parameterRow :: Parameter -> [Text]+parameterRow p =+  [ unParameterName (p ^. #name),+    case p ^. #kind of+      Generated generator -> "generated " <> Text.pack (show generator)+      Plain -> "plain"+      Secret -> "secret",+    maybe "" (displayLiteral . hurlValueLiteralText) (p ^. #defaultValue),+    fromMaybe "" (p ^. #environment),+    describe (p ^. #description)+  ]++fragmentRow :: Fragment -> [Text]+fragmentRow f = [unFragmentName (f ^. #name), Text.pack (f ^. #path), describe (f ^. #description)]++workflowRow :: Workflow -> [Text]+workflowRow w =+  [ unWorkflowName (w ^. #name),+    commaList (map unFragmentName (w ^. #fragments)),+    commaList (map unParameterName (w ^. #parameters)),+    describe (w ^. #description)+  ]++recipeRow :: Recipe -> [Text]+recipeRow r =+  [ unRecipeName (r ^. #name),+    unWorkflowName (r ^. #workflow),+    case r ^. #safety of+      ReadOnly -> "read-only"+      Mutating -> "mutating",+    bindingList (r ^. #bindings),+    describe (r ^. #description)+  ]++matrixRow :: Matrix -> [Text]+matrixRow m =+  [ unMatrixName (m ^. #name),+    unRecipeName (m ^. #recipe),+    commaList (map (unMatrixCaseName . view #name) (m ^. #cases)),+    yesNo (m ^. #failFast),+    describe (m ^. #description)+  ]++checkRow :: Check -> [Text]+checkRow check = [unCheckName (check ^. #name), Text.pack (show (check ^. #expectedStatus)), maybe "60" (Text.pack . show) (check ^. #timeoutSeconds), describe (check ^. #description)]++serviceRow :: Service -> [Text]+serviceRow s =+  [ unServiceName (s ^. #name),+    s ^. #command . #executable,+    case s ^. #readiness of+      HttpReadinessCheck http -> "http " <> http ^. #url+      CommandReadinessCheck check -> "command " <> check ^. #command . #executable,+    describe (s ^. #description)+  ]++suiteRow :: ValidatedWorkspace -> Suite -> [Text]+suiteRow validated s =+  [ unSuiteName (s ^. #name),+    commaList (map runLabel (s ^. #runs)),+    commaList (map unServiceName (suiteServices s)),+    suiteSafety validated s,+    yesNo (s ^. #failFast),+    describe (s ^. #description)+  ]+  where+    runLabel = \case+      WorkflowRun w -> "workflow:" <> unWorkflowName w+      RecipeRun r -> "recipe:" <> unRecipeName r+      MatrixRun m -> "matrix:" <> unMatrixName m+      CheckRun c -> "check:" <> unCheckName c+      ServiceRun step -> "service:" <> Text.pack (show step)++suiteSafety :: ValidatedWorkspace -> Suite -> Text+suiteSafety validated suite =+  case (any (== MutatingRun) dispositions, any (== UnclassifiedRun) dispositions) of+    (False, False) -> "read-only"+    (True, False) -> "mutating"+    (False, True) -> "unclassified"+    (True, True) -> "mutating, unclassified"+  where+    dispositions = map disposition (suite ^. #runs)+    disposition = \case+      CheckRun _ -> ReadOnlyRun+      ServiceRun _ -> ReadOnlyRun+      WorkflowRun _ -> UnclassifiedRun+      RecipeRun name -> maybe UnclassifiedRun recipeDisposition (lookupRecipe name validated)+      MatrixRun name -> case lookupMatrix name validated >>= (\matrix -> lookupRecipe (matrix ^. #recipe) validated) of+        Nothing -> UnclassifiedRun+        Just recipe -> recipeDisposition recipe+    recipeDisposition recipe = case recipe ^. #safety of+      ReadOnly -> ReadOnlyRun+      Mutating -> MutatingRun++data ListedSafety = ReadOnlyRun | MutatingRun | UnclassifiedRun+  deriving stock (Eq)++-- | Committed plain bindings are part of the workspace source, so they are+--   safe to display.+bindingList :: [Binding] -> Text+bindingList bindings =+  commaList [unParameterName (b ^. #parameter) <> "=" <> displayLiteral (hurlValueLiteralText (b ^. #value)) | b <- bindings]++displayLiteral :: Text -> Text+displayLiteral value = if Text.null value then "\"\"" else value++commaList :: [Text] -> Text+commaList = Text.intercalate ", "++describe :: Maybe Text -> Text+describe = fromMaybe ""++yesNo :: Bool -> Text+yesNo b = if b then "yes" else "no"
+ src/HurlWorkbench/Cli/Command/Matrix.hs view
@@ -0,0 +1,204 @@+module HurlWorkbench.Cli.Command.Matrix+  ( runMatrix,+    runMatrixWith,+    replayCaptured,+    renderCaseSummary,+  )+where++import Data.Generics.Labels ()+import Data.IORef (atomicModifyIORef', newIORef)+import Data.List.NonEmpty qualified as NonEmpty+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text.Encoding+import Data.Text.Encoding.Error (lenientDecode)+import Data.Text.IO qualified as Text.IO+import HurlWorkbench.Cli.Command.Run (buildExecutionInputs)+import HurlWorkbench.Cli.Error (cliErrors, cliFailure)+import HurlWorkbench.Cli.Options+import HurlWorkbench.Cli.Output (CommandResult (..))+import HurlWorkbench.Cli.Workspace (loadValidatedWorkspace)+import HurlWorkbench.Hurl.Capabilities+import HurlWorkbench.Hurl.Format+import HurlWorkbench.Hurl.Run+import HurlWorkbench.Parameter.Resolve (BindingInput)+import HurlWorkbench.Prelude+import HurlWorkbench.Run.Artifact+import HurlWorkbench.Run.Batch+import HurlWorkbench.Run.FileRoot+import HurlWorkbench.Run.Prepare+import HurlWorkbench.Run.Selection+import HurlWorkbench.Suite.Isolation+import HurlWorkbench.Workflow.Render (RenderedWorkflow)+import HurlWorkbench.Workspace.Context (lookupMatrix, validatedRoot)+import HurlWorkbench.Workspace.Types (IsolationPolicy (..), MatrixName (..), Safety (..), WorkspaceRoot (..))+import System.Exit (ExitCode (..))+import System.FilePath (isAbsolute, (</>))+import System.IO qualified as IO++runMatrix :: GlobalOptions -> FilePath -> MatrixOptions -> IO CommandResult+runMatrix = runMatrixWith detectHurlCapabilities validateRenderedWorkflow++runMatrixWith ::+  IO (Either DependencyError HurlCapabilities) ->+  (HurlfmtCapabilities -> RenderedWorkflow -> IO (Either HurlfmtError ())) ->+  GlobalOptions ->+  FilePath ->+  MatrixOptions ->+  IO CommandResult+runMatrixWith detectCapabilities validateRendered global currentDirectory matrixOptions =+  loadValidatedWorkspace global currentDirectory >>= \case+    Left errors -> pure (cliFailure 2 errors)+    Right validated -> case lookupMatrix (matrixOptions ^. #matrix) validated of+      Nothing -> pure (cliFailure 2 ["unknown matrix \"" <> unMatrixName (matrixOptions ^. #matrix) <> "\""])+      Just matrixDefinition -> case validateMatrixOptions matrixOptions of+        Just problem -> pure (cliFailure 2 [problem])+        Nothing+          | Right expanded <- resolveSelection validated (SelectMatrix (matrixOptions ^. #matrix)),+            problems@(_ : _) <- checkIsolation SharedState (matrixOptions ^. #jobs) (toList expanded) ->+              pure (cliErrors 2 (map renderIsolationIssue problems))+        Nothing -> case buildInputs matrixOptions of+          Left errors -> pure (cliErrors 2 errors)+          Right (bindingInput, hurlOptions) ->+            let requested = matrixOptions ^. #hurl . #fileRoot+                base = maybe (unWorkspaceRoot (validatedRoot validated)) (const currentDirectory) requested+             in validateFileRoot base requested >>= \case+                  Left err -> pure (cliFailure 2 [renderFileRootError err])+                  Right root ->+                    checkFileRootOutputs root (maybe [] pure (matrixOptions ^. #outputDirectory) <> maybe [] pure (hurlOptions ^. #curlExportPath)) >>= \case+                      Left err -> pure (cliFailure 2 [renderFileRootError err])+                      Right () ->+                        detectCapabilities >>= \case+                          Left err -> pure (cliFailure 3 [renderDependencyError err])+                          Right capabilities ->+                            prepareSelectionAtFileRootWith+                              root+                              validateRendered+                              (capabilities ^. #hurlfmt)+                              validated+                              bindingInput+                              hurlOptions+                              (SelectMatrix (matrixOptions ^. #matrix))+                              >>= \case+                                Left errors ->+                                  pure+                                    ( cliErrors+                                        (preparationExitCode errors)+                                        (map (("preflight: " <>) . renderPreparationError) (toList errors))+                                    )+                                Right prepared+                                  | any (mutatingDenied (matrixOptions ^. #allowMutating) . view #safety) prepared ->+                                      pure (cliFailure 2 ["mutating matrix requires --allow-mutating"])+                                  | isJust (matrixOptions ^. #hurl . #curlExportPath) && length prepared > 1 ->+                                      pure (cliFailure 2 ["--curl has one path and cannot be shared by multiple matrix cases"])+                                  | otherwise -> do+                                      built <- buildCases currentDirectory matrixOptions prepared+                                      case built of+                                        Left errors -> pure (cliErrors 2 (map renderArtifactError (toList errors)))+                                        Right batchCases -> do+                                          let failFast = fromMaybe (matrixDefinition ^. #failFast) (matrixOptions ^. #failFastOverride)+                                              batchOptions = BatchOptions (matrixOptions ^. #jobs) failFast+                                              runner = mkHurlRunner capabilities+                                          selectedRunner <-+                                            if usesInheritedOutput matrixOptions+                                              then labelSequentialRunner (map (view #name) (toList batchCases)) runner+                                              else pure runner+                                          result <- runBatch selectedRunner batchOptions batchCases+                                          pure+                                            CommandResult+                                              { stdoutLines = [],+                                                stderrLines =+                                                  hurlCapabilityWarnings capabilities+                                                    <> replayCaptured (result ^. #cases)+                                                    <> map renderCaseSummary (toList (result ^. #cases)),+                                                exitCode = result ^. #selectedExitCode+                                              }++buildInputs :: MatrixOptions -> Either [Text] (BindingInput, HurlOptions)+buildInputs matrixOptions =+  buildExecutionInputs+    ExecuteOptions+      { selection = SelectMatrix (matrixOptions ^. #matrix),+        bindings = matrixOptions ^. #bindings,+        hurl = matrixOptions ^. #hurl,+        allowMutating = matrixOptions ^. #allowMutating+      }++validateMatrixOptions :: MatrixOptions -> Maybe Text+validateMatrixOptions options+  | options ^. #overwrite && isNothing (options ^. #outputDirectory) = Just "--overwrite requires --output-dir"+  | options ^. #mode == ClientMode && positiveIntValue (options ^. #jobs) > 1 && isNothing (options ^. #outputDirectory) =+      Just "client matrices with --jobs greater than 1 require --output-dir"+  | options ^. #mode == TestMode && isJust (options ^. #outputDirectory) = Just "--output-dir is available only with --mode run"+  | otherwise = Nothing++buildCases :: FilePath -> MatrixOptions -> NonEmpty PreparedRun -> IO (Either (NonEmpty ArtifactError) (NonEmpty BatchCase))+buildCases currentDirectory options prepared = case (options ^. #mode, options ^. #outputDirectory) of+  (ClientMode, Just requestedDirectory) -> do+    let outputDirectory = if isAbsolute requestedDirectory then requestedDirectory else currentDirectory </> requestedDirectory+    prepareResponseArtifacts outputDirectory (options ^. #overwrite) prepared <&> \case+      Left errors -> Left errors+      Right paths -> Right (NonEmpty.zipWith (\ready path -> buildBatchCase ClientMode (ResponseFile path) [] ready) prepared paths)+  (ClientMode, Nothing) -> pure (Right (fmap (buildBatchCase ClientMode InheritRunOutput []) prepared))+  (TestMode, Nothing) -> pure (Right (fmap (buildBatchCase TestMode CaptureRunOutput []) prepared))+  (TestMode, Just _) -> error "buildCases: output directory rejected by validation"++usesInheritedOutput :: MatrixOptions -> Bool+usesInheritedOutput options = options ^. #mode == ClientMode && isNothing (options ^. #outputDirectory)++labelSequentialRunner :: [Text] -> HurlRunner -> IO HurlRunner+labelSequentialRunner names runner = do+  remaining <- newIORef names+  pure $ HurlRunner $ \request -> do+    caseName <- atomicModifyIORef' remaining $ \case+      [] -> ([], "unknown")+      name : rest -> (rest, name)+    Text.IO.hPutStrLn IO.stderr ("==> " <> caseName)+    result <- runHurl runner request+    Text.IO.hPutStrLn IO.stderr ("<== " <> caseName <> " (" <> resultLabel result <> ")")+    pure result+  where+    resultLabel = \case+      Left _ -> "start failed"+      Right result -> case result ^. #exitCode of+        ExitSuccess -> "passed"+        ExitFailure status -> "Hurl exit " <> Text.pack (show status)++replayCaptured :: (Foldable f) => f CaseResult -> [Text]+replayCaptured = concatMap replayOne . toList+  where+    replayOne result = case result ^. #capturedOutput of+      Nothing -> []+      Just captured ->+        let stdoutText = decode (captured ^. #stdout)+            stderrText = decode (captured ^. #stderr)+            lines_ = Text.lines stdoutText <> Text.lines stderrText+         in ["--- " <> result ^. #name <> " diagnostics ---" | not (null lines_)] <> lines_+    decode = Text.Encoding.decodeUtf8With lenientDecode++renderCaseSummary :: CaseResult -> Text+renderCaseSummary result =+  summary <> maybe "" ((" -> " <>) . Text.pack) (result ^. #outputPath)+  where+    summary = case result ^. #outcome of+      CasePassed -> "PASS  " <> result ^. #name+      CaseFailed (ExitFailure status) -> "FAIL  " <> result ^. #name <> " (Hurl exit " <> Text.pack (show status) <> ")"+      CaseFailed ExitSuccess -> "FAIL  " <> result ^. #name <> " (invalid successful failure status)"+      CaseStartFailed err -> "ERROR " <> result ^. #name <> " (" <> renderRunStartError err <> ")"+      CaseSkipped reason -> "SKIP  " <> result ^. #name <> " (" <> skipReason reason <> ")"+    skipReason FailFastTriggered = "fail-fast"+    skipReason BatchPrerequisiteFailed = "batch prerequisite failed"+    skipReason BatchCancelled = "cancelled"++preparationExitCode :: NonEmpty PreparationError -> Int+preparationExitCode errors = maximum (map one (toList errors))+  where+    one = \case+      RunFormatFailed _ InvalidHurl {} -> 2+      RunFormatFailed {} -> 3+      _ -> 2++mutatingDenied :: Bool -> SafetyDisposition -> Bool+mutatingDenied allowed = \case+  Classified Mutating -> not allowed+  _ -> False
+ src/HurlWorkbench/Cli/Command/Render.hs view
@@ -0,0 +1,140 @@+-- | @hurl-workbench render workflow@: exact, syntax-checked Hurl output.+module HurlWorkbench.Cli.Command.Render+  ( runRender,+    runRenderWith,+  )+where++import Control.Exception (IOException, displayException, mask, onException, try)+import Data.ByteString qualified as ByteString+import Data.Generics.Labels ()+import Data.List.NonEmpty qualified as NonEmpty+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text.Encoding+import HurlWorkbench.Cli.Options (GlobalOptions, RenderOptions)+import HurlWorkbench.Cli.Output (CommandResult (..), failure)+import HurlWorkbench.Cli.Workspace (loadValidatedWorkspace)+import HurlWorkbench.Hurl.Format+import HurlWorkbench.Parameter.Generate (generatorName)+import HurlWorkbench.Parameter.Resolve (BindingSource (..))+import HurlWorkbench.Prelude+import HurlWorkbench.Run.Selection+import HurlWorkbench.Workflow.Render+import HurlWorkbench.Workspace.Context+import HurlWorkbench.Workspace.Types+import System.Directory (canonicalizePath, removeFile, renameFile)+import System.Exit (ExitCode (..))+import System.FilePath (isAbsolute, takeDirectory, takeFileName, (</>))+import System.IO (hClose, hFlush)+import System.IO.Temp (openBinaryTempFile)++runRender :: GlobalOptions -> FilePath -> RenderOptions -> IO CommandResult+runRender = runRenderWith detectHurlfmtCapabilities validateRenderedWorkflow++runRenderWith ::+  IO (Either DependencyError HurlfmtCapabilities) ->+  (HurlfmtCapabilities -> RenderedWorkflow -> IO (Either HurlfmtError ())) ->+  GlobalOptions ->+  FilePath ->+  RenderOptions ->+  IO CommandResult+runRenderWith detectCapabilities validateRendered global currentDirectory options =+  loadValidatedWorkspace global currentDirectory >>= \case+    Left errors -> pure (failure errors)+    Right validated -> case resolveSelection validated (options ^. #selection) of+      Left err -> pure (failure ["error: " <> renderSelectionError err])+      Right (expanded :| []) ->+        renderWorkflow (expanded ^. #workflow) >>= \case+          Left err -> pure (failure ["error: " <> renderRenderError err])+          Right rendered ->+            detectCapabilities >>= \case+              Left err -> pure (failure ["error: " <> renderDependencyError err])+              Right capabilities ->+                validateRendered capabilities rendered >>= \case+                  Left err -> pure (failure ["error: " <> renderHurlfmtError err])+                  Right () -> emitRendered currentDirectory options (explanation options expanded <> generatedExplanation validated options expanded) rendered+      Right _ -> pure (failure ["error: render accepts one workflow or recipe, not a matrix"])++emitRendered :: FilePath -> RenderOptions -> [Text] -> RenderedWorkflow -> IO CommandResult+emitRendered currentDirectory options explanationLines rendered =+  case options ^. #output of+    Nothing ->+      pure+        CommandResult+          { stdoutLines = Text.lines (rendered ^. #contents),+            stderrLines = explanationLines,+            exitCode = ExitSuccess+          }+    Just requested -> do+      let requestedPath = if isAbsolute requested then requested else currentDirectory </> requested+      canonicalOutputResult <- try (canonicalizePath requestedPath)+      case canonicalOutputResult of+        Left (err :: IOException) -> pure (failure ["error: " <> Text.pack (displayException err)])+        Right canonicalOutput+          | canonicalOutput `elem` sourcePaths rendered ->+              pure+                ( failure+                    [ "error: refusing to overwrite source fragment "+                        <> Text.pack canonicalOutput+                    ]+                )+          | otherwise ->+              atomicWrite canonicalOutput (Text.Encoding.encodeUtf8 (rendered ^. #contents)) >>= \case+                Left err -> pure (failure ["error: " <> Text.pack (displayException err)])+                Right () ->+                  pure+                    CommandResult+                      { stdoutLines = [],+                        stderrLines = Text.pack canonicalOutput : explanationLines,+                        exitCode = ExitSuccess+                      }+  where+    sourcePaths = map (view #fragmentPath) . NonEmpty.toList . view #sourceFragments++explanation :: RenderOptions -> ExpandedRun -> [Text]+explanation options expanded+  | not (options ^. #explain) = []+  | null (expanded ^. #bindingLayers) = ["Binding sources: none (direct workflow; safety unclassified)"]+  | otherwise =+      "Binding sources (values redacted):"+        : [ "  "+              <> renderSource (layer ^. #source)+              <> " ("+              <> Text.pack (show (length (layer ^. #values)))+              <> " plain bindings)"+          | layer <- expanded ^. #bindingLayers+          ]+  where+    renderSource = \case+      CommittedBinding label -> label+      source -> Text.pack (show source)++atomicWrite :: FilePath -> ByteString.ByteString -> IO (Either IOException ())+atomicWrite target bytes =+  mask $ \restore -> do+    let directory = takeDirectory target+        template = takeFileName target <> ".tmp"+    opened <- try (openBinaryTempFile directory template)+    case opened of+      Left err -> pure (Left err)+      Right (temporaryPath, handle) -> do+        let cleanup = do+              _ <- try @IOException (hClose handle)+              _ <- try @IOException (removeFile temporaryPath)+              pure ()+            writeAndRename = do+              ByteString.hPut handle bytes+              hFlush handle+              hClose handle+              renameFile temporaryPath target+        try (restore writeAndRename `onException` cleanup)++-- Inspection reads declarations only; it has no generator dependency.+generatedExplanation :: ValidatedWorkspace -> RenderOptions -> ExpandedRun -> [Text]+generatedExplanation validated options expanded =+  [ "  " <> unParameterName name <> ": generated (" <> generatorName generator <> ")"+  | options ^. #explain,+    name <- expanded ^. #workflow . #workflow . #parameters,+    Just parameter <- [lookupParameter name validated],+    Generated generator <- [parameter ^. #kind]+  ]
+ src/HurlWorkbench/Cli/Command/Run.hs view
@@ -0,0 +1,213 @@+module HurlWorkbench.Cli.Command.Run+  ( runExecute,+    runExecuteWith,+    buildExecutionInputs,+    buildRuntimeInputs,+  )+where++import Data.Bifunctor (first)+import Data.Generics.Labels ()+import Data.List (sort)+import Data.Map.Strict qualified as Map+import Data.Text qualified as Text+import HurlWorkbench.Cli.Error (cliErrors, cliFailure)+import HurlWorkbench.Cli.Options+import HurlWorkbench.Cli.Output (CommandResult (..))+import HurlWorkbench.Cli.Workspace (loadValidatedWorkspace)+import HurlWorkbench.Hurl.Capabilities+import HurlWorkbench.Hurl.Format+import HurlWorkbench.Hurl.Run+import HurlWorkbench.Parameter.Resolve+import HurlWorkbench.Prelude+import HurlWorkbench.Run.FileRoot+import HurlWorkbench.Run.Prepare+import HurlWorkbench.Run.Selection+import HurlWorkbench.Workflow.Render+import HurlWorkbench.Workspace.Context (validatedRoot)+import HurlWorkbench.Workspace.Types+import HurlWorkbench.Workspace.Validate (isValidEnvironmentName, isValidParameterName)++runExecute :: GlobalOptions -> FilePath -> HurlRunMode -> ExecuteOptions -> IO CommandResult+runExecute = runExecuteWith detectHurlCapabilities validateRenderedWorkflow++runExecuteWith ::+  IO (Either DependencyError HurlCapabilities) ->+  (HurlfmtCapabilities -> RenderedWorkflow -> IO (Either HurlfmtError ())) ->+  GlobalOptions ->+  FilePath ->+  HurlRunMode ->+  ExecuteOptions ->+  IO CommandResult+runExecuteWith detectCapabilities validateRendered global currentDirectory mode executeOptions =+  loadValidatedWorkspace global currentDirectory >>= \case+    Left errors -> pure (cliFailure 2 errors)+    Right validated -> case buildExecutionInputs executeOptions of+      Left errors -> pure (cliErrors 2 errors)+      Right (bindingInput, hurlOptions) ->+        let requested = executeOptions ^. #hurl . #fileRoot+            base = maybe (unWorkspaceRoot (validatedRoot validated)) (const currentDirectory) requested+         in validateFileRoot base requested >>= \case+              Left err -> pure (cliFailure 2 [renderFileRootError err])+              Right root ->+                checkFileRootOutputs root (maybe [] pure (hurlOptions ^. #curlExportPath)) >>= \case+                  Left err -> pure (cliFailure 2 [renderFileRootError err])+                  Right () ->+                    detectCapabilities >>= \case+                      Left err -> pure (cliFailure 3 [renderDependencyError err])+                      Right capabilities ->+                        prepareSelectionAtFileRootWith+                          root+                          validateRendered+                          (capabilities ^. #hurlfmt)+                          validated+                          bindingInput+                          hurlOptions+                          (executeOptions ^. #selection)+                          >>= \case+                            Left errors ->+                              pure+                                ( cliErrors+                                    (preparationExitCode errors)+                                    (map (("preflight: " <>) . renderPreparationError) (toList errors))+                                )+                            Right (prepared :| [])+                              | mutatingDenied (executeOptions ^. #allowMutating) (prepared ^. #safety) ->+                                  pure (cliFailure 2 ["mutating recipe requires --allow-mutating"])+                              | otherwise -> do+                                  executed <-+                                    runHurl+                                      (mkHurlRunner capabilities)+                                      RunRequest+                                        { renderedWorkflow = prepared ^. #renderedWorkflow,+                                          fileRoot = root,+                                          mode,+                                          bindings = prepared ^. #bindings,+                                          options = prepared ^. #options,+                                          outputPolicy = InheritRunOutput,+                                          reportTargets = []+                                        }+                                  pure $ case executed of+                                    Left err ->+                                      cliFailure+                                        (runStartExitCode err)+                                        [renderRunStartError err]+                                    Right result ->+                                      CommandResult+                                        { stdoutLines = [],+                                          stderrLines = hurlCapabilityWarnings capabilities,+                                          exitCode = result ^. #exitCode+                                        }+                            Right _ -> pure (cliFailure 2 ["run and test accept one workflow or recipe, not a matrix"])++buildExecutionInputs :: ExecuteOptions -> Either [Text] (BindingInput, HurlOptions)+buildExecutionInputs executeOptions = buildRuntimeInputs (executeOptions ^. #bindings) (executeOptions ^. #hurl)++buildRuntimeInputs :: BindingOptions -> CliHurlOptions -> Either [Text] (BindingInput, HurlOptions)+buildRuntimeInputs bindingOptions cli = do+  plain <- parseUnique "--variable" parsePlain (bindingOptions ^. #variables)+  secretEnvironments <- parseUnique "--secret-env" parseSecretEnvironment (bindingOptions ^. #secretEnvironments)+  additional <- collect (map (first renderCliHurlOptionError . mkAllowedHurlArgument) (cli ^. #additionalArguments))+  pure+    ( BindingInput+        { plainOverrides = plain,+          secretEnvironmentOverrides = secretEnvironments,+          variableFiles = bindingOptions ^. #variableFiles,+          secretFiles = bindingOptions ^. #secretFiles+        },+      HurlOptions+        { connectTimeoutSeconds = cli ^. #connectTimeoutSeconds,+          maxTimeSeconds = cli ^. #maxTimeSeconds,+          retryCount = cli ^. #retryCount,+          retryIntervalMilliseconds = cli ^. #retryIntervalMilliseconds,+          insecureTls = cli ^. #insecureTls,+          includeHeaders = cli ^. #includeHeaders,+          jsonOutput = cli ^. #jsonOutput,+          verbosity = cli ^. #verbosity,+          curlExportPath = cli ^. #curlExportPath,+          additionalArguments = additional+        }+    )++parsePlain :: Text -> Either Text (ParameterName, HurlValueLiteral)+parsePlain raw = do+  (name, value) <- splitAssignment "--variable" raw+  validateParameterName "--variable" name+  literal <- first (\err -> "--variable " <> name <> ": " <> renderHurlValueLiteralError err) (mkHurlValueLiteral value)+  pure (ParameterName name, literal)++parseSecretEnvironment :: Text -> Either Text (ParameterName, Text)+parseSecretEnvironment raw = do+  (name, environment) <- splitAssignment "--secret-env" raw+  validateParameterName "--secret-env" name+  unlessEither (isValidEnvironmentName environment) ("--secret-env " <> name <> " names invalid environment variable " <> quote environment)+  pure (ParameterName name, environment)++splitAssignment :: Text -> Text -> Either Text (Text, Text)+splitAssignment option raw = case Text.breakOn "=" raw of+  (name, rest)+    | Text.null rest || Text.null name -> Left (option <> " expects NAME=VALUE")+    | otherwise -> Right (name, Text.drop 1 rest)++validateParameterName :: Text -> Text -> Either Text ()+validateParameterName option name =+  unlessEither (isValidParameterName name) (option <> " has invalid parameter name " <> quote name)++parseUnique :: (Ord key) => Text -> (value -> Either Text (key, parsed)) -> [value] -> Either [Text] (Map key parsed)+parseUnique option parser values = do+  parsed <- collect (map parser values)+  let duplicateKeys = duplicates (map fst parsed)+  case duplicateKeys of+    [] -> Right (Map.fromList parsed)+    _ -> Left [option <> " defines the same parameter more than once"]++collect :: [Either Text value] -> Either [Text] [value]+collect values = case [err | Left err <- values] of+  [] -> Right [value | Right value <- values]+  errors -> Left errors++duplicates :: (Ord value) => [value] -> [value]+duplicates values = [value | value : _duplicate : _rest <- groupSorted (sort values)]+  where+    groupSorted [] = []+    groupSorted (value : remaining) =+      let (same, rest) = span (== value) remaining+       in (value : same) : groupSorted rest++unlessEither :: Bool -> err -> Either err ()+unlessEither condition err = if condition then Right () else Left err++hurlfmtExitCode :: HurlfmtError -> Int+hurlfmtExitCode = \case+  InvalidHurl {} -> 2+  HurlfmtNotFound {} -> 3+  HurlfmtFailed {} -> 3++preparationExitCode :: NonEmpty PreparationError -> Int+preparationExitCode errors =+  maximum (map one (toList errors))+  where+    one = \case+      RunFormatFailed _ err -> hurlfmtExitCode err+      _ -> 2++mutatingDenied :: Bool -> SafetyDisposition -> Bool+mutatingDenied allowed = \case+  Classified Mutating -> not allowed+  _ -> False++runStartExitCode :: RunStartError -> Int+runStartExitCode = \case+  InvalidFileRootLayout {} -> 2+  HurlSpawnFailed {} -> 3+  InvalidRunOptions {} -> 2+  SecureRunFileError {} -> 2++quote :: Text -> Text+quote value = "\"" <> value <> "\""++renderCliHurlOptionError :: HurlOptionError -> Text+renderCliHurlOptionError = \case+  UnsupportedHurlArgument rawArgument -> "unsupported --hurl-arg " <> rawArgument+  NegativeOption name value -> name <> " must not be negative, got " <> Text.pack (show value)+  DuplicateReportFormat format -> "duplicate " <> format <> " report target"
+ src/HurlWorkbench/Cli/Command/Suite.hs view
@@ -0,0 +1,139 @@+-- | @hurl-workbench test suite@: whole-suite preflight, optional managed+--   service ownership, bounded Hurl execution, and report summaries.+module HurlWorkbench.Cli.Command.Suite+  ( runSuiteCommand,+    runSuiteCommandWith,+  )+where++import Data.Generics.Labels ()+import Data.Set qualified as Set+import Data.Text qualified as Text+import HurlWorkbench.Check.Run (checkStatus)+import HurlWorkbench.Cli.Command.Matrix (renderCaseSummary, replayCaptured)+import HurlWorkbench.Cli.Command.Run (buildRuntimeInputs)+import HurlWorkbench.Cli.Error (cliErrors, cliFailure)+import HurlWorkbench.Cli.Options+import HurlWorkbench.Cli.Output (CommandResult (..))+import HurlWorkbench.Cli.Workspace (loadValidatedWorkspace)+import HurlWorkbench.Hurl.Capabilities+import HurlWorkbench.Hurl.Format+import HurlWorkbench.Hurl.Run (mkHurlRunner)+import HurlWorkbench.Prelude+import HurlWorkbench.Run.FileRoot+import HurlWorkbench.Run.Prepare (PreparationError (..))+import HurlWorkbench.Service.Resolve (renderServiceError)+import HurlWorkbench.Suite.Resolve qualified as Suite+import HurlWorkbench.Suite.Run qualified as Suite+import HurlWorkbench.Suite.Scenario+import HurlWorkbench.Workflow.Render (RenderedWorkflow)+import HurlWorkbench.Workspace.Types (CheckName (..), ServiceName (..))+import System.FilePath (isAbsolute, (</>))++runSuiteCommand :: GlobalOptions -> FilePath -> SuiteCommandOptions -> IO CommandResult+runSuiteCommand = runSuiteCommandWith detectHurlCapabilities validateRenderedWorkflow++runSuiteCommandWith ::+  IO (Either DependencyError HurlCapabilities) ->+  (HurlfmtCapabilities -> RenderedWorkflow -> IO (Either HurlfmtError ())) ->+  GlobalOptions ->+  FilePath ->+  SuiteCommandOptions ->+  IO CommandResult+runSuiteCommandWith detectCapabilities validateRendered global currentDirectory options =+  loadValidatedWorkspace global currentDirectory >>= \case+    Left errors -> pure (cliFailure 2 errors)+    Right validated -> case validateOptions options of+      Just problem -> pure (cliFailure 2 [problem])+      Nothing -> case buildRuntimeInputs (options ^. #bindings) (options ^. #hurl) of+        Left errors -> pure (cliErrors 2 errors)+        Right (bindingInput, hurlOptions) -> do+          let request root scenarioInputs =+                Suite.SuiteRequest+                  { suiteName = options ^. #suite,+                    fileRoot = root,+                    scenarioInputs,+                    bindingInput,+                    hurlOptions,+                    suiteOptions =+                      Suite.SuiteOptions+                        { jobs = options ^. #jobs,+                          failFastOverride = options ^. #failFastOverride,+                          allowMutating = options ^. #allowMutating,+                          manageService = not (options ^. #externalService),+                          reportFormats = Set.fromList (options ^. #reportFormats),+                          reportDirectory = fmap resolvePath (options ^. #reportDirectory),+                          overwriteReports = options ^. #overwrite+                        }+                  }+              refusal root inputs err = preflightResult <$> Suite.finalizeSuitePreflight validated (request root inputs) err+          cliRoot <- case options ^. #hurl . #fileRoot of+            Nothing -> pure (Right Nothing)+            Just path -> fmap Just <$> validateFileRoot currentDirectory (Just path)+          case cliRoot of+            Left err -> pure (cliFailure 2 [renderFileRootError err])+            Right selected ->+              Suite.preflightFileRoot validated (request selected Nothing) >>= \case+                Left err -> pure (preflightResult err)+                Right root -> do+                  loaded <- case options ^. #scenarioInputsFile of+                    Nothing -> pure (Right Nothing)+                    Just path -> fmap Just <$> loadScenarioInputs (resolvePath path)+                  case loaded of+                    Left issue -> refusal (Just root) Nothing (Suite.SuitePreflightError (Suite.SuiteScenarioFailed issue :| []))+                    Right inputs ->+                      Suite.preflightScenarioInputs validated (request (Just root) inputs) >>= \case+                        Left err -> refusal (Just root) inputs err+                        Right _ ->+                          detectCapabilities >>= \case+                            Left _ -> refusal (Just root) inputs (Suite.SuitePreflightError (Suite.SuiteDependencyUnavailable :| []))+                            Right capabilities ->+                              Suite.runSuiteWith validateRendered (mkHurlRunner capabilities) (capabilities ^. #hurlfmt) validated (request (Just root) inputs) <&> \case+                                Left err -> preflightResult err+                                Right result ->+                                  CommandResult+                                    { stdoutLines = [],+                                      stderrLines =+                                        hurlCapabilityWarnings capabilities+                                          <> replayCaptured (result ^. #cases)+                                          <> map renderCaseSummary (toList (result ^. #cases))+                                          <> ["CHECK " <> unCheckName (event ^. #name) <> " " <> checkStatus event <> maybe "" ((" -> " <>) . Text.pack) (event ^. #stdoutPath) | Suite.CheckStep event <- result ^. #steps]+                                          <> ["SERVICE " <> unServiceName (event ^. #name) <> " " <> event ^. #action <> " " <> event ^. #status | event <- result ^. #services]+                                          <> ["PHASE " <> event ^. #phase <> " " <> Text.pack (show (event ^. #ordinal)) <> " " <> Text.pack (show (event ^. #status)) | event <- result ^. #phases]+                                          <> renderServiceOutcome (result ^. #serviceOutcome)+                                          <> maybe [] (pure . ("SUMMARY " <>) . Text.pack) (result ^. #summaryPath)+                                          <> maybe [] (pure . ("ERROR " <>)) (result ^. #summaryError),+                                      exitCode = result ^. #selectedExitCode+                                    }+  where+    resolvePath path+      | isAbsolute path = path+      | otherwise = currentDirectory </> path++preflightResult :: Suite.SuitePreflightError -> CommandResult+preflightResult err = cliErrors (preflightExitCode err) (map (("preflight: " <>) . Suite.renderSuitePreflightIssue) (toList (err ^. #issues)))++validateOptions :: SuiteCommandOptions -> Maybe Text+validateOptions options+  | options ^. #overwrite && isNothing (options ^. #reportDirectory) = Just "--overwrite requires --report-dir"+  | Set.size (Set.fromList (options ^. #reportFormats)) /= length (options ^. #reportFormats) = Just "each --report format may be requested at most once"+  | otherwise = Nothing++preflightExitCode :: Suite.SuitePreflightError -> Int+preflightExitCode error_ = maximum (2 : map issueExitCode (toList (error_ ^. #issues)))+  where+    issueExitCode = \case+      Suite.SuiteScenarioFailed ScenarioFileUnreadable -> 3+      Suite.SuiteDependencyUnavailable -> 3+      Suite.SuiteReportIoError {} -> 3+      Suite.SuiteRunPreparationFailed (RunFormatFailed _ HurlfmtNotFound {}) -> 3+      Suite.SuiteRunPreparationFailed (RunFormatFailed _ HurlfmtFailed {}) -> 3+      Suite.SuiteCheckFailed _ _ -> 3+      Suite.SuiteServiceResolutionFailed _ -> 4+      _ -> 2++renderServiceOutcome :: Maybe Suite.ServiceOutcome -> [Text]+renderServiceOutcome = \case+  Nothing -> []+  Just Suite.ServiceStopped -> ["SERVICE stopped"]+  Just (Suite.ServiceFailed err) -> ["SERVICE failed (" <> renderServiceError err <> ")"]
+ src/HurlWorkbench/Cli/Command/Validate.hs view
@@ -0,0 +1,66 @@+-- | @hurl-workbench validate@: report the manifest path and entity counts,+--   or every validation issue at once.+module HurlWorkbench.Cli.Command.Validate+  ( runValidate,+    runValidateWith,+    validateSummary,+  )+where++import Data.Generics.Labels ()+import Data.Text qualified as Text+import HurlWorkbench.Cli.Options (GlobalOptions)+import HurlWorkbench.Cli.Output (CommandResult, failure, plural, success)+import HurlWorkbench.Cli.Workspace (loadValidatedWorkspace)+import HurlWorkbench.Hurl.Format+import HurlWorkbench.Prelude+import HurlWorkbench.Workspace.Context (ValidatedWorkspace, validatedManifestPath, validatedWorkspace)++runValidate :: GlobalOptions -> FilePath -> IO CommandResult+runValidate = runValidateWith detectHurlfmtCapabilities validateWorkspaceSyntax++runValidateWith ::+  IO (Either DependencyError HurlfmtCapabilities) ->+  (HurlfmtCapabilities -> ValidatedWorkspace -> IO (Either DependencyError [WorkflowSyntaxError])) ->+  GlobalOptions ->+  FilePath ->+  IO CommandResult+runValidateWith detectCapabilities validateSyntax options currentDirectory =+  loadValidatedWorkspace options currentDirectory >>= \case+    Left errors -> pure (failure errors)+    Right validated ->+      detectCapabilities >>= \case+        Left err -> pure (failure ["error: " <> renderDependencyError err])+        Right capabilities ->+          validateSyntax capabilities validated >>= \case+            Left err -> pure (failure ["error: " <> renderDependencyError err])+            Right [] -> pure (success (validateSummary validated))+            Right issues ->+              pure+                ( failure+                    ( ["Invalid Hurl syntax: " <> Text.pack (validatedManifestPath validated)]+                        <> map (("  " <>) . indentMultiline . renderWorkflowSyntaxError) issues+                        <> [plural (length issues) "workflow issue" "workflow issues" <> " found"]+                    )+                )++indentMultiline :: Text -> Text+indentMultiline = Text.replace "\n" "\n  "++validateSummary :: ValidatedWorkspace -> [Text]+validateSummary vw =+  [ "Valid workspace: " <> Text.pack (validatedManifestPath vw),+    Text.intercalate+      ", "+      [ plural (length (ws ^. #parameters)) "parameter" "parameters",+        plural (length (ws ^. #fragments)) "fragment" "fragments",+        plural (length (ws ^. #workflows)) "workflow" "workflows",+        plural (length (ws ^. #recipes)) "recipe" "recipes",+        plural (length (ws ^. #matrices)) "matrix" "matrices",+        plural (length (ws ^. #checks)) "check" "checks",+        plural (length (ws ^. #services)) "service" "services",+        plural (length (ws ^. #suites)) "suite" "suites"+      ]+  ]+  where+    ws = validatedWorkspace vw
+ src/HurlWorkbench/Cli/Error.hs view
@@ -0,0 +1,34 @@+-- | Consistent CLI failure rendering and exit construction.+module HurlWorkbench.Cli.Error+  ( cliFailure,+    cliErrors,+    renderCliError,+  )+where++import Data.Text qualified as Text+import HurlWorkbench.Cli.Output (CommandResult (..))+import HurlWorkbench.Prelude+import System.Exit (ExitCode (..))++-- | Render one safe, already-domain-specific diagnostic.+renderCliError :: Text -> Text+renderCliError message+  | "error:" `Text.isPrefixOf` Text.toLower message = message+  | otherwise = "error: " <> message++-- | Construct a failure whose first line introduces one possibly-multiline+-- diagnostic. Continuation lines retain their indentation and context.+cliFailure :: Int -> [Text] -> CommandResult+cliFailure status messages =+  CommandResult+    { stdoutLines = [],+      stderrLines = case messages of+        [] -> ["error: command failed"]+        first : rest -> renderCliError first : rest,+      exitCode = ExitFailure status+    }++-- | Construct a failure from several independent diagnostics.+cliErrors :: Int -> [Text] -> CommandResult+cliErrors status = cliFailure status . map renderCliError
+ src/HurlWorkbench/Cli/Help.hs view
@@ -0,0 +1,37 @@+-- | Terminal-aware parser preferences.+module HurlWorkbench.Cli.Help+  ( helpPreferences,+    resolveHelpColumns,+  )+where++import Options.Applicative+  ( ParserPrefs,+    columns,+    prefs,+    showHelpOnEmpty,+    showHelpOnError,+  )+import System.Console.Terminal.Size qualified as Terminal+import System.IO (hIsTerminalDevice, stdout)++-- | Apply the public help behavior at a known output width.+helpPreferences :: Int -> ParserPrefs+helpPreferences width =+  prefs (showHelpOnError <> showHelpOnEmpty <> columns (max 1 width))++-- | Use the actual terminal width when interactive, capped for readability.+-- Piped output uses a stable 80-column layout.+resolveHelpColumns :: IO Int+resolveHelpColumns = do+  interactive <- hIsTerminalDevice stdout+  if not interactive+    then pure defaultColumns+    else do+      window <- Terminal.hSize stdout+      pure $ case window of+        Just size | Terminal.width size > 0 -> min maxColumns (Terminal.width size)+        _ -> defaultColumns+  where+    defaultColumns = 80+    maxColumns = 140
+ src/HurlWorkbench/Cli/Options.hs view
@@ -0,0 +1,684 @@+-- | Command-line grammar:+--+-- > hurl-workbench [--workspace FILE] validate+-- > hurl-workbench [--workspace FILE] list [all|parameters|fragments|workflows|recipes|matrices|services|suites]+-- > hurl-workbench [--workspace FILE] render (workflow|recipe) NAME [--output FILE]+-- > hurl-workbench [--workspace FILE] run (workflow|recipe) NAME [OPTIONS]+-- > hurl-workbench [--workspace FILE] test (workflow|recipe) NAME [OPTIONS]+-- > hurl-workbench [--workspace FILE] test suite NAME [OPTIONS]+-- > hurl-workbench [--workspace FILE] matrix NAME [OPTIONS]+-- > hurl-workbench completions (bash|zsh|fish)+-- > hurl-workbench doctor+module HurlWorkbench.Cli.Options+  ( Options (..),+    GlobalOptions (..),+    Command (..),+    RenderOptions (..),+    ExecuteOptions (..),+    MatrixOptions (..),+    SuiteCommandOptions (..),+    BindingOptions (..),+    emptyBindingOptions,+    CliHurlOptions (..),+    defaultCliHurlOptions,+    ListCategory (..),+    CompletionShell (..),+    allListCategories,+    listCategoryName,+    parseListCategory,+    parserInfo,+  )+where++import Data.Text qualified as Text+import HurlWorkbench.Cli.Version (versionText)+import HurlWorkbench.Hurl.Run (HurlRunMode (..), HurlVerbosity (..))+import HurlWorkbench.Prelude hiding (argument)+import HurlWorkbench.Run.Batch (PositiveInt, mkPositiveInt)+import HurlWorkbench.Run.Selection (RunSelection (..))+import HurlWorkbench.Suite.Resolve (ReportFormat (..))+import HurlWorkbench.Workspace.Types (MatrixName (..), RecipeName (..), SuiteName (..), WorkflowName (..))+import Options.Applicative+  ( Parser,+    ParserInfo,+    argument,+    auto,+    command,+    completeWith,+    eitherReader,+    flag',+    fullDesc,+    header,+    help,+    helper,+    hsubparser,+    info,+    infoOption,+    long,+    many,+    metavar,+    option,+    optional,+    parserOptionGroup,+    progDesc,+    strArgument,+    strOption,+    switch,+    value,+    (<**>),+    (<|>),+  )+import Text.Read (readMaybe)++-- | Parsed argv.+data Options = Options+  { global :: !GlobalOptions,+    cmd :: !Command+  }+  deriving stock (Generic, Eq, Show)++-- | Options shared by every command.+data GlobalOptions = GlobalOptions+  { -- | Explicit manifest; otherwise discovered from the current directory.+    workspace :: !(Maybe FilePath)+  }+  deriving stock (Generic, Eq, Show)++-- | A subcommand.+data Command+  = ValidateCommand+  | ListCommand !ListCategory+  | RenderCommand !RenderOptions+  | RunCommand !ExecuteOptions+  | TestCommand !ExecuteOptions+  | MatrixCommand !MatrixOptions+  | SuiteCommand !SuiteCommandOptions+  | CompletionsCommand !CompletionShell+  | DoctorCommand+  deriving stock (Generic, Eq, Show)++-- | Options for rendering one named workflow.+data RenderOptions = RenderOptions+  { selection :: !RunSelection,+    output :: !(Maybe FilePath),+    explain :: !Bool+  }+  deriving stock (Generic, Eq, Show)++data ExecuteOptions = ExecuteOptions+  { selection :: !RunSelection,+    bindings :: !BindingOptions,+    hurl :: !CliHurlOptions,+    allowMutating :: !Bool+  }+  deriving stock (Generic, Eq, Show)++data MatrixOptions = MatrixOptions+  { matrix :: !MatrixName,+    mode :: !HurlRunMode,+    jobs :: !PositiveInt,+    failFastOverride :: !(Maybe Bool),+    allowMutating :: !Bool,+    outputDirectory :: !(Maybe FilePath),+    overwrite :: !Bool,+    bindings :: !BindingOptions,+    hurl :: !CliHurlOptions+  }+  deriving stock (Generic, Eq, Show)++data SuiteCommandOptions = SuiteCommandOptions+  { suite :: !SuiteName,+    jobs :: !PositiveInt,+    failFastOverride :: !(Maybe Bool),+    allowMutating :: !Bool,+    externalService :: !Bool,+    scenarioInputsFile :: !(Maybe FilePath),+    reportFormats :: ![ReportFormat],+    reportDirectory :: !(Maybe FilePath),+    overwrite :: !Bool,+    bindings :: !BindingOptions,+    hurl :: !CliHurlOptions+  }+  deriving stock (Generic, Eq, Show)++data BindingOptions = BindingOptions+  { variables :: ![Text],+    variableFiles :: ![FilePath],+    secretEnvironments :: ![Text],+    secretFiles :: ![FilePath]+  }+  deriving stock (Generic, Eq, Show)++emptyBindingOptions :: BindingOptions+emptyBindingOptions = BindingOptions [] [] [] []++data CliHurlOptions = CliHurlOptions+  { connectTimeoutSeconds :: !(Maybe Int),+    maxTimeSeconds :: !(Maybe Int),+    retryCount :: !(Maybe Int),+    retryIntervalMilliseconds :: !(Maybe Int),+    insecureTls :: !Bool,+    includeHeaders :: !Bool,+    jsonOutput :: !Bool,+    verbosity :: !(Maybe HurlVerbosity),+    fileRoot :: !(Maybe FilePath),+    curlExportPath :: !(Maybe FilePath),+    additionalArguments :: ![Text]+  }+  deriving stock (Generic, Eq, Show)++defaultCliHurlOptions :: CliHurlOptions+defaultCliHurlOptions =+  CliHurlOptions+    { connectTimeoutSeconds = Nothing,+      maxTimeSeconds = Nothing,+      retryCount = Nothing,+      retryIntervalMilliseconds = Nothing,+      insecureTls = False,+      includeHeaders = False,+      jsonOutput = False,+      verbosity = Nothing,+      fileRoot = Nothing,+      curlExportPath = Nothing,+      additionalArguments = []+    }++data HttpRetryOptions = HttpRetryOptions !(Maybe Int) !(Maybe Int) !(Maybe Int) !(Maybe Int) !Bool++data OutputDiagnosticOptions = OutputDiagnosticOptions !Bool !Bool !(Maybe HurlVerbosity) !(Maybe FilePath)++data BatchControlOptions = BatchControlOptions !HurlRunMode !PositiveInt !(Maybe Bool) !Bool++data MatrixOutputOptions = MatrixOutputOptions !(Maybe FilePath) !Bool !OutputDiagnosticOptions++data SuiteControlOptions = SuiteControlOptions !PositiveInt !(Maybe Bool) !Bool++data SuiteReportOptions = SuiteReportOptions ![ReportFormat] !(Maybe FilePath) !Bool++-- | Which entities @list@ prints.+data ListCategory+  = AllCategories+  | ParametersCategory+  | FragmentsCategory+  | WorkflowsCategory+  | RecipesCategory+  | MatricesCategory+  | ChecksCategory+  | ServicesCategory+  | SuitesCategory+  deriving stock (Generic, Eq, Ord, Show, Enum, Bounded)++-- | Supported public shell-completion formats.+data CompletionShell = Bash | Zsh | Fish+  deriving stock (Generic, Eq, Ord, Show, Enum, Bounded)++allListCategories :: [ListCategory]+allListCategories = [minBound .. maxBound]++-- | The command-line spelling of a category.+listCategoryName :: ListCategory -> Text+listCategoryName = \case+  AllCategories -> "all"+  ParametersCategory -> "parameters"+  FragmentsCategory -> "fragments"+  WorkflowsCategory -> "workflows"+  RecipesCategory -> "recipes"+  MatricesCategory -> "matrices"+  ChecksCategory -> "checks"+  ServicesCategory -> "services"+  SuitesCategory -> "suites"++parseListCategory :: String -> Either String ListCategory+parseListCategory raw =+  case filter ((== Text.pack raw) . listCategoryName) allListCategories of+    [category] -> Right category+    _ ->+      Left+        ( "unknown category "+            <> show raw+            <> "; expected one of: "+            <> Text.unpack (Text.intercalate ", " (map listCategoryName allListCategories))+        )++parserInfo :: ParserInfo Options+parserInfo =+  info+    (optionsParser <**> helper <**> versionOption)+    ( fullDesc+        <> header "hurl-workbench - compose, explore, and test reusable Hurl workflows"+        <> progDesc "Keep each Hurl entry once, combine entries into named workflows, and validate the workspace that describes them."+    )+  where+    versionOption = infoOption versionText (long "version" <> help "Show package version and build revision")++optionsParser :: Parser Options+optionsParser = Options <$> globalOptionsParser <*> commandParser++globalOptionsParser :: Parser GlobalOptions+globalOptionsParser =+  parserOptionGroup "Workspace" $+    GlobalOptions+      <$> optional+        ( strOption+            ( long "workspace"+                <> metavar "FILE"+                <> help "Workspace manifest to use instead of discovering hurl-workbench.dhall from the current directory upward"+            )+        )++commandParser :: Parser Command+commandParser =+  hsubparser+    ( command+        "validate"+        ( info+            (pure ValidateCommand)+            (progDesc "Check the workspace and report every problem at once")+        )+        <> command+          "list"+          ( info+              (ListCommand <$> listCategoryParser)+              (progDesc "List the named entities in the workspace")+          )+        <> command+          "render"+          ( info+              renderCommandParser+              (progDesc "Compose and syntax-check inspectable Hurl source")+          )+        <> command+          "run"+          ( info+              (executeCommandParser RunCommand)+              (progDesc "Execute one workflow in Hurl client mode")+          )+        <> command+          "test"+          ( info+              testCommandParser+              (progDesc "Execute one workflow, recipe, or integration suite in Hurl test mode")+          )+        <> command+          "matrix"+          ( info+              (MatrixCommand <$> matrixOptionsParser)+              (progDesc "Execute every declared case in one named matrix")+          )+        <> command+          "completions"+          ( info+              (CompletionsCommand <$> completionShellParser)+              (progDesc "Generate a completion script for Bash, Zsh, or Fish")+          )+        <> command+          "doctor"+          ( info+              (pure DoctorCommand)+              (progDesc "Report Hurl and Hurlfmt paths, versions, and support status")+          )+    )++completionShellParser :: Parser CompletionShell+completionShellParser =+  argument+    (eitherReader parseShell)+    (metavar "SHELL" <> completeWith ["bash", "zsh", "fish"] <> help "One of bash, zsh, or fish")+  where+    parseShell = \case+      "bash" -> Right Bash+      "zsh" -> Right Zsh+      "fish" -> Right Fish+      other -> Left ("unknown shell " <> show other <> "; expected bash, zsh, or fish")++renderCommandParser :: Parser Command+renderCommandParser =+  hsubparser+    ( command+        "workflow"+        ( info+            (RenderCommand <$> renderOptionsParser (SelectWorkflow . WorkflowName . Text.pack))+            (progDesc "Render one named workflow")+        )+        <> command+          "recipe"+          ( info+              (RenderCommand <$> renderOptionsParser (SelectRecipe . RecipeName . Text.pack))+              (progDesc "Render the workflow selected by one named recipe")+          )+    )++renderOptionsParser :: (String -> RunSelection) -> Parser RenderOptions+renderOptionsParser selectionConstructor =+  RenderOptions+    <$> (selectionConstructor <$> strArgument (metavar "NAME" <> help "Workflow or recipe name"))+    <*> parserOptionGroup+      "Output"+      ( optional+          ( strOption+              ( long "output"+                  <> metavar "FILE"+                  <> help "Atomically replace FILE instead of writing Hurl source to stdout"+              )+          )+      )+    <*> switch (long "explain" <> help "Print the recipe's binding sources to stderr without values")++executeCommandParser :: (ExecuteOptions -> Command) -> Parser Command+executeCommandParser constructor =+  hsubparser+    ( command+        "workflow"+        ( info+            (constructor <$> executeOptionsParser (SelectWorkflow . WorkflowName . Text.pack))+            (progDesc "Execute one unclassified low-level workflow")+        )+        <> command+          "recipe"+          ( info+              (constructor <$> executeOptionsParser (SelectRecipe . RecipeName . Text.pack))+              (progDesc "Execute one safety-classified recipe")+          )+    )++testCommandParser :: Parser Command+testCommandParser =+  hsubparser+    ( command+        "workflow"+        ( info+            (TestCommand <$> executeOptionsParser (SelectWorkflow . WorkflowName . Text.pack))+            (progDesc "Execute one unclassified low-level workflow")+        )+        <> command+          "recipe"+          ( info+              (TestCommand <$> executeOptionsParser (SelectRecipe . RecipeName . Text.pack))+              (progDesc "Execute one safety-classified recipe")+          )+        <> command+          "suite"+          ( info+              (SuiteCommand <$> suiteCommandOptionsParser)+              (progDesc "Execute every run in one integration suite")+          )+    )++executeOptionsParser :: (String -> RunSelection) -> Parser ExecuteOptions+executeOptionsParser selectionConstructor =+  ExecuteOptions+    <$> (selectionConstructor <$> strArgument (metavar "NAME" <> help "Workflow or recipe name"))+    <*> bindingOptionsParser+    <*> cliHurlOptionsParser+    <*> parserOptionGroup+      "Execution safety"+      (switch (long "allow-mutating" <> help "Authorize this invocation to run a mutating recipe"))++matrixOptionsParser :: Parser MatrixOptions+matrixOptionsParser =+  assemble+    <$> (MatrixName . Text.pack <$> strArgument (metavar "NAME" <> help "Matrix name"))+    <*> batchControlOptionsParser+    <*> bindingOptionsParser+    <*> httpRetryOptionsParser+    <*> matrixOutputOptionsParser+    <*> fileRootParser+    <*> advancedArgumentsParser+  where+    assemble+      matrix+      (BatchControlOptions mode jobs failFastOverride allowMutating)+      bindings+      http+      (MatrixOutputOptions outputDirectory overwrite diagnostics)+      root+      additionalArguments =+        MatrixOptions+          { matrix,+            mode,+            jobs,+            failFastOverride,+            allowMutating,+            outputDirectory,+            overwrite,+            bindings,+            hurl = assembleCliHurlOptions http diagnostics root additionalArguments+          }++suiteCommandOptionsParser :: Parser SuiteCommandOptions+suiteCommandOptionsParser =+  assemble+    <$> (SuiteName . Text.pack <$> strArgument (metavar "NAME" <> help "Suite name"))+    <*> suiteControlOptionsParser+    <*> parserOptionGroup+      "Service lifecycle"+      (switch (long "external-service" <> help "Skip setup, managed services, and teardown; suites with lifecycle steps are refused"))+    <*> optional (strOption (long "scenario-inputs" <> metavar "FILE" <> help "Private per-scenario fixture and endpoint assignments"))+    <*> suiteReportOptionsParser+    <*> bindingOptionsParser+    <*> httpRetryOptionsParser+    <*> outputDiagnosticOptionsParser+    <*> fileRootParser+    <*> advancedArgumentsParser+  where+    assemble+      suite+      (SuiteControlOptions jobs failFastOverride allowMutating)+      externalService+      scenarioInputsFile+      (SuiteReportOptions reportFormats reportDirectory overwrite)+      bindings+      http+      diagnostics+      root+      additionalArguments =+        SuiteCommandOptions+          { suite,+            jobs,+            failFastOverride,+            allowMutating,+            externalService,+            scenarioInputsFile,+            reportFormats,+            reportDirectory,+            overwrite,+            bindings,+            hurl = assembleCliHurlOptions http diagnostics root additionalArguments+          }++suiteControlOptionsParser :: Parser SuiteControlOptions+suiteControlOptionsParser =+  parserOptionGroup "Suite control" $+    SuiteControlOptions+      <$> option+        (eitherReader parsePositiveInt)+        (long "jobs" <> metavar "N" <> value oneJob <> help "Maximum concurrent Hurl processes (default: 1)")+      <*> optional+        ( flag' True (long "fail-fast" <> help "Stop scheduling after the first observed failure")+            <|> flag' False (long "keep-going" <> help "Run every case despite failures")+        )+      <*> switch (long "allow-mutating" <> help "Authorize mutating and unclassified runs in this invocation")++suiteReportOptionsParser :: Parser SuiteReportOptions+suiteReportOptionsParser =+  parserOptionGroup "Reports" $+    SuiteReportOptions+      <$> many+        ( option+            (eitherReader parseReportFormat)+            (long "report" <> metavar "junit|html|json|tap" <> help "Emit one isolated report of this format per expanded run; repeat for several formats")+        )+      <*> optional+        (strOption (long "report-dir" <> metavar "DIR" <> help "Write reports and summary.json beneath DIR/SUITE (required for checks)"))+      <*> switch (long "overwrite" <> help "Replace only the selected suite subtree beneath --report-dir")++batchControlOptionsParser :: Parser BatchControlOptions+batchControlOptionsParser =+  parserOptionGroup "Batch control" $+    BatchControlOptions+      <$> option+        (eitherReader parseMode)+        (long "mode" <> metavar "run|test" <> value ClientMode <> help "Use Hurl client mode (run) or test mode")+      <*> option+        (eitherReader parsePositiveInt)+        (long "jobs" <> metavar "N" <> value oneJob <> help "Maximum concurrent Hurl processes (default: 1)")+      <*> optional+        ( flag' True (long "fail-fast" <> help "Stop scheduling after the first observed failure")+            <|> flag' False (long "keep-going" <> help "Run every case despite failures")+        )+      <*> switch (long "allow-mutating" <> help "Authorize every mutating case in this invocation")++matrixOutputOptionsParser :: Parser MatrixOutputOptions+matrixOutputOptionsParser =+  parserOptionGroup "Output" $+    MatrixOutputOptions+      <$> optional+        ( strOption+            (long "output-dir" <> metavar "DIR" <> help "Write one owner-only client response artifact per case")+        )+      <*> switch (long "overwrite" <> help "Replace the exact response artifacts for this batch")+      <*> outputDiagnosticOptionsFields++bindingOptionsParser :: Parser BindingOptions+bindingOptionsParser =+  parserOptionGroup "Bindings" $+    BindingOptions+      <$> many+        ( Text.pack+            <$> strOption+              ( long "variable"+                  <> metavar "NAME=VALUE"+                  <> help "Set one plain workflow parameter; repeat for several parameters"+              )+        )+      <*> many+        ( strOption+            ( long "variables-file"+                <> metavar "FILE"+                <> help "Load plain parameters from a Hurl properties file; later files win"+            )+        )+      <*> many+        ( Text.pack+            <$> strOption+              ( long "secret-env"+                  <> metavar "NAME=ENVIRONMENT_NAME"+                  <> help "Read a secret parameter from the named environment variable"+              )+        )+      <*> many+        ( strOption+            ( long "secrets-file"+                <> metavar "FILE"+                <> help "Load secret parameters from a Hurl secrets file; later files win"+            )+        )++cliHurlOptionsParser :: Parser CliHurlOptions+cliHurlOptionsParser = assembleCliHurlOptions <$> httpRetryOptionsParser <*> outputDiagnosticOptionsParser <*> fileRootParser <*> advancedArgumentsParser++assembleCliHurlOptions :: HttpRetryOptions -> OutputDiagnosticOptions -> Maybe FilePath -> [Text] -> CliHurlOptions+assembleCliHurlOptions+  (HttpRetryOptions connectTimeout maxTime retries retryInterval insecure)+  (OutputDiagnosticOptions include json verbosity curl)+  root+  additional =+    CliHurlOptions+      { connectTimeoutSeconds = connectTimeout,+        maxTimeSeconds = maxTime,+        retryCount = retries,+        retryIntervalMilliseconds = retryInterval,+        insecureTls = insecure,+        includeHeaders = include,+        jsonOutput = json,+        verbosity,+        fileRoot = root,+        curlExportPath = curl,+        additionalArguments = additional+      }++httpRetryOptionsParser :: Parser HttpRetryOptions+httpRetryOptionsParser =+  parserOptionGroup "HTTP and retry" $+    HttpRetryOptions+      <$> optional (integerOption "connect-timeout" "SECONDS" "Maximum connection time in seconds")+      <*> optional (integerOption "max-time" "SECONDS" "Maximum transfer time in seconds")+      <*> optional (integerOption "retry" "NUM" "Maximum retry count")+      <*> optional (integerOption "retry-interval" "MILLISECONDS" "Delay between retries in milliseconds")+      <*> switch (long "insecure" <> help "Allow insecure TLS connections")++outputDiagnosticOptionsParser :: Parser OutputDiagnosticOptions+outputDiagnosticOptionsParser =+  parserOptionGroup "Output and diagnostics" outputDiagnosticOptionsFields++outputDiagnosticOptionsFields :: Parser OutputDiagnosticOptions+outputDiagnosticOptionsFields =+  OutputDiagnosticOptions+    <$> switch (long "include" <> help "Include response headers in client output")+    <*> switch (long "json" <> help "Emit JSON output where Hurl supports it")+    <*> optional+      ( flag' Verbose (long "verbose" <> help "Enable Hurl verbose diagnostics")+          <|> flag' VeryVerbose (long "very-verbose" <> help "Enable Hurl and libcurl debug diagnostics")+      )+    <*> optional+      ( strOption+          ( long "curl"+              <> metavar "FILE"+              <> help "Export requests as curl commands to an owner-only file"+          )+      )++advancedArgumentsParser :: Parser [Text]+advancedArgumentsParser =+  parserOptionGroup "Advanced Hurl arguments" $+    many+      ( Text.pack+          <$> strOption+            ( long "hurl-arg"+                <> metavar "FLAG"+                <> help "Pass one audited zero-argument Hurl flag"+            )+      )++integerOption :: String -> String -> String -> Parser Int+integerOption name valueName description =+  option auto (long name <> metavar valueName <> help description)++parseMode :: String -> Either String HurlRunMode+parseMode = \case+  "run" -> Right ClientMode+  "test" -> Right TestMode+  other -> Left ("unknown matrix mode " <> show other <> "; expected run or test")++parsePositiveInt :: String -> Either String PositiveInt+parsePositiveInt raw = case readMaybe raw of+  Nothing -> Left ("expected a positive integer, got " <> show raw)+  Just parsedValue -> case mkPositiveInt parsedValue of+    Left _ -> Left ("expected a positive integer, got " <> show raw)+    Right positive -> Right positive++parseReportFormat :: String -> Either String ReportFormat+parseReportFormat = \case+  "junit" -> Right JUnit+  "html" -> Right Html+  "json" -> Right Json+  "tap" -> Right Tap+  other -> Left ("unknown report format " <> show other <> "; expected junit, html, json, or tap")++oneJob :: PositiveInt+oneJob = case mkPositiveInt 1 of+  Right positive -> positive+  Left _ -> error "one is positive"++listCategoryParser :: Parser ListCategory+listCategoryParser =+  argument+    (eitherReader parseListCategory)+    ( metavar "CATEGORY"+        <> value AllCategories+        <> completeWith (map (Text.unpack . listCategoryName) allListCategories)+        <> help "One of all, parameters, fragments, workflows, recipes, matrices, checks, services, suites (default: all)"+    )++fileRootParser :: Parser (Maybe FilePath)+fileRootParser = parserOptionGroup "Request body files" $ optional (strOption (long "file-root" <> metavar "DIR" <> help "Read file request bodies from a caller-owned directory (relative to invocation)"))
+ src/HurlWorkbench/Cli/Output.hs view
@@ -0,0 +1,51 @@+-- | Command results as data, so commands can be tested without capturing+--   process handles, plus a small aligned-table renderer.+module HurlWorkbench.Cli.Output+  ( CommandResult (..),+    success,+    failure,+    emitResult,+    renderTable,+    plural,+  )+where++import Data.Text qualified as Text+import Data.Text.IO qualified as Text.IO+import HurlWorkbench.Prelude+import System.Exit (ExitCode (..), exitWith)+import System.IO (stderr)++-- | What a command prints and how the process exits.+data CommandResult = CommandResult+  { stdoutLines :: ![Text],+    stderrLines :: ![Text],+    exitCode :: !ExitCode+  }+  deriving stock (Generic, Eq, Show)++success :: [Text] -> CommandResult+success out = CommandResult {stdoutLines = out, stderrLines = [], exitCode = ExitSuccess}++-- | Workbench failures (discovery, decoding, validation) exit with status 1.+failure :: [Text] -> CommandResult+failure err = CommandResult {stdoutLines = [], stderrLines = err, exitCode = ExitFailure 1}++emitResult :: CommandResult -> IO ()+emitResult CommandResult {stdoutLines, stderrLines, exitCode} = do+  traverse_ Text.IO.putStrLn stdoutLines+  traverse_ (Text.IO.hPutStrLn stderr) stderrLines+  when (exitCode /= ExitSuccess) (exitWith exitCode)++-- | Left-aligned columns separated by two spaces; empty cells print as @-@.+renderTable :: [Text] -> [[Text]] -> [Text]+renderTable headings rows =+  map renderRow (headings : map (map orDash) rows)+  where+    orDash cell = if Text.null cell then "-" else cell+    widths = foldr (zipWith max . map Text.length) (map (const 0) headings) (headings : map (map orDash) rows)+    renderRow cells = Text.stripEnd (Text.intercalate "  " (zipWith (`Text.justifyLeft` ' ') widths cells))++-- | @plural 2 "recipe" "recipes"@ is @2 recipes@.+plural :: Int -> Text -> Text -> Text+plural n one many = Text.pack (show n) <> " " <> (if n == 1 then one else many)
+ src/HurlWorkbench/Cli/Version.hs view
@@ -0,0 +1,33 @@+{-# LANGUAGE CPP #-}+{-# LANGUAGE TemplateHaskell #-}++-- | Build identity shown by @--version@.+module HurlWorkbench.Cli.Version+  ( versionText,+    revisionText,+  )+where++import Data.Version (showVersion)+import GitHash qualified+import Paths_hurl_workbench_cli qualified as Package++-- | The package version followed by the short build revision.+versionText :: String+versionText = "hurl-workbench " <> showVersion Package.version <> " (" <> revisionText <> ")"++-- | Prefer the reproducible revision injected by Nix. Local builds fall back+-- to the repository revision captured by Template Haskell; source archives+-- without either form of metadata remain buildable.+revisionText :: String+#ifdef GIT_HASH+revisionText = take 7 GIT_HASH+#else+revisionText =+  revisionFromGitInfo $$(GitHash.tGitInfoCwdTry)++revisionFromGitInfo :: Either String GitHash.GitInfo -> String+revisionFromGitInfo = \case+    Right gitInfo -> take 7 (GitHash.giHash gitInfo)+    Left _ -> "unknown"+#endif
+ src/HurlWorkbench/Cli/Workspace.hs view
@@ -0,0 +1,43 @@+-- | Load and validate the workspace once for every command. Command+--   handlers receive only a 'ValidatedWorkspace'.+module HurlWorkbench.Cli.Workspace+  ( loadValidatedWorkspace,+    withValidatedWorkspace,+    renderIssueReport,+  )+where++import Data.Generics.Labels ()+import Data.Text qualified as Text+import HurlWorkbench.Cli.Options (GlobalOptions)+import HurlWorkbench.Cli.Output (CommandResult, failure, plural)+import HurlWorkbench.Prelude+import HurlWorkbench.Workspace.Context (ValidatedWorkspace, WorkspaceContext, loadWorkspaceContext)+import HurlWorkbench.Workspace.Discover (discoverWorkspace)+import HurlWorkbench.Workspace.Error (renderWorkspaceError)+import HurlWorkbench.Workspace.Validate (ValidationIssue, renderValidationIssue, validateWorkspace)++-- | Discover, decode, and validate. The start directory is the process's+--   current directory, passed explicitly so tests can choose it.+loadValidatedWorkspace :: GlobalOptions -> FilePath -> IO (Either [Text] ValidatedWorkspace)+loadValidatedWorkspace options currentDirectory = do+  discovered <- discoverWorkspace (options ^. #workspace) currentDirectory+  case discovered of+    Left err -> pure (Left ["error: " <> renderWorkspaceError err])+    Right source ->+      loadWorkspaceContext source >>= \case+        Left err -> pure (Left ["error: " <> renderWorkspaceError err])+        Right context ->+          validateWorkspace context <&> \case+            Left issues -> Left (renderIssueReport context issues)+            Right validated -> Right validated++withValidatedWorkspace :: GlobalOptions -> FilePath -> (ValidatedWorkspace -> CommandResult) -> IO CommandResult+withValidatedWorkspace options currentDirectory handler =+  either failure handler <$> loadValidatedWorkspace options currentDirectory++renderIssueReport :: WorkspaceContext -> NonEmpty ValidationIssue -> [Text]+renderIssueReport context issues =+  ["Invalid workspace: " <> Text.pack (context ^. #manifestPath)]+    <> map (("  " <>) . renderValidationIssue) (toList issues)+    <> [plural (length issues) "issue" "issues" <> " found"]
+ test/CheckTest.hs view
@@ -0,0 +1,208 @@+module CheckTest (tests) where++import Control.Concurrent (threadDelay)+import Control.Concurrent.Async (cancel, waitCatch, withAsync)+import Control.Concurrent.MVar (MVar, newMVar, withMVar)+import Data.Generics.Labels ()+import Data.Text qualified as Text+import Data.Text.IO qualified as Text.IO+import HurlWorkbench.Cli (runCommandWithDependencies)+import HurlWorkbench.Cli.Options+import HurlWorkbench.Cli.Output+import HurlWorkbench.Hurl.Capabilities+import HurlWorkbench.Hurl.Format+import HurlWorkbench.Prelude+import Network.HTTP.Types (status200)+import Network.Wai (Application, pathInfo, responseLBS)+import Network.Wai.Handler.Warp (testWithApplication)+import Options.Applicative (ParserResult (..), defaultPrefs, execParserPure)+import System.Directory (canonicalizePath, doesFileExist, removeFile)+import System.Exit (ExitCode (..))+import System.FilePath ((</>))+import System.IO.Temp (withSystemTempDirectory)+import System.Posix.Files (setFileMode)+import System.Timeout qualified as Timeout+import Test.Tasty+import Test.Tasty.HUnit++tests :: TestTree+tests =+  testGroup+    "public executable checks"+    [ testCase "jobs=2 drains both sides, lists checks and uses private artifacts" $+        fixture $ \root manifest -> do+          result <- invoke root manifest "ordered" []+          result ^. #exitCode @?= ExitSuccess+          events <- Text.lines <$> Text.IO.readFile (root </> "events")+          let before = ["before1-end", "before2-end"]+              after = ["after1-start", "after2-start"]+              position label = length (takeWhile (/= label) events)+          assertBool "barrier after both earlier ends" (all ((< position "check") . position) before)+          assertBool "barrier before both later starts" (all ((> position "check") . position) after)+          summary <- Text.IO.readFile (root </> "reports" </> "ordered" </> "summary.json")+          for_ ["\"kind\":\"check\"", "\"name\":\"parity\"", "checks/parity/stdout"] $ \part -> assertBool "named summary" (part `Text.isInfixOf` summary)+          let ordinary = Text.unlines (result ^. #stderrLines) <> summary+          assertBool "sensitive output absent" (not ("secret-sentinel" `Text.isInfixOf` ordinary))+          Text.IO.readFile (root </> "reports" </> "ordered" </> "checks" </> "parity" </> "stdout") >>= (\captured -> assertBool "private output retained" ("secret-sentinel" `Text.isInfixOf` captured))+          listed <- command root manifest ["list", "checks"]+          listed ^. #exitCode @?= ExitSuccess+          assertBool "check category" ("parity" `Text.isInfixOf` Text.unlines (listed ^. #stdoutLines))+          repeated <- invoke root manifest "ordered" []+          repeated ^. #exitCode @?= ExitFailure 2,+      testCase "status mismatch keeps going, fail-fast skips later Hurl and named checks" $+        fixture $ \root manifest -> do+          mismatch <- invoke root manifest "mismatch" []+          mismatch ^. #exitCode @?= ExitFailure 7+          Text.lines <$> Text.IO.readFile (root </> "events") >>= (\events -> assertBool "keep-going" ("after1-end" `elem` events))+          writeFile (root </> "events") ""+          stopped <- invoke root manifest "mismatch" ["--fail-fast", "--overwrite"]+          stopped ^. #exitCode @?= ExitFailure 7+          events <- Text.lines <$> Text.IO.readFile (root </> "events")+          assertBool "later Hurl skipped" (not ("after1-start" `elem` events))+          summary <- Text.IO.readFile (root </> "reports" </> "mismatch" </> "summary.json")+          assertBool "explicit later check skip" ("skipped-fail-fast" `Text.isInfixOf` summary)+          assertBool "named mismatch" ("\"actualStatus\":7" `Text.isInfixOf` summary),+      testCase "ordinary failures use declaration order and a later spawn failure retains exit 3 priority" $+        fixture $ \root manifest -> do+          firstHurl <- invoke root manifest "hurl-first" []+          firstHurl ^. #exitCode @?= ExitFailure 4+          firstCheck <- invoke root manifest "check-first" []+          firstCheck ^. #exitCode @?= ExitFailure 7+          spawnFailure <- invoke root manifest "spawn-race" []+          spawnFailure ^. #exitCode @?= ExitFailure 3,+      testCase "checks-only supports expected nonzero and missing reports refuse before execution" $+        fixture $ \root manifest -> do+          missing <- command root manifest ["test", "suite", "only", "--secrets-file", root </> "secrets"]+          missing ^. #exitCode @?= ExitFailure 2+          doesFileExist (root </> "events") >>= (@?= False)+          result <- invoke root manifest "only" []+          result ^. #exitCode @?= ExitSuccess,+      testCase "late missing environment rejects whole suite before setup or Hurl" $+        fixture $ \root manifest -> do+          result <- invoke root manifest "late" []+          result ^. #exitCode @?= ExitFailure 3+          doesFileExist (root </> "events") >>= (@?= False),+      testCase "a check failure still tears down services before teardown" $+        fixture $ \root manifest -> do+          result <- invoke root manifest "managed-failure" []+          result ^. #exitCode @?= ExitFailure 7+          events <- Text.lines <$> Text.IO.readFile (root </> "events")+          drop (length events - 2) events @?= ["service-stop", "teardown"],+      testCase "service death cancels a check, preserves both failures and tears down" $+        fixture $ \root manifest -> do+          result <- bounded (invoke root manifest "service-death" [])+          result ^. #exitCode @?= ExitFailure 4+          events <- Text.lines <$> Text.IO.readFile (root </> "events")+          last events @?= "teardown"+          summary <- Text.IO.readFile (root </> "reports" </> "service-death" </> "summary.json")+          assertBool "check interruption retained" ("\"status\":\"cancelled\"" `Text.isInfixOf` summary)+          assertBool "service failure retained" ("\"service\":\"failed\"" `Text.isInfixOf` summary),+      testCase "timeout cleans the check before later Hurl; cancellation writes partial summary" $+        fixture $ \root manifest -> do+          result <- bounded (invoke root manifest "timeout" [])+          result ^. #exitCode @?= ExitFailure 124+          events <- Text.lines <$> Text.IO.readFile (root </> "events")+          assertBool "keep-going after timeout" ("after1-end" `elem` events)+          withAsync (invoke root manifest "cancel" []) $ \worker -> do+            bounded (waitFor (root </> "cancel-started"))+            bounded (cancel worker)+            waitCatch worker >>= \case Left _ -> pure (); Right _ -> assertFailure "cancel swallowed"+          summary <- Text.IO.readFile (root </> "reports" </> "cancel" </> "summary.json")+          assertBool "cancelled summary" ("\"status\":\"cancelled\"" `Text.isInfixOf` summary)+    ]++command :: FilePath -> FilePath -> [String] -> IO CommandResult+command root manifest args = case execParserPure defaultPrefs parserInfo args of+  Success options -> runCommandWithDependencies detectHurlfmtCapabilities detectHurlCapabilities validateRenderedWorkflow (\_ _ -> pure (Right [])) (GlobalOptions (Just manifest)) root (options ^. #cmd)+  _ -> assertFailure ("parse failed: " <> show args)++invoke :: FilePath -> FilePath -> String -> [String] -> IO CommandResult+invoke root manifest suite extra = command root manifest (["test", "suite", suite, "--jobs", "2", "--report-dir", root </> "reports", "--secrets-file", root </> "secrets"] <> extra)++fixture :: (FilePath -> FilePath -> IO a) -> IO a+fixture action = withSystemTempDirectory "check-cli" $ \directory -> do+  root <- canonicalizePath directory+  schema <- canonicalizePath "schema/package.dhall"+  Text.IO.writeFile (root </> "check.py") checkScript+  Text.IO.writeFile (root </> "service.py") serviceScript+  setFileMode (root </> "check.py") 0o700+  writeFile (root </> "secrets") "token=secret-sentinel\n"+  setFileMode (root </> "secrets") 0o600+  lock <- newMVar ()+  testWithApplication (pure (application lock root)) $ \port -> do+    for_ ["before1", "before2", "after1", "after2", "fail", "remove"] $ \name ->+      Text.IO.writeFile (root </> name <> ".hurl") ("GET http://127.0.0.1:" <> Text.pack (show port) <> "/" <> Text.pack name <> (if name == "fail" then "\nHTTP 201\n" else "\nHTTP 200\n"))+    let manifest = root </> "hurl-workbench.dhall"+    Text.IO.writeFile manifest (source (Text.pack schema))+    action root manifest++application :: MVar () -> FilePath -> Application+application lock root request respond = do+  let label = Text.unpack (Text.intercalate "/" (pathInfo request))+  withMVar lock $ \_ -> appendFile (root </> "events") (label <> "-start\n")+  when (label == "remove") (removeFile (root </> "check.py"))+  threadDelay 60000+  withMVar lock $ \_ -> appendFile (root </> "events") (label <> "-end\n")+  respond (responseLBS status200 [] "ok")++source :: Text -> Text+source schema =+  Text.unlines+    [ "let S = " <> schema,+      "let check = \\(name : Text) -> \\(mode : Text) -> S.Check::{name = name, command = S.CommandSpec::{executable = \"check.py\", arguments = [mode, \"{{value}}\"], environmentPolicy = S.EnvironmentPolicy.Clean [\"PATH\"], environment = [ {variable = \"TOKEN\", parameter = \"token\"} ] }}",+      "let phase = S.CommandSpec::{executable = \"python3\", arguments = [\"-c\", \"open('events','a').write('teardown\\\\n')\"]}",+      "let managed = \\(name : Text) -> \\(run : S.RunReference) -> S.Suite::{name = name, services = [\"server\"], teardown = [phase], runs = [run]}",+      "in S.Workspace::{schemaVersion = 1,",+      " parameters = [ S.Parameter::{name = \"value\", defaultValue = Some \"one argument\"}, S.Parameter::{name = \"token\", kind = S.ParameterKind.Secret} ],",+      " fragments = [" <> Text.intercalate "," ["S.Fragment::{name = \"" <> n <> "\", path = \"" <> n <> ".hurl\"}" | n <- names] <> "],",+      " workflows = [" <> Text.intercalate "," ["S.Workflow::{name = \"" <> n <> "\", fragments = [\"" <> n <> "\"]}" | n <- names] <> "],",+      " recipes = [" <> Text.intercalate "," ["S.Recipe::{name = \"" <> n <> "\", workflow = \"" <> n <> "\", safety = S.Safety.ReadOnly}" | n <- names] <> "],",+      " checks = [check \"parity\" \"0\", check \"mismatch\" \"7\", check \"expected\" \"7\" // {expectedStatus = 7}, check \"later\" \"0\", check \"death\" \"death\", check \"timeout\" \"timeout\" // {timeoutSeconds = Some 1}, check \"cancel\" \"cancel\", (check \"bad\" \"0\") with command.environmentPolicy = S.EnvironmentPolicy.Clean [\"HURL_WORKBENCH_ABSENT_CHECK_65182\"] ],",+      " services = [ S.Service::{name = \"server\", command = S.CommandSpec::{executable = \"python3\", arguments = [\"service.py\"]}, readiness = S.Readiness.Command S.CommandReadiness::{command = S.CommandSpec::{executable = \"python3\", arguments = [\"-c\", \"import os; raise SystemExit(0 if os.path.exists('ready') else 1)\"]}}, shutdownTimeoutSeconds = 1} ],",+      " suites = [ S.Suite::{name = \"ordered\", runs = [S.RunReference.Recipe \"before1\", S.RunReference.Recipe \"before2\", S.RunReference.Check \"parity\", S.RunReference.Recipe \"after1\", S.RunReference.Recipe \"after2\"]},",+      " S.Suite::{name = \"mismatch\", runs = [S.RunReference.Recipe \"before1\", S.RunReference.Check \"mismatch\", S.RunReference.Recipe \"after1\", S.RunReference.Check \"later\"]},",+      " S.Suite::{name = \"only\", runs = [S.RunReference.Check \"expected\"]},",+      " S.Suite::{name = \"late\", runs = [S.RunReference.Recipe \"before1\", S.RunReference.Check \"bad\"]},",+      " S.Suite::{name = \"hurl-first\", runs = [S.RunReference.Recipe \"fail\", S.RunReference.Check \"mismatch\"]},",+      " S.Suite::{name = \"check-first\", runs = [S.RunReference.Check \"mismatch\", S.RunReference.Recipe \"fail\"]},",+      " S.Suite::{name = \"spawn-race\", runs = [S.RunReference.Recipe \"fail\", S.RunReference.Recipe \"remove\", S.RunReference.Check \"mismatch\"]},",+      " managed \"managed-failure\" (S.RunReference.Check \"mismatch\"), managed \"service-death\" (S.RunReference.Check \"death\"),",+      " S.Suite::{name = \"timeout\", runs = [S.RunReference.Check \"timeout\", S.RunReference.Recipe \"after1\"]}, S.Suite::{name = \"cancel\", runs = [S.RunReference.Check \"cancel\"]} ] }"+    ]+  where+    names = ["before1", "before2", "after1", "after2", "fail", "remove"]++checkScript :: Text+checkScript =+  Text.unlines+    [ "#!/usr/bin/env python3",+      "import os, sys, time",+      "assert len(sys.argv) == 3 and sys.argv[2] == 'one argument'",+      "assert not any(key.startswith(('REI_', 'MORI_', 'PG')) for key in os.environ)",+      "assert os.environ['TOKEN'] == 'secret-sentinel'",+      "print(os.environ['TOKEN'], flush=True)",+      "with open('events','a') as f: f.write('check\\n')",+      "if sys.argv[1] == 'death': open('die','w').close()",+      "if sys.argv[1] == 'cancel': open('cancel-started','w').close()",+      "if sys.argv[1] in ('death','cancel','timeout'): time.sleep(30)",+      "raise SystemExit(int(sys.argv[1]))"+    ]++serviceScript :: Text+serviceScript =+  Text.unlines+    [ "import signal, os, time",+      "def stop(*args):",+      "    with open('events','a') as f: f.write('service-stop\\n')",+      "    raise SystemExit(0)",+      "signal.signal(signal.SIGTERM, stop)",+      "open('ready','w').close()",+      "while not os.path.exists('die'): time.sleep(0.01)",+      "raise SystemExit(9)"+    ]++bounded :: IO a -> IO a+bounded action = Timeout.timeout 15000000 action >>= maybe (assertFailure "suite exceeded time bound") pure++waitFor :: FilePath -> IO ()+waitFor path = doesFileExist path >>= \exists -> unless exists (threadDelay 5000 >> waitFor path)
+ test/FileRootTest.hs view
@@ -0,0 +1,177 @@+module FileRootTest (tests) where++import Control.Concurrent (threadDelay)+import Control.Concurrent.Async (cancel, concurrently, waitCatch, withAsync)+import Control.Exception (bracket_)+import Data.ByteString qualified as Bytes+import Data.ByteString.Lazy (fromStrict)+import Data.Generics.Labels ()+import Data.IORef+import Data.Map.Strict qualified as Map+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Encoding+import Data.Text.IO qualified as Text.IO+import HurlWorkbench.Cli (runCommandWithDependencies)+import HurlWorkbench.Cli.Options+import HurlWorkbench.Cli.Output+import HurlWorkbench.Hurl.Capabilities+import HurlWorkbench.Hurl.Format+import HurlWorkbench.Prelude+import Network.HTTP.Types (status201)+import Network.Wai (pathInfo, responseLBS, strictRequestBody)+import Network.Wai.Handler.Warp (testWithApplication)+import Options.Applicative (CompletionResult (..), ParserResult (..), defaultPrefs, execParserPure, renderFailure)+import System.Directory (canonicalizePath, copyFile, createDirectory, createDirectoryLink, listDirectory, removeDirectoryRecursive)+import System.Exit (ExitCode (..))+import System.FilePath ((</>))+import System.IO.Temp (withSystemTempDirectory)+import System.Posix.Files (fileMode, getFileStatus, setFileMode)+import System.Posix.User (getEffectiveUserID)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))++parsed :: [String] -> IO Command+parsed args = case execParserPure defaultPrefs parserInfo args of+  Success options -> pure (options ^. #cmd)+  _ -> assertFailure ("parse failed: " <> show args)++invoke :: FilePath -> FilePath -> IO (Either DependencyError HurlCapabilities) -> [String] -> IO CommandResult+invoke manifest cwd detect args = do+  command <- parsed args+  runCommandWithDependencies (fmap (fmap (view #hurlfmt)) detect) detect validateRenderedWorkflow (\_ _ -> pure (Right [])) (GlobalOptions (Just manifest)) cwd command++createWorkspace :: FilePath -> IO FilePath+createWorkspace directory = do+  schema <- canonicalizePath "schema/package.dhall"+  source <- Text.IO.readFile "examples/file-body/hurl-workbench.dhall"+  let manifest = directory </> "hurl-workbench.dhall"+  Text.IO.writeFile manifest (Text.replace "../../schema/package.dhall" (Text.pack schema) source)+  copyFile "examples/file-body/request.hurl" (directory </> "request.hurl")+  pure manifest++tests :: TestTree+tests =+  testGroup+    "caller-owned file roots"+    [ testCase "option is execution-only, rejects duplicates, and appears in help/completion" $ do+        for_ [["run", "workflow", "send-body"], ["test", "recipe", "send-body"], ["matrix", "bodies"], ["test", "suite", "bodies"]] $ \prefix -> do+          void (parsed (prefix <> ["--file-root", "private"]))+          case execParserPure defaultPrefs parserInfo (prefix <> ["--help"]) of+            Failure failure -> assertBool "help describes file root" ("--file-root" `Text.isInfixOf` Text.pack (fst (renderFailure failure "workbench")))+            _ -> assertFailure "missing execution help"+          case execParserPure defaultPrefs parserInfo (["--bash-completion-index", show (length prefix + 1), "--bash-completion-word", "hurl-workbench"] <> concatMap (\word -> ["--bash-completion-word", word]) prefix <> ["--bash-completion-word", "--file-"]) of+            CompletionInvoked (CompletionResult complete) -> complete "hurl-workbench" >>= assertBool "offers file-root completion" . Text.isInfixOf "--file-root" . Text.pack+            _ -> assertFailure "file-root completion protocol unavailable"+          case execParserPure defaultPrefs parserInfo (prefix <> ["--file-root", "one", "--file-root", "two"]) of+            Failure _ -> pure ()+            _ -> assertFailure "duplicate file root accepted"+        for_ [["render", "workflow", "send-body"], ["validate"]] $ \prefix ->+          case execParserPure defaultPrefs parserInfo (prefix <> ["--file-root", "private"]) of+            Failure _ -> pure ()+            _ -> assertFailure "inspection accepted execution option",+      testCase "invalid roots and overlaps refuse before injected dependency or formatter work" $+        withSystemTempDirectory "file-root-preflight" $ \directory -> do+          manifest <- createWorkspace directory+          let body = directory </> "body"+              failProbe = assertFailure "dependency detection ran before file-root validation"+              selections = [["run", "workflow", "send-body"], ["test", "workflow", "send-body"], ["run", "recipe", "send-body"], ["test", "recipe", "send-body"], ["matrix", "bodies"], ["test", "suite", "bodies"]]+          writeFile (directory </> "regular") "payload"+          createDirectory body+          for_ ["missing", "regular", ""] $ \bad -> for_ selections $ \prefix -> do+            result <- invoke manifest directory failProbe (prefix <> ["--file-root", if null bad then "" else directory </> bad])+            result ^. #exitCode @?= ExitFailure 2+          uid <- getEffectiveUserID+          when (uid /= 0) $ for_ [0o300, 0o600] $ \mode -> bracket_ (setFileMode body mode) (setFileMode body 0o700) $+            for_ selections $+              \prefix -> invoke manifest directory failProbe (prefix <> ["--file-root", body]) >>= (\result -> result ^. #exitCode @?= ExitFailure 2)+          for_ [["matrix", "bodies", "--output-dir", body </> "output", "--overwrite"], ["test", "suite", "bodies", "--report-dir", directory, "--overwrite"], ["test", "workflow", "send-body", "--curl", body </> "curl"]] $ \args -> do+            result <- invoke manifest directory failProbe (args <> ["--file-root", body])+            result ^. #exitCode @?= ExitFailure 2,+      testCase "static parameter validation rejects unknown, Secret, and Generated references even with CLI override" $+        withSystemTempDirectory "file-root-static" $ \directory -> do+          manifest <- createWorkspace directory+          source <- Text.IO.readFile manifest+          for_ [Text.replace "Some \"payloadRoot\"" "Some \"unknown\"" source, Text.replace "name = \"payloadRoot\"" "name = \"payloadRoot\", kind = S.ParameterKind.Secret" source, Text.replace "name = \"payloadRoot\"" "name = \"payloadRoot\", kind = S.ParameterKind.Generated S.Generator.Uuid4" source] $ \bad -> do+            Text.IO.writeFile manifest bad+            result <- invoke manifest directory (assertFailure "invalid manifest started a probe") ["test", "suite", "parameter", "--file-root", directory]+            result ^. #exitCode @?= ExitFailure 2,+      testCase "real Hurl delivers exact bytes across execution paths, concurrent roots, failure, and cancellation" $ do+        capabilities <- detectHurlCapabilities >>= either (assertFailure . Text.unpack . renderDependencyError) pure+        received <- newIORef Map.empty+        slowStarted <- newIORef False+        let app request respond = do+              bytes <- strictRequestBody request+              atomicModifyIORef' received (\values -> (Map.insert (pathInfo request) bytes values, ()))+              when ("slow" `elem` pathInfo request) $ writeIORef slowStarted True >> threadDelay 10000000+              respond (responseLBS status201 [] bytes)+        testWithApplication (pure app) $ \port -> withSystemTempDirectory "file-root-live" $ \directory -> do+          let ws = directory </> "workspace"+              first = directory </> "root 雪 sentinel"+              second = directory </> "second root"+              nested = ws </> "nested"+              one = "{\"payload-sentinel\":\"雪\"}\r\n"+              two = "{\"payload-sentinel\":\"別\"}"+              url label = "baseUrl=http://127.0.0.1:" <> show port <> "/" <> label+              run args = invoke (ws </> "hurl-workbench.dhall") nested (pure (Right capabilities)) args+              successful args = run args >>= \result -> assertBool (show (result ^. #stderrLines)) (result ^. #exitCode == ExitSuccess)+              capture label expected = readIORef received >>= \values -> Map.lookup [Text.pack label, "mutating"] values @?= Just expected+              snapshot root = do+                names <- listDirectory root+                files <- for names $ \name -> (name,,) <$> Bytes.readFile (root </> name) <*> (fileMode <$> getFileStatus (root </> name))+                mode <- fileMode <$> getFileStatus root+                pure (mode, files)+          traverse_ createDirectory [ws, first, second]+          createDirectory nested+          _ <- createWorkspace ws+          Bytes.writeFile (first </> "request.json") (Encoding.encodeUtf8 one)+          Bytes.writeFile (second </> "request.json") (Encoding.encodeUtf8 two)+          Bytes.writeFile (ws </> "request.json") "workspace fallback\n"+          createDirectoryLink first (directory </> "alias")+          beforeSecond <- snapshot second+          sourceBytes <- traverse (Bytes.readFile . (ws </>)) ["request.hurl", "hurl-workbench.dhall", "request.json"]+          before <- snapshot first+          rendered <- run ["render", "workflow", "send-body"]+          rendered ^. #exitCode @?= ExitSuccess+          original <- Text.IO.readFile (ws </> "request.hurl")+          Text.unlines (rendered ^. #stdoutLines) @?= original+          inspected <- run ["validate"]+          inspected ^. #exitCode @?= ExitSuccess+          let firstRoot = ["--file-root", "../../alias"]+          for_ [["run", "workflow", "send-body"], ["test", "workflow", "send-body"], ["run", "recipe", "send-body", "--allow-mutating"], ["test", "recipe", "send-body", "--allow-mutating"], ["matrix", "bodies", "--allow-mutating", "--output-dir", directory </> "matrix"], ["test", "suite", "bodies", "--allow-mutating", "--report-dir", directory </> "reports"]] $ \prefix -> do+            successful (prefix <> firstRoot <> ["--variable", url "one"])+            capture "one" (fromStrict (Encoding.encodeUtf8 one))+          let command root label = ["matrix", "bodies", "--allow-mutating", "--file-root", root, "--output-dir", directory </> label, "--variable", url label]+          void (concurrently (successful (command first "a")) (successful (command second "b")))+          capture "a" (fromStrict (Encoding.encodeUtf8 one))+          capture "b" (fromStrict (Encoding.encodeUtf8 two))+          successful ["test", "workflow", "send-body", "--variable", url "fallback"]+          capture "fallback" "workspace fallback\n"+          successful ["test", "suite", "parameter", "--allow-mutating", "--variable", "payloadRoot=../root 雪 sentinel", "--variable", url "parameter"]+          capture "parameter" (fromStrict (Encoding.encodeUtf8 one))+          successful (["test", "suite", "parameter", "--allow-mutating", "--variable", "payloadRoot=missing", "--variable", url "precedence"] <> firstRoot)+          capture "precedence" (fromStrict (Encoding.encodeUtf8 one))+          -- A missing shadowed value must not be required.+          successful (["test", "suite", "parameter", "--allow-mutating", "--variable", url "shadowed"] <> firstRoot)+          summary <- Bytes.readFile (directory </> "reports/bodies/summary.json")+          for_ ["sentinel", "payload-sentinel", Encoding.encodeUtf8 (Text.pack first)] $ \sentinel -> assertBool "summary omits root and payload" (not (sentinel `Bytes.isInfixOf` summary))+          source <- Text.IO.readFile (ws </> "request.hurl")+          Text.IO.writeFile (ws </> "request.hurl") (Text.replace "HTTP 201" "HTTP 200" source)+          failed <- run (["test", "workflow", "send-body", "--variable", url "failure"] <> firstRoot)+          assertBool "Hurl assertion failed" (failed ^. #exitCode /= ExitSuccess)+          Text.IO.writeFile (ws </> "request.hurl") source+          withAsync (run (["test", "workflow", "send-body", "--variable", url "slow"] <> firstRoot)) $ \worker -> do+            let awaitSlow remaining = readIORef slowStarted >>= \ready -> unless ready (if remaining == (0 :: Int) then assertFailure "slow request never arrived" else threadDelay 10000 >> awaitSlow (remaining - 1))+            awaitSlow 1000+            cancel worker+            waitCatch worker >>= \case Left _ -> pure (); Right _ -> assertFailure "cancelled execution returned normally"+          snapshot first >>= (@?= before)+          snapshot second >>= (@?= beforeSecond)+          traverse (Bytes.readFile . (ws </>)) ["request.hurl", "hurl-workbench.dhall", "request.json"] >>= (@?= sourceBytes)+          -- Disappearance after validation is a normal Hurl failure; never recreate.+          let gone = directory </> "gone"+          createDirectory gone+          Bytes.writeFile (gone </> "request.json") "gone"+          cmd <- parsed ["test", "workflow", "send-body", "--file-root", gone, "--variable", url "gone"]+          result <- runCommandWithDependencies (assertFailure "format probe") (removeDirectoryRecursive gone >> pure (Right capabilities)) validateRenderedWorkflow (\_ _ -> pure (Right [])) (GlobalOptions (Just (ws </> "hurl-workbench.dhall"))) nested cmd+          assertBool "missing runtime root fails" (result ^. #exitCode /= ExitSuccess)+    ]
+ test/Main.hs view
@@ -0,0 +1,704 @@+module Main (main) where++import CheckTest qualified+import Control.Monad (join)+import Data.Bits ((.&.))+import Data.ByteString qualified as ByteString+import Data.Generics.Labels ()+import Data.IORef+import Data.Map.Strict qualified as Map+import Data.Text qualified as Text+import Data.Text.IO qualified as Text.IO+import Data.Version (makeVersion)+import FileRootTest qualified+import FixtureServer qualified+import HurlWorkbench.Cli (runCommandWithDependencies, runCommandWithHurlfmt)+import HurlWorkbench.Cli.Command.Suite qualified as SuiteCommand+import HurlWorkbench.Cli.Help (helpPreferences)+import HurlWorkbench.Cli.Options+import HurlWorkbench.Cli.Output (CommandResult (..))+import HurlWorkbench.Cli.Version (versionText)+import HurlWorkbench.Cli.Workspace (loadValidatedWorkspace)+import HurlWorkbench.Hurl.Capabilities+import HurlWorkbench.Hurl.Format+import HurlWorkbench.Hurl.Run+import HurlWorkbench.Parameter.Resolve+import HurlWorkbench.Prelude+import HurlWorkbench.Run.Batch+import HurlWorkbench.Run.FileRoot+import HurlWorkbench.Run.Prepare+import HurlWorkbench.Run.Selection+import HurlWorkbench.Suite.Resolve (ReportFormat (..))+import HurlWorkbench.Workflow.Render (RenderedWorkflow, renderRenderError, renderWorkflow)+import HurlWorkbench.Workflow.Resolve (renderWorkflowError, resolveWorkflow)+import HurlWorkbench.Workspace.Context (lookupWorkflow)+import HurlWorkbench.Workspace.Types (MatrixName (..), ParameterName (..), RecipeName (..), SuiteName (..), WorkflowName (..), WorkspaceRoot (..), mkHurlValueLiteral)+import Network.Wai (queryString)+import Network.Wai.Handler.Warp (testWithApplication)+import Options.Applicative (CompletionResult (..), ParserResult (..), defaultPrefs, execParserPure, renderFailure)+import System.Directory (canonicalizePath, doesPathExist, getPermissions, setOwnerExecutable, setPermissions)+import System.Exit (ExitCode (..))+import System.FilePath ((</>))+import System.IO.Temp (withSystemTempDirectory)+import System.Posix.Files (setFileMode)+import Test.Tasty (TestTree, defaultMain, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))++main :: IO ()+main = defaultMain (testGroup "hurl-workbench-cli" [CheckTest.tests, FileRootTest.tests, parserTests, commandTests, executionTests])++fixtureDir :: FilePath -> FilePath+fixtureDir name = "../hurl-workbench-core/test/fixtures/workspaces" </> name++fixtureManifest :: FilePath -> FilePath+fixtureManifest name = fixtureDir name </> "hurl-workbench.dhall"++parse :: [String] -> ParserResult Options+parse = execParserPure defaultPrefs parserInfo++parsePublic :: [String] -> ParserResult Options+parsePublic = execParserPure (helpPreferences 80) parserInfo++parserTests :: TestTree+parserTests =+  testGroup+    "parser"+    [ testCase "external suite help explains phase bypass and lifecycle refusal" $+        case parsePublic ["test", "suite", "--help"] of+          Failure failure -> do+            let (message, _) = renderFailure failure "hurl-workbench"+            for_ ["Skip setup", "teardown", "lifecycle steps are refused"] $ \phrase ->+              assertBool "external lifecycle contract is visible" (Text.pack phrase `Text.isInfixOf` Text.unwords (Text.words (Text.pack message)))+          _ -> assertFailure "expected suite help",+      testCase "validate with an explicit workspace" $+        successOf (parse ["--workspace", "ws.dhall", "validate"])+          >>= (@?= Options (GlobalOptions (Just "ws.dhall")) ValidateCommand),+      testCase "list defaults to all categories" $+        successOf (parse ["list"]) >>= (@?= Options (GlobalOptions Nothing) (ListCommand AllCategories)),+      testCase "list accepts every category name" $+        for_ allListCategories $ \category ->+          successOf (parse ["list", Text.unpack (listCategoryName category)])+            >>= (@?= Options (GlobalOptions Nothing) (ListCommand category)),+      testCase "list rejects an unknown category" $+        case parse ["list", "widgets"] of+          Failure _ -> pure ()+          _ -> assertFailure "expected a parse failure",+      testCase "render parses a workflow and optional output" $+        successOf (parse ["render", "workflow", "oauth-property", "--output", "rendered.hurl"])+          >>= (@?= Options (GlobalOptions Nothing) (RenderCommand (RenderOptions (SelectWorkflow (WorkflowName "oauth-property")) (Just "rendered.hurl") False))),+      testCase "run parses grouped binding and Hurl options" $ do+        options <-+          successOf+            ( parse+                [ "run",+                  "workflow",+                  "health",+                  "--variable",+                  "baseUrl=http://127.0.0.1",+                  "--retry",+                  "2",+                  "--include",+                  "--hurl-arg=--compressed"+                ]+            )+        case options ^. #cmd of+          RunCommand execute -> do+            execute ^. #selection @?= SelectWorkflow (WorkflowName "health")+            execute ^. #bindings . #variables @?= ["baseUrl=http://127.0.0.1"]+            execute ^. #hurl . #retryCount @?= Just 2+            execute ^. #hurl . #includeHeaders @?= True+            execute ^. #hurl . #additionalArguments @?= ["--compressed"]+          other -> assertFailure ("expected run command, got " <> show other),+      testCase "run help groups options by intent" $ do+        helpText <- case parse ["run", "workflow", "health", "--help"] of+          Failure failure -> pure (fst (renderFailure failure "hurl-workbench"))+          _ -> assertFailure "expected --help to produce help text"+        for_ ["Bindings", "HTTP and retry", "Output and diagnostics", "Advanced Hurl arguments"] $ \heading ->+          assertBool ("mentions " <> heading) (heading `isIn` helpText),+      testCase "recipe and matrix selectors parse with grouped batch controls" $ do+        rendered <- successOf (parse ["render", "recipe", "properties", "--explain"])+        rendered+          @?= Options+            (GlobalOptions Nothing)+            (RenderCommand (RenderOptions (SelectRecipe (RecipeName "properties")) Nothing True))+        selected <-+          successOf+            ( parse+                [ "matrix",+                  "property-by-mls",+                  "--mode",+                  "run",+                  "--jobs",+                  "2",+                  "--fail-fast",+                  "--allow-mutating",+                  "--output-dir",+                  "responses"+                ]+            )+        case selected ^. #cmd of+          MatrixCommand options -> do+            options ^. #matrix @?= MatrixName "property-by-mls"+            options ^. #mode @?= ClientMode+            positiveIntValue (options ^. #jobs) @?= 2+            options ^. #failFastOverride @?= Just True+            options ^. #allowMutating @?= True+            options ^. #outputDirectory @?= Just "responses"+          other -> assertFailure ("expected matrix command, got " <> show other)+        helpText <- case parse ["matrix", "property-by-mls", "--help"] of+          Failure failure -> pure (fst (renderFailure failure "hurl-workbench"))+          _ -> assertFailure "expected matrix --help to produce help text"+        for_ ["Batch control", "Bindings", "HTTP and retry", "Output", "Advanced Hurl arguments"] $ \heading ->+          assertBool ("mentions " <> heading) (heading `isIn` helpText),+      testCase "suite test parses lifecycle, safety, and report controls" $ do+        selected <-+          successOf+            ( parse+                [ "test",+                  "suite",+                  "default",+                  "--jobs",+                  "3",+                  "--keep-going",+                  "--allow-mutating",+                  "--external-service",+                  "--report",+                  "junit",+                  "--report",+                  "json",+                  "--report-dir",+                  "reports",+                  "--overwrite"+                ]+            )+        case selected ^. #cmd of+          SuiteCommand options -> do+            options ^. #suite @?= SuiteName "default"+            positiveIntValue (options ^. #jobs) @?= 3+            options ^. #failFastOverride @?= Just False+            options ^. #allowMutating @?= True+            options ^. #externalService @?= True+            options ^. #reportFormats @?= [JUnit, Json]+            options ^. #reportDirectory @?= Just "reports"+            options ^. #overwrite @?= True+          other -> assertFailure ("expected suite command, got " <> show other)+        helpText <- case parse ["test", "suite", "default", "--help"] of+          Failure failure -> pure (fst (renderFailure failure "hurl-workbench"))+          _ -> assertFailure "expected suite --help to produce help text"+        for_ ["Suite control", "Service lifecycle", "Reports", "Bindings", "Advanced Hurl arguments"] $ \heading ->+          assertBool ("mentions " <> heading) (heading `isIn` helpText),+      testCase "help lists every top-level command and no longer mentions hello" $ do+        helpText <- case parsePublic ["--help"] of+          Failure failure -> pure (fst (renderFailure failure "hurl-workbench"))+          _ -> assertFailure "expected --help to produce help text"+        assertBool "mentions validate" ("validate" `isIn` helpText)+        assertBool "mentions list" ("list" `isIn` helpText)+        assertBool "mentions render" ("render" `isIn` helpText)+        assertBool "mentions run" ("run" `isIn` helpText)+        assertBool "mentions test" ("test" `isIn` helpText)+        assertBool "mentions matrix" ("matrix" `isIn` helpText)+        assertBool "mentions doctor" ("doctor" `isIn` helpText)+        assertBool "mentions completions" ("completions" `isIn` helpText)+        assertBool "mentions --workspace" ("--workspace" `isIn` helpText)+        assertBool "does not mention hello" (not ("hello" `isIn` helpText))+        assertGolden "test/golden/top-level-help.txt" (Text.pack helpText),+      testCase "empty and invalid invocations include contextual help" $ do+        for_ [[], ["unknown-command"]] $ \arguments ->+          case parsePublic arguments of+            Failure parserFailure -> do+              let rendered = fst (renderFailure parserFailure "hurl-workbench")+              assertBool "contains usage" ("Usage:" `isIn` rendered)+              assertBool "contains commands" ("Available commands:" `isIn` rendered)+            _ -> assertFailure "expected a help-bearing parse failure",+      testCase "version includes package version and a build revision" $+        case parsePublic ["--version"] of+          Failure parserFailure -> do+            let rendered = fst (renderFailure parserFailure "hurl-workbench")+            assertBool "matches the version module" (versionText `isIn` rendered)+            assertBool "includes the package version" ("0.1.0.0" `isIn` rendered)+          _ -> assertFailure "expected the version option to terminate parsing",+      testCase "public completion commands generate scripts for every shell" $ do+        for_ [("bash", "complete -o filenames"), ("zsh", "#compdef hurl-workbench"), ("fish", "complete --no-files")] $ \(shell, marker) -> do+          options <- successOf (parse ["completions", shell])+          result <- runTestCommand (options ^. #global) "." (options ^. #cmd)+          result ^. #exitCode @?= ExitSuccess+          let script = Text.unlines (result ^. #stdoutLines)+          assertBool (shell <> " script names the stable executable") ("hurl-workbench" `Text.isInfixOf` script)+          assertBool (shell <> " script uses the completion protocol") ("--bash-completion-index" `Text.isInfixOf` script)+          assertBool (shell <> " script has its native marker") (Text.pack marker `Text.isInfixOf` script),+      testCase "hidden completion protocol follows the public parser" $+        case parsePublic ["--bash-completion-index", "1", "--bash-completion-word", "hurl-workbench", "--bash-completion-word", "c"] of+          CompletionInvoked (CompletionResult complete) -> do+            candidates <- complete "hurl-workbench"+            assertBool "offers completions" ("completions" `isIn` candidates)+          _ -> assertFailure "expected a completion query"+    ]+  where+    isIn needle haystack = Text.pack needle `Text.isInfixOf` Text.pack haystack++successOf :: ParserResult a -> IO a+successOf = \case+  Success a -> pure a+  _ -> assertFailure "expected the arguments to parse"++commandTests :: TestTree+commandTests =+  testGroup+    "commands"+    [ testCase "validate discovers a parent workspace and reports counts" $ do+        result <- runTestCommand (GlobalOptions Nothing) (fixtureDir "full" </> "hurl") ValidateCommand+        manifest <- canonicalizePath (fixtureManifest "full")+        result+          @?= CommandResult+            { stdoutLines =+                [ "Valid workspace: " <> Text.pack manifest,+                  "5 parameters, 3 fragments, 2 workflows, 1 recipe, 1 matrix, 0 checks, 1 service, 1 suite"+                ],+              stderrLines = [],+              exitCode = ExitSuccess+            },+      testCase "validate reports every invalid reference and exits non-zero" $ do+        result <- runTestCommand (GlobalOptions (Just (fixtureManifest "missing-reference"))) "." ValidateCommand+        result ^. #exitCode @?= ExitFailure 1+        result ^. #stdoutLines @?= []+        let err = result ^. #stderrLines+        take 1 err @?= take 1 (filter ("Invalid workspace: " `Text.isPrefixOf`) err)+        for_ ["missing-fragment", "missing_parameter", "missing-workflow", "missing-recipe", "missing-matrix", "missing-service"] $ \name ->+          assertBool ("reports " <> Text.unpack name) (any (Text.isInfixOf name) err)+        drop (length err - 1) err @?= ["8 issues found"],+      testCase "a missing explicit workspace is a workbench error" $ do+        result <- runTestCommand (GlobalOptions (Just "does-not-exist.dhall")) "." ValidateCommand+        result ^. #exitCode @?= ExitFailure 1+        assertBool "names the missing manifest" (any (Text.isInfixOf "workspace manifest not found") (result ^. #stderrLines)),+      testCase "list all groups every category and shows relationships" $ do+        result <- runTestCommand (GlobalOptions (Just (fixtureManifest "full"))) "." (ListCommand AllCategories)+        result ^. #exitCode @?= ExitSuccess+        let out = result ^. #stdoutLines+        for_ ["Parameters:", "Fragments:", "Workflows:", "Recipes:", "Matrices:", "Services:", "Suites:"] $ \title ->+          assertBool ("has heading " <> Text.unpack title) (title `elem` out)+        assertBool "workflow shares the oauth fragment" (any (Text.isInfixOf "list-properties  oauth, properties") out)+        assertBool "suite lists its runs" (any (Text.isInfixOf "workflow:list-members, recipe:top-properties, matrix:property-page-sizes") out)+        assertBool "suite identifies unclassified safety" (any (Text.isInfixOf "unclassified") out),+      testCase "list parameters names environment variables but never reads them" $ do+        result <- runTestCommand (GlobalOptions (Just (fixtureManifest "full"))) "." (ListCommand ParametersCategory)+        result ^. #stdoutLines+          @?= [ "NAME           KIND    DEFAULT                    ENVIRONMENT            DESCRIPTION",+                "auth_url       plain   https://auth.example.test  -                      -",+                "base_url       plain   https://api.example.test   -                      -",+                "client_id      plain   -                          EXAMPLE_CLIENT_ID      -",+                "client_secret  secret  -                          EXAMPLE_CLIENT_SECRET  OAuth client secret",+                "top            plain   5                          -                      -"+              ],+      testCase "list of an empty category says so" $ do+        result <- runTestCommand (GlobalOptions (Just (fixtureManifest "minimal"))) "." (ListCommand SuitesCategory)+        result @?= CommandResult ["(none)"] [] ExitSuccess,+      testCase "render writes only composed Hurl source to stdout" $ do+        result <-+          runTestCommand+            (GlobalOptions (Just (fixtureManifest "full")))+            "."+            (RenderCommand (RenderOptions (SelectWorkflow (WorkflowName "list-properties")) Nothing False))+        oauth <- Text.IO.readFile (fixtureDir "full" </> "hurl/oauth.hurl")+        properties <- Text.IO.readFile (fixtureDir "full" </> "hurl/properties.hurl")+        result ^. #exitCode @?= ExitSuccess+        result ^. #stderrLines @?= []+        Text.unlines (result ^. #stdoutLines) @?= oauth <> "\n" <> properties,+      testCase "render reports an unknown workflow on stderr" $ do+        result <-+          runTestCommand+            (GlobalOptions (Just (fixtureManifest "full")))+            "."+            (RenderCommand (RenderOptions (SelectWorkflow (WorkflowName "missing")) Nothing False))+        result ^. #exitCode @?= ExitFailure 1+        result ^. #stdoutLines @?= []+        assertBool "names the unknown workflow" (any (Text.isInfixOf "unknown workflow \"missing\"") (result ^. #stderrLines))+        assertGolden "test/golden/unknown-workflow.txt" (Text.unlines (result ^. #stderrLines)),+      testCase "render recipe explains binding sources without values" $ do+        result <-+          runTestCommand+            (GlobalOptions (Just (fixtureManifest "full")))+            "."+            (RenderCommand (RenderOptions (SelectRecipe (RecipeName "top-properties")) Nothing True))+        result ^. #exitCode @?= ExitSuccess+        result ^. #stderrLines+          @?= ["Binding sources (values redacted):", "  recipe \"top-properties\" (1 plain bindings)"]+        assertBool "does not explain the bound value" (not (any (Text.isInfixOf "10") (result ^. #stderrLines))),+      testCase "render atomically replaces an output file and reports its path" $+        withSystemTempDirectory "hurl-workbench-cli" $ \dir -> do+          manifest <- canonicalizePath (fixtureManifest "full")+          let output = dir </> "rendered.hurl"+          Text.IO.writeFile output "old contents"+          result <-+            runTestCommand+              (GlobalOptions (Just manifest))+              dir+              (RenderCommand (RenderOptions (SelectWorkflow (WorkflowName "list-properties")) (Just "rendered.hurl") False))+          canonicalOutput <- canonicalizePath output+          result ^. #exitCode @?= ExitSuccess+          result ^. #stdoutLines @?= []+          result ^. #stderrLines @?= [Text.pack canonicalOutput]+          rendered <- Text.IO.readFile output+          assertBool "replaced the old file" ("POST {{auth_url}}/oauth/token" `Text.isInfixOf` rendered),+      testCase "render refuses to overwrite an input fragment" $ do+        manifest <- canonicalizePath (fixtureManifest "full")+        source <- canonicalizePath (fixtureDir "full" </> "hurl/oauth.hurl")+        before <- Text.IO.readFile source+        result <-+          runTestCommand+            (GlobalOptions (Just manifest))+            "."+            (RenderCommand (RenderOptions (SelectWorkflow (WorkflowName "list-properties")) (Just source) False))+        result ^. #exitCode @?= ExitFailure 1+        after <- Text.IO.readFile source+        after @?= before+        assertBool "explains the refusal" (any (Text.isInfixOf "refusing to overwrite source fragment") (result ^. #stderrLines)),+      testCase "run propagates the exact Hurl exit status after spawn" $+        withSystemTempDirectory "hurl-workbench-cli-run" $ \dir -> do+          executable <- writeExitHurl dir 17+          result <-+            runExecutionCommand+              (Right (testCapabilities executable))+              ( RunCommand+                  ExecuteOptions+                    { selection = SelectWorkflow (WorkflowName "health"),+                      bindings = BindingOptions ["baseUrl=http://127.0.0.1"] [] [] [],+                      hurl = defaultCliHurlOptions,+                      allowMutating = False+                    }+              )+          result ^. #exitCode @?= ExitFailure 17,+      testCase "run uses exit 2 for binding errors and exit 3 for dependency errors" $+        withSystemTempDirectory "hurl-workbench-cli-exits" $ \dir -> do+          executable <- writeExitHurl dir 0+          let command = RunCommand (ExecuteOptions (SelectWorkflow (WorkflowName "health")) emptyBindingOptions defaultCliHurlOptions False)+          bindingFailure <- runExecutionCommand (Right (testCapabilities executable)) command+          bindingFailure ^. #exitCode @?= ExitFailure 2+          dependencyFailure <- runExecutionCommand (Left (DependencyNotFound "hurl" "hurl")) command+          dependencyFailure ^. #exitCode @?= ExitFailure 3,+      testCase "mutating recipes require an explicit invocation gate" $+        withSystemTempDirectory "hurl-workbench-cli-mutation" $ \dir -> do+          executable <- writeExitHurl dir 0+          let recipeCommand allowed =+                RunCommand+                  ExecuteOptions+                    { selection = SelectRecipe (RecipeName "dangerous-health"),+                      bindings = BindingOptions ["baseUrl=http://127.0.0.1"] [] [] [],+                      hurl = defaultCliHurlOptions,+                      allowMutating = allowed+                    }+          denied <- runExecutionCommand (Right (testCapabilities executable)) (recipeCommand False)+          denied ^. #exitCode @?= ExitFailure 2+          assertBool "explains the safety gate" (any (Text.isInfixOf "requires --allow-mutating") (denied ^. #stderrLines))+          allowed <- runExecutionCommand (Right (testCapabilities executable)) (recipeCommand True)+          allowed ^. #exitCode @?= ExitSuccess,+      testCase "parallel client matrices require and populate isolated artifacts" $+        withSystemTempDirectory "hurl-workbench-cli-matrix" $ \dir -> do+          executable <- writeExitHurl dir 0+          let options outputDirectory =+                MatrixOptions+                  { matrix = MatrixName "health-cases",+                    mode = ClientMode,+                    jobs = positiveJobs 2,+                    failFastOverride = Nothing,+                    allowMutating = False,+                    outputDirectory,+                    overwrite = False,+                    bindings = BindingOptions ["baseUrl=http://127.0.0.1"] [] [] [],+                    hurl = defaultCliHurlOptions+                  }+          rejected <- runExecutionCommand (Right (testCapabilities executable)) (MatrixCommand (options Nothing))+          rejected ^. #exitCode @?= ExitFailure 2+          assertBool "requires output isolation" (any (Text.isInfixOf "require --output-dir") (rejected ^. #stderrLines))+          let output = dir </> "responses"+          result <- runExecutionCommand (Right (testCapabilities executable)) (MatrixCommand (options (Just output)))+          result ^. #exitCode @?= ExitSuccess+          length (filter (Text.isPrefixOf "PASS") (result ^. #stderrLines)) @?= 3+          for_ ["first", "second", "third"] $ \caseName ->+            ByteString.readFile (output </> "health-cases" </> caseName <> ".response") >>= (@?= ""),+      testCase "suite and matrix refusal never probes capabilities or validates syntax" $ do+        let run argv = do+              parsed <- successOf (parse argv)+              runCommandWithDependencies+                (assertFailure "hurlfmt probe called")+                (assertFailure "Hurl probe called")+                (\_ _ -> assertFailure "syntax validation called")+                (\_ _ -> assertFailure "workspace syntax called")+                (GlobalOptions (Just (fixtureManifest "isolation")))+                "."+                (parsed ^. #cmd)+        for_ [False, True] $ \allow -> for_ [False, True] $ \external -> do+          for_ ["mixed", "single-write", "independent"] $ \name -> do+            result <- run (["test", "suite", name, "--jobs", "2"] <> ["--allow-mutating" | allow] <> ["--external-service" | external])+            result ^. #exitCode @?= ExitFailure 2+          for_ ["writes", "single-write"] $ \matrix -> do+            result <- run (["matrix", matrix, "--mode", "test", "--jobs", "2"] <> ["--allow-mutating" | allow])+            result ^. #exitCode @?= ExitFailure 2+        for_ ["service-started", "readiness-started"] $ \marker ->+          doesPathExist (fixtureDir "isolation" </> marker) >>= (@?= False),+      testCase "invalid private scenario files fail before capability probes" $+        withSystemTempDirectory "scenario-cli" $ \directory -> do+          let input = directory </> "inputs.json"+              run = do+                parsed <- successOf (parse ["test", "suite", "independent", "--external-service", "--scenario-inputs", input, "--report-dir", directory </> "reports", "--overwrite"])+                runCommandWithDependencies (assertFailure "format probe called") (assertFailure "Hurl probe called") (\_ _ -> assertFailure "format called") (\_ _ -> assertFailure "syntax called") (GlobalOptions (Just (fixtureManifest "isolation"))) "." (parsed ^. #cmd)+          missing <- run+          missing ^. #exitCode @?= ExitFailure 3+          ByteString.writeFile input "private-invalid-input"+          setFileMode input 0o600+          invalid <- run+          invalid ^. #exitCode @?= ExitFailure 2+          assertBool "raw input is redacted" (not (any (Text.isInfixOf "private-invalid-input") (invalid ^. #stderrLines))),+      testCase "a verified handoff executes separate endpoints through the suite command" $+        testWithApplication (pure FixtureServer.application) $ \firstPort ->+          testWithApplication (pure FixtureServer.application) $ \secondPort ->+            withSystemTempDirectory "scenario-live-cli" $ \directory -> do+              let input = directory </> "inputs.json"+                  entry name port = "{\"scenario\":\"writes/" <> name <> "\",\"fixtureId\":\"private-fixture-" <> name <> "\",\"endpoint\":\"http://127.0.0.1:" <> Text.pack (show port) <> "\",\"endpointParameter\":\"baseUrl\",\"bindings\":[],\"secretEnvironment\":[]}"+              Text.IO.writeFile input ("{\"version\":1,\"scenarios\":[" <> entry "first" firstPort <> "," <> entry "second" secondPort <> "]}")+              setFileMode input 0o600+              parsed <- successOf (parse ["test", "suite", "independent", "--external-service", "--scenario-inputs", input, "--allow-mutating", "--jobs", "2", "--report-dir", directory </> "reports"])+              case parsed ^. #cmd of+                SuiteCommand options -> do+                  result <- SuiteCommand.runSuiteCommand (GlobalOptions (Just (fixtureManifest "isolation"))) "." options+                  result ^. #exitCode @?= ExitSuccess+                  length (filter (Text.isPrefixOf "PASS") (result ^. #stderrLines)) @?= 2+                  summary <- ByteString.readFile (directory </> "reports" </> "independent" </> "summary.json")+                  assertBool "fixture identities are private" (not ("private-fixture" `ByteString.isInfixOf` summary))+                _ -> assertFailure "expected suite command",+      testCase "read-only suites retain parallel execution" $+        withSystemTempDirectory "hurl-workbench-cli-read-suite" $ \dir -> do+          executable <- writeExitHurl dir 0+          parsed <- successOf (parse ["test", "suite", "reads", "--jobs", "2", "--external-service", "--variable", "baseUrl=http://fixture.test"])+          result <- runExecutionCommandAt (fixtureManifest "isolation") (Right (testCapabilities executable)) (parsed ^. #cmd)+          result ^. #exitCode @?= ExitSuccess+          length (filter (Text.isPrefixOf "PASS") (result ^. #stderrLines)) @?= 3,+      testCase "suite command gates unsafe runs and writes isolated reports" $+        withSystemTempDirectory "hurl-workbench-cli-suite" $ \dir -> do+          executable <- writeExitHurl dir 0+          let secretFile = dir </> "secrets.env"+              reportRoot = dir </> "reports"+              options allowed reports =+                SuiteCommandOptions+                  { suite = SuiteName "smoke",+                    jobs = positiveJobs 1,+                    failFastOverride = Nothing,+                    allowMutating = allowed,+                    externalService = True,+                    scenarioInputsFile = Nothing,+                    reportFormats = reports,+                    reportDirectory = if null reports then Nothing else Just reportRoot,+                    overwrite = False,+                    bindings = BindingOptions ["client_id=suite-client"] [] [] [secretFile],+                    hurl = defaultCliHurlOptions+                  }+          ByteString.writeFile secretFile "client_secret=suite-secret\n"+          denied <- runExecutionCommandAt (fixtureManifest "full") (Right (testCapabilities executable)) (SuiteCommand (options False []))+          denied ^. #exitCode @?= ExitFailure 2+          assertBool "explains suite safety" (any (Text.isInfixOf "requires --allow-mutating") (denied ^. #stderrLines))+          passed <- runExecutionCommandAt (fixtureManifest "full") (Right (testCapabilities executable)) (SuiteCommand (options True [JUnit, Json]))+          passed ^. #exitCode @?= ExitSuccess+          length (filter (Text.isPrefixOf "PASS") (passed ^. #stderrLines)) @?= 4+          assertBool "prints summary path" (any (Text.isPrefixOf "SUMMARY ") (passed ^. #stderrLines))+          for_+            [ reportRoot </> "smoke" </> "list-members" </> "junit.xml",+              reportRoot </> "smoke" </> "list-members" </> "json",+              reportRoot </> "smoke" </> "property-page-sizes" </> "small" </> "junit.xml",+              reportRoot </> "smoke" </> "summary.json"+            ]+            $ \path -> doesPathExist path >>= assertBool ("missing suite artifact " <> path),+      testCase "doctor reports both executable paths and versions without a workspace" $ do+        let capabilities = testCapabilities "/tools/hurl"+        result <- runExecutionCommand (Right capabilities) DoctorCommand+        result ^. #exitCode @?= ExitSuccess+        assertBool "reports Hurl" (any (Text.isInfixOf "/tools/hurl (8.0.1, supported)") (result ^. #stdoutLines))+        assertBool "reports Hurlfmt" (any (Text.isInfixOf "/tools/hurlfmt (8.0.1, supported)") (result ^. #stdoutLines))+    ]++runTestCommand :: GlobalOptions -> FilePath -> Command -> IO CommandResult+runTestCommand =+  runCommandWithHurlfmt+    (pure (Right (HurlfmtCapabilities (HurlfmtExecutable "unused-in-tests") (makeVersion [8, 0, 1]))))+    (\_capabilities _rendered -> pure (Right ()))+    (\_capabilities _validated -> pure (Right []))++assertGolden :: FilePath -> Text -> IO ()+assertGolden path actual = Text.IO.readFile path >>= (\expected -> Text.stripEnd actual @?= Text.stripEnd expected)++runExecutionCommand :: Either DependencyError HurlCapabilities -> Command -> IO CommandResult+runExecutionCommand = runExecutionCommandAt (fixtureManifest "execution")++runExecutionCommandAt :: FilePath -> Either DependencyError HurlCapabilities -> Command -> IO CommandResult+runExecutionCommandAt manifest detected =+  runCommandWithDependencies+    (pure (Right (HurlfmtCapabilities (HurlfmtExecutable "/tools/hurlfmt") (makeVersion [8, 0, 1]))))+    (pure detected)+    (\_capabilities _rendered -> pure (Right ()))+    (\_capabilities _validated -> pure (Right []))+    (GlobalOptions (Just manifest))+    "."++testCapabilities :: FilePath -> HurlCapabilities+testCapabilities executable =+  HurlCapabilities+    executable+    (makeVersion [8, 0, 1])+    (HurlfmtCapabilities (HurlfmtExecutable "/tools/hurlfmt") (makeVersion [8, 0, 1]))++writeExitHurl :: FilePath -> Int -> IO FilePath+writeExitHurl directory status = do+  let executable = directory </> "fake-hurl"+  Text.IO.writeFile executable ("#!/bin/sh\nexit " <> Text.pack (show status) <> "\n")+  permissions <- getPermissions executable+  setPermissions executable (setOwnerExecutable True permissions)+  pure executable++executionTests :: TestTree+executionTests =+  testGroup+    "live Hurl execution"+    [ testCase "public validation rejects committed generated inputs before execution" $+        withSystemTempDirectory "generated-invalid" $ \directory -> do+          schema <- canonicalizePath "schema/package.dhall"+          source <- Text.IO.readFile "examples/generated-parameters/hurl-workbench.dhall"+          for_ ["first.hurl", "second.hurl"] $ \file -> ByteString.readFile ("examples/generated-parameters" </> file) >>= ByteString.writeFile (directory </> file)+          let manifest = directory </> "hurl-workbench.dhall"+              local = Text.replace "../../schema/package.dhall" (Text.pack schema) source+          for_+            [ Text.replace "name = \"recipientId\"" "name = \"recipientId\", defaultValue = Some \"fixed\"" local,+              Text.replace "name = \"one\"" "name = \"one\", bindings = [ S.Binding::{ parameter = \"recipientId\", value = \"fixed\" } ]" local+            ]+            $ \invalid -> do+              Text.IO.writeFile manifest invalid+              result <- runTestCommand (GlobalOptions (Just manifest)) "." ValidateCommand+              result ^. #exitCode @?= ExitFailure 1+              assertBool "explains generated constraint" (any (Text.isInfixOf "generated parameters") (result ^. #stderrLines))+              parsed <- successOf (parse ["test", "recipe", "recipient"])+              blocked <- runCommandWithDependencies (assertFailure "format probe") (assertFailure "Hurl probe") (\_ _ -> assertFailure "format") (\_ _ -> assertFailure "syntax") (GlobalOptions (Just manifest)) "." (parsed ^. #cmd)+              blocked ^. #exitCode @?= ExitFailure 2,+      testCase "generated CLI inputs are shared per execution and fresh across concurrent cases" $ do+        capabilities <- detectHurlCapabilities >>= either (assertFailure . Text.unpack . renderDependencyError) pure+        received <- newIORef ([] :: [ByteString.ByteString])+        let app request respond = do+              for_ (join (lookup "id" (queryString request))) $ \value -> atomicModifyIORef' received (\values -> (value : values, ()))+              FixtureServer.application request respond+        testWithApplication (pure app) $ \port -> withSystemTempDirectory "generated-cli" $ \directory -> do+          let manifest = "examples/generated-parameters/hurl-workbench.dhall"+              base = "baseUrl=http://127.0.0.1:" <> show port+              invoke args = do+                parsed <- successOf (parse args)+                result <- runExecutionCommandAt manifest (Right capabilities) (parsed ^. #cmd)+                assertBool (show (result ^. #stderrLines)) (result ^. #exitCode == ExitSuccess)+                pure result+              reproduction = "rcp_01h2xcejqtf2nbrexx3vqjhp41"+          inspected <- invoke ["render", "recipe", "recipient", "--explain"]+          assertBool "explains generated input" (any (Text.isInfixOf "recipientId: generated (TypeId)") (inspected ^. #stderrLines))+          readIORef received >>= (@?= [])+          for_ [1 :: Int, 2] $ \_ -> void (invoke ["test", "recipe", "recipient", "--variable", base])+          void (invoke ["test", "recipe", "recipient", "--variable", base, "--variable", "recipientId=" <> reproduction])+          void (invoke ["matrix", "recipients", "--jobs", "2", "--variable", base, "--output-dir", directory </> "matrix"])+          void (invoke ["test", "suite", "recipients", "--jobs", "2", "--variable", base, "--report", "json", "--report-dir", directory </> "suite"])+          values <- readIORef received+          length values @?= 14+          let generated = filter (/= "rcp_01h2xcejqtf2nbrexx3vqjhp41") values+              counts = Map.fromListWith (+) [(value, 1 :: Int) | value <- generated]+          Map.size counts @?= 6+          Map.elems counts @?= replicate 6 2+          for_ (Map.keys counts) $ \value -> do+            ByteString.take 4 value @?= "rcp_"+            ByteString.length value @?= 30+            let alphabet = "0123456789abcdefghjkmnpqrstvwxyz"+                decoded = foldl (\acc byte -> acc * 32 + fromIntegral (fromMaybe (-100) (ByteString.elemIndex byte alphabet))) (0 :: Integer) (ByteString.unpack (ByteString.drop 4 value))+            (decoded `div` 2 ^ (76 :: Int)) .&. 15 @?= 7+            (decoded `div` 2 ^ (62 :: Int)) .&. 3 @?= 2+          summary <- ByteString.readFile (directory </> "suite/recipients/summary.json")+          for_ values $ \value -> assertBool "summary excludes generated and override values" (not (value `ByteString.isInfixOf` summary)),+      testCase "client and test modes execute against the shared fixture service" $ do+        detected <- detectHurlCapabilities+        case detected of+          Left DependencyNotFound {} -> putStrLn "SKIP: Hurl 8.x or Hurlfmt is not installed"+          Left err -> assertFailure (Text.unpack (renderDependencyError err))+          Right capabilities ->+            testWithApplication (pure FixtureServer.application) $ \port -> do+              validated <-+                loadValidatedWorkspace+                  (GlobalOptions (Just (fixtureManifest "execution")))+                  "."+                  >>= either (assertFailure . Text.unpack . Text.unlines) pure+              workflow <- maybe (assertFailure "health workflow missing") pure (lookupWorkflow (WorkflowName "health") validated)+              resolved <- either (assertFailure . Text.unpack . renderWorkflowError) pure (resolveWorkflow validated (WorkflowName "health"))+              rendered <- renderWorkflow resolved >>= either (assertFailure . Text.unpack . renderRenderError) pure+              validateRenderedWorkflow (capabilities ^. #hurlfmt) rendered+                >>= either (assertFailure . Text.unpack . renderHurlfmtError) pure+              baseUrl <-+                either+                  (assertFailure . show)+                  pure+                  (mkHurlValueLiteral ("http://127.0.0.1:" <> Text.pack (show port)))+              bindings <-+                resolveWorkflowBindings+                  validated+                  workflow+                  emptyBindingInput {plainOverrides = Map.singleton (ParameterName "baseUrl") baseUrl}+                  >>= either (assertFailure . Text.unpack . renderBindingError) pure+              client <- runLive capabilities rendered bindings ClientMode+              test <- runLive capabilities rendered bindings TestMode+              client ^. #exitCode @?= ExitSuccess+              test ^. #exitCode @?= ExitSuccess+              assertBool "client prints fixture response" (maybe False (ByteString.isInfixOf "ok" . view #stdout) (client ^. #capturedOutput))+              assertBool+                "test mode prints a Hurl result"+                ( maybe+                    False+                    (\captured -> not (ByteString.null (captured ^. #stdout) && ByteString.null (captured ^. #stderr)))+                    (test ^. #capturedOutput)+                ),+      testCase "a real Hurl matrix runs with bounded captured output" $ do+        detected <- detectHurlCapabilities+        case detected of+          Left DependencyNotFound {} -> putStrLn "SKIP: Hurl 8.x or Hurlfmt is not installed"+          Left err -> assertFailure (Text.unpack (renderDependencyError err))+          Right capabilities ->+            testWithApplication (pure FixtureServer.application) $ \port -> do+              validated <-+                loadValidatedWorkspace+                  (GlobalOptions (Just (fixtureManifest "execution")))+                  "."+                  >>= either (assertFailure . Text.unpack . Text.unlines) pure+              baseUrl <-+                either+                  (assertFailure . show)+                  pure+                  (mkHurlValueLiteral ("http://127.0.0.1:" <> Text.pack (show port)))+              prepared <-+                prepareSelection+                  (capabilities ^. #hurlfmt)+                  validated+                  emptyBindingInput {plainOverrides = Map.singleton (ParameterName "baseUrl") baseUrl}+                  defaultHurlOptions+                  (SelectMatrix (MatrixName "health-cases"))+                  >>= either (assertFailure . Text.unpack . Text.unlines . map renderPreparationError . toList) pure+              let cases = fmap (buildBatchCase TestMode CaptureRunOutput []) prepared+              result <- runBatch (mkHurlRunner capabilities) (BatchOptions (positiveJobs 2) False) cases+              result ^. #selectedExitCode @?= ExitSuccess+              map (view #outcome) (toList (result ^. #cases)) @?= replicate 3 CasePassed+              assertBool "all cases retain captured Hurl diagnostics" (all (isJust . view #capturedOutput) (result ^. #cases))+    ]++positiveJobs :: Int -> PositiveInt+positiveJobs value = either (error . show) id (mkPositiveInt value)++runLive :: HurlCapabilities -> RenderedWorkflow -> ResolvedBindings -> HurlRunMode -> IO RunResult+runLive capabilities rendered bindings mode = do+  root <- validateFileRoot (unWorkspaceRoot (rendered ^. #workspaceRoot)) Nothing >>= either (assertFailure . show) pure+  runHurl+    (mkHurlRunner capabilities)+    RunRequest+      { renderedWorkflow = rendered,+        fileRoot = root,+        mode,+        bindings,+        options = defaultHurlOptions,+        outputPolicy = CaptureRunOutput,+        reportTargets = []+      }+    >>= either (assertFailure . Text.unpack . renderRunStartError) pure
+ test/fixture-server/FixtureServer.hs view
@@ -0,0 +1,133 @@+module FixtureServer+  ( application,+    runFixtureServer,+    runFixtureRole,+  )+where++import Control.Concurrent (forkIO, killThread, myThreadId, threadDelay, throwTo)+import Control.Exception (AsyncException (UserInterrupt), bracket, bracket_)+import Control.Monad (forever, when)+import Data.ByteString qualified as ByteString+import Data.ByteString.Char8 qualified as Char8+import Data.ByteString.Lazy qualified as LazyByteString+import Data.IORef (atomicModifyIORef', newIORef)+import Network.HTTP.Types+  ( hContentType,+    methodDelete,+    methodGet,+    methodPost,+    status200,+    status201,+    status404,+    status405,+    status409,+    status503,+  )+import Network.Wai+  ( Application,+    pathInfo,+    queryString,+    requestMethod,+    responseLBS,+    strictRequestBody,+  )+import Network.Wai.Handler.Warp (defaultSettings, runSettings, setHost, setPort)+import System.Directory (doesFileExist, renameFile)+import System.FilePath ((</>))+import System.Posix.Process (getProcessID)+import System.Posix.Signals (Handler (Catch), installHandler, sigTERM)++runFixtureServer :: Int -> IO ()+runFixtureServer port =+  runSettings (setPort port (setHost "127.0.0.1" defaultSettings)) application++-- | Deterministic roles for the public orchestration example. Only the worker+-- writes visibility; the API accepts one command and exposes read-only polling.+runFixtureRole :: String -> Int -> FilePath -> IO ()+runFixtureRole role port root = do+  mainThread <- myThreadId+  _ <- installHandler sigTERM (Catch (throwTo mainThread UserInterrupt)) Nothing+  pid <- getProcessID+  appendFile (root </> "pids") (show pid <> "\n")+  generation <-+    if role == "worker"+      then do+        exists <- doesFileExist (root </> "generation")+        previous <- if exists then read . Char8.unpack <$> ByteString.readFile (root </> "generation") else pure (0 :: Int)+        writeFile (root </> "generation") (show (previous + 1))+        pure (previous + 1)+      else pure 0+  firstHealth <- newIORef True+  let event label = appendFile (root </> "events") (role <> "-" <> label <> "\n")+      project = forever $ do+        accepted <- doesFileExist (root </> "accepted")+        when accepted $ do+          writeFile (root </> "visible.tmp") ("visible:" <> show generation)+          renameFile (root </> "visible.tmp") (root </> "visible")+        threadDelay 10000+      serve request respond = case (requestMethod request, pathInfo request) of+        (method, ["health"]) | method == methodGet -> do+          first <- atomicModifyIORef' firstHealth (\value -> (False, value))+          when first (event "ready")+          respond (responseLBS status200 [] "ok")+        (method, ["command"])+          | role == "api",+            method == methodPost -> do+              exists <- doesFileExist (root </> "accepted")+              if exists+                then respond (responseLBS status409 [] "duplicate")+                else do+                  _ <- strictRequestBody request+                  writeFile (root </> "accepted") "accepted"+                  event "mutation"+                  respond (responseLBS status201 [] "accepted")+        (method, ["read"])+          | role == "api",+            method == methodGet -> do+              event "read"+              exists <- doesFileExist (root </> "visible")+              if exists+                then do+                  visible <- ByteString.readFile (root </> "visible")+                  respond (responseLBS status200 [] (LazyByteString.fromStrict visible))+                else respond (responseLBS status503 [] "pending")+        _ -> respond (responseLBS status404 [] "not found")+      run = runSettings (setPort port (setHost "127.0.0.1" defaultSettings)) serve+  bracket_ (event "start") (event "stop") $+    if role == "worker" then bracket (forkIO project) killThread (const run) else run++application :: Application+application request respond = case (requestMethod request, pathInfo request) of+  (method, ["health"])+    | method == methodGet ->+        respond (text status200 "ok\n")+  (method, ["oauth", "token"])+    | method == methodPost ->+        respond (json status200 "{\"access_token\":\"fixture-token\",\"token_type\":\"Bearer\"}\n")+  (method, ["odata", "echo"])+    | method == methodGet ->+        respond (json status200 (LazyByteString.fromStrict (showQuery (queryString request))))+  (method, ["capture"])+    | method == methodGet ->+        respond (responseLBS status200 [("Content-Type", "text/plain"), ("X-Fixture-Capture", "captured-value")] "capture-body\n")+  (method, ["raw-response"])+    | method == methodGet ->+        respond (responseLBS status200 [("Content-Type", "application/octet-stream"), ("X-Raw", "yes")] "raw\NULbytes\n")+  (method, ["mutating"]) | method == methodPost || method == methodDelete -> do+    body <- strictRequestBody request+    respond (responseLBS status201 [(hContentType, "application/octet-stream")] body)+  (_method, ["mutating"]) ->+    respond (text status405 "method not allowed\n")+  _ -> respond (text status404 "not found\n")+  where+    text status = responseLBS status [(hContentType, "text/plain; charset=utf-8")]+    json status = responseLBS status [(hContentType, "application/json")]++showQuery :: [(ByteString.ByteString, Maybe ByteString.ByteString)] -> ByteString.ByteString+showQuery query =+  "{\"query\":\""+    <> ByteString.intercalate+      "&"+      [name <> maybe "" ("=" <>) value | (name, value) <- query]+    <> "\"}\n"
+ test/fixture-server/Main.hs view
@@ -0,0 +1,38 @@+module Main (main) where++import FixtureServer (runFixtureRole, runFixtureServer)+import System.Environment (getArgs, getEnvironment, lookupEnv)+import Text.Read (readMaybe)++main :: IO ()+main = do+  arguments <- getArgs+  case arguments of+    ["--role", role, "--port", value, "--state-dir", root] | role `elem` ["api", "stub", "worker"] ->+      case readMaybe value of+        Just port | port > 0 && port <= 65535 -> runFixtureRole role port root+        _ -> ioError (userError "invalid role port")+    _ -> runDefault arguments++runDefault :: [String] -> IO ()+runDefault arguments = do+  if arguments == ["--assert-clean"]+    then do+      environment <- getEnvironment+      if filter (/= "__CF_USER_TEXT_ENCODING") (map fst environment) == ["PORT"] then pure () else ioError (userError "unexpected clean fixture environment names")+    else pure ()+  environmentPort <- lookupEnv "PORT"+  case parsePort arguments environmentPort of+    Left message -> ioError (userError message)+    Right port -> runFixtureServer port++parsePort :: [String] -> Maybe String -> Either String Int+parsePort arguments environmentPort = case arguments of+  ["--assert-clean"] -> maybe (Right 18080) readPort environmentPort+  [] -> maybe (Right 18080) readPort environmentPort+  ["--port", value] -> readPort value+  _ -> Left "usage: hurl-workbench-fixture-server [--port PORT]"+  where+    readPort value = case readMaybe value of+      Just port | port > 0 && port <= 65535 -> Right port+      _ -> Left ("invalid port: " <> value)
+ test/fixtures/scenario-isolation/hurl-workbench.dhall view
@@ -0,0 +1,19 @@+let S = ../../../../schema/package.dhall++in  S.Workspace::{+    , schemaVersion = 1+    , parameters =+      [ S.Parameter::{ name = "baseUrl" }+      , S.Parameter::{ name = "marker" }+      , S.Parameter::{ name = "token", kind = S.ParameterKind.Secret }+      ]+    , fragments = [ S.Fragment::{ name = "write", path = "hurl/write.hurl" } ]+    , workflows =+      [ S.Workflow::{ name = "write", fragments = [ "write" ], parameters = [ "baseUrl", "marker", "token" ] } ]+    , recipes =+      [ S.Recipe::{ name = "write", workflow = "write", safety = S.Safety.Mutating } ]+    , matrices =+      [ S.Matrix::{ name = "writes", recipe = "write", cases = [ S.MatrixCase::{ name = "first" }, S.MatrixCase::{ name = "second" } ] } ]+    , suites =+      [ S.Suite::{ name = "independent-writes", runs = [ S.RunReference.Matrix "writes" ], isolation = S.IsolationPolicy.IndependentState } ]+    }
+ test/fixtures/scenario-isolation/hurl/write.hurl view
@@ -0,0 +1,14 @@+POST {{baseUrl}}/markers+Content-Type: application/json+X-Test-Token: {{token}}+{+  "marker": "{{marker}}"+}+HTTP 201++GET {{baseUrl}}/markers+HTTP 200+[Asserts]+header "Content-Type" contains "application/json"+jsonpath "$.marker" == "{{marker}}"+jsonpath "$.count" == 1
+ test/fixtures/scenario-isolation/server.py view
@@ -0,0 +1,87 @@+"""Disposable provider-owned HTTP fixture. Database mode requires real psql.++Only --memory selects the local test fixture; real lease acceptance never falls+back to it. Credentials remain in the provider's environment/protected passfile.+"""+import argparse+import http.server+import json+import os+import pathlib+import subprocess+import threading+import time++parser = argparse.ArgumentParser()+parser.add_argument("--directory", type=pathlib.Path, required=True)+parser.add_argument("--name", required=True, choices=("first", "second"))+parser.add_argument("--mode", default="success", choices=("success", "failure", "cancellation"))+parser.add_argument("--memory", action="store_true")+options = parser.parse_args()+markers = []+lock = threading.Lock()+++def sql(statement):+    result = subprocess.run([os.environ["SCENARIO_PSQL"], "-X", "-w", "-Atq"],+                            input=statement.encode(), capture_output=True, timeout=10)+    if result.returncode:+        raise RuntimeError("private database operation failed")+    return result.stdout.decode().strip().splitlines()+++if not options.memory:+    assert sql("SELECT current_database();") == [os.environ["PGDATABASE"]]+    assert sql("SELECT current_user;") == [os.environ["PGUSER"]]+    sql("CREATE TABLE scenario_markers (marker text NOT NULL);")+++class Handler(http.server.BaseHTTPRequestHandler):+    def log_message(self, *_args):+        pass++    def reply(self, status, body):+        data = json.dumps(body).encode()+        self.send_response(status)+        self.send_header("Content-Type", "application/json")+        self.send_header("Content-Length", str(len(data)))+        self.end_headers()+        self.wfile.write(data)++    def do_GET(self):+        if self.path == "/health":+            self.reply(200, {})+            return+        with lock:+            values = list(markers) if options.memory else sql("SELECT marker FROM scenario_markers ORDER BY marker;")+        self.reply(200, {"count": len(values), "marker": values[0] if len(values) == 1 else None})++    def do_POST(self):+        data = json.loads(self.rfile.read(int(self.headers["Content-Length"])))+        marker = data["marker"]+        assert marker in ("private-first-marker", "private-second-marker")+        with lock:+            if options.memory:+                markers.append(marker)+            else:+                sql("INSERT INTO scenario_markers VALUES ('" + marker + "');")+        (options.directory / (options.name + ".posted")).touch()+        if options.mode == "failure":+            self.reply(500, {})+            return+        deadline = time.monotonic() + 20+        while options.mode == "cancellation" or not all((options.directory / (name + ".posted")).exists() for name in ("first", "second")):+            if time.monotonic() >= deadline:+                self.reply(504, {})+                return+            time.sleep(0.01)+        self.reply(201, {})+++server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)+ready = options.directory / (options.name + ".ready.json")+temporary = ready.with_suffix(".tmp")+temporary.touch(mode=0o600)+temporary.write_text(json.dumps({"endpoint": f"http://127.0.0.1:{server.server_port}", "pid": os.getpid()}))+temporary.replace(ready)+server.serve_forever()
+ test/golden/top-level-help.txt view
@@ -0,0 +1,27 @@+hurl-workbench - compose, explore, and test reusable Hurl workflows++Usage: hurl-workbench [--workspace FILE] COMMAND [--version]++  Keep each Hurl entry once, combine entries into named workflows, and validate+  the workspace that describes them.++Workspace+  --workspace FILE         Workspace manifest to use instead of discovering+                           hurl-workbench.dhall from the current directory+                           upward++Available options:+  -h,--help                Show this help text+  --version                Show package version and build revision++Available commands:+  validate                 Check the workspace and report every problem at once+  list                     List the named entities in the workspace+  render                   Compose and syntax-check inspectable Hurl source+  run                      Execute one workflow in Hurl client mode+  test                     Execute one workflow, recipe, or integration suite in+                           Hurl test mode+  matrix                   Execute every declared case in one named matrix+  completions              Generate a completion script for Bash, Zsh, or Fish+  doctor                   Report Hurl and Hurlfmt paths, versions, and support+                           status
+ test/golden/unknown-workflow.txt view
@@ -0,0 +1,1 @@+error: unknown workflow "missing"