diff --git a/.github/workflows/guard.yml b/.github/workflows/guard.yml index 234b769..a55e9fc 100644 --- a/.github/workflows/guard.yml +++ b/.github/workflows/guard.yml @@ -56,4 +56,4 @@ jobs: $binDir | Out-File -Append -Encoding utf8 $env:GITHUB_PATH - name: Flavor self-check - run: flavor check --root . --config flavor.json + run: flavor check --root . --config flavor.toml diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index c34c833..ad4f385 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -80,7 +80,7 @@ jobs: echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Flavor self-check - run: flavor check --root . --config flavor.json + run: flavor check --root . --config flavor.toml build: needs: [metadata, verify] diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index c762ec4..442daf4 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -79,7 +79,7 @@ jobs: echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Flavor self-check - run: flavor check --root . --config flavor.json + run: flavor check --root . --config flavor.toml build: needs: [metadata, verify] diff --git a/.runseal/lib/python-module b/.runseal/lib/python-module deleted file mode 100755 index 118b317..0000000 --- a/.runseal/lib/python-module +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env sh -set -eu - -name=${RUNSEAL_WRAPPER_NAME:?RUNSEAL_WRAPPER_NAME is not set} -module=$(printf '%s' "$name" | tr '-' '_') -root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) - -if ! command -v uv >/dev/null 2>&1; then - echo "missing dependency: uv" >&2 - exit 1 -fi - -PYTHONPATH="$root/scripts${PYTHONPATH+:$PYTHONPATH}" -export PYTHONPATH -exec uv run --project "$root/scripts" python -m "cli.$module" "$@" diff --git a/.runseal/wrappers/cloudflare.seal b/.runseal/wrappers/cloudflare.seal new file mode 100644 index 0000000..26a1000 --- /dev/null +++ b/.runseal/wrappers/cloudflare.seal @@ -0,0 +1,221 @@ +usage() { + print "Usage: runseal :cloudflare [args]" + print "" + print "Commands:" + print " init create repo-local .local/secrets/cloudflare.env template" + print " check validate repo-local credentials and probe core account APIs" + print " manage-plan print the desired manage redirect rule shape" + print " manage-inspect inspect current dynamic redirect ruleset for manage rules" + print " manage-ensure-redirect create/update exact-path manage redirects (use --dry-run first)" + print " api use: runseal @tool cloudflare api request ..." + print "" + print "Credentials:" + print " .local/secrets/cloudflare.env" +} + +if empty "$1"; then + usage + exit 0 +fi + +if eq "$1" help; then + usage + exit 0 +fi + +if eq "$1" --help; then + usage + exit 0 +fi + +case "$1" in + init) + if not_empty "$2"; then + fail "cloudflare: init does not accept arguments" + fi + local_dir="${RUNSEAL_REPO_LOCAL_DIR:-.local}" + secrets_dir="${RUNSEAL_REPO_SECRETS_DIR:-.local/secrets}" + tmp_dir="${RUNSEAL_REPO_TMP_DIR:-.local/tmp}" + token_file="$secrets_dir/cloudflare.env" + seal fs mkdir "$local_dir" 700 + seal fs mkdir "$secrets_dir" 700 + seal fs mkdir "$tmp_dir" 700 + if file_exists "$token_file"; then + print "exists $token_file" + else + seal fs write-base64 "$token_file" IyBSZXBvLWxvY2FsIENsb3VkZmxhcmUgY3JlZGVudGlhbHMgZm9yIHJ1bnNlYWwgc3VwcG9ydCBjb21tYW5kcy4KIyBGaWxsIHRoZXNlIHZhbHVlcyBtYW51YWxseS4gVGhpcyBmaWxlIHN0YXlzIGxvY2FsIGFuZCBnaXRpZ25vcmVkLgpDTE9VREZMQVJFX0FDQ09VTlRfSUQ9CkNMT1VERkxBUkVfQVBJX1RPS0VOPQpDTE9VREZMQVJFX1pPTkVfTkFNRT1wZXJpc2gudWsKQ0xPVURGTEFSRV9NQU5BR0VfSE9TVD1ydW5zZWFsLnBlcmlzaC51awpDTE9VREZMQVJFX01BTkFHRV9PUklHSU5fSE9TVD1yZWxlYXNlcy5ydW5zZWFsLnBlcmlzaC51awpDTE9VREZMQVJFX01BTkFHRV9SRURJUkVDVF9QUkVGSVg9Cg== + seal fs chmod "$token_file" 600 + print "created $token_file" + fi + ;; + check) + if not_empty "$2"; then + fail "cloudflare: check does not accept arguments" + fi + account_id=$(seal cloudflare config get account_id) + zone_name=$(seal cloudflare config get zone_name) + zone=$(seal cloudflare zone get --name "$zone_name") + zone_id=$(seal json get "$zone" .id) + rulesets=$(seal cloudflare zone ruleset list --zone-id "$zone_id") + ruleset_count=$(seal json len "$rulesets") + zones_payload=$(seal cloudflare api request GET /zones --query "account.id=$account_id" --query per_page=50) + zones=$(seal json get "$zones_payload" .result) + zones_pretty=$(seal json pretty "$zones") + account=$(seal cloudflare account get --account-id "$account_id") + account_name=$(seal json get "$account" .name) + buckets=$(seal cloudflare account r2 bucket list --account-id "$account_id") + buckets_pretty=$(seal json pretty "$buckets") + print "cloudflare check: ok" + print "account id: $account_id" + print "account name: $account_name" + print "manage zone: $zone_name ($zone_id)" + print "zone rulesets: $ruleset_count" + print "zones:" + print "$zones_pretty" + print "r2 buckets:" + print "$buckets_pretty" + ;; + manage-plan) + if not_empty "$2"; then + fail "cloudflare: manage-plan does not accept arguments" + fi + zone_name=$(seal cloudflare config get zone_name) + request_host=$(seal cloudflare config get manage_host) + redirect_host=$(seal cloudflare config get manage_origin_host) + prefix=$(seal cloudflare config get manage_redirect_prefix) + if empty "$prefix"; then + target_sh="https://$redirect_host/manage.sh" + target_ps1="https://$redirect_host/manage.ps1" + else + target_sh="https://$redirect_host/$prefix/manage.sh" + target_ps1="https://$redirect_host/$prefix/manage.ps1" + fi + rule_sh=$(seal cloudflare redirect-rule exact --ref runseal_manage_sh_redirect --description "Redirect runseal manage.sh to releases bucket asset" --host "$request_host" --path /manage.sh --target-url "$target_sh") + rule_ps1=$(seal cloudflare redirect-rule exact --ref runseal_manage_ps1_redirect --description "Redirect runseal manage.ps1 to releases bucket asset" --host "$request_host" --path /manage.ps1 --target-url "$target_ps1") + pretty_sh=$(seal json pretty "$rule_sh") + pretty_ps1=$(seal json pretty "$rule_ps1") + print "manage redirect plan" + print "zone: $zone_name" + print "request host: $request_host" + print "redirect host: $redirect_host" + print "phase: http_request_dynamic_redirect" + print "rules:" + print "$pretty_sh" + print "$pretty_ps1" + ;; + manage-inspect) + if not_empty "$2"; then + fail "cloudflare: manage-inspect does not accept arguments" + fi + zone_name=$(seal cloudflare config get zone_name) + zone=$(seal cloudflare zone get --name "$zone_name") + zone_id=$(seal json get "$zone" .id) + rulesets=$(seal cloudflare zone ruleset list --zone-id "$zone_id") + ruleset=$(seal json find "$rulesets" phase http_request_dynamic_redirect) + if empty "$ruleset"; then + print "manage inspect: no http_request_dynamic_redirect zone ruleset found" + exit 0 + fi + ruleset_id=$(seal json get "$ruleset" .id) + full_ruleset=$(seal cloudflare zone ruleset get --zone-id "$zone_id" --ruleset-id "$ruleset_id") + ruleset_name=$(seal json get "$full_ruleset" .name) + rules=$(seal json get "$full_ruleset" .rules) + matched=$(seal json filter "$rules" ref runseal_manage_sh_redirect runseal_manage_ps1_redirect) + matched_count=$(seal json len "$matched") + print "zone id: $zone_id" + print "ruleset id: $ruleset_id" + print "ruleset name: $ruleset_name" + if eq "$matched_count" 0; then + print "manage inspect: no manage redirect rules found" + exit 0 + fi + pretty=$(seal json pretty "$matched") + print "manage rules:" + print "$pretty" + ;; + manage-ensure-redirect) + dry_run=false + if eq "$2" --dry-run; then + dry_run=true + if not_empty "$3"; then + fail "cloudflare: unknown manage-ensure-redirect argument: $3" + fi + else + if not_empty "$2"; then + fail "cloudflare: unknown manage-ensure-redirect argument: $2" + fi + fi + zone_name=$(seal cloudflare config get zone_name) + request_host=$(seal cloudflare config get manage_host) + redirect_host=$(seal cloudflare config get manage_origin_host) + prefix=$(seal cloudflare config get manage_redirect_prefix) + if empty "$prefix"; then + target_sh="https://$redirect_host/manage.sh" + target_ps1="https://$redirect_host/manage.ps1" + else + target_sh="https://$redirect_host/$prefix/manage.sh" + target_ps1="https://$redirect_host/$prefix/manage.ps1" + fi + rule_sh=$(seal cloudflare redirect-rule exact --ref runseal_manage_sh_redirect --description "Redirect runseal manage.sh to releases bucket asset" --host "$request_host" --path /manage.sh --target-url "$target_sh") + rule_ps1=$(seal cloudflare redirect-rule exact --ref runseal_manage_ps1_redirect --description "Redirect runseal manage.ps1 to releases bucket asset" --host "$request_host" --path /manage.ps1 --target-url "$target_ps1") + zone=$(seal cloudflare zone get --name "$zone_name") + zone_id=$(seal json get "$zone" .id) + if eq "$dry_run" true; then + pretty_sh=$(seal json pretty "$rule_sh") + pretty_ps1=$(seal json pretty "$rule_ps1") + print "manage redirect plan" + print "zone: $zone_name" + print "zone id: $zone_id" + print "request host: $request_host" + print "redirect host: $redirect_host" + print "phase: http_request_dynamic_redirect" + print "rules:" + print "$pretty_sh" + print "$pretty_ps1" + exit 0 + fi + rulesets=$(seal cloudflare zone ruleset list --zone-id "$zone_id") + ruleset=$(seal json find "$rulesets" phase http_request_dynamic_redirect) + if empty "$ruleset"; then + ruleset=$(seal cloudflare zone ruleset create --zone-id "$zone_id" --phase http_request_dynamic_redirect --name "Single Redirects ruleset") + else + ruleset_id=$(seal json get "$ruleset" .id) + ruleset=$(seal cloudflare zone ruleset get --zone-id "$zone_id" --ruleset-id "$ruleset_id") + fi + ruleset_id=$(seal json get "$ruleset" .id) + rules=$(seal json get "$ruleset" .rules) + current_sh=$(seal json find "$rules" ref runseal_manage_sh_redirect) + current_ps1=$(seal json find "$rules" ref runseal_manage_ps1_redirect) + if empty "$current_sh"; then + seal cloudflare zone ruleset rule add --zone-id "$zone_id" --ruleset-id "$ruleset_id" --json "$rule_sh" + changed_sh="created runseal_manage_sh_redirect" + else + rule_id=$(seal json get "$current_sh" .id) + seal cloudflare zone ruleset rule update --zone-id "$zone_id" --ruleset-id "$ruleset_id" --rule-id "$rule_id" --json "$rule_sh" + changed_sh="updated runseal_manage_sh_redirect" + fi + if empty "$current_ps1"; then + seal cloudflare zone ruleset rule add --zone-id "$zone_id" --ruleset-id "$ruleset_id" --json "$rule_ps1" + changed_ps1="created runseal_manage_ps1_redirect" + else + rule_id=$(seal json get "$current_ps1" .id) + seal cloudflare zone ruleset rule update --zone-id "$zone_id" --ruleset-id "$ruleset_id" --rule-id "$rule_id" --json "$rule_ps1" + changed_ps1="updated runseal_manage_ps1_redirect" + fi + print "manage ensure redirect: ok" + print " - $changed_sh" + print " - $changed_ps1" + ;; + api) + if empty "$2"; then + fail "cloudflare: api requires a method" + fi + if empty "$3"; then + fail "cloudflare: api requires a path" + fi + seal passthrough 2 cloudflare api request + ;; + *) + fail "cloudflare: unknown command: $1" + ;; +esac diff --git a/.runseal/wrappers/cloudflare.sh b/.runseal/wrappers/cloudflare.sh deleted file mode 100755 index 34aa12b..0000000 --- a/.runseal/wrappers/cloudflare.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env sh -set -eu -exec "$(dirname -- "$0")/../lib/python-module" "$@" diff --git a/.runseal/wrappers/init.seal b/.runseal/wrappers/init.seal new file mode 100644 index 0000000..2139982 --- /dev/null +++ b/.runseal/wrappers/init.seal @@ -0,0 +1,162 @@ +seal argv parse --flag force + +print "==> resolving repository" +root=$(git rev-parse --show-toplevel) +git_dir=$(git rev-parse --absolute-git-dir) +hooks_dir="$git_dir/hooks" +pre_commit="$hooks_dir/pre-commit" +commit_msg="$hooks_dir/commit-msg" +print "repository: $root" + +print "==> checking required tools" +if tool_exists git; then +else + fail "init: missing required tool: git" +fi +if tool_exists python3; then +else + fail "init: missing required tool: python3" +fi +if tool_exists cargo; then +else + fail "init: missing required tool: cargo" +fi +if tool_exists runseal; then +else + fail "init: missing required tool: runseal" +fi +if tool_exists flavor; then +else + fail "init: missing required tool: flavor" +fi +if tool_exists sh; then +else + fail "init: missing required tool: sh" +fi +if tool_exists bash; then +else + fail "init: missing required tool: bash" +fi +if tool_exists sed; then +else + fail "init: missing required tool: sed" +fi +if tool_exists grep; then +else + fail "init: missing required tool: grep" +fi +print "ok: git, python3, cargo, runseal, flavor, sh, bash, sed, grep" + +print "==> checking repository entrypoints" +if file_exists "$root/Cargo.toml"; then +else + fail "init: missing required path: Cargo.toml" +fi +if file_exists "$root/Cargo.lock"; then +else + fail "init: missing required path: Cargo.lock" +fi +if file_exists "$root/flavor.toml"; then +else + fail "init: missing required path: flavor.toml" +fi +if file_exists "$root/manage.sh"; then +else + fail "init: missing required path: manage.sh" +fi +if file_exists "$root/manage.ps1"; then +else + fail "init: missing required path: manage.ps1" +fi +if file_exists "$root/runseal.toml"; then +else + fail "init: missing required path: runseal.toml" +fi +if file_exists "$root/.runseal/wrappers/cloudflare.seal"; then +else + fail "init: missing required path: .runseal/wrappers/cloudflare.seal" +fi +if file_exists "$root/.runseal/wrappers/init.seal"; then +else + fail "init: missing required path: .runseal/wrappers/init.seal" +fi +if file_exists "$root/.runseal/wrappers/pr.seal"; then +else + fail "init: missing required path: .runseal/wrappers/pr.seal" +fi +if file_exists "$root/.runseal/wrappers/release.seal"; then +else + fail "init: missing required path: .runseal/wrappers/release.seal" +fi +if file_exists "$root/.github/workflows/guard.yml"; then +else + fail "init: missing required path: .github/workflows/guard.yml" +fi +if file_exists "$root/.github/workflows/release-beta.yml"; then +else + fail "init: missing required path: .github/workflows/release-beta.yml" +fi +if file_exists "$root/.github/workflows/release-stable.yml"; then +else + fail "init: missing required path: .github/workflows/release-stable.yml" +fi +if file_exists "$root/.github/scripts/release/assets/package.sh"; then +else + fail "init: missing required path: .github/scripts/release/assets/package.sh" +fi +if file_exists "$root/.github/scripts/release/assets/package.ps1"; then +else + fail "init: missing required path: .github/scripts/release/assets/package.ps1" +fi +if file_exists "$root/.github/scripts/release/r2/publish.sh"; then +else + fail "init: missing required path: .github/scripts/release/r2/publish.sh" +fi +if file_exists "$root/.github/scripts/release/smoke/smoke.sh"; then +else + fail "init: missing required path: .github/scripts/release/smoke/smoke.sh" +fi +if file_exists "$root/.github/scripts/release/smoke/smoke.ps1"; then +else + fail "init: missing required path: .github/scripts/release/smoke/smoke.ps1" +fi +print "ok: repository entrypoints" + +print "==> installing git hooks" +seal fs mkdir "$hooks_dir" 700 + +if file_exists "$pre_commit"; then + generated=$(seal fs contains-any "$pre_commit" "runseal init hook" "runseal bootstrap hook") + if eq "$generated" true; then + else + if eq "$force" true; then + backup=$(seal fs backup-numbered "$pre_commit") + print "backed up existing hook to $backup" + else + fail "init: $pre_commit already exists and was not generated by runseal init; rerun with --force to back it up and replace it" + fi + fi +fi + +seal fs write-base64 "$pre_commit" IyEvdXNyL2Jpbi9lbnYgc2gKIyBydW5zZWFsIGluaXQgaG9vazogZ2VuZXJhdGVkIGJ5IC5ydW5zZWFsL3dyYXBwZXJzL2luaXQuc2VhbApzZXQgLWV1CnJvb3Q9JChnaXQgcmV2LXBhcnNlIC0tc2hvdy10b3BsZXZlbCkKY2QgIiRyb290IgoKZWNobyAiPT0+IGNhcmdvIGZtdCIKY2FyZ28gZm10IC0tYWxsIC0tY2hlY2sKCmVjaG8gIj09PiBjYXJnbyBjbGlwcHkiCmNhcmdvIGNsaXBweSAtLWxvY2tlZCAtLXdvcmtzcGFjZSAtLWFsbC10YXJnZXRzIC0tIC1EIHdhcm5pbmdzCgplY2hvICI9PT4gY2FyZ28gdGVzdCIKY2FyZ28gdGVzdCAtLWxvY2tlZCAtLXdvcmtzcGFjZQoKZWNobyAiPT0+IHNlYWwgd3JhcHBlcnMiCmNhcmdvIHJ1biAtLXF1aWV0IC0tbG9ja2VkIC1wIHJ1bnNlYWwgLS0gQHRyYW5zcGlsZSAtLWlucHV0LWxhbmc9c2VhbCAtLW91dHB1dC1sYW5nPXNlYWxpciAucnVuc2VhbC93cmFwcGVycy9pbml0LnNlYWwgPi9kZXYvbnVsbApjYXJnbyBydW4gLS1xdWlldCAtLWxvY2tlZCAtcCBydW5zZWFsIC0tIEB0cmFuc3BpbGUgLS1pbnB1dC1sYW5nPXNlYWwgLS1vdXRwdXQtbGFuZz1zZWFsaXIgLnJ1bnNlYWwvd3JhcHBlcnMvY2xvdWRmbGFyZS5zZWFsID4vZGV2L251bGwKY2FyZ28gcnVuIC0tcXVpZXQgLS1sb2NrZWQgLXAgcnVuc2VhbCAtLSBAdHJhbnNwaWxlIC0taW5wdXQtbGFuZz1zZWFsIC0tb3V0cHV0LWxhbmc9c2VhbGlyIC5ydW5zZWFsL3dyYXBwZXJzL3ByLnNlYWwgPi9kZXYvbnVsbApjYXJnbyBydW4gLS1xdWlldCAtLWxvY2tlZCAtcCBydW5zZWFsIC0tIEB0cmFuc3BpbGUgLS1pbnB1dC1sYW5nPXNlYWwgLS1vdXRwdXQtbGFuZz1zZWFsaXIgLnJ1bnNlYWwvd3JhcHBlcnMvcmVsZWFzZS5zZWFsID4vZGV2L251bGwKCmVjaG8gIj09PiBmbGF2b3Igc2VsZi1jaGVjayIKZmxhdm9yIGNoZWNrIC0tcm9vdCAuIC0tY29uZmlnIGZsYXZvci50b21sCgplY2hvICI9PT4gc2hlbGwgc3ludGF4IgpzaCAtbiBtYW5hZ2Uuc2gKc2ggLW4gLmdpdGh1Yi9zY3JpcHRzL3JlbGVhc2UvYXNzZXRzL2NoZWNrc3Vtcy5zaApzaCAtbiAuZ2l0aHViL3NjcmlwdHMvcmVsZWFzZS9hc3NldHMvcGFja2FnZS5zaApzaCAtbiAuZ2l0aHViL3NjcmlwdHMvcmVsZWFzZS9hc3NldHMvdmVyaWZ5LnNoCnNoIC1uIC5naXRodWIvc2NyaXB0cy9yZWxlYXNlL2dpdGh1Yi9jbGVhbnVwLWFydGlmYWN0cy5zaApiYXNoIC1uIC5naXRodWIvc2NyaXB0cy9yZWxlYXNlL3IyL2NoZWNrLnNoCmJhc2ggLW4gLmdpdGh1Yi9zY3JpcHRzL3JlbGVhc2UvcjIvcHVibGlzaC5zaApiYXNoIC1uIC5naXRodWIvc2NyaXB0cy9yZWxlYXNlL3IyL3N1bW1hcnkuc2gKYmFzaCAtbiAuZ2l0aHViL3NjcmlwdHMvcmVsZWFzZS9yMi92ZXJpZnkuc2gKc2ggLW4gLmdpdGh1Yi9zY3JpcHRzL3JlbGVhc2Uvc21va2Uvc21va2Uuc2gKCmVjaG8gIj09PiBweXRob24gc3ludGF4IgpweXRob24zIC1tIHB5X2NvbXBpbGUgLmdpdGh1Yi9zY3JpcHRzL3JlbGVhc2UvbWV0YWRhdGEvYmV0YS5weQpweXRob24zIC1tIHB5X2NvbXBpbGUgLmdpdGh1Yi9zY3JpcHRzL3JlbGVhc2UvbWV0YWRhdGEvc3RhYmxlLnB5CgppZiBjb21tYW5kIC12IHB3c2ggPi9kZXYvbnVsbCAyPiYxOyB0aGVuCiAgZWNobyAiPT0+IFBvd2VyU2hlbGwgc3ludGF4IgogIHB3c2ggLU5vUHJvZmlsZSAtTm9uSW50ZXJhY3RpdmUgLUNvbW1hbmQgICAgICcKJEVycm9yQWN0aW9uUHJlZmVyZW5jZSA9ICJTdG9wIgokcGF0aHMgPSBAKAogICJtYW5hZ2UucHMxIiwKICAiLmdpdGh1Yi9zY3JpcHRzL3JlbGVhc2UvYXNzZXRzL3BhY2thZ2UucHMxIiwKICAiLmdpdGh1Yi9zY3JpcHRzL3JlbGVhc2Uvc21va2Uvc21va2UucHMxIgopCmZvcmVhY2ggKCRwYXRoIGluICRwYXRocykgewogIFtzY3JpcHRibG9ja106OkNyZWF0ZSgoR2V0LUNvbnRlbnQgLVJhdyAkcGF0aCkpIHwgT3V0LU51bGwKfQonCmVsc2UKICBlY2hvICI9PT4gUG93ZXJTaGVsbCBzeW50YXgiCiAgZWNobyAic2tpcDogcHdzaCBub3QgZm91bmQiCmZp +seal fs chmod "$pre_commit" 755 +print "installed $pre_commit" + +if file_exists "$commit_msg"; then + generated=$(seal fs contains-any "$commit_msg" "runseal init hook" "runseal bootstrap hook") + if eq "$generated" true; then + else + if eq "$force" true; then + backup=$(seal fs backup-numbered "$commit_msg") + print "backed up existing hook to $backup" + else + fail "init: $commit_msg already exists and was not generated by runseal init; rerun with --force to back it up and replace it" + fi + fi +fi + +seal fs write-base64 "$commit_msg" IyEvdXNyL2Jpbi9lbnYgc2gKIyBydW5zZWFsIGluaXQgaG9vazogZ2VuZXJhdGVkIGJ5IC5ydW5zZWFsL3dyYXBwZXJzL2luaXQuc2VhbApzZXQgLWV1CgppZiBbICIkIyIgLW5lIDEgXSB8fCBbICEgLWYgIiQxIiBdOyB0aGVuCiAgZWNobyAiY29tbWl0IG1lc3NhZ2UgZmlsZSBub3QgZm91bmQiID4mMgogIGV4aXQgMQpmaQoKc3ViamVjdD0kKHNlZCAtbiAnCi9eW1s6c3BhY2U6XV0qIy9kCi9bXls6c3BhY2U6XV0vIHsKICBzL15bWzpzcGFjZTpdXSovLwogIHMvW1s6c3BhY2U6XV0qJC8vCiAgcAogIHEKfQonICIkMSIpCgppZiBbIC16ICIkc3ViamVjdCIgXTsgdGhlbgogIGVjaG8gImNvbW1pdCBzdWJqZWN0IGlzIGVtcHR5IiA+JjIKICBleGl0IDEKZmkKCmNhc2UgIiRzdWJqZWN0IiBpbgogICJNZXJnZSAiKnwiUmV2ZXJ0ICIqfCJmaXh1cCEiKnwic3F1YXNoISIqKQogICAgZXhpdCAwCiAgICA7Owplc2FjCgppZiAhIHByaW50ZiAnJXMKJyAiJHN1YmplY3QiIHwgZ3JlcCAtRXEgJ15bYS16XVthLXowLTktXSooL1thLXowLTktXSspKjogW15bOnNwYWNlOl1dLionOyB0aGVuCiAgZWNobyAiY29tbWl0IHN1YmplY3QgbXVzdCBsb29rIGxpa2UgJzxhcmVhPjogPGltcGVyYXRpdmUgc3VtbWFyeT4nIiA+JjIKICBlY2hvICJhY3R1YWw6ICRzdWJqZWN0IiA+JjIKICBleGl0IDEKZmkKCmlmIFsgIiR7I3N1YmplY3R9IiAtZ3QgNzIgXTsgdGhlbgogIGVjaG8gIndhcm5pbmc6IGNvbW1pdCBzdWJqZWN0IGlzICR7I3N1YmplY3R9IGNoYXJzOyBwcmVmZXIgPD0gNzIiID4mMgpmaQo= +seal fs chmod "$commit_msg" 755 +print "installed $commit_msg" + +print "development environment ready" diff --git a/.runseal/wrappers/pr.seal b/.runseal/wrappers/pr.seal new file mode 100644 index 0000000..ff08157 --- /dev/null +++ b/.runseal/wrappers/pr.seal @@ -0,0 +1,164 @@ +usage() { + print "Usage: runseal :pr [options]" + print "" + print "Create or update, watch, and squash-merge the GitHub PR for the current branch." + print "" + print "Options:" + print " --base PR base branch (default: main)" + print " --title title when creating a new PR" + print " --body-file <path> body file when creating a new PR" + print " --draft create the PR as draft and require --no-merge" + print " --no-watch do not watch PR checks" + print " --no-merge do not squash-merge after checks" + print " --no-push do not push the current branch first" + print " --dry-run print planned actions without changing remote state" +} + +seal argv parse --string base=main --string title= --string body_file= --flag draft --flag no_watch --flag no_merge --flag no_push --flag dry_run + +if eq "$__seal_help" true; then + usage + exit 0 +fi + +git --version +gh --version +gh auth status + +branch=$(git branch --show-current) +if empty "$branch"; then + fail "pr: not on a branch" +fi + +if eq "$branch" "$base"; then + fail "pr: refusing to open a PR from base branch: $branch" +fi + +if eq "$branch" main; then + fail "pr: refusing to open a PR from base branch: $branch" +fi + +if eq "$branch" master; then + fail "pr: refusing to open a PR from base branch: $branch" +fi + +if eq "$draft" true; then + if eq "$no_merge" false; then + fail "pr: --draft requires --no-merge" + fi +fi + +if eq "$dry_run" true; then + print "branch: $branch" + print "base: $base" + if eq "$no_push" true; then + print "push: False" + else + print "push: True" + fi + print "pr: create if missing, otherwise reuse existing" + if eq "$draft" true; then + print "draft: True" + print "ready: False" + else + print "draft: False" + print "ready: True" + fi + if eq "$no_watch" true; then + print "watch: False" + else + print "watch: True" + fi + if eq "$no_merge" true; then + print "squash_merge: False" + else + print "squash_merge: True" + fi + exit 0 +fi + +if eq "$no_push" false; then + git push -u origin "$branch" +fi + +created=false +pr_raw=$(gh pr list --head "$branch" --json number,title,state,url,isDraft) + +if json_empty "$pr_raw"; then + if eq "$draft" true; then + if not_empty "$title"; then + if not_empty "$body_file"; then + gh pr create --draft --base "$base" --head "$branch" --title "$title" --body-file "$body_file" + else + gh pr create --draft --base "$base" --head "$branch" --title "$title" --fill + fi + else + if not_empty "$body_file"; then + gh pr create --draft --base "$base" --head "$branch" --fill --body-file "$body_file" + else + gh pr create --draft --base "$base" --head "$branch" --fill + fi + fi + else + if not_empty "$title"; then + if not_empty "$body_file"; then + gh pr create --base "$base" --head "$branch" --title "$title" --body-file "$body_file" + else + gh pr create --base "$base" --head "$branch" --title "$title" --fill + fi + else + if not_empty "$body_file"; then + gh pr create --base "$base" --head "$branch" --fill --body-file "$body_file" + else + gh pr create --base "$base" --head "$branch" --fill + fi + fi + fi + created=true + pr_raw=$(gh pr list --head "$branch" --json number,title,state,url,isDraft) + if json_empty "$pr_raw"; then + fail "pr: created PR for $branch, but could not find it afterward" + fi +fi + +number=$(seal json get "$pr_raw" '.[0].number') +url=$(seal json get "$pr_raw" '.[0].url') +is_draft=$(seal json get "$pr_raw" '.[0].isDraft') + +if eq "$created" true; then + print "created PR #$number: $url" +else + print "found PR #$number: $url" +fi + +if eq "$is_draft" true; then + if eq "$draft" false; then + gh pr ready "$number" + print "marked PR #$number ready" + fi +fi + +if eq "$no_watch" false; then + checks_seen=false + attempt=0 + while lt "$attempt" 12; do + seal capture optional checks_output checks_status gh pr checks "$number" + no_checks=$(seal regex capture "$checks_output" '(no checks reported)' 1) + if empty "$no_checks"; then + checks_seen=true + break + fi + sleep 5 + attempt=$(seal int add "$attempt" 1) + done + if eq "$checks_seen" false; then + print "no checks reported on PR #$number; skipping watch" + else + gh pr checks "$number" --watch --interval 10 + fi +fi + +if eq "$no_merge" false; then + gh pr merge "$number" --squash --delete-branch + print "squash-merged PR #$number" +fi diff --git a/.runseal/wrappers/pr.sh b/.runseal/wrappers/pr.sh deleted file mode 100755 index 34aa12b..0000000 --- a/.runseal/wrappers/pr.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env sh -set -eu -exec "$(dirname -- "$0")/../lib/python-module" "$@" diff --git a/.runseal/wrappers/release.seal b/.runseal/wrappers/release.seal new file mode 100644 index 0000000..18493b2 --- /dev/null +++ b/.runseal/wrappers/release.seal @@ -0,0 +1,71 @@ +usage() { + print "Usage: runseal :release --channel=stable|beta [options]" + print "" + print "Trigger a release workflow." + print "" + print "Options:" + print " --channel <name> release channel: stable or beta" + print " --ref <ref> git ref passed to the workflow (default: main)" + print " --version <version> optional workflow version_override" + print " --watch watch the triggered workflow run" + print " --dry-run print planned action without triggering a workflow" +} + +seal argv parse --string channel= --string ref=main --string version= --flag watch --flag dry_run + +if eq "$__seal_argc" 0; then + usage + exit 0 +fi + +if eq "$__seal_help" true; then + usage + exit 0 +fi + +if empty "$channel"; then + fail "release: --channel is required" +fi + +case "$channel" in + stable) workflow=release-stable.yml ;; + beta) workflow=release-beta.yml ;; + *) + error "invalid choice: $channel" + exit 2 + ;; +esac + +command="gh workflow run $workflow --ref $ref -f ref=$ref -f version_override=$version" + +if eq "$dry_run" true; then + print "$command" +else + gh --version + gh auth status + trigger_output=$(gh workflow run "$workflow" --ref "$ref" -f "ref=$ref" -f "version_override=$version") + if not_empty "$trigger_output"; then + print "$trigger_output" + fi + print "triggered $workflow for ref $ref" + if eq "$watch" true; then + run_id=$(seal regex capture "$trigger_output" '/actions/runs/([0-9]+)' 1) + if empty "$run_id"; then + attempt=0 + raw='[]' + while lt "$attempt" 6; do + raw=$(gh run list --workflow "$workflow" --branch "$ref" --event workflow_dispatch --limit 1 --json databaseId) + if json_not_empty "$raw"; then + run_id=$(seal json get "$raw" '.[0].databaseId') + break + fi + sleep 2 + attempt=$(seal int add "$attempt" 1) + done + fi + if empty "$run_id"; then + fail "release: could not find a recent run for $workflow on $ref" + fi + gh run watch "$run_id" --interval 10 + fi +fi diff --git a/.runseal/wrappers/release.sh b/.runseal/wrappers/release.sh deleted file mode 100755 index 34aa12b..0000000 --- a/.runseal/wrappers/release.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env sh -set -eu -exec "$(dirname -- "$0")/../lib/python-module" "$@" diff --git a/AGENTS.md b/AGENTS.md index 79097e7..5990e20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,8 +10,14 @@ Small CLI. Explicit profile. No hidden orchestration. - Treat `RUNSEAL_HOME` as the runseal configuration root. - Treat `RUNSEAL_PROFILE_HOME` as the profile directory, defaulting to `<RUNSEAL_HOME>/profiles`. - Resolve one concrete `RUNSEAL_PROFILE_PATH` during app initialization. +- Treat `runseal` and `flavor` as installed developer infrastructure, at the + same level as `git`, `gh`, and `cargo`; this repository does not bootstrap + them. - Preserve command lifecycle semantics: load profile, register symlinks, export env, run command, cleanup symlinks. - Keep command namespaces explicit: `<cmd>` is external, `:<cmd>` is profile wrapper, `@<cmd>` is runseal internal. +- Treat `.runseal/wrappers/*.seal` as first-class wrappers executed directly by + runseal. `@transpile` is an isomorphic debug/export tool, not the normal + wrapper execution path. ## Directory Conventions @@ -19,11 +25,12 @@ Small CLI. Explicit profile. No hidden orchestration. - `app/src/core/config.rs`: app configuration and profile discovery. - `app/src/core/profile.rs`: profile format loading and normalization. - `app/src/core/runtime.rs`: command execution lifecycle. +- `app/src/core/transpile/runner.rs`: direct Seal wrapper runtime. - `app/src/core/injections/`: `env` and `symlink` implementations. - `app/tests/`: integration tests and focused unit tests. - `runseal.toml`: repo-local operator profile. -- `.runseal/wrappers/`: thin repo-local `:wrapper` entrypoints. -- `scripts/cli/`: uv-managed support commands for repository operations. +- `.runseal/wrappers/`: repo-local `:wrapper` entrypoints. Prefer `.seal` + wrappers; platform scripts exist only while a wrapper has not migrated. - `manage.sh` and `manage.ps1`: public install/uninstall managers. - `.task/`: branch-bound task state, ignored by git. @@ -48,7 +55,8 @@ Successful profile and wrapper paths are normalized absolute paths. ## Development Workflow 1. Work on a feature branch. -2. Use `runseal :cloudflare`, `runseal :pr`, and `runseal :release` for repo management. +2. Use `runseal :init`, `runseal :cloudflare`, `runseal :pr`, and + `runseal :release` for repo management. 3. Keep changes scoped to the reduced CLI surface. 4. Run: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index c9d33ab..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,15 +0,0 @@ -# Contributing - -Keep changes aligned with the current 0.1.0 surface: - -- `env` injection -- `symlink` injection -- profile discovery through `--profile`, cwd `runseal.*`, and `RUNSEAL_PROFILE_HOME/default.*` -- command execution through `runseal [--profile <path>] <command> -- <args>` - -Run local checks before sending changes: - -```bash -cargo fmt --check -cargo test -``` diff --git a/Cargo.lock b/Cargo.lock index 0e3918f..528375e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "1.0.0" @@ -38,7 +47,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -49,7 +58,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -73,6 +82,18 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.12.1" @@ -90,12 +111,40 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "clap" version = "4.6.1" @@ -166,7 +215,18 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -182,7 +242,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -191,12 +251,76 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -204,8 +328,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", ] [[package]] @@ -216,7 +356,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -242,12 +382,213 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -260,6 +601,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -272,6 +619,18 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -305,18 +664,41 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "log" version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "memchr" version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -353,6 +735,36 @@ dependencies = [ "once_cell", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "predicates" version = "3.1.4" @@ -399,6 +811,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -408,12 +875,47 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -425,20 +927,100 @@ dependencies = [ "thiserror", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] [[package]] -name = "runseal" -version = "0.2.0" +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "anyhow", - "assert_cmd", - "clap", - "path-absolutize", + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "runseal" +version = "0.2.0" +dependencies = [ + "anyhow", + "assert_cmd", + "base64", + "clap", + "path-absolutize", + "regex", + "reqwest", "serde", "serde_json", "shellexpand", @@ -447,6 +1029,12 @@ dependencies = [ "yaml_serde", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustix" version = "1.1.4" @@ -457,9 +1045,50 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.23" @@ -524,6 +1153,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "shellexpand" version = "3.1.2" @@ -533,12 +1174,52 @@ dependencies = [ "dirs", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -550,6 +1231,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -560,7 +1261,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -589,6 +1290,55 @@ dependencies = [ "syn", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -630,6 +1380,76 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -642,6 +1462,30 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -657,6 +1501,15 @@ dependencies = [ "libc", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -681,6 +1534,61 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -715,12 +1623,59 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -730,6 +1685,135 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.15" @@ -833,6 +1917,12 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "yaml_serde" version = "0.10.4" @@ -846,6 +1936,109 @@ dependencies = [ "serde", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/README.md b/README.md index 999ee0f..6a26ddf 100644 --- a/README.md +++ b/README.md @@ -43,13 +43,14 @@ runseal :ssh-run host ## Inspect What Runseal Sees -Internal commands are read-only and do not run profile injections: +Runseal inspection commands are read-only and do not run profile injections: ```bash runseal @profile runseal @resources runseal @resolve resource:// resource://ssh/config runseal @transpile --input-lang=seal --output-lang=bash ./operator.seal +runseal @tool json get '{"releaseVersion":"v0.2.0"}' '.releaseVersion' runseal @wrappers runseal @which :ssh-run ``` @@ -64,7 +65,7 @@ Command routing is based on the first command token: - `runseal <cmd>` runs an external command inside the profile. - `runseal :<cmd>` runs a profile wrapper. -- `runseal @<cmd>` runs a read-only runseal internal command. +- `runseal @<cmd>` runs a runseal-owned command. For example: @@ -231,8 +232,10 @@ runseal :ssh-run host ./probe.sh -- arg Wrapper lookup order is: -1. `<profile-dir>/.runseal/wrappers/<name>.sh` -2. `$RUNSEAL_HOME/wrappers/<name>.sh` +1. `<profile-dir>/.runseal/wrappers/<name>.seal` +2. `<profile-dir>/.runseal/wrappers/<name>.sh` +3. `$RUNSEAL_HOME/wrappers/<name>.seal` +4. `$RUNSEAL_HOME/wrappers/<name>.sh` The profile directory is the directory containing `RUNSEAL_PROFILE_PATH`. Successful profile and wrapper paths are normalized absolute paths. @@ -241,7 +244,8 @@ The child working directory is not changed. A resolved wrapper receives: - `RUNSEAL_WRAPPER_NAME` - `RUNSEAL_WRAPPER_FILE` -On Unix, wrapper files use the `.sh` suffix and must be executable. On Windows, +Seal wrappers use the `.seal` suffix and are interpreted directly by runseal. +On Unix, shell wrappers use the `.sh` suffix and must be executable. On Windows, runseal also checks `.exe`, `.cmd`, and `.bat` when the wrapper name has no extension. @@ -259,7 +263,8 @@ runseal @wrappers runseal @which :ssh-run ``` -Internal commands are read-only and do not run profile injections. +Runseal-owned commands do not run profile injections. Inspection commands are +read-only; `@tool` is the explicit atomic tool runtime. - `@profile` prints the resolved runseal runtime paths. If resources are configured, it also prints `RUNSEAL_RESOURCE_ROOT`. @@ -270,6 +275,8 @@ Internal commands are read-only and do not run profile injections. explicit glue languages and prints the generated output. Cold start supports `bash`, `seal`, `powershell`, and `sealir` inputs and outputs for the currently recognized intersection. +- `@tool <namespace> <command> ...` runs an atomic runseal tool command. Cold + start supports `json get`, `string trim`, `regex capture`, and `int add`. - `@wrappers` lists the effective wrappers visible to the current profile. - `@which :<name>` prints the wrapper file that `:<name>` resolves to. @@ -307,7 +314,7 @@ injections: Initialize local development hooks: ```bash -python3 scripts/init.py +runseal :init ``` ```bash diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index b9b8c3e..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,23 +0,0 @@ -# Security Policy - -## Supported Versions - -runseal is in early beta. Security fixes are handled on a best-effort basis for -the latest beta release on the `main` branch. - -## Reporting a Vulnerability - -Please do not open a public issue for suspected vulnerabilities. - -Instead, report them privately by emailing `security@runseal.ai` with: - -- a short description of the issue -- affected version, commit, or branch -- reproduction steps or a proof of concept -- any suggested mitigations or impact details - -You can expect an initial response within 5 business days. We will work with -you to validate the report, assess impact, and coordinate a fix and disclosure. - -If you are unsure whether something is security-sensitive, please report it -privately first. diff --git a/app/Cargo.toml b/app/Cargo.toml index a2cfd4f..4b86476 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -8,11 +8,14 @@ anyhow = "1.0" clap = { version = "4.5", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +regex = "1.11" shellexpand = "3.1" path-absolutize = "3.1" toml = "0.8" yaml_serde = "0.10" tempfile = "3.12" +base64 = "0.22" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } [dev-dependencies] assert_cmd = "2.0" diff --git a/app/src/bin/runseal.rs b/app/src/bin/runseal.rs index c987989..1e628a8 100644 --- a/app/src/bin/runseal.rs +++ b/app/src/bin/runseal.rs @@ -6,6 +6,7 @@ use clap::{CommandFactory, Parser}; use runseal::core::app::AppState; use runseal::core::config::{CliInput, RawEnv, RuntimeConfig}; use runseal::core::internal_help; +use runseal::core::tool; use runseal::core::transpile; use runseal::run; @@ -18,21 +19,22 @@ use runseal::run; Command model: runseal <cmd> run an external command inside the profile runseal :<name> run a profile wrapper - runseal @<name> run a read-only internal command + runseal @<name> run a runseal command -Internal commands: +Runseal commands: @profile print resolved runtime paths @resources print the resolved resource root @resolve <uri>... resolve resource:// paths @transpile transpile explicit input/output glue languages + @tool run an atomic runseal tool command @wrappers list visible wrappers @which :<name> print a wrapper path Profile discovery walks from the current directory upward for runseal.toml|yaml|yml|json, then falls back to $RUNSEAL_PROFILE_HOME/default.toml|yaml|yml|json. -Run runseal @profile --help, @resolve --help, @transpile --help, @wrappers --help, -or @which --help for details. +Run runseal @profile --help, @resolve --help, @transpile --help, @tool --help, +@wrappers --help, or @which --help for details. Repository: https://github.com/PerishCode/runseal" )] @@ -81,13 +83,19 @@ fn build_runtime_config(cli: Cli) -> Result<RuntimeConfig> { } fn run_early_internal(command: &[String]) -> Result<bool> { - if command.first().map(String::as_str) != Some("@transpile") { - return Ok(false); + match command.first().map(String::as_str) { + Some("@transpile") => { + let options = transpile::parse_args(&command[1..])?; + let output = transpile::transpile_file(&options)?; + print!("{output}"); + Ok(true) + } + Some("@tool") => { + tool::run(&command[1..])?; + Ok(true) + } + _ => Ok(false), } - let options = transpile::parse_args(&command[1..])?; - let output = transpile::transpile_file(&options)?; - print!("{output}"); - Ok(true) } fn print_internal_help(command: &[String]) -> Result<bool> { diff --git a/app/src/core/internal_help.rs b/app/src/core/internal_help.rs index ce31fce..1a2285f 100644 --- a/app/src/core/internal_help.rs +++ b/app/src/core/internal_help.rs @@ -12,6 +12,7 @@ fn text(name: &str) -> Result<&'static str> { "profile" => Ok(PROFILE), "resolve" => Ok(RESOLVE), "resources" => Ok(RESOURCES), + "tool" => Ok(crate::core::tool::help()), "transpile" => Ok(TRANSPILE), "wrappers" => Ok(WRAPPERS), "which" => Ok(WHICH), @@ -109,12 +110,15 @@ Usage: runseal @wrappers List the effective wrappers visible to the selected profile. Lookup order: - 1. <profile-dir>/.runseal/wrappers/<name>.sh - 2. $RUNSEAL_HOME/wrappers/<name>.sh + 1. <profile-dir>/.runseal/wrappers/<name>.seal + 2. <profile-dir>/.runseal/wrappers/<name>.sh + 3. $RUNSEAL_HOME/wrappers/<name>.seal + 4. $RUNSEAL_HOME/wrappers/<name>.sh Profile-local wrappers shadow home wrappers with the same name. On Unix, wrapper -files use the .sh suffix and must be executable. On Windows, runseal also checks -.exe, .cmd, and .bat when the wrapper name has no extension. +shell files use the .sh suffix and must be executable. Seal wrappers use the +.seal suffix and are interpreted directly by runseal. On Windows, runseal also +checks .exe, .cmd, and .bat when the wrapper name has no extension. @wrappers is read-only and does not run profile injections. "; diff --git a/app/src/core/mod.rs b/app/src/core/mod.rs index adc3c27..8f83d76 100644 --- a/app/src/core/mod.rs +++ b/app/src/core/mod.rs @@ -5,4 +5,5 @@ pub mod injections; pub mod internal_help; pub mod profile; pub mod runtime; +pub mod tool; pub mod transpile; diff --git a/app/src/core/runtime.rs b/app/src/core/runtime.rs index 3ae8bbe..33f1d0a 100644 --- a/app/src/core/runtime.rs +++ b/app/src/core/runtime.rs @@ -1,19 +1,15 @@ -use std::{ - collections::{BTreeMap, BTreeSet}, - env, - path::{Path, PathBuf}, - process::Command, -}; +use std::{collections::BTreeMap, path::PathBuf, process::Command}; use anyhow::{Context, Result, bail}; -use path_absolutize::Absolutize; use super::app::AppContext; use super::config::RuntimeConfig; use super::env_key::is_valid_env_key; use super::internal_help; use super::profile::InjectionProfile; -use super::{injections, profile}; +use super::{injections, profile, transpile}; + +mod wrapper_paths; pub struct RunResult { pub exit_code: Option<i32>, @@ -76,7 +72,7 @@ fn resolve_command( bail!("command mode requires at least one command token"); } if let Some(name) = wrapper_name(&config.command[0])? { - let file = resolve_wrapper(config, &name)?; + let file = wrapper_paths::resolve(config, &name)?; let mut argv = Vec::with_capacity(config.command.len()); argv.push(file.to_string_lossy().into_owned()); argv.extend_from_slice(&config.command[1..]); @@ -163,86 +159,6 @@ fn validate_symbol_name(name: &str) -> Result<()> { Ok(()) } -fn resolve_wrapper(config: &RuntimeConfig, name: &str) -> Result<PathBuf> { - let searched = wrapper_search_paths(config, name); - - for candidate in &searched { - if wrapper_is_executable(candidate) { - return candidate - .absolutize() - .with_context(|| format!("failed to absolutize wrapper: {}", candidate.display())) - .map(|path| path.to_path_buf()); - } - } - - let searched = searched - .iter() - .map(|path| format!("- {}", path.display())) - .collect::<Vec<_>>() - .join("\n"); - bail!("wrapper not found: :{name}\nsearched:\n{searched}") -} - -fn wrapper_search_paths(config: &RuntimeConfig, name: &str) -> Vec<PathBuf> { - wrapper_search_dirs(config) - .into_iter() - .flat_map(|dir| wrapper_candidates(&dir, name)) - .collect() -} - -fn wrapper_search_dirs(config: &RuntimeConfig) -> Vec<PathBuf> { - vec![ - profile_root(&config.profile_path) - .join(".runseal") - .join("wrappers"), - config.runseal_home.join("wrappers"), - ] -} - -fn profile_root(profile_path: &Path) -> &Path { - profile_path.parent().unwrap_or(Path::new(".")) -} - -#[cfg(unix)] -fn wrapper_candidates(dir: &Path, name: &str) -> Vec<PathBuf> { - if Path::new(name).extension().is_some() { - return vec![dir.join(name)]; - } - vec![dir.join(format!("{name}.sh"))] -} - -#[cfg(windows)] -fn wrapper_candidates(dir: &Path, name: &str) -> Vec<PathBuf> { - let exact = dir.join(name); - if Path::new(name).extension().is_some() { - return vec![exact]; - } - [exact] - .into_iter() - .chain( - ["exe", "cmd", "bat"] - .into_iter() - .map(|ext| dir.join(format!("{name}.{ext}"))), - ) - .collect() -} - -#[cfg(unix)] -fn wrapper_is_executable(path: &Path) -> bool { - use std::os::unix::fs::PermissionsExt; - - path.is_file() - && path - .metadata() - .map(|metadata| metadata.permissions().mode() & 0o111 != 0) - .unwrap_or(false) -} - -#[cfg(windows)] -fn wrapper_is_executable(path: &Path) -> bool { - path.is_file() -} - fn run_internal(config: &RuntimeConfig, command: InternalCommand) -> Result<()> { match command { InternalCommand::Help(help) => print!("{help}"), @@ -267,13 +183,13 @@ fn print_profile(config: &RuntimeConfig) -> Result<()> { } println!( "RUNSEAL_WRAPPER_PATH={}", - wrapper_path_env(config)?.to_string_lossy() + wrapper_paths::path_env(config)?.to_string_lossy() ); Ok(()) } fn print_wrappers(config: &RuntimeConfig) -> Result<()> { - for wrapper in effective_wrappers(config)? { + for wrapper in wrapper_paths::effective(config)? { println!( ":{:<20} {}\t{}", wrapper.name, @@ -302,84 +218,11 @@ fn print_resources(config: &RuntimeConfig) -> Result<()> { } fn print_which_wrapper(config: &RuntimeConfig, name: &str) -> Result<()> { - let file = resolve_wrapper(config, name)?; + let file = wrapper_paths::resolve(config, name)?; println!("{}", file.display()); Ok(()) } -struct ListedWrapper { - name: String, - source: &'static str, - file: PathBuf, -} - -fn effective_wrappers(config: &RuntimeConfig) -> Result<Vec<ListedWrapper>> { - let dirs = wrapper_search_dirs(config); - let mut names = BTreeSet::new(); - - for dir in &dirs { - let Ok(entries) = std::fs::read_dir(dir) else { - continue; - }; - for entry in entries { - let entry = - entry.with_context(|| format!("failed to read wrapper dir: {}", dir.display()))?; - let file = entry.path(); - if !wrapper_is_executable(&file) { - continue; - } - let Some(name) = listed_wrapper_name(&file) else { - continue; - }; - names.insert(name); - } - } - - let mut wrappers = Vec::new(); - for name in names { - let file = resolve_wrapper(config, &name)?; - let source = if file.starts_with(&dirs[0]) { - "profile" - } else { - "home" - }; - wrappers.push(ListedWrapper { name, source, file }); - } - Ok(wrappers) -} - -#[cfg(unix)] -fn listed_wrapper_name(path: &Path) -> Option<String> { - if path.extension().and_then(std::ffi::OsStr::to_str) != Some("sh") { - return None; - } - let stem = path.file_stem()?.to_str()?; - validate_symbol_name(stem).ok()?; - Some(stem.to_string()) -} - -#[cfg(windows)] -fn listed_wrapper_name(path: &Path) -> Option<String> { - let file_name = path.file_name()?.to_str()?; - if let Some(ext) = path.extension().and_then(std::ffi::OsStr::to_str) - && matches_ignore_ascii_case(ext, &["exe", "cmd", "bat"]) - { - let stem = path.file_stem()?.to_str()?; - validate_symbol_name(stem).ok()?; - return Some(stem.to_string()); - } - - validate_symbol_name(file_name).ok()?; - Some(file_name.to_string()) -} - -#[cfg(windows)] -fn matches_ignore_ascii_case(value: &str, expected: &[&str]) -> bool { - expected - .iter() - .any(|candidate| value.eq_ignore_ascii_case(candidate)) -} - fn apply_argv_injections( command: &[String], injections: &[InjectionProfile], @@ -437,19 +280,17 @@ fn run_command( if command.is_empty() { bail!("command mode requires at least one command token"); } + if let Some(wrapper) = &resolved.wrapper + && wrapper_paths::is_seal(&wrapper.file) + { + let env = run_env(config, resolved, exports)?; + return transpile::run_seal_file(&wrapper.file, &resolved.argv[1..], &env); + } let mut child = child_command(resolved); - child.envs(exports.iter().map(|(k, v)| (k.as_str(), v.as_str()))); - child.env("RUNSEAL_HOME", &config.runseal_home); - child.env("RUNSEAL_PROFILE_HOME", &config.profile_home); - child.env("RUNSEAL_PROFILE_PATH", &config.profile_path); - child.env("RUNSEAL_WRAPPER_PATH", wrapper_path_env(config)?); child.env_remove("RUNSEAL_WRAPPER_NAME"); child.env_remove("RUNSEAL_WRAPPER_FILE"); - if let Some(wrapper) = &resolved.wrapper { - child.env("RUNSEAL_WRAPPER_NAME", &wrapper.name); - child.env("RUNSEAL_WRAPPER_FILE", &wrapper.file); - } + child.envs(run_env(config, resolved, exports)?); let status = child.status().context("failed to execute child command")?; if let Some(code) = status.code() { @@ -467,6 +308,40 @@ fn run_command( Ok(1) } +fn run_env( + config: &RuntimeConfig, + resolved: &ResolvedCommand, + exports: &[(String, String)], +) -> Result<Vec<(String, String)>> { + let mut env = exports.to_vec(); + env.push(( + "RUNSEAL_HOME".to_string(), + config.runseal_home.to_string_lossy().into_owned(), + )); + env.push(( + "RUNSEAL_PROFILE_HOME".to_string(), + config.profile_home.to_string_lossy().into_owned(), + )); + env.push(( + "RUNSEAL_PROFILE_PATH".to_string(), + config.profile_path.to_string_lossy().into_owned(), + )); + env.push(( + "RUNSEAL_WRAPPER_PATH".to_string(), + wrapper_paths::path_env(config)? + .to_string_lossy() + .into_owned(), + )); + if let Some(wrapper) = &resolved.wrapper { + env.push(("RUNSEAL_WRAPPER_NAME".to_string(), wrapper.name.clone())); + env.push(( + "RUNSEAL_WRAPPER_FILE".to_string(), + wrapper.file.to_string_lossy().into_owned(), + )); + } + Ok(env) +} + fn child_command(resolved: &ResolvedCommand) -> Command { #[cfg(windows)] if let Some(wrapper) = &resolved.wrapper @@ -488,13 +363,9 @@ fn child_command(resolved: &ResolvedCommand) -> Command { } #[cfg(windows)] -fn wrapper_uses_cmd(path: &Path) -> bool { +fn wrapper_uses_cmd(path: &std::path::Path) -> bool { matches!( path.extension().and_then(|ext| ext.to_str()), Some(ext) if ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat") ) } - -fn wrapper_path_env(config: &RuntimeConfig) -> Result<std::ffi::OsString> { - env::join_paths(wrapper_search_dirs(config)).context("failed to build RUNSEAL_WRAPPER_PATH") -} diff --git a/app/src/core/runtime/wrapper_paths.rs b/app/src/core/runtime/wrapper_paths.rs new file mode 100644 index 0000000..dc73e01 --- /dev/null +++ b/app/src/core/runtime/wrapper_paths.rs @@ -0,0 +1,199 @@ +use std::{ + collections::BTreeSet, + env, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result, bail}; +use path_absolutize::Absolutize; + +use crate::core::config::RuntimeConfig; + +#[derive(Debug)] +pub(super) struct Listed { + pub(super) name: String, + pub(super) source: &'static str, + pub(super) file: PathBuf, +} + +pub(super) fn resolve(config: &RuntimeConfig, name: &str) -> Result<PathBuf> { + let searched = search_paths(config, name); + for candidate in &searched { + if is_runnable(candidate) { + return candidate + .absolutize() + .with_context(|| format!("failed to absolutize wrapper: {}", candidate.display())) + .map(|path| path.to_path_buf()); + } + } + + let searched = searched + .iter() + .map(|path| format!("- {}", path.display())) + .collect::<Vec<_>>() + .join("\n"); + bail!("wrapper not found: :{name}\nsearched:\n{searched}") +} + +pub(super) fn effective(config: &RuntimeConfig) -> Result<Vec<Listed>> { + let dirs = search_dirs(config); + let mut names = BTreeSet::new(); + + for dir in &dirs { + let Ok(entries) = std::fs::read_dir(dir) else { + continue; + }; + for entry in entries { + let entry = + entry.with_context(|| format!("failed to read wrapper dir: {}", dir.display()))?; + let file = entry.path(); + if !is_runnable(&file) { + continue; + } + let Some(name) = listed_name(&file) else { + continue; + }; + names.insert(name); + } + } + + let mut wrappers = Vec::new(); + for name in names { + let file = resolve(config, &name)?; + let source = if file.starts_with(&dirs[0]) { + "profile" + } else { + "home" + }; + wrappers.push(Listed { name, source, file }); + } + Ok(wrappers) +} + +pub(super) fn path_env(config: &RuntimeConfig) -> Result<std::ffi::OsString> { + env::join_paths(search_dirs(config)).context("failed to build RUNSEAL_WRAPPER_PATH") +} + +pub(super) fn is_seal(path: &Path) -> bool { + path.extension().and_then(std::ffi::OsStr::to_str) == Some("seal") +} + +fn is_runnable(path: &Path) -> bool { + if is_seal(path) { + return path.is_file(); + } + is_executable(path) +} + +fn search_paths(config: &RuntimeConfig, name: &str) -> Vec<PathBuf> { + search_dirs(config) + .into_iter() + .flat_map(|dir| candidates(&dir, name)) + .collect() +} + +fn search_dirs(config: &RuntimeConfig) -> Vec<PathBuf> { + vec![ + profile_root(&config.profile_path) + .join(".runseal") + .join("wrappers"), + config.runseal_home.join("wrappers"), + ] +} + +fn profile_root(profile_path: &Path) -> &Path { + profile_path.parent().unwrap_or(Path::new(".")) +} + +#[cfg(unix)] +fn candidates(dir: &Path, name: &str) -> Vec<PathBuf> { + if Path::new(name).extension().is_some() { + return vec![dir.join(name)]; + } + vec![ + dir.join(format!("{name}.seal")), + dir.join(format!("{name}.sh")), + ] +} + +#[cfg(windows)] +fn candidates(dir: &Path, name: &str) -> Vec<PathBuf> { + let exact = dir.join(name); + if Path::new(name).extension().is_some() { + return vec![exact]; + } + [exact] + .into_iter() + .chain( + ["seal", "exe", "cmd", "bat"] + .into_iter() + .map(|ext| dir.join(format!("{name}.{ext}"))), + ) + .collect() +} + +#[cfg(unix)] +fn is_executable(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + + path.is_file() + && path + .metadata() + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +#[cfg(windows)] +fn is_executable(path: &Path) -> bool { + path.is_file() +} + +#[cfg(unix)] +fn listed_name(path: &Path) -> Option<String> { + if path.extension().and_then(std::ffi::OsStr::to_str) != Some("sh") { + if path.extension().and_then(std::ffi::OsStr::to_str) == Some("seal") { + let stem = path.file_stem()?.to_str()?; + validate_symbol_name(stem).ok()?; + return Some(stem.to_string()); + } + return None; + } + let stem = path.file_stem()?.to_str()?; + validate_symbol_name(stem).ok()?; + Some(stem.to_string()) +} + +#[cfg(windows)] +fn listed_name(path: &Path) -> Option<String> { + let file_name = path.file_name()?.to_str()?; + if let Some(ext) = path.extension().and_then(std::ffi::OsStr::to_str) + && matches_ignore_ascii_case(ext, &["seal", "exe", "cmd", "bat"]) + { + let stem = path.file_stem()?.to_str()?; + validate_symbol_name(stem).ok()?; + return Some(stem.to_string()); + } + + validate_symbol_name(file_name).ok()?; + Some(file_name.to_string()) +} + +#[cfg(windows)] +fn matches_ignore_ascii_case(value: &str, expected: &[&str]) -> bool { + expected + .iter() + .any(|candidate| value.eq_ignore_ascii_case(candidate)) +} + +fn validate_symbol_name(name: &str) -> Result<()> { + if name == "." || name == ".." { + bail!("reserved name"); + } + if !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + bail!("expected only ASCII letters, numbers, '.', '_', and '-'"); + } + Ok(()) +} diff --git a/app/src/core/tool/cloudflare.rs b/app/src/core/tool/cloudflare.rs new file mode 100644 index 0000000..2fb7d5a --- /dev/null +++ b/app/src/core/tool/cloudflare.rs @@ -0,0 +1,472 @@ +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, + time::Duration, +}; + +use anyhow::{Context, Result, bail}; +use serde_json::Value as JsonValue; + +#[derive(Debug, Clone)] +struct Config { + account_id: String, + api_token: String, + zone_name: String, + manage_host: String, + manage_origin_host: String, + manage_redirect_prefix: String, +} + +struct Options { + query: Vec<(String, String)>, + body: Option<JsonValue>, +} + +pub fn eval(command: &str, args: &[String]) -> Result<Option<String>> { + match command { + "config" => config(args), + "api" => api(args), + "zone" => zone(args), + "account" => account(args), + "redirect-rule" => redirect_rule(args), + _ => bail!("unknown tool command: cloudflare {command}"), + } +} + +fn config(args: &[String]) -> Result<Option<String>> { + match args { + [command, key] if command == "get" => { + let config = load_config()?; + Ok(Some(match key.as_str() { + "account_id" => config.account_id, + "zone_name" => config.zone_name, + "manage_host" => config.manage_host, + "manage_origin_host" => config.manage_origin_host, + "manage_redirect_prefix" => config.manage_redirect_prefix, + other => bail!("unknown Cloudflare config key: {other}"), + })) + } + [command] if command == "json" => { + let config = load_config()?; + Ok(Some(serde_json::to_string(&serde_json::json!({ + "account_id": config.account_id, + "zone_name": config.zone_name, + "manage_host": config.manage_host, + "manage_origin_host": config.manage_origin_host, + "manage_redirect_prefix": config.manage_redirect_prefix, + }))?)) + } + _ => bail!("usage: runseal @tool cloudflare config get <key>|json"), + } +} + +fn api(args: &[String]) -> Result<Option<String>> { + let [command, rest @ ..] = args else { + bail!( + "usage: runseal @tool cloudflare api request <method> <path> [--query k=v]... [--json <json>]" + ); + }; + if command != "request" { + bail!( + "usage: runseal @tool cloudflare api request <method> <path> [--query k=v]... [--json <json>]" + ); + } + let [method, path, options @ ..] = rest else { + bail!( + "usage: runseal @tool cloudflare api request <method> <path> [--query k=v]... [--json <json>]" + ); + }; + let parsed = parse_options(options)?; + let config = load_config()?; + request(&config, method, path, parsed.query, parsed.body) +} + +fn zone(args: &[String]) -> Result<Option<String>> { + match args { + [command, rest @ ..] if command == "get" => zone_get(rest), + [ruleset, rest @ ..] if ruleset == "ruleset" => zone_ruleset(rest), + _ => bail!("usage: runseal @tool cloudflare zone get|ruleset ..."), + } +} + +fn zone_get(args: &[String]) -> Result<Option<String>> { + let name = required_option(args, "--name")?; + let config = load_config()?; + let payload = request( + &config, + "GET", + "/zones", + vec![("name".to_string(), name.clone())], + None, + )?; + let value: JsonValue = serde_json::from_str(&payload.unwrap_or_default())?; + let result = value + .get("result") + .and_then(JsonValue::as_array) + .context("Cloudflare zone response missing result array")?; + if result.is_empty() { + bail!("zone not found for name: {name}"); + } + if result.len() != 1 { + bail!("expected one zone for {name}, found {}", result.len()); + } + Ok(Some(serde_json::to_string(&result[0])?)) +} + +fn zone_ruleset(args: &[String]) -> Result<Option<String>> { + match args { + [command, rest @ ..] if command == "list" => zone_ruleset_list(rest), + [command, rest @ ..] if command == "get" => zone_ruleset_get(rest), + [command, rest @ ..] if command == "create" => zone_ruleset_create(rest), + [rule, rest @ ..] if rule == "rule" => zone_ruleset_rule(rest), + _ => bail!("usage: runseal @tool cloudflare zone ruleset list|get|create|rule ..."), + } +} + +fn zone_ruleset_list(args: &[String]) -> Result<Option<String>> { + let zone_id = required_option(args, "--zone-id")?; + let config = load_config()?; + let payload = request( + &config, + "GET", + &format!("/zones/{zone_id}/rulesets"), + Vec::new(), + None, + )?; + let value: JsonValue = serde_json::from_str(&payload.unwrap_or_default())?; + Ok(Some(serde_json::to_string( + value.get("result").unwrap_or(&JsonValue::Array(Vec::new())), + )?)) +} + +fn zone_ruleset_get(args: &[String]) -> Result<Option<String>> { + let zone_id = required_option(args, "--zone-id")?; + let ruleset_id = required_option(args, "--ruleset-id")?; + let config = load_config()?; + let payload = request( + &config, + "GET", + &format!("/zones/{zone_id}/rulesets/{ruleset_id}"), + Vec::new(), + None, + )?; + let value: JsonValue = serde_json::from_str(&payload.unwrap_or_default())?; + Ok(Some(serde_json::to_string( + value.get("result").unwrap_or(&JsonValue::Null), + )?)) +} + +fn zone_ruleset_create(args: &[String]) -> Result<Option<String>> { + let zone_id = required_option(args, "--zone-id")?; + let phase = required_option(args, "--phase")?; + let name = required_option(args, "--name")?; + let body = serde_json::json!({ + "kind": "zone", + "name": name, + "phase": phase, + "rules": [], + }); + let config = load_config()?; + let payload = request( + &config, + "POST", + &format!("/zones/{zone_id}/rulesets"), + Vec::new(), + Some(body), + )?; + let value: JsonValue = serde_json::from_str(&payload.unwrap_or_default())?; + Ok(Some(serde_json::to_string( + value.get("result").unwrap_or(&JsonValue::Null), + )?)) +} + +fn zone_ruleset_rule(args: &[String]) -> Result<Option<String>> { + match args { + [command, rest @ ..] if command == "add" || command == "update" => { + let zone_id = required_option(rest, "--zone-id")?; + let ruleset_id = required_option(rest, "--ruleset-id")?; + let rule_json = required_option(rest, "--json")?; + let body: JsonValue = serde_json::from_str(&rule_json).context("invalid rule JSON")?; + let (method, path) = if command == "add" { + ( + "POST", + format!("/zones/{zone_id}/rulesets/{ruleset_id}/rules"), + ) + } else { + let rule_id = required_option(rest, "--rule-id")?; + ( + "PATCH", + format!("/zones/{zone_id}/rulesets/{ruleset_id}/rules/{rule_id}"), + ) + }; + let config = load_config()?; + let payload = request(&config, method, &path, Vec::new(), Some(body))?; + let value: JsonValue = serde_json::from_str(&payload.unwrap_or_default())?; + Ok(Some(serde_json::to_string( + value.get("result").unwrap_or(&JsonValue::Null), + )?)) + } + _ => bail!("usage: runseal @tool cloudflare zone ruleset rule add|update ..."), + } +} + +fn account(args: &[String]) -> Result<Option<String>> { + match args { + [command, rest @ ..] if command == "get" => account_get(rest), + [r2, bucket, command, rest @ ..] + if r2 == "r2" && bucket == "bucket" && command == "list" => + { + r2_bucket_list(rest) + } + _ => bail!("usage: runseal @tool cloudflare account get|r2 bucket list ..."), + } +} + +fn account_get(args: &[String]) -> Result<Option<String>> { + let account_id = required_option(args, "--account-id")?; + let config = load_config()?; + let payload = request( + &config, + "GET", + &format!("/accounts/{account_id}"), + Vec::new(), + None, + )?; + let value: JsonValue = serde_json::from_str(&payload.unwrap_or_default())?; + Ok(Some(serde_json::to_string( + value.get("result").unwrap_or(&JsonValue::Null), + )?)) +} + +fn r2_bucket_list(args: &[String]) -> Result<Option<String>> { + let account_id = required_option(args, "--account-id")?; + let config = load_config()?; + let payload = request( + &config, + "GET", + &format!("/accounts/{account_id}/r2/buckets"), + Vec::new(), + None, + )?; + let value: JsonValue = serde_json::from_str(&payload.unwrap_or_default())?; + Ok(Some(serde_json::to_string( + value + .get("result") + .and_then(|result| result.get("buckets")) + .unwrap_or(&JsonValue::Array(Vec::new())), + )?)) +} + +fn redirect_rule(args: &[String]) -> Result<Option<String>> { + let [command, rest @ ..] = args else { + bail!("usage: runseal @tool cloudflare redirect-rule exact ..."); + }; + if command != "exact" { + bail!("usage: runseal @tool cloudflare redirect-rule exact ..."); + } + let ref_name = required_option(rest, "--ref")?; + let description = required_option(rest, "--description")?; + let host = required_option(rest, "--host")?; + let path = required_option(rest, "--path")?; + let target_url = required_option(rest, "--target-url")?; + let status_code = optional_option(rest, "--status-code") + .unwrap_or_else(|| "302".to_string()) + .parse::<u16>() + .context("invalid redirect status code")?; + Ok(Some(serde_json::to_string(&serde_json::json!({ + "ref": ref_name, + "description": description, + "expression": format!("(http.host eq \"{host}\" and http.request.uri.path eq \"{path}\")"), + "action": "redirect", + "enabled": true, + "action_parameters": { + "from_value": { + "target_url": { + "value": target_url, + }, + "status_code": status_code, + "preserve_query_string": false, + }, + }, + }))?)) +} + +fn parse_options(args: &[String]) -> Result<Options> { + let mut query = Vec::new(); + let mut body = None; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--query" => { + let Some(value) = args.get(index + 1) else { + bail!("--query requires a value"); + }; + let Some((key, value)) = value.split_once('=') else { + bail!("invalid --query value: {value}; expected key=value"); + }; + query.push((key.to_string(), value.to_string())); + index += 2; + } + "--json" => { + let Some(value) = args.get(index + 1) else { + bail!("--json requires a value"); + }; + body = Some(serde_json::from_str(value).context("invalid --json payload")?); + index += 2; + } + other => bail!("unknown Cloudflare option: {other}"), + } + } + Ok(Options { query, body }) +} + +fn load_config() -> Result<Config> { + let values = parse_env_file(&token_file()?)?; + let account_id = required_config_value(&values, "CLOUDFLARE_ACCOUNT_ID")?; + let api_token = required_config_value(&values, "CLOUDFLARE_API_TOKEN")?; + Ok(Config { + account_id, + api_token, + zone_name: values + .get("CLOUDFLARE_ZONE_NAME") + .filter(|value| !value.is_empty()) + .cloned() + .unwrap_or_else(|| "perish.uk".to_string()), + manage_host: values + .get("CLOUDFLARE_MANAGE_HOST") + .filter(|value| !value.is_empty()) + .cloned() + .unwrap_or_else(|| "runseal.perish.uk".to_string()), + manage_origin_host: values + .get("CLOUDFLARE_MANAGE_ORIGIN_HOST") + .filter(|value| !value.is_empty()) + .cloned() + .unwrap_or_else(|| "releases.runseal.perish.uk".to_string()), + manage_redirect_prefix: values + .get("CLOUDFLARE_MANAGE_REDIRECT_PREFIX") + .map(|value| value.trim_matches('/').to_string()) + .unwrap_or_default(), + }) +} + +fn token_file() -> Result<PathBuf> { + let secrets = std::env::var_os("RUNSEAL_REPO_SECRETS_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".local").join("secrets")); + Ok(secrets.join("cloudflare.env")) +} + +fn parse_env_file(path: &Path) -> Result<BTreeMap<String, String>> { + let text = std::fs::read_to_string(path) + .with_context(|| format!("missing secrets file: {}", path.display()))?; + let mut values = BTreeMap::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + bail!("invalid line in {}: {line}", path.display()); + }; + values.insert( + key.trim().to_string(), + value + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_string(), + ); + } + Ok(values) +} + +fn required_config_value(values: &BTreeMap<String, String>, key: &str) -> Result<String> { + let Some(value) = values.get(key).filter(|value| !value.is_empty()) else { + bail!( + "missing required key(s) in {}: {key}", + token_file()?.display() + ); + }; + Ok(value.clone()) +} + +fn request( + config: &Config, + method: &str, + path: &str, + query: Vec<(String, String)>, + body: Option<JsonValue>, +) -> Result<Option<String>> { + let base = std::env::var("RUNSEAL_CLOUDFLARE_API_BASE") + .unwrap_or_else(|_| "https://api.cloudflare.com/client/v4".to_string()); + let path = if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + }; + let url = format!("{base}{path}"); + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + let method = method + .parse::<reqwest::Method>() + .with_context(|| format!("invalid HTTP method: {method}"))?; + let mut request = client + .request(method.clone(), &url) + .bearer_auth(&config.api_token) + .header(reqwest::header::ACCEPT, "application/json") + .header(reqwest::header::CONTENT_TYPE, "application/json"); + if !query.is_empty() { + request = request.query(&query); + } + if let Some(body) = body { + request = request.json(&body); + } + let response = request + .send() + .with_context(|| format!("Cloudflare API {method} {path} unreachable"))?; + let status = response.status(); + let raw = response + .text() + .with_context(|| format!("Cloudflare API {method} {path} returned unreadable body"))?; + if !status.is_success() { + bail!( + "Cloudflare API {method} {path} -> {}: {raw}", + status.as_u16() + ); + } + let payload: JsonValue = if raw.trim().is_empty() { + JsonValue::Object(Default::default()) + } else { + serde_json::from_str(&raw) + .with_context(|| format!("Cloudflare API returned invalid JSON for {path}"))? + }; + if payload + .get("success") + .and_then(JsonValue::as_bool) + .is_some_and(|success| !success) + { + bail!("Cloudflare API {method} {path} failed: {payload}"); + } + Ok(Some(serde_json::to_string(&payload)?)) +} + +fn required_option(args: &[String], name: &str) -> Result<String> { + optional_option(args, name).ok_or_else(|| anyhow::anyhow!("{name} is required")) +} + +fn optional_option(args: &[String], name: &str) -> Option<String> { + let prefix = format!("{name}="); + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + if arg == name { + return args.get(index + 1).cloned(); + } + if let Some(value) = arg.strip_prefix(&prefix) { + return Some(value.to_string()); + } + index += 1; + } + None +} diff --git a/app/src/core/tool/fs.rs b/app/src/core/tool/fs.rs new file mode 100644 index 0000000..8a512fd --- /dev/null +++ b/app/src/core/tool/fs.rs @@ -0,0 +1,127 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use base64::Engine; + +pub fn eval(command: &str, args: &[String]) -> Result<Option<String>> { + match command { + "mkdir" => mkdir(args), + "write-base64" => write_base64(args), + "chmod" => chmod(args), + "contains-any" => contains_any(args), + "backup-numbered" => backup_numbered(args), + _ => bail!("unknown tool command: fs {command}"), + } +} + +fn mkdir(args: &[String]) -> Result<Option<String>> { + let (path, mode) = match args { + [path] => (path, None), + [path, mode] => (path, Some(mode)), + _ => bail!("usage: runseal @tool fs mkdir <path> [mode]"), + }; + std::fs::create_dir_all(path).with_context(|| format!("failed to create directory: {path}"))?; + if let Some(mode) = mode { + chmod_path(Path::new(path), mode)?; + } + Ok(None) +} + +fn write_base64(args: &[String]) -> Result<Option<String>> { + let [path, encoded] = args else { + bail!("usage: runseal @tool fs write-base64 <path> <base64>"); + }; + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .context("invalid base64 content")?; + if let Some(parent) = Path::new(path).parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create parent directory: {}", parent.display()))?; + } + std::fs::write(path, bytes).with_context(|| format!("failed to write file: {path}"))?; + Ok(None) +} + +fn chmod(args: &[String]) -> Result<Option<String>> { + let [path, mode] = args else { + bail!("usage: runseal @tool fs chmod <path> <mode>"); + }; + chmod_path(Path::new(path), mode)?; + Ok(None) +} + +fn contains_any(args: &[String]) -> Result<Option<String>> { + let [path, needles @ ..] = args else { + bail!("usage: runseal @tool fs contains-any <path> <text>..."); + }; + if needles.is_empty() { + bail!("fs contains-any requires at least one text argument"); + } + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(err) => return Err(err).with_context(|| format!("failed to read file: {path}")), + }; + Ok(Some( + needles + .iter() + .any(|needle| text.contains(needle)) + .to_string(), + )) +} + +fn backup_numbered(args: &[String]) -> Result<Option<String>> { + let [path] = args else { + bail!("usage: runseal @tool fs backup-numbered <path>"); + }; + let path = PathBuf::from(path); + let backup = next_backup_path(&path)?; + std::fs::rename(&path, &backup) + .with_context(|| format!("failed to move {} to {}", path.display(), backup.display()))?; + Ok(Some(backup.to_string_lossy().into_owned())) +} + +fn next_backup_path(path: &Path) -> Result<PathBuf> { + let backup = path.with_file_name(format!( + "{}.bak", + path.file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow::anyhow!("invalid path: {}", path.display()))? + )); + if !backup.exists() { + return Ok(backup); + } + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow::anyhow!("invalid path: {}", path.display()))?; + for index in 1..1000 { + let candidate = path.with_file_name(format!("{file_name}.bak.{index}")); + if !candidate.exists() { + return Ok(candidate); + } + } + bail!("too many existing backups for {}", path.display()) +} + +#[cfg(unix)] +fn chmod_path(path: &Path, mode: &str) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let mode = u32::from_str_radix(mode.trim_start_matches("0o"), 8) + .with_context(|| format!("invalid file mode: {mode}"))?; + let mut permissions = std::fs::metadata(path) + .with_context(|| format!("failed to read metadata: {}", path.display()))? + .permissions(); + permissions.set_mode(mode); + std::fs::set_permissions(path, permissions) + .with_context(|| format!("failed to chmod {}", path.display()))?; + Ok(()) +} + +#[cfg(not(unix))] +fn chmod_path(_path: &Path, _mode: &str) -> Result<()> { + Ok(()) +} diff --git a/app/src/core/tool/int.rs b/app/src/core/tool/int.rs new file mode 100644 index 0000000..dffa13e --- /dev/null +++ b/app/src/core/tool/int.rs @@ -0,0 +1,21 @@ +use anyhow::{Context, Result, bail}; + +pub fn eval(command: &str, args: &[String]) -> Result<Option<String>> { + match command { + "add" => add(args), + _ => bail!("unknown tool command: int {command}"), + } +} + +fn add(args: &[String]) -> Result<Option<String>> { + let [left, right] = args else { + bail!("usage: runseal @tool int add <left> <right>"); + }; + let left = left + .parse::<i64>() + .with_context(|| format!("invalid integer: {left}"))?; + let right = right + .parse::<i64>() + .with_context(|| format!("invalid integer: {right}"))?; + Ok(Some((left + right).to_string())) +} diff --git a/app/src/core/tool/json.rs b/app/src/core/tool/json.rs new file mode 100644 index 0000000..4564ddd --- /dev/null +++ b/app/src/core/tool/json.rs @@ -0,0 +1,164 @@ +use anyhow::{Context, Result, bail}; +use serde_json::Value as JsonValue; + +pub fn eval(command: &str, args: &[String]) -> Result<Option<String>> { + match command { + "get" => get(args), + "empty" => empty(args), + "len" => len(args), + "pretty" => pretty(args), + "find" => find(args), + "filter" => filter(args), + _ => bail!("unknown tool command: json {command}"), + } +} + +fn get(args: &[String]) -> Result<Option<String>> { + let [json, path] = args else { + bail!("usage: runseal @tool json get <json> <path>"); + }; + let value: JsonValue = serde_json::from_str(json).context("invalid JSON input")?; + let selected = select_path(&value, path)?; + let output = match selected { + JsonValue::Null => None, + JsonValue::String(value) => Some(value.clone()), + JsonValue::Bool(value) => Some(value.to_string()), + JsonValue::Number(value) => Some(value.to_string()), + JsonValue::Array(_) | JsonValue::Object(_) => Some(serde_json::to_string(selected)?), + }; + Ok(output) +} + +fn empty(args: &[String]) -> Result<Option<String>> { + let [json] = args else { + bail!("usage: runseal @tool json empty <json>"); + }; + let value: JsonValue = serde_json::from_str(json).context("invalid JSON input")?; + Ok(Some(value_is_empty(&value).to_string())) +} + +fn len(args: &[String]) -> Result<Option<String>> { + let [json] = args else { + bail!("usage: runseal @tool json len <json>"); + }; + let value: JsonValue = serde_json::from_str(json).context("invalid JSON input")?; + let len = match value { + JsonValue::Null => 0, + JsonValue::String(value) => value.len(), + JsonValue::Array(value) => value.len(), + JsonValue::Object(value) => value.len(), + JsonValue::Bool(_) | JsonValue::Number(_) => 1, + }; + Ok(Some(len.to_string())) +} + +fn pretty(args: &[String]) -> Result<Option<String>> { + let [json] = args else { + bail!("usage: runseal @tool json pretty <json>"); + }; + let value: JsonValue = serde_json::from_str(json).context("invalid JSON input")?; + Ok(Some(serde_json::to_string_pretty(&value)?)) +} + +fn find(args: &[String]) -> Result<Option<String>> { + let [json, field, expected] = args else { + bail!("usage: runseal @tool json find <array> <field> <value>"); + }; + let value: JsonValue = serde_json::from_str(json).context("invalid JSON input")?; + let Some(found) = json_array(&value)? + .iter() + .find(|item| field_string(item, field).as_deref() == Some(expected.as_str())) + else { + return Ok(None); + }; + Ok(Some(serde_json::to_string(found)?)) +} + +fn filter(args: &[String]) -> Result<Option<String>> { + let [json, field, expected @ ..] = args else { + bail!("usage: runseal @tool json filter <array> <field> <value>..."); + }; + if expected.is_empty() { + bail!("json filter requires at least one expected value"); + } + let value: JsonValue = serde_json::from_str(json).context("invalid JSON input")?; + let filtered = json_array(&value)? + .iter() + .filter(|item| { + field_string(item, field) + .as_deref() + .is_some_and(|actual| expected.iter().any(|value| value == actual)) + }) + .cloned() + .collect::<Vec<_>>(); + Ok(Some(serde_json::to_string(&filtered)?)) +} + +fn json_array(value: &JsonValue) -> Result<&[JsonValue]> { + let JsonValue::Array(values) = value else { + bail!("expected JSON array"); + }; + Ok(values) +} + +fn field_string(value: &JsonValue, field: &str) -> Option<String> { + value.get(field).map(|value| match value { + JsonValue::String(value) => value.clone(), + JsonValue::Bool(value) => value.to_string(), + JsonValue::Number(value) => value.to_string(), + JsonValue::Null | JsonValue::Array(_) | JsonValue::Object(_) => { + serde_json::to_string(value).unwrap_or_default() + } + }) +} + +fn value_is_empty(value: &JsonValue) -> bool { + match value { + JsonValue::Null => true, + JsonValue::String(value) => value.is_empty(), + JsonValue::Array(value) => value.is_empty(), + JsonValue::Object(value) => value.is_empty(), + JsonValue::Bool(_) | JsonValue::Number(_) => false, + } +} + +fn select_path<'a>(value: &'a JsonValue, path: &str) -> Result<&'a JsonValue> { + let mut input = path.strip_prefix('.').unwrap_or(path); + if input.is_empty() { + bail!("json path cannot be empty"); + } + let mut current = value; + while !input.is_empty() { + if let Some(rest) = input.strip_prefix('[') { + let Some((index, rest)) = rest.split_once(']') else { + bail!("unsupported json path: {path}"); + }; + let index = index + .parse::<usize>() + .with_context(|| format!("invalid json path index: {index}"))?; + current = current + .get(index) + .with_context(|| format!("json path not found: {path}"))?; + input = rest.strip_prefix('.').unwrap_or(rest); + continue; + } + let end = input.find(['.', '[']).unwrap_or(input.len()); + let field = &input[..end]; + validate_field(field)?; + current = current + .get(field) + .with_context(|| format!("json path not found: {path}"))?; + input = input[end..].strip_prefix('.').unwrap_or(&input[end..]); + } + Ok(current) +} + +fn validate_field(field: &str) -> Result<()> { + let mut bytes = field.bytes(); + let valid = matches!(bytes.next(), Some(byte) if byte.is_ascii_alphabetic() || byte == b'_') + && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'); + if !valid { + bail!("invalid json path field: {field}"); + } + Ok(()) +} diff --git a/app/src/core/tool/mod.rs b/app/src/core/tool/mod.rs new file mode 100644 index 0000000..85ec150 --- /dev/null +++ b/app/src/core/tool/mod.rs @@ -0,0 +1,66 @@ +use anyhow::{Result, bail}; + +mod cloudflare; +mod fs; +mod int; +mod json; +mod process; +mod regex; +mod string; + +pub fn run(args: &[String]) -> Result<()> { + if matches!(args, [arg] if matches!(arg.as_str(), "-h" | "--help" | "help")) { + print!("{}", help()); + return Ok(()); + } + if let Some(output) = eval(args)? { + println!("{output}"); + } + Ok(()) +} + +pub fn eval(args: &[String]) -> Result<Option<String>> { + match args { + [namespace, command, rest @ ..] if namespace == "json" => json::eval(command, rest), + [namespace, command, rest @ ..] if namespace == "string" => string::eval(command, rest), + [namespace, command, rest @ ..] if namespace == "regex" => regex::eval(command, rest), + [namespace, command, rest @ ..] if namespace == "int" => int::eval(command, rest), + [namespace, command, rest @ ..] if namespace == "process" => process::eval(command, rest), + [namespace, command, rest @ ..] if namespace == "fs" => fs::eval(command, rest), + [namespace, command, rest @ ..] if namespace == "cloudflare" => { + cloudflare::eval(command, rest) + } + [] => bail!("@tool requires a tool path"), + [namespace, command, ..] => bail!("unknown tool command: {namespace} {command}"), + [namespace] => bail!("tool namespace requires a command: {namespace}"), + } +} + +pub fn help() -> &'static str { + "\ +Usage: runseal @tool <namespace> <command> [args] + +Run an atomic runseal tool command. + +Tools: + json get <json> <path> print a JSON value + json empty <json> print true when JSON length is zero + json len <json> print JSON array/object/string length + json pretty <json> print formatted JSON + json find <array> <field> <value> print first object with field=value + json filter <array> <field> <value>... print objects with field matching values + string trim <value> trim leading and trailing whitespace + regex capture <value> <pattern> <n> print regex capture group n, or empty + int add <left> <right> print integer sum + process exists <name> print true when command exists on PATH + fs mkdir <path> [mode] create a directory and parents + fs write-base64 <path> <base64> write decoded bytes to a file + fs chmod <path> <mode> set a file mode on Unix + fs contains-any <path> <text>... print true when file contains any text + fs backup-numbered <path> move path to .bak or .bak.N and print it + cloudflare ... run an atomic Cloudflare resource op + +@tool is the runseal atomic tool runtime. Tool inputs use argv/env, output is +stdout, diagnostics are stderr, and failure is a non-zero exit code. +" +} diff --git a/app/src/core/tool/process.rs b/app/src/core/tool/process.rs new file mode 100644 index 0000000..a9d25bc --- /dev/null +++ b/app/src/core/tool/process.rs @@ -0,0 +1,22 @@ +use anyhow::{Result, bail}; + +pub fn eval(command: &str, args: &[String]) -> Result<Option<String>> { + match command { + "exists" => exists(args), + _ => bail!("unknown tool command: process {command}"), + } +} + +fn exists(args: &[String]) -> Result<Option<String>> { + let [name] = args else { + bail!("usage: runseal @tool process exists <name>"); + }; + Ok(Some(command_exists(name).to_string())) +} + +fn command_exists(name: &str) -> bool { + let Some(path) = std::env::var_os("PATH") else { + return false; + }; + std::env::split_paths(&path).any(|dir| dir.join(name).is_file()) +} diff --git a/app/src/core/tool/regex.rs b/app/src/core/tool/regex.rs new file mode 100644 index 0000000..0221e05 --- /dev/null +++ b/app/src/core/tool/regex.rs @@ -0,0 +1,28 @@ +use anyhow::{Context, Result, bail}; +use regex::Regex; + +pub fn eval(command: &str, args: &[String]) -> Result<Option<String>> { + match command { + "capture" => capture(args), + _ => bail!("unknown tool command: regex {command}"), + } +} + +fn capture(args: &[String]) -> Result<Option<String>> { + let [value, pattern, group] = args else { + bail!("usage: runseal @tool regex capture <value> <pattern> <group>"); + }; + let group = group + .parse::<usize>() + .with_context(|| format!("invalid regex capture group: {group}"))?; + if !(1..=9).contains(&group) { + bail!("regex capture group must be between 1 and 9"); + } + let regex = Regex::new(pattern).context("invalid regex pattern")?; + let captured = regex + .captures(value) + .and_then(|captures| captures.get(group)) + .map(|capture| capture.as_str()) + .unwrap_or(""); + Ok(Some(captured.to_string())) +} diff --git a/app/src/core/tool/string.rs b/app/src/core/tool/string.rs new file mode 100644 index 0000000..e720e9a --- /dev/null +++ b/app/src/core/tool/string.rs @@ -0,0 +1,15 @@ +use anyhow::{Result, bail}; + +pub fn eval(command: &str, args: &[String]) -> Result<Option<String>> { + match command { + "trim" => trim(args), + _ => bail!("unknown tool command: string {command}"), + } +} + +fn trim(args: &[String]) -> Result<Option<String>> { + let [value] = args else { + bail!("usage: runseal @tool string trim <value>"); + }; + Ok(Some(value.trim().to_string())) +} diff --git a/app/src/core/transpile/ast.rs b/app/src/core/transpile/ast.rs index b5cb7de..886d5ca 100644 --- a/app/src/core/transpile/ast.rs +++ b/app/src/core/transpile/ast.rs @@ -28,6 +28,22 @@ pub enum Statement { name: String, argv: Vec<Value>, }, + CaptureOptional { + name: String, + status: String, + argv: Vec<Value>, + }, + ToolExec { + invocation: ToolInvocation, + }, + ToolPassthrough { + start: usize, + invocation: ToolInvocation, + }, + ToolCapture { + name: String, + invocation: ToolInvocation, + }, StringTrim { name: String, value: Value, @@ -96,6 +112,12 @@ pub struct ArgvSpec { pub default: Option<String>, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ToolInvocation { + pub path: Vec<String>, + pub argv: Vec<Value>, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ArgvKind { diff --git a/app/src/core/transpile/emit/mod.rs b/app/src/core/transpile/emit/mod.rs index 685432f..748a5d4 100644 --- a/app/src/core/transpile/emit/mod.rs +++ b/app/src/core/transpile/emit/mod.rs @@ -1,10 +1,16 @@ -use super::ast::{ArgvKind, ArgvSpec, Item, Predicate, Program, Statement, Value}; +use super::ast::{ArgvKind, ArgvSpec, Item, Program, Statement}; use super::guards::{bash_required_tools, emit_bash_guards}; use super::json_path::json_path; mod powershell; +mod support; pub(crate) use powershell::emit_powershell; +use support::{ + argv_spec_name, bash_int_value, bash_predicate, bash_tool_command, bash_value, + generated_header, join_values, option_name, seal_predicate, seal_value, sed_capture_expr, + sh_quote, +}; pub(crate) fn emit_seal(program: &Program) -> String { let mut out = String::new(); @@ -60,6 +66,49 @@ fn emit_seal_statement(out: &mut String, statement: &Statement, indent: usize) { out.push_str(&join_values(argv, seal_value)); out.push_str(")\n"); } + Statement::CaptureOptional { name, status, argv } => { + out.push_str(&pad); + out.push_str("seal capture optional "); + out.push_str(name); + out.push(' '); + out.push_str(status); + out.push(' '); + out.push_str(&join_values(argv, seal_value)); + out.push('\n'); + } + Statement::ToolExec { invocation } => { + out.push_str(&pad); + out.push_str("seal "); + out.push_str(&invocation.path.join(" ")); + if !invocation.argv.is_empty() { + out.push(' '); + out.push_str(&join_values(&invocation.argv, seal_value)); + } + out.push('\n'); + } + Statement::ToolPassthrough { start, invocation } => { + out.push_str(&pad); + out.push_str("seal passthrough "); + out.push_str(&start.to_string()); + out.push(' '); + out.push_str(&invocation.path.join(" ")); + if !invocation.argv.is_empty() { + out.push(' '); + out.push_str(&join_values(&invocation.argv, seal_value)); + } + out.push('\n'); + } + Statement::ToolCapture { name, invocation } => { + out.push_str(&pad); + out.push_str(name); + out.push_str("=$(seal "); + out.push_str(&invocation.path.join(" ")); + if !invocation.argv.is_empty() { + out.push(' '); + out.push_str(&join_values(&invocation.argv, seal_value)); + } + out.push_str(")\n"); + } Statement::StringTrim { name, value } => { out.push_str(&format!( "{pad}{name}=$(seal string trim {})\n", @@ -185,6 +234,34 @@ fn emit_bash_statement(out: &mut String, statement: &Statement, indent: usize) { out.push_str(&join_values(argv, bash_value)); out.push_str(")\n"); } + Statement::CaptureOptional { name, status, argv } => { + out.push_str(&format!("{pad}set +e\n")); + out.push_str(&pad); + out.push_str(name); + out.push_str("=$("); + out.push_str(&join_values(argv, bash_value)); + out.push_str(" 2>&1)\n"); + out.push_str(&format!("{pad}{status}=$?\n")); + out.push_str(&format!("{pad}set -e\n")); + } + Statement::ToolExec { invocation } => { + out.push_str(&pad); + out.push_str(&bash_tool_command(invocation)); + out.push('\n'); + } + Statement::ToolPassthrough { start, invocation } => { + out.push_str(&pad); + out.push_str(&bash_tool_command(invocation)); + out.push_str(&format!(" \"${{@:{start}}}\"")); + out.push('\n'); + } + Statement::ToolCapture { name, invocation } => { + out.push_str(&pad); + out.push_str(name); + out.push_str("=$("); + out.push_str(&bash_tool_command(invocation)); + out.push_str(")\n"); + } Statement::StringTrim { name, value } => { out.push_str(&format!( "{pad}{name}=$(printf '%s' {} | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')\n", @@ -270,6 +347,8 @@ fn emit_bash_statement(out: &mut String, statement: &Statement, indent: usize) { fn emit_bash_argv_parse(out: &mut String, specs: &[ArgvSpec], indent: usize) { let pad = " ".repeat(indent); + out.push_str(&format!("{pad}__seal_argc=$#\n")); + out.push_str(&format!("{pad}__seal_help=false\n")); for spec in specs { let value = match spec.kind { ArgvKind::String => sh_quote(spec.default.as_deref().unwrap_or("")), @@ -289,6 +368,10 @@ fn emit_bash_argv_parse(out: &mut String, specs: &[ArgvSpec], indent: usize) { out.push_str(&format!("{pad} shift\n")); out.push_str(&format!("{pad} break\n")); out.push_str(&format!("{pad} ;;\n")); + out.push_str(&format!("{pad} -h|--help|help)\n")); + out.push_str(&format!("{pad} __seal_help=true\n")); + out.push_str(&format!("{pad} shift\n")); + out.push_str(&format!("{pad} ;;\n")); out.push_str(&format!( "{pad} *) seal_fail \"unknown option: $1\" ;;\n" )); @@ -320,170 +403,3 @@ fn emit_bash_flag_option(out: &mut String, spec: &ArgvSpec, indent: usize) { out.push_str(&format!("{pad} shift\n")); out.push_str(&format!("{pad} ;;\n")); } - -fn sed_capture_expr(pattern: &str, group: usize) -> String { - let delimiter = sed_delimiter(pattern); - format!( - "s{delimiter}.*{}.*{delimiter}\\{group}{delimiter}p", - pattern.replace(delimiter, &format!("\\{delimiter}")) - ) -} - -fn sed_delimiter(pattern: &str) -> char { - ['#', '@', '%', '|', ';', ':'] - .into_iter() - .find(|delimiter| !pattern.contains(*delimiter)) - .unwrap_or('#') -} - -fn argv_spec_name(spec: &ArgvSpec) -> String { - match &spec.default { - Some(default) => format!("{}={default}", spec.name), - None => spec.name.clone(), - } -} - -pub(super) fn option_name(name: &str) -> String { - format!("--{}", name.replace('_', "-")) -} - -fn join_values(values: &[Value], format: fn(&Value) -> String) -> String { - values.iter().map(format).collect::<Vec<_>>().join(" ") -} - -pub(super) fn generated_header(target: &str, source_name: Option<&str>) -> String { - let source = source_name.unwrap_or("<memory>"); - format!("# Generated by runseal @transpile from {source} for {target}.\n") -} - -fn seal_predicate(predicate: &Predicate) -> String { - match predicate { - Predicate::Empty { value } => format!("empty {}", seal_value(value)), - Predicate::NotEmpty { value } => format!("not_empty {}", seal_value(value)), - Predicate::Eq { left, right } => format!("eq {} {}", seal_value(left), seal_value(right)), - Predicate::Neq { left, right } => format!("neq {} {}", seal_value(left), seal_value(right)), - Predicate::IntLt { left, right } => { - format!("lt {} {}", seal_value(left), seal_value(right)) - } - Predicate::IntLte { left, right } => { - format!("lte {} {}", seal_value(left), seal_value(right)) - } - Predicate::IntGt { left, right } => { - format!("gt {} {}", seal_value(left), seal_value(right)) - } - Predicate::IntGte { left, right } => { - format!("gte {} {}", seal_value(left), seal_value(right)) - } - Predicate::JsonEmpty { value } => format!("json_empty {}", seal_value(value)), - Predicate::JsonNotEmpty { value } => format!("json_not_empty {}", seal_value(value)), - Predicate::FileExists { path } => format!("file_exists {}", seal_value(path)), - Predicate::DirExists { path } => format!("dir_exists {}", seal_value(path)), - Predicate::ToolExists { name } => format!("tool_exists {name}"), - } -} - -fn bash_predicate(predicate: &Predicate) -> String { - match predicate { - Predicate::Empty { value } => format!("[ -z {} ]", bash_value(value)), - Predicate::NotEmpty { value } => format!("[ -n {} ]", bash_value(value)), - Predicate::Eq { left, right } => { - format!("[ {} = {} ]", bash_value(left), bash_value(right)) - } - Predicate::Neq { left, right } => { - format!("[ {} != {} ]", bash_value(left), bash_value(right)) - } - Predicate::IntLt { left, right } => { - format!("[ {} -lt {} ]", bash_int_value(left), bash_int_value(right)) - } - Predicate::IntLte { left, right } => { - format!("[ {} -le {} ]", bash_int_value(left), bash_int_value(right)) - } - Predicate::IntGt { left, right } => { - format!("[ {} -gt {} ]", bash_int_value(left), bash_int_value(right)) - } - Predicate::IntGte { left, right } => { - format!("[ {} -ge {} ]", bash_int_value(left), bash_int_value(right)) - } - Predicate::JsonEmpty { value } => { - format!( - "[ \"$(printf '%s' {} | jq 'length')\" -eq 0 ]", - bash_value(value) - ) - } - Predicate::JsonNotEmpty { value } => { - format!( - "[ \"$(printf '%s' {} | jq 'length')\" -gt 0 ]", - bash_value(value) - ) - } - Predicate::FileExists { path } => format!("[ -f {} ]", bash_value(path)), - Predicate::DirExists { path } => format!("[ -d {} ]", bash_value(path)), - Predicate::ToolExists { name } => format!("command -v {} >/dev/null 2>&1", sh_quote(name)), - } -} - -fn seal_value(value: &Value) -> String { - match value { - Value::Literal { text } => sh_quote(text), - Value::Var { name } => format!("${name}"), - Value::Env { name } => format!("${{{name}}}"), - Value::EnvDefault { name, default } => format!("${{{name}:-{default}}}"), - Value::Concat { parts } => { - let inner = parts - .iter() - .map(|part| match part { - Value::Literal { text } => text.clone(), - _ => seal_value(part), - }) - .collect::<String>(); - double_quote(&inner) - } - } -} - -fn bash_value(value: &Value) -> String { - match value { - Value::Literal { text } => sh_quote(text), - Value::Var { name } => format!("\"${name}\""), - Value::Env { name } => format!("\"${{{name}}}\""), - Value::EnvDefault { name, default } => format!("\"${{{name}:-{default}}}\""), - Value::Concat { parts } => double_quote( - &parts - .iter() - .map(|part| match part { - Value::Literal { text } => text.clone(), - Value::Var { name } => format!("${name}"), - Value::Env { name } => format!("${{{name}}}"), - Value::EnvDefault { name, default } => format!("${{{name}:-{default}}}"), - Value::Concat { .. } => bash_value(part), - }) - .collect::<String>(), - ), - } -} - -fn bash_int_value(value: &Value) -> String { - match value { - Value::Var { name } => format!("${name}"), - Value::Env { name } => format!("${{{name}}}"), - Value::EnvDefault { name, default } => format!("${{{name}:-{default}}}"), - Value::Literal { text } => sh_quote(text), - Value::Concat { .. } => bash_value(value), - } -} - -fn sh_quote(value: &str) -> String { - if value.is_empty() { - return "''".to_string(); - } - if value.bytes().all(|byte| { - byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'/' | b'-' | b':') - }) { - return value.to_string(); - } - format!("'{}'", value.replace('\'', "'\"'\"'")) -} - -fn double_quote(value: &str) -> String { - format!("\"{}\"", value.replace('"', "\\\"")) -} diff --git a/app/src/core/transpile/emit/powershell.rs b/app/src/core/transpile/emit/powershell.rs index c29c3ef..2499d71 100644 --- a/app/src/core/transpile/emit/powershell.rs +++ b/app/src/core/transpile/emit/powershell.rs @@ -1,4 +1,4 @@ -use super::{generated_header, option_name}; +use super::support::{generated_header, option_name}; use crate::core::transpile::ast::{ArgvKind, ArgvSpec, Item, Predicate, Program, Statement, Value}; use crate::core::transpile::json_path::powershell_json_get; @@ -47,6 +47,35 @@ fn emit_statement(out: &mut String, statement: &Statement, indent: usize) { out.push_str(&join_values(argv, powershell_value)); out.push('\n'); } + Statement::CaptureOptional { name, status, argv } => { + out.push_str(&pad); + out.push_str(&format!("${name} = (& ")); + out.push_str(&join_values(argv, powershell_value)); + out.push_str(" 2>&1 | Out-String).TrimEnd()\n"); + out.push_str(&format!( + "{pad}${status} = if ($null -ne $LASTEXITCODE) {{ $LASTEXITCODE }} elseif ($?) {{ 0 }} else {{ 1 }}\n" + )); + } + Statement::ToolExec { invocation } => { + out.push_str(&pad); + out.push_str(&powershell_tool_command(invocation)); + out.push('\n'); + } + Statement::ToolPassthrough { start, invocation } => { + out.push_str(&pad); + out.push_str(&powershell_tool_command(invocation)); + out.push_str(&format!( + " @($args[{}..($args.Count - 1)])", + start.saturating_sub(1) + )); + out.push('\n'); + } + Statement::ToolCapture { name, invocation } => { + out.push_str(&pad); + out.push_str(&format!("${name} = ")); + out.push_str(&powershell_tool_command(invocation)); + out.push('\n'); + } Statement::StringTrim { name, value } => { out.push_str(&format!( "{pad}${name} = ({}).Trim()\n", @@ -130,6 +159,8 @@ fn emit_statement(out: &mut String, statement: &Statement, indent: usize) { fn emit_argv_parse(out: &mut String, specs: &[ArgvSpec], indent: usize) { let pad = " ".repeat(indent); + out.push_str(&format!("{pad}$__seal_argc = $args.Count\n")); + out.push_str(&format!("{pad}$__seal_help = $false\n")); for spec in specs { let value = match spec.kind { ArgvKind::String => powershell_quote(spec.default.as_deref().unwrap_or("")), @@ -151,6 +182,11 @@ fn emit_argv_parse(out: &mut String, specs: &[ArgvSpec], indent: usize) { out.push_str(&format!("{pad} $__seal_index = $args.Count\n")); out.push_str(&format!("{pad} break\n")); out.push_str(&format!("{pad} }}\n")); + out.push_str(&format!("{pad} '^(-h|--help|help)$' {{\n")); + out.push_str(&format!("{pad} $__seal_help = $true\n")); + out.push_str(&format!("{pad} $__seal_index += 1\n")); + out.push_str(&format!("{pad} break\n")); + out.push_str(&format!("{pad} }}\n")); out.push_str(&format!( "{pad} default {{ throw \"unknown option: $__seal_arg\" }}\n" )); @@ -255,13 +291,13 @@ fn predicate_text(predicate: &Predicate) -> String { Predicate::IntGte { left, right } => int_compare(left, "-ge", right), Predicate::JsonEmpty { value } => { format!( - "(({} | ConvertFrom-Json).Count -eq 0)", + "(& 'runseal' '@tool' 'json' 'empty' {}) -eq 'true'", powershell_value(value) ) } Predicate::JsonNotEmpty { value } => { format!( - "(({} | ConvertFrom-Json).Count -gt 0)", + "(& 'runseal' '@tool' 'json' 'empty' {}) -eq 'false'", powershell_value(value) ) } @@ -323,3 +359,14 @@ fn join_values(values: &[Value], format: fn(&Value) -> String) -> String { fn powershell_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "''")) } + +fn powershell_tool_command(invocation: &crate::core::transpile::ast::ToolInvocation) -> String { + let mut parts = vec![ + "&".to_string(), + powershell_quote("runseal"), + powershell_quote("@tool"), + ]; + parts.extend(invocation.path.iter().map(|part| powershell_quote(part))); + parts.extend(invocation.argv.iter().map(powershell_value)); + parts.join(" ") +} diff --git a/app/src/core/transpile/emit/support.rs b/app/src/core/transpile/emit/support.rs new file mode 100644 index 0000000..22407a7 --- /dev/null +++ b/app/src/core/transpile/emit/support.rs @@ -0,0 +1,175 @@ +use crate::core::transpile::ast::{ArgvSpec, Predicate, ToolInvocation, Value}; + +pub(super) fn sed_capture_expr(pattern: &str, group: usize) -> String { + let delimiter = sed_delimiter(pattern); + format!( + "s{delimiter}.*{}.*{delimiter}\\{group}{delimiter}p", + pattern.replace(delimiter, &format!("\\{delimiter}")) + ) +} + +fn sed_delimiter(pattern: &str) -> char { + ['#', '@', '%', '|', ';', ':'] + .into_iter() + .find(|delimiter| !pattern.contains(*delimiter)) + .unwrap_or('#') +} + +pub(super) fn argv_spec_name(spec: &ArgvSpec) -> String { + match &spec.default { + Some(default) => format!("{}={default}", spec.name), + None => spec.name.clone(), + } +} + +pub(super) fn option_name(name: &str) -> String { + format!("--{}", name.replace('_', "-")) +} + +pub(super) fn join_values(values: &[Value], format: fn(&Value) -> String) -> String { + values.iter().map(format).collect::<Vec<_>>().join(" ") +} + +pub(super) fn bash_tool_command(invocation: &ToolInvocation) -> String { + let mut parts = vec!["runseal".to_string(), "@tool".to_string()]; + parts.extend(invocation.path.iter().map(|part| sh_quote(part))); + parts.extend(invocation.argv.iter().map(bash_value)); + parts.join(" ") +} + +pub(super) fn generated_header(target: &str, source_name: Option<&str>) -> String { + let source = source_name.unwrap_or("<memory>"); + format!("# Generated by runseal @transpile from {source} for {target}.\n") +} + +pub(super) fn seal_predicate(predicate: &Predicate) -> String { + match predicate { + Predicate::Empty { value } => format!("empty {}", seal_value(value)), + Predicate::NotEmpty { value } => format!("not_empty {}", seal_value(value)), + Predicate::Eq { left, right } => format!("eq {} {}", seal_value(left), seal_value(right)), + Predicate::Neq { left, right } => format!("neq {} {}", seal_value(left), seal_value(right)), + Predicate::IntLt { left, right } => { + format!("lt {} {}", seal_value(left), seal_value(right)) + } + Predicate::IntLte { left, right } => { + format!("lte {} {}", seal_value(left), seal_value(right)) + } + Predicate::IntGt { left, right } => { + format!("gt {} {}", seal_value(left), seal_value(right)) + } + Predicate::IntGte { left, right } => { + format!("gte {} {}", seal_value(left), seal_value(right)) + } + Predicate::JsonEmpty { value } => format!("json_empty {}", seal_value(value)), + Predicate::JsonNotEmpty { value } => format!("json_not_empty {}", seal_value(value)), + Predicate::FileExists { path } => format!("file_exists {}", seal_value(path)), + Predicate::DirExists { path } => format!("dir_exists {}", seal_value(path)), + Predicate::ToolExists { name } => format!("tool_exists {name}"), + } +} + +pub(super) fn bash_predicate(predicate: &Predicate) -> String { + match predicate { + Predicate::Empty { value } => format!("[ -z {} ]", bash_value(value)), + Predicate::NotEmpty { value } => format!("[ -n {} ]", bash_value(value)), + Predicate::Eq { left, right } => { + format!("[ {} = {} ]", bash_value(left), bash_value(right)) + } + Predicate::Neq { left, right } => { + format!("[ {} != {} ]", bash_value(left), bash_value(right)) + } + Predicate::IntLt { left, right } => { + format!("[ {} -lt {} ]", bash_int_value(left), bash_int_value(right)) + } + Predicate::IntLte { left, right } => { + format!("[ {} -le {} ]", bash_int_value(left), bash_int_value(right)) + } + Predicate::IntGt { left, right } => { + format!("[ {} -gt {} ]", bash_int_value(left), bash_int_value(right)) + } + Predicate::IntGte { left, right } => { + format!("[ {} -ge {} ]", bash_int_value(left), bash_int_value(right)) + } + Predicate::JsonEmpty { value } => { + format!( + "[ \"$(runseal @tool json empty {})\" = true ]", + bash_value(value) + ) + } + Predicate::JsonNotEmpty { value } => { + format!( + "[ \"$(runseal @tool json empty {})\" = false ]", + bash_value(value) + ) + } + Predicate::FileExists { path } => format!("[ -f {} ]", bash_value(path)), + Predicate::DirExists { path } => format!("[ -d {} ]", bash_value(path)), + Predicate::ToolExists { name } => format!("command -v {} >/dev/null 2>&1", sh_quote(name)), + } +} + +pub(super) fn seal_value(value: &Value) -> String { + match value { + Value::Literal { text } => sh_quote(text), + Value::Var { name } => format!("${name}"), + Value::Env { name } => format!("${{{name}}}"), + Value::EnvDefault { name, default } => format!("${{{name}:-{default}}}"), + Value::Concat { parts } => { + let inner = parts + .iter() + .map(|part| match part { + Value::Literal { text } => text.clone(), + _ => seal_value(part), + }) + .collect::<String>(); + double_quote(&inner) + } + } +} + +pub(super) fn bash_value(value: &Value) -> String { + match value { + Value::Literal { text } => sh_quote(text), + Value::Var { name } => format!("\"${name}\""), + Value::Env { name } => format!("\"${{{name}}}\""), + Value::EnvDefault { name, default } => format!("\"${{{name}:-{default}}}\""), + Value::Concat { parts } => double_quote( + &parts + .iter() + .map(|part| match part { + Value::Literal { text } => text.clone(), + Value::Var { name } => format!("${name}"), + Value::Env { name } => format!("${{{name}}}"), + Value::EnvDefault { name, default } => format!("${{{name}:-{default}}}"), + Value::Concat { .. } => bash_value(part), + }) + .collect::<String>(), + ), + } +} + +pub(super) fn bash_int_value(value: &Value) -> String { + match value { + Value::Var { name } => format!("${name}"), + Value::Env { name } => format!("${{{name}}}"), + Value::EnvDefault { name, default } => format!("${{{name}:-{default}}}"), + Value::Literal { text } => sh_quote(text), + Value::Concat { .. } => bash_value(value), + } +} + +pub(super) fn sh_quote(value: &str) -> String { + if value.is_empty() { + return "''".to_string(); + } + if value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'/' | b'-' | b':') + }) { + return value.to_string(); + } + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +fn double_quote(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\\\"")) +} diff --git a/app/src/core/transpile/frontend/mod.rs b/app/src/core/transpile/frontend/mod.rs new file mode 100644 index 0000000..d0dcde0 --- /dev/null +++ b/app/src/core/transpile/frontend/mod.rs @@ -0,0 +1,4 @@ +mod powershell; +mod predicate; + +pub(crate) use powershell::parse_powershell; diff --git a/app/src/core/transpile/powershell.rs b/app/src/core/transpile/frontend/powershell.rs similarity index 98% rename from app/src/core/transpile/powershell.rs rename to app/src/core/transpile/frontend/powershell.rs index 3301d29..6f4c936 100644 --- a/app/src/core/transpile/powershell.rs +++ b/app/src/core/transpile/frontend/powershell.rs @@ -1,9 +1,9 @@ use anyhow::{Result, bail}; -use super::ast::{CaseArm, Item, Program, Statement, Value}; -use super::helpers::{parse_capture_helper, parse_statement_helper}; -use super::lower::lower_functions; -use super::powershell_predicate::parse_powershell_predicate; +use super::predicate::parse_powershell_predicate; +use crate::core::transpile::ast::{CaseArm, Item, Program, Statement, Value}; +use crate::core::transpile::helpers::{parse_capture_helper, parse_statement_helper}; +use crate::core::transpile::lower::lower_functions; #[derive(Debug, Clone)] struct Line { diff --git a/app/src/core/transpile/powershell_predicate.rs b/app/src/core/transpile/frontend/predicate.rs similarity index 98% rename from app/src/core/transpile/powershell_predicate.rs rename to app/src/core/transpile/frontend/predicate.rs index 2c2480c..7de662b 100644 --- a/app/src/core/transpile/powershell_predicate.rs +++ b/app/src/core/transpile/frontend/predicate.rs @@ -1,7 +1,7 @@ use anyhow::{Result, bail}; -use super::ast::Predicate; use super::powershell::parse_value; +use crate::core::transpile::ast::Predicate; pub(crate) fn parse_powershell_predicate(text: &str, line: usize) -> Result<Predicate> { if let Some(value) = text diff --git a/app/src/core/transpile/guards.rs b/app/src/core/transpile/guards.rs index 22fa8c9..2431d4b 100644 --- a/app/src/core/transpile/guards.rs +++ b/app/src/core/transpile/guards.rs @@ -1,6 +1,6 @@ use std::collections::BTreeSet; -use super::ast::{Item, Predicate, Program, Statement}; +use super::ast::{Item, Program, Statement}; pub(crate) fn bash_required_tools(program: &Program) -> BTreeSet<&'static str> { let mut tools = BTreeSet::new(); @@ -38,11 +38,6 @@ fn collect_bash_tool(statement: &Statement, tools: &mut BTreeSet<&'static str>) Statement::JsonGet { .. } => { tools.insert("jq"); } - Statement::If { predicate, .. } | Statement::While { predicate, .. } - if predicate_requires_jq(predicate) => - { - tools.insert("jq"); - } Statement::If { then_body, else_body, @@ -63,6 +58,10 @@ fn collect_bash_tool(statement: &Statement, tools: &mut BTreeSet<&'static str>) | Statement::ArgvParse { .. } | Statement::ExecChecked { .. } | Statement::CaptureChecked { .. } + | Statement::CaptureOptional { .. } + | Statement::ToolExec { .. } + | Statement::ToolPassthrough { .. } + | Statement::ToolCapture { .. } | Statement::IntAdd { .. } | Statement::CallFunction { .. } | Statement::Print { .. } @@ -73,10 +72,3 @@ fn collect_bash_tool(statement: &Statement, tools: &mut BTreeSet<&'static str>) | Statement::Sleep { .. } => {} } } - -fn predicate_requires_jq(predicate: &Predicate) -> bool { - matches!( - predicate, - Predicate::JsonEmpty { .. } | Predicate::JsonNotEmpty { .. } - ) -} diff --git a/app/src/core/transpile/helpers.rs b/app/src/core/transpile/helpers.rs index 58636e8..8b3493e 100644 --- a/app/src/core/transpile/helpers.rs +++ b/app/src/core/transpile/helpers.rs @@ -1,6 +1,6 @@ use anyhow::{Result, bail}; -use super::ast::{ArgvKind, ArgvSpec, Statement, Value}; +use super::ast::{ArgvKind, ArgvSpec, Statement, ToolInvocation, Value}; use super::json_path::parse_json_path; pub(crate) fn parse_statement_helper(args: &[String], line: usize) -> Result<Statement> { @@ -10,6 +10,47 @@ pub(crate) fn parse_statement_helper(args: &[String], line: usize) -> Result<Sta specs: parse_argv_specs(rest, line)?, }) } + [passthrough, start, namespace, command, rest @ ..] if passthrough == "passthrough" => { + let start = start + .parse::<usize>() + .map_err(|_| anyhow::anyhow!("{line}: invalid passthrough start: {start}"))?; + Ok(Statement::ToolPassthrough { + start, + invocation: ToolInvocation { + path: vec![namespace.clone(), command.clone()], + argv: rest + .iter() + .map(|arg| super::value::parse_value_text(arg, line)) + .collect::<Result<Vec<_>>>()?, + }, + }) + } + [capture, optional, name, status, rest @ ..] + if capture == "capture" && optional == "optional" => + { + validate_name(name, line)?; + validate_name(status, line)?; + if rest.is_empty() { + bail!("{line}: seal capture optional requires a command"); + } + Ok(Statement::CaptureOptional { + name: name.clone(), + status: status.clone(), + argv: rest + .iter() + .map(|arg| super::value::parse_value_text(arg, line)) + .collect::<Result<Vec<_>>>()?, + }) + } + [namespace, command, rest @ ..] => Ok(Statement::ToolExec { + invocation: ToolInvocation { + path: vec![namespace.clone(), command.clone()], + argv: rest + .iter() + .map(|arg| super::value::parse_value_text(arg, line)) + .collect::<Result<Vec<_>>>()?, + }, + }), _ => bail!("{line}: unsupported seal helper statement"), } } @@ -26,10 +67,7 @@ pub(crate) fn parse_capture_helper( Value::Literal { text: trim }, value, ] if seal == "seal" && string == "string" && trim == "trim" => { - Some(Statement::StringTrim { - name: name.to_string(), - value: value.clone(), - }) + Some(tool_capture(name, ["string", "trim"], vec![value.clone()])) } [ Value::Literal { text: seal }, @@ -37,11 +75,14 @@ pub(crate) fn parse_capture_helper( Value::Literal { text: get }, value, Value::Literal { text: path }, - ] if seal == "seal" && json == "json" && get == "get" => Some(Statement::JsonGet { - name: name.to_string(), - json: value.clone(), - path: parse_json_path(path, line)?, - }), + ] if seal == "seal" && json == "json" && get == "get" => { + parse_json_path(path, line)?; + Some(tool_capture( + name, + ["json", "get"], + vec![value.clone(), Value::Literal { text: path.clone() }], + )) + } [ Value::Literal { text: seal }, Value::Literal { text: regex }, @@ -50,12 +91,20 @@ pub(crate) fn parse_capture_helper( Value::Literal { text: pattern }, Value::Literal { text: group }, ] if seal == "seal" && regex == "regex" && capture == "capture" => { - Some(Statement::RegexCapture { - name: name.to_string(), - value: value.clone(), - pattern: pattern.clone(), - group: parse_group(group, line)?, - }) + parse_group(group, line)?; + Some(tool_capture( + name, + ["regex", "capture"], + vec![ + value.clone(), + Value::Literal { + text: pattern.clone(), + }, + Value::Literal { + text: group.clone(), + }, + ], + )) } [ Value::Literal { text: seal }, @@ -63,16 +112,38 @@ pub(crate) fn parse_capture_helper( Value::Literal { text: add }, left, right, - ] if seal == "seal" && int == "int" && add == "add" => Some(Statement::IntAdd { + ] if seal == "seal" && int == "int" && add == "add" => Some(tool_capture( + name, + ["int", "add"], + vec![left.clone(), right.clone()], + )), + [ + Value::Literal { text: seal }, + Value::Literal { text: namespace }, + Value::Literal { text: command }, + rest @ .., + ] if seal == "seal" => Some(Statement::ToolCapture { name: name.to_string(), - left: left.clone(), - right: right.clone(), + invocation: ToolInvocation { + path: vec![namespace.clone(), command.clone()], + argv: rest.to_vec(), + }, }), _ => None, }; Ok(statement) } +fn tool_capture<const N: usize>(name: &str, path: [&str; N], argv: Vec<Value>) -> Statement { + Statement::ToolCapture { + name: name.to_string(), + invocation: ToolInvocation { + path: path.into_iter().map(str::to_string).collect(), + argv, + }, + } +} + fn parse_group(group: &str, line: usize) -> Result<usize> { let group = group .parse() @@ -119,7 +190,7 @@ fn validate_name(name: &str, line: usize) -> Result<()> { let valid = matches!(bytes.next(), Some(byte) if byte.is_ascii_alphabetic() || byte == b'_') && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'); if !valid { - bail!("{line}: invalid argv parse name: {name}"); + bail!("{line}: invalid variable name: {name}"); } Ok(()) } diff --git a/app/src/core/transpile/lower.rs b/app/src/core/transpile/lower.rs index 9ecf23b..7bf9698 100644 --- a/app/src/core/transpile/lower.rs +++ b/app/src/core/transpile/lower.rs @@ -57,6 +57,10 @@ fn lower_statement(statement: &mut Statement, functions: &BTreeSet<String>) { Statement::Assign { .. } | Statement::ArgvParse { .. } | Statement::CaptureChecked { .. } + | Statement::CaptureOptional { .. } + | Statement::ToolExec { .. } + | Statement::ToolPassthrough { .. } + | Statement::ToolCapture { .. } | Statement::StringTrim { .. } | Statement::JsonGet { .. } | Statement::RegexCapture { .. } diff --git a/app/src/core/transpile/mod.rs b/app/src/core/transpile/mod.rs index 4e9a353..15dc9f8 100644 --- a/app/src/core/transpile/mod.rs +++ b/app/src/core/transpile/mod.rs @@ -4,18 +4,19 @@ use anyhow::{Result, bail}; mod ast; mod emit; +mod frontend; mod guards; mod helpers; mod json_path; mod lower; mod parse; -mod powershell; -mod powershell_predicate; +mod runner; mod value; use emit::{emit_bash, emit_powershell, emit_seal}; +use frontend::parse_powershell; use parse::parse_seal; -use powershell::parse_powershell; +pub(crate) use runner::run_seal_file; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Lang { diff --git a/app/src/core/transpile/runner.rs b/app/src/core/transpile/runner.rs new file mode 100644 index 0000000..77eb3f1 --- /dev/null +++ b/app/src/core/transpile/runner.rs @@ -0,0 +1,480 @@ +use std::{ + collections::BTreeMap, + path::Path, + process::{Command, Stdio}, + time::Duration, +}; + +use anyhow::{Context, Result, bail}; + +use crate::core::tool; + +use super::ast::{ArgvKind, ArgvSpec, Item, Predicate, Program, Statement, ToolInvocation, Value}; +use super::json_path::json_path; +use super::parse::parse_seal; + +pub(crate) fn run_seal_file( + path: &Path, + argv: &[String], + env_overlay: &[(String, String)], +) -> Result<i32> { + let source = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + let program = parse_seal(&source)?; + let mut runner = Runner::new(&program, argv, env_overlay); + runner.run_program() +} + +struct Runner<'a> { + program: &'a Program, + vars: BTreeMap<String, String>, + env: BTreeMap<String, String>, +} + +enum Flow { + Continue, + Break, + Exit(i32), +} + +impl<'a> Runner<'a> { + fn new(program: &'a Program, argv: &[String], env_overlay: &[(String, String)]) -> Self { + let mut env = std::env::vars().collect::<BTreeMap<_, _>>(); + env.extend(env_overlay.iter().cloned()); + let mut vars = BTreeMap::new(); + vars.insert("__seal_argv".to_string(), shell_words(argv)); + vars.insert("0".to_string(), argv.len().to_string()); + for (index, value) in argv.iter().enumerate() { + vars.insert((index + 1).to_string(), value.clone()); + } + Self { program, vars, env } + } + + fn run_program(&mut self) -> Result<i32> { + let statements = self + .program + .items + .iter() + .filter_map(|item| match item { + Item::Statement { statement } => Some(statement), + Item::Function { .. } => None, + }) + .collect::<Vec<_>>(); + match self.run_statements(&statements)? { + Flow::Continue | Flow::Break => Ok(0), + Flow::Exit(code) => Ok(code), + } + } + + fn run_statements(&mut self, statements: &[&Statement]) -> Result<Flow> { + for statement in statements { + match self.run_statement(statement)? { + Flow::Continue => {} + flow => return Ok(flow), + } + } + Ok(Flow::Continue) + } + + fn run_body(&mut self, statements: &[Statement]) -> Result<Flow> { + let refs = statements.iter().collect::<Vec<_>>(); + self.run_statements(&refs) + } + + fn run_statement(&mut self, statement: &Statement) -> Result<Flow> { + match statement { + Statement::Assign { name, value } => { + let value = self.value(value); + self.vars.insert(name.clone(), value); + } + Statement::ArgvParse { specs } => self.parse_argv(specs)?, + Statement::ExecChecked { argv } => { + let code = self.run_external(argv, CaptureMode::None)?.code; + if code != 0 { + return Ok(Flow::Exit(code)); + } + } + Statement::CaptureChecked { name, argv } => { + let output = self.run_external(argv, CaptureMode::Stdout)?; + if output.code != 0 { + return Ok(Flow::Exit(output.code)); + } + self.vars + .insert(name.clone(), output.stdout.trim().to_string()); + } + Statement::CaptureOptional { name, status, argv } => { + let output = self.run_external(argv, CaptureMode::Combined)?; + self.vars + .insert(name.clone(), output.stdout.trim().to_string()); + self.vars.insert(status.clone(), output.code.to_string()); + } + Statement::ToolExec { invocation } => { + self.tool_output(invocation)?; + } + Statement::ToolPassthrough { start, invocation } => { + if let Some(output) = self.tool_passthrough_output(*start, invocation)? { + println!("{output}"); + } + } + Statement::ToolCapture { name, invocation } => { + let output = self.tool_output(invocation)?; + self.vars.insert(name.clone(), output); + } + Statement::StringTrim { name, value } => { + let output = self.tool_path(&["string", "trim"], std::slice::from_ref(value))?; + self.vars.insert(name.clone(), output); + } + Statement::JsonGet { name, json, path } => { + let output = self.tool_path( + &["json", "get"], + &[ + json.clone(), + Value::Literal { + text: json_path(path), + }, + ], + )?; + self.vars.insert(name.clone(), output); + } + Statement::RegexCapture { + name, + value, + pattern, + group, + } => { + let output = self.tool_path( + &["regex", "capture"], + &[ + value.clone(), + Value::Literal { + text: pattern.clone(), + }, + Value::Literal { + text: group.to_string(), + }, + ], + )?; + self.vars.insert(name.clone(), output); + } + Statement::IntAdd { name, left, right } => { + let output = self.tool_path(&["int", "add"], &[left.clone(), right.clone()])?; + self.vars.insert(name.clone(), output); + } + Statement::If { + predicate, + then_body, + else_body, + } => { + let flow = if self.predicate(predicate)? { + self.run_body(then_body)? + } else { + self.run_body(else_body)? + }; + if !matches!(flow, Flow::Continue) { + return Ok(flow); + } + } + Statement::While { predicate, body } => { + while self.predicate(predicate)? { + match self.run_body(body)? { + Flow::Continue => {} + Flow::Break => break, + flow => return Ok(flow), + } + } + } + Statement::Case { value, arms } => { + let value = self.value(value); + for arm in arms { + if arm + .patterns + .iter() + .any(|pattern| case_matches(pattern, &value)) + { + let flow = self.run_body(&arm.body)?; + if !matches!(flow, Flow::Continue) { + return Ok(flow); + } + break; + } + } + } + Statement::CallFunction { name, argv } => { + let old_args = self.set_function_args(argv); + let flow = self.run_function(name)?; + self.restore_function_args(old_args); + if !matches!(flow, Flow::Continue) { + return Ok(flow); + } + } + Statement::Print { value } => println!("{}", self.value(value)), + Statement::Error { value } => eprintln!("{}", self.value(value)), + Statement::Fail { value } => { + eprintln!("{}", self.value(value)); + return Ok(Flow::Exit(1)); + } + Statement::Exit { code } => return Ok(Flow::Exit(*code)), + Statement::Break => return Ok(Flow::Break), + Statement::Sleep { seconds } => std::thread::sleep(Duration::from_secs(*seconds)), + } + Ok(Flow::Continue) + } + + fn run_function(&mut self, name: &str) -> Result<Flow> { + let Some(body) = self.program.items.iter().find_map(|item| match item { + Item::Function { + name: function_name, + body, + } if function_name == name => Some(body), + _ => None, + }) else { + bail!("unknown function: {name}"); + }; + self.run_body(body) + } + + fn parse_argv(&mut self, specs: &[ArgvSpec]) -> Result<()> { + let argv = self + .vars + .get("__seal_argv") + .map(|value| split_words(value)) + .unwrap_or_default(); + self.vars + .insert("__seal_argc".to_string(), argv.len().to_string()); + self.vars + .insert("__seal_help".to_string(), "false".to_string()); + for spec in specs { + let value = match spec.kind { + ArgvKind::String => spec.default.clone().unwrap_or_default(), + ArgvKind::Flag => "false".to_string(), + }; + self.vars.insert(spec.name.clone(), value); + } + let mut index = 0; + while index < argv.len() { + let arg = &argv[index]; + if arg == "--" { + break; + } + if matches!(arg.as_str(), "-h" | "--help" | "help") { + self.vars + .insert("__seal_help".to_string(), "true".to_string()); + index += 1; + continue; + } + let Some(spec) = find_spec(specs, arg) else { + eprintln!("unknown option: {arg}"); + bail!("seal argv parse failed"); + }; + match spec.kind { + ArgvKind::Flag => { + self.vars.insert(spec.name.clone(), "true".to_string()); + index += 1; + } + ArgvKind::String => { + let option = option_name(&spec.name); + if let Some(value) = arg.strip_prefix(&(option.clone() + "=")) { + self.vars.insert(spec.name.clone(), value.to_string()); + index += 1; + } else { + let Some(value) = argv.get(index + 1) else { + eprintln!("missing value for {option}"); + bail!("seal argv parse failed"); + }; + self.vars.insert(spec.name.clone(), value.clone()); + index += 2; + } + } + } + } + Ok(()) + } + + fn value(&self, value: &Value) -> String { + match value { + Value::Literal { text } => text.clone(), + Value::Var { name } => self.vars.get(name).cloned().unwrap_or_default(), + Value::Env { name } => self.env.get(name).cloned().unwrap_or_default(), + Value::EnvDefault { name, default } => self + .env + .get(name) + .filter(|value| !value.is_empty()) + .cloned() + .unwrap_or_else(|| default.clone()), + Value::Concat { parts } => parts.iter().map(|part| self.value(part)).collect(), + } + } + + fn predicate(&self, predicate: &Predicate) -> Result<bool> { + Ok(match predicate { + Predicate::Empty { value } => self.value(value).is_empty(), + Predicate::NotEmpty { value } => !self.value(value).is_empty(), + Predicate::Eq { left, right } => self.value(left) == self.value(right), + Predicate::Neq { left, right } => self.value(left) != self.value(right), + Predicate::IntLt { left, right } => self.int_value(left)? < self.int_value(right)?, + Predicate::IntLte { left, right } => self.int_value(left)? <= self.int_value(right)?, + Predicate::IntGt { left, right } => self.int_value(left)? > self.int_value(right)?, + Predicate::IntGte { left, right } => self.int_value(left)? >= self.int_value(right)?, + Predicate::JsonEmpty { value } => { + self.tool_path(&["json", "empty"], std::slice::from_ref(value))? == "true" + } + Predicate::JsonNotEmpty { value } => { + self.tool_path(&["json", "empty"], std::slice::from_ref(value))? == "false" + } + Predicate::FileExists { path } => Path::new(&self.value(path)).is_file(), + Predicate::DirExists { path } => Path::new(&self.value(path)).is_dir(), + Predicate::ToolExists { name } => command_exists(name), + }) + } + + fn int_value(&self, value: &Value) -> Result<i64> { + let value = self.value(value); + value + .parse::<i64>() + .with_context(|| format!("invalid integer: {value}")) + } + + fn tool_output(&self, invocation: &ToolInvocation) -> Result<String> { + let mut args = invocation.path.clone(); + args.extend(invocation.argv.iter().map(|value| self.value(value))); + Ok(tool::eval(&args)?.unwrap_or_default()) + } + + fn tool_passthrough_output( + &self, + start: usize, + invocation: &ToolInvocation, + ) -> Result<Option<String>> { + let argc = self + .vars + .get("0") + .and_then(|value| value.parse::<usize>().ok()) + .unwrap_or_default(); + let mut args = invocation.path.clone(); + args.extend(invocation.argv.iter().map(|value| self.value(value))); + for index in start..=argc { + if let Some(value) = self.vars.get(&index.to_string()) { + args.push(value.clone()); + } + } + tool::eval(&args) + } + + fn tool_path(&self, path: &[&str], argv: &[Value]) -> Result<String> { + let invocation = ToolInvocation { + path: path.iter().map(|part| part.to_string()).collect(), + argv: argv.to_vec(), + }; + self.tool_output(&invocation) + } + + fn run_external(&self, argv: &[Value], capture: CaptureMode) -> Result<CommandOutput> { + let argv = argv + .iter() + .map(|value| self.value(value)) + .collect::<Vec<_>>(); + let Some((program, args)) = argv.split_first() else { + bail!("external command cannot be empty"); + }; + let mut command = Command::new(program); + command.args(args).envs(&self.env); + if matches!(capture, CaptureMode::None) { + let status = command + .status() + .with_context(|| format!("failed to execute command: {program}"))?; + return Ok(CommandOutput { + code: status.code().unwrap_or(1), + stdout: String::new(), + }); + } + command.stdout(Stdio::piped()); + if matches!(capture, CaptureMode::Combined) { + command.stderr(Stdio::piped()); + } + let output = command + .output() + .with_context(|| format!("failed to execute command: {program}"))?; + let mut stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + if matches!(capture, CaptureMode::Combined) { + stdout.push_str(&String::from_utf8_lossy(&output.stderr)); + } + Ok(CommandOutput { + code: output.status.code().unwrap_or(1), + stdout, + }) + } + + fn set_function_args(&mut self, argv: &[Value]) -> Vec<Option<String>> { + let values = argv + .iter() + .map(|value| self.value(value)) + .collect::<Vec<_>>(); + let len = values.len(); + let old = (0..=values.len()) + .map(|index| self.vars.remove(&index.to_string())) + .collect::<Vec<_>>(); + for (index, value) in values.into_iter().enumerate() { + self.vars.insert((index + 1).to_string(), value); + } + self.vars.insert("0".to_string(), len.to_string()); + old + } + + fn restore_function_args(&mut self, old: Vec<Option<String>>) { + for (index, value) in old.into_iter().enumerate() { + match value { + Some(value) => { + self.vars.insert(index.to_string(), value); + } + None => { + self.vars.remove(&index.to_string()); + } + } + } + } +} + +enum CaptureMode { + None, + Stdout, + Combined, +} + +struct CommandOutput { + code: i32, + stdout: String, +} + +fn find_spec<'a>(specs: &'a [ArgvSpec], arg: &str) -> Option<&'a ArgvSpec> { + specs.iter().find(|spec| { + let option = option_name(&spec.name); + arg == option || arg.starts_with(&(option + "=")) + }) +} + +fn option_name(name: &str) -> String { + format!("--{}", name.replace('_', "-")) +} + +fn case_matches(pattern: &str, value: &str) -> bool { + pattern == "*" || pattern == value +} + +fn command_exists(name: &str) -> bool { + let Some(path) = std::env::var_os("PATH") else { + return false; + }; + std::env::split_paths(&path).any(|dir| dir.join(name).is_file()) +} + +fn shell_words(argv: &[String]) -> String { + argv.join("\u{1f}") +} + +fn split_words(value: &str) -> Vec<String> { + if value.is_empty() { + Vec::new() + } else { + value.split('\u{1f}').map(str::to_string).collect() + } +} diff --git a/app/src/core/transpile/value.rs b/app/src/core/transpile/value.rs index 3c3ca7d..c2ff698 100644 --- a/app/src/core/transpile/value.rs +++ b/app/src/core/transpile/value.rs @@ -18,6 +18,11 @@ pub(crate) fn parse_value_text(text: &str, line: usize) -> Result<Value> { return parse_template(value, line); } if let Some(name) = text.strip_prefix('$') { + if is_positional_name(name) { + return Ok(Value::Var { + name: name.to_string(), + }); + } if let Some(name) = name .strip_prefix('{') .and_then(|name| name.strip_suffix('}')) @@ -83,6 +88,14 @@ fn parse_template(text: &str, line: usize) -> Result<Value> { continue; } let mut name = String::new(); + if let Some(next) = chars.peek().copied() + && next.is_ascii_digit() + { + name.push(next); + chars.next(); + parts.push(Value::Var { name }); + continue; + } while let Some(next) = chars.peek().copied() { if next.is_ascii_alphanumeric() || next == '_' { name.push(next); @@ -103,6 +116,10 @@ fn parse_template(text: &str, line: usize) -> Result<Value> { } } +fn is_positional_name(name: &str) -> bool { + name.len() == 1 && name.bytes().all(|byte| byte.is_ascii_digit()) +} + fn validate_name(name: &str, line: usize) -> Result<()> { let mut bytes = name.bytes(); let valid = matches!(bytes.next(), Some(byte) if byte.is_ascii_alphabetic() || byte == b'_') diff --git a/app/tests/internal.rs b/app/tests/internal.rs index c939cc6..2c28368 100644 --- a/app/tests/internal.rs +++ b/app/tests/internal.rs @@ -65,7 +65,6 @@ struct Fixture { profile: PathBuf, home: PathBuf, project_wrappers: PathBuf, - home_wrappers: PathBuf, } fn fixture() -> Fixture { @@ -74,9 +73,8 @@ fn fixture() -> Fixture { let profile = project.join("runseal.toml"); let home = temp.path().join("home"); let project_wrappers = project.join(".runseal").join("wrappers"); - let home_wrappers = home.join("wrappers"); std::fs::create_dir_all(&project_wrappers).expect("project wrappers should be created"); - std::fs::create_dir_all(&home_wrappers).expect("home wrappers should be created"); + std::fs::create_dir_all(home.join("wrappers")).expect("home wrappers should be created"); std::fs::write( &profile, "injections = []\n[resources]\nroot = \".resource\"\n", @@ -88,7 +86,6 @@ fn fixture() -> Fixture { profile, home, project_wrappers, - home_wrappers, } } @@ -156,6 +153,7 @@ fn internal_help_topics() { (vec!["@resources", "--help"], "Usage: runseal @resources"), (vec!["@resolve", "--help"], "Usage: runseal @resolve"), (vec!["@transpile", "--help"], "Usage: runseal @transpile"), + (vec!["@tool", "--help"], "Usage: runseal @tool"), (vec!["@wrappers", "--help"], "Lookup order"), (vec!["@which", "--help"], "Usage: runseal @which :<wrapper>"), ] { @@ -196,60 +194,6 @@ fn profile_prints_paths() { assert!(stdout.contains(fx.profile.to_str().expect("path should be UTF-8"))); } -#[test] -fn wrappers_show_effective() { - let fx = fixture(); - make_wrapper(&wrapper_file(&fx.project_wrappers, "wrap"), "project"); - make_wrapper(&wrapper_file(&fx.home_wrappers, "wrap"), "home"); - make_wrapper(&wrapper_file(&fx.home_wrappers, "home-only"), "home"); - - let output = run_in(&fx, &["@wrappers"]); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains(":wrap")); - assert!(stdout.contains(":home-only")); - assert!(stdout.contains("profile")); - assert!(stdout.contains("home")); - let wrap_line = stdout - .lines() - .find(|line| line.contains(":wrap")) - .expect("wrap should be listed"); - let wrap_file = wrap_line - .split_whitespace() - .last() - .expect("wrap line should include a file"); - assert!(wrap_line.contains("profile")); - assert!( - std::path::Path::new(wrap_file) - .ends_with(path_suffix(&wrapper_file(&fx.project_wrappers, "wrap"), 4)), - "expected {wrap_file} to point at the profile wrapper" - ); -} - -#[test] -fn wrappers_hide_shadow() { - let fx = fixture(); - let project_wrapper = wrapper_file(&fx.project_wrappers, "wrap"); - make_wrapper(&project_wrapper, "project"); - make_wrapper(&wrapper_file(&fx.home_wrappers, "wrap"), "home"); - - let which = run_in(&fx, &["@which", ":wrap"]); - assert!(which.status.success()); - let stdout = String::from_utf8(which.stdout).expect("stdout should be UTF-8"); - assert_path_ends_with(stdout.trim(), &project_wrapper); - - let output = run_in(&fx, &["@wrappers"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - let wrap_lines = stdout - .lines() - .filter(|line| line.starts_with(":wrap ")) - .collect::<Vec<_>>(); - assert_eq!(wrap_lines.len(), 1); - assert!(wrap_lines[0].contains("profile")); -} - #[test] fn which_resolves_wrapper() { let fx = fixture(); diff --git a/app/tests/internal_tool.rs b/app/tests/internal_tool.rs new file mode 100644 index 0000000..1a405c7 --- /dev/null +++ b/app/tests/internal_tool.rs @@ -0,0 +1,118 @@ +use std::{path::PathBuf, process::Command}; + +use tempfile::TempDir; + +fn bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_runseal")) +} + +#[test] +fn tool_runs_without_profile() { + let temp = TempDir::new().expect("temp dir should be created"); + let cwd = temp.path().join("empty"); + std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); + + for (args, expected) in [ + ( + vec![ + "@tool", + "json", + "get", + r#"[{"databaseId":123}]"#, + ".[0].databaseId", + ], + "123\n", + ), + (vec!["@tool", "string", "trim", " value "], "value\n"), + ( + vec![ + "@tool", + "regex", + "capture", + "https://github.test/actions/runs/456", + "/actions/runs/([0-9]+)", + "1", + ], + "456\n", + ), + (vec!["@tool", "int", "add", "2", "3"], "5\n"), + ( + vec!["@tool", "process", "exists", "definitely-not-runseal-tool"], + "false\n", + ), + ] { + let output = bin() + .current_dir(&cwd) + .env("RUNSEAL_HOME", temp.path().join("home")) + .args(args.clone()) + .output() + .expect("runseal should run"); + + assert!(output.status.success(), "{args:?} should succeed"); + let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); + assert_eq!(stdout, expected, "{args:?} stdout should match"); + } +} + +#[test] +fn fs_runs_without_profile() { + let temp = TempDir::new().expect("temp dir should be created"); + let cwd = temp.path().join("empty"); + std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); + let file = cwd.join("hook"); + let nested = cwd.join("nested"); + + let mkdir = bin() + .current_dir(&cwd) + .env("RUNSEAL_HOME", temp.path().join("home")) + .args(["@tool", "fs", "mkdir", nested.to_str().unwrap(), "700"]) + .output() + .expect("runseal should run"); + assert!(mkdir.status.success()); + assert!(nested.is_dir()); + + let write = bin() + .current_dir(&cwd) + .env("RUNSEAL_HOME", temp.path().join("home")) + .args([ + "@tool", + "fs", + "write-base64", + file.to_str().unwrap(), + "c2VhbCBtYXJrZXIK", + ]) + .output() + .expect("runseal should run"); + assert!(write.status.success()); + assert_eq!( + std::fs::read_to_string(&file).expect("file should be readable"), + "seal marker\n" + ); + + let contains = bin() + .current_dir(&cwd) + .env("RUNSEAL_HOME", temp.path().join("home")) + .args([ + "@tool", + "fs", + "contains-any", + file.to_str().unwrap(), + "missing", + "seal marker", + ]) + .output() + .expect("runseal should run"); + assert!(contains.status.success()); + assert_eq!(String::from_utf8(contains.stdout).unwrap(), "true\n"); + + let backup = bin() + .current_dir(&cwd) + .env("RUNSEAL_HOME", temp.path().join("home")) + .args(["@tool", "fs", "backup-numbered", file.to_str().unwrap()]) + .output() + .expect("runseal should run"); + assert!(backup.status.success()); + assert!(!file.exists()); + let backup_path = PathBuf::from(String::from_utf8(backup.stdout).unwrap().trim()); + assert!(backup_path.is_file()); +} diff --git a/app/tests/internal_wrappers.rs b/app/tests/internal_wrappers.rs new file mode 100644 index 0000000..439d912 --- /dev/null +++ b/app/tests/internal_wrappers.rs @@ -0,0 +1,215 @@ +use std::{ + path::{Path, PathBuf}, + process::Command, +}; + +use tempfile::TempDir; + +fn bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_runseal")) +} + +#[cfg(unix)] +fn wrapper_file(dir: &Path, name: &str) -> PathBuf { + dir.join(format!("{name}.sh")) +} + +#[cfg(windows)] +fn wrapper_file(dir: &Path, name: &str) -> PathBuf { + dir.join(format!("{name}.cmd")) +} + +#[cfg(unix)] +fn make_wrapper(path: &Path, label: &str) { + use std::os::unix::fs::PermissionsExt; + + std::fs::write(path, format!("#!/usr/bin/env sh\nprintf '{}'\n", label)) + .expect("wrapper should be written"); + let mut permissions = std::fs::metadata(path) + .expect("wrapper metadata should be readable") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions).expect("wrapper should be executable"); +} + +#[cfg(windows)] +fn make_wrapper(path: &Path, label: &str) { + std::fs::write( + path, + format!("@echo off\r\n<nul set /p=\"{}\"\r\nexit /b 0\r\n", label), + ) + .expect("wrapper should be written"); +} + +fn make_seal_wrapper(path: &Path, source: &str) { + std::fs::write(path, source).expect("seal wrapper should be written"); +} + +struct Fixture { + _temp: TempDir, + project: PathBuf, + home: PathBuf, + project_wrappers: PathBuf, + home_wrappers: PathBuf, +} + +fn fixture() -> Fixture { + let temp = TempDir::new().expect("temp dir should be created"); + let project = temp.path().join("project"); + let home = temp.path().join("home"); + let project_wrappers = project.join(".runseal").join("wrappers"); + let home_wrappers = home.join("wrappers"); + std::fs::create_dir_all(&project_wrappers).expect("project wrappers should be created"); + std::fs::create_dir_all(&home_wrappers).expect("home wrappers should be created"); + std::fs::write( + project.join("runseal.toml"), + "injections = []\n[resources]\nroot = \".resource\"\n", + ) + .expect("profile should be written"); + Fixture { + _temp: temp, + project, + home, + project_wrappers, + home_wrappers, + } +} + +fn run_in(fx: &Fixture, args: &[&str]) -> std::process::Output { + bin() + .current_dir(&fx.project) + .env("RUNSEAL_HOME", &fx.home) + .args(args) + .output() + .expect("runseal should run") +} + +fn path_suffix(path: &Path, count: usize) -> PathBuf { + path.components() + .rev() + .take(count) + .collect::<Vec<_>>() + .into_iter() + .rev() + .collect() +} + +fn assert_path_ends_with(actual: &str, expected: &Path) { + let expected_suffix = path_suffix(expected, 4); + assert!( + Path::new(actual).ends_with(&expected_suffix), + "expected {actual:?} to end with {}", + expected_suffix.display() + ); +} + +#[test] +fn wrappers_show_effective() { + let fx = fixture(); + make_wrapper(&wrapper_file(&fx.project_wrappers, "wrap"), "project"); + make_wrapper(&wrapper_file(&fx.home_wrappers, "wrap"), "home"); + make_wrapper(&wrapper_file(&fx.home_wrappers, "home-only"), "home"); + + let output = run_in(&fx, &["@wrappers"]); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); + assert!(stdout.contains(":wrap")); + assert!(stdout.contains(":home-only")); + assert!(stdout.contains("profile")); + assert!(stdout.contains("home")); + let wrap_line = stdout + .lines() + .find(|line| line.contains(":wrap")) + .expect("wrap should be listed"); + let wrap_file = wrap_line + .split_whitespace() + .last() + .expect("wrap line should include a file"); + assert!(wrap_line.contains("profile")); + assert!( + std::path::Path::new(wrap_file) + .ends_with(path_suffix(&wrapper_file(&fx.project_wrappers, "wrap"), 4)), + "expected {wrap_file} to point at the profile wrapper" + ); +} + +#[test] +fn seal_wrapper_resolves() { + let fx = fixture(); + let wrapper = fx.project_wrappers.join("seal-tool.seal"); + make_seal_wrapper(&wrapper, "print seal\n"); + + let which = run_in(&fx, &["@which", ":seal-tool"]); + assert!(which.status.success()); + let stdout = String::from_utf8(which.stdout).expect("stdout should be UTF-8"); + assert_path_ends_with(stdout.trim(), &wrapper); + + let wrappers = run_in(&fx, &["@wrappers"]); + assert!(wrappers.status.success()); + let stdout = String::from_utf8(wrappers.stdout).expect("stdout should be UTF-8"); + assert!(stdout.contains(":seal-tool")); + assert!(stdout.contains("seal-tool.seal")); +} + +#[test] +fn seal_wrapper_runs_directly() { + let fx = fixture(); + make_seal_wrapper( + &fx.project_wrappers.join("seal-tool.seal"), + r#" +seal argv parse --string name=world --flag loud +if eq "$__seal_argc" 0; then + print "hello $name" +else + if eq "$loud" true; then + print "HELLO $name from ${RUNSEAL_WRAPPER_NAME}" + else + print "hello $name" + fi +fi +"#, + ); + + let output = run_in(&fx, &[":seal-tool", "--name", "seal", "--loud"]); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); + assert_eq!(stdout, "HELLO seal from seal-tool\n"); +} + +#[test] +fn seal_wrapper_shadows() { + let fx = fixture(); + make_wrapper(&wrapper_file(&fx.project_wrappers, "tool"), "shell"); + make_seal_wrapper(&fx.project_wrappers.join("tool.seal"), "print seal\n"); + + let output = run_in(&fx, &[":tool"]); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); + assert_eq!(stdout, "seal\n"); +} + +#[test] +fn wrappers_hide_shadow() { + let fx = fixture(); + let project_wrapper = wrapper_file(&fx.project_wrappers, "wrap"); + make_wrapper(&project_wrapper, "project"); + make_wrapper(&wrapper_file(&fx.home_wrappers, "wrap"), "home"); + + let which = run_in(&fx, &["@which", ":wrap"]); + assert!(which.status.success()); + let stdout = String::from_utf8(which.stdout).expect("stdout should be UTF-8"); + assert_path_ends_with(stdout.trim(), &project_wrapper); + + let output = run_in(&fx, &["@wrappers"]); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); + let wrap_lines = stdout + .lines() + .filter(|line| line.starts_with(":wrap ")) + .collect::<Vec<_>>(); + assert_eq!(wrap_lines.len(), 1); + assert!(wrap_lines[0].contains("profile")); +} diff --git a/app/tests/operator.rs b/app/tests/operator.rs new file mode 100644 index 0000000..bca7afa --- /dev/null +++ b/app/tests/operator.rs @@ -0,0 +1,6 @@ +#[path = "operator/cloudflare.rs"] +mod cloudflare; +#[path = "operator/init.rs"] +mod init; +#[path = "operator/repo.rs"] +mod repo; diff --git a/app/tests/operator/cloudflare.rs b/app/tests/operator/cloudflare.rs new file mode 100644 index 0000000..934900f --- /dev/null +++ b/app/tests/operator/cloudflare.rs @@ -0,0 +1,234 @@ +#![cfg(unix)] + +use std::{ + io::{Read, Write}, + net::TcpListener, + path::{Path, PathBuf}, + process::Command, + thread, +}; + +use tempfile::TempDir; + +struct Fixture { + _temp: TempDir, + project: PathBuf, +} + +fn fixture() -> Fixture { + let temp = TempDir::new().expect("temp dir should be created"); + let project = temp.path().join("project"); + std::fs::create_dir_all(project.join(".runseal/wrappers")) + .expect("wrapper dir should be created"); + std::fs::write( + project.join("runseal.toml"), + r#" +[resources] +root = ".local" + +[[injections]] +type = "env" + +[injections.vars] +RUNSEAL_REPO_LOCAL_DIR = "resource://" +RUNSEAL_REPO_SECRETS_DIR = "resource://secrets" +RUNSEAL_REPO_TMP_DIR = "resource://tmp" +"#, + ) + .expect("profile should be written"); + std::fs::write( + project.join(".runseal/wrappers/cloudflare.seal"), + std::fs::read_to_string(repo_root().join(".runseal/wrappers/cloudflare.seal")) + .expect("repo cloudflare seal should be readable"), + ) + .expect("cloudflare seal should be copied"); + Fixture { + _temp: temp, + project, + } +} + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("app dir should have repo parent") + .to_path_buf() +} + +fn run_cloudflare(fx: &Fixture, args: &[&str]) -> std::process::Output { + run_cloudflare_with_env(fx, args, &[]) +} + +fn run_cloudflare_with_env( + fx: &Fixture, + args: &[&str], + envs: &[(&str, String)], +) -> std::process::Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_runseal")); + command + .current_dir(&fx.project) + .arg("-p") + .arg(fx.project.join("runseal.toml")) + .arg(":cloudflare") + .args(args); + for (key, value) in envs { + command.env(key, value); + } + command.output().expect("cloudflare wrapper should run") +} + +fn run_cloudflare_tool(args: &[&str], envs: &[(&str, String)]) -> std::process::Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_runseal")); + command.args(args); + for (key, value) in envs { + command.env(key, value); + } + command.output().expect("cloudflare tool should run") +} + +fn write_credentials(fx: &Fixture) { + let secrets = fx.project.join(".local/secrets"); + std::fs::create_dir_all(&secrets).expect("secrets dir should be created"); + std::fs::write( + secrets.join("cloudflare.env"), + "\ +CLOUDFLARE_ACCOUNT_ID=account-123 +CLOUDFLARE_API_TOKEN=token-456 +CLOUDFLARE_ZONE_NAME=perish.uk +CLOUDFLARE_MANAGE_HOST=runseal.perish.uk +CLOUDFLARE_MANAGE_ORIGIN_HOST=releases.runseal.perish.uk +CLOUDFLARE_MANAGE_REDIRECT_PREFIX= +", + ) + .expect("credentials should be written"); +} + +fn stdout(output: &std::process::Output) -> String { + String::from_utf8(output.stdout.clone()).expect("stdout should be UTF-8") +} + +fn stderr(output: &std::process::Output) -> String { + String::from_utf8(output.stderr.clone()).expect("stderr should be UTF-8") +} + +#[test] +fn cloudflare_init_writes_template() { + let fx = fixture(); + + let output = run_cloudflare(&fx, &["init"]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert!(stdout(&output).contains("created")); + let token_file = fx.project.join(".local/secrets/cloudflare.env"); + let text = std::fs::read_to_string(token_file).expect("token template should exist"); + assert!(text.contains("CLOUDFLARE_ACCOUNT_ID=")); + assert!(text.contains("CLOUDFLARE_ZONE_NAME=perish.uk")); +} + +#[test] +fn manage_plan_uses_seal() { + let fx = fixture(); + write_credentials(&fx); + + let output = run_cloudflare(&fx, &["manage-plan"]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let stdout = stdout(&output); + assert!(stdout.contains("manage redirect plan")); + assert!(stdout.contains("runseal_manage_sh_redirect")); + assert!(stdout.contains("https://releases.runseal.perish.uk/manage.sh")); + assert!(stdout.contains("runseal_manage_ps1_redirect")); +} + +#[test] +fn zone_get_uses_tool() { + let temp = TempDir::new().expect("temp dir should be created"); + let secrets = temp.path().join("secrets"); + std::fs::create_dir_all(&secrets).expect("secrets dir should be created"); + std::fs::write( + secrets.join("cloudflare.env"), + "\ +CLOUDFLARE_ACCOUNT_ID=account-123 +CLOUDFLARE_API_TOKEN=token-456 +", + ) + .expect("credentials should be written"); + let server = TcpListener::bind("127.0.0.1:0").expect("mock server should bind"); + let address = server + .local_addr() + .expect("mock server address should exist"); + let handle = thread::spawn(move || { + let (mut stream, _) = server.accept().expect("mock request should arrive"); + let mut request = [0_u8; 2048]; + let read = stream + .read(&mut request) + .expect("request should be readable"); + let request = String::from_utf8_lossy(&request[..read]); + assert!(request.starts_with("GET /zones?name=perish.uk ")); + assert!(request.contains("authorization: Bearer token-456")); + let body = + r#"{"success":true,"result":[{"id":"zone-123","name":"perish.uk","status":"active"}]}"#; + write!( + stream, + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", + body.len(), + body + ) + .expect("response should be written"); + }); + + let output = run_cloudflare_tool( + &["@tool", "cloudflare", "zone", "get", "--name", "perish.uk"], + &[ + ( + "RUNSEAL_REPO_SECRETS_DIR", + secrets.to_string_lossy().into_owned(), + ), + ("RUNSEAL_CLOUDFLARE_API_BASE", format!("http://{address}")), + ], + ); + + handle.join().expect("mock server should finish"); + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert_eq!( + stdout(&output), + r#"{"id":"zone-123","name":"perish.uk","status":"active"}"#.to_string() + "\n" + ); +} + +#[test] +fn api_passthrough_uses_tool() { + let fx = fixture(); + write_credentials(&fx); + let server = TcpListener::bind("127.0.0.1:0").expect("mock server should bind"); + let address = server + .local_addr() + .expect("mock server address should exist"); + let handle = thread::spawn(move || { + let (mut stream, _) = server.accept().expect("mock request should arrive"); + let mut request = [0_u8; 2048]; + let read = stream + .read(&mut request) + .expect("request should be readable"); + let request = String::from_utf8_lossy(&request[..read]); + assert!(request.starts_with("GET /zones?name=perish.uk ")); + let body = r#"{"success":true,"result":[{"id":"zone-123"}]}"#; + write!( + stream, + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", + body.len(), + body + ) + .expect("response should be written"); + }); + + let output = run_cloudflare_with_env( + &fx, + &["api", "GET", "/zones", "--query", "name=perish.uk"], + &[("RUNSEAL_CLOUDFLARE_API_BASE", format!("http://{address}"))], + ); + + handle.join().expect("mock server should finish"); + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert!(stdout(&output).contains(r#""id":"zone-123""#)); +} diff --git a/app/tests/operator/init.rs b/app/tests/operator/init.rs new file mode 100644 index 0000000..6bacb24 --- /dev/null +++ b/app/tests/operator/init.rs @@ -0,0 +1,160 @@ +#![cfg(unix)] + +use std::{ + ffi::OsString, + path::{Path, PathBuf}, + process::Command, +}; + +use tempfile::TempDir; + +struct Fixture { + _temp: TempDir, + project: PathBuf, + bin: PathBuf, +} + +fn fixture() -> Fixture { + let temp = TempDir::new().expect("temp dir should be created"); + let project = temp.path().join("project"); + let bin = temp.path().join("bin"); + std::fs::create_dir_all(&project).expect("project should be created"); + std::fs::create_dir_all(&bin).expect("bin should be created"); + Command::new("git") + .arg("init") + .arg(&project) + .output() + .expect("git init should run"); + write_required_files(&project); + write_stub(&bin.join("python3")); + write_stub(&bin.join("cargo")); + write_stub(&bin.join("runseal")); + write_stub(&bin.join("flavor")); + write_stub(&bin.join("sh")); + write_stub(&bin.join("bash")); + write_stub(&bin.join("sed")); + write_stub(&bin.join("grep")); + Fixture { + _temp: temp, + project, + bin, + } +} + +fn write_required_files(project: &Path) { + for path in [ + "Cargo.toml", + "Cargo.lock", + "flavor.toml", + "manage.sh", + "manage.ps1", + "runseal.toml", + ".runseal/wrappers/cloudflare.seal", + ".runseal/wrappers/init.seal", + ".runseal/wrappers/pr.seal", + ".runseal/wrappers/release.seal", + ".github/workflows/guard.yml", + ".github/workflows/release-beta.yml", + ".github/workflows/release-stable.yml", + ".github/scripts/release/assets/package.sh", + ".github/scripts/release/assets/package.ps1", + ".github/scripts/release/r2/publish.sh", + ".github/scripts/release/smoke/smoke.sh", + ".github/scripts/release/smoke/smoke.ps1", + ] { + let file = project.join(path); + std::fs::create_dir_all(file.parent().expect("file should have a parent")) + .expect("parent should be created"); + std::fs::write(&file, "").expect("required file should be written"); + } + std::fs::write( + project.join(".runseal/wrappers/init.seal"), + std::fs::read_to_string(repo_root().join(".runseal/wrappers/init.seal")) + .expect("repo init seal should be readable"), + ) + .expect("init seal should be copied"); + std::fs::write(project.join("runseal.toml"), "injections = []\n") + .expect("profile should be written"); +} + +fn write_stub(path: &Path) { + use std::os::unix::fs::PermissionsExt; + + std::fs::write(path, "#!/usr/bin/env sh\nexit 0\n").expect("stub should be written"); + let mut permissions = std::fs::metadata(path) + .expect("stub metadata should be readable") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions).expect("stub should be executable"); +} + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("app dir should have repo parent") + .to_path_buf() +} + +fn run_init(fx: &Fixture, args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_runseal")) + .current_dir(&fx.project) + .env("PATH", prepend_path(&fx.bin)) + .arg("-p") + .arg(fx.project.join("runseal.toml")) + .arg(":init") + .args(args) + .output() + .expect("runseal init should run") +} + +fn prepend_path(first: &Path) -> OsString { + let mut paths = vec![first.to_path_buf()]; + if let Some(existing) = std::env::var_os("PATH") { + paths.extend(std::env::split_paths(&existing)); + } + std::env::join_paths(paths).expect("PATH should be joinable") +} + +#[test] +fn init_installs_generated_hooks() { + let fx = fixture(); + + let output = run_init(&fx, &[]); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let pre_commit = fx.project.join(".git/hooks/pre-commit"); + let commit_msg = fx.project.join(".git/hooks/commit-msg"); + let pre_commit_text = std::fs::read_to_string(&pre_commit).expect("pre-commit should exist"); + let commit_msg_text = std::fs::read_to_string(&commit_msg).expect("commit-msg should exist"); + assert!(pre_commit_text.contains("runseal init hook")); + assert!(pre_commit_text.contains(".runseal/wrappers/init.seal")); + assert!(commit_msg_text.contains("runseal init hook")); +} + +#[test] +fn force_backs_up_hook() { + let fx = fixture(); + let pre_commit = fx.project.join(".git/hooks/pre-commit"); + std::fs::write(&pre_commit, "#!/usr/bin/env sh\necho custom\n") + .expect("custom hook should be written"); + + let rejected = run_init(&fx, &[]); + + assert!(!rejected.status.success()); + assert!(String::from_utf8_lossy(&rejected.stderr).contains("rerun with --force")); + + let forced = run_init(&fx, &["--force"]); + + assert!( + forced.status.success(), + "stderr: {}", + String::from_utf8_lossy(&forced.stderr) + ); + assert!(fx.project.join(".git/hooks/pre-commit.bak").is_file()); + let pre_commit_text = std::fs::read_to_string(&pre_commit).expect("pre-commit should exist"); + assert!(pre_commit_text.contains("runseal init hook")); +} diff --git a/app/tests/operator/repo.rs b/app/tests/operator/repo.rs new file mode 100644 index 0000000..df01d47 --- /dev/null +++ b/app/tests/operator/repo.rs @@ -0,0 +1,457 @@ +#![cfg(unix)] + +use std::{ + ffi::OsString, + path::{Path, PathBuf}, + process::Command, +}; + +use tempfile::TempDir; + +struct Fixture { + _temp: TempDir, + project: PathBuf, + bin: PathBuf, + state: PathBuf, +} + +fn fixture() -> Option<Fixture> { + let temp = TempDir::new().expect("temp dir should be created"); + let project = temp.path().join("project"); + let bin = temp.path().join("bin"); + let state = temp.path().join("state"); + std::fs::create_dir_all(&project).expect("project should be created"); + std::fs::create_dir_all(&bin).expect("stub bin dir should be created"); + std::fs::create_dir_all(&state).expect("stub state dir should be created"); + write_stub( + &bin.join("git"), + r#"#!/usr/bin/env sh +set -eu +case "${1:-}" in + --version) + ;; + branch) + [ "${2:-}" = "--show-current" ] || exit 9 + printf '%s\n' "${RUNSEAL_TEST_BRANCH:-feat/seal}" + ;; + *) + printf 'git %s\n' "$*" >> "${RUNSEAL_TEST_LOG:?}" + ;; +esac +"#, + ); + write_stub( + &bin.join("gh"), + r#"#!/usr/bin/env sh +set -eu + +log() { + printf 'gh %s\n' "$*" >> "${RUNSEAL_TEST_LOG:?}" +} + +case "${1:-}" in + --version) + ;; + auth) + [ "${2:-}" = status ] || exit 9 + ;; + workflow) + log "$@" + [ "${2:-}" = run ] || exit 9 + printf '%s\n' "${RUNSEAL_TEST_WORKFLOW_OUTPUT:-}" + ;; + run) + log "$@" + case "${2:-}" in + list) + printf '%s\n' "${RUNSEAL_TEST_RUN_LIST:-[]}" + ;; + watch) + ;; + *) + exit 9 + ;; + esac + ;; + pr) + log "$@" + case "${2:-}" in + list) + count_file="${RUNSEAL_TEST_STATE:?}/pr_list_count" + count=0 + if [ -f "$count_file" ]; then + count=$(cat "$count_file") + fi + next=$((count + 1)) + printf '%s\n' "$next" > "$count_file" + if [ "$count" -eq 0 ] && [ "${RUNSEAL_TEST_PR_LIST_FIRST+x}" ]; then + printf '%s\n' "$RUNSEAL_TEST_PR_LIST_FIRST" + elif [ "$count" -gt 0 ] && [ "${RUNSEAL_TEST_PR_LIST_NEXT+x}" ]; then + printf '%s\n' "$RUNSEAL_TEST_PR_LIST_NEXT" + elif [ "${RUNSEAL_TEST_PR_LIST+x}" ]; then + printf '%s\n' "$RUNSEAL_TEST_PR_LIST" + else + printf '%s\n' '[{"number":42,"title":"Seal","state":"OPEN","url":"https://example.test/pull/42","isDraft":false}]' + fi + ;; + create|ready|checks|merge) + ;; + *) + exit 9 + ;; + esac + ;; + *) + log "$@" + ;; +esac +"#, + ); + Some(Fixture { + _temp: temp, + project, + bin, + state, + }) +} + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("app dir should have repo parent") + .to_path_buf() +} + +fn write_stub(path: &Path, content: &str) { + use std::os::unix::fs::PermissionsExt; + + std::fs::write(path, content).expect("stub should be written"); + let mut permissions = std::fs::metadata(path) + .expect("stub metadata should be readable") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions).expect("stub should be executable"); +} + +fn run_active_wrapper(fx: &Fixture, name: &str, args: &[&str]) -> std::process::Output { + run_wrapper_env(fx, name, args, &[]) +} + +fn run_wrapper_env( + fx: &Fixture, + name: &str, + args: &[&str], + envs: &[(&str, &str)], +) -> std::process::Output { + let log = fx.project.join("commands.log"); + let path = prepend_path(&fx.bin); + Command::new(env!("CARGO_BIN_EXE_runseal")) + .current_dir(&fx.project) + .env("PATH", path) + .env("RUNSEAL_TEST_LOG", &log) + .env("RUNSEAL_TEST_STATE", &fx.state) + .arg("-p") + .arg(repo_root().join("runseal.toml")) + .arg(format!(":{name}")) + .args(args) + .envs(envs.iter().copied()) + .output() + .expect("active operator wrapper should run") +} + +fn prepend_path(first: &Path) -> OsString { + let mut paths = vec![first.to_path_buf()]; + if let Some(runseal_dir) = Path::new(env!("CARGO_BIN_EXE_runseal")).parent() { + paths.push(runseal_dir.to_path_buf()); + } + if let Some(existing) = std::env::var_os("PATH") { + paths.extend(std::env::split_paths(&existing)); + } + std::env::join_paths(paths).expect("PATH should be joinable") +} + +fn stdout(output: &std::process::Output) -> String { + String::from_utf8(output.stdout.clone()).expect("stdout should be UTF-8") +} + +fn stderr(output: &std::process::Output) -> String { + String::from_utf8(output.stderr.clone()).expect("stderr should be UTF-8") +} + +fn command_log(fx: &Fixture) -> String { + std::fs::read_to_string(fx.project.join("commands.log")).unwrap_or_default() +} + +#[test] +fn pr_help_option() { + let Some(fx) = fixture() else { + return; + }; + + let output = run_active_wrapper(&fx, "pr", &["--help"]); + + assert!(output.status.success()); + let stdout = stdout(&output); + assert!(stdout.contains("Usage: runseal :pr [options]")); + assert!(stdout.contains("--dry-run")); +} + +#[test] +fn pr_dry_run_matches() { + let Some(fx) = fixture() else { + return; + }; + + let output = run_active_wrapper(&fx, "pr", &["--dry-run"]); + + assert!(output.status.success()); + assert_eq!( + stdout(&output), + "\ +branch: feat/seal +base: main +push: True +pr: create if missing, otherwise reuse existing +draft: False +ready: True +watch: True +squash_merge: True +" + ); +} + +#[test] +fn pr_rejects_draft_merge() { + let Some(fx) = fixture() else { + return; + }; + + let output = run_active_wrapper(&fx, "pr", &["--draft", "--dry-run"]); + + assert!(!output.status.success()); + assert!(stderr(&output).contains("pr: --draft requires --no-merge")); +} + +#[test] +fn pr_rejects_base_branch() { + let Some(fx) = fixture() else { + return; + }; + + let output = run_wrapper_env( + &fx, + "pr", + &["--dry-run"], + &[("RUNSEAL_TEST_BRANCH", "main")], + ); + + assert!(!output.status.success()); + assert!(stderr(&output).contains("pr: refusing to open a PR from base branch: main")); +} + +#[test] +fn pr_reuses_draft() { + let Some(fx) = fixture() else { + return; + }; + + let output = run_wrapper_env( + &fx, + "pr", + &["--no-push", "--no-watch", "--no-merge"], + &[( + "RUNSEAL_TEST_PR_LIST", + r#"[{"number":42,"title":"Seal","state":"OPEN","url":"https://example.test/pull/42","isDraft":true}]"#, + )], + ); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert_eq!( + stdout(&output), + "\ +found PR #42: https://example.test/pull/42 +marked PR #42 ready +" + ); + assert_eq!( + command_log(&fx), + "\ +gh pr list --head feat/seal --json number,title,state,url,isDraft +gh pr ready 42 +" + ); +} + +#[test] +fn pr_creates_and_merges() { + let Some(fx) = fixture() else { + return; + }; + + let output = run_wrapper_env( + &fx, + "pr", + &[ + "--title", + "Seal migration", + "--body-file", + "body.md", + "--base", + "develop", + ], + &[ + ("RUNSEAL_TEST_PR_LIST_FIRST", "[]"), + ( + "RUNSEAL_TEST_PR_LIST_NEXT", + r#"[{"number":77,"title":"Seal migration","state":"OPEN","url":"https://example.test/pull/77","isDraft":false}]"#, + ), + ], + ); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert_eq!( + stdout(&output), + "\ +created PR #77: https://example.test/pull/77 +squash-merged PR #77 +" + ); + assert_eq!( + command_log(&fx), + "\ +git push -u origin feat/seal +gh pr list --head feat/seal --json number,title,state,url,isDraft +gh pr create --base develop --head feat/seal --title Seal migration --body-file body.md +gh pr list --head feat/seal --json number,title,state,url,isDraft +gh pr checks 77 +gh pr checks 77 --watch --interval 10 +gh pr merge 77 --squash --delete-branch +" + ); +} + +#[test] +fn release_help_without_args() { + let Some(fx) = fixture() else { + return; + }; + + let output = run_active_wrapper(&fx, "release", &[]); + + assert!(output.status.success()); + let stdout = stdout(&output); + assert!(stdout.contains("Usage: runseal :release --channel=stable|beta [options]")); + assert!(stdout.contains("--watch")); +} + +#[test] +fn release_dry_run_matches() { + let Some(fx) = fixture() else { + return; + }; + + let output = run_active_wrapper( + &fx, + "release", + &[ + "--channel", + "beta", + "--ref", + "feature/ref", + "--version", + "v1.2.3-beta.4", + "--dry-run", + ], + ); + + assert!(output.status.success()); + assert_eq!( + stdout(&output), + "gh workflow run release-beta.yml --ref feature/ref -f ref=feature/ref -f version_override=v1.2.3-beta.4\n" + ); +} + +#[test] +fn release_requires_channel() { + let Some(fx) = fixture() else { + return; + }; + + let output = run_active_wrapper(&fx, "release", &["--dry-run"]); + + assert!(!output.status.success()); + assert!(stderr(&output).contains("release: --channel is required")); +} + +#[test] +fn release_rejects_invalid_channel() { + let Some(fx) = fixture() else { + return; + }; + + let output = run_active_wrapper(&fx, "release", &["--channel", "nightly", "--dry-run"]); + + assert_eq!(output.status.code(), Some(2)); + assert!(stderr(&output).contains("invalid choice")); +} + +#[test] +fn release_watches_trigger_url() { + let Some(fx) = fixture() else { + return; + }; + + let output = run_wrapper_env( + &fx, + "release", + &["--channel", "stable", "--watch"], + &[( + "RUNSEAL_TEST_WORKFLOW_OUTPUT", + "https://github.com/acme/runseal/actions/runs/12345", + )], + ); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert_eq!( + stdout(&output), + "\ +https://github.com/acme/runseal/actions/runs/12345 +triggered release-stable.yml for ref main +" + ); + assert_eq!( + command_log(&fx), + "\ +gh workflow run release-stable.yml --ref main -f ref=main -f version_override= +gh run watch 12345 --interval 10 +" + ); +} + +#[test] +fn release_uses_latest_run() { + let Some(fx) = fixture() else { + return; + }; + + let output = run_wrapper_env( + &fx, + "release", + &["--channel", "beta", "--ref", "feature/ref", "--watch"], + &[("RUNSEAL_TEST_RUN_LIST", r#"[{"databaseId":67890}]"#)], + ); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert_eq!( + stdout(&output), + "triggered release-beta.yml for ref feature/ref\n" + ); + assert_eq!( + command_log(&fx), + "\ +gh workflow run release-beta.yml --ref feature/ref -f ref=feature/ref -f version_override= +gh run list --workflow release-beta.yml --branch feature/ref --event workflow_dispatch --limit 1 --json databaseId +gh run watch 67890 --interval 10 +" + ); +} diff --git a/app/tests/transpile.rs b/app/tests/transpile.rs index fc901e6..f5b19d2 100644 --- a/app/tests/transpile.rs +++ b/app/tests/transpile.rs @@ -1,10 +1,9 @@ -use std::{ - io::Write, - path::Path, - process::{Command, Stdio}, -}; +use std::process::Command; use tempfile::TempDir; +#[path = "transpile_support/syntax.rs"] +mod syntax; + fn bin() -> Command { Command::new(env!("CARGO_BIN_EXE_runseal")) } @@ -199,7 +198,7 @@ fn powershell_to_bash() { let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); assert!(stdout.contains("release_run() {")); assert!(stdout.contains("gh workflow run release.yml --ref main -f \"channel=$channel\"")); - assert_bash_syntax(&stdout); + syntax::assert_bash(&stdout); } #[test] @@ -239,8 +238,8 @@ fn capture_to_targets() { let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); assert!(bash.contains("raw=$(gh run list --json databaseId)")); assert!(powershell.contains("$raw = & 'gh' 'run' 'list' '--json' 'databaseId'")); - assert_bash_syntax(&bash); - assert_pwsh_syntax(&powershell); + syntax::assert_bash(&bash); + syntax::assert_pwsh(&powershell); } #[test] @@ -251,7 +250,8 @@ fn string_trim_helper_roundtrip() { assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("string_trim")); + assert!(stdout.contains("tool_capture")); + assert!(stdout.contains("string")); } let fx = fixture(powershell_trim_source()); @@ -259,7 +259,8 @@ fn string_trim_helper_roundtrip() { assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("string_trim")); + assert!(stdout.contains("tool_capture")); + assert!(stdout.contains("string")); } #[test] @@ -273,11 +274,10 @@ fn string_trim_emits_native() { assert!(powershell.status.success()); let bash = String::from_utf8(bash.stdout).expect("stdout should be UTF-8"); let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); - assert!(bash.contains("command -v sed")); - assert!(bash.contains("trimmed=$(printf '%s' \"$raw\" | sed")); - assert!(powershell.contains("$trimmed = ($raw).Trim()")); - assert_bash_syntax(&bash); - assert_pwsh_syntax(&powershell); + assert!(bash.contains("trimmed=$(runseal @tool string trim \"$raw\")")); + assert!(powershell.contains("$trimmed = & 'runseal' '@tool' 'string' 'trim' $raw")); + syntax::assert_bash(&bash); + syntax::assert_pwsh(&powershell); } #[test] @@ -288,7 +288,8 @@ fn json_get_helper_roundtrip() { assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("json_get")); + assert!(stdout.contains("tool_capture")); + assert!(stdout.contains("json")); assert!(stdout.contains("databaseId")); } @@ -297,7 +298,8 @@ fn json_get_helper_roundtrip() { assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("json_get")); + assert!(stdout.contains("tool_capture")); + assert!(stdout.contains("json")); assert!(stdout.contains("databaseId")); } @@ -312,11 +314,12 @@ fn json_get_emits_native() { assert!(powershell.status.success()); let bash = String::from_utf8(bash.stdout).expect("stdout should be UTF-8"); let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); - assert!(bash.contains("command -v jq")); - assert!(bash.contains("run_id=$(printf '%s' \"$raw\" | jq -r '.[0].databaseId')")); - assert!(powershell.contains("$run_id = [string](($raw | ConvertFrom-Json)[0].databaseId)")); - assert_bash_syntax(&bash); - assert_pwsh_syntax(&powershell); + assert!(bash.contains("run_id=$(runseal @tool json get \"$raw\" '.[0].databaseId')")); + assert!( + powershell.contains("$run_id = & 'runseal' '@tool' 'json' 'get' $raw '.[0].databaseId'") + ); + syntax::assert_bash(&bash); + syntax::assert_pwsh(&powershell); } #[test] @@ -330,7 +333,7 @@ fn bash_syntax_valid() { assert!(stdout.contains("set -euo pipefail")); assert!(stdout.contains("gh workflow run release.yml --ref main -f \"channel=$channel\"")); assert!(stdout.contains("case \"$channel\" in")); - assert_bash_syntax(&stdout); + syntax::assert_bash(&stdout); } #[test] @@ -346,7 +349,7 @@ fn powershell_readable() { assert!(stdout.contains("& 'gh' 'workflow' 'run' 'release.yml' '--ref' 'main' '-f'")); assert!(stdout.contains("('channel=' + $channel)")); assert!(stdout.contains("switch ($channel)")); - assert_pwsh_syntax(&stdout); + syntax::assert_pwsh(&stdout); } #[test] @@ -422,79 +425,3 @@ fn metacharacters_fail() { ); } } - -fn assert_bash_syntax(source: &str) { - if !tool_exists("bash") || !bash_accepts_stdin() { - return; - } - let mut child = Command::new("bash") - .arg("-n") - .arg("-s") - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .expect("bash should run"); - child - .stdin - .as_mut() - .expect("bash stdin should be piped") - .write_all(source.as_bytes()) - .expect("bash source should be written"); - let output = child.wait_with_output().expect("bash should finish"); - assert!( - output.status.success(), - "bash syntax should pass: stdout={} stderr={}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -fn bash_accepts_stdin() -> bool { - let output = Command::new("bash") - .arg("-n") - .arg("-s") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .output(); - output.is_ok_and(|output| output.status.success()) -} - -fn assert_pwsh_syntax(source: &str) { - if !tool_exists("pwsh") { - return; - } - let output = Command::new("pwsh") - .arg("-NoProfile") - .arg("-NonInteractive") - .arg("-Command") - .arg("[scriptblock]::Create($args[0]) | Out-Null") - .arg(source) - .output() - .expect("pwsh should run"); - assert!( - output.status.success(), - "PowerShell syntax should pass: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -fn tool_exists(name: &str) -> bool { - let path = std::env::var_os("PATH").unwrap_or_default(); - std::env::split_paths(&path).any(|dir| executable_exists(&dir.join(name))) -} - -#[cfg(unix)] -fn executable_exists(path: &Path) -> bool { - use std::os::unix::fs::PermissionsExt; - path.is_file() - && path - .metadata() - .is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0) -} - -#[cfg(windows)] -fn executable_exists(path: &Path) -> bool { - path.is_file() -} diff --git a/app/tests/transpile_cases.rs b/app/tests/transpile_cases.rs new file mode 100644 index 0000000..b3424b2 --- /dev/null +++ b/app/tests/transpile_cases.rs @@ -0,0 +1,10 @@ +#[path = "transpile_cases/argv.rs"] +mod argv; +#[path = "transpile_cases/regex.rs"] +mod regex; +#[path = "transpile_cases/release.rs"] +mod release; +#[path = "transpile_cases/retry.rs"] +mod retry; +#[path = "transpile_support/syntax.rs"] +mod syntax; diff --git a/app/tests/transpile_argv.rs b/app/tests/transpile_cases/argv.rs similarity index 55% rename from app/tests/transpile_argv.rs rename to app/tests/transpile_cases/argv.rs index 4744c0f..bfa759c 100644 --- a/app/tests/transpile_argv.rs +++ b/app/tests/transpile_cases/argv.rs @@ -1,8 +1,4 @@ -use std::{ - io::Write, - path::Path, - process::{Command, Stdio}, -}; +use std::process::Command; use tempfile::TempDir; @@ -95,84 +91,6 @@ fn argv_parse_emits_targets() { assert!(bash.contains("dry_run=true")); assert!(powershell.contains("$body_file = $__seal_arg.Substring(12)")); assert!(powershell.contains("$dry_run = $true")); - assert_bash_syntax(&bash); - assert_pwsh_syntax(&powershell); -} - -fn assert_bash_syntax(source: &str) { - if !tool_exists("bash") || !bash_accepts_stdin() { - return; - } - let mut child = Command::new("bash") - .arg("-n") - .arg("-s") - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .expect("bash should run"); - child - .stdin - .as_mut() - .expect("bash stdin should be piped") - .write_all(source.as_bytes()) - .expect("bash source should be written"); - let output = child.wait_with_output().expect("bash should finish"); - assert!( - output.status.success(), - "bash syntax should pass: stdout={} stderr={}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -fn bash_accepts_stdin() -> bool { - let output = Command::new("bash") - .arg("-n") - .arg("-s") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .output(); - output.is_ok_and(|output| output.status.success()) -} - -fn assert_pwsh_syntax(source: &str) { - if !tool_exists("pwsh") { - return; - } - let output = Command::new("pwsh") - .arg("-NoProfile") - .arg("-NonInteractive") - .arg("-Command") - .arg("[scriptblock]::Create($args[0]) | Out-Null") - .arg(source) - .output() - .expect("pwsh should run"); - assert!( - output.status.success(), - "PowerShell syntax should pass: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -fn tool_exists(name: &str) -> bool { - let path = std::env::var_os("PATH").unwrap_or_default(); - std::env::split_paths(&path).any(|dir| executable_exists(&dir.join(name))) -} - -#[cfg(unix)] -fn executable_exists(path: &Path) -> bool { - use std::os::unix::fs::PermissionsExt; - path.is_file() - && path - .metadata() - .is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0) -} - -#[cfg(windows)] -fn executable_exists(path: &Path) -> bool { - path.with_extension("exe").is_file() - || path.with_extension("cmd").is_file() - || path.with_extension("bat").is_file() + super::syntax::assert_bash(&bash); + super::syntax::assert_pwsh(&powershell); } diff --git a/app/tests/transpile_cases/regex.rs b/app/tests/transpile_cases/regex.rs new file mode 100644 index 0000000..2fd70a2 --- /dev/null +++ b/app/tests/transpile_cases/regex.rs @@ -0,0 +1,103 @@ +use std::process::Command; + +use tempfile::TempDir; + +struct Fixture { + _temp: TempDir, + dir: std::path::PathBuf, + source: std::path::PathBuf, +} + +fn fixture(source: &str) -> Fixture { + let temp = TempDir::new().expect("temp dir should be created"); + let dir = temp.path().join("project-without-profile"); + std::fs::create_dir_all(&dir).expect("project dir should be created"); + let source_path = dir.join("operator.seal"); + std::fs::write(&source_path, source).expect("source should be written"); + Fixture { + _temp: temp, + dir, + source: source_path, + } +} + +fn run_transpile(fx: &Fixture, input_lang: &str, output_lang: &str) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_runseal")) + .current_dir(&fx.dir) + .arg("@transpile") + .arg("--input-lang") + .arg(input_lang) + .arg("--output-lang") + .arg(output_lang) + .arg(&fx.source) + .output() + .expect("runseal should run") +} + +fn regex_source() -> &'static str { + r#" +trigger_output='https://github.com/PerishCode/runseal/actions/runs/12345' +run_id=$(seal regex capture "$trigger_output" '/actions/runs/([0-9]+)' 1) +if empty "$run_id"; then + run_id=$(latest_run_id "$workflow" "$ref") +fi +print "$run_id" +"# +} + +fn powershell_regex_source() -> &'static str { + r#" +$trigger_output = 'https://github.com/PerishCode/runseal/actions/runs/12345' +$run_id = seal regex capture $trigger_output '/actions/runs/([0-9]+)' '1' +if ([string]::IsNullOrEmpty($run_id)) { + $run_id = & 'latest_run_id' $workflow $ref +} +Write-Output $run_id +"# +} + +#[test] +fn regex_capture_roundtrip() { + for input_lang in ["seal", "bash"] { + let fx = fixture(regex_source()); + let output = run_transpile(&fx, input_lang, "sealir"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); + assert!(stdout.contains("tool_capture")); + assert!(stdout.contains("regex")); + assert!(stdout.contains("/actions/runs/([0-9]+)")); + } + + let fx = fixture(powershell_regex_source()); + let output = run_transpile(&fx, "powershell", "sealir"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); + assert!(stdout.contains("tool_capture")); + assert!(stdout.contains("regex")); + assert!(stdout.contains("\"1\"")); +} + +#[test] +fn regex_capture_emits_targets() { + let fx = fixture(regex_source()); + + let bash = run_transpile(&fx, "seal", "bash"); + let powershell = run_transpile(&fx, "seal", "powershell"); + + assert!(bash.status.success()); + assert!(powershell.status.success()); + let bash = String::from_utf8(bash.stdout).expect("stdout should be UTF-8"); + let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); + assert!(bash.contains( + "run_id=$(runseal @tool regex capture \"$trigger_output\" '/actions/runs/([0-9]+)' 1)" + )); + assert!( + powershell.contains( + "$run_id = & 'runseal' '@tool' 'regex' 'capture' $trigger_output '/actions/runs/([0-9]+)' '1'" + ) + ); + super::syntax::assert_bash(&bash); + super::syntax::assert_pwsh(&powershell); +} diff --git a/app/tests/transpile_release.rs b/app/tests/transpile_cases/release.rs similarity index 54% rename from app/tests/transpile_release.rs rename to app/tests/transpile_cases/release.rs index 7c07e8a..32e8b02 100644 --- a/app/tests/transpile_release.rs +++ b/app/tests/transpile_cases/release.rs @@ -1,8 +1,4 @@ -use std::{ - io::Write, - path::Path, - process::{Command, Stdio}, -}; +use std::process::Command; use tempfile::TempDir; @@ -91,9 +87,10 @@ fn release_fixture_roundtrip() { assert!(sealir.status.success()); let sealir = String::from_utf8(sealir.stdout).expect("stdout should be UTF-8"); assert!(sealir.contains("argv_parse")); - assert!(sealir.contains("regex_capture")); + assert!(sealir.contains("tool_capture")); + assert!(sealir.contains("regex")); assert!(sealir.contains("json_not_empty")); - assert!(sealir.contains("int_add")); + assert!(sealir.contains("int")); assert!(sealir.contains("release-stable.yml")); } @@ -108,91 +105,14 @@ fn release_fixture_emits_targets() { assert!(powershell.status.success()); let bash = String::from_utf8(bash.stdout).expect("stdout should be UTF-8"); let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); - assert!(bash.contains("command -v sed")); - assert!(bash.contains("command -v jq")); assert!(bash.contains("trigger_output=$(gh workflow run \"$workflow\"")); - assert!(bash.contains("run_id=$(printf '%s' \"$trigger_output\" | sed -nE")); + assert!(bash.contains( + "run_id=$(runseal @tool regex capture \"$trigger_output\" '/actions/runs/([0-9]+)' 1)" + )); + assert!(bash.contains("attempt=$(runseal @tool int add \"$attempt\" 1)")); assert!(powershell.contains("$trigger_output = & 'gh' 'workflow' 'run' $workflow")); - assert!(powershell.contains("[regex]::Match($trigger_output")); - assert!(powershell.contains("ConvertFrom-Json")); - assert_bash_syntax(&bash); - assert_pwsh_syntax(&powershell); -} - -fn assert_bash_syntax(source: &str) { - if !tool_exists("bash") || !bash_accepts_stdin() { - return; - } - let mut child = Command::new("bash") - .arg("-n") - .arg("-s") - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .expect("bash should run"); - child - .stdin - .as_mut() - .expect("bash stdin should be piped") - .write_all(source.as_bytes()) - .expect("bash source should be written"); - let output = child.wait_with_output().expect("bash should finish"); - assert!( - output.status.success(), - "bash syntax should pass: stdout={} stderr={}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -fn bash_accepts_stdin() -> bool { - let output = Command::new("bash") - .arg("-n") - .arg("-s") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .output(); - output.is_ok_and(|output| output.status.success()) -} - -fn assert_pwsh_syntax(source: &str) { - if !tool_exists("pwsh") { - return; - } - let output = Command::new("pwsh") - .arg("-NoProfile") - .arg("-NonInteractive") - .arg("-Command") - .arg("[scriptblock]::Create($args[0]) | Out-Null") - .arg(source) - .output() - .expect("pwsh should run"); - assert!( - output.status.success(), - "PowerShell syntax should pass: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -fn tool_exists(name: &str) -> bool { - let path = std::env::var_os("PATH").unwrap_or_default(); - std::env::split_paths(&path).any(|dir| executable_exists(&dir.join(name))) -} - -#[cfg(unix)] -fn executable_exists(path: &Path) -> bool { - use std::os::unix::fs::PermissionsExt; - path.is_file() - && path - .metadata() - .is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0) -} - -#[cfg(windows)] -fn executable_exists(path: &Path) -> bool { - path.with_extension("exe").is_file() - || path.with_extension("cmd").is_file() - || path.with_extension("bat").is_file() + assert!(powershell.contains("& 'runseal' '@tool' 'regex' 'capture' $trigger_output")); + assert!(powershell.contains("& 'runseal' '@tool' 'json' 'empty' $raw")); + super::syntax::assert_bash(&bash); + super::syntax::assert_pwsh(&powershell); } diff --git a/app/tests/transpile_retry.rs b/app/tests/transpile_cases/retry.rs similarity index 54% rename from app/tests/transpile_retry.rs rename to app/tests/transpile_cases/retry.rs index 05a36b7..a8261dc 100644 --- a/app/tests/transpile_retry.rs +++ b/app/tests/transpile_cases/retry.rs @@ -1,8 +1,4 @@ -use std::{ - io::Write, - path::Path, - process::{Command, Stdio}, -}; +use std::process::Command; use tempfile::TempDir; @@ -82,7 +78,8 @@ fn retry_loop_roundtrip() { let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); assert!(stdout.contains("\"type\": \"while\"")); assert!(stdout.contains("json_not_empty")); - assert!(stdout.contains("int_add")); + assert!(stdout.contains("tool_capture")); + assert!(stdout.contains("int")); assert!(stdout.contains("\"type\": \"break\"")); } @@ -107,91 +104,12 @@ fn retry_loop_emits_targets() { assert!(powershell.status.success()); let bash = String::from_utf8(bash.stdout).expect("stdout should be UTF-8"); let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); - assert!(bash.contains("command -v jq")); assert!(bash.contains("while [ $attempt -lt 6 ]; do")); - assert!(bash.contains("attempt=$(($attempt + 1))")); + assert!(bash.contains("attempt=$(runseal @tool int add \"$attempt\" 1)")); assert!(bash.contains("break")); assert!(powershell.contains("while ([int]$attempt -lt '6') {")); - assert!(powershell.contains("(($raw | ConvertFrom-Json).Count -gt 0)")); - assert!(powershell.contains("$attempt = [int]$attempt + '1'")); - assert_bash_syntax(&bash); - assert_pwsh_syntax(&powershell); -} - -fn assert_bash_syntax(source: &str) { - if !tool_exists("bash") || !bash_accepts_stdin() { - return; - } - let mut child = Command::new("bash") - .arg("-n") - .arg("-s") - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .expect("bash should run"); - child - .stdin - .as_mut() - .expect("bash stdin should be piped") - .write_all(source.as_bytes()) - .expect("bash source should be written"); - let output = child.wait_with_output().expect("bash should finish"); - assert!( - output.status.success(), - "bash syntax should pass: stdout={} stderr={}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -fn bash_accepts_stdin() -> bool { - let output = Command::new("bash") - .arg("-n") - .arg("-s") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .output(); - output.is_ok_and(|output| output.status.success()) -} - -fn assert_pwsh_syntax(source: &str) { - if !tool_exists("pwsh") { - return; - } - let output = Command::new("pwsh") - .arg("-NoProfile") - .arg("-NonInteractive") - .arg("-Command") - .arg("[scriptblock]::Create($args[0]) | Out-Null") - .arg(source) - .output() - .expect("pwsh should run"); - assert!( - output.status.success(), - "PowerShell syntax should pass: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -fn tool_exists(name: &str) -> bool { - let path = std::env::var_os("PATH").unwrap_or_default(); - std::env::split_paths(&path).any(|dir| executable_exists(&dir.join(name))) -} - -#[cfg(unix)] -fn executable_exists(path: &Path) -> bool { - use std::os::unix::fs::PermissionsExt; - path.is_file() - && path - .metadata() - .is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0) -} - -#[cfg(windows)] -fn executable_exists(path: &Path) -> bool { - path.with_extension("exe").is_file() - || path.with_extension("cmd").is_file() - || path.with_extension("bat").is_file() + assert!(powershell.contains("& 'runseal' '@tool' 'json' 'empty' $raw")); + assert!(powershell.contains("$attempt = & 'runseal' '@tool' 'int' 'add' $attempt '1'")); + super::syntax::assert_bash(&bash); + super::syntax::assert_pwsh(&powershell); } diff --git a/app/tests/transpile_regex.rs b/app/tests/transpile_regex.rs deleted file mode 100644 index 84e3731..0000000 --- a/app/tests/transpile_regex.rs +++ /dev/null @@ -1,180 +0,0 @@ -use std::{ - io::Write, - path::Path, - process::{Command, Stdio}, -}; - -use tempfile::TempDir; - -struct Fixture { - _temp: TempDir, - dir: std::path::PathBuf, - source: std::path::PathBuf, -} - -fn fixture(source: &str) -> Fixture { - let temp = TempDir::new().expect("temp dir should be created"); - let dir = temp.path().join("project-without-profile"); - std::fs::create_dir_all(&dir).expect("project dir should be created"); - let source_path = dir.join("operator.seal"); - std::fs::write(&source_path, source).expect("source should be written"); - Fixture { - _temp: temp, - dir, - source: source_path, - } -} - -fn run_transpile(fx: &Fixture, input_lang: &str, output_lang: &str) -> std::process::Output { - Command::new(env!("CARGO_BIN_EXE_runseal")) - .current_dir(&fx.dir) - .arg("@transpile") - .arg("--input-lang") - .arg(input_lang) - .arg("--output-lang") - .arg(output_lang) - .arg(&fx.source) - .output() - .expect("runseal should run") -} - -fn regex_source() -> &'static str { - r#" -trigger_output='https://github.com/PerishCode/runseal/actions/runs/12345' -run_id=$(seal regex capture "$trigger_output" '/actions/runs/([0-9]+)' 1) -if empty "$run_id"; then - run_id=$(latest_run_id "$workflow" "$ref") -fi -print "$run_id" -"# -} - -fn powershell_regex_source() -> &'static str { - r#" -$trigger_output = 'https://github.com/PerishCode/runseal/actions/runs/12345' -$run_id = seal regex capture $trigger_output '/actions/runs/([0-9]+)' '1' -if ([string]::IsNullOrEmpty($run_id)) { - $run_id = & 'latest_run_id' $workflow $ref -} -Write-Output $run_id -"# -} - -#[test] -fn regex_capture_roundtrip() { - for input_lang in ["seal", "bash"] { - let fx = fixture(regex_source()); - let output = run_transpile(&fx, input_lang, "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("regex_capture")); - assert!(stdout.contains("/actions/runs/([0-9]+)")); - } - - let fx = fixture(powershell_regex_source()); - let output = run_transpile(&fx, "powershell", "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("regex_capture")); - assert!(stdout.contains("\"group\": 1")); -} - -#[test] -fn regex_capture_emits_targets() { - let fx = fixture(regex_source()); - - let bash = run_transpile(&fx, "seal", "bash"); - let powershell = run_transpile(&fx, "seal", "powershell"); - - assert!(bash.status.success()); - assert!(powershell.status.success()); - let bash = String::from_utf8(bash.stdout).expect("stdout should be UTF-8"); - let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); - assert!(bash.contains("command -v sed")); - assert!(bash.contains("sed -nE 's#.*")); - assert!(bash.contains("/actions/runs/([0-9]+).*#\\1#p'")); - assert!(powershell.contains("[regex]::Match($trigger_output, '/actions/runs/([0-9]+)')")); - assert!(powershell.contains("$run_id = if ($__seal_match_run_id.Success")); - assert_bash_syntax(&bash); - assert_pwsh_syntax(&powershell); -} - -fn assert_bash_syntax(source: &str) { - if !tool_exists("bash") || !bash_accepts_stdin() { - return; - } - let mut child = Command::new("bash") - .arg("-n") - .arg("-s") - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .expect("bash should run"); - child - .stdin - .as_mut() - .expect("bash stdin should be piped") - .write_all(source.as_bytes()) - .expect("bash source should be written"); - let output = child.wait_with_output().expect("bash should finish"); - assert!( - output.status.success(), - "bash syntax should pass: stdout={} stderr={}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -fn bash_accepts_stdin() -> bool { - let output = Command::new("bash") - .arg("-n") - .arg("-s") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .output(); - output.is_ok_and(|output| output.status.success()) -} - -fn assert_pwsh_syntax(source: &str) { - if !tool_exists("pwsh") { - return; - } - let output = Command::new("pwsh") - .arg("-NoProfile") - .arg("-NonInteractive") - .arg("-Command") - .arg("[scriptblock]::Create($args[0]) | Out-Null") - .arg(source) - .output() - .expect("pwsh should run"); - assert!( - output.status.success(), - "PowerShell syntax should pass: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -fn tool_exists(name: &str) -> bool { - let path = std::env::var_os("PATH").unwrap_or_default(); - std::env::split_paths(&path).any(|dir| executable_exists(&dir.join(name))) -} - -#[cfg(unix)] -fn executable_exists(path: &Path) -> bool { - use std::os::unix::fs::PermissionsExt; - path.is_file() - && path - .metadata() - .is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0) -} - -#[cfg(windows)] -fn executable_exists(path: &Path) -> bool { - path.with_extension("exe").is_file() - || path.with_extension("cmd").is_file() - || path.with_extension("bat").is_file() -} diff --git a/app/tests/transpile_support/syntax.rs b/app/tests/transpile_support/syntax.rs new file mode 100644 index 0000000..22bf792 --- /dev/null +++ b/app/tests/transpile_support/syntax.rs @@ -0,0 +1,88 @@ +use std::{ + io::Write, + process::{Command, Stdio}, +}; + +use tempfile::TempDir; + +#[path = "tool.rs"] +mod tool; + +pub fn assert_bash(source: &str) { + if !tool::exists("bash") || !bash_accepts_stdin() { + return; + } + let mut child = Command::new("bash") + .arg("-n") + .arg("-s") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .expect("bash should run"); + child + .stdin + .as_mut() + .expect("bash stdin should be piped") + .write_all(source.as_bytes()) + .expect("bash source should be written"); + let output = child.wait_with_output().expect("bash should finish"); + assert!( + output.status.success(), + "bash syntax should pass: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn bash_accepts_stdin() -> bool { + let output = Command::new("bash") + .arg("-n") + .arg("-s") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .output(); + output.is_ok_and(|output| output.status.success()) +} + +pub fn assert_pwsh(source: &str) { + if !tool::exists("pwsh") { + return; + } + let temp = TempDir::new().expect("temp dir should be created"); + let source_path = temp.path().join("source.ps1"); + let checker_path = temp.path().join("check.ps1"); + std::fs::write(&source_path, source).expect("PowerShell source should be written"); + std::fs::write( + &checker_path, + r#" +param([string]$Path) +$tokens = $null +$errors = $null +[System.Management.Automation.Language.Parser]::ParseInput( + (Get-Content -Raw -LiteralPath $Path), + [ref]$tokens, + [ref]$errors +) | Out-Null +if ($errors.Count -gt 0) { + $errors | ForEach-Object { Write-Error $_.Message } + exit 1 +} +"#, + ) + .expect("PowerShell checker should be written"); + let output = Command::new("pwsh") + .arg("-NoProfile") + .arg("-NonInteractive") + .arg("-File") + .arg(&checker_path) + .arg(&source_path) + .output() + .expect("pwsh should run"); + assert!( + output.status.success(), + "PowerShell syntax should pass: {}", + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/app/tests/transpile_support/tool.rs b/app/tests/transpile_support/tool.rs new file mode 100644 index 0000000..1b797bb --- /dev/null +++ b/app/tests/transpile_support/tool.rs @@ -0,0 +1,21 @@ +use std::path::Path; + +pub fn exists(name: &str) -> bool { + let path = std::env::var_os("PATH").unwrap_or_default(); + std::env::split_paths(&path).any(|dir| executable_exists(&dir.join(name))) +} + +#[cfg(unix)] +fn executable_exists(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + + path.is_file() + && path + .metadata() + .is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0) +} + +#[cfg(windows)] +fn executable_exists(path: &Path) -> bool { + path.is_file() +} diff --git a/flavor.json b/flavor.json deleted file mode 100644 index bfe45fe..0000000 --- a/flavor.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "scan": { - "include": [ - "app/src/**/*.rs", - "app/tests/**/*.rs" - ], - "exclude": [ - "**/target/**" - ] - }, - "overrides": [] -} diff --git a/flavor.toml b/flavor.toml new file mode 100644 index 0000000..85fcdd8 --- /dev/null +++ b/flavor.toml @@ -0,0 +1,10 @@ +[scan] +include = [ + "app/src/**/*.rs", + "app/tests/**/*.rs", +] +exclude = [ + "**/target/**", +] + +overrides = [] diff --git a/scripts/cli/__init__.py b/scripts/cli/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/scripts/cli/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/scripts/cli/cloudflare.py b/scripts/cli/cloudflare.py deleted file mode 100644 index bef130b..0000000 --- a/scripts/cli/cloudflare.py +++ /dev/null @@ -1,238 +0,0 @@ -from __future__ import annotations - -import argparse -import json - -from lib.cloudflare import ( - MANAGE_RULE_SPECS, - TOKEN_FILE, - add_rule, - api_request, - create_phase_ruleset, - ensure_local_layout, - find_phase_ruleset, - get_ruleset, - load_config, - manage_rule_definition, - masked, - resolve_zone_id, - update_rule, - write_template, -) -from lib.utils.cli import CliError, dispatch - - -def usage() -> None: - print( - """Usage: runseal :cloudflare <command> [args] - -Commands: - init create repo-local .local/secrets/cloudflare.env template - check validate repo-local credentials and probe core account APIs - manage-plan print the desired manage redirect rule shape - manage-inspect inspect current dynamic redirect ruleset for manage rules - manage-ensure-redirect create/update exact-path manage redirects (use --dry-run first) - api <method> <path> authenticated Cloudflare API call using repo-local token - [--query key=value]... optional query params - [--json <json>] optional JSON body - -Credentials: - .local/secrets/cloudflare.env -""" - ) - - -def cmd_init(args: list[str]) -> int: - if args: - raise RuntimeError("init does not accept arguments") - created = write_template() - ensure_local_layout() - if created: - print(f"created {TOKEN_FILE}") - else: - print(f"exists {TOKEN_FILE}") - return 0 - - -def cmd_check(args: list[str]) -> int: - if args: - raise RuntimeError("check does not accept arguments") - config = load_config() - zone_id = resolve_zone_id(config) - rulesets = api_request(config, "GET", f"/zones/{zone_id}/rulesets") - zones = api_request( - config, - "GET", - "/zones", - params={"account.id": config.account_id, "per_page": "50"}, - ) - account = None - account_error = False - try: - account = api_request(config, "GET", f"/accounts/{config.account_id}") - except CliError: - account_error = True - buckets = None - buckets_error = False - try: - buckets = api_request(config, "GET", f"/accounts/{config.account_id}/r2/buckets") - except CliError: - buckets_error = True - - print("cloudflare check: ok") - print(f"account id: {masked(config.account_id)}") - if account is not None: - print(f"account name: {account['result']['name']}") - elif account_error: - print("account probe: skipped (token is not authorized for account details)") - print(f"manage zone: {config.zone_name} ({zone_id})") - print(f"zone rulesets: {len(rulesets.get('result', []))}") - print("zones:") - for zone in zones.get("result", []): - print(f" - {zone['name']} ({zone['status']})") - if buckets is not None: - print("r2 buckets:") - for bucket in buckets.get("result", {}).get("buckets", []): - print(f" - {bucket['name']}") - elif buckets_error: - print("r2 bucket probe: skipped (token is not authorized for R2 bucket list)") - return 0 - - -def cmd_manage_plan(args: list[str]) -> int: - if args: - raise RuntimeError("manage-plan does not accept arguments") - config = load_config() - print("manage redirect plan") - print(f"zone: {config.zone_name}") - print(f"request host: {config.manage_host}") - print(f"redirect host: {config.manage_origin_host}") - print("phase: http_request_dynamic_redirect") - print("rules:") - for spec in MANAGE_RULE_SPECS: - rule = manage_rule_definition(config, spec) - print(json.dumps(rule, indent=2, sort_keys=True)) - return 0 - - -def cmd_manage_inspect(args: list[str]) -> int: - if args: - raise RuntimeError("manage-inspect does not accept arguments") - config = load_config() - zone_id = resolve_zone_id(config) - ruleset = find_phase_ruleset(config, zone_id, phase="http_request_dynamic_redirect") - if ruleset is None: - print("manage inspect: no http_request_dynamic_redirect zone ruleset found") - return 0 - ruleset = get_ruleset(config, zone_id, ruleset["id"]) - print(f"zone id: {zone_id}") - print(f"ruleset id: {ruleset['id']}") - print(f"ruleset name: {ruleset['name']}") - matched = [rule for rule in ruleset.get("rules", []) if rule.get("ref") in {spec.ref for spec in MANAGE_RULE_SPECS}] - if not matched: - print("manage inspect: no manage redirect rules found") - return 0 - print("manage rules:") - print(json.dumps(matched, indent=2, sort_keys=True)) - return 0 - - -def cmd_manage_ensure_redirect(args: list[str]) -> int: - parser = argparse.ArgumentParser(prog="runseal :cloudflare manage-ensure-redirect", add_help=False) - parser.add_argument("--dry-run", action="store_true") - parsed = parser.parse_args(args) - - config = load_config() - zone_id = resolve_zone_id(config) - planned_rules = [manage_rule_definition(config, spec) for spec in MANAGE_RULE_SPECS] - - if parsed.dry_run: - payload = { - "zone": config.zone_name, - "zone_id": zone_id, - "request_host": config.manage_host, - "redirect_host": config.manage_origin_host, - "phase": "http_request_dynamic_redirect", - "planned_rules": planned_rules, - } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - - ruleset = find_phase_ruleset(config, zone_id, phase="http_request_dynamic_redirect") - if ruleset is None: - ruleset = create_phase_ruleset( - config, - zone_id, - phase="http_request_dynamic_redirect", - name="Single Redirects ruleset", - ) - else: - ruleset = get_ruleset(config, zone_id, ruleset["id"]) - - existing = {rule.get("ref"): rule for rule in ruleset.get("rules", [])} - changed: list[str] = [] - for planned_rule in planned_rules: - current = existing.get(planned_rule["ref"]) - if current is None: - add_rule(config, zone_id, ruleset["id"], planned_rule) - changed.append(f"created {planned_rule['ref']}") - continue - update_rule(config, zone_id, ruleset["id"], current["id"], planned_rule) - changed.append(f"updated {planned_rule['ref']}") - - print("manage ensure redirect: ok") - for item in changed: - print(f" - {item}") - return 0 - - -def cmd_api(args: list[str]) -> int: - parser = argparse.ArgumentParser(prog="runseal :cloudflare api", add_help=False) - parser.add_argument("method") - parser.add_argument("path") - parser.add_argument("--query", action="append", default=[]) - parser.add_argument("--json") - parsed = parser.parse_args(args) - - params: dict[str, str] = {} - for item in parsed.query: - if "=" not in item: - raise RuntimeError(f"invalid --query value: {item}; expected key=value") - key, value = item.split("=", 1) - params[key] = value - - body = None - if parsed.json is not None: - try: - body = json.loads(parsed.json) - except json.JSONDecodeError as err: - raise RuntimeError(f"invalid --json payload: {err}") from err - - config = load_config() - payload = api_request( - config, - parsed.method, - parsed.path, - params=params or None, - body=body, - ) - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 - - -COMMANDS = { - "init": cmd_init, - "check": cmd_check, - "manage-plan": cmd_manage_plan, - "manage-inspect": cmd_manage_inspect, - "manage-ensure-redirect": cmd_manage_ensure_redirect, - "api": cmd_api, -} - - -def main(argv: list[str] | None = None) -> int: - return dispatch(argv, usage=usage, commands=COMMANDS, name="cloudflare") - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/cli/pr.py b/scripts/cli/pr.py deleted file mode 100644 index b383cf5..0000000 --- a/scripts/cli/pr.py +++ /dev/null @@ -1,171 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import subprocess -import sys - -from lib.utils.cli import CliError, run_checked - - -def usage() -> None: - print( - """Usage: runseal :pr [options] - -Create or update, watch, and squash-merge the GitHub PR for the current branch. - -Options: - --base <branch> PR base branch (default: main) - --title <title> title when creating a new PR - --body-file <path> body file when creating a new PR - --draft create the PR as draft and require --no-merge - --no-watch do not watch PR checks - --no-merge do not squash-merge after checks - --no-push do not push the current branch first - --dry-run print planned actions without changing remote state -""" - ) - - -def output(argv: list[str]) -> str: - result = run_checked(argv, stdout=subprocess.PIPE) - return result.stdout.decode("utf-8").strip() - - -def current_branch() -> str: - branch = output(["git", "branch", "--show-current"]) - if not branch: - raise CliError("not on a branch") - return branch - - -def require_operator_tools() -> None: - run_checked(["git", "--version"], stdout=subprocess.DEVNULL) - run_checked(["gh", "--version"], stdout=subprocess.DEVNULL) - run_checked(["gh", "auth", "status"], stdout=subprocess.DEVNULL) - - -def find_pr(branch: str) -> dict[str, object] | None: - raw = output( - [ - "gh", - "pr", - "list", - "--head", - branch, - "--json", - "number,title,state,url,isDraft", - ] - ) - items = json.loads(raw) - if not items: - return None - return items[0] - - -def create_pr( - branch: str, - base: str, - title: str | None, - body_file: str | None, - *, - draft: bool, -) -> dict[str, object]: - argv = [ - "gh", - "pr", - "create", - "--base", - base, - "--head", - branch, - ] - if draft: - argv.append("--draft") - if title: - argv.extend(["--title", title]) - else: - argv.append("--fill") - if body_file: - argv.extend(["--body-file", body_file]) - elif title: - argv.append("--fill") - run_checked(argv) - found = find_pr(branch) - if found is None: - raise CliError(f"created PR for {branch}, but could not find it afterward") - return found - - -def cmd_default(args: list[str]) -> int: - parser = argparse.ArgumentParser(prog="runseal :pr", add_help=False) - parser.add_argument("--base", default="main") - parser.add_argument("--title") - parser.add_argument("--body-file") - parser.add_argument("--draft", action="store_true") - parser.add_argument("--no-watch", action="store_true") - parser.add_argument("--no-merge", action="store_true") - parser.add_argument("--no-push", action="store_true") - parser.add_argument("--dry-run", action="store_true") - parsed = parser.parse_args(args) - - require_operator_tools() - branch = current_branch() - if branch in {parsed.base, "main", "master"}: - raise CliError(f"refusing to open a PR from base branch: {branch}") - if parsed.draft and not parsed.no_merge: - raise CliError("--draft requires --no-merge") - - if parsed.dry_run: - print(f"branch: {branch}") - print(f"base: {parsed.base}") - print(f"push: {not parsed.no_push}") - print("pr: create if missing, otherwise reuse existing") - print(f"draft: {parsed.draft}") - print(f"ready: {not parsed.draft}") - print(f"watch: {not parsed.no_watch}") - print(f"squash_merge: {not parsed.no_merge}") - return 0 - - if not parsed.no_push: - run_checked(["git", "push", "-u", "origin", branch]) - - pr = find_pr(branch) - if pr is None: - pr = create_pr( - branch, - parsed.base, - parsed.title, - parsed.body_file, - draft=parsed.draft, - ) - print(f"created PR #{pr['number']}: {pr['url']}", flush=True) - else: - print(f"found PR #{pr['number']}: {pr['url']}", flush=True) - - number = str(pr["number"]) - if pr.get("isDraft") and not parsed.draft: - run_checked(["gh", "pr", "ready", number]) - print(f"marked PR #{number} ready") - if not parsed.no_watch: - run_checked(["gh", "pr", "checks", number, "--watch", "--interval", "10"]) - if not parsed.no_merge: - run_checked(["gh", "pr", "merge", number, "--squash", "--delete-branch"]) - print(f"squash-merged PR #{number}") - return 0 - - -def main(argv: list[str] | None = None) -> int: - args = list(sys.argv[1:] if argv is None else argv) - if not args or args[0] in {"-h", "--help", "help"}: - usage() - return 0 - try: - return cmd_default(args) - except (CliError, RuntimeError, OSError, subprocess.CalledProcessError) as exc: - print(f"pr: {exc}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/cli/release.py b/scripts/cli/release.py deleted file mode 100644 index 47e0671..0000000 --- a/scripts/cli/release.py +++ /dev/null @@ -1,124 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import re -import subprocess -import sys -import time - -from lib.utils.cli import CliError, run_checked - - -CHANNEL_WORKFLOWS = { - "stable": "release-stable.yml", - "beta": "release-beta.yml", -} - - -def usage() -> None: - print( - """Usage: runseal :release --channel=stable|beta [options] - -Trigger a release workflow. - -Options: - --channel <name> release channel: stable or beta - --ref <ref> git ref passed to the workflow (default: main) - --version <version> optional workflow version_override - --watch watch the triggered workflow run - --dry-run print planned action without triggering a workflow -""" - ) - - -def output(argv: list[str]) -> str: - result = run_checked(argv, stdout=subprocess.PIPE) - return result.stdout.decode("utf-8").strip() - - -def require_operator_tools() -> None: - run_checked(["gh", "--version"], stdout=subprocess.DEVNULL) - run_checked(["gh", "auth", "status"], stdout=subprocess.DEVNULL) - - -def latest_run_id(workflow: str, ref: str) -> str: - for _ in range(6): - raw = output( - [ - "gh", - "run", - "list", - "--workflow", - workflow, - "--branch", - ref, - "--event", - "workflow_dispatch", - "--limit", - "1", - "--json", - "databaseId", - ] - ) - runs = json.loads(raw) - if runs: - return str(runs[0]["databaseId"]) - time.sleep(2) - raise CliError(f"could not find a recent run for {workflow} on {ref}") - - -def cmd_default(args: list[str]) -> int: - parser = argparse.ArgumentParser(prog="runseal :release", add_help=False) - parser.add_argument("--channel", choices=sorted(CHANNEL_WORKFLOWS)) - parser.add_argument("--ref", default="main") - parser.add_argument("--version", default="") - parser.add_argument("--watch", action="store_true") - parser.add_argument("--dry-run", action="store_true") - parsed = parser.parse_args(args) - - if parsed.channel is None: - raise CliError("--channel is required") - workflow = CHANNEL_WORKFLOWS[parsed.channel] - argv = [ - "gh", - "workflow", - "run", - workflow, - "--ref", - parsed.ref, - "-f", - f"ref={parsed.ref}", - "-f", - f"version_override={parsed.version}", - ] - if parsed.dry_run: - print(" ".join(argv)) - return 0 - require_operator_tools() - result = run_checked(argv, stdout=subprocess.PIPE) - trigger_output = result.stdout.decode("utf-8").strip() - if trigger_output: - print(trigger_output) - print(f"triggered {workflow} for ref {parsed.ref}") - if parsed.watch: - match = re.search(r"/actions/runs/([0-9]+)", trigger_output) - run_id = match.group(1) if match else latest_run_id(workflow, parsed.ref) - run_checked(["gh", "run", "watch", run_id, "--interval", "10"]) - return 0 - - -def main(argv: list[str] | None = None) -> int: - args = list(sys.argv[1:] if argv is None else argv) - if not args or args[0] in {"-h", "--help", "help"}: - usage() - return 0 - try: - return cmd_default(args) - except (CliError, RuntimeError, OSError, subprocess.CalledProcessError) as exc: - print(f"release: {exc}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/init.py b/scripts/init.py deleted file mode 100755 index b7bef85..0000000 --- a/scripts/init.py +++ /dev/null @@ -1,279 +0,0 @@ -#!/usr/bin/env python3 -"""Initialize this checkout for local development.""" - -from __future__ import annotations - -import argparse -import shutil -import stat -import subprocess -from pathlib import Path - - -CURRENT_HOOK_MARKER = "runseal init hook" -GENERATED_HOOK_MARKERS = ( - CURRENT_HOOK_MARKER, - "runseal bootstrap hook", -) - -REQUIRED_TOOLS = ( - "git", - "python3", - "cargo", - "runseal", - "uv", - "flavor", - "sh", - "bash", - "sed", - "grep", -) - -REQUIRED_PATHS = ( - "Cargo.toml", - "Cargo.lock", - "flavor.json", - "manage.sh", - "manage.ps1", - "runseal.toml", - ".runseal/lib/python-module", - ".runseal/wrappers/cloudflare.sh", - ".runseal/wrappers/pr.sh", - ".runseal/wrappers/release.sh", - ".github/workflows/guard.yml", - ".github/workflows/release-beta.yml", - ".github/workflows/release-stable.yml", - ".github/scripts/release/assets/package.sh", - ".github/scripts/release/assets/package.ps1", - ".github/scripts/release/r2/publish.sh", - ".github/scripts/release/smoke/smoke.sh", - ".github/scripts/release/smoke/smoke.ps1", - "scripts/pyproject.toml", - "scripts/uv.lock", - "scripts/cli/cloudflare.py", - "scripts/cli/pr.py", - "scripts/cli/release.py", - "scripts/init.py", -) - -PRE_COMMIT_HOOK = f"""#!/usr/bin/env sh -# {CURRENT_HOOK_MARKER}: generated by scripts/init.py -set -eu -root=$(git rev-parse --show-toplevel) -cd "$root" - -echo "==> cargo fmt" -cargo fmt --all --check - -echo "==> cargo clippy" -cargo clippy --locked --workspace --all-targets -- -D warnings - -echo "==> cargo test" -cargo test --locked --workspace - -echo "==> flavor self-check" -flavor check --root . --config flavor.json - -echo "==> shell syntax" -sh -n .runseal/lib/python-module -sh -n .runseal/wrappers/cloudflare.sh -sh -n .runseal/wrappers/pr.sh -sh -n .runseal/wrappers/release.sh -sh -n manage.sh -sh -n .github/scripts/release/assets/checksums.sh -sh -n .github/scripts/release/assets/package.sh -sh -n .github/scripts/release/assets/verify.sh -sh -n .github/scripts/release/github/cleanup-artifacts.sh -bash -n .github/scripts/release/r2/check.sh -bash -n .github/scripts/release/r2/publish.sh -bash -n .github/scripts/release/r2/summary.sh -bash -n .github/scripts/release/r2/verify.sh -sh -n .github/scripts/release/smoke/smoke.sh - -echo "==> python syntax" -python3 -m py_compile scripts/init.py -python3 -m py_compile scripts/cli/cloudflare.py -python3 -m py_compile scripts/cli/pr.py -python3 -m py_compile scripts/cli/release.py -python3 -m py_compile scripts/lib/cloudflare.py -python3 -m py_compile scripts/lib/utils/cli.py -python3 -m py_compile scripts/lib/utils/paths.py -python3 -m py_compile .github/scripts/release/metadata/beta.py -python3 -m py_compile .github/scripts/release/metadata/stable.py - -if command -v pwsh >/dev/null 2>&1; then - echo "==> PowerShell syntax" - pwsh -NoProfile -NonInteractive -Command \\ - ' -$ErrorActionPreference = "Stop" -$paths = @( - "manage.ps1", - ".github/scripts/release/assets/package.ps1", - ".github/scripts/release/smoke/smoke.ps1" -) -foreach ($path in $paths) {{ - [scriptblock]::Create((Get-Content -Raw $path)) | Out-Null -}} -' -else - echo "==> PowerShell syntax" - echo "skip: pwsh not found" -fi -""" - -COMMIT_MSG_HOOK = f"""#!/usr/bin/env sh -# {CURRENT_HOOK_MARKER}: generated by scripts/init.py -set -eu - -if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then - echo "commit message file not found" >&2 - exit 1 -fi - -subject=$(sed -n ' -/^[[:space:]]*#/d -/[^[:space:]]/ {{ - s/^[[:space:]]*// - s/[[:space:]]*$// - p - q -}} -' "$1") - -if [ -z "$subject" ]; then - echo "commit subject is empty" >&2 - exit 1 -fi - -case "$subject" in - "Merge "*|"Revert "*|"fixup!"*|"squash!"*) - exit 0 - ;; -esac - -if ! printf '%s\\n' "$subject" | grep -Eq '^[a-z][a-z0-9-]*(/[a-z0-9-]+)*: [^[:space:]].*'; then - echo "commit subject must look like '<area>: <imperative summary>'" >&2 - echo "actual: $subject" >&2 - exit 1 -fi - -if [ "${{#subject}}" -gt 72 ]; then - echo "warning: commit subject is ${{#subject}} chars; prefer <= 72" >&2 -fi -""" - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--force", - action="store_true", - help="replace existing non-init hooks after creating a numbered .bak copy", - ) - args = parser.parse_args() - - root = repo_root() - print(f"repository: {root}") - check_required_tools() - check_required_paths(root) - install_hooks(root, force=args.force) - print("development environment ready") - return 0 - - -def repo_root() -> Path: - try: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - check=True, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - except (FileNotFoundError, subprocess.CalledProcessError) as error: - raise SystemExit("not inside a git checkout, or git is not available") from error - return Path(result.stdout.strip()) - - -def check_required_tools() -> None: - print("==> checking required tools") - missing = [tool for tool in REQUIRED_TOOLS if shutil.which(tool) is None] - if missing: - raise SystemExit("missing required tool(s): " + ", ".join(missing)) - print("ok: " + ", ".join(REQUIRED_TOOLS)) - - -def check_required_paths(root: Path) -> None: - print("==> checking repository entrypoints") - missing = [path for path in REQUIRED_PATHS if not (root / path).is_file()] - if missing: - raise SystemExit("missing required path(s): " + ", ".join(missing)) - print("ok: " + ", ".join(REQUIRED_PATHS)) - - -def install_hooks(root: Path, *, force: bool) -> None: - print("==> installing git hooks") - hooks_dir = git_path(root, "hooks") - hooks_dir.mkdir(parents=True, exist_ok=True) - install_hook(root, hooks_dir / "pre-commit", PRE_COMMIT_HOOK, force=force) - install_hook(root, hooks_dir / "commit-msg", COMMIT_MSG_HOOK, force=force) - - -def git_path(root: Path, suffix: str) -> Path: - result = subprocess.run( - ["git", "rev-parse", "--git-path", suffix], - cwd=root, - check=True, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - path = Path(result.stdout.strip()) - if path.is_absolute(): - return path - return root / path - - -def install_hook(root: Path, path: Path, content: str, *, force: bool) -> None: - if path.exists() and not is_generated_hook(path): - if not force: - raise SystemExit( - f"{path} already exists and was not generated by scripts/init.py; " - "rerun with --force to back it up and replace it" - ) - backup = next_backup_path(path) - path.replace(backup) - print(f"backed up existing hook to {display_path(root, backup)}") - - path.write_text(content, encoding="utf-8") - path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - print(f"installed {display_path(root, path)}") - - -def is_generated_hook(path: Path) -> bool: - text = path.read_text(encoding="utf-8", errors="replace") - return any(marker in text for marker in GENERATED_HOOK_MARKERS) - - -def next_backup_path(path: Path) -> Path: - backup = path.with_name(f"{path.name}.bak") - if not backup.exists(): - return backup - - for index in range(1, 1000): - candidate = path.with_name(f"{path.name}.bak.{index}") - if not candidate.exists(): - return candidate - - raise SystemExit(f"too many existing hook backups for {path}") - - -def display_path(root: Path, path: Path) -> str: - try: - return str(path.relative_to(root)) - except ValueError: - return str(path) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/lib/__init__.py b/scripts/lib/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/scripts/lib/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/scripts/lib/cloudflare.py b/scripts/lib/cloudflare.py deleted file mode 100644 index 9f851e3..0000000 --- a/scripts/lib/cloudflare.py +++ /dev/null @@ -1,265 +0,0 @@ -from __future__ import annotations - -import json -import os -import urllib.error -import urllib.parse -import urllib.request -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from lib.utils.cli import CliError -from lib.utils.paths import LOCAL_DIR, SECRETS_DIR, TMP_DIR - - -TOKEN_FILE = SECRETS_DIR / "cloudflare.env" -REQUIRED_KEYS = ("CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN") -API_BASE = "https://api.cloudflare.com/client/v4" -DEFAULT_ZONE_NAME = "perish.uk" -DEFAULT_MANAGE_HOST = "runseal.perish.uk" -DEFAULT_MANAGE_ORIGIN_HOST = "releases.runseal.perish.uk" -DEFAULT_MANAGE_REDIRECT_PREFIX = "" -MANAGE_REDIRECT_STATUS_CODE = 302 - - -@dataclass(frozen=True) -class CloudflareConfig: - account_id: str - api_token: str - zone_name: str - manage_host: str - manage_origin_host: str - manage_redirect_prefix: str - - -@dataclass(frozen=True) -class ManageRuleSpec: - ref: str - description: str - path: str - - -MANAGE_RULE_SPECS = ( - ManageRuleSpec( - ref="runseal_manage_sh_redirect", - description="Redirect runseal manage.sh to releases bucket asset", - path="/manage.sh", - ), - ManageRuleSpec( - ref="runseal_manage_ps1_redirect", - description="Redirect runseal manage.ps1 to releases bucket asset", - path="/manage.ps1", - ), -) - - -def ensure_local_layout() -> None: - for path in (LOCAL_DIR, SECRETS_DIR, TMP_DIR): - path.mkdir(parents=True, exist_ok=True) - os.chmod(path, 0o700) - - -def parse_env_file(path: Path) -> dict[str, str]: - values: dict[str, str] = {} - if not path.is_file(): - raise CliError(f"missing secrets file: {path}") - for line in path.read_text(encoding="utf-8").splitlines(): - stripped = line.strip() - if not stripped or stripped.startswith("#"): - continue - if "=" not in stripped: - raise CliError(f"invalid line in {path}: {stripped}") - key, value = stripped.split("=", 1) - values[key.strip()] = value.strip().strip('"').strip("'") - return values - - -def load_config() -> CloudflareConfig: - values = parse_env_file(TOKEN_FILE) - missing = [key for key in REQUIRED_KEYS if not values.get(key)] - if missing: - raise CliError(f"missing required key(s) in {TOKEN_FILE}: {', '.join(missing)}") - return CloudflareConfig( - account_id=values["CLOUDFLARE_ACCOUNT_ID"], - api_token=values["CLOUDFLARE_API_TOKEN"], - zone_name=values.get("CLOUDFLARE_ZONE_NAME", DEFAULT_ZONE_NAME), - manage_host=values.get("CLOUDFLARE_MANAGE_HOST", DEFAULT_MANAGE_HOST), - manage_origin_host=values.get("CLOUDFLARE_MANAGE_ORIGIN_HOST", DEFAULT_MANAGE_ORIGIN_HOST), - manage_redirect_prefix=values.get( - "CLOUDFLARE_MANAGE_REDIRECT_PREFIX", - DEFAULT_MANAGE_REDIRECT_PREFIX, - ).strip("/"), - ) - - -def write_template() -> bool: - ensure_local_layout() - if TOKEN_FILE.exists(): - return False - TOKEN_FILE.write_text( - "\n".join( - [ - "# Repo-local Cloudflare credentials for runseal support commands.", - "# Fill these values manually. This file stays local and gitignored.", - "CLOUDFLARE_ACCOUNT_ID=", - "CLOUDFLARE_API_TOKEN=", - f"CLOUDFLARE_ZONE_NAME={DEFAULT_ZONE_NAME}", - f"CLOUDFLARE_MANAGE_HOST={DEFAULT_MANAGE_HOST}", - f"CLOUDFLARE_MANAGE_ORIGIN_HOST={DEFAULT_MANAGE_ORIGIN_HOST}", - f"CLOUDFLARE_MANAGE_REDIRECT_PREFIX={DEFAULT_MANAGE_REDIRECT_PREFIX}", - "", - ] - ), - encoding="utf-8", - ) - TOKEN_FILE.chmod(0o600) - return True - - -def masked(value: str, *, keep: int = 4) -> str: - if len(value) <= keep * 2: - return "*" * len(value) - return value[:keep] + "..." + value[-keep:] - - -def api_request( - config: CloudflareConfig, - method: str, - path: str, - *, - params: dict[str, str] | None = None, - body: Any | None = None, -) -> Any: - if not path.startswith("/"): - path = "/" + path - url = API_BASE + path - if params: - url += "?" + urllib.parse.urlencode(params) - data = None - if body is not None: - data = json.dumps(body).encode("utf-8") - request = urllib.request.Request(url, data=data, method=method.upper()) - request.add_header("Authorization", f"Bearer {config.api_token}") - request.add_header("Content-Type", "application/json") - request.add_header("Accept", "application/json") - try: - with urllib.request.urlopen(request, timeout=30) as response: - raw = response.read().decode("utf-8", errors="replace") - except urllib.error.HTTPError as err: - detail = err.read().decode("utf-8", errors="replace").strip() - raise CliError(f"Cloudflare API {method.upper()} {path} -> {err.code}: {detail}") - except urllib.error.URLError as err: - raise CliError(f"Cloudflare API {method.upper()} {path} unreachable: {err.reason}") - try: - payload = json.loads(raw) if raw else {} - except json.JSONDecodeError as err: - raise CliError(f"Cloudflare API returned invalid JSON for {path}: {err}") from err - if isinstance(payload, dict) and payload.get("success") is False: - raise CliError(f"Cloudflare API {method.upper()} {path} failed: {json.dumps(payload)}") - return payload - - -def manage_redirect_url(config: CloudflareConfig, spec: ManageRuleSpec) -> str: - if not config.manage_redirect_prefix: - return f"https://{config.manage_origin_host}{spec.path}" - return f"https://{config.manage_origin_host}/{config.manage_redirect_prefix}{spec.path}" - - -def manage_rule_definition(config: CloudflareConfig, spec: ManageRuleSpec) -> dict[str, Any]: - expression = f'(http.host eq "{config.manage_host}" and http.request.uri.path eq "{spec.path}")' - return { - "ref": spec.ref, - "description": spec.description, - "expression": expression, - "action": "redirect", - "enabled": True, - "action_parameters": { - "from_value": { - "target_url": { - "value": manage_redirect_url(config, spec), - }, - "status_code": MANAGE_REDIRECT_STATUS_CODE, - "preserve_query_string": False, - }, - }, - } - - -def resolve_zone_id(config: CloudflareConfig) -> str: - payload = api_request(config, "GET", "/zones", params={"name": config.zone_name}) - result = payload.get("result", []) - if not result: - raise CliError(f"zone not found for name: {config.zone_name}") - if len(result) != 1: - raise CliError(f"expected one zone for {config.zone_name}, found {len(result)}") - return result[0]["id"] - - -def list_zone_rulesets(config: CloudflareConfig, zone_id: str) -> list[dict[str, Any]]: - payload = api_request(config, "GET", f"/zones/{zone_id}/rulesets") - return payload.get("result", []) - - -def find_phase_ruleset( - config: CloudflareConfig, - zone_id: str, - *, - phase: str, -) -> dict[str, Any] | None: - for ruleset in list_zone_rulesets(config, zone_id): - if ruleset.get("phase") == phase and ruleset.get("kind") == "zone": - return ruleset - return None - - -def get_ruleset(config: CloudflareConfig, zone_id: str, ruleset_id: str) -> dict[str, Any]: - return api_request( - config, - "GET", - f"/zones/{zone_id}/rulesets/{ruleset_id}", - )["result"] - - -def create_phase_ruleset( - config: CloudflareConfig, - zone_id: str, - *, - phase: str, - name: str, -) -> dict[str, Any]: - return api_request( - config, - "POST", - f"/zones/{zone_id}/rulesets", - body={ - "kind": "zone", - "name": name, - "phase": phase, - "rules": [], - }, - )["result"] - - -def add_rule(config: CloudflareConfig, zone_id: str, ruleset_id: str, rule: dict[str, Any]) -> dict[str, Any]: - return api_request( - config, - "POST", - f"/zones/{zone_id}/rulesets/{ruleset_id}/rules", - body=rule, - )["result"] - - -def update_rule( - config: CloudflareConfig, - zone_id: str, - ruleset_id: str, - rule_id: str, - rule: dict[str, Any], -) -> dict[str, Any]: - return api_request( - config, - "PATCH", - f"/zones/{zone_id}/rulesets/{ruleset_id}/rules/{rule_id}", - body=rule, - )["result"] diff --git a/scripts/lib/utils/__init__.py b/scripts/lib/utils/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/scripts/lib/utils/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/scripts/lib/utils/cli.py b/scripts/lib/utils/cli.py deleted file mode 100644 index a423bb1..0000000 --- a/scripts/lib/utils/cli.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -import os -import subprocess -import sys -from pathlib import Path -from typing import Callable - - -class CliError(Exception): - pass - - -Command = Callable[[list[str]], int | None] - - -def die(message: str, code: int = 1) -> int: - print(message, file=os.sys.stderr) - return code - - -def dispatch( - argv: list[str] | None, - *, - usage: Callable[[], None], - commands: dict[str, Command], - name: str, -) -> int: - args = list(sys.argv[1:] if argv is None else argv) - if not args or args[0] in {"-h", "--help", "help"}: - usage() - return 0 - command = args[0] - handler = commands.get(command) - if handler is None: - usage() - return 2 - try: - result = handler(args[1:]) - except (CliError, RuntimeError, OSError, subprocess.CalledProcessError) as exc: - return die(f"{name} {command}: {exc}") - return int(result or 0) - - -def run_checked( - argv: list[str], - *, - cwd: Path | None = None, - input_bytes: bytes | None = None, - stdout: int | None = None, -) -> subprocess.CompletedProcess[bytes]: - return subprocess.run(argv, cwd=cwd, input=input_bytes, stdout=stdout, check=True) diff --git a/scripts/lib/utils/paths.py b/scripts/lib/utils/paths.py deleted file mode 100644 index f8e63fb..0000000 --- a/scripts/lib/utils/paths.py +++ /dev/null @@ -1,10 +0,0 @@ -from __future__ import annotations - -import os -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parents[3] -LOCAL_DIR = Path(os.environ.get("RUNSEAL_REPO_LOCAL_DIR", REPO_ROOT / ".local")).resolve() -SECRETS_DIR = Path(os.environ.get("RUNSEAL_REPO_SECRETS_DIR", LOCAL_DIR / "secrets")).resolve() -TMP_DIR = Path(os.environ.get("RUNSEAL_REPO_TMP_DIR", LOCAL_DIR / "tmp")).resolve() diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml deleted file mode 100644 index ebc63f6..0000000 --- a/scripts/pyproject.toml +++ /dev/null @@ -1,5 +0,0 @@ -[project] -name = "runseal-support-scripts" -version = "0.1.0" -requires-python = ">=3.12" -dependencies = [] diff --git a/scripts/uv.lock b/scripts/uv.lock deleted file mode 100644 index ec7736a..0000000 --- a/scripts/uv.lock +++ /dev/null @@ -1,8 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.12" - -[[package]] -name = "runseal-support-scripts" -version = "0.1.0" -source = { virtual = "." }