From 0243866725faa87257514b32364f6c3331710a3f Mon Sep 17 00:00:00 2001 From: PerishCode Date: Thu, 25 Jun 2026 13:20:25 +0800 Subject: [PATCH 1/5] Pivot wrappers to Deno --- .github/workflows/guard.yml | 4 + .runseal/deno.json | 9 + .runseal/lib/runseal.ts | 186 +++++++ .runseal/wrappers/cloudflare.seal | 222 -------- .runseal/wrappers/cloudflare.ts | 447 ++++++++++++++++ .runseal/wrappers/guard.seal | 195 ------- .runseal/wrappers/guard.ts | 212 ++++++++ .runseal/wrappers/init.seal | 140 ----- .runseal/wrappers/init.ts | 166 ++++++ .runseal/wrappers/pr.seal | 257 --------- .runseal/wrappers/pr.ts | 231 ++++++++ .runseal/wrappers/release.seal | 139 ----- .runseal/wrappers/release.ts | 170 ++++++ AGENTS.md | 81 ++- README.md | 102 ++-- app/src/bin/runseal.rs | 20 +- app/src/core/internal_help.rs | 66 +-- app/src/core/mod.rs | 1 - app/src/core/profile.rs | 27 + app/src/core/runtime.rs | 72 ++- app/src/core/runtime/wrapper_paths.rs | 14 +- app/src/core/tool/help/basic.rs | 4 +- app/src/core/transpile/ast.rs | 174 ------ app/src/core/transpile/emit/mod.rs | 490 ----------------- app/src/core/transpile/emit/powershell.rs | 396 -------------- .../core/transpile/emit/powershell_argv.rs | 113 ---- .../core/transpile/emit/powershell_support.rs | 120 ----- app/src/core/transpile/emit/support.rs | 142 ----- app/src/core/transpile/frontend/mod.rs | 4 - app/src/core/transpile/frontend/powershell.rs | 262 --------- .../transpile/frontend/powershell_value.rs | 315 ----------- app/src/core/transpile/frontend/predicate.rs | 66 --- app/src/core/transpile/guards.rs | 64 --- app/src/core/transpile/lower.rs | 86 --- app/src/core/transpile/mod.rs | 127 ----- app/src/core/transpile/parse.rs | 496 ------------------ app/src/core/transpile/parse_argv.rs | 214 -------- app/src/core/transpile/parse_command.rs | 100 ---- app/src/core/transpile/parse_lex.rs | 196 ------- app/src/core/transpile/runner/args.rs | 163 ------ app/src/core/transpile/runner/mod.rs | 357 ------------- app/src/core/transpile/runner/support.rs | 146 ------ app/src/core/transpile/value.rs | 173 ------ app/tests/first_run.rs | 1 - app/tests/fixtures/estate/admin.seal | 191 ------- app/tests/fixtures/estate/kube.seal | 13 - app/tests/fixtures/estate/pr.seal | 161 ------ app/tests/fixtures/estate/ssh.seal | 73 --- app/tests/internal.rs | 10 +- app/tests/internal_tool.rs | 4 +- app/tests/internal_wrappers.rs | 358 +++++-------- app/tests/operator.rs | 2 - app/tests/operator/cloudflare.rs | 26 +- app/tests/operator/estate.rs | 486 ----------------- app/tests/operator/guard.rs | 113 ++-- app/tests/operator/init.rs | 66 ++- app/tests/operator/repo.rs | 22 +- app/tests/transpile.rs | 479 ----------------- app/tests/transpile_cases.rs | 18 - app/tests/transpile_cases/argv.rs | 257 --------- .../transpile_cases/command_predicate.rs | 77 --- app/tests/transpile_cases/env.rs | 65 --- app/tests/transpile_cases/expansion.rs | 183 ------- app/tests/transpile_cases/regex.rs | 103 ---- app/tests/transpile_cases/release.rs | 172 ------ app/tests/transpile_cases/retry.rs | 115 ---- app/tests/transpile_cases/wrappers.rs | 284 ---------- app/tests/transpile_support/syntax.rs | 88 ---- app/tests/transpile_support/tool.rs | 21 - docs/examples/README.md | 7 +- docs/examples/seal/case.md | 170 ------ docs/examples/tools/github.md | 2 +- runseal.toml | 11 + 73 files changed, 1915 insertions(+), 8632 deletions(-) create mode 100644 .runseal/deno.json create mode 100644 .runseal/lib/runseal.ts delete mode 100644 .runseal/wrappers/cloudflare.seal create mode 100644 .runseal/wrappers/cloudflare.ts delete mode 100644 .runseal/wrappers/guard.seal create mode 100644 .runseal/wrappers/guard.ts delete mode 100644 .runseal/wrappers/init.seal create mode 100644 .runseal/wrappers/init.ts delete mode 100644 .runseal/wrappers/pr.seal create mode 100644 .runseal/wrappers/pr.ts delete mode 100644 .runseal/wrappers/release.seal create mode 100644 .runseal/wrappers/release.ts delete mode 100644 app/src/core/transpile/ast.rs delete mode 100644 app/src/core/transpile/emit/mod.rs delete mode 100644 app/src/core/transpile/emit/powershell.rs delete mode 100644 app/src/core/transpile/emit/powershell_argv.rs delete mode 100644 app/src/core/transpile/emit/powershell_support.rs delete mode 100644 app/src/core/transpile/emit/support.rs delete mode 100644 app/src/core/transpile/frontend/mod.rs delete mode 100644 app/src/core/transpile/frontend/powershell.rs delete mode 100644 app/src/core/transpile/frontend/powershell_value.rs delete mode 100644 app/src/core/transpile/frontend/predicate.rs delete mode 100644 app/src/core/transpile/guards.rs delete mode 100644 app/src/core/transpile/lower.rs delete mode 100644 app/src/core/transpile/mod.rs delete mode 100644 app/src/core/transpile/parse.rs delete mode 100644 app/src/core/transpile/parse_argv.rs delete mode 100644 app/src/core/transpile/parse_command.rs delete mode 100644 app/src/core/transpile/parse_lex.rs delete mode 100644 app/src/core/transpile/runner/args.rs delete mode 100644 app/src/core/transpile/runner/mod.rs delete mode 100644 app/src/core/transpile/runner/support.rs delete mode 100644 app/src/core/transpile/value.rs delete mode 100644 app/tests/fixtures/estate/admin.seal delete mode 100644 app/tests/fixtures/estate/kube.seal delete mode 100644 app/tests/fixtures/estate/pr.seal delete mode 100644 app/tests/fixtures/estate/ssh.seal delete mode 100644 app/tests/operator/estate.rs delete mode 100644 app/tests/transpile.rs delete mode 100644 app/tests/transpile_cases.rs delete mode 100644 app/tests/transpile_cases/argv.rs delete mode 100644 app/tests/transpile_cases/command_predicate.rs delete mode 100644 app/tests/transpile_cases/env.rs delete mode 100644 app/tests/transpile_cases/expansion.rs delete mode 100644 app/tests/transpile_cases/regex.rs delete mode 100644 app/tests/transpile_cases/release.rs delete mode 100644 app/tests/transpile_cases/retry.rs delete mode 100644 app/tests/transpile_cases/wrappers.rs delete mode 100644 app/tests/transpile_support/syntax.rs delete mode 100644 app/tests/transpile_support/tool.rs delete mode 100644 docs/examples/seal/case.md diff --git a/.github/workflows/guard.yml b/.github/workflows/guard.yml index 9598160..f7cb0b8 100644 --- a/.github/workflows/guard.yml +++ b/.github/workflows/guard.yml @@ -29,6 +29,10 @@ jobs: with: components: rustfmt, clippy + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - name: Format if: runner.os != 'Windows' run: | diff --git a/.runseal/deno.json b/.runseal/deno.json new file mode 100644 index 0000000..7bb762d --- /dev/null +++ b/.runseal/deno.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "strict": true + }, + "fmt": { + "lineWidth": 100, + "semiColons": true + } +} diff --git a/.runseal/lib/runseal.ts b/.runseal/lib/runseal.ts new file mode 100644 index 0000000..e02158b --- /dev/null +++ b/.runseal/lib/runseal.ts @@ -0,0 +1,186 @@ +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +export type CommandOptions = { + cwd?: string; + env?: Record; + stdin?: "inherit" | "null" | "piped"; + stdout?: "inherit" | "null" | "piped"; + stderr?: "inherit" | "null" | "piped"; +}; + +const blockedInheritedEnv = new Set([ + "DYLD_FALLBACK_LIBRARY_PATH", + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + "LD_PRELOAD", + "LD_LIBRARY_PATH", +]); + +function commandEnv(extra: Record | undefined): Record { + const env = Deno.env.toObject(); + for (const key of blockedInheritedEnv) { + delete env[key]; + } + return { ...env, ...(extra ?? {}) }; +} + +export function print(value = ""): void { + console.log(value); +} + +export function error(value: string): void { + console.error(value); +} + +export function fail(message: string, code = 1): never { + error(message); + Deno.exit(code); +} + +export function env(name: string, fallback = ""): string { + return Deno.env.get(name) ?? fallback; +} + +export function requireEnv(name: string): string { + const value = Deno.env.get(name); + if (value === undefined || value === "") { + fail(`missing required env: ${name}`); + } + return value; +} + +export function isHelp(args: string[]): boolean { + return args.length === 1 && ["-h", "--help", "help"].includes(args[0]); +} + +export async function run(command: string, args: string[] = [], options: CommandOptions = {}) { + const status = await new Deno.Command(command, { + args, + cwd: options.cwd, + clearEnv: true, + env: commandEnv(options.env), + stdin: options.stdin ?? "inherit", + stdout: options.stdout ?? "inherit", + stderr: options.stderr ?? "inherit", + }).spawn().status; + if (!status.success) { + Deno.exit(status.code); + } +} + +export async function runText( + command: string, + args: string[] = [], + options: Omit = {}, +): Promise { + const output = await new Deno.Command(command, { + args, + cwd: options.cwd, + clearEnv: true, + env: commandEnv(options.env), + stdin: options.stdin ?? "null", + stdout: "piped", + stderr: options.stderr ?? "inherit", + }).output(); + if (!output.success) { + Deno.exit(output.code); + } + return decoder.decode(output.stdout).trimEnd(); +} + +export async function runInput( + command: string, + args: string[], + input: string, + options: Omit = {}, +): Promise { + const child = new Deno.Command(command, { + args, + cwd: options.cwd, + clearEnv: true, + env: commandEnv(options.env), + stdin: "piped", + stdout: options.stdout ?? "piped", + stderr: options.stderr ?? "inherit", + }).spawn(); + const writer = child.stdin.getWriter(); + await writer.write(encoder.encode(input)); + await writer.close(); + const output = await child.output(); + if (!output.success) { + Deno.exit(output.code); + } + return decoder.decode(output.stdout).trimEnd(); +} + +export async function runsealText(args: string[]): Promise { + return await runText("runseal", args); +} + +export async function runseal(args: string[]): Promise { + await run("runseal", args); +} + +export async function commandExists(name: string): Promise { + return (await runsealText(["@tool", "process", "exists", name])) === "true"; +} + +export async function jsonGet(json: string, path: string): Promise { + return await runsealText(["@tool", "json", "get", json, path]); +} + +export async function jsonEmpty(json: string): Promise { + return (await runsealText(["@tool", "json", "empty", json])) === "true"; +} + +export async function fileExists(path: string): Promise { + try { + const stat = await Deno.stat(path); + return stat.isFile; + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + return false; + } + throw err; + } +} + +export async function dirExists(path: string): Promise { + try { + const stat = await Deno.stat(path); + return stat.isDirectory; + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + return false; + } + throw err; + } +} + +export function pathJoin(...parts: string[]): string { + const separator = Deno.build.os === "windows" ? "\\" : "/"; + const joined = parts + .filter((part) => part !== "") + .map((part, index) => + index === 0 ? part.replace(/[\\/]+$/g, "") : part.replace(/^[\\/]+|[\\/]+$/g, "") + ) + .filter((part) => part !== "") + .join(separator); + return joined === "" ? "." : joined; +} + +export function pathListSeparator(): string { + return Deno.build.os === "windows" ? ";" : ":"; +} + +export async function readTextIfExists(path: string): Promise { + try { + return await Deno.readTextFile(path); + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + return ""; + } + throw err; + } +} diff --git a/.runseal/wrappers/cloudflare.seal b/.runseal/wrappers/cloudflare.seal deleted file mode 100644 index fa24817..0000000 --- a/.runseal/wrappers/cloudflare.seal +++ /dev/null @@ -1,222 +0,0 @@ -print() { - printf '%s\n' "$1" -} - -error() { - printf '%s\n' "$1" >&2 -} - -fail() { - error "$1" - exit 1 -} - -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" -} - -reject_extra_arg() { - if [ -n "$1" ]; then - fail "$2" - fi -} - -load_manage_redirect_rules() { - zone_name=$(runseal @tool cloudflare config get zone_name) - request_host=$(runseal @tool cloudflare config get manage_host) - redirect_host=$(runseal @tool cloudflare config get manage_origin_host) - prefix=$(runseal @tool cloudflare config get manage_redirect_prefix) - if [ -z "$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=$(runseal @tool 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=$(runseal @tool 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") -} - -print_manage_redirect_plan() { - pretty_sh=$(runseal @tool json pretty value "$rule_sh") - pretty_ps1=$(runseal @tool json pretty value "$rule_ps1") - print "manage redirect plan" - print "zone: $zone_name" - if [ "$1" = with-zone-id ]; then - print "zone id: $zone_id" - fi - print "request host: $request_host" - print "redirect host: $redirect_host" - print "phase: http_request_dynamic_redirect" - print "rules:" - print "$pretty_sh" - print "$pretty_ps1" -} - -if [ -z "$1" ]; then - usage - exit 0 -fi - -if [ "$1" = help ]; then - usage - exit 0 -fi - -if [ "$1" = --help ]; then - usage - exit 0 -fi - -case "$1" in - init) - reject_extra_arg "$2" "cloudflare: init does not accept arguments" - 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" - runseal @tool fs mkdir "$local_dir" 700 - runseal @tool fs mkdir "$secrets_dir" 700 - runseal @tool fs mkdir "$tmp_dir" 700 - if [ -f "$token_file" ]; then - print "exists $token_file" - else - runseal @tool fs write-base64 "$token_file" IyBSZXBvLWxvY2FsIENsb3VkZmxhcmUgY3JlZGVudGlhbHMgZm9yIHJ1bnNlYWwgc3VwcG9ydCBjb21tYW5kcy4KIyBGaWxsIHRoZXNlIHZhbHVlcyBtYW51YWxseS4gVGhpcyBmaWxlIHN0YXlzIGxvY2FsIGFuZCBnaXRpZ25vcmVkLgpDTE9VREZMQVJFX0FDQ09VTlRfSUQ9CkNMT1VERkxBUkVfQVBJX1RPS0VOPQpDTE9VREZMQVJFX1pPTkVfTkFNRT1wZXJpc2gudWsKQ0xPVURGTEFSRV9NQU5BR0VfSE9TVD1ydW5zZWFsLnBlcmlzaC51awpDTE9VREZMQVJFX01BTkFHRV9PUklHSU5fSE9TVD1yZWxlYXNlcy5ydW5zZWFsLnBlcmlzaC51awpDTE9VREZMQVJFX01BTkFHRV9SRURJUkVDVF9QUkVGSVg9Cg== - runseal @tool fs chmod "$token_file" 600 - print "created $token_file" - fi - ;; - check) - reject_extra_arg "$2" "cloudflare: check does not accept arguments" - account_id=$(runseal @tool cloudflare config get account_id) - zone_name=$(runseal @tool cloudflare config get zone_name) - zone=$(runseal @tool cloudflare zone get --name "$zone_name") - zone_id=$(runseal @tool json get "$zone" .id) - rulesets=$(runseal @tool cloudflare zone ruleset list --zone-id "$zone_id") - ruleset_count=$(runseal @tool json len "$rulesets") - zones_payload=$(runseal @tool cloudflare api request GET /zones --query "account.id=$account_id" --query per_page=50) - zones=$(runseal @tool json get "$zones_payload" .result) - zones_pretty=$(runseal @tool json pretty value "$zones") - account=$(runseal @tool cloudflare account get --account-id "$account_id") - account_name=$(runseal @tool json get "$account" .name) - buckets=$(runseal @tool cloudflare account r2 bucket list --account-id "$account_id") - buckets_pretty=$(runseal @tool json pretty value "$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) - reject_extra_arg "$2" "cloudflare: manage-plan does not accept arguments" - load_manage_redirect_rules - print_manage_redirect_plan - ;; - manage-inspect) - reject_extra_arg "$2" "cloudflare: manage-inspect does not accept arguments" - zone_name=$(runseal @tool cloudflare config get zone_name) - zone=$(runseal @tool cloudflare zone get --name "$zone_name") - zone_id=$(runseal @tool json get "$zone" .id) - rulesets=$(runseal @tool cloudflare zone ruleset list --zone-id "$zone_id") - ruleset=$(runseal @tool json find "$rulesets" phase http_request_dynamic_redirect) - if [ -z "$ruleset" ]; then - print "manage inspect: no http_request_dynamic_redirect zone ruleset found" - exit 0 - fi - ruleset_id=$(runseal @tool json get "$ruleset" .id) - full_ruleset=$(runseal @tool cloudflare zone ruleset get --zone-id "$zone_id" --ruleset-id "$ruleset_id") - ruleset_name=$(runseal @tool json get "$full_ruleset" .name) - rules=$(runseal @tool json get "$full_ruleset" .rules) - matched=$(runseal @tool json filter "$rules" ref runseal_manage_sh_redirect runseal_manage_ps1_redirect) - matched_count=$(runseal @tool json len "$matched") - print "zone id: $zone_id" - print "ruleset id: $ruleset_id" - print "ruleset name: $ruleset_name" - if [ "$matched_count" = 0 ]; then - print "manage inspect: no manage redirect rules found" - exit 0 - fi - pretty=$(runseal @tool json pretty value "$matched") - print "manage rules:" - print "$pretty" - ;; - manage-ensure-redirect) - dry_run=false - if [ "$2" = --dry-run ]; then - dry_run=true - if [ -n "$3" ]; then - fail "cloudflare: unknown manage-ensure-redirect argument: $3" - fi - else - if [ -n "$2" ]; then - fail "cloudflare: unknown manage-ensure-redirect argument: $2" - fi - fi - load_manage_redirect_rules - zone=$(runseal @tool cloudflare zone get --name "$zone_name") - zone_id=$(runseal @tool json get "$zone" .id) - if [ "$dry_run" = true ]; then - print_manage_redirect_plan with-zone-id - exit 0 - fi - rulesets=$(runseal @tool cloudflare zone ruleset list --zone-id "$zone_id") - ruleset=$(runseal @tool json find "$rulesets" phase http_request_dynamic_redirect) - if [ -z "$ruleset" ]; then - ruleset=$(runseal @tool cloudflare zone ruleset create --zone-id "$zone_id" --phase http_request_dynamic_redirect --name "Single Redirects ruleset") - else - ruleset_id=$(runseal @tool json get "$ruleset" .id) - ruleset=$(runseal @tool cloudflare zone ruleset get --zone-id "$zone_id" --ruleset-id "$ruleset_id") - fi - ruleset_id=$(runseal @tool json get "$ruleset" .id) - rules=$(runseal @tool json get "$ruleset" .rules) - current_sh=$(runseal @tool json find "$rules" ref runseal_manage_sh_redirect) - current_ps1=$(runseal @tool json find "$rules" ref runseal_manage_ps1_redirect) - if [ -z "$current_sh" ]; then - runseal @tool 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=$(runseal @tool json get "$current_sh" .id) - runseal @tool 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 [ -z "$current_ps1" ]; then - runseal @tool 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=$(runseal @tool json get "$current_ps1" .id) - runseal @tool 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 [ -z "$2" ]; then - fail "cloudflare: api requires a method" - fi - if [ -z "$3" ]; then - fail "cloudflare: api requires a path" - fi - shift - runseal @tool cloudflare api request "$@" - ;; - *) - fail "cloudflare: unknown command: $1" - ;; -esac diff --git a/.runseal/wrappers/cloudflare.ts b/.runseal/wrappers/cloudflare.ts new file mode 100644 index 0000000..6500e2c --- /dev/null +++ b/.runseal/wrappers/cloudflare.ts @@ -0,0 +1,447 @@ +import { env, fail, fileExists, jsonGet, print, runseal, runsealText } from "../lib/runseal.ts"; + +function usage(): void { + 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"); +} + +function rejectExtraArg(value: string | undefined, message: string): void { + if (value !== undefined && value !== "") { + fail(message); + } +} + +type ManageRules = { + zoneName: string; + requestHost: string; + redirectHost: string; + ruleSh: string; + rulePs1: string; +}; + +async function loadManageRedirectRules(): Promise { + const zoneName = await runsealText(["@tool", "cloudflare", "config", "get", "zone_name"]); + const requestHost = await runsealText(["@tool", "cloudflare", "config", "get", "manage_host"]); + const redirectHost = await runsealText([ + "@tool", + "cloudflare", + "config", + "get", + "manage_origin_host", + ]); + const prefix = await runsealText([ + "@tool", + "cloudflare", + "config", + "get", + "manage_redirect_prefix", + ]); + const targetSh = prefix === "" + ? `https://${redirectHost}/manage.sh` + : `https://${redirectHost}/${prefix}/manage.sh`; + const targetPs1 = prefix === "" + ? `https://${redirectHost}/manage.ps1` + : `https://${redirectHost}/${prefix}/manage.ps1`; + const ruleSh = await runsealText([ + "@tool", + "cloudflare", + "redirect-rule", + "exact", + "--ref", + "runseal_manage_sh_redirect", + "--description", + "Redirect runseal manage.sh to releases bucket asset", + "--host", + requestHost, + "--path", + "/manage.sh", + "--target-url", + targetSh, + ]); + const rulePs1 = await runsealText([ + "@tool", + "cloudflare", + "redirect-rule", + "exact", + "--ref", + "runseal_manage_ps1_redirect", + "--description", + "Redirect runseal manage.ps1 to releases bucket asset", + "--host", + requestHost, + "--path", + "/manage.ps1", + "--target-url", + targetPs1, + ]); + return { zoneName, requestHost, redirectHost, ruleSh, rulePs1 }; +} + +async function printManageRedirectPlan(rules: ManageRules, zoneId?: string): Promise { + const prettySh = await runsealText(["@tool", "json", "pretty", "value", rules.ruleSh]); + const prettyPs1 = await runsealText(["@tool", "json", "pretty", "value", rules.rulePs1]); + print("manage redirect plan"); + print(`zone: ${rules.zoneName}`); + if (zoneId !== undefined) { + print(`zone id: ${zoneId}`); + } + print(`request host: ${rules.requestHost}`); + print(`redirect host: ${rules.redirectHost}`); + print("phase: http_request_dynamic_redirect"); + print("rules:"); + print(prettySh); + print(prettyPs1); +} + +const [command, ...rest] = Deno.args; +if (command === undefined || command === "help" || command === "--help") { + usage(); + Deno.exit(0); +} + +switch (command) { + case "init": { + rejectExtraArg(rest[0], "cloudflare: init does not accept arguments"); + const localDir = env("RUNSEAL_REPO_LOCAL_DIR", ".local"); + const secretsDir = env("RUNSEAL_REPO_SECRETS_DIR", ".local/secrets"); + const tmpDir = env("RUNSEAL_REPO_TMP_DIR", ".local/tmp"); + const tokenFile = `${secretsDir}/cloudflare.env`; + await runseal(["@tool", "fs", "mkdir", localDir, "700"]); + await runseal(["@tool", "fs", "mkdir", secretsDir, "700"]); + await runseal(["@tool", "fs", "mkdir", tmpDir, "700"]); + if (await fileExists(tokenFile)) { + print(`exists ${tokenFile}`); + } else { + await runseal([ + "@tool", + "fs", + "write-base64", + tokenFile, + "IyBSZXBvLWxvY2FsIENsb3VkZmxhcmUgY3JlZGVudGlhbHMgZm9yIHJ1bnNlYWwgc3VwcG9ydCBjb21tYW5kcy4KIyBGaWxsIHRoZXNlIHZhbHVlcyBtYW51YWxseS4gVGhpcyBmaWxlIHN0YXlzIGxvY2FsIGFuZCBnaXRpZ25vcmVkLgpDTE9VREZMQVJFX0FDQ09VTlRfSUQ9CkNMT1VERkxBUkVfQVBJX1RPS0VOPQpDTE9VREZMQVJFX1pPTkVfTkFNRT1wZXJpc2gudWsKQ0xPVURGTEFSRV9NQU5BR0VfSE9TVD1ydW5zZWFsLnBlcmlzaC51awpDTE9VREZMQVJFX01BTkFHRV9PUklHSU5fSE9TVD1yZWxlYXNlcy5ydW5zZWFsLnBlcmlzaC51awpDTE9VREZMQVJFX01BTkFHRV9SRURJUkVDVF9QUkVGSVg9Cg==", + ]); + await runseal(["@tool", "fs", "chmod", tokenFile, "600"]); + print(`created ${tokenFile}`); + } + break; + } + case "check": { + rejectExtraArg(rest[0], "cloudflare: check does not accept arguments"); + const accountId = await runsealText(["@tool", "cloudflare", "config", "get", "account_id"]); + const zoneName = await runsealText(["@tool", "cloudflare", "config", "get", "zone_name"]); + const zone = await runsealText(["@tool", "cloudflare", "zone", "get", "--name", zoneName]); + const zoneId = await jsonGet(zone, ".id"); + const rulesets = await runsealText([ + "@tool", + "cloudflare", + "zone", + "ruleset", + "list", + "--zone-id", + zoneId, + ]); + const rulesetCount = await runsealText(["@tool", "json", "len", rulesets]); + const zonesPayload = await runsealText([ + "@tool", + "cloudflare", + "api", + "request", + "GET", + "/zones", + "--query", + `account.id=${accountId}`, + "--query", + "per_page=50", + ]); + const zones = await jsonGet(zonesPayload, ".result"); + const zonesPretty = await runsealText(["@tool", "json", "pretty", "value", zones]); + const account = await runsealText([ + "@tool", + "cloudflare", + "account", + "get", + "--account-id", + accountId, + ]); + const accountName = await jsonGet(account, ".name"); + const buckets = await runsealText([ + "@tool", + "cloudflare", + "account", + "r2", + "bucket", + "list", + "--account-id", + accountId, + ]); + const bucketsPretty = await runsealText(["@tool", "json", "pretty", "value", buckets]); + print("cloudflare check: ok"); + print(`account id: ${accountId}`); + print(`account name: ${accountName}`); + print(`manage zone: ${zoneName} (${zoneId})`); + print(`zone rulesets: ${rulesetCount}`); + print("zones:"); + print(zonesPretty); + print("r2 buckets:"); + print(bucketsPretty); + break; + } + case "manage-plan": { + rejectExtraArg(rest[0], "cloudflare: manage-plan does not accept arguments"); + await printManageRedirectPlan(await loadManageRedirectRules()); + break; + } + case "manage-inspect": { + rejectExtraArg(rest[0], "cloudflare: manage-inspect does not accept arguments"); + const zoneName = await runsealText(["@tool", "cloudflare", "config", "get", "zone_name"]); + const zone = await runsealText(["@tool", "cloudflare", "zone", "get", "--name", zoneName]); + const zoneId = await jsonGet(zone, ".id"); + const rulesets = await runsealText([ + "@tool", + "cloudflare", + "zone", + "ruleset", + "list", + "--zone-id", + zoneId, + ]); + const ruleset = await runsealText([ + "@tool", + "json", + "find", + rulesets, + "phase", + "http_request_dynamic_redirect", + ]); + if (ruleset === "") { + print("manage inspect: no http_request_dynamic_redirect zone ruleset found"); + break; + } + const rulesetId = await jsonGet(ruleset, ".id"); + const fullRuleset = await runsealText([ + "@tool", + "cloudflare", + "zone", + "ruleset", + "get", + "--zone-id", + zoneId, + "--ruleset-id", + rulesetId, + ]); + const rulesetName = await jsonGet(fullRuleset, ".name"); + const rules = await jsonGet(fullRuleset, ".rules"); + const matched = await runsealText([ + "@tool", + "json", + "filter", + rules, + "ref", + "runseal_manage_sh_redirect", + "runseal_manage_ps1_redirect", + ]); + const matchedCount = await runsealText(["@tool", "json", "len", matched]); + print(`zone id: ${zoneId}`); + print(`ruleset id: ${rulesetId}`); + print(`ruleset name: ${rulesetName}`); + if (matchedCount === "0") { + print("manage inspect: no manage redirect rules found"); + break; + } + const pretty = await runsealText(["@tool", "json", "pretty", "value", matched]); + print("manage rules:"); + print(pretty); + break; + } + case "manage-ensure-redirect": { + let dryRun = false; + if (rest[0] === "--dry-run") { + dryRun = true; + if (rest[1] !== undefined) { + fail(`cloudflare: unknown manage-ensure-redirect argument: ${rest[1]}`); + } + } else if (rest[0] !== undefined) { + fail(`cloudflare: unknown manage-ensure-redirect argument: ${rest[0]}`); + } + const rules = await loadManageRedirectRules(); + const zone = await runsealText([ + "@tool", + "cloudflare", + "zone", + "get", + "--name", + rules.zoneName, + ]); + const zoneId = await jsonGet(zone, ".id"); + if (dryRun) { + await printManageRedirectPlan(rules, zoneId); + break; + } + const rulesets = await runsealText([ + "@tool", + "cloudflare", + "zone", + "ruleset", + "list", + "--zone-id", + zoneId, + ]); + let ruleset = await runsealText([ + "@tool", + "json", + "find", + rulesets, + "phase", + "http_request_dynamic_redirect", + ]); + if (ruleset === "") { + ruleset = await runsealText([ + "@tool", + "cloudflare", + "zone", + "ruleset", + "create", + "--zone-id", + zoneId, + "--phase", + "http_request_dynamic_redirect", + "--name", + "Single Redirects ruleset", + ]); + } else { + const rulesetId = await jsonGet(ruleset, ".id"); + ruleset = await runsealText([ + "@tool", + "cloudflare", + "zone", + "ruleset", + "get", + "--zone-id", + zoneId, + "--ruleset-id", + rulesetId, + ]); + } + const rulesetId = await jsonGet(ruleset, ".id"); + const existingRules = await jsonGet(ruleset, ".rules"); + const currentSh = await runsealText([ + "@tool", + "json", + "find", + existingRules, + "ref", + "runseal_manage_sh_redirect", + ]); + const currentPs1 = await runsealText([ + "@tool", + "json", + "find", + existingRules, + "ref", + "runseal_manage_ps1_redirect", + ]); + let changedSh: string; + if (currentSh === "") { + await runseal([ + "@tool", + "cloudflare", + "zone", + "ruleset", + "rule", + "add", + "--zone-id", + zoneId, + "--ruleset-id", + rulesetId, + "--json", + rules.ruleSh, + ]); + changedSh = "created runseal_manage_sh_redirect"; + } else { + const ruleId = await jsonGet(currentSh, ".id"); + await runseal([ + "@tool", + "cloudflare", + "zone", + "ruleset", + "rule", + "update", + "--zone-id", + zoneId, + "--ruleset-id", + rulesetId, + "--rule-id", + ruleId, + "--json", + rules.ruleSh, + ]); + changedSh = "updated runseal_manage_sh_redirect"; + } + let changedPs1: string; + if (currentPs1 === "") { + await runseal([ + "@tool", + "cloudflare", + "zone", + "ruleset", + "rule", + "add", + "--zone-id", + zoneId, + "--ruleset-id", + rulesetId, + "--json", + rules.rulePs1, + ]); + changedPs1 = "created runseal_manage_ps1_redirect"; + } else { + const ruleId = await jsonGet(currentPs1, ".id"); + await runseal([ + "@tool", + "cloudflare", + "zone", + "ruleset", + "rule", + "update", + "--zone-id", + zoneId, + "--ruleset-id", + rulesetId, + "--rule-id", + ruleId, + "--json", + rules.rulePs1, + ]); + changedPs1 = "updated runseal_manage_ps1_redirect"; + } + print("manage ensure redirect: ok"); + print(` - ${changedSh}`); + print(` - ${changedPs1}`); + break; + } + case "api": { + if (rest[0] === undefined) { + fail("cloudflare: api requires a method"); + } + if (rest[1] === undefined) { + fail("cloudflare: api requires a path"); + } + await runseal(["@tool", "cloudflare", "api", "request", ...rest]); + break; + } + default: + fail(`cloudflare: unknown command: ${command}`); +} diff --git a/.runseal/wrappers/guard.seal b/.runseal/wrappers/guard.seal deleted file mode 100644 index cbda19f..0000000 --- a/.runseal/wrappers/guard.seal +++ /dev/null @@ -1,195 +0,0 @@ -print() { - printf '%s\n' "$1" -} - -error() { - printf '%s\n' "$1" >&2 -} - -fail() { - error "$1" - exit 1 -} - -usage() { - print "Usage: runseal :guard [version-check|version-hash]" - print "" - print "Run repository guard checks or one explicit version-policy helper." - print "" - print "Commands:" - print " version-check validate version policy against stable metadata" - print " version-hash print the current guard.version.hash value" -} - -mode=full -if [ "$#" -gt 0 ]; then - case "$1" in - version-check) - mode=version-check - shift - ;; - version-hash) - mode=version-hash - shift - ;; - -h|--help|help) - usage - exit 0 - ;; - *) - fail "guard: unknown command: $1" - ;; - esac -fi - -if [ "$#" -gt 0 ]; then - fail "guard: unexpected arguments" -fi - -if [ "$mode" = version-hash ]; then - runseal @tool hash tree app/tests - exit 0 -fi - -public_url="${RUNSEAL_RELEASES_PUBLIC_URL:-}" -if [ -z "$public_url" ]; then - public_url="https://releases.runseal.perish.uk" -fi - -metadata_url="${RUNSEAL_STABLE_METADATA_URL:-}" -if [ -z "$metadata_url" ]; then - metadata_url="$public_url/stable/latest/metadata.json" -fi - -tmp_dir="${RUNSEAL_REPO_TMP_DIR:-.local/tmp}" -if [ -n "${RUNNER_TEMP:-}" ]; then - tmp_dir="${RUNNER_TEMP}/runseal-guard" -fi -runseal @tool fs mkdir "$tmp_dir" 700 -metadata_file="$tmp_dir/runseal-guard-stable-metadata.json" -cargo_metadata=$(cargo metadata --no-deps --format-version 1) -current_version=$(runseal @tool json get "$cargo_metadata" '.packages[0].version') -current_hash=$(runseal @tool hash tree app/tests) -status=$(curl -sS -o "$metadata_file" -w "%{http_code}" "$metadata_url?version=$current_version") -if [ "$status" = 404 ]; then - print "guard version policy: no stable metadata; skipping" - if [ "$mode" = version-check ]; then - exit 0 - fi -else - if [ "$status" = 200 ]; then - else - fail "guard version policy: failed to fetch stable metadata: HTTP $status" - fi - - metadata=$(cat "$metadata_file") - has_prior_hash=$(runseal @tool json has "$metadata" .guard.version.hash) - if [ "$has_prior_hash" = true ]; then - prior_hash=$(runseal @tool json get "$metadata" .guard.version.hash) - else - prior_hash= - fi - if [ -z "$prior_hash" ]; then - print "guard version policy: stable metadata has no guard.version.hash; skipping" - if [ "$mode" = version-check ]; then - exit 0 - fi - else - has_stable_version=$(runseal @tool json has "$metadata" .stableVersion) - if [ "$has_stable_version" = true ]; then - prior_version=$(runseal @tool json get "$metadata" .stableVersion) - else - prior_version= - fi - if [ -z "$prior_version" ]; then - has_release_version=$(runseal @tool json has "$metadata" .releaseVersion) - if [ "$has_release_version" = true ]; then - prior_version=$(runseal @tool json get "$metadata" .releaseVersion) - fi - fi - if [ -z "$prior_version" ]; then - fail "guard version policy: stable metadata is missing stableVersion/releaseVersion" - fi - - current_order=$(runseal @tool version compare "$current_version" "$prior_version") - prior_major=$(runseal @tool version part "$prior_version" major) - prior_minor=$(runseal @tool version part "$prior_version" minor) - current_major=$(runseal @tool version part "$current_version" major) - current_minor=$(runseal @tool version part "$current_version" minor) - same_minor_lineage=false - - if [ "$current_order" = lt ]; then - fail "guard version policy: version regressed below prior stable $prior_version" - fi - if [ "$current_order" = eq ]; then - fail "guard version policy: version matches prior stable $prior_version" - fi - if [ "$current_major" = "$prior_major" ]; then - if [ "$current_minor" = "$prior_minor" ]; then - same_minor_lineage=true - fi - fi - - if [ "$current_hash" = "$prior_hash" ]; then - if [ "$same_minor_lineage" = false ]; then - fail "guard version policy: unchanged guard.version.hash requires a patch-only bump above $prior_version" - fi - print "guard version policy: hash unchanged -> patch bump ok ($prior_version -> $current_version)" - else - if [ "$same_minor_lineage" = true ]; then - fail "guard version policy: changed guard.version.hash requires a minor-or-higher bump above $prior_version" - fi - print "guard version policy: hash changed -> minor-or-higher bump ok ($prior_version -> $current_version)" - fi - - if [ "$mode" = version-check ]; then - exit 0 - fi - fi -fi - -print "==> cargo fmt" -cargo fmt --all --check - -print "==> cargo clippy" -cargo clippy --locked --workspace --all-targets -- -D warnings - -print "==> cargo test" -cargo test --locked --workspace - -print "==> seal wrappers" -cargo run --quiet --locked -p runseal -- @transpile --input-lang=seal --output-lang=sealir .runseal/wrappers/cloudflare.seal -cargo run --quiet --locked -p runseal -- @transpile --input-lang=seal --output-lang=sealir .runseal/wrappers/guard.seal -cargo run --quiet --locked -p runseal -- @transpile --input-lang=seal --output-lang=sealir .runseal/wrappers/init.seal -cargo run --quiet --locked -p runseal -- @transpile --input-lang=seal --output-lang=sealir .runseal/wrappers/pr.seal -cargo run --quiet --locked -p runseal -- @transpile --input-lang=seal --output-lang=sealir .runseal/wrappers/release.seal - -print "==> flavor self-check" -flavor check --root . --config flavor.toml - -print "==> shell syntax" -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 - -print "==> python syntax" -python3 -m py_compile .github/scripts/release/metadata/beta.py -python3 -m py_compile .github/scripts/release/metadata/stable.py - -has_pwsh=$(runseal @tool process exists pwsh) -if [ "$has_pwsh" = true ]; then - print "==> PowerShell syntax" - pwsh -NoProfile -NonInteractive -Command "[scriptblock]::Create((Get-Content -Raw 'manage.ps1')) | Out-Null" - pwsh -NoProfile -NonInteractive -Command "[scriptblock]::Create((Get-Content -Raw '.github/scripts/release/assets/package.ps1')) | Out-Null" - pwsh -NoProfile -NonInteractive -Command "[scriptblock]::Create((Get-Content -Raw '.github/scripts/release/smoke/smoke.ps1')) | Out-Null" -else - print "==> PowerShell syntax" - print "skip: pwsh not found" -fi diff --git a/.runseal/wrappers/guard.ts b/.runseal/wrappers/guard.ts new file mode 100644 index 0000000..3a139e7 --- /dev/null +++ b/.runseal/wrappers/guard.ts @@ -0,0 +1,212 @@ +import { env, fail, jsonGet, print, run, runsealText, runText } from "../lib/runseal.ts"; + +function usage(): void { + print("Usage: runseal :guard [version-check|version-hash]"); + print(""); + print("Run repository guard checks or one explicit version-policy helper."); + print(""); + print("Commands:"); + print(" version-check validate version policy against stable metadata"); + print(" version-hash print the current guard.version.hash value"); +} + +let mode = "full"; +const args = [...Deno.args]; +if (args.length > 0) { + const arg = args.shift()!; + switch (arg) { + case "version-check": + mode = "version-check"; + break; + case "version-hash": + mode = "version-hash"; + break; + case "-h": + case "--help": + case "help": + usage(); + Deno.exit(0); + default: + fail(`guard: unknown command: ${arg}`); + } +} +if (args.length > 0) { + fail("guard: unexpected arguments"); +} + +async function currentHash(): Promise { + return await runsealText(["@tool", "hash", "tree", "app/tests"]); +} + +async function versionPolicy(): Promise { + const publicUrl = env("RUNSEAL_RELEASES_PUBLIC_URL", "https://releases.runseal.perish.uk"); + const metadataUrl = env( + "RUNSEAL_STABLE_METADATA_URL", + `${publicUrl}/stable/latest/metadata.json`, + ); + + const cargoMetadata = await runText("cargo", ["metadata", "--no-deps", "--format-version", "1"]); + const currentVersion = await jsonGet(cargoMetadata, ".packages[0].version"); + const hash = await currentHash(); + const response = await fetch(`${metadataUrl}?version=${encodeURIComponent(currentVersion)}`); + if (response.status === 404) { + print("guard version policy: no stable metadata; skipping"); + return; + } + if (response.status !== 200) { + fail(`guard version policy: failed to fetch stable metadata: HTTP ${response.status}`); + } + + const metadata = await response.text(); + const hasPriorHash = await runsealText(["@tool", "json", "has", metadata, ".guard.version.hash"]); + const priorHash = hasPriorHash === "true" ? await jsonGet(metadata, ".guard.version.hash") : ""; + if (priorHash === "") { + print("guard version policy: stable metadata has no guard.version.hash; skipping"); + return; + } + + const hasStableVersion = await runsealText(["@tool", "json", "has", metadata, ".stableVersion"]); + let priorVersion = hasStableVersion === "true" ? await jsonGet(metadata, ".stableVersion") : ""; + if (priorVersion === "") { + const hasReleaseVersion = await runsealText([ + "@tool", + "json", + "has", + metadata, + ".releaseVersion", + ]); + if (hasReleaseVersion === "true") { + priorVersion = await jsonGet(metadata, ".releaseVersion"); + } + } + if (priorVersion === "") { + fail("guard version policy: stable metadata is missing stableVersion/releaseVersion"); + } + + const currentOrder = await runsealText([ + "@tool", + "version", + "compare", + currentVersion, + priorVersion, + ]); + const priorMajor = await runsealText(["@tool", "version", "part", priorVersion, "major"]); + const priorMinor = await runsealText(["@tool", "version", "part", priorVersion, "minor"]); + const currentMajor = await runsealText(["@tool", "version", "part", currentVersion, "major"]); + const currentMinor = await runsealText(["@tool", "version", "part", currentVersion, "minor"]); + const sameMinorLineage = currentMajor === priorMajor && currentMinor === priorMinor; + + if (currentOrder === "lt") { + fail(`guard version policy: version regressed below prior stable ${priorVersion}`); + } + if (currentOrder === "eq") { + fail(`guard version policy: version matches prior stable ${priorVersion}`); + } + + if (hash === priorHash) { + if (!sameMinorLineage) { + fail( + `guard version policy: unchanged guard.version.hash requires a patch-only bump above ${priorVersion}`, + ); + } + print( + `guard version policy: hash unchanged -> patch bump ok (${priorVersion} -> ${currentVersion})`, + ); + } else { + if (sameMinorLineage) { + fail( + `guard version policy: changed guard.version.hash requires a minor-or-higher bump above ${priorVersion}`, + ); + } + print( + `guard version policy: hash changed -> minor-or-higher bump ok (${priorVersion} -> ${currentVersion})`, + ); + } +} + +if (mode === "version-hash") { + print(await currentHash()); + Deno.exit(0); +} + +await versionPolicy(); +if (mode === "version-check") { + Deno.exit(0); +} + +print("==> cargo fmt"); +await run("cargo", ["fmt", "--all", "--check"]); + +print("==> cargo clippy"); +await run("cargo", ["clippy", "--locked", "--workspace", "--all-targets", "--", "-D", "warnings"]); + +print("==> cargo test"); +await run("cargo", ["test", "--locked", "--workspace"]); + +print("==> deno fmt"); +await run("deno", ["fmt", "--check", ".runseal"]); + +print("==> deno check"); +await run("deno", [ + "check", + "--config", + ".runseal/deno.json", + "--lock", + "deno.lock", + "--frozen=true", + ".runseal/wrappers/cloudflare.ts", + ".runseal/wrappers/guard.ts", + ".runseal/wrappers/init.ts", + ".runseal/wrappers/pr.ts", + ".runseal/wrappers/release.ts", +]); + +print("==> flavor self-check"); +await run("flavor", ["check", "--root", ".", "--config", "flavor.toml"]); + +print("==> shell syntax"); +for ( + const [command, script] of [ + ["sh", "manage.sh"], + ["sh", ".github/scripts/release/assets/checksums.sh"], + ["sh", ".github/scripts/release/assets/package.sh"], + ["sh", ".github/scripts/release/assets/verify.sh"], + ["sh", ".github/scripts/release/github/cleanup-artifacts.sh"], + ["bash", ".github/scripts/release/r2/check.sh"], + ["bash", ".github/scripts/release/r2/publish.sh"], + ["bash", ".github/scripts/release/r2/summary.sh"], + ["bash", ".github/scripts/release/r2/verify.sh"], + ["sh", ".github/scripts/release/smoke/smoke.sh"], + ] +) { + await run(command, ["-n", script]); +} + +print("==> python syntax"); +await run("python3", ["-m", "py_compile", ".github/scripts/release/metadata/beta.py"]); +await run("python3", ["-m", "py_compile", ".github/scripts/release/metadata/stable.py"]); + +const hasPwsh = await runsealText(["@tool", "process", "exists", "pwsh"]); +print("==> PowerShell syntax"); +if (hasPwsh === "true") { + await run("pwsh", [ + "-NoProfile", + "-NonInteractive", + "-Command", + "[scriptblock]::Create((Get-Content -Raw 'manage.ps1')) | Out-Null", + ]); + await run("pwsh", [ + "-NoProfile", + "-NonInteractive", + "-Command", + "[scriptblock]::Create((Get-Content -Raw '.github/scripts/release/assets/package.ps1')) | Out-Null", + ]); + await run("pwsh", [ + "-NoProfile", + "-NonInteractive", + "-Command", + "[scriptblock]::Create((Get-Content -Raw '.github/scripts/release/smoke/smoke.ps1')) | Out-Null", + ]); +} else { + print("skip: pwsh not found"); +} diff --git a/.runseal/wrappers/init.seal b/.runseal/wrappers/init.seal deleted file mode 100644 index 904da5e..0000000 --- a/.runseal/wrappers/init.seal +++ /dev/null @@ -1,140 +0,0 @@ -__seal_argc=$# -__seal_help=false -force=false -while [ "$#" -gt 0 ]; do - case "$1" in - --force) - force=true - shift - ;; - --) - shift - break - ;; - -h|--help|help) - __seal_help=true - shift - ;; - *) fail "unknown option: $1" ;; - esac -done - -print() { - printf '%s\n' "$1" -} - -error() { - printf '%s\n' "$1" >&2 -} - -fail() { - error "$1" - exit 1 -} - -usage() { - print "Usage: runseal :init [--force]" - print "" - print "Validate the repository and install generated git hooks." - print "" - print "Options:" - print " --force back up custom hooks before replacing them" -} - -require_tool() { - exists=$(runseal @tool process exists "$1") - if [ "$exists" = true ]; then - else - fail "init: missing required tool: $1" - fi -} - -require_path() { - path="$root/$1" - if [ -f "$path" ]; then - else - fail "init: missing required path: $1" - fi -} - -prepare_hook() { - if [ -f "$1" ]; then - generated=$(runseal @tool fs contains-any "$1" "runseal init hook" "runseal bootstrap hook") - if [ "$generated" = true ]; then - else - if [ "$force" = true ]; then - backup=$(runseal @tool fs backup-numbered "$1") - print "backed up existing hook to $backup" - else - fail "init: $1 already exists and was not generated by runseal init; rerun with --force to back it up and replace it" - fi - fi - fi -} - -if [ "$__seal_help" = true ]; then - usage - exit 0 -fi - -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" -require_tool git -require_tool python3 -require_tool cargo -require_tool runseal -require_tool flavor -require_tool sh -require_tool bash -require_tool cp -require_tool sed -require_tool grep -print "ok: git, python3, cargo, runseal, flavor, sh, bash, cp, sed, grep" - -print "==> checking repository entrypoints" -require_path Cargo.toml -require_path Cargo.lock -require_path flavor.toml -require_path manage.sh -require_path manage.ps1 -require_path runseal.toml -require_path .runseal/hooks/pre-commit -require_path .runseal/hooks/commit-msg -require_path .runseal/wrappers/cloudflare.seal -require_path .runseal/wrappers/guard.seal -require_path .runseal/wrappers/init.seal -require_path .runseal/wrappers/pr.seal -require_path .runseal/wrappers/release.seal -require_path .github/workflows/guard.yml -require_path .github/workflows/release-beta.yml -require_path .github/workflows/release-stable.yml -require_path .github/scripts/release/assets/package.sh -require_path .github/scripts/release/assets/package.ps1 -require_path .github/scripts/release/r2/publish.sh -require_path .github/scripts/release/smoke/smoke.sh -require_path .github/scripts/release/smoke/smoke.ps1 -print "ok: repository entrypoints" - -print "==> installing git hooks" -runseal @tool fs mkdir "$hooks_dir" 700 - -prepare_hook "$pre_commit" - -cp .runseal/hooks/pre-commit "$pre_commit" -runseal @tool fs chmod "$pre_commit" 755 -print "installed $pre_commit" - -prepare_hook "$commit_msg" - -cp .runseal/hooks/commit-msg "$commit_msg" -runseal @tool fs chmod "$commit_msg" 755 -print "installed $commit_msg" - -print "development environment ready" diff --git a/.runseal/wrappers/init.ts b/.runseal/wrappers/init.ts new file mode 100644 index 0000000..2f21a5d --- /dev/null +++ b/.runseal/wrappers/init.ts @@ -0,0 +1,166 @@ +import { + commandExists, + fail, + fileExists, + pathJoin, + print, + run, + runseal, + runsealText, + runText, +} from "../lib/runseal.ts"; + +let force = false; +let help = false; +const args = [...Deno.args]; +while (args.length > 0) { + const arg = args.shift()!; + switch (arg) { + case "--force": + force = true; + break; + case "--": + args.length = 0; + break; + case "-h": + case "--help": + case "help": + help = true; + break; + default: + fail(`unknown option: ${arg}`); + } +} + +function usage(): void { + print("Usage: runseal :init [--force]"); + print(""); + print("Validate the repository and install generated git hooks."); + print(""); + print("Options:"); + print(" --force back up custom hooks before replacing them"); +} + +async function requireTool(name: string): Promise { + if (!(await commandExists(name))) { + fail(`init: missing required tool: ${name}`); + } +} + +async function requirePath(root: string, path: string): Promise { + if (!(await fileExists(pathJoin(root, path)))) { + fail(`init: missing required path: ${path}`); + } +} + +async function prepareHook(path: string): Promise { + if (!(await fileExists(path))) { + return; + } + const generated = await runsealText([ + "@tool", + "fs", + "contains-any", + path, + "runseal init hook", + "runseal bootstrap hook", + ]); + if (generated === "true") { + return; + } + if (!force) { + fail( + `init: ${path} already exists and was not generated by runseal init; rerun with --force to back it up and replace it`, + ); + } + const backup = await runsealText(["@tool", "fs", "backup-numbered", path]); + print(`backed up existing hook to ${backup}`); +} + +if (help) { + usage(); + Deno.exit(0); +} + +print("==> resolving repository"); +const root = await runText("git", ["rev-parse", "--show-toplevel"]); +const gitDir = await runText("git", ["rev-parse", "--absolute-git-dir"]); +const hooksDir = pathJoin(gitDir, "hooks"); +const preCommit = pathJoin(hooksDir, "pre-commit"); +const commitMsg = pathJoin(hooksDir, "commit-msg"); +print(`repository: ${root}`); + +print("==> checking required tools"); +for ( + const tool of [ + "git", + "deno", + "python3", + "cargo", + "runseal", + "flavor", + "sh", + "bash", + "sed", + "grep", + ] +) { + await requireTool(tool); +} +print("ok: git, deno, python3, cargo, runseal, flavor, sh, bash, sed, grep"); + +print("==> checking repository entrypoints"); +for ( + const path of [ + "Cargo.toml", + "Cargo.lock", + "flavor.toml", + "manage.sh", + "manage.ps1", + "runseal.toml", + ".runseal/deno.json", + ".runseal/hooks/pre-commit", + ".runseal/hooks/commit-msg", + ".runseal/lib/runseal.ts", + ".runseal/wrappers/cloudflare.ts", + ".runseal/wrappers/guard.ts", + ".runseal/wrappers/init.ts", + ".runseal/wrappers/pr.ts", + ".runseal/wrappers/release.ts", + ".github/workflows/guard.yml", + ".github/workflows/release-beta.yml", + ".github/workflows/release-stable.yml", + ".github/scripts/release/assets/checksums.sh", + ".github/scripts/release/assets/package.sh", + ".github/scripts/release/assets/package.ps1", + ".github/scripts/release/assets/verify.sh", + ".github/scripts/release/github/cleanup-artifacts.sh", + ".github/scripts/release/metadata/beta.py", + ".github/scripts/release/metadata/stable.py", + ".github/scripts/release/r2/check.sh", + ".github/scripts/release/r2/publish.sh", + ".github/scripts/release/r2/summary.sh", + ".github/scripts/release/r2/verify.sh", + ".github/scripts/release/smoke/smoke.sh", + ".github/scripts/release/smoke/smoke.ps1", + ] +) { + await requirePath(root, path); +} +print("ok: repository entrypoints"); + +print("==> installing git hooks"); +await runseal(["@tool", "fs", "mkdir", hooksDir, "700"]); + +await prepareHook(preCommit); +await Deno.copyFile(".runseal/hooks/pre-commit", preCommit); +await runseal(["@tool", "fs", "chmod", preCommit, "755"]); +print(`installed ${preCommit}`); + +await prepareHook(commitMsg); +await Deno.copyFile(".runseal/hooks/commit-msg", commitMsg); +await runseal(["@tool", "fs", "chmod", commitMsg, "755"]); +print(`installed ${commitMsg}`); + +await run("deno", ["--version"], { stdout: "null" }); +print("development environment ready"); diff --git a/.runseal/wrappers/pr.seal b/.runseal/wrappers/pr.seal deleted file mode 100644 index 1b83fa4..0000000 --- a/.runseal/wrappers/pr.seal +++ /dev/null @@ -1,257 +0,0 @@ -print() { - printf '%s\n' "$1" -} - -error() { - printf '%s\n' "$1" >&2 -} - -fail() { - error "$1" - exit 1 -} - -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" -} - -print_dry_run() { - print "branch: $branch" - print "base: $base" - if [ "$no_push" = true ]; then - print "push: False" - else - print "push: True" - fi - print "pr: create if missing, otherwise reuse existing" - if [ "$draft" = true ]; then - print "draft: True" - print "ready: False" - else - print "draft: False" - print "ready: True" - fi - if [ "$no_watch" = true ]; then - print "watch: False" - else - print "watch: True" - fi - if [ "$no_merge" = true ]; then - print "squash_merge: False" - else - print "squash_merge: True" - fi -} - -create_pr() { - if [ "$draft" = true ]; then - if [ -n "$title" ]; then - if [ -n "$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 [ -n "$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 [ -n "$title" ]; then - if [ -n "$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 [ -n "$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 -} - -watch_checks() { - checks_seen=false - attempt=0 - while [ "$attempt" -lt 12 ]; do - checks_seen=$(runseal @tool github pr checks probe "$number") - if [ "$checks_seen" = true ]; then - checks_seen=true - break - fi - sleep 5 - attempt=$(runseal @tool int add "$attempt" 1) - done - if [ "$checks_seen" = false ]; then - print "no checks reported on PR #$number; skipping watch" - else - gh pr checks "$number" --watch --interval 10 - fi -} - -__seal_argc=$# -__seal_help=false -base=main -title= -body_file= -draft=false -no_watch=false -no_merge=false -no_push=false -dry_run=false -while [ "$#" -gt 0 ]; do - case "$1" in - --base) - if [ "$#" -lt 2 ]; then fail "missing value for --base"; fi - base=$2 - shift 2 - ;; - --base=*) - base=${1#--base=} - shift - ;; - --title) - if [ "$#" -lt 2 ]; then fail "missing value for --title"; fi - title=$2 - shift 2 - ;; - --title=*) - title=${1#--title=} - shift - ;; - --body-file) - if [ "$#" -lt 2 ]; then fail "missing value for --body-file"; fi - body_file=$2 - shift 2 - ;; - --body-file=*) - body_file=${1#--body-file=} - shift - ;; - --draft) - draft=true - shift - ;; - --no-watch) - no_watch=true - shift - ;; - --no-merge) - no_merge=true - shift - ;; - --no-push) - no_push=true - shift - ;; - --dry-run) - dry_run=true - shift - ;; - --) - shift - break - ;; - -h|--help|help) - __seal_help=true - shift - ;; - *) fail "unknown option: $1" ;; - esac -done - -if [ "$__seal_help" = true ]; then - usage - exit 0 -fi - -git --version -gh --version -gh auth status - -branch=$(git branch --show-current) -if [ -z "$branch" ]; then - fail "pr: not on a branch" -fi - -if [ "$branch" = "$base" ]; then - fail "pr: refusing to open a PR from base branch: $branch" -fi - -if [ "$branch" = main ]; then - fail "pr: refusing to open a PR from base branch: $branch" -fi - -if [ "$branch" = master ]; then - fail "pr: refusing to open a PR from base branch: $branch" -fi - -if [ "$draft" = true ]; then - if [ "$no_merge" = false ]; then - fail "pr: --draft requires --no-merge" - fi -fi - -if [ "$dry_run" = true ]; then - print_dry_run - exit 0 -fi - -if [ "$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 [ "$(runseal @tool json empty "$pr_raw")" = true ]; then - create_pr - created=true - pr_raw=$(gh pr list --head "$branch" --json number,title,state,url,isDraft) - if [ "$(runseal @tool json empty "$pr_raw")" = true ]; then - fail "pr: created PR for $branch, but could not find it afterward" - fi -fi - -number=$(runseal @tool json get "$pr_raw" '.[0].number') -url=$(runseal @tool json get "$pr_raw" '.[0].url') -is_draft=$(runseal @tool json get "$pr_raw" '.[0].isDraft') - -if [ "$created" = true ]; then - print "created PR #$number: $url" -else - print "found PR #$number: $url" -fi - -if [ "$is_draft" = true ]; then - if [ "$draft" = false ]; then - gh pr ready "$number" - print "marked PR #$number ready" - fi -fi - -if [ "$no_watch" = false ]; then - watch_checks -fi - -if [ "$no_merge" = false ]; then - gh pr merge "$number" --squash --delete-branch - print "squash-merged PR #$number" -fi diff --git a/.runseal/wrappers/pr.ts b/.runseal/wrappers/pr.ts new file mode 100644 index 0000000..9ca9abe --- /dev/null +++ b/.runseal/wrappers/pr.ts @@ -0,0 +1,231 @@ +import { fail, jsonEmpty, jsonGet, print, run, runsealText, runText } from "../lib/runseal.ts"; + +type Options = { + base: string; + title: string; + bodyFile: string; + draft: boolean; + noWatch: boolean; + noMerge: boolean; + noPush: boolean; + dryRun: boolean; +}; + +function usage(): void { + 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 <branch> PR base branch (default: main)"); + print(" --title <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"); +} + +function parseArgs(args: string[]): Options & { help: boolean } { + const options = { + base: "main", + title: "", + bodyFile: "", + draft: false, + noWatch: false, + noMerge: false, + noPush: false, + dryRun: false, + help: false, + }; + while (args.length > 0) { + const arg = args.shift()!; + if (arg === "--") { + break; + } + if (arg === "-h" || arg === "--help" || arg === "help") { + options.help = true; + continue; + } + if (arg === "--draft") { + options.draft = true; + continue; + } + if (arg === "--no-watch") { + options.noWatch = true; + continue; + } + if (arg === "--no-merge") { + options.noMerge = true; + continue; + } + if (arg === "--no-push") { + options.noPush = true; + continue; + } + if (arg === "--dry-run") { + options.dryRun = true; + continue; + } + for ( + const [name, key] of [ + ["--base", "base"], + ["--title", "title"], + ["--body-file", "bodyFile"], + ] as const + ) { + if (arg === name) { + const value = args.shift(); + if (value === undefined) { + fail(`missing value for ${name}`); + } + options[key] = value; + continue; + } + const prefix = `${name}=`; + if (arg.startsWith(prefix)) { + options[key] = arg.slice(prefix.length); + continue; + } + } + if ( + arg === "--base" || arg.startsWith("--base=") || + arg === "--title" || arg.startsWith("--title=") || + arg === "--body-file" || arg.startsWith("--body-file=") + ) { + continue; + } + fail(`unknown option: ${arg}`); + } + return options; +} + +function printDryRun(options: Options, branch: string): void { + print(`branch: ${branch}`); + print(`base: ${options.base}`); + print(`push: ${options.noPush ? "False" : "True"}`); + print("pr: create if missing, otherwise reuse existing"); + if (options.draft) { + print("draft: True"); + print("ready: False"); + } else { + print("draft: False"); + print("ready: True"); + } + print(`watch: ${options.noWatch ? "False" : "True"}`); + print(`squash_merge: ${options.noMerge ? "False" : "True"}`); +} + +async function createPr(options: Options, branch: string): Promise<void> { + const args = ["pr", "create"]; + if (options.draft) { + args.push("--draft"); + } + args.push("--base", options.base, "--head", branch); + if (options.title !== "") { + args.push("--title", options.title); + } + if (options.bodyFile !== "") { + args.push("--body-file", options.bodyFile); + } else { + args.push("--fill"); + } + await run("gh", args); +} + +async function watchChecks(number: string): Promise<void> { + let checksSeen = false; + for (let attempt = 0; attempt < 12; attempt += 1) { + checksSeen = (await runsealText(["@tool", "github", "pr", "checks", "probe", number])) === + "true"; + if (checksSeen) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + if (!checksSeen) { + print(`no checks reported on PR #${number}; skipping watch`); + } else { + await run("gh", ["pr", "checks", number, "--watch", "--interval", "10"]); + } +} + +const options = parseArgs([...Deno.args]); +if (options.help) { + usage(); + Deno.exit(0); +} + +await run("git", ["--version"]); +await run("gh", ["--version"]); +await run("gh", ["auth", "status"]); + +const branch = await runText("git", ["branch", "--show-current"]); +if (branch === "") { + fail("pr: not on a branch"); +} +if (branch === options.base || branch === "main" || branch === "master") { + fail(`pr: refusing to open a PR from base branch: ${branch}`); +} +if (options.draft && !options.noMerge) { + fail("pr: --draft requires --no-merge"); +} + +if (options.dryRun) { + printDryRun(options, branch); + Deno.exit(0); +} + +if (!options.noPush) { + await run("git", ["push", "-u", "origin", branch]); +} + +let created = false; +let prRaw = await runText("gh", [ + "pr", + "list", + "--head", + branch, + "--json", + "number,title,state,url,isDraft", +]); +if (await jsonEmpty(prRaw)) { + await createPr(options, branch); + created = true; + prRaw = await runText("gh", [ + "pr", + "list", + "--head", + branch, + "--json", + "number,title,state,url,isDraft", + ]); + if (await jsonEmpty(prRaw)) { + fail(`pr: created PR for ${branch}, but could not find it afterward`); + } +} + +const number = await jsonGet(prRaw, ".[0].number"); +const url = await jsonGet(prRaw, ".[0].url"); +const isDraft = await jsonGet(prRaw, ".[0].isDraft"); + +if (created) { + print(`created PR #${number}: ${url}`); +} else { + print(`found PR #${number}: ${url}`); +} + +if (isDraft === "true" && !options.draft) { + await run("gh", ["pr", "ready", number]); + print(`marked PR #${number} ready`); +} + +if (!options.noWatch) { + await watchChecks(number); +} + +if (!options.noMerge) { + await run("gh", ["pr", "merge", number, "--squash", "--delete-branch"]); + print(`squash-merged PR #${number}`); +} diff --git a/.runseal/wrappers/release.seal b/.runseal/wrappers/release.seal deleted file mode 100644 index 07d1616..0000000 --- a/.runseal/wrappers/release.seal +++ /dev/null @@ -1,139 +0,0 @@ -print() { - printf '%s\n' "$1" -} - -error() { - printf '%s\n' "$1" >&2 -} - -fail() { - error "$1" - exit 1 -} - -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_argc=$# -__seal_help=false -channel= -ref=main -version= -watch=false -dry_run=false -while [ "$#" -gt 0 ]; do - case "$1" in - --channel) - if [ "$#" -lt 2 ]; then fail "missing value for --channel"; fi - channel=$2 - shift 2 - ;; - --channel=*) - channel=${1#--channel=} - shift - ;; - --ref) - if [ "$#" -lt 2 ]; then fail "missing value for --ref"; fi - ref=$2 - shift 2 - ;; - --ref=*) - ref=${1#--ref=} - shift - ;; - --version) - if [ "$#" -lt 2 ]; then fail "missing value for --version"; fi - version=$2 - shift 2 - ;; - --version=*) - version=${1#--version=} - shift - ;; - --watch) - watch=true - shift - ;; - --dry-run) - dry_run=true - shift - ;; - --) - shift - break - ;; - -h|--help|help) - __seal_help=true - shift - ;; - *) fail "unknown option: $1" ;; - esac -done - -if [ "$__seal_argc" = 0 ]; then - usage - exit 0 -fi - -if [ "$__seal_help" = true ]; then - usage - exit 0 -fi - -if [ -z "$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 [ "$dry_run" = true ]; then - print "$command" -else - gh --version - gh auth status - ref_sha=$(git rev-parse "$ref") - trigger_output=$(gh workflow run "$workflow" --ref "$ref" -f "ref=$ref" -f "version_override=$version") - if [ -n "$trigger_output" ]; then - print "$trigger_output" - fi - print "triggered $workflow for ref $ref" - if [ "$watch" = true ]; then - run_id=$(runseal @tool regex capture "$trigger_output" '/actions/runs/([0-9]+)' 1) - if [ -z "$run_id" ]; then - attempt=0 - raw='[]' - while [ "$attempt" -lt 6 ]; do - raw=$(gh run list --workflow "$workflow" --branch "$ref" --commit "$ref_sha" --event workflow_dispatch --limit 1 --json databaseId) - if [ "$(runseal @tool json empty "$raw")" = false ]; then - run_id=$(runseal @tool json get "$raw" '.[0].databaseId') - break - fi - sleep 2 - attempt=$(runseal @tool int add "$attempt" 1) - done - fi - if [ -z "$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.ts b/.runseal/wrappers/release.ts new file mode 100644 index 0000000..00a58ed --- /dev/null +++ b/.runseal/wrappers/release.ts @@ -0,0 +1,170 @@ +import { fail, jsonEmpty, jsonGet, print, run, runsealText, runText } from "../lib/runseal.ts"; + +type Options = { + channel: string; + ref: string; + version: string; + watch: boolean; + dryRun: boolean; +}; + +function usage(): void { + 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"); +} + +function parseArgs(args: string[]): Options & { help: boolean; argc: number } { + const options = { + channel: "", + ref: "main", + version: "", + watch: false, + dryRun: false, + help: false, + argc: args.length, + }; + while (args.length > 0) { + const arg = args.shift()!; + if (arg === "--") { + break; + } + if (arg === "-h" || arg === "--help" || arg === "help") { + options.help = true; + continue; + } + if (arg === "--watch") { + options.watch = true; + continue; + } + if (arg === "--dry-run") { + options.dryRun = true; + continue; + } + for ( + const [name, key] of [ + ["--channel", "channel"], + ["--ref", "ref"], + ["--version", "version"], + ] as const + ) { + if (arg === name) { + const value = args.shift(); + if (value === undefined) { + fail(`missing value for ${name}`); + } + options[key] = value; + continue; + } + const prefix = `${name}=`; + if (arg.startsWith(prefix)) { + options[key] = arg.slice(prefix.length); + continue; + } + } + if ( + arg === "--channel" || arg.startsWith("--channel=") || + arg === "--ref" || arg.startsWith("--ref=") || + arg === "--version" || arg.startsWith("--version=") + ) { + continue; + } + fail(`unknown option: ${arg}`); + } + return options; +} + +const options = parseArgs([...Deno.args]); +if (options.argc === 0 || options.help) { + usage(); + Deno.exit(0); +} +if (options.channel === "") { + fail("release: --channel is required"); +} + +let workflow: string; +switch (options.channel) { + case "stable": + workflow = "release-stable.yml"; + break; + case "beta": + workflow = "release-beta.yml"; + break; + default: + fail(`invalid choice: ${options.channel}`, 2); +} + +const dryRunCommand = + `gh workflow run ${workflow} --ref ${options.ref} -f ref=${options.ref} -f version_override=${options.version}`; +if (options.dryRun) { + print(dryRunCommand); + Deno.exit(0); +} + +await run("gh", ["--version"]); +await run("gh", ["auth", "status"]); +const refSha = await runText("git", ["rev-parse", options.ref]); +const triggerOutput = await runText("gh", [ + "workflow", + "run", + workflow, + "--ref", + options.ref, + "-f", + `ref=${options.ref}`, + "-f", + `version_override=${options.version}`, +]); +if (triggerOutput !== "") { + print(triggerOutput); +} +print(`triggered ${workflow} for ref ${options.ref}`); + +if (options.watch) { + let runId = await runsealText([ + "@tool", + "regex", + "capture", + triggerOutput, + "/actions/runs/([0-9]+)", + "1", + ]); + if (runId === "") { + let raw = "[]"; + for (let attempt = 0; attempt < 6; attempt += 1) { + raw = await runText("gh", [ + "run", + "list", + "--workflow", + workflow, + "--branch", + options.ref, + "--commit", + refSha, + "--event", + "workflow_dispatch", + "--limit", + "1", + "--json", + "databaseId", + ]); + if (!(await jsonEmpty(raw))) { + runId = await jsonGet(raw, ".[0].databaseId"); + break; + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + } + if (runId === "") { + fail(`release: could not find a recent run for ${workflow} on ${options.ref}`); + } + await run("gh", ["run", "watch", runId, "--interval", "10"]); +} diff --git a/AGENTS.md b/AGENTS.md index b911150..00f0b27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,20 +23,20 @@ Core product stance: external scripts, then keeping those layers explicit. - `runseal` exists to reduce environment-dependency complexity in real cross-platform operations work: too many environment variables, too many - machine-specific assumptions, and too much operational glue falling back to - Python or JavaScript even when the underlying workflow is clear and finite. + machine-specific assumptions, and too much operational glue falling into + uncontrolled Python or shell dependency stacks. - Explicit profile. No hidden orchestration. -- Prefer a stable shared subset of `bash` and PowerShell for `.seal`, plus - explicit atomic `@tool` capabilities for needs that do not fit that shared - shell surface cleanly. -- Preserve cross-shell semantic equivalence as the hard constraint. Do not - require local syntax symmetry or IR-level elegance when a `.seal` behavior is - still clear, finite, and can be translated reliably into a more awkward - PowerShell form. +- Prefer Deno `.ts` wrappers for structured cross-platform operator flows, + thin `.sh` wrappers only for Unix bootstrap glue, and explicit atomic `@tool` + capabilities for reusable domain operations. +- Treat Deno as an explicit single-binary runtime prerequisite when the profile + declares a `[deno]` policy. Do not hide runtime setup or prompt for missing + permissions at wrapper execution time. - Keep the Rust core thin and concrete. - Support only `env`, `symlink`, fixed-prefix `argv`, explicit `:wrapper` - resolution, direct `.seal` execution, and read-only `@internal` - introspection unless a new product decision explicitly expands the surface. + resolution, Deno `.ts` wrapper execution, platform script wrappers, and + read-only `@internal` introspection unless a new product decision explicitly + expands the surface. - Use `clap` for CLI parsing. Do not hand-roll argument parsing. - Preserve command lifecycle semantics: load profile, register symlinks, export env, run command, clean up symlinks. @@ -83,12 +83,11 @@ the repository-owned canonical files directly. - `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/src/core/tool/`: built-in atomic `@tool` surface. - `app/tests/`: integration tests and focused behavioral coverage. -- `.runseal/wrappers/`: repo-local `:wrapper` entrypoints. Prefer `.seal` - wrappers; platform scripts exist only while a wrapper has not migrated. +- `.runseal/wrappers/`: repo-local `:wrapper` entrypoints. Prefer `.ts` + wrappers for structured operations and `.sh` only for thin Unix bootstrap. - `runseal.toml`: repo-local operator profile. - `manage.sh` and `manage.ps1`: public install and uninstall managers. @@ -160,16 +159,16 @@ Successful profile and wrapper paths are normalized absolute paths. ### What defines the CLI surface? This repository is building explicit runtime glue, not a hidden orchestrator. -New behavior should be added only when it fits one of two shapes cleanly: +New behavior should be added only when it fits one of these shapes cleanly: -- shared `bash` and PowerShell semantics that are worth making first-class in - `.seal` +- a Deno `.ts` wrapper for repo-local structured operational flow - an explicit atomic `@tool` +- a thin platform script for bootstrap or platform-specific shell integration `runseal` should not be treated as a grab-bag operations toolkit where every pain point becomes another command. Its value is methodological first: -- decide what should be flow control in `.seal` +- decide what should be flow control in a `.ts` wrapper - decide what should be an atomic `@tool` - decide what should be a visible repo or local artifact under `.runseal/` or `.local/` @@ -184,30 +183,23 @@ clear operational workflows should not need to depend on heavyweight language runtimes or repository-local script stacks just to survive environment drift, cross-platform differences, and routine operator setup friction. -The goal is not "be as small as possible". The goal is to absorb the -right kind of complexity: +The goal is not "no runtime dependencies ever". The goal is to absorb the +right kind of complexity with explicit prerequisites: -- clear, finite, cross-platform operational flow control should fit in - `runseal` -- shell-specific cleverness, open-ended scripting power, and accidental runtime +- clear, finite, cross-platform operational flow control should fit in Deno + wrappers plus runseal profile/context glue +- reusable domain operations should become `@tool` +- shell-specific cleverness, open-ended scripting power, and accidental dependency sprawl should not -That is why the product boundary is a shared shell subset plus explicit atomic +That is why the product boundary is Deno-first wrappers plus explicit atomic tools, rather than a general scripting platform or a partial shell clone. -The hard promise is behavioral equivalence across `bash` and PowerShell, not -surface-level symmetry in generated code. Some worthwhile `.seal` semantics may -compile into elegant `bash` and relatively ugly PowerShell. That tradeoff is -acceptable when: +### When should behavior become a Deno wrapper? -- the `.seal` behavior is clear and finite -- the translation is reliable and testable -- the result still serves explicit cross-platform operations flow control - -### When should behavior become Seal syntax? - -Only when bash and PowerShell share an elegant, stable semantic shape that is -worth making first-class. +When the logic is repo-local operational flow: argument parsing, policy, +defaults, validation, polling, JSON/HTTP handling, and sequencing around +existing CLIs or runseal tools. ### When should behavior become `@tool`? @@ -216,11 +208,11 @@ the result still fits the explicit atomic-tool model. ### When should logic stay outside runseal? -When the behavior cannot be described cleanly as shared shell-shape syntax or a -clear atomic tool, keep it in Python, Ruby, JavaScript, or another external -script. +When the behavior cannot be described cleanly as a repo-local Deno flow or a +clear atomic tool, keep it in Python, Ruby, JavaScript, Zig, shell, or another +external script. -### Should `.seal` wrappers build multi-line config or payload text inline? +### Should wrappers build multi-line config or payload text inline? Usually no. @@ -244,15 +236,14 @@ The wrapper should usually do the smaller, clearer job: This is an intentional product boundary. `runseal` is meant to reduce environment and runtime dependency complexity in operations workflows, not to -turn `.seal` into a general inline text-construction language. If a multi-line +turn wrappers into a general inline text-construction language. If a multi-line artifact is important enough to exist, prefer making it a visible repo or local artifact first. -### Should `.seal` wrappers be treated as first-class runtime entrypoints? +### Should `.ts` wrappers be treated as first-class runtime entrypoints? -Yes. Treat `.runseal/wrappers/*.seal` as first-class wrappers executed directly -by runseal. `@transpile` is a debug/export tool, not the normal wrapper -execution path. +Yes. Treat `.runseal/wrappers/*.ts` as first-class wrappers executed by runseal +through the selected profile's `[deno]` policy. ### What should never be committed? diff --git a/README.md b/README.md index ddf3d86..37cc4d4 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,6 @@ Runseal inspection commands are read-only and do not run profile injections: 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.6.0"}' '.releaseVersion' runseal @wrappers runseal @which :ssh @@ -77,13 +76,11 @@ Use `runseal profile` without `@` to run an external command named `profile`. ## Examples -Repository-owned examples for canonical `.seal` and `@tool` shapes live under -[docs/examples](./docs/examples/README.md). +Repository-owned examples for canonical `@tool` and wrapper boundary shapes +live under [docs/examples](./docs/examples/README.md). -Start here when a wrapper shape feels "obvious in shell" but still needs the -exact runseal form: +Start here when an operator flow needs the exact runseal form: -- [Seal `case` / argv parser shapes](./docs/examples/seal/case.md) - [GitHub tool examples](./docs/examples/tools/github.md) ## Fit @@ -112,6 +109,7 @@ and internal command namespaces: - `symlink`: create symlinks for the command lifecycle, then clean them up. - `argv`: inject fixed arguments after a matching child command token. - `resource://...`: resolve profile-local resource paths inside env values. +- `[deno]`: declare the repo-level Deno wrapper policy for `.ts` wrappers. ## Install @@ -243,9 +241,9 @@ runseal :ssh host --run ./probe.sh -- arg Wrapper lookup order is: -1. `<profile-dir>/.runseal/wrappers/<name>.seal` +1. `<profile-dir>/.runseal/wrappers/<name>.ts` 2. `<profile-dir>/.runseal/wrappers/<name>.sh` -3. `$RUNSEAL_HOME/wrappers/<name>.seal` +3. `$RUNSEAL_HOME/wrappers/<name>.ts` 4. `$RUNSEAL_HOME/wrappers/<name>.sh` The profile directory is the directory containing `RUNSEAL_PROFILE_PATH`. @@ -255,56 +253,51 @@ The child working directory is not changed. A resolved wrapper receives: - `RUNSEAL_WRAPPER_NAME` - `RUNSEAL_WRAPPER_FILE` -Seal wrappers use the `.seal` suffix and are interpreted directly by runseal. -On Unix, shell wrappers use the `.sh` suffix and must be executable. -Extensionless files in `.runseal/wrappers` are not wrapper entrypoints; migrate -legacy wrappers to `<name>.seal` or `<name>.sh`. On Windows, runseal also -checks `.exe`, `.cmd`, and `.bat` when the wrapper name has no extension. - -### Seal wrappers - -`.seal` files are bash-runnable wrapper glue. They are meant for -cross-platform repository operations where the bash/PowerShell shared shape is -clear. The boundary is syntax shape, not script size: - -- ordinary command execution, assignment, functions, `if`, `while`, `case`, - `shift`, `"$@"`, command success predicates such as - `if git checkout "$branch"; then`, and command-scoped env overlays such as - `KUBECONFIG="$kubeconfig" kubectl "$@"` -- bash `[ ... ]` tests for ordinary predicates -- explicit `runseal @tool ...` calls for atomic glue where bash and PowerShell - do not share a clean expression - -Use `.seal` as the profile integration layer: it should pass caller-specific -paths, env names, and defaults explicitly. Keep reusable domain atoms in -`@tool`, such as SSH config inspection, stdin script execution, path-list -joining, branch slugging, Gitee PR API calls, and encrypted local archive -round trips. For example, a wrapper can expose `:ssh <host> --run <script>` -while `runseal @tool ssh script run` owns the stdin, argv forwarding, and host -config details. - -For example, a repo-local `:kube` wrapper can stay pure `.seal` by pushing file -enumeration and path joining into atomic tools: +Deno wrappers use the `.ts` suffix and are executed with `deno run` using the +selected profile's `[deno]` policy. On Unix, shell wrappers use the `.sh` +suffix and must be executable. Extensionless files in `.runseal/wrappers` are +not wrapper entrypoints; migrate legacy wrappers to `<name>.ts` or `<name>.sh`. +On Windows, runseal also checks `.exe`, `.cmd`, and `.bat` when the wrapper +name has no extension. -```bash -kube_dir=${PERISH_TOP_KUBE_DIR:?kube: missing PERISH_TOP_KUBE_DIR} -configs=$(runseal @tool fs list "$kube_dir" --glob "*.yaml" --files --require-nonempty) -kubeconfig=$(runseal @tool string join "$configs" --separator path) -KUBECONFIG="$kubeconfig" kubectl "$@" +### Deno wrappers + +Use `.ts` wrappers for structured cross-platform operations: argument parsing, +JSON handling, HTTP, polling, and small control flow that would otherwise turn +into an uncontrolled Python or shell dependency stack. Deno fits runseal when it +is an explicit single-binary prerequisite with a repo-declared permission +policy. + +Declare the policy in the selected profile: + +```toml +[deno] +config = ".runseal/deno.json" +lock = "deno.lock" +permissions = [ + "--allow-read=.", + "--allow-write=.", + "--allow-env", + "--allow-net", + "--allow-run=git,gh,runseal", +] ``` +Runseal adds `--no-prompt`, then the configured `--config`, `--lock` +`--frozen=true`, and permission flags before the wrapper file and caller args. +The wrapper still receives `RUNSEAL_WRAPPER_NAME`, `RUNSEAL_WRAPPER_FILE`, +`RUNSEAL_PROFILE_PATH`, `RUNSEAL_HOME`, `RUNSEAL_PROFILE_HOME`, and +`RUNSEAL_WRAPPER_PATH`. + +Keep reusable domain atoms in `@tool`, such as SSH config inspection, stdin +script execution, path-list joining, branch slugging, GitHub/Gitee PR API +calls, Cloudflare helpers, and encrypted local archive round trips. Use the +wrapper to bind repo-local policy, defaults, and flow around those atoms. + Prefer visible repo or local artifacts under `.runseal/` or `.local/` for multi-line config and payload text. The wrapper should usually validate preconditions, choose files, assemble arguments, and invoke the operational -flow, rather than build inline heredoc-style configuration. - -Runseal interprets `.seal` wrappers directly when called as `runseal :name`. -Use `runseal @transpile --input-lang=seal --output-lang=bash <file>` or -`--output-lang=powershell` to inspect generated targets. - -Seal is not intended to become a general scripting language. If a workflow -wants richer parsing, data structures, or platform-specific behavior, move that -part to Python, Ruby, JavaScript, etc. and call it from the wrapper. +flow, rather than build large inline text blobs. ## Internal Commands @@ -315,7 +308,6 @@ command instead of a literal program name: runseal @profile runseal @resources runseal @resolve resource:// resource://ssh/config -runseal @transpile --input-lang=seal --output-lang=sealir ./operator.seal runseal @wrappers runseal @which :ssh ``` @@ -328,10 +320,6 @@ read-only; `@tool` is the explicit atomic tool runtime. - `@resources` prints the resolved resource root. - `@resolve resource://...` prints resolved absolute resource paths, one per argument. -- `@transpile --input-lang=<lang> --output-lang=<lang> <source>` transpiles - 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, string, regex, integer, process, filesystem, archive, SSH config, GitHub, Gitee, and Cloudflare helpers. Run `runseal @tool --help` diff --git a/app/src/bin/runseal.rs b/app/src/bin/runseal.rs index 589a96b..8f6c623 100644 --- a/app/src/bin/runseal.rs +++ b/app/src/bin/runseal.rs @@ -7,7 +7,6 @@ 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; #[derive(Debug, Parser)] @@ -25,22 +24,19 @@ 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 -Seal wrappers: - .seal files are bash-runnable wrapper glue interpreted directly by runseal. - Use ordinary commands for shared behavior and runseal @tool for atomic glue - where bash and PowerShell do not share a clean expression. - Run runseal @transpile --help for Seal code-generation support. +Deno wrappers: + .ts files are run with deno using the repo-level [deno] profile policy. + Use TypeScript for structured operations and runseal @tool for atomic glue. 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, @tool --help, -@wrappers --help, or @which --help for details. +Run runseal @profile --help, @resolve --help, @tool --help, @wrappers --help, +or @which --help for details. Repository: https://github.com/PerishCode/runseal" )] @@ -90,12 +86,6 @@ fn build_runtime_config(cli: Cli) -> Result<RuntimeConfig> { fn run_early_internal(command: &[String]) -> Result<bool> { 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) diff --git a/app/src/core/internal_help.rs b/app/src/core/internal_help.rs index 3154772..3d41be3 100644 --- a/app/src/core/internal_help.rs +++ b/app/src/core/internal_help.rs @@ -13,7 +13,6 @@ fn text(name: &str) -> Result<&'static str> { "resolve" => Ok(RESOLVE), "resources" => Ok(RESOURCES), "tool" => Ok(crate::core::tool::help()), - "transpile" => Ok(TRANSPILE), "wrappers" => Ok(WRAPPERS), "which" => Ok(WHICH), _ => bail!("unknown internal command: @{name}"), @@ -70,71 +69,26 @@ Invalid resource paths include empty segments, '.', '..', backslashes, and ':' i path segments. Resolved paths are printed even when the target file does not exist. "; -const TRANSPILE: &str = "\ -Usage: runseal @transpile --input-lang=<lang> --output-lang=<lang> <source> - -Transpile one explicit glue language into another and print the result to stdout. - -Languages: - seal bash-runnable Seal wrapper glue - sealir JSON SealIR semantic form - bash bash output target - powershell PowerShell output target - -Seal source is intentionally a constrained bash subset. Prefer ordinary bash -syntax for control flow, argv parsing, tests, shift, and command execution. Use -runseal @tool as explicit glue for atomic behavior that does not have a clean -bash/PowerShell intersection. If a workflow wants a richer language, move that -part to Python, Ruby, JavaScript, etc. instead of expanding Seal. - -Cold-start supported paths: - bash -> sealir - bash -> seal - bash -> powershell - seal -> sealir - seal -> bash - seal -> powershell - powershell -> sealir - powershell -> seal - powershell -> bash - sealir -> seal - sealir -> bash - sealir -> powershell - -Examples: - runseal @transpile --input-lang=seal --output-lang=bash manage.seal - runseal @transpile --input-lang=seal --output-lang=powershell manage.seal - runseal @transpile --input-lang=seal --output-lang=sealir manage.seal - -@transpile is explicit code generation only. It does not infer languages, write -files, execute generated code, or run profile injections. -"; - const WRAPPERS: &str = "\ Usage: runseal @wrappers List the effective wrappers visible to the selected profile. Lookup order: - 1. <profile-dir>/.runseal/wrappers/<name>.seal + 1. <profile-dir>/.runseal/wrappers/<name>.ts 2. <profile-dir>/.runseal/wrappers/<name>.sh - 3. $RUNSEAL_HOME/wrappers/<name>.seal + 3. $RUNSEAL_HOME/wrappers/<name>.ts 4. $RUNSEAL_HOME/wrappers/<name>.sh Profile-local wrappers shadow home wrappers with the same name. On Unix, wrapper -shell files use the .sh suffix and must be executable. Seal wrappers use the -.seal suffix and are interpreted directly by runseal. Extensionless files in a -wrappers directory are not wrapper entrypoints; migrate legacy wrappers to -<name>.seal or <name>.sh. On Windows, runseal also checks .exe, .cmd, and .bat -when the wrapper name has no extension. - -.seal wrappers are bash-runnable wrapper glue. They are intended for -cross-platform repository operations where bash and PowerShell share a clear -shape: shell-shaped control flow, command success predicates, command-scoped env -overlays, and explicit runseal @tool calls for atomic glue. - -The boundary is syntax shape, not script size. Keep reusable domain atoms in -@tool and pass profile-specific paths or env names from the wrapper. +shell files use the .sh suffix and must be executable. Deno wrappers use the .ts +suffix and are executed with deno using the selected profile's [deno] policy. +Extensionless files in a wrappers directory are not wrapper entrypoints; migrate +legacy wrappers to <name>.ts or <name>.sh. On Windows, runseal also checks .exe, +.cmd, and .bat when the wrapper name has no extension. + +Use .ts wrappers for structured cross-platform operations and keep reusable +domain atoms in @tool. Use .sh for thin Unix bootstrap glue. @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 8f83d76..1c21755 100644 --- a/app/src/core/mod.rs +++ b/app/src/core/mod.rs @@ -6,4 +6,3 @@ pub mod internal_help; pub mod profile; pub mod runtime; pub mod tool; -pub mod transpile; diff --git a/app/src/core/profile.rs b/app/src/core/profile.rs index 56752c2..d0522c6 100644 --- a/app/src/core/profile.rs +++ b/app/src/core/profile.rs @@ -20,6 +20,8 @@ pub struct Profile { #[serde(default)] pub resources: Option<ResourcesProfile>, #[serde(default)] + pub deno: Option<DenoProfile>, + #[serde(default)] pub injections: Vec<InjectionProfile>, } @@ -34,6 +36,16 @@ pub struct ResourcesProfile { pub root: PathBuf, } +#[derive(Debug, Deserialize, Clone, Default)] +pub struct DenoProfile { + #[serde(default)] + pub config: Option<PathBuf>, + #[serde(default)] + pub lock: Option<PathBuf>, + #[serde(default)] + pub permissions: Vec<String>, +} + #[derive(Debug, Deserialize, Clone)] #[serde(tag = "type", rename_all = "lowercase")] pub enum InjectionProfile { @@ -139,6 +151,7 @@ pub fn load(path: &Path) -> Result<Profile> { path.display() ), }; + normalize_deno_paths(path, &mut profile)?; normalize_symlink_paths(path, &mut profile)?; let resources = profile.resources.clone(); normalize_env_resource_values(path, resources.as_ref(), &mut profile)?; @@ -209,6 +222,20 @@ fn normalize_symlink_paths(profile_path: &Path, profile: &mut Profile) -> Result Ok(()) } +fn normalize_deno_paths(profile_path: &Path, profile: &mut Profile) -> Result<()> { + let Some(deno) = &mut profile.deno else { + return Ok(()); + }; + let base_dir = profile_path.parent().unwrap_or(Path::new(".")); + if let Some(config) = &mut deno.config { + *config = normalize_path(config, base_dir)?; + } + if let Some(lock) = &mut deno.lock { + *lock = normalize_path(lock, base_dir)?; + } + Ok(()) +} + fn normalize_env_resource_values( profile_path: &Path, resources: Option<&ResourcesProfile>, diff --git a/app/src/core/runtime.rs b/app/src/core/runtime.rs index 33f1d0a..bb33833 100644 --- a/app/src/core/runtime.rs +++ b/app/src/core/runtime.rs @@ -6,8 +6,8 @@ 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, transpile}; +use super::profile::{DenoProfile, InjectionProfile, Profile}; +use super::{injections, profile}; mod wrapper_paths; @@ -42,15 +42,16 @@ pub fn run(app: &dyn AppContext) -> Result<RunResult> { } let profile = profile::load(&config.profile_path).context("unable to load runseal profile")?; - let command = resolve_command(config, &profile.injections)?; - let run_result = injections::with_registered_exports(app, profile.injections, |exports| { - let env = to_env_map(exports.to_vec())?; - let run_exports: Vec<(String, String)> = env.into_iter().collect(); - let code = run_command(config, &command, &run_exports)?; - Ok(RunResult { - exit_code: Some(code), - }) - })?; + let command = resolve_command(config, &profile)?; + let run_result = + injections::with_registered_exports(app, profile.injections.clone(), |exports| { + let env = to_env_map(exports.to_vec())?; + let run_exports: Vec<(String, String)> = env.into_iter().collect(); + let code = run_command(config, &profile, &command, &run_exports)?; + Ok(RunResult { + exit_code: Some(code), + }) + })?; Ok(run_result) } @@ -64,10 +65,7 @@ fn resolve_internal_dispatch(config: &RuntimeConfig) -> Result<Option<InternalCo Ok(Some(resolve_internal_command(&name, &config.command[1..])?)) } -fn resolve_command( - config: &RuntimeConfig, - injections: &[InjectionProfile], -) -> Result<ResolvedCommand> { +fn resolve_command(config: &RuntimeConfig, profile: &Profile) -> Result<ResolvedCommand> { if config.command.is_empty() { bail!("command mode requires at least one command token"); } @@ -83,7 +81,7 @@ fn resolve_command( } Ok(ResolvedCommand { - argv: apply_argv_injections(&config.command, injections)?, + argv: apply_argv_injections(&config.command, &profile.injections)?, wrapper: None, }) } @@ -273,6 +271,7 @@ fn to_env_map(exports: Vec<(String, String)>) -> Result<BTreeMap<String, String> fn run_command( config: &RuntimeConfig, + profile: &Profile, resolved: &ResolvedCommand, exports: &[(String, String)], ) -> Result<i32> { @@ -281,10 +280,9 @@ fn run_command( bail!("command mode requires at least one command token"); } if let Some(wrapper) = &resolved.wrapper - && wrapper_paths::is_seal(&wrapper.file) + && wrapper_paths::is_deno(&wrapper.file) { - let env = run_env(config, resolved, exports)?; - return transpile::run_seal_file(&wrapper.file, &resolved.argv[1..], &env); + return run_deno_wrapper(config, profile.deno.as_ref(), resolved, exports); } let mut child = child_command(resolved); @@ -292,6 +290,42 @@ fn run_command( child.env_remove("RUNSEAL_WRAPPER_FILE"); child.envs(run_env(config, resolved, exports)?); + wait_for_child(child) +} + +fn run_deno_wrapper( + config: &RuntimeConfig, + deno: Option<&DenoProfile>, + resolved: &ResolvedCommand, + exports: &[(String, String)], +) -> Result<i32> { + let deno = + deno.ok_or_else(|| anyhow::anyhow!("deno wrapper requires a [deno] profile policy"))?; + let wrapper = resolved + .wrapper + .as_ref() + .ok_or_else(|| anyhow::anyhow!("deno wrapper execution requires a resolved wrapper"))?; + let mut child = Command::new("deno"); + child.arg("run").arg("--no-prompt"); + if let Some(config) = &deno.config { + child.arg("--config").arg(config); + } + if let Some(lock) = &deno.lock { + child.arg("--lock").arg(lock); + child.arg("--frozen=true"); + } + child.args(&deno.permissions); + child.arg(&wrapper.file); + if resolved.argv.len() > 1 { + child.args(&resolved.argv[1..]); + } + child.env_remove("RUNSEAL_WRAPPER_NAME"); + child.env_remove("RUNSEAL_WRAPPER_FILE"); + child.envs(run_env(config, resolved, exports)?); + wait_for_child(child).context("failed to execute deno wrapper") +} + +fn wait_for_child(mut child: Command) -> Result<i32> { let status = child.status().context("failed to execute child command")?; if let Some(code) = status.code() { return Ok(code); diff --git a/app/src/core/runtime/wrapper_paths.rs b/app/src/core/runtime/wrapper_paths.rs index dc73e01..7f96150 100644 --- a/app/src/core/runtime/wrapper_paths.rs +++ b/app/src/core/runtime/wrapper_paths.rs @@ -74,12 +74,12 @@ 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") +pub(super) fn is_deno(path: &Path) -> bool { + path.extension().and_then(std::ffi::OsStr::to_str) == Some("ts") } fn is_runnable(path: &Path) -> bool { - if is_seal(path) { + if is_deno(path) { return path.is_file(); } is_executable(path) @@ -111,7 +111,7 @@ fn candidates(dir: &Path, name: &str) -> Vec<PathBuf> { return vec![dir.join(name)]; } vec![ - dir.join(format!("{name}.seal")), + dir.join(format!("{name}.ts")), dir.join(format!("{name}.sh")), ] } @@ -125,7 +125,7 @@ fn candidates(dir: &Path, name: &str) -> Vec<PathBuf> { [exact] .into_iter() .chain( - ["seal", "exe", "cmd", "bat"] + ["ts", "exe", "cmd", "bat"] .into_iter() .map(|ext| dir.join(format!("{name}.{ext}"))), ) @@ -151,7 +151,7 @@ fn is_executable(path: &Path) -> bool { #[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") { + if path.extension().and_then(std::ffi::OsStr::to_str) == Some("ts") { let stem = path.file_stem()?.to_str()?; validate_symbol_name(stem).ok()?; return Some(stem.to_string()); @@ -167,7 +167,7 @@ fn listed_name(path: &Path) -> Option<String> { 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"]) + && matches_ignore_ascii_case(ext, &["ts", "exe", "cmd", "bat"]) { let stem = path.file_stem()?.to_str()?; validate_symbol_name(stem).ok()?; diff --git a/app/src/core/tool/help/basic.rs b/app/src/core/tool/help/basic.rs index 59bfd7f..5bb8f39 100644 --- a/app/src/core/tool/help/basic.rs +++ b/app/src/core/tool/help/basic.rs @@ -178,7 +178,7 @@ pub const ARCHIVE_LOCAL_EXPORT: Entry = Entry { ], }], examples: &[ - "runseal @tool archive local export --source .local --archive backup.seal --password-env ESTATE_LOCAL_PASSWORD", + "runseal @tool archive local export --source .local --archive backup.local.archive --password-env ESTATE_LOCAL_PASSWORD", ], }; @@ -203,7 +203,7 @@ pub const ARCHIVE_LOCAL_IMPORT: Entry = Entry { ], }], examples: &[ - "runseal @tool archive local import --source .local --archive backup.seal --password-env ESTATE_LOCAL_PASSWORD --force", + "runseal @tool archive local import --source .local --archive backup.local.archive --password-env ESTATE_LOCAL_PASSWORD --force", ], }; diff --git a/app/src/core/transpile/ast.rs b/app/src/core/transpile/ast.rs deleted file mode 100644 index d883232..0000000 --- a/app/src/core/transpile/ast.rs +++ /dev/null @@ -1,174 +0,0 @@ -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct Program { - pub version: u32, - pub items: Vec<Item>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum Item { - Function { name: String, body: Vec<Statement> }, - Statement { statement: Statement }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum Statement { - Assign { - name: String, - value: Value, - }, - ExecWrite { - stream: OutputStream, - path: Value, - append: bool, - argv: Vec<Value>, - }, - ExecChecked { - argv: Vec<Value>, - }, - EnvExecChecked { - env: Vec<EnvAssign>, - argv: Vec<Value>, - }, - Shift { - count: usize, - }, - ArgvParse { - specs: Vec<ArgvSpec>, - positional: Option<ArgvPositional>, - }, - CaptureChecked { - name: String, - argv: Vec<Value>, - }, - CaptureFunction { - name: String, - function: String, - argv: Vec<Value>, - }, - If { - predicate: Predicate, - then_body: Vec<Statement>, - else_body: Vec<Statement>, - }, - While { - predicate: Predicate, - body: Vec<Statement>, - }, - Case { - value: Value, - arms: Vec<CaseArm>, - }, - CallFunction { - name: String, - argv: Vec<Value>, - }, - Print { - value: Value, - }, - Error { - value: Value, - }, - Fail { - value: Value, - }, - Exit { - code: i32, - }, - Break, - Sleep { - seconds: u64, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct CaseArm { - pub patterns: Vec<String>, - pub body: Vec<Statement>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ArgvSpec { - pub name: String, - pub kind: ArgvKind, - pub default: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ArgvPositional { - pub name: String, - pub default: String, - pub extra_error: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct EnvAssign { - pub name: String, - pub value: Value, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ArgvKind { - String, - Flag, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum Value { - Literal { - text: String, - }, - Argc, - Args, - Expand { - source: ValueSource, - op: ExpansionOp, - }, - Concat { - parts: Vec<Value>, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ValueSource { - Var { name: String }, - Env { name: String }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ExpansionOp { - Plain, - DefaultIfUnsetOrEmpty { fallback: String }, - RequireNonEmpty { message: String }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum Predicate { - Command { argv: Vec<Value> }, - Empty { value: Value }, - NotEmpty { value: Value }, - Eq { left: Value, right: Value }, - Neq { left: Value, right: Value }, - IntLt { left: Value, right: Value }, - IntLte { left: Value, right: Value }, - IntGt { left: Value, right: Value }, - IntGte { left: Value, right: Value }, - JsonEmpty { value: Value }, - JsonNotEmpty { value: Value }, - FileExists { path: Value }, - DirExists { path: Value }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum OutputStream { - Stdout, - Stderr, -} -use serde::{Deserialize, Serialize}; diff --git a/app/src/core/transpile/emit/mod.rs b/app/src/core/transpile/emit/mod.rs deleted file mode 100644 index a47950b..0000000 --- a/app/src/core/transpile/emit/mod.rs +++ /dev/null @@ -1,490 +0,0 @@ -use super::ast::{ArgvKind, ArgvPositional, ArgvSpec, Item, OutputStream, Program, Statement}; -use super::guards::{bash_required_tools, emit_bash_guards}; - -mod powershell; -mod powershell_support; -mod support; - -pub(crate) use powershell::emit_powershell; -use support::{ - bash_predicate, bash_value, generated_header, join_values, option_name, seal_value, sh_quote, -}; - -pub(crate) fn emit_seal(program: &Program) -> String { - let mut out = String::new(); - for item in &program.items { - match item { - Item::Function { name, body } => { - out.push_str(name); - out.push_str("() {\n"); - emit_seal_statements(&mut out, body, 1); - out.push_str("}\n"); - } - Item::Statement { statement } => emit_seal_statement(&mut out, statement, 0), - } - } - out -} - -fn emit_seal_statements(out: &mut String, statements: &[Statement], indent: usize) { - for statement in statements { - emit_seal_statement(out, statement, indent); - } -} - -fn emit_seal_statement(out: &mut String, statement: &Statement, indent: usize) { - let pad = " ".repeat(indent); - match statement { - Statement::Assign { name, value } => { - out.push_str(&format!("{pad}{name}={}\n", seal_value(value))); - } - Statement::ExecChecked { argv } => { - out.push_str(&pad); - out.push_str(&join_values(argv, seal_value)); - out.push('\n'); - } - Statement::ExecWrite { - stream, - path, - append, - argv, - } => { - out.push_str(&pad); - out.push_str(&join_values(argv, seal_value)); - out.push(' '); - out.push_str(match (stream, append) { - (OutputStream::Stdout, false) => ">", - (OutputStream::Stdout, true) => ">>", - (OutputStream::Stderr, false) => "2>", - (OutputStream::Stderr, true) => "2>>", - }); - out.push(' '); - out.push_str(&seal_value(path)); - out.push('\n'); - } - Statement::EnvExecChecked { env, argv } => { - out.push_str(&pad); - for item in env { - out.push_str(&item.name); - out.push('='); - out.push_str(&seal_value(&item.value)); - out.push(' '); - } - out.push_str(&join_values(argv, seal_value)); - out.push('\n'); - } - Statement::Shift { count } => { - out.push_str(&pad); - out.push_str("shift"); - if *count != 1 { - out.push(' '); - out.push_str(&count.to_string()); - } - out.push('\n'); - } - Statement::ArgvParse { specs, positional } => { - emit_seal_argv_parse(out, specs, positional.as_ref(), indent); - } - Statement::CaptureChecked { name, argv } => { - out.push_str(&pad); - out.push_str(name); - out.push_str("=$("); - out.push_str(&join_values(argv, seal_value)); - out.push_str(")\n"); - } - Statement::CaptureFunction { - name, - function, - argv, - } => { - out.push_str(&pad); - out.push_str(name); - out.push_str("=$("); - out.push_str(function); - if !argv.is_empty() { - out.push(' '); - out.push_str(&join_values(argv, seal_value)); - } - out.push_str(")\n"); - } - Statement::If { - predicate, - then_body, - else_body, - } => { - out.push_str(&format!("{pad}if {}; then\n", bash_predicate(predicate))); - emit_seal_statements(out, then_body, indent + 1); - if !else_body.is_empty() { - out.push_str(&format!("{pad}else\n")); - emit_seal_statements(out, else_body, indent + 1); - } - out.push_str(&format!("{pad}fi\n")); - } - Statement::While { predicate, body } => { - out.push_str(&format!("{pad}while {}; do\n", bash_predicate(predicate))); - emit_seal_statements(out, body, indent + 1); - out.push_str(&format!("{pad}done\n")); - } - Statement::Case { value, arms } => { - out.push_str(&format!("{pad}case {} in\n", seal_value(value))); - for arm in arms { - out.push_str(&format!("{pad} {})\n", arm.patterns.join("|"))); - emit_seal_statements(out, &arm.body, indent + 2); - out.push_str(&format!("{pad} ;;\n")); - } - out.push_str(&format!("{pad}esac\n")); - } - Statement::CallFunction { name, argv } => { - out.push_str(&pad); - out.push_str(name); - if !argv.is_empty() { - out.push(' '); - out.push_str(&join_values(argv, seal_value)); - } - out.push('\n'); - } - Statement::Print { value } => out.push_str(&format!("{pad}print {}\n", seal_value(value))), - Statement::Error { value } => out.push_str(&format!("{pad}error {}\n", seal_value(value))), - Statement::Fail { value } => out.push_str(&format!("{pad}fail {}\n", seal_value(value))), - Statement::Exit { code } => out.push_str(&format!("{pad}exit {code}\n")), - Statement::Break => out.push_str(&format!("{pad}break\n")), - Statement::Sleep { seconds } => out.push_str(&format!("{pad}sleep {seconds}\n")), - } -} - -fn emit_seal_argv_parse( - out: &mut String, - specs: &[ArgvSpec], - positional: Option<&ArgvPositional>, - 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 => spec.default.as_deref().unwrap_or(""), - ArgvKind::Flag => "false", - }; - out.push_str(&format!("{pad}{}={value}\n", spec.name)); - } - if let Some(positional) = positional { - out.push_str(&format!( - "{pad}{}={}\n", - positional.name, positional.default - )); - } - out.push_str(&format!("{pad}while [ \"$#\" -gt 0 ]; do\n")); - out.push_str(&format!("{pad} case \"$1\" in\n")); - for spec in specs { - match spec.kind { - ArgvKind::String => emit_seal_string_option(out, spec, indent), - ArgvKind::Flag => emit_seal_flag_option(out, spec, indent), - } - } - out.push_str(&format!("{pad} --)\n")); - 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")); - if let Some(positional) = positional { - out.push_str(&format!("{pad} *)\n")); - out.push_str(&format!( - "{pad} if [ -z \"${}\" ]; then\n", - positional.name - )); - out.push_str(&format!("{pad} {}=$1\n", positional.name)); - out.push_str(&format!("{pad} shift\n")); - out.push_str(&format!("{pad} else\n")); - out.push_str(&format!( - "{pad} fail \"{}\"\n", - positional.extra_error - )); - out.push_str(&format!("{pad} fi\n")); - out.push_str(&format!("{pad} ;;\n")); - } else { - out.push_str(&format!("{pad} *) fail \"unknown option: $1\" ;;\n")); - } - out.push_str(&format!("{pad} esac\n")); - out.push_str(&format!("{pad}done\n")); -} - -fn emit_seal_string_option(out: &mut String, spec: &ArgvSpec, indent: usize) { - let pad = " ".repeat(indent); - let option = option_name(&spec.name); - out.push_str(&format!("{pad} {option})\n")); - out.push_str(&format!( - "{pad} if [ \"$#\" -lt 2 ]; then fail 'missing value for {option}'; fi\n" - )); - out.push_str(&format!("{pad} {}=$2\n", spec.name)); - out.push_str(&format!("{pad} shift 2\n")); - out.push_str(&format!("{pad} ;;\n")); - out.push_str(&format!("{pad} {option}=*)\n")); - out.push_str(&format!("{pad} {}=${{1#{option}=}}\n", spec.name)); - out.push_str(&format!("{pad} shift\n")); - out.push_str(&format!("{pad} ;;\n")); -} - -fn emit_seal_flag_option(out: &mut String, spec: &ArgvSpec, indent: usize) { - let pad = " ".repeat(indent); - let option = option_name(&spec.name); - out.push_str(&format!("{pad} {option})\n")); - out.push_str(&format!("{pad} {}=true\n", spec.name)); - out.push_str(&format!("{pad} shift\n")); - out.push_str(&format!("{pad} ;;\n")); -} - -pub(crate) fn emit_bash(program: &Program, source_name: Option<&str>) -> String { - let mut out = generated_header("bash", source_name); - out.push_str("set -euo pipefail\n\n"); - out.push_str("seal_fail() {\n printf '%s\\n' \"$1\" >&2\n exit 1\n}\n\n"); - emit_bash_guards(&mut out, &bash_required_tools(program)); - emit_bash_items(&mut out, program); - out -} - -fn emit_bash_items(out: &mut String, program: &Program) { - for item in &program.items { - match item { - Item::Function { name, body } => { - out.push_str(name); - out.push_str("() {\n"); - emit_bash_body(out, body, 1); - out.push_str("}\n\n"); - } - Item::Statement { statement } => emit_bash_statement(out, statement, 0), - } - } -} - -fn emit_bash_body(out: &mut String, statements: &[Statement], indent: usize) { - if statements.is_empty() { - let pad = " ".repeat(indent); - out.push_str(&format!("{pad}:\n")); - } else { - emit_bash_statements(out, statements, indent); - } -} - -fn emit_bash_statements(out: &mut String, statements: &[Statement], indent: usize) { - for statement in statements { - emit_bash_statement(out, statement, indent); - } -} - -fn emit_bash_statement(out: &mut String, statement: &Statement, indent: usize) { - let pad = " ".repeat(indent); - match statement { - Statement::Assign { name, value } => { - out.push_str(&format!("{pad}{name}={}\n", bash_value(value))); - } - Statement::ExecWrite { - stream, - path, - append, - argv, - } => { - out.push_str(&pad); - out.push_str(&join_values(argv, bash_value)); - out.push(' '); - out.push_str(match (stream, append) { - (OutputStream::Stdout, false) => ">", - (OutputStream::Stdout, true) => ">>", - (OutputStream::Stderr, false) => "2>", - (OutputStream::Stderr, true) => "2>>", - }); - out.push(' '); - out.push_str(&bash_value(path)); - out.push('\n'); - } - Statement::ExecChecked { argv } => { - out.push_str(&pad); - out.push_str(&join_values(argv, bash_value)); - out.push('\n'); - } - Statement::EnvExecChecked { env, argv } => { - out.push_str(&pad); - for item in env { - out.push_str(&item.name); - out.push('='); - out.push_str(&bash_value(&item.value)); - out.push(' '); - } - out.push_str(&join_values(argv, bash_value)); - out.push('\n'); - } - Statement::Shift { count } => { - out.push_str(&pad); - out.push_str("shift"); - if *count != 1 { - out.push(' '); - out.push_str(&count.to_string()); - } - out.push('\n'); - } - Statement::ArgvParse { specs, positional } => { - emit_bash_argv_parse(out, specs, positional.as_ref(), indent) - } - Statement::CaptureChecked { name, argv } => { - out.push_str(&pad); - out.push_str(name); - out.push_str("=$("); - out.push_str(&join_values(argv, bash_value)); - out.push_str(")\n"); - } - Statement::CaptureFunction { - name, - function, - argv, - } => { - out.push_str(&pad); - out.push_str(name); - out.push_str("=$("); - out.push_str(function); - if !argv.is_empty() { - out.push(' '); - out.push_str(&join_values(argv, bash_value)); - } - out.push_str(")\n"); - } - Statement::If { - predicate, - then_body, - else_body, - } => { - out.push_str(&format!("{pad}if {}; then\n", bash_predicate(predicate))); - emit_bash_body(out, then_body, indent + 1); - if !else_body.is_empty() { - out.push_str(&format!("{pad}else\n")); - emit_bash_body(out, else_body, indent + 1); - } - out.push_str(&format!("{pad}fi\n")); - } - Statement::While { predicate, body } => { - out.push_str(&format!("{pad}while {}; do\n", bash_predicate(predicate))); - emit_bash_body(out, body, indent + 1); - out.push_str(&format!("{pad}done\n")); - } - Statement::Case { value, arms } => { - out.push_str(&format!("{pad}case {} in\n", bash_value(value))); - for arm in arms { - out.push_str(&format!("{pad} {})\n", arm.patterns.join("|"))); - emit_bash_body(out, &arm.body, indent + 2); - out.push_str(&format!("{pad} ;;\n")); - } - out.push_str(&format!("{pad}esac\n")); - } - Statement::CallFunction { name, argv } => { - out.push_str(&pad); - out.push_str(name); - if !argv.is_empty() { - out.push(' '); - out.push_str(&join_values(argv, bash_value)); - } - out.push('\n'); - } - Statement::Print { value } => { - out.push_str(&format!("{pad}printf '%s\\n' {}\n", bash_value(value))); - } - Statement::Error { value } => { - out.push_str(&format!("{pad}printf '%s\\n' {} >&2\n", bash_value(value))); - } - Statement::Fail { value } => { - out.push_str(&format!("{pad}seal_fail {}\n", bash_value(value))); - } - Statement::Exit { code } => out.push_str(&format!("{pad}exit {code}\n")), - Statement::Break => out.push_str(&format!("{pad}break\n")), - Statement::Sleep { seconds } => out.push_str(&format!("{pad}sleep {seconds}\n")), - } -} - -fn emit_bash_argv_parse( - out: &mut String, - specs: &[ArgvSpec], - positional: Option<&ArgvPositional>, - 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("")), - ArgvKind::Flag => "false".to_string(), - }; - out.push_str(&format!("{pad}{}={value}\n", spec.name)); - } - if let Some(positional) = positional { - out.push_str(&format!( - "{pad}{}={}\n", - positional.name, - sh_quote(&positional.default) - )); - } - out.push_str(&format!("{pad}while [ \"$#\" -gt 0 ]; do\n")); - out.push_str(&format!("{pad} case \"$1\" in\n")); - for spec in specs { - match spec.kind { - ArgvKind::String => emit_bash_string_option(out, spec, indent), - ArgvKind::Flag => emit_bash_flag_option(out, spec, indent), - } - } - out.push_str(&format!("{pad} --)\n")); - 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")); - if let Some(positional) = positional { - out.push_str(&format!("{pad} *)\n")); - out.push_str(&format!( - "{pad} if [ -z \"${}\" ]; then\n", - positional.name - )); - out.push_str(&format!("{pad} {}=$1\n", positional.name)); - out.push_str(&format!("{pad} shift\n")); - out.push_str(&format!("{pad} else\n")); - out.push_str(&format!( - "{pad} seal_fail \"{}\"\n", - positional.extra_error - )); - out.push_str(&format!("{pad} fi\n")); - out.push_str(&format!("{pad} ;;\n")); - } else { - out.push_str(&format!( - "{pad} *) seal_fail \"unknown option: $1\" ;;\n" - )); - } - out.push_str(&format!("{pad} esac\n")); - out.push_str(&format!("{pad}done\n")); -} - -fn emit_bash_string_option(out: &mut String, spec: &ArgvSpec, indent: usize) { - let pad = " ".repeat(indent); - let option = option_name(&spec.name); - out.push_str(&format!("{pad} {option})\n")); - out.push_str(&format!( - "{pad} if [ \"$#\" -lt 2 ]; then seal_fail 'missing value for {option}'; fi\n" - )); - out.push_str(&format!("{pad} {}=$2\n", spec.name)); - out.push_str(&format!("{pad} shift 2\n")); - out.push_str(&format!("{pad} ;;\n")); - out.push_str(&format!("{pad} {option}=*)\n")); - out.push_str(&format!("{pad} {}=${{1#{option}=}}\n", spec.name)); - out.push_str(&format!("{pad} shift\n")); - out.push_str(&format!("{pad} ;;\n")); -} - -fn emit_bash_flag_option(out: &mut String, spec: &ArgvSpec, indent: usize) { - let pad = " ".repeat(indent); - let option = option_name(&spec.name); - out.push_str(&format!("{pad} {option})\n")); - out.push_str(&format!("{pad} {}=true\n", spec.name)); - out.push_str(&format!("{pad} shift\n")); - out.push_str(&format!("{pad} ;;\n")); -} diff --git a/app/src/core/transpile/emit/powershell.rs b/app/src/core/transpile/emit/powershell.rs deleted file mode 100644 index 2947eb8..0000000 --- a/app/src/core/transpile/emit/powershell.rs +++ /dev/null @@ -1,396 +0,0 @@ -use super::powershell_support::{emit_positional_bindings, max_positional_statements}; -use super::support::generated_header; -use crate::core::transpile::ast::{ - EnvAssign, ExpansionOp, Item, OutputStream, Predicate, Program, Statement, Value, ValueSource, -}; - -#[path = "powershell_argv.rs"] -mod powershell_argv; -use self::powershell_argv::emit_argv_parse; - -pub(crate) fn emit_powershell(program: &Program, source_name: Option<&str>) -> String { - let mut out = generated_header("powershell", source_name); - out.push_str("$ErrorActionPreference = 'Stop'\n\n"); - let top_level = program - .items - .iter() - .filter_map(|item| match item { - Item::Statement { statement } => Some(statement), - Item::Function { .. } => None, - }) - .collect::<Vec<_>>(); - if emit_positional_bindings( - &mut out, - 0, - max_positional_statements(top_level.iter().copied()), - ) { - out.push('\n'); - } - emit_items(&mut out, program); - out -} - -fn emit_items(out: &mut String, program: &Program) { - let top_level_max = max_positional_statements(program.items.iter().filter_map(|item| { - if let Item::Statement { statement } = item { - Some(statement) - } else { - None - } - })); - for item in &program.items { - match item { - Item::Function { name, body } => { - out.push_str(&format!("function {name} {{\n")); - emit_positional_bindings(out, 1, max_positional_statements(body.iter())); - emit_statements(out, body, 1); - out.push_str("}\n\n"); - } - Item::Statement { statement } => emit_statement(out, statement, 0, top_level_max), - } - } -} - -fn emit_statements(out: &mut String, statements: &[Statement], indent: usize) { - let positional_max = max_positional_statements(statements.iter()); - for statement in statements { - emit_statement(out, statement, indent, positional_max); - } -} - -fn emit_statement(out: &mut String, statement: &Statement, indent: usize, positional_max: usize) { - let pad = " ".repeat(indent); - match statement { - Statement::Assign { name, value } => { - out.push_str(&format!("{pad}${name} = {}\n", powershell_value(value))); - } - Statement::ExecWrite { - stream, - path, - append, - argv, - } => { - out.push_str(&pad); - out.push_str("& "); - out.push_str(&join_values(argv, powershell_value)); - out.push(' '); - out.push_str(match (stream, append) { - (OutputStream::Stdout, false) => ">", - (OutputStream::Stdout, true) => ">>", - (OutputStream::Stderr, false) => "2>", - (OutputStream::Stderr, true) => "2>>", - }); - out.push(' '); - out.push_str(&powershell_value(path)); - out.push('\n'); - } - Statement::ExecChecked { argv } => { - out.push_str(&pad); - out.push_str("& "); - out.push_str(&join_values(argv, powershell_value)); - out.push('\n'); - } - Statement::EnvExecChecked { env, argv } => emit_env_exec(out, &pad, env, argv), - Statement::Shift { count } => { - if *count == 0 { - out.push_str(&format!("{pad}$args = @($args)\n")); - } else { - out.push_str(&format!( - "{pad}$args = if ($args.Count -gt {count}) {{ @($args[{count}..($args.Count - 1)]) }} else {{ @() }}\n" - )); - } - emit_positional_bindings(out, indent, positional_max); - } - Statement::ArgvParse { specs, positional } => { - emit_argv_parse(out, specs, positional.as_ref(), indent) - } - Statement::CaptureChecked { name, argv } => { - out.push_str(&pad); - out.push_str(&format!("${name} = & ")); - out.push_str(&join_values(argv, powershell_value)); - out.push('\n'); - } - Statement::CaptureFunction { - name, - function, - argv, - } => { - out.push_str(&pad); - out.push_str(&format!("${name} = & {function}")); - if !argv.is_empty() { - out.push(' '); - out.push_str(&join_values(argv, powershell_value)); - } - out.push('\n'); - } - Statement::If { - predicate, - then_body, - else_body, - } => emit_if(out, &pad, predicate, then_body, else_body, indent), - Statement::While { predicate, body } => { - emit_while(out, &pad, predicate, body, indent); - } - Statement::Case { value, arms } => { - out.push_str(&format!("{pad}switch ({}) {{\n", powershell_value(value))); - for arm in arms { - for pattern in &arm.patterns { - let pattern = if pattern == "*" { - "Default".to_string() - } else { - powershell_quote(pattern) - }; - out.push_str(&format!("{pad} {pattern} {{\n")); - emit_statements(out, &arm.body, indent + 2); - out.push_str(&format!("{pad} break\n")); - out.push_str(&format!("{pad} }}\n")); - } - } - out.push_str(&format!("{pad}}}\n")); - } - Statement::CallFunction { name, argv } => { - out.push_str(&pad); - out.push_str(name); - if !argv.is_empty() { - out.push(' '); - out.push_str(&join_values(argv, powershell_value)); - } - out.push('\n'); - } - Statement::Print { value } => { - out.push_str(&format!("{pad}Write-Output {}\n", powershell_value(value))); - } - Statement::Error { value } => { - out.push_str(&format!( - "{pad}[Console]::Error.WriteLine({})\n", - powershell_value(value) - )); - } - Statement::Fail { value } => { - out.push_str(&format!("{pad}throw {}\n", powershell_value(value))); - } - Statement::Exit { code } => out.push_str(&format!("{pad}exit {code}\n")), - Statement::Break => out.push_str(&format!("{pad}break\n")), - Statement::Sleep { seconds } => { - out.push_str(&format!("{pad}Start-Sleep -Seconds {seconds}\n")); - } - } -} - -fn emit_if( - out: &mut String, - pad: &str, - predicate: &Predicate, - then_body: &[Statement], - else_body: &[Statement], - indent: usize, -) { - if let Predicate::Command { argv } = predicate { - out.push_str(&format!("{pad}& ")); - out.push_str(&join_values(argv, powershell_value)); - out.push('\n'); - out.push_str(&format!("{pad}if ($LASTEXITCODE -eq 0) {{\n")); - emit_statements(out, then_body, indent + 1); - if else_body.is_empty() { - out.push_str(&format!("{pad}}}\n")); - } else { - out.push_str(&format!("{pad}}} else {{\n")); - emit_statements(out, else_body, indent + 1); - out.push_str(&format!("{pad}}}\n")); - } - return; - } - out.push_str(&format!("{pad}if ({}) {{\n", predicate_text(predicate))); - emit_statements(out, then_body, indent + 1); - if else_body.is_empty() { - out.push_str(&format!("{pad}}}\n")); - } else { - out.push_str(&format!("{pad}}} else {{\n")); - emit_statements(out, else_body, indent + 1); - out.push_str(&format!("{pad}}}\n")); - } -} - -fn emit_while( - out: &mut String, - pad: &str, - predicate: &Predicate, - body: &[Statement], - indent: usize, -) { - if let Predicate::Command { argv } = predicate { - out.push_str(&format!("{pad}while ($true) {{\n")); - let inner = " ".repeat(indent + 1); - out.push_str(&format!("{inner}& ")); - out.push_str(&join_values(argv, powershell_value)); - out.push('\n'); - out.push_str(&format!( - "{inner}if ($LASTEXITCODE -ne 0) {{\n{inner} break\n{inner}}}\n" - )); - emit_statements(out, body, indent + 1); - out.push_str(&format!("{pad}}}\n")); - return; - } - out.push_str(&format!("{pad}while ({}) {{\n", predicate_text(predicate))); - emit_statements(out, body, indent + 1); - out.push_str(&format!("{pad}}}\n")); -} - -fn predicate_text(predicate: &Predicate) -> String { - match predicate { - Predicate::Command { argv } => { - format!( - "(& {}; $LASTEXITCODE) -eq 0", - join_values(argv, powershell_value) - ) - } - Predicate::Empty { value } => { - format!("[string]::IsNullOrEmpty({})", powershell_value(value)) - } - Predicate::NotEmpty { value } => { - format!("![string]::IsNullOrEmpty({})", powershell_value(value)) - } - Predicate::Eq { left, right } => { - format!("{} -eq {}", powershell_value(left), powershell_value(right)) - } - Predicate::Neq { left, right } => { - format!("{} -ne {}", powershell_value(left), powershell_value(right)) - } - Predicate::IntLt { left, right } => int_compare(left, "-lt", right), - Predicate::IntLte { left, right } => int_compare(left, "-le", right), - Predicate::IntGt { left, right } => int_compare(left, "-gt", right), - Predicate::IntGte { left, right } => int_compare(left, "-ge", right), - Predicate::JsonEmpty { value } => { - format!( - "(& 'runseal' '@tool' 'json' 'empty' {}) -eq 'true'", - powershell_value(value) - ) - } - Predicate::JsonNotEmpty { value } => { - format!( - "(& 'runseal' '@tool' 'json' 'empty' {}) -eq 'false'", - powershell_value(value) - ) - } - Predicate::FileExists { path } => { - format!( - "Test-Path -LiteralPath {} -PathType Leaf", - powershell_value(path) - ) - } - Predicate::DirExists { path } => { - format!( - "Test-Path -LiteralPath {} -PathType Container", - powershell_value(path) - ) - } - } -} - -fn int_compare(left: &Value, operator: &str, right: &Value) -> String { - format!( - "[int]{} {operator} {}", - powershell_value(left), - powershell_value(right) - ) -} - -fn powershell_value(value: &Value) -> String { - match value { - Value::Literal { text } => powershell_quote(text), - Value::Argc => "$args.Count".to_string(), - Value::Args => "@args".to_string(), - Value::Expand { source, op } => powershell_expand(source, op), - Value::Concat { parts } => { - if parts.is_empty() { - return "''".to_string(); - } - let value = parts - .iter() - .map(powershell_value) - .collect::<Vec<_>>() - .join(" + "); - format!("({value})") - } - } -} - -fn powershell_expand(source: &ValueSource, op: &ExpansionOp) -> String { - match op { - ExpansionOp::Plain => powershell_source_value(source), - ExpansionOp::DefaultIfUnsetOrEmpty { fallback } => { - powershell_guarded_expand(source, fallback, false) - } - ExpansionOp::RequireNonEmpty { message } => { - powershell_guarded_expand(source, message, true) - } - } -} - -fn powershell_source_value(source: &ValueSource) -> String { - match source { - ValueSource::Var { name } => format!("${name}"), - ValueSource::Env { name } => format!("$env:{name}"), - } -} - -fn powershell_guarded_expand(source: &ValueSource, text: &str, require: bool) -> String { - let fallback_or_message = powershell_quote(text); - let action = if require { - format!("throw {fallback_or_message}") - } else { - fallback_or_message - }; - match source { - ValueSource::Env { name } => format!( - "$(if ([string]::IsNullOrEmpty($env:{name})) {{ {action} }} else {{ $env:{name} }})" - ), - ValueSource::Var { name } if name.bytes().all(|byte| byte.is_ascii_digit()) => { - let index = name.parse::<usize>().unwrap_or_default(); - format!( - "$(if (($args.Count -lt {index}) -or [string]::IsNullOrEmpty(${name})) {{ {action} }} else {{ ${name} }})" - ) - } - ValueSource::Var { name } => { - format!("$(if ([string]::IsNullOrEmpty(${name})) {{ {action} }} else {{ ${name} }})") - } - } -} - -fn emit_env_exec(out: &mut String, pad: &str, env: &[EnvAssign], argv: &[Value]) { - out.push_str(&format!("{pad}& {{\n")); - for item in env { - out.push_str(&format!( - "{pad} $__seal_old_env_{} = $env:{}\n", - item.name, item.name - )); - } - out.push_str(&format!("{pad} try {{\n")); - for item in env { - out.push_str(&format!( - "{pad} $env:{} = {}\n", - item.name, - powershell_value(&item.value) - )); - } - out.push_str(&format!("{pad} & ")); - out.push_str(&join_values(argv, powershell_value)); - out.push('\n'); - out.push_str(&format!("{pad} }} finally {{\n")); - for item in env { - out.push_str(&format!( - "{pad} $env:{} = $__seal_old_env_{}\n", - item.name, item.name - )); - } - out.push_str(&format!("{pad} }}\n")); - out.push_str(&format!("{pad}}}\n")); -} - -fn join_values(values: &[Value], format: fn(&Value) -> String) -> String { - values.iter().map(format).collect::<Vec<_>>().join(" ") -} - -fn powershell_quote(value: &str) -> String { - format!("'{}'", value.replace('\'', "''")) -} diff --git a/app/src/core/transpile/emit/powershell_argv.rs b/app/src/core/transpile/emit/powershell_argv.rs deleted file mode 100644 index 0108e2b..0000000 --- a/app/src/core/transpile/emit/powershell_argv.rs +++ /dev/null @@ -1,113 +0,0 @@ -use crate::core::transpile::ast::{ArgvKind, ArgvPositional, ArgvSpec}; -use crate::core::transpile::emit::support::option_name; - -use super::powershell_quote; - -pub(super) fn emit_argv_parse( - out: &mut String, - specs: &[ArgvSpec], - positional: Option<&ArgvPositional>, - 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("")), - ArgvKind::Flag => "'false'".to_string(), - }; - out.push_str(&format!("{pad}${} = {value}\n", spec.name)); - } - if let Some(positional) = positional { - out.push_str(&format!( - "{pad}${} = {}\n", - positional.name, - powershell_quote(&positional.default) - )); - } - out.push_str(&format!("{pad}$__seal_index = 0\n")); - out.push_str(&format!("{pad}while ($__seal_index -lt $args.Count) {{\n")); - out.push_str(&format!("{pad} $__seal_arg = $args[$__seal_index]\n")); - out.push_str(&format!("{pad} switch -Regex ($__seal_arg) {{\n")); - for spec in specs { - match spec.kind { - ArgvKind::String => emit_string_option(out, spec, indent), - ArgvKind::Flag => emit_flag_option(out, spec, indent), - } - } - out.push_str(&format!("{pad} '^--$' {{\n")); - 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")); - if let Some(positional) = positional { - out.push_str(&format!("{pad} default {{\n")); - out.push_str(&format!( - "{pad} if ([string]::IsNullOrEmpty(${})) {{\n", - positional.name - )); - out.push_str(&format!( - "{pad} ${} = $__seal_arg\n", - positional.name - )); - out.push_str(&format!("{pad} $__seal_index += 1\n")); - out.push_str(&format!("{pad} break\n")); - out.push_str(&format!("{pad} }} else {{\n")); - out.push_str(&format!( - "{pad} throw \"{}\"\n", - positional.extra_error.replace("$1", "$__seal_arg") - )); - out.push_str(&format!("{pad} }}\n")); - out.push_str(&format!("{pad} }}\n")); - } else { - out.push_str(&format!( - "{pad} default {{ throw \"unknown option: $__seal_arg\" }}\n" - )); - } - out.push_str(&format!("{pad} }}\n")); - out.push_str(&format!("{pad}}}\n")); -} - -fn emit_string_option(out: &mut String, spec: &ArgvSpec, indent: usize) { - let pad = " ".repeat(indent); - let option = option_name(&spec.name); - out.push_str(&format!("{pad} '^{}$' {{\n", regex_quote(&option))); - out.push_str(&format!( - "{pad} if ($__seal_index + 1 -ge $args.Count) {{ throw 'missing value for {option}' }}\n" - )); - out.push_str(&format!( - "{pad} ${} = $args[$__seal_index + 1]\n", - spec.name - )); - out.push_str(&format!("{pad} $__seal_index += 2\n")); - out.push_str(&format!("{pad} break\n")); - out.push_str(&format!("{pad} }}\n")); - out.push_str(&format!("{pad} '^{}=' {{\n", regex_quote(&option))); - out.push_str(&format!( - "{pad} ${} = $__seal_arg.Substring({})\n", - spec.name, - option.len() + 1 - )); - out.push_str(&format!("{pad} $__seal_index += 1\n")); - out.push_str(&format!("{pad} break\n")); - out.push_str(&format!("{pad} }}\n")); -} - -fn emit_flag_option(out: &mut String, spec: &ArgvSpec, indent: usize) { - let pad = " ".repeat(indent); - let option = option_name(&spec.name); - out.push_str(&format!("{pad} '^{}$' {{\n", regex_quote(&option))); - out.push_str(&format!("{pad} ${} = 'true'\n", spec.name)); - out.push_str(&format!("{pad} $__seal_index += 1\n")); - out.push_str(&format!("{pad} break\n")); - out.push_str(&format!("{pad} }}\n")); -} - -fn regex_quote(value: &str) -> String { - value.replace('-', "\\-") -} diff --git a/app/src/core/transpile/emit/powershell_support.rs b/app/src/core/transpile/emit/powershell_support.rs deleted file mode 100644 index 30a39bb..0000000 --- a/app/src/core/transpile/emit/powershell_support.rs +++ /dev/null @@ -1,120 +0,0 @@ -use crate::core::transpile::ast::{ExpansionOp, Predicate, Statement, Value, ValueSource}; - -pub(super) fn emit_positional_bindings(out: &mut String, indent: usize, max: usize) -> bool { - if max == 0 { - return false; - } - let pad = " ".repeat(indent); - out.push_str(&format!("{pad}$0 = $args.Count\n")); - for index in 1..=max { - let offset = index - 1; - out.push_str(&format!( - "{pad}${index} = if ($args.Count -ge {index}) {{ $args[{offset}] }} else {{ '' }}\n" - )); - } - true -} - -pub(super) fn max_positional_statements<'a>( - statements: impl IntoIterator<Item = &'a Statement>, -) -> usize { - statements - .into_iter() - .map(max_positional_statement) - .max() - .unwrap_or_default() -} - -fn max_positional_statement(statement: &Statement) -> usize { - match statement { - Statement::Assign { value, .. } => max_positional_value(value), - Statement::ExecWrite { argv, path, .. } => argv - .iter() - .map(max_positional_value) - .max() - .unwrap_or_default() - .max(max_positional_value(path)), - Statement::ExecChecked { argv } - | Statement::EnvExecChecked { argv, .. } - | Statement::CaptureChecked { argv, .. } - | Statement::CaptureFunction { argv, .. } - | Statement::CallFunction { argv, .. } => argv - .iter() - .map(max_positional_value) - .max() - .unwrap_or_default(), - Statement::If { - predicate, - then_body, - else_body, - } => max_positional_predicate(predicate) - .max(max_positional_statements(then_body.iter())) - .max(max_positional_statements(else_body.iter())), - Statement::While { predicate, body } => { - max_positional_predicate(predicate).max(max_positional_statements(body.iter())) - } - Statement::Case { value, arms } => arms - .iter() - .map(|arm| max_positional_statements(arm.body.iter())) - .max() - .unwrap_or_default() - .max(max_positional_value(value)), - Statement::Print { value } | Statement::Error { value } | Statement::Fail { value } => { - max_positional_value(value) - } - Statement::ArgvParse { .. } - | Statement::Shift { .. } - | Statement::Exit { .. } - | Statement::Break - | Statement::Sleep { .. } => 0, - } -} - -fn max_positional_predicate(predicate: &Predicate) -> usize { - match predicate { - Predicate::Command { argv } => argv - .iter() - .map(max_positional_value) - .max() - .unwrap_or_default(), - Predicate::Empty { value } - | Predicate::NotEmpty { value } - | Predicate::JsonEmpty { value } - | Predicate::JsonNotEmpty { value } => max_positional_value(value), - Predicate::Eq { left, right } - | Predicate::Neq { left, right } - | Predicate::IntLt { left, right } - | Predicate::IntLte { left, right } - | Predicate::IntGt { left, right } - | Predicate::IntGte { left, right } => { - max_positional_value(left).max(max_positional_value(right)) - } - Predicate::FileExists { path } | Predicate::DirExists { path } => { - max_positional_value(path) - } - } -} - -fn max_positional_value(value: &Value) -> usize { - match value { - Value::Expand { source, op } => max_positional_expand(source, op), - Value::Concat { parts } => parts - .iter() - .map(max_positional_value) - .max() - .unwrap_or_default(), - Value::Literal { .. } | Value::Argc | Value::Args => 0, - } -} - -fn max_positional_expand(source: &ValueSource, op: &ExpansionOp) -> usize { - let source_max = match source { - ValueSource::Var { name } => name.parse::<usize>().unwrap_or_default(), - ValueSource::Env { .. } => 0, - }; - match op { - ExpansionOp::Plain - | ExpansionOp::DefaultIfUnsetOrEmpty { .. } - | ExpansionOp::RequireNonEmpty { .. } => source_max, - } -} diff --git a/app/src/core/transpile/emit/support.rs b/app/src/core/transpile/emit/support.rs deleted file mode 100644 index 458f91c..0000000 --- a/app/src/core/transpile/emit/support.rs +++ /dev/null @@ -1,142 +0,0 @@ -use crate::core::transpile::ast::{ExpansionOp, Predicate, Value, ValueSource}; - -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 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 bash_predicate(predicate: &Predicate) -> String { - match predicate { - Predicate::Command { argv } => join_values(argv, bash_value), - 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)), - } -} - -pub(super) fn seal_value(value: &Value) -> String { - match value { - Value::Literal { text } => sh_quote(text), - Value::Argc => "$#".to_string(), - Value::Args => "\"$@\"".to_string(), - Value::Expand { source, op } => seal_expand(source, op), - Value::Concat { parts } => { - let inner = parts - .iter() - .map(|part| match part { - Value::Literal { text } => text.clone(), - Value::Argc => "$#".to_string(), - Value::Args => "$@".to_string(), - _ => 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::Argc => "\"$#\"".to_string(), - Value::Args => "\"$@\"".to_string(), - Value::Expand { source, op } => format!("\"{}\"", seal_expand(source, op)), - Value::Concat { parts } => double_quote( - &parts - .iter() - .map(|part| match part { - Value::Literal { text } => text.clone(), - Value::Argc => "$#".to_string(), - Value::Args => "$@".to_string(), - Value::Expand { source, op } => seal_expand(source, op), - Value::Concat { .. } => bash_value(part), - }) - .collect::<String>(), - ), - } -} - -pub(super) fn bash_int_value(value: &Value) -> String { - match value { - Value::Argc => "$#".to_string(), - Value::Args => "\"$@\"".to_string(), - Value::Expand { source, op } => seal_expand(source, op), - Value::Literal { text } => sh_quote(text), - Value::Concat { .. } => bash_value(value), - } -} - -fn seal_expand(source: &ValueSource, op: &ExpansionOp) -> String { - let name = match source { - ValueSource::Var { name } | ValueSource::Env { name } => name, - }; - match op { - ExpansionOp::Plain => match source { - ValueSource::Var { .. } => format!("${name}"), - ValueSource::Env { .. } => format!("${{{name}}}"), - }, - ExpansionOp::DefaultIfUnsetOrEmpty { fallback } => { - format!("${{{name}:-{fallback}}}") - } - ExpansionOp::RequireNonEmpty { message } => { - format!("${{{name}:?{message}}}") - } - } -} - -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':') - || byte == 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 deleted file mode 100644 index d0dcde0..0000000 --- a/app/src/core/transpile/frontend/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -mod powershell; -mod predicate; - -pub(crate) use powershell::parse_powershell; diff --git a/app/src/core/transpile/frontend/powershell.rs b/app/src/core/transpile/frontend/powershell.rs deleted file mode 100644 index 4f67d91..0000000 --- a/app/src/core/transpile/frontend/powershell.rs +++ /dev/null @@ -1,262 +0,0 @@ -use anyhow::{Result, bail}; - -#[path = "powershell_value.rs"] -mod powershell_value; - -pub(crate) use self::powershell_value::parse_value; -use self::powershell_value::{ - assignment, is_generated_positional_binding, parse_argv, parse_pattern, strip_comment, - validate_name, -}; -use super::predicate::parse_powershell_predicate; -use crate::core::transpile::ast::{CaseArm, Item, Program, Statement, Value}; -use crate::core::transpile::lower::lower_functions; -use crate::core::transpile::parse_lex::is_valid_name; - -#[derive(Debug, Clone)] -struct Line { - number: usize, - text: String, -} - -struct Parser { - lines: Vec<Line>, - index: usize, -} - -pub(crate) fn parse_powershell(source: &str) -> Result<Program> { - Parser::new(source).parse_program() -} - -impl Parser { - fn new(source: &str) -> Self { - let lines = source - .lines() - .enumerate() - .filter_map(|(index, line)| { - let text = strip_comment(line).trim().to_string(); - if text.is_empty() - || text.starts_with("# Generated by ") - || text == "$ErrorActionPreference = 'Stop'" - || is_generated_positional_binding(&text) - { - return None; - } - Some(Line { - number: index + 1, - text, - }) - }) - .collect(); - Self { lines, index: 0 } - } - - fn parse_program(mut self) -> Result<Program> { - let mut items = Vec::new(); - while self.peek().is_some() { - if self - .peek_text() - .is_some_and(|text| text.starts_with("function ")) - { - items.push(self.parse_function()?); - } else { - items.push(Item::Statement { - statement: self.parse_statement()?, - }); - } - } - Ok(lower_functions(Program { version: 1, items })) - } - - fn parse_function(&mut self) -> Result<Item> { - let line = self.next().expect("function line should exist"); - let name = line - .text - .strip_prefix("function ") - .and_then(|text| text.strip_suffix(" {")) - .ok_or_else(|| anyhow::anyhow!("{}: expected function header", line.number))?; - validate_name(name, line.number)?; - let body = self.parse_block(&["}"])?; - self.expect("}")?; - Ok(Item::Function { - name: name.to_string(), - body, - }) - } - - fn parse_block(&mut self, terminators: &[&str]) -> Result<Vec<Statement>> { - let mut body = Vec::new(); - while let Some(line) = self.peek() { - if terminators - .iter() - .any(|terminator| line.text == *terminator) - { - break; - } - body.push(self.parse_statement()?); - } - Ok(body) - } - - fn parse_statement(&mut self) -> Result<Statement> { - let Some(line) = self.peek().cloned() else { - bail!("unexpected end of input"); - }; - if line.text.starts_with("if ") { - return self.parse_if(); - } - if line.text.starts_with("while ") { - return self.parse_while(); - } - if line.text.starts_with("switch ") { - return self.parse_switch(); - } - self.next(); - parse_simple_statement(&line) - } - - fn parse_if(&mut self) -> Result<Statement> { - let line = self.next().expect("if line should exist"); - let inner = line - .text - .strip_prefix("if (") - .and_then(|text| text.strip_suffix(") {")) - .ok_or_else(|| anyhow::anyhow!("{}: unsupported if predicate", line.number))?; - let then_body = self.parse_block(&["}", "} else {"])?; - let else_body = if self.peek_text() == Some("} else {") { - self.next(); - let body = self.parse_block(&["}"])?; - self.expect("}")?; - body - } else { - self.expect("}")?; - Vec::new() - }; - Ok(Statement::If { - predicate: parse_powershell_predicate(inner, line.number)?, - then_body, - else_body, - }) - } - fn parse_while(&mut self) -> Result<Statement> { - let line = self.next().expect("while line should exist"); - let inner = line - .text - .strip_prefix("while (") - .and_then(|text| text.strip_suffix(") {")) - .ok_or_else(|| anyhow::anyhow!("{}: expected while condition", line.number))?; - let predicate = parse_powershell_predicate(inner, line.number)?; - let body = self.parse_block(&["}"])?; - self.expect("}")?; - Ok(Statement::While { predicate, body }) - } - - fn parse_switch(&mut self) -> Result<Statement> { - let line = self.next().expect("switch line should exist"); - let value = line - .text - .strip_prefix("switch (") - .and_then(|text| text.strip_suffix(") {")) - .ok_or_else(|| anyhow::anyhow!("{}: expected switch header", line.number))?; - let mut arms = Vec::new(); - while let Some(line) = self.peek().cloned() { - if line.text == "}" { - self.next(); - break; - } - let pattern = line - .text - .strip_suffix(" {") - .ok_or_else(|| anyhow::anyhow!("{}: expected switch arm", line.number))?; - self.next(); - let body = self.parse_block(&["}"])?; - self.expect("}")?; - arms.push(CaseArm { - patterns: vec![parse_pattern(pattern)], - body, - }); - } - Ok(Statement::Case { - value: parse_value(value, line.number)?, - arms, - }) - } - - fn expect(&mut self, expected: &str) -> Result<()> { - let Some(line) = self.next() else { - bail!("expected `{expected}`, found end of input"); - }; - if line.text != expected { - bail!( - "{}: expected `{expected}`, got `{}`", - line.number, - line.text - ); - } - Ok(()) - } - - fn peek(&self) -> Option<&Line> { - self.lines.get(self.index) - } - - fn peek_text(&self) -> Option<&str> { - self.peek().map(|line| line.text.as_str()) - } - - fn next(&mut self) -> Option<Line> { - let line = self.lines.get(self.index).cloned(); - self.index += usize::from(line.is_some()); - line - } -} - -fn parse_simple_statement(line: &Line) -> Result<Statement> { - if let Some((name, value)) = assignment(&line.text) { - if let Some(argv) = value.strip_prefix("& ") { - let argv = parse_argv(argv, line.number)?; - return Ok(Statement::CaptureChecked { name, argv }); - } - return Ok(Statement::Assign { - name, - value: parse_value(value, line.number)?, - }); - } - if let Some(value) = line.text.strip_prefix("Write-Output ") { - return Ok(Statement::Print { - value: parse_value(value, line.number)?, - }); - } - if let Some(value) = line.text.strip_prefix("throw ") { - return Ok(Statement::Fail { - value: parse_value(value, line.number)?, - }); - } - if line.text == "break" { - return Ok(Statement::Break); - } - if let Some(seconds) = line.text.strip_prefix("Start-Sleep -Seconds ") { - return Ok(Statement::Sleep { - seconds: seconds - .parse() - .map_err(|_| anyhow::anyhow!("{}: invalid sleep seconds", line.number))?, - }); - } - if let Some(argv) = line.text.strip_prefix("& ") { - return Ok(Statement::ExecChecked { - argv: parse_argv(argv, line.number)?, - }); - } - if is_valid_name(&line.text) { - return Ok(Statement::ExecChecked { - argv: vec![Value::Literal { - text: line.text.clone(), - }], - }); - } - bail!( - "{}: unsupported PowerShell statement: {}", - line.number, - line.text - ) -} diff --git a/app/src/core/transpile/frontend/powershell_value.rs b/app/src/core/transpile/frontend/powershell_value.rs deleted file mode 100644 index 976ae60..0000000 --- a/app/src/core/transpile/frontend/powershell_value.rs +++ /dev/null @@ -1,315 +0,0 @@ -use anyhow::{Result, bail}; - -use crate::core::transpile::ast::{ExpansionOp, Value, ValueSource}; - -pub(super) fn parse_argv(text: &str, line: usize) -> Result<Vec<Value>> { - split_exprs(text, line)? - .iter() - .map(|arg| parse_argv_value(arg, line)) - .collect::<Result<Vec<_>>>() -} - -pub(crate) fn parse_value(text: &str, line: usize) -> Result<Value> { - let text = text.trim(); - if let Some(value) = text - .strip_prefix('\'') - .and_then(|value| value.strip_suffix('\'')) - { - return Ok(Value::Literal { - text: value.replace("''", "'"), - }); - } - if let Some((source, op)) = guarded_expand(text, line)? { - return Ok(Value::Expand { source, op }); - } - if let Some(name) = text.strip_prefix("$env:") { - validate_name(name, line)?; - return Ok(Value::Expand { - source: ValueSource::Env { - name: name.to_string(), - }, - op: ExpansionOp::Plain, - }); - } - if let Some(name) = text.strip_prefix('$') { - if is_valid_positional(name) { - return Ok(Value::Expand { - source: ValueSource::Var { - name: name.to_string(), - }, - op: ExpansionOp::Plain, - }); - } - validate_name(name, line)?; - return Ok(Value::Expand { - source: ValueSource::Var { - name: name.to_string(), - }, - op: ExpansionOp::Plain, - }); - } - if text.bytes().all(|byte| byte.is_ascii_digit()) { - return Ok(Value::Literal { - text: text.to_string(), - }); - } - if let Some(inner) = text - .strip_prefix('(') - .and_then(|value| value.strip_suffix(')')) - && inner.contains(" + ") - { - return Ok(Value::Concat { - parts: split_concat(inner, line)? - .iter() - .map(|part| parse_value(part, line)) - .collect::<Result<Vec<_>>>()?, - }); - } - bail!("{line}: unsupported PowerShell value: {text}") -} - -pub(super) fn assignment(text: &str) -> Option<(String, &str)> { - let (name, value) = text.split_once(" = ")?; - let name = name.strip_prefix('$')?; - is_valid_var_name(name).then_some((name.to_string(), value)) -} - -pub(super) fn parse_pattern(pattern: &str) -> String { - if pattern == "Default" { - return "*".to_string(); - } - pattern - .strip_prefix('\'') - .and_then(|value| value.strip_suffix('\'')) - .unwrap_or(pattern) - .to_string() -} - -pub(super) fn strip_comment(line: &str) -> String { - let mut output = String::new(); - let mut quote = false; - for ch in line.chars() { - match ch { - '\'' => { - quote = !quote; - output.push(ch); - } - '#' if !quote => break, - _ => output.push(ch), - } - } - output -} - -pub(super) fn is_generated_positional_binding(text: &str) -> bool { - if text == "$0 = $args.Count" { - return true; - } - let Some(rest) = text.strip_prefix('$') else { - return false; - }; - let Some((index, rest)) = rest.split_once(" = if ($args.Count -ge ") else { - return false; - }; - let Some((index2, rest)) = rest.split_once(") { $args[") else { - return false; - }; - let Some((offset, _rest)) = rest.split_once("] } else { '' }") else { - return false; - }; - if index != index2 { - return false; - } - let Ok(index) = index.parse::<usize>() else { - return false; - }; - let Ok(offset) = offset.parse::<usize>() else { - return false; - }; - index.checked_sub(1) == Some(offset) -} - -pub(super) fn validate_name(name: &str, line: usize) -> Result<()> { - if !is_valid_name(name) { - bail!("{line}: invalid PowerShell name: {name}"); - } - Ok(()) -} - -fn parse_argv_value(text: &str, line: usize) -> Result<Value> { - parse_value(text, line).or_else(|_| { - if is_valid_name(text) { - Ok(Value::Literal { - text: text.to_string(), - }) - } else { - bail!("{line}: unsupported PowerShell argv value: {text}") - } - }) -} - -fn guarded_expand(text: &str, line: usize) -> Result<Option<(ValueSource, ExpansionOp)>> { - let Some(inner) = text - .strip_prefix("$(if (") - .and_then(|value| value.strip_suffix(" })")) - else { - return Ok(None); - }; - if let Some(parsed) = env_guarded_expand(inner, line)? { - return Ok(Some(parsed)); - } - if let Some(parsed) = positional_guarded_expand(inner, line)? { - return Ok(Some(parsed)); - } - if let Some(parsed) = var_guarded_expand(inner, line)? { - return Ok(Some(parsed)); - } - Ok(None) -} - -fn env_guarded_expand(text: &str, line: usize) -> Result<Option<(ValueSource, ExpansionOp)>> { - let Some(rest) = text.strip_prefix("[string]::IsNullOrEmpty($env:") else { - return Ok(None); - }; - let (name, rest) = rest - .split_once(")) { ") - .ok_or_else(|| anyhow::anyhow!("{line}: unsupported PowerShell env expansion"))?; - validate_name(name, line)?; - let source = ValueSource::Env { - name: name.to_string(), - }; - let plain = format!("$env:{name}"); - parse_guarded_action(source, &plain, rest, line).map(Some) -} - -fn positional_guarded_expand( - text: &str, - line: usize, -) -> Result<Option<(ValueSource, ExpansionOp)>> { - let Some(rest) = text.strip_prefix("($args.Count -lt ") else { - return Ok(None); - }; - let (index, rest) = rest - .split_once(") -or [string]::IsNullOrEmpty($") - .ok_or_else(|| anyhow::anyhow!("{line}: unsupported PowerShell positional expansion"))?; - let (name, rest) = rest - .split_once(")) { ") - .ok_or_else(|| anyhow::anyhow!("{line}: unsupported PowerShell positional expansion"))?; - if index != name { - bail!("{line}: unsupported PowerShell positional expansion"); - } - let source = ValueSource::Var { - name: name.to_string(), - }; - let plain = format!("${name}"); - parse_guarded_action(source, &plain, rest, line).map(Some) -} - -fn var_guarded_expand(text: &str, line: usize) -> Result<Option<(ValueSource, ExpansionOp)>> { - let Some(rest) = text.strip_prefix("[string]::IsNullOrEmpty($") else { - return Ok(None); - }; - let (name, rest) = rest - .split_once(")) { ") - .ok_or_else(|| anyhow::anyhow!("{line}: unsupported PowerShell variable expansion"))?; - validate_name(name, line)?; - let source = ValueSource::Var { - name: name.to_string(), - }; - let plain = format!("${name}"); - parse_guarded_action(source, &plain, rest, line).map(Some) -} - -fn parse_guarded_action( - source: ValueSource, - plain: &str, - rest: &str, - line: usize, -) -> Result<(ValueSource, ExpansionOp)> { - let (action, expected_plain) = rest - .split_once(" } else { ") - .ok_or_else(|| anyhow::anyhow!("{line}: unsupported PowerShell guarded expansion"))?; - if expected_plain != plain { - bail!("{line}: unsupported PowerShell guarded expansion"); - } - if let Some(message) = action.strip_prefix("throw ") { - let message = parse_single_quoted(message, line)?; - return Ok((source, ExpansionOp::RequireNonEmpty { message })); - } - let fallback = parse_single_quoted(action, line)?; - Ok((source, ExpansionOp::DefaultIfUnsetOrEmpty { fallback })) -} - -fn parse_single_quoted(text: &str, line: usize) -> Result<String> { - let Some(value) = text - .strip_prefix('\'') - .and_then(|value| value.strip_suffix('\'')) - else { - bail!("{line}: unsupported PowerShell quoted literal: {text}"); - }; - Ok(value.replace("''", "'")) -} - -fn split_exprs(text: &str, line: usize) -> Result<Vec<String>> { - split_top_level(text, line, ' ') -} - -fn split_concat(text: &str, line: usize) -> Result<Vec<String>> { - split_top_level(text, line, '+').map(|items| { - items - .into_iter() - .map(|item| item.trim().to_string()) - .collect() - }) -} - -fn split_top_level(text: &str, line: usize, delimiter: char) -> Result<Vec<String>> { - let mut items = Vec::new(); - let mut current = String::new(); - let mut quote = false; - let mut depth = 0usize; - for ch in text.chars() { - match ch { - '\'' => { - quote = !quote; - current.push(ch); - } - '(' if !quote => { - depth += 1; - current.push(ch); - } - ')' if !quote => { - depth = depth.saturating_sub(1); - current.push(ch); - } - ch if ch == delimiter && !quote && depth == 0 => { - if !current.trim().is_empty() { - items.push(current.trim().to_string()); - current.clear(); - } - } - _ => current.push(ch), - } - } - if quote { - bail!("{line}: unterminated PowerShell string"); - } - if !current.trim().is_empty() { - items.push(current.trim().to_string()); - } - Ok(items) -} - -fn is_valid_var_name(name: &str) -> bool { - is_valid_name(name) || is_valid_positional(name) -} - -fn is_valid_name(name: &str) -> bool { - let mut bytes = name.bytes(); - matches!(bytes.next(), Some(byte) if byte.is_ascii_alphabetic() || byte == b'_') - && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') -} - -fn is_valid_positional(name: &str) -> bool { - !name.is_empty() && name.bytes().all(|byte| byte.is_ascii_digit()) -} diff --git a/app/src/core/transpile/frontend/predicate.rs b/app/src/core/transpile/frontend/predicate.rs deleted file mode 100644 index 7de662b..0000000 --- a/app/src/core/transpile/frontend/predicate.rs +++ /dev/null @@ -1,66 +0,0 @@ -use anyhow::{Result, bail}; - -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 - .strip_prefix("[string]::IsNullOrEmpty(") - .and_then(|value| value.strip_suffix(')')) - { - return Ok(Predicate::Empty { - value: parse_value(value, line)?, - }); - } - if let Some(value) = text - .strip_prefix("![string]::IsNullOrEmpty(") - .and_then(|value| value.strip_suffix(')')) - { - return Ok(Predicate::NotEmpty { - value: parse_value(value, line)?, - }); - } - if let Some(value) = json_count_value(text, "-eq 0") { - return Ok(Predicate::JsonEmpty { - value: parse_value(value, line)?, - }); - } - if let Some(value) = json_count_value(text, "-gt 0") { - return Ok(Predicate::JsonNotEmpty { - value: parse_value(value, line)?, - }); - } - if let Some((operator, left, right)) = powershell_compare(text) { - return int_predicate(operator, left, right, line); - } - bail!("{line}: unsupported PowerShell predicate: {text}") -} - -fn int_predicate(operator: &str, left: &str, right: &str, line: usize) -> Result<Predicate> { - let left = left.strip_prefix("[int]").unwrap_or(left); - let left = parse_value(left, line)?; - let right = parse_value(right, line)?; - match operator { - "-lt" => Ok(Predicate::IntLt { left, right }), - "-le" => Ok(Predicate::IntLte { left, right }), - "-gt" => Ok(Predicate::IntGt { left, right }), - "-ge" => Ok(Predicate::IntGte { left, right }), - _ => bail!("{line}: unsupported PowerShell comparison operator: {operator}"), - } -} - -fn powershell_compare(text: &str) -> Option<(&str, &str, &str)> { - for operator in [" -lt ", " -le ", " -gt ", " -ge "] { - if let Some((left, right)) = text.split_once(operator) { - return Some((operator.trim(), left, right)); - } - } - None -} - -fn json_count_value<'a>(text: &'a str, comparison: &str) -> Option<&'a str> { - let inner = text.strip_prefix("((")?; - let suffix = format!(").Count {comparison})"); - let inner = inner.strip_suffix(&suffix)?; - inner.strip_suffix(" | ConvertFrom-Json") -} diff --git a/app/src/core/transpile/guards.rs b/app/src/core/transpile/guards.rs deleted file mode 100644 index d1a807c..0000000 --- a/app/src/core/transpile/guards.rs +++ /dev/null @@ -1,64 +0,0 @@ -use std::collections::BTreeSet; - -use super::ast::{Item, Program, Statement}; - -pub(crate) fn bash_required_tools(program: &Program) -> BTreeSet<&'static str> { - let mut tools = BTreeSet::new(); - for item in &program.items { - match item { - Item::Function { body, .. } => collect_bash_tools(body, &mut tools), - Item::Statement { statement } => collect_bash_tool(statement, &mut tools), - } - } - tools -} - -pub(crate) fn emit_bash_guards(out: &mut String, tools: &BTreeSet<&'static str>) { - for tool in tools { - out.push_str(&format!( - "if ! command -v {tool} >/dev/null 2>&1; then\n seal_fail 'missing dependency: {tool}'\nfi\n\n" - )); - } -} - -fn collect_bash_tools(statements: &[Statement], tools: &mut BTreeSet<&'static str>) { - for statement in statements { - collect_bash_tool(statement, tools); - } -} - -fn collect_bash_tool(statement: &Statement, tools: &mut BTreeSet<&'static str>) { - match statement { - Statement::If { - then_body, - else_body, - .. - } => { - collect_bash_tools(then_body, tools); - collect_bash_tools(else_body, tools); - } - Statement::While { body, .. } => { - collect_bash_tools(body, tools); - } - Statement::Case { arms, .. } => { - for arm in arms { - collect_bash_tools(&arm.body, tools); - } - } - Statement::Assign { .. } - | Statement::ArgvParse { .. } - | Statement::ExecWrite { .. } - | Statement::ExecChecked { .. } - | Statement::EnvExecChecked { .. } - | Statement::Shift { .. } - | Statement::CaptureChecked { .. } - | Statement::CaptureFunction { .. } - | Statement::CallFunction { .. } - | Statement::Print { .. } - | Statement::Error { .. } - | Statement::Fail { .. } - | Statement::Exit { .. } - | Statement::Break - | Statement::Sleep { .. } => {} - } -} diff --git a/app/src/core/transpile/lower.rs b/app/src/core/transpile/lower.rs deleted file mode 100644 index b8d515a..0000000 --- a/app/src/core/transpile/lower.rs +++ /dev/null @@ -1,86 +0,0 @@ -use std::collections::BTreeSet; - -use super::ast::{Item, Program, Statement, Value}; - -pub(crate) fn lower_functions(mut program: Program) -> Program { - let functions = program - .items - .iter() - .filter_map(|item| match item { - Item::Function { name, .. } => Some(name.clone()), - Item::Statement { .. } => None, - }) - .collect::<BTreeSet<_>>(); - for item in &mut program.items { - match item { - Item::Function { body, .. } => lower_statements(body, &functions), - Item::Statement { statement } => lower_statement(statement, &functions), - } - } - program -} - -fn lower_statements(statements: &mut [Statement], functions: &BTreeSet<String>) { - for statement in statements { - lower_statement(statement, functions); - } -} - -fn lower_statement(statement: &mut Statement, functions: &BTreeSet<String>) { - match statement { - Statement::ExecChecked { argv } => { - let Some(Value::Literal { text }) = argv.first() else { - return; - }; - if functions.contains(text) { - let name = text.clone(); - let argv = argv[1..].to_vec(); - *statement = Statement::CallFunction { name, argv }; - } - } - Statement::CaptureChecked { name, argv } => { - let Some(Value::Literal { text }) = argv.first() else { - return; - }; - if functions.contains(text) { - let destination = name.clone(); - let function = text.clone(); - let argv = argv[1..].to_vec(); - *statement = Statement::CaptureFunction { - name: destination, - function, - argv, - }; - } - } - Statement::If { - then_body, - else_body, - .. - } => { - lower_statements(then_body, functions); - lower_statements(else_body, functions); - } - Statement::While { body, .. } => { - lower_statements(body, functions); - } - Statement::Case { arms, .. } => { - for arm in arms { - lower_statements(&mut arm.body, functions); - } - } - Statement::Assign { .. } - | Statement::ArgvParse { .. } - | Statement::ExecWrite { .. } - | Statement::EnvExecChecked { .. } - | Statement::Shift { .. } - | Statement::CaptureFunction { .. } - | Statement::CallFunction { .. } - | Statement::Print { .. } - | Statement::Error { .. } - | Statement::Fail { .. } - | Statement::Exit { .. } - | Statement::Break - | Statement::Sleep { .. } => {} - } -} diff --git a/app/src/core/transpile/mod.rs b/app/src/core/transpile/mod.rs deleted file mode 100644 index 374e0e6..0000000 --- a/app/src/core/transpile/mod.rs +++ /dev/null @@ -1,127 +0,0 @@ -use std::fs; - -use anyhow::{Result, bail}; - -mod ast; -mod emit; -mod frontend; -mod guards; -mod lower; -mod parse; -mod parse_argv; -mod parse_command; -mod parse_lex; -mod runner; -mod value; - -use emit::{emit_bash, emit_powershell, emit_seal}; -use frontend::parse_powershell; -use parse::parse_seal; -pub(crate) use runner::run_seal_file; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Lang { - Seal, - Bash, - PowerShell, - SealIr, -} - -impl Lang { - fn parse(value: &str, flag: &str) -> Result<Self> { - match value { - "seal" => Ok(Self::Seal), - "bash" => Ok(Self::Bash), - "powershell" => Ok(Self::PowerShell), - "sealir" => Ok(Self::SealIr), - _ => bail!("invalid {flag}: {value}; expected seal, bash, powershell, or sealir"), - } - } -} - -#[derive(Debug, Clone)] -pub struct Options { - pub input_lang: Lang, - pub output_lang: Lang, - pub source: String, -} - -pub fn parse_args(args: &[String]) -> Result<Options> { - let mut input_lang = None; - let mut output_lang = None; - let mut source = None; - let mut index = 0; - while index < args.len() { - let arg = &args[index]; - if let Some(value) = arg.strip_prefix("--input-lang=") { - input_lang = Some(Lang::parse(value, "--input-lang")?); - index += 1; - continue; - } - if arg == "--input-lang" { - index += 1; - let Some(value) = args.get(index) else { - bail!("--input-lang requires a value"); - }; - input_lang = Some(Lang::parse(value, "--input-lang")?); - index += 1; - continue; - } - if let Some(value) = arg.strip_prefix("--output-lang=") { - output_lang = Some(Lang::parse(value, "--output-lang")?); - index += 1; - continue; - } - if arg == "--output-lang" { - index += 1; - let Some(value) = args.get(index) else { - bail!("--output-lang requires a value"); - }; - output_lang = Some(Lang::parse(value, "--output-lang")?); - index += 1; - continue; - } - if arg.starts_with('-') { - bail!("unknown @transpile option: {arg}"); - } - if source.replace(arg.clone()).is_some() { - bail!("@transpile requires exactly one source file"); - } - index += 1; - } - Ok(Options { - input_lang: input_lang.ok_or_else(|| anyhow::anyhow!("--input-lang is required"))?, - output_lang: output_lang.ok_or_else(|| anyhow::anyhow!("--output-lang is required"))?, - source: source.ok_or_else(|| anyhow::anyhow!("@transpile requires one source file"))?, - }) -} - -pub fn transpile_file(options: &Options) -> Result<String> { - let source = fs::read_to_string(&options.source) - .map_err(|err| anyhow::anyhow!("failed to read {}: {err}", options.source))?; - transpile_source( - options.input_lang, - options.output_lang, - &source, - Some(&options.source), - ) -} - -pub fn transpile_source( - input_lang: Lang, - output_lang: Lang, - source: &str, - source_name: Option<&str>, -) -> Result<String> { - let program = match input_lang { - Lang::Seal | Lang::Bash => parse_seal(source)?, - Lang::PowerShell => parse_powershell(source)?, - Lang::SealIr => serde_json::from_str(source)?, - }; - match output_lang { - Lang::SealIr => Ok(serde_json::to_string_pretty(&program)? + "\n"), - Lang::Seal => Ok(emit_seal(&program)), - Lang::Bash => Ok(emit_bash(&program, source_name)), - Lang::PowerShell => Ok(emit_powershell(&program, source_name)), - } -} diff --git a/app/src/core/transpile/parse.rs b/app/src/core/transpile/parse.rs deleted file mode 100644 index adc8945..0000000 --- a/app/src/core/transpile/parse.rs +++ /dev/null @@ -1,496 +0,0 @@ -use anyhow::{Result, bail}; - -use super::ast::{CaseArm, EnvAssign, Item, Predicate, Program, Statement, Value}; -use super::lower::lower_functions; -use super::parse_argv::parse_argv_block; -use super::parse_command::{parse_exec_write, validate_external_tokens, validate_shell_command}; -use super::parse_lex::{ - assignment, is_safe_command_name, is_valid_name, split_test_words, split_words, strip_comment, -}; -use super::value::parse_value_text; - -#[derive(Debug, Clone)] -pub(super) struct SourceLine { - pub(super) number: usize, - pub(super) text: String, -} - -pub(super) struct Parser { - lines: Vec<SourceLine>, - index: usize, -} -impl Parser { - fn new(source: &str) -> Self { - let lines = source - .lines() - .enumerate() - .filter_map(|(index, line)| { - let text = strip_comment(line).trim().to_string(); - if text.is_empty() { - return None; - } - Some(SourceLine { - number: index + 1, - text, - }) - }) - .collect(); - Self { lines, index: 0 } - } - - fn parse_program(mut self) -> Result<Program> { - let mut items = Vec::new(); - while self.peek().is_some() { - if self - .peek_text() - .is_some_and(|text| function_header(text).is_some()) - { - items.push(self.parse_function()?); - } else { - items.push(Item::Statement { - statement: self.parse_statement()?, - }); - } - } - let program = Program { version: 1, items }; - Ok(lower_functions(program)) - } - fn parse_function(&mut self) -> Result<Item> { - let line = self.next().expect("function header should exist"); - let name = function_header(&line.text) - .ok_or_else(|| anyhow::anyhow!("{}: expected function header", line.number))? - .to_string(); - let body = self.parse_block(&["}"])?; - self.expect_exact("}")?; - Ok(Item::Function { name, body }) - } - fn parse_block(&mut self, terminators: &[&str]) -> Result<Vec<Statement>> { - let mut body = Vec::new(); - while let Some(line) = self.peek() { - if terminators - .iter() - .any(|terminator| line.text == *terminator) - { - break; - } - body.push(self.parse_statement()?); - } - Ok(body) - } - fn parse_statement(&mut self) -> Result<Statement> { - let Some(line) = self.peek().cloned() else { - bail!("unexpected end of input"); - }; - if line.text == "__seal_argc=$#" { - return parse_argv_block(self); - } - if line.text.starts_with("if ") { - return self.parse_if(); - } - if line.text.starts_with("while ") { - return self.parse_while(); - } - if line.text.starts_with("case ") { - return self.parse_case(); - } - self.next(); - parse_simple_statement(&line) - } - fn parse_if(&mut self) -> Result<Statement> { - let line = self.next().expect("if line should exist"); - let inner = line - .text - .strip_prefix("if ") - .and_then(|text| text.strip_suffix("; then")) - .ok_or_else(|| anyhow::anyhow!("{}: expected `if <predicate>; then`", line.number))?; - let predicate = parse_predicate(inner, line.number)?; - let then_body = self.parse_block(&["else", "fi"])?; - let else_body = if self.peek_text() == Some("else") { - self.next(); - self.parse_block(&["fi"])? - } else { - Vec::new() - }; - self.expect_exact("fi")?; - Ok(Statement::If { - predicate, - then_body, - else_body, - }) - } - fn parse_while(&mut self) -> Result<Statement> { - let line = self.next().expect("while line should exist"); - let inner = line - .text - .strip_prefix("while ") - .and_then(|text| text.strip_suffix("; do")) - .ok_or_else(|| anyhow::anyhow!("{}: expected `while <predicate>; do`", line.number))?; - let predicate = parse_predicate(inner, line.number)?; - let body = self.parse_block(&["done"])?; - self.expect_exact("done")?; - Ok(Statement::While { predicate, body }) - } - fn parse_case(&mut self) -> Result<Statement> { - let line = self.next().expect("case line should exist"); - let value_text = line - .text - .strip_prefix("case ") - .and_then(|text| text.strip_suffix(" in")) - .ok_or_else(|| anyhow::anyhow!("{}: expected `case <value> in`", line.number))?; - let value = parse_value_text(value_text, line.number)?; - let mut arms = Vec::new(); - loop { - let Some(line) = self.peek().cloned() else { - bail!("{}: missing esac for case", line.number); - }; - if line.text == "esac" { - self.next(); - break; - } - let text = line.text.clone(); - let Some((patterns, remainder)) = text.split_once(')') else { - bail!("{}: expected case arm pattern", line.number); - }; - self.next(); - let patterns = patterns - .split('|') - .map(str::trim) - .map(str::to_string) - .collect::<Vec<_>>(); - if patterns.iter().any(|pattern| pattern.is_empty()) { - bail!("{}: empty case pattern", line.number); - } - let mut body = Vec::new(); - let remainder = remainder.trim(); - if !remainder.is_empty() { - let statement = remainder - .strip_suffix(";;") - .ok_or_else(|| { - anyhow::anyhow!("{}: inline case arms must end with `;;`", line.number) - })? - .trim(); - if !statement.is_empty() { - body.push(parse_simple_statement(&SourceLine { - number: line.number, - text: statement.to_string(), - })?); - } - } else { - while let Some(next) = self.peek().cloned() { - if next.text == ";;" { - self.next(); - break; - } - if next.text == "esac" { - bail!("{}: missing `;;` before esac", next.number); - } - body.push(self.parse_statement()?); - } - } - arms.push(CaseArm { patterns, body }); - } - Ok(Statement::Case { value, arms }) - } - pub(super) fn expect_exact(&mut self, expected: &str) -> Result<()> { - let Some(line) = self.next() else { - bail!("expected `{expected}`, found end of input"); - }; - if line.text != expected { - bail!( - "{}: expected `{expected}`, got `{}`", - line.number, - line.text - ); - } - Ok(()) - } - pub(super) fn peek(&self) -> Option<&SourceLine> { - self.lines.get(self.index) - } - pub(super) fn peek_text(&self) -> Option<&str> { - self.peek().map(|line| line.text.as_str()) - } - pub(super) fn next(&mut self) -> Option<SourceLine> { - let line = self.lines.get(self.index).cloned(); - self.index += usize::from(line.is_some()); - line - } -} - -fn function_header(text: &str) -> Option<&str> { - let name = text.strip_suffix("() {")?; - is_valid_name(name).then_some(name) -} - -fn parse_simple_statement(line: &SourceLine) -> Result<Statement> { - if let Some((name, value)) = assignment(&line.text) - && let Some(argv) = capture_argv(value, line.number)? - { - return Ok(Statement::CaptureChecked { - name: name.to_string(), - argv, - }); - } - let tokens = split_words(&line.text, line.number)?; - if let Some(statement) = parse_exec_write(&tokens, line.number)? { - return Ok(statement); - } - if let Some(statement) = parse_env_exec(&tokens, line.number)? { - return Ok(statement); - } - if let Some((name, value)) = assignment(&line.text) { - return Ok(Statement::Assign { - name: name.to_string(), - value: parse_value_text(value, line.number)?, - }); - } - let Some((command, args)) = tokens.split_first() else { - bail!("{}: expected statement", line.number); - }; - validate_shell_command(command, args, line.number)?; - match command.as_str() { - "printf" => parse_printf(args, line.number), - "eval" => bail!("{}: unsupported statement: eval", line.number), - "seal" => bail!("{}: unsupported legacy seal helper statement", line.number), - "shift" => { - let count = match args { - [] => 1, - [count] => count - .parse::<usize>() - .map_err(|_| anyhow::anyhow!("{}: invalid shift count", line.number))?, - _ => bail!("{}: shift accepts at most one argument", line.number), - }; - Ok(Statement::Shift { count }) - } - "print" => Ok(Statement::Print { - value: one_value(args, line.number, "print")?, - }), - "error" => Ok(Statement::Error { - value: one_value(args, line.number, "error")?, - }), - "fail" => Ok(Statement::Fail { - value: one_value(args, line.number, "fail")?, - }), - "break" => { - if !args.is_empty() { - bail!("{}: break does not accept arguments", line.number); - } - Ok(Statement::Break) - } - "exit" => { - if args.len() != 1 { - bail!("{}: exit requires one code argument", line.number); - } - Ok(Statement::Exit { - code: args[0] - .parse() - .map_err(|_| anyhow::anyhow!("{}: invalid exit code", line.number))?, - }) - } - "sleep" => { - if args.len() != 1 { - bail!("{}: sleep requires one seconds argument", line.number); - } - Ok(Statement::Sleep { - seconds: args[0] - .parse() - .map_err(|_| anyhow::anyhow!("{}: invalid sleep seconds", line.number))?, - }) - } - _ if is_safe_command_name(command) => { - validate_external_tokens(&tokens, line.number)?; - Ok(Statement::ExecChecked { - argv: parse_values(&tokens, line.number)?, - }) - } - _ => bail!("{}: unsupported statement: {}", line.number, line.text), - } -} - -fn parse_env_exec(tokens: &[String], line: usize) -> Result<Option<Statement>> { - let mut env = Vec::new(); - let mut index = 0; - while let Some(token) = tokens.get(index) { - let Some((name, value)) = assignment(token) else { - break; - }; - env.push(EnvAssign { - name: name.to_string(), - value: parse_value_text(value, line)?, - }); - index += 1; - } - if env.is_empty() || index == tokens.len() { - return Ok(None); - } - let argv_tokens = &tokens[index..]; - validate_external_tokens(argv_tokens, line)?; - let Some(command) = argv_tokens.first() else { - return Ok(None); - }; - validate_shell_command(command, &argv_tokens[1..], line)?; - if !is_safe_command_name(command) { - bail!("{line}: unsupported statement: {}", tokens.join(" ")); - } - Ok(Some(Statement::EnvExecChecked { - env, - argv: parse_values(argv_tokens, line)?, - })) -} - -fn parse_printf(args: &[String], line: usize) -> Result<Statement> { - match args { - [format, value] if format == "'%s\\n'" => Ok(Statement::Print { - value: parse_value_text(value, line)?, - }), - [format, value, redirect] if format == "'%s\\n'" && redirect == ">&2" => { - Ok(Statement::Error { - value: parse_value_text(value, line)?, - }) - } - [format, value, redirect, target] - if format == "'%s\\n'" && redirect == ">" && target == "&2" => - { - Ok(Statement::Error { - value: parse_value_text(value, line)?, - }) - } - _ => bail!("{line}: unsupported printf form"), - } -} - -pub(super) fn option_to_name(option: &str, line: usize) -> Result<String> { - let Some(option) = option.strip_prefix("--") else { - bail!("{line}: expected long option: {option}"); - }; - let name = option.replace('-', "_"); - if !is_valid_name(&name) { - bail!("{line}: invalid option name: {option}"); - } - Ok(name) -} - -fn capture_argv(value: &str, line: usize) -> Result<Option<Vec<Value>>> { - let Some(inner) = value - .strip_prefix("$(") - .and_then(|value| value.strip_suffix(')')) - else { - return Ok(None); - }; - let tokens = split_words(inner, line)?; - if tokens.is_empty() { - bail!("{line}: capture command cannot be empty"); - } - validate_external_tokens(&tokens, line)?; - Ok(Some(parse_values(&tokens, line)?)) -} - -fn one_value(args: &[String], line: usize, command: &str) -> Result<Value> { - if args.len() != 1 { - bail!("{line}: {command} requires exactly one argument"); - } - parse_value_text(&args[0], line) -} - -fn parse_predicate(text: &str, line: usize) -> Result<Predicate> { - if let Some(inner) = text - .strip_prefix("[ ") - .and_then(|text| text.strip_suffix(" ]")) - { - return parse_test_predicate(inner, line); - } - - let tokens = split_words(text, line)?; - let Some(command) = tokens.first() else { - bail!("{line}: command predicate cannot be empty"); - }; - validate_shell_command(command, &tokens[1..], line)?; - if !is_safe_command_name(command) { - bail!("{line}: unsupported predicate: {text}"); - } - validate_external_tokens(&tokens, line)?; - Ok(Predicate::Command { - argv: parse_values(&tokens, line)?, - }) -} - -pub(super) fn parse_values(tokens: &[String], line: usize) -> Result<Vec<Value>> { - tokens - .iter() - .map(|arg| parse_value_text(arg, line)) - .collect() -} - -fn parse_test_predicate(text: &str, line: usize) -> Result<Predicate> { - let tokens = split_test_words(text, line)?; - match tokens.as_slice() { - [flag, value] if flag == "-z" => Ok(Predicate::Empty { - value: parse_value_text(value, line)?, - }), - [flag, value] if flag == "-n" => Ok(Predicate::NotEmpty { - value: parse_value_text(value, line)?, - }), - [flag, path] if flag == "-f" => Ok(Predicate::FileExists { - path: parse_value_text(path, line)?, - }), - [flag, path] if flag == "-d" => Ok(Predicate::DirExists { - path: parse_value_text(path, line)?, - }), - [left, op, right] if op == "=" => { - if let Some(value) = json_empty_value(left, line)? { - return match right.as_str() { - "true" => Ok(Predicate::JsonEmpty { value }), - "false" => Ok(Predicate::JsonNotEmpty { value }), - _ => bail!("{line}: unsupported json empty comparison: {text}"), - }; - } - Ok(Predicate::Eq { - left: parse_value_text(left, line)?, - right: parse_value_text(right, line)?, - }) - } - [left, op, right] if op == "!=" => Ok(Predicate::Neq { - left: parse_value_text(left, line)?, - right: parse_value_text(right, line)?, - }), - [left, op, right] if op == "-lt" => Ok(Predicate::IntLt { - left: parse_value_text(left, line)?, - right: parse_value_text(right, line)?, - }), - [left, op, right] if op == "-le" => Ok(Predicate::IntLte { - left: parse_value_text(left, line)?, - right: parse_value_text(right, line)?, - }), - [left, op, right] if op == "-gt" => Ok(Predicate::IntGt { - left: parse_value_text(left, line)?, - right: parse_value_text(right, line)?, - }), - [left, op, right] if op == "-ge" => Ok(Predicate::IntGte { - left: parse_value_text(left, line)?, - right: parse_value_text(right, line)?, - }), - _ => bail!("{line}: unsupported test predicate: {text}"), - } -} - -fn json_empty_value(text: &str, line: usize) -> Result<Option<Value>> { - let Some(inner) = text - .strip_prefix("\"$(") - .and_then(|text| text.strip_suffix(")\"")) - else { - return Ok(None); - }; - let tokens = split_words(inner, line)?; - match tokens.as_slice() { - [runseal, tool, json, empty, value] - if runseal == "runseal" && tool == "@tool" && json == "json" && empty == "empty" => - { - Ok(Some(parse_value_text(value, line)?)) - } - _ => bail!("{line}: unsupported command substitution predicate: {text}"), - } -} - -pub(crate) fn parse_seal(source: &str) -> Result<Program> { - Parser::new(source).parse_program() -} diff --git a/app/src/core/transpile/parse_argv.rs b/app/src/core/transpile/parse_argv.rs deleted file mode 100644 index dd823a4..0000000 --- a/app/src/core/transpile/parse_argv.rs +++ /dev/null @@ -1,214 +0,0 @@ -use std::collections::BTreeMap; - -use anyhow::{Result, bail}; - -use super::ast::{ArgvKind, ArgvPositional, ArgvSpec, Statement}; -use super::parse::{Parser, option_to_name}; -use super::parse_lex::assignment; - -pub(super) fn parse_argv_block(parser: &mut Parser) -> Result<Statement> { - parser.expect_exact("__seal_argc=$#")?; - parser.expect_exact("__seal_help=false")?; - - let mut order = Vec::new(); - let mut defaults = BTreeMap::new(); - while let Some(line) = parser.peek().cloned() { - if line.text == "while [ \"$#\" -gt 0 ]; do" { - break; - } - let Some((name, value)) = assignment(&line.text) else { - bail!("{}: expected argv variable default", line.number); - }; - parser.next(); - order.push(name.to_string()); - defaults.insert(name.to_string(), value.to_string()); - } - - parser.expect_exact("while [ \"$#\" -gt 0 ]; do")?; - parser.expect_exact("case \"$1\" in")?; - - let mut kinds = BTreeMap::new(); - let mut positional = None; - loop { - let Some(line) = parser.peek().cloned() else { - bail!("missing esac for argv parser"); - }; - match line.text.as_str() { - "esac" => { - parser.next(); - break; - } - "--)" => parse_double_dash(parser)?, - "-h|--help|help)" => parse_help(parser)?, - "*) fail \"unknown option: $1\" ;;" | "*) seal_fail \"unknown option: $1\" ;;" => { - parser.next(); - } - "*)" => { - positional = Some(parse_positional_arm(parser, &defaults)?); - } - text if text.starts_with("--") && text.ends_with("=*)") => { - parse_eq_arm(parser, &mut kinds, text, line.number)?; - } - text if text.starts_with("--") && text.ends_with(')') => { - parse_option_arm(parser, &mut kinds, text, line.number)?; - } - _ => bail!( - "{}: unsupported argv parser arm: {}", - line.number, - line.text - ), - } - } - parser.expect_exact("done")?; - Ok(Statement::ArgvParse { - specs: argv_specs(order, defaults, kinds, positional.as_ref())?, - positional, - }) -} - -fn parse_eq_arm( - parser: &mut Parser, - kinds: &mut BTreeMap<String, ArgvKind>, - text: &str, - line: usize, -) -> Result<()> { - let option = text.trim_end_matches("=*)"); - let name = option_to_name(option, line)?; - parser.next(); - parser.expect_exact(&format!("{name}=${{1#{option}=}}"))?; - parser.expect_exact("shift")?; - parser.expect_exact(";;")?; - kinds.insert(name, ArgvKind::String); - Ok(()) -} - -fn parse_option_arm( - parser: &mut Parser, - kinds: &mut BTreeMap<String, ArgvKind>, - text: &str, - line: usize, -) -> Result<()> { - let option = text.trim_end_matches(')'); - let name = option_to_name(option, line)?; - parser.next(); - if parse_missing_value_guard(parser)? { - parser.expect_exact(&format!("{name}=$2"))?; - parser.expect_exact("shift 2")?; - parser.expect_exact(";;")?; - kinds.insert(name, ArgvKind::String); - } else { - parser.expect_exact(&format!("{name}=true"))?; - parser.expect_exact("shift")?; - parser.expect_exact(";;")?; - kinds.insert(name, ArgvKind::Flag); - } - Ok(()) -} - -fn parse_missing_value_guard(parser: &mut Parser) -> Result<bool> { - let Some(line) = parser.peek().cloned() else { - bail!("missing argv option arm body"); - }; - if line.text.starts_with("if [ \"$#\" -lt 2 ]; then ") { - parser.next(); - return Ok(true); - } - if line.text != "if [ \"$#\" -lt 2 ]; then" { - return Ok(false); - } - - parser.next(); - while let Some(next) = parser.peek().cloned() { - parser.next(); - if next.text == "fi" { - return Ok(true); - } - } - bail!("missing fi for argv missing-value guard"); -} - -fn parse_double_dash(parser: &mut Parser) -> Result<()> { - parser.expect_exact("--)")?; - parser.expect_exact("shift")?; - parser.expect_exact("break")?; - parser.expect_exact(";;") -} - -fn parse_help(parser: &mut Parser) -> Result<()> { - parser.expect_exact("-h|--help|help)")?; - parser.expect_exact("__seal_help=true")?; - parser.expect_exact("shift")?; - parser.expect_exact(";;") -} - -fn parse_positional_arm( - parser: &mut Parser, - defaults: &BTreeMap<String, String>, -) -> Result<ArgvPositional> { - parser.expect_exact("*)")?; - let line = parser - .next() - .ok_or_else(|| anyhow::anyhow!("missing positional argv arm body"))?; - let Some(name) = line - .text - .strip_prefix("if [ -z \"$") - .and_then(|text| text.strip_suffix("\" ]; then")) - else { - bail!("{}: unsupported argv positional arm", line.number); - }; - parser.expect_exact(&format!("{name}=$1"))?; - parser.expect_exact("shift")?; - parser.expect_exact("else")?; - let fail_line = parser - .next() - .ok_or_else(|| anyhow::anyhow!("missing argv positional else body"))?; - let extra_error = fail_line - .text - .strip_prefix("fail \"") - .and_then(|text| text.strip_suffix('"')) - .or_else(|| { - fail_line - .text - .strip_prefix("seal_fail \"") - .and_then(|text| text.strip_suffix('"')) - }) - .ok_or_else(|| { - anyhow::anyhow!("{}: unsupported argv positional else arm", fail_line.number) - })?; - parser.expect_exact("fi")?; - parser.expect_exact(";;")?; - Ok(ArgvPositional { - name: name.to_string(), - default: defaults.get(name).cloned().unwrap_or_default(), - extra_error: extra_error.to_string(), - }) -} - -fn argv_specs( - order: Vec<String>, - mut defaults: BTreeMap<String, String>, - mut kinds: BTreeMap<String, ArgvKind>, - positional: Option<&ArgvPositional>, -) -> Result<Vec<ArgvSpec>> { - let mut specs = Vec::new(); - for name in order { - if positional.is_some_and(|positional| positional.name == name) { - defaults.remove(&name); - continue; - } - let Some(kind) = kinds.remove(&name) else { - bail!("missing argv parser arm for {name}"); - }; - let default = defaults.remove(&name).unwrap_or_default(); - let default = match kind { - ArgvKind::String => Some(default), - ArgvKind::Flag => None, - }; - specs.push(ArgvSpec { - name, - kind, - default, - }); - } - Ok(specs) -} diff --git a/app/src/core/transpile/parse_command.rs b/app/src/core/transpile/parse_command.rs deleted file mode 100644 index 7e1d628..0000000 --- a/app/src/core/transpile/parse_command.rs +++ /dev/null @@ -1,100 +0,0 @@ -use anyhow::{Result, bail}; - -use super::ast::{OutputStream, Statement}; -use super::parse::parse_values; -use super::parse_lex::is_safe_command_name; -use super::value::parse_value_text; - -pub(super) fn parse_exec_write(tokens: &[String], line: usize) -> Result<Option<Statement>> { - let redirects = tokens - .iter() - .enumerate() - .filter(|(_, token)| matches!(token.as_str(), ">" | ">>" | "2>" | "2>>" | "|")) - .collect::<Vec<_>>(); - if redirects.is_empty() { - return Ok(None); - } - if redirects.iter().any(|(_, token)| token.as_str() == "|") { - bail!("{line}: unsupported shell metacharacter: |"); - } - if redirects.len() != 1 { - bail!("{line}: unsupported redirect combination"); - } - let (index, token) = redirects[0]; - if index == 0 || index + 2 != tokens.len() { - bail!("{line}: redirect requires one command and one file target"); - } - let argv_tokens = &tokens[..index]; - validate_external_tokens(argv_tokens, line)?; - let Some((command, args)) = argv_tokens.split_first() else { - bail!("{line}: redirect requires one command"); - }; - if command == "printf" && token.as_str() == ">" && tokens[index + 1] == "&2" { - return Ok(None); - } - validate_shell_command(command, args, line)?; - if !is_safe_command_name(command) { - bail!("{line}: unsupported statement: {}", tokens.join(" ")); - } - if matches!(tokens[index + 1].as_str(), "&1" | "&2") { - bail!("{line}: unsupported redirect combination"); - } - let path = parse_value_text(&tokens[index + 1], line)?; - let (stream, append) = match token.as_str() { - ">" => (OutputStream::Stdout, false), - ">>" => (OutputStream::Stdout, true), - "2>" => (OutputStream::Stderr, false), - "2>>" => (OutputStream::Stderr, true), - _ => unreachable!(), - }; - Ok(Some(Statement::ExecWrite { - stream, - path, - append, - argv: parse_values(argv_tokens, line)?, - })) -} - -pub(super) fn validate_external_tokens(tokens: &[String], line: usize) -> Result<()> { - for token in tokens { - if matches!(token.as_str(), ">" | ">>" | "2>" | "2>>" | "|") { - bail!("{line}: unsupported shell metacharacter: {token}"); - } - if token.starts_with('"') || token.starts_with('\'') { - continue; - } - if token - .chars() - .any(|ch| matches!(ch, '|' | '>' | '<' | '&' | ';' | '`')) - { - bail!("{line}: unsupported shell metacharacter in token: {token}"); - } - } - Ok(()) -} - -pub(super) fn validate_shell_command(command: &str, args: &[String], line: usize) -> Result<()> { - if SHELL_ONLY_COMMANDS.contains(&command) { - bail!( - "{line}: shell-specific construct is not supported in .seal: {command}; use .sh/.ps1 or file an issue for first-class support" - ); - } - let shell_launch = matches!( - (command, args.first().map(String::as_str)), - ("sh", Some("-c")) - | ("bash", Some("-c")) - | ("pwsh", Some("-Command" | "-command")) - | ("powershell", Some("-Command" | "-command")) - ); - if shell_launch { - bail!( - "{line}: shell-specific construct is not supported in .seal: {command} {}; use .sh/.ps1 or file an issue for first-class support", - args.first().expect("checked first arg exists") - ); - } - Ok(()) -} - -const SHELL_ONLY_COMMANDS: &[&str] = &[ - ".", "alias", "exec", "export", "local", "readonly", "source", "trap", "unalias", "unset", -]; diff --git a/app/src/core/transpile/parse_lex.rs b/app/src/core/transpile/parse_lex.rs deleted file mode 100644 index 988b091..0000000 --- a/app/src/core/transpile/parse_lex.rs +++ /dev/null @@ -1,196 +0,0 @@ -use anyhow::{Result, bail}; - -pub(super) fn assignment(text: &str) -> Option<(&str, &str)> { - let (name, value) = text.split_once('=')?; - is_valid_name(name).then_some((name, value)) -} - -pub(super) fn split_words(text: &str, line: usize) -> Result<Vec<String>> { - let mut words = Vec::new(); - let mut current = String::new(); - let mut quote = None; - let mut parameter_depth = 0_usize; - let mut chars = text.chars().peekable(); - while let Some(ch) = chars.next() { - if quote.is_none() && ch == '$' && chars.peek() == Some(&'{') { - current.push(ch); - current.push(chars.next().expect("peeked char should exist")); - parameter_depth += 1; - continue; - } - match quote { - Some(q) if ch == q => { - current.push(ch); - quote = None; - } - Some(_) => current.push(ch), - None if ch == '\'' || ch == '"' => { - current.push(ch); - quote = Some(ch); - } - None if ch == '}' && parameter_depth > 0 => { - current.push(ch); - parameter_depth -= 1; - } - None if ch == '2' && matches!(chars.peek(), Some('>')) => { - if !current.is_empty() { - words.push(std::mem::take(&mut current)); - } - chars.next(); - if matches!(chars.peek(), Some('>')) { - chars.next(); - words.push("2>>".to_string()); - } else { - words.push("2>".to_string()); - } - } - None if ch == '>' => { - if !current.is_empty() { - words.push(std::mem::take(&mut current)); - } - if matches!(chars.peek(), Some('>')) { - chars.next(); - words.push(">>".to_string()); - } else { - words.push(">".to_string()); - } - } - None if ch == '|' => { - if !current.is_empty() { - words.push(std::mem::take(&mut current)); - } - words.push("|".to_string()); - } - None if ch.is_whitespace() && parameter_depth == 0 => { - if !current.is_empty() { - words.push(std::mem::take(&mut current)); - } - } - None => current.push(ch), - } - } - if let Some(q) = quote { - bail!("{line}: unterminated {q} quote"); - } - if parameter_depth != 0 { - bail!("{line}: unterminated parameter expansion"); - } - if !current.is_empty() { - words.push(current); - } - Ok(words) -} - -pub(super) fn split_test_words(text: &str, line: usize) -> Result<Vec<String>> { - let mut words = Vec::new(); - let mut current = String::new(); - let mut quote = None; - let mut command_depth = 0_usize; - let mut parameter_depth = 0_usize; - let mut chars = text.chars().peekable(); - while let Some(ch) = chars.next() { - if quote.is_none() && ch == '$' && chars.peek() == Some(&'{') { - current.push(ch); - current.push(chars.next().expect("peeked char should exist")); - parameter_depth += 1; - continue; - } - if ch == '$' && chars.peek() == Some(&'(') { - current.push(ch); - current.push(chars.next().expect("peeked char should exist")); - command_depth += 1; - continue; - } - - match quote { - Some(q) if ch == q && command_depth == 0 => { - current.push(ch); - quote = None; - } - Some(_) => { - if ch == ')' && command_depth > 0 { - command_depth -= 1; - } - current.push(ch); - } - None if ch == '\'' || ch == '"' => { - current.push(ch); - quote = Some(ch); - } - None if ch == '}' && parameter_depth > 0 => { - current.push(ch); - parameter_depth -= 1; - } - None if ch.is_whitespace() && parameter_depth == 0 => { - if !current.is_empty() { - words.push(std::mem::take(&mut current)); - } - } - None => current.push(ch), - } - } - if let Some(q) = quote { - bail!("{line}: unterminated {q} quote"); - } - if command_depth != 0 { - bail!("{line}: unterminated command substitution"); - } - if parameter_depth != 0 { - bail!("{line}: unterminated parameter expansion"); - } - if !current.is_empty() { - words.push(current); - } - Ok(words) -} - -pub(super) fn strip_comment(line: &str) -> String { - let mut output = String::new(); - let mut quote = None; - let mut parameter_depth = 0_usize; - let mut chars = line.chars().peekable(); - while let Some(ch) = chars.next() { - if quote.is_none() && ch == '$' && chars.peek() == Some(&'{') { - output.push(ch); - output.push(chars.next().expect("peeked char should exist")); - parameter_depth += 1; - continue; - } - if quote.is_none() && ch == '$' && chars.peek() == Some(&'#') { - output.push(ch); - output.push(chars.next().expect("peeked char should exist")); - continue; - } - match quote { - Some(q) if ch == q => { - output.push(ch); - quote = None; - } - Some(_) => output.push(ch), - None if ch == '\'' || ch == '"' => { - output.push(ch); - quote = Some(ch); - } - None if ch == '}' && parameter_depth > 0 => { - parameter_depth -= 1; - output.push(ch); - } - None if ch == '#' && parameter_depth == 0 => break, - None => output.push(ch), - } - } - output -} - -pub(super) fn is_valid_name(name: &str) -> bool { - let mut bytes = name.bytes(); - matches!(bytes.next(), Some(byte) if byte.is_ascii_alphabetic() || byte == b'_') - && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') -} - -pub(super) fn is_safe_command_name(name: &str) -> bool { - !name.is_empty() - && name - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'/' | b'-')) -} diff --git a/app/src/core/transpile/runner/args.rs b/app/src/core/transpile/runner/args.rs deleted file mode 100644 index 5a1d3ab..0000000 --- a/app/src/core/transpile/runner/args.rs +++ /dev/null @@ -1,163 +0,0 @@ -use anyhow::{Result, bail}; - -use super::*; - -impl<'a> Runner<'a> { - pub(super) fn parse_argv( - &mut self, - specs: &[ArgvSpec], - positional: Option<&ArgvPositional>, - ) -> 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); - } - if let Some(positional) = positional { - self.vars - .insert(positional.name.clone(), positional.default.clone()); - } - 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 { - if let Some(positional) = positional { - let current = self.vars.get(&positional.name).cloned().unwrap_or_default(); - if current.is_empty() { - self.vars.insert(positional.name.clone(), arg.clone()); - index += 1; - continue; - } - eprintln!("{}", positional.extra_error.replace("$1", arg)); - bail!("argv parse failed"); - } - eprintln!("unknown option: {arg}"); - bail!("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!("argv parse failed"); - }; - self.vars.insert(spec.name.clone(), value.clone()); - index += 2; - } - } - } - } - Ok(()) - } - - pub(super) fn set_function_args(&mut self, argv: &[Value]) -> Result<ArgSnapshot> { - let values = self.expanded_values(argv)?; - let old_len = self.argc(); - let old = (0..=old_len) - .map(|index| self.vars.remove(&index.to_string())) - .collect::<Vec<_>>(); - let snapshot = ArgSnapshot { - argv: self.vars.remove("__seal_argv"), - values: old, - }; - self.set_positional_args(&values); - Ok(snapshot) - } - - pub(super) fn restore_function_args(&mut self, old: ArgSnapshot) { - let current_len = self.argc(); - for index in 0..=current_len { - self.vars.remove(&index.to_string()); - } - for (index, value) in old.values.into_iter().enumerate() { - match value { - Some(value) => { - self.vars.insert(index.to_string(), value); - } - None => { - self.vars.remove(&index.to_string()); - } - } - } - match old.argv { - Some(value) => { - self.vars.insert("__seal_argv".to_string(), value); - } - None => { - self.vars.remove("__seal_argv"); - } - } - } - - pub(super) fn expanded_values(&self, values: &[Value]) -> Result<Vec<String>> { - let mut expanded = Vec::new(); - for value in values { - match value { - Value::Args => expanded.extend(self.current_args()), - Value::Argc => expanded.push(self.argc().to_string()), - _ => expanded.push(self.try_value(value)?), - } - } - Ok(expanded) - } - - pub(super) fn argc(&self) -> usize { - self.vars - .get("0") - .and_then(|value| value.parse::<usize>().ok()) - .unwrap_or_default() - } - - pub(super) fn current_args(&self) -> Vec<String> { - (1..=self.argc()) - .filter_map(|index| self.vars.get(&index.to_string()).cloned()) - .collect() - } - - pub(super) fn shift_args(&mut self, count: usize) { - let args = self.current_args(); - let remaining = args.into_iter().skip(count).collect::<Vec<_>>(); - self.set_positional_args(&remaining); - } - - fn set_positional_args(&mut self, values: &[String]) { - let old_len = self.argc(); - for index in 0..=old_len { - self.vars.remove(&index.to_string()); - } - for (index, value) in values.iter().enumerate() { - self.vars.insert((index + 1).to_string(), value.clone()); - } - self.vars.insert("0".to_string(), values.len().to_string()); - self.vars - .insert("__seal_argv".to_string(), shell_words(values)); - } -} diff --git a/app/src/core/transpile/runner/mod.rs b/app/src/core/transpile/runner/mod.rs deleted file mode 100644 index 53f5341..0000000 --- a/app/src/core/transpile/runner/mod.rs +++ /dev/null @@ -1,357 +0,0 @@ -use std::{collections::BTreeMap, path::Path, process::Command, time::Duration}; - -use anyhow::{Context, Result, bail}; - -use crate::core::tool; - -use self::support::{ - ArgSnapshot, CaptureMode, CommandOutput, SourceState, case_matches, find_spec, - map_source_state, option_name, shell_words, split_words, write_stderr, write_stderr_line, - write_stdout, write_stdout_line, write_stream_file, -}; -use super::ast::{ - ArgvKind, ArgvPositional, ArgvSpec, ExpansionOp, Item, Predicate, Program, Statement, Value, - ValueSource, -}; -use super::parse::parse_seal; - -mod args; -mod support; - -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>, - stdout_stack: Vec<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, - stdout_stack: Vec::new(), - } - } - - 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.try_value(value)?; - self.vars.insert(name.clone(), value); - } - Statement::ArgvParse { specs, positional } => { - self.parse_argv(specs, positional.as_ref())? - } - Statement::Shift { count } => self.shift_args(*count), - Statement::ExecChecked { argv } => { - let code = self.run_external(argv, CaptureMode::None)?.code; - if code != 0 { - return Ok(Flow::Exit(code)); - } - } - Statement::ExecWrite { - stream, - path, - append, - argv, - } => { - let output = self.run_external(argv, CaptureMode::All)?; - let path = self.try_value(path)?; - write_stream_file(stream, Path::new(&path), *append, &output)?; - if output.code != 0 { - return Ok(Flow::Exit(output.code)); - } - } - Statement::EnvExecChecked { env, argv } => { - let overlay = env - .iter() - .map(|item| Ok((item.name.clone(), self.try_value(&item.value)?))) - .collect::<Result<Vec<_>>>()?; - let code = self - .run_external_with_env(argv, CaptureMode::None, &overlay)? - .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::CaptureFunction { - name, - function, - argv, - } => { - let old_args = self.set_function_args(argv)?; - self.stdout_stack.push(String::new()); - let flow = self.run_function(function)?; - let captured = self - .stdout_stack - .pop() - .expect("stdout capture stack should be balanced"); - self.restore_function_args(old_args); - if !matches!(flow, Flow::Continue) { - return Ok(flow); - } - self.vars.insert(name.clone(), captured.trim().to_string()); - } - 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.try_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 } => { - let text = self.try_value(value)?; - write_stdout_line(&mut self.stdout_stack, &text)? - } - Statement::Error { value } => write_stderr_line(&self.try_value(value)?)?, - Statement::Fail { value } => { - write_stderr_line(&self.try_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 try_value(&self, value: &Value) -> Result<String> { - Ok(match value { - Value::Literal { text } => text.clone(), - Value::Argc => self.argc().to_string(), - Value::Args => shell_words(&self.current_args()), - Value::Expand { source, op } => self.expand_value(source, op)?, - Value::Concat { parts } => { - let mut combined = String::new(); - for part in parts { - combined.push_str(&self.try_value(part)?); - } - combined - } - }) - } - - fn predicate(&mut self, predicate: &Predicate) -> Result<bool> { - Ok(match predicate { - Predicate::Command { argv } => self.run_external(argv, CaptureMode::None)?.code == 0, - Predicate::Empty { value } => self.try_value(value)?.is_empty(), - Predicate::NotEmpty { value } => !self.try_value(value)?.is_empty(), - Predicate::Eq { left, right } => self.try_value(left)? == self.try_value(right)?, - Predicate::Neq { left, right } => self.try_value(left)? != self.try_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.try_value(path)?).is_file(), - Predicate::DirExists { path } => Path::new(&self.try_value(path)?).is_dir(), - }) - } - - fn int_value(&self, value: &Value) -> Result<i64> { - let value = self.try_value(value)?; - value - .parse::<i64>() - .with_context(|| format!("invalid integer: {value}")) - } - - fn expand_value(&self, source: &ValueSource, op: &ExpansionOp) -> Result<String> { - let state = self.source_state(source); - match op { - ExpansionOp::Plain => Ok(match state { - SourceState::Unset => String::new(), - SourceState::Empty => String::new(), - SourceState::Present(value) => value, - }), - ExpansionOp::DefaultIfUnsetOrEmpty { fallback } => Ok(match state { - SourceState::Present(value) => value, - SourceState::Unset | SourceState::Empty => fallback.clone(), - }), - ExpansionOp::RequireNonEmpty { message } => match state { - SourceState::Present(value) => Ok(value), - SourceState::Unset | SourceState::Empty => bail!("{message}"), - }, - } - } - - fn source_state(&self, source: &ValueSource) -> SourceState { - match source { - ValueSource::Env { name } => map_source_state(self.env.get(name).cloned()), - ValueSource::Var { name } => map_source_state(self.vars.get(name).cloned()), - } - } - - fn tool_path(&self, path: &[&str], argv: &[Value]) -> Result<String> { - let mut args = path.iter().map(|part| part.to_string()).collect::<Vec<_>>(); - args.extend(self.expanded_values(argv)?); - Ok(tool::eval(&args)?.unwrap_or_default()) - } - - fn run_external(&mut self, argv: &[Value], capture: CaptureMode) -> Result<CommandOutput> { - self.run_external_with_env(argv, capture, &[]) - } - - fn run_external_with_env( - &mut self, - argv: &[Value], - capture: CaptureMode, - env_overlay: &[(String, String)], - ) -> Result<CommandOutput> { - let argv = self.expanded_values(argv)?; - 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); - command.envs(env_overlay.iter().map(|(key, value)| (key, value))); - if matches!(capture, CaptureMode::None) && self.stdout_stack.is_empty() { - let status = command - .status() - .with_context(|| format!("failed to execute command: {program}"))?; - return Ok(CommandOutput { - code: status.code().unwrap_or(1), - stdout: String::new(), - stderr: String::new(), - }); - } - let output = command - .output() - .with_context(|| format!("failed to execute command: {program}"))?; - let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); - let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); - if matches!(capture, CaptureMode::None) { - write_stdout(&mut self.stdout_stack, &stdout)?; - write_stderr(&stderr)?; - } - Ok(CommandOutput { - code: output.status.code().unwrap_or(1), - stdout, - stderr, - }) - } -} diff --git a/app/src/core/transpile/runner/support.rs b/app/src/core/transpile/runner/support.rs deleted file mode 100644 index 6bfa3e6..0000000 --- a/app/src/core/transpile/runner/support.rs +++ /dev/null @@ -1,146 +0,0 @@ -use std::path::Path; - -use anyhow::{Context, Result}; - -use super::super::ast::{ArgvSpec, OutputStream}; - -pub(super) enum CaptureMode { - None, - Stdout, - All, -} - -pub(super) struct CommandOutput { - pub(super) code: i32, - pub(super) stdout: String, - pub(super) stderr: String, -} - -pub(super) struct ArgSnapshot { - pub(super) argv: Option<String>, - pub(super) values: Vec<Option<String>>, -} - -pub(super) enum SourceState { - Unset, - Empty, - Present(String), -} - -pub(super) fn write_stream_file( - stream: &OutputStream, - path: &Path, - append: bool, - output: &CommandOutput, -) -> Result<()> { - use std::io::Write; - - if let Some(parent) = path.parent() - && !parent.as_os_str().is_empty() - { - std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create parent directory: {}", parent.display()))?; - } - let captured = match stream { - OutputStream::Stdout => output.stdout.as_bytes(), - OutputStream::Stderr => output.stderr.as_bytes(), - }; - if append { - let mut file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(path) - .with_context(|| format!("failed to append file: {}", path.display()))?; - file.write_all(captured) - .with_context(|| format!("failed to append file: {}", path.display()))?; - } else { - std::fs::write(path, captured) - .with_context(|| format!("failed to write file: {}", path.display()))?; - } - match stream { - OutputStream::Stdout => { - std::io::stderr() - .write_all(output.stderr.as_bytes()) - .context("failed to write stderr")?; - } - OutputStream::Stderr => { - std::io::stdout() - .write_all(output.stdout.as_bytes()) - .context("failed to write stdout")?; - } - } - Ok(()) -} - -pub(super) 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 + "=")) - }) -} - -pub(super) fn option_name(name: &str) -> String { - format!("--{}", name.replace('_', "-")) -} - -pub(super) fn case_matches(pattern: &str, value: &str) -> bool { - pattern == "*" || pattern == value -} - -pub(super) fn shell_words(argv: &[String]) -> String { - argv.join("\u{1f}") -} - -pub(super) fn split_words(value: &str) -> Vec<String> { - if value.is_empty() { - Vec::new() - } else { - value.split('\u{1f}').map(str::to_string).collect() - } -} - -pub(super) fn map_source_state(value: Option<String>) -> SourceState { - match value { - None => SourceState::Unset, - Some(value) if value.is_empty() => SourceState::Empty, - Some(value) => SourceState::Present(value), - } -} - -pub(super) fn write_stdout(stdout_stack: &mut [String], text: &str) -> Result<()> { - use std::io::Write; - - if text.is_empty() { - return Ok(()); - } - if let Some(buffer) = stdout_stack.last_mut() { - buffer.push_str(text); - return Ok(()); - } - std::io::stdout() - .write_all(text.as_bytes()) - .context("failed to write stdout") -} - -pub(super) fn write_stdout_line(stdout_stack: &mut [String], text: &str) -> Result<()> { - let mut line = text.to_string(); - line.push('\n'); - write_stdout(stdout_stack, &line) -} - -pub(super) fn write_stderr(text: &str) -> Result<()> { - use std::io::Write; - - if text.is_empty() { - return Ok(()); - } - std::io::stderr() - .write_all(text.as_bytes()) - .context("failed to write stderr") -} - -pub(super) fn write_stderr_line(text: &str) -> Result<()> { - let mut line = text.to_string(); - line.push('\n'); - write_stderr(&line) -} diff --git a/app/src/core/transpile/value.rs b/app/src/core/transpile/value.rs deleted file mode 100644 index e7d5e1b..0000000 --- a/app/src/core/transpile/value.rs +++ /dev/null @@ -1,173 +0,0 @@ -use anyhow::{Result, bail}; - -use super::ast::{ExpansionOp, Value, ValueSource}; - -pub(crate) fn parse_value_text(text: &str, line: usize) -> Result<Value> { - if text == "$@" || text == "\"$@\"" { - return Ok(Value::Args); - } - if text == "$#" || text == "\"$#\"" { - return Ok(Value::Argc); - } - if let Some(value) = text - .strip_prefix('\'') - .and_then(|value| value.strip_suffix('\'')) - { - return Ok(Value::Literal { - text: value.to_string(), - }); - } - if let Some(value) = text - .strip_prefix('"') - .and_then(|value| value.strip_suffix('"')) - { - return parse_template(value, line); - } - if let Some(name) = text.strip_prefix('$') { - if is_positional_name(name) { - return Ok(Value::Expand { - source: ValueSource::Var { - name: name.to_string(), - }, - op: ExpansionOp::Plain, - }); - } - if let Some(name) = name - .strip_prefix('{') - .and_then(|name| name.strip_suffix('}')) - { - return parse_braced_expansion(name, line); - } - validate_name(name, line)?; - return Ok(Value::Expand { - source: ValueSource::Var { - name: name.to_string(), - }, - op: ExpansionOp::Plain, - }); - } - if text.contains('$') { - return parse_template(text, line); - } - Ok(Value::Literal { - text: text.to_string(), - }) -} - -fn parse_template(text: &str, line: usize) -> Result<Value> { - let mut parts = Vec::new(); - let mut literal = String::new(); - let mut chars = text.chars().peekable(); - while let Some(ch) = chars.next() { - if ch != '$' { - literal.push(ch); - continue; - } - if !literal.is_empty() { - parts.push(Value::Literal { - text: std::mem::take(&mut literal), - }); - } - if chars.peek() == Some(&'{') { - chars.next(); - let mut inner = String::new(); - for next in chars.by_ref() { - if next == '}' { - break; - } - inner.push(next); - } - parts.push(parse_braced_expansion(&inner, line)?); - continue; - } - let mut name = String::new(); - if let Some(next) = chars.peek().copied() - && next == '@' - { - bail!("{line}: $@ is only supported as a standalone argument"); - } - if let Some(next) = chars.peek().copied() - && next.is_ascii_digit() - { - name.push(next); - chars.next(); - parts.push(Value::Expand { - source: ValueSource::Var { name }, - op: ExpansionOp::Plain, - }); - continue; - } - while let Some(next) = chars.peek().copied() { - if next.is_ascii_alphanumeric() || next == '_' { - name.push(next); - chars.next(); - } else { - break; - } - } - validate_name(&name, line)?; - parts.push(Value::Expand { - source: ValueSource::Var { name }, - op: ExpansionOp::Plain, - }); - } - if !literal.is_empty() { - parts.push(Value::Literal { text: literal }); - } - match parts.as_slice() { - [single] => Ok(single.clone()), - _ => Ok(Value::Concat { parts }), - } -} - -fn is_positional_name(name: &str) -> bool { - !name.is_empty() && name.bytes().all(|byte| byte.is_ascii_digit()) -} - -fn parse_braced_expansion(text: &str, line: usize) -> Result<Value> { - if let Some((name, message)) = text.split_once(":?") { - let source = parse_braced_source(name, line)?; - return Ok(Value::Expand { - source, - op: ExpansionOp::RequireNonEmpty { - message: message.to_string(), - }, - }); - } - if let Some((name, fallback)) = text.split_once(":-") { - let source = parse_braced_source(name, line)?; - return Ok(Value::Expand { - source, - op: ExpansionOp::DefaultIfUnsetOrEmpty { - fallback: fallback.to_string(), - }, - }); - } - let source = parse_braced_source(text, line)?; - Ok(Value::Expand { - source, - op: ExpansionOp::Plain, - }) -} - -fn parse_braced_source(name: &str, line: usize) -> Result<ValueSource> { - if is_positional_name(name) { - return Ok(ValueSource::Var { - name: name.to_string(), - }); - } - validate_name(name, line)?; - Ok(ValueSource::Env { - name: name.to_string(), - }) -} - -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'_') - && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'); - if !valid { - bail!("{line}: invalid variable name: {name}"); - } - Ok(()) -} diff --git a/app/tests/first_run.rs b/app/tests/first_run.rs index 4f44b63..cd11650 100644 --- a/app/tests/first_run.rs +++ b/app/tests/first_run.rs @@ -26,7 +26,6 @@ fn internal_help_without_profile() { (vec!["@profile", "--help"], "Usage: runseal @profile"), (vec!["@resources", "--help"], "Usage: runseal @resources"), (vec!["@resolve", "--help"], "Usage: runseal @resolve"), - (vec!["@transpile", "--help"], "Usage: runseal @transpile"), (vec!["@wrappers", "--help"], "Usage: runseal @wrappers"), (vec!["@which", "--help"], "Usage: runseal @which :<wrapper>"), ] { diff --git a/app/tests/fixtures/estate/admin.seal b/app/tests/fixtures/estate/admin.seal deleted file mode 100644 index 7a352aa..0000000 --- a/app/tests/fixtures/estate/admin.seal +++ /dev/null @@ -1,191 +0,0 @@ -print() { - printf '%s\n' "$1" -} - -error() { - printf '%s\n' "$1" >&2 -} - -fail() { - error "$1" - exit 1 -} - -usage() { - print "Usage: runseal :admin <command> [args]" - print "" - print "Commands:" - print " init" - print " check" - print " bootstrap-kube" - print " export <archive.enc>" - print " import [--force] <archive.enc>" -} - -local_dir=${PERISH_TOP_LOCAL_DIR} -ssh_dir=${PERISH_TOP_SSH_DIR} -ssh_config=${PERISH_TOP_SSH_CONFIG} -kube_dir=${PERISH_TOP_KUBE_DIR} -secrets_dir=${PERISH_TOP_SECRETS_DIR} -tmp_dir=${PERISH_TOP_TMP_DIR} - -ensure_dir() { - runseal @tool fs mkdir "$1" 700 -} - -ensure_file() { - runseal @tool fs touch "$1" 600 -} - -check_dir() { - if [ -d "$1" ]; then - current=$(runseal @tool fs mode "$1") - if [ "$current" = 700 ]; then - else - print "bad mode: $1 is $current, expected 700" - ok=false - fi - else - print "missing: $1" - ok=false - fi -} - -check_file_mode() { - if [ -f "$1" ]; then - current=$(runseal @tool fs mode "$1") - if [ "$current" = "$2" ]; then - else - print "bad mode: $1 is $current, expected $2" - ok=false - fi - else - print "missing: $1" - ok=false - fi -} - -check_host() { - allowed=$(runseal @tool ssh config host "$1" --config "$config") - if [ "$allowed" = true ]; then - else - print "missing ssh Host: $1" - ok=false - fi -} - -if [ -z "$1" ]; then - usage - exit 0 -fi - -command=$1 -shift - -case "$command" in - init) - if [ -n "$1" ]; then - fail "admin init: unknown argument: $1" - fi - ensure_dir "$local_dir" - ensure_dir "$ssh_dir" - ensure_dir "$kube_dir" - ensure_dir "$secrets_dir" - ensure_dir "$tmp_dir" - config="$ssh_config" - if [ -f "$config" ]; then - print "exists $config" - else - runseal @tool fs write-base64 "$config" "__SSH_CONFIG_BASE64__" - runseal @tool fs chmod "$config" 600 - print "created $config" - fi - ensure_file "$ssh_dir/id_perish_top_root" - ensure_file "$ssh_dir/known_hosts" - print "admin init: ok" - ;; - check) - if [ -n "$1" ]; then - fail "admin check: unknown argument: $1" - fi - ok=true - config="$ssh_config" - check_dir "$local_dir" - check_dir "$ssh_dir" - check_dir "$kube_dir" - check_dir "$secrets_dir" - check_file_mode "$config" 600 - check_host 10m.hk.zxi - check_host 5m.hk.zxi - check_host la.us.lisa - check_host ny.us.lisa - identities=$(runseal @tool ssh config identities --config "$config") - identity=$(runseal @tool json get "$identities" '.[0]') - check_file_mode "$identity" 600 - kube_files=$(runseal @tool fs list "$kube_dir" --glob "*.yaml" --files --require-nonempty) - kube_file=$(runseal @tool json get "$kube_files" '.[0]') - check_file_mode "$kube_file" 600 - if [ "$ok" = true ]; then - print "admin check: ok" - else - print "admin check: failed" - exit 1 - fi - ;; - bootstrap-kube) - if [ -n "$1" ]; then - fail "admin bootstrap-kube: unknown argument: $1" - fi - ensure_dir "$kube_dir" - host=10m.hk.zxi - context=hk-zxi - server=https://k8s.perish.top:6443 - ops_admin=infra/k8s/access/ops-admin.yaml - setup=nodes/10m-hk-zxi/k3s/bootstrap/35-emit-kubeconfig.sh - kubeconfig="$kube_dir/$context.yaml" - runseal @tool ssh script run --config "$ssh_config" --host "$host" --file "$ops_admin" - raw=$(runseal @tool ssh script capture --config "$ssh_config" --host "$host" --file "$setup" -- "$server" "$context") - runseal @tool fs write "$kubeconfig" "$raw" 600 - print "admin bootstrap-kube: wrote $kubeconfig" - ;; - export) - if [ -z "$1" ]; then - fail "admin export: archive path is required" - fi - archive=$1 - shift - if [ -n "$1" ]; then - fail "admin export: unknown argument: $1" - fi - runseal @tool archive local export --source "$local_dir" --archive "$archive" --password-env PERISH_TOP_LOCAL_PASSWORD - print "admin export: wrote encrypted archive $archive" - ;; - import) - force=false - if [ "$1" = --force ]; then - force=true - shift - fi - if [ -z "$1" ]; then - fail "admin import: archive path is required" - fi - archive=$1 - shift - if [ -n "$1" ]; then - fail "admin import: unknown argument: $1" - fi - if [ "$force" = true ]; then - runseal @tool archive local import --source "$local_dir" --archive "$archive" --password-env PERISH_TOP_LOCAL_PASSWORD --force - else - runseal @tool archive local import --source "$local_dir" --archive "$archive" --password-env PERISH_TOP_LOCAL_PASSWORD - fi - print "admin import: restored .local" - ;; - -h|--help|help) - usage - ;; - *) - usage - exit 2 - ;; -esac diff --git a/app/tests/fixtures/estate/kube.seal b/app/tests/fixtures/estate/kube.seal deleted file mode 100644 index a326e3d..0000000 --- a/app/tests/fixtures/estate/kube.seal +++ /dev/null @@ -1,13 +0,0 @@ -case "${1:-}" in - -h|--help|help) - print "Usage: runseal :kube [kubectl args...]" - print "" - print "Assemble KUBECONFIG from .local/kube/*.yaml and run kubectl with it." - exit 0 - ;; -esac - -kube_dir=${PERISH_TOP_KUBE_DIR:?kube: missing PERISH_TOP_KUBE_DIR} -configs=$(runseal @tool fs list "$kube_dir" --glob "*.yaml" --files --require-nonempty) -kubeconfig=$(runseal @tool string join "$configs" --separator path) -KUBECONFIG="$kubeconfig" kubectl "$@" diff --git a/app/tests/fixtures/estate/pr.seal b/app/tests/fixtures/estate/pr.seal deleted file mode 100644 index 3a7565b..0000000 --- a/app/tests/fixtures/estate/pr.seal +++ /dev/null @@ -1,161 +0,0 @@ -print() { - printf '%s\n' "$1" -} - -error() { - printf '%s\n' "$1" >&2 -} - -fail() { - error "$1" - exit 1 -} - -usage() { - print "Usage: runseal :pr [options] <message>" -} - -base=main -branch= -body= -no_merge=false -dry_run=false -resume=false -message= - -while [ "$#" -gt 0 ]; do - case "$1" in - --base) - if [ "$#" -lt 2 ]; then - fail "missing value for --base" - fi - base=$2 - shift 2 - ;; - --branch) - if [ "$#" -lt 2 ]; then - fail "missing value for --branch" - fi - branch=$2 - shift 2 - ;; - --body) - if [ "$#" -lt 2 ]; then - fail "missing value for --body" - fi - body=$2 - shift 2 - ;; - --no-merge) - no_merge=true - shift - ;; - --dry-run) - dry_run=true - shift - ;; - --resume) - resume=true - shift - ;; - -h|--help|help) - usage - exit 0 - ;; - *) - if [ -z "$message" ]; then - message=$1 - shift - else - fail "pr: unexpected argument: $1" - fi - ;; - esac -done - -if [ -z "$message" ]; then - usage - exit 2 -fi - -if [ -z "$body" ]; then - body="$message" -fi - -current=$(git branch --show-current) - -if [ -z "$branch" ]; then - if [ "$resume" = true ]; then - branch="$current" - else - slug=$(runseal @tool string slug "$message" --max-len 48 --fallback change) - branch="auto/$slug" - fi -fi - -token_file="${PERISH_TOP_SECRETS_DIR}/gitee.env" -origin=$(git remote get-url origin) -repo_json=$(runseal @tool gitee repo parse-origin "$origin") -owner=$(runseal @tool json get "$repo_json" .owner) -repo=$(runseal @tool json get "$repo_json" .repo) - -if [ "$dry_run" = true ]; then - print "branch: $branch" - print "base: $base" - print "owner: $owner" - print "repo: $repo" - print "resume: $resume" - exit 0 -fi - -if [ "$resume" = true ]; then - if [ "$branch" = "$base" ]; then - fail "pr: --resume needs a topic branch, not the base branch" - fi - if [ "$current" = "$branch" ]; then - else - if git checkout "$branch"; then - else - git fetch origin "$branch" - git checkout -B "$branch" "origin/$branch" - fi - fi - dirty=$(git status --short) - if [ -n "$dirty" ]; then - fail "pr: --resume requires a clean topic branch" - fi -else - if [ "$current" = "$base" ]; then - else - fail "pr: must start from $base, current branch is $current" - fi - dirty=$(git status --short) - if [ -z "$dirty" ]; then - fail "pr: no local changes to land" - fi - git checkout -b "$branch" - git add -A - git commit -m "$message" -fi - -git push -u origin "$branch" - -pr=$(runseal @tool gitee pr find --owner "$owner" --repo "$repo" --token-file "$token_file" --head "$branch" --base "$base") -if [ "$(runseal @tool json empty "$pr")" = true ]; then - pr=$(runseal @tool gitee pr create --owner "$owner" --repo "$repo" --token-file "$token_file" --base "$base" --head "$branch" --title "$message" --body "$body") -fi -number=$(runseal @tool json get "$pr" .number) -url=$(runseal @tool json get "$pr" .html_url) - -if [ "$no_merge" = false ]; then - runseal @tool gitee pr pass-gates --owner "$owner" --repo "$repo" --token-file "$token_file" --number "$number" - runseal @tool gitee pr merge --owner "$owner" --repo "$repo" --token-file "$token_file" --number "$number" --method squash - git checkout "$base" - git pull --ff-only origin "$base" - git push origin --delete "$branch" - git branch -D "$branch" -else - git checkout "$base" -fi - -print "$url" diff --git a/app/tests/fixtures/estate/ssh.seal b/app/tests/fixtures/estate/ssh.seal deleted file mode 100644 index c9c6ca4..0000000 --- a/app/tests/fixtures/estate/ssh.seal +++ /dev/null @@ -1,73 +0,0 @@ -print() { - printf '%s\n' "$1" -} - -error() { - printf '%s\n' "$1" >&2 -} - -fail() { - error "$1" - exit 1 -} - -usage() { - print "Usage: runseal :ssh <host> [--run <script> [-- <args>...] | -- <remote-command>...]" -} - -ssh_config=${PERISH_TOP_SSH_CONFIG} -run_script=false -script= - -if [ -z "$1" ]; then - usage - exit 0 -fi - -case "$1" in - -h|--help|help) - usage - exit 0 - ;; -esac - -host=$1 -shift - -if [ -n "$1" ]; then - if [ "$1" = --run ]; then - run_script=true - shift - if [ -z "$1" ]; then - usage - exit 2 - fi - script=$1 - shift - if [ -n "$1" ]; then - if [ "$1" = -- ]; then - shift - else - fail "ssh: script arguments must be separated with --" - fi - fi - else - if [ "$1" = -- ]; then - shift - else - fail "ssh: remote command must be separated with --" - fi - fi -fi - -if [ "$run_script" = true ]; then - runseal @tool ssh script run --config "$ssh_config" --host "$host" --file "$script" -- "$@" -else - allowed=$(runseal @tool ssh config host "$host" --config "$ssh_config") - if [ "$allowed" = true ]; then - else - fail "ssh: host is not declared in $ssh_config: $host" - fi - - ssh -F "$ssh_config" "$host" "$@" -fi diff --git a/app/tests/internal.rs b/app/tests/internal.rs index c48eb37..097a2df 100644 --- a/app/tests/internal.rs +++ b/app/tests/internal.rs @@ -136,11 +136,10 @@ fn help_explains_model() { assert!(stdout.contains("runseal <cmd>")); assert!(stdout.contains("runseal :<name>")); assert!(stdout.contains("runseal @<name>")); - assert!(stdout.contains(".seal files are bash-runnable")); + assert!(stdout.contains(".ts files are run with deno")); assert!(stdout.contains("runseal @tool for atomic glue")); assert!(stdout.contains("@profile")); assert!(stdout.contains("@resolve")); - assert!(stdout.contains("@transpile")); assert!(stdout.contains("current directory upward")); assert!(stdout.contains("https://github.com/PerishCode/runseal")); } @@ -154,11 +153,12 @@ fn internal_help_topics() { (vec!["@profile", "help"], "Profile discovery"), (vec!["@resources", "--help"], "Usage: runseal @resources"), (vec!["@resolve", "--help"], "Usage: runseal @resolve"), - (vec!["@transpile", "--help"], "Usage: runseal @transpile"), - (vec!["@transpile", "-h"], "constrained bash subset"), (vec!["@tool", "--help"], "Usage: runseal @tool"), (vec!["@wrappers", "--help"], "Lookup order"), - (vec!["@wrappers", "-h"], ".seal wrappers are bash-runnable"), + ( + vec!["@wrappers", "-h"], + ".ts wrappers for structured cross-platform operations", + ), (vec!["@which", "--help"], "Usage: runseal @which :<wrapper>"), ] { let output = run_in(&fx, &args); diff --git a/app/tests/internal_tool.rs b/app/tests/internal_tool.rs index 38e4855..fa93981 100644 --- a/app/tests/internal_tool.rs +++ b/app/tests/internal_tool.rs @@ -64,13 +64,13 @@ fn tool_runs_without_profile() { "@tool", "string", "slug", - "Land Change: ship .seal!", + "Land Change: ship deno!", "--max-len", "48", "--fallback", "change", ], - "land-change-ship-seal\n", + "land-change-ship-deno\n", ), ( vec![ diff --git a/app/tests/internal_wrappers.rs b/app/tests/internal_wrappers.rs index cf9c948..c9bbe29 100644 --- a/app/tests/internal_wrappers.rs +++ b/app/tests/internal_wrappers.rs @@ -1,4 +1,7 @@ +#![cfg(unix)] + use std::{ + ffi::OsString, path::{Path, PathBuf}, process::Command, }; @@ -9,46 +12,39 @@ fn bin() -> Command { Command::new(env!("CARGO_BIN_EXE_runseal")) } -#[cfg(unix)] -fn wrapper_file(dir: &Path, name: &str) -> PathBuf { +fn shell_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")) +fn ts_wrapper_file(dir: &Path, name: &str) -> PathBuf { + dir.join(format!("{name}.ts")) +} + +fn make_shell_wrapper(path: &Path, label: &str) { + write_executable(path, &format!("#!/usr/bin/env sh\nprintf '{}\\n'\n", label)); +} + +fn make_ts_wrapper(path: &Path) { + std::fs::write(path, "console.log(Deno.args.join('|'));\n") + .expect("ts wrapper should be written"); } -#[cfg(unix)] -fn make_wrapper(path: &Path, label: &str) { +fn write_executable(path: &Path, content: &str) { use std::os::unix::fs::PermissionsExt; - std::fs::write(path, format!("#!/usr/bin/env sh\nprintf '{}'\n", label)) - .expect("wrapper should be written"); + std::fs::write(path, content).expect("executable should be written"); let mut permissions = std::fs::metadata(path) - .expect("wrapper metadata should be readable") + .expect("executable 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"); + std::fs::set_permissions(path, permissions).expect("executable should be executable"); } struct Fixture { _temp: TempDir, project: PathBuf, home: PathBuf, + bin: PathBuf, project_wrappers: PathBuf, home_wrappers: PathBuf, } @@ -57,19 +53,35 @@ 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 bin = temp.path().join("bin"); let project_wrappers = project.join(".runseal").join("wrappers"); let home_wrappers = home.join("wrappers"); + std::fs::create_dir_all(project.join(".runseal")).expect("project .runseal should exist"); 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(&bin).expect("stub bin should be created"); + std::fs::write(project.join(".runseal/deno.json"), "{}\n") + .expect("deno config should be written"); std::fs::write( project.join("runseal.toml"), - "injections = []\n[resources]\nroot = \".resource\"\n", + r#" +injections = [] + +[resources] +root = ".resource" + +[deno] +config = ".runseal/deno.json" +lock = "deno.lock" +permissions = ["--allow-env"] +"#, ) .expect("profile should be written"); Fixture { _temp: temp, project, home, + bin, project_wrappers, home_wrappers, } @@ -79,11 +91,20 @@ fn run_in(fx: &Fixture, args: &[&str]) -> std::process::Output { bin() .current_dir(&fx.project) .env("RUNSEAL_HOME", &fx.home) + .env("PATH", prepend_path(&fx.bin)) .args(args) .output() .expect("runseal 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") +} + fn path_suffix(path: &Path, count: usize) -> PathBuf { path.components() .rev() @@ -103,12 +124,27 @@ fn assert_path_ends_with(actual: &str, expected: &Path) { ); } +fn install_fake_deno(fx: &Fixture) -> PathBuf { + let log = fx.project.join("deno.log"); + write_executable( + &fx.bin.join("deno"), + r#"#!/usr/bin/env sh +set -eu +printf 'deno %s\n' "$*" >> "${RUNSEAL_TEST_DENO_LOG:?}" +printf 'name=%s\n' "${RUNSEAL_WRAPPER_NAME:-}" +printf 'file=%s\n' "${RUNSEAL_WRAPPER_FILE:-}" +printf 'args=%s\n' "$*" +"#, + ); + log +} + #[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"); + make_ts_wrapper(&ts_wrapper_file(&fx.project_wrappers, "wrap")); + make_shell_wrapper(&shell_wrapper_file(&fx.home_wrappers, "wrap"), "home"); + make_ts_wrapper(&ts_wrapper_file(&fx.home_wrappers, "home-only")); let output = run_in(&fx, &["@wrappers"]); @@ -128,262 +164,136 @@ fn wrappers_show_effective() { .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)), + Path::new(wrap_file).ends_with(path_suffix( + &ts_wrapper_file(&fx.project_wrappers, "wrap"), + 4 + )), "expected {wrap_file} to point at the profile wrapper" ); } #[test] -fn seal_wrapper_resolves() { +fn ts_wrapper_resolves() { let fx = fixture(); - let wrapper = fx.project_wrappers.join("seal-tool.seal"); - make_seal_wrapper(&wrapper, "print seal\n"); + let wrapper = ts_wrapper_file(&fx.project_wrappers, "tool"); + make_ts_wrapper(&wrapper); + + let which = run_in(&fx, &["@which", ":tool"]); - 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] -#[cfg(unix)] fn extensionless_is_ignored() { let fx = fixture(); - make_wrapper(&fx.project_wrappers.join("legacy"), "legacy"); + make_shell_wrapper(&fx.project_wrappers.join("legacy"), "legacy"); let output = run_in(&fx, &[":legacy"]); assert!(!output.status.success()); let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8"); assert!(stderr.contains("wrapper not found: :legacy")); - assert!(stderr.contains("legacy.seal")); + assert!(stderr.contains("legacy.ts")); assert!(stderr.contains("legacy.sh")); assert!(!stderr.contains(".runseal/wrappers/legacy\n")); } #[test] -fn seal_wrapper_runs_directly() { +fn deno_uses_profile_policy() { let fx = fixture(); - make_seal_wrapper( - &fx.project_wrappers.join("seal-tool.seal"), - r#" -__seal_argc=$# -__seal_help=false -name=world -loud=false -while [ "$#" -gt 0 ]; do - case "$1" in - --name) - if [ "$#" -lt 2 ]; then fail "missing value for --name"; fi - name=$2 - shift 2 - ;; - --name=*) - name=${1#--name=} - shift - ;; - --loud) - loud=true - shift - ;; - --) - shift - break - ;; - -h|--help|help) - __seal_help=true - shift - ;; - *) fail "unknown option: $1" ;; - esac -done -if [ "$__seal_argc" = 0 ]; then - print "hello $name" -else - if [ "$loud" = true ]; then - print "HELLO $name from ${RUNSEAL_WRAPPER_NAME}" - else - print "hello $name" - fi -fi -"#, - ); + let log = install_fake_deno(&fx); + let wrapper = ts_wrapper_file(&fx.project_wrappers, "tool"); + make_ts_wrapper(&wrapper); - let output = run_in(&fx, &[":seal-tool", "--name", "seal", "--loud"]); + let output = bin() + .current_dir(&fx.project) + .env("RUNSEAL_HOME", &fx.home) + .env("PATH", prepend_path(&fx.bin)) + .env("RUNSEAL_TEST_DENO_LOG", &log) + .args([":tool", "hello", "world"]) + .output() + .expect("runseal should run"); 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_captures_local_output() { - let fx = fixture(); - make_seal_wrapper( - &fx.project_wrappers.join("capture-local.seal"), - r#" -helper() { - print "hello $1" -} - -value=$(helper seal) -print "$value" -"#, + assert!(stdout.contains("name=tool")); + assert_path_ends_with( + stdout + .lines() + .find_map(|line| line.strip_prefix("file=")) + .expect("stdout should include wrapper file"), + &wrapper, ); - - let output = run_in(&fx, &[":capture-local"]); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert_eq!(stdout, "hello seal\n"); + let log = std::fs::read_to_string(log).expect("deno log should be readable"); + assert!(log.contains("deno run --no-prompt")); + assert!(log.contains("--config")); + assert!(log.contains(".runseal/deno.json")); + assert!(log.contains("--lock")); + assert!(log.contains("deno.lock")); + assert!(log.contains("--frozen=true")); + assert!(log.contains("--allow-env")); + assert!(log.contains("hello world")); } #[test] -fn seal_argv_positional() { +fn deno_requires_profile_policy() { let fx = fixture(); - make_seal_wrapper( - &fx.project_wrappers.join("argv-positional.seal"), - r#" -print() { - printf '%s\n' "$1" -} - -fail() { - print "$1" - exit 1 -} - -__seal_argc=$# -__seal_help=false -body= -message= -while [ "$#" -gt 0 ]; do - case "$1" in - --body) - if [ "$#" -lt 2 ]; then fail "missing value for --body"; fi - body=$2 - shift 2 - ;; - --body=*) - body=${1#--body=} - shift - ;; - --) - shift - break - ;; - -h|--help|help) - __seal_help=true - shift - ;; - *) - if [ -z "$message" ]; then - message=$1 - shift - else - fail "unexpected argument: $1" - fi - ;; - esac -done - -print "$body|$message" -"#, - ); + std::fs::write( + fx.project.join("runseal.toml"), + "injections = []\n[resources]\nroot = \".resource\"\n", + ) + .expect("profile should be written"); + make_ts_wrapper(&ts_wrapper_file(&fx.project_wrappers, "tool")); - let output = run_in(&fx, &[":argv-positional", "--body=demo", "hello"]); + 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, "demo|hello\n"); + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8"); + assert!(stderr.contains("deno wrapper requires a [deno] profile policy")); } #[test] -fn seal_argv_multiline_guard() { +fn ts_shadows_shell() { let fx = fixture(); - make_seal_wrapper( - &fx.project_wrappers.join("argv-multiline-guard.seal"), - r#" -print() { - printf '%s\n' "$1" -} - -fail() { - print "$1" - exit 1 -} - -__seal_argc=$# -__seal_help=false -body= -while [ "$#" -gt 0 ]; do - case "$1" in - --body) - if [ "$#" -lt 2 ]; then - fail "missing value for --body" - fi - body=$2 - shift 2 - ;; - *) fail "unknown option: $1" ;; - esac -done - -print "$body" -"#, - ); + let log = install_fake_deno(&fx); + make_ts_wrapper(&ts_wrapper_file(&fx.project_wrappers, "tool")); + make_shell_wrapper(&shell_wrapper_file(&fx.project_wrappers, "tool"), "shell"); - let output = run_in(&fx, &[":argv-multiline-guard", "--body", "demo"]); + let output = bin() + .current_dir(&fx.project) + .env("RUNSEAL_HOME", &fx.home) + .env("PATH", prepend_path(&fx.bin)) + .env("RUNSEAL_TEST_DENO_LOG", &log) + .args([":tool"]) + .output() + .expect("runseal should run"); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert_eq!(stdout, "demo\n"); + assert!(stdout.contains("name=tool")); + assert!(!stdout.contains("shell")); } #[test] -fn seal_wrapper_shadows() { +fn shell_runs_without_ts() { let fx = fixture(); - make_wrapper(&wrapper_file(&fx.project_wrappers, "tool"), "shell"); - make_seal_wrapper(&fx.project_wrappers.join("tool.seal"), "print seal\n"); + make_shell_wrapper(&shell_wrapper_file(&fx.project_wrappers, "tool"), "shell"); 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 seal_env_overlay_fails() { - let fx = fixture(); - make_seal_wrapper( - &fx.project_wrappers.join("env-tool.seal"), - r#" -RUNSEAL_MARKER=sealed sh -c 'printf %s "$RUNSEAL_MARKER"' -"#, - ); - - let output = run_in(&fx, &[":env-tool"]); - - assert!(!output.status.success()); - let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8"); - assert!(stderr.contains("shell-specific construct is not supported in .seal")); - assert!(stderr.contains("sh -c")); + assert_eq!(stdout, "shell\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 project_wrapper = ts_wrapper_file(&fx.project_wrappers, "wrap"); + make_ts_wrapper(&project_wrapper); + make_shell_wrapper(&shell_wrapper_file(&fx.home_wrappers, "wrap"), "home"); let which = run_in(&fx, &["@which", ":wrap"]); assert!(which.status.success()); diff --git a/app/tests/operator.rs b/app/tests/operator.rs index c46ca46..7000ce5 100644 --- a/app/tests/operator.rs +++ b/app/tests/operator.rs @@ -1,7 +1,5 @@ #[path = "operator/cloudflare.rs"] mod cloudflare; -#[path = "operator/estate.rs"] -mod estate; #[path = "operator/guard.rs"] mod guard; #[path = "operator/init.rs"] diff --git a/app/tests/operator/cloudflare.rs b/app/tests/operator/cloudflare.rs index 4ca8a2b..f42aba3 100644 --- a/app/tests/operator/cloudflare.rs +++ b/app/tests/operator/cloudflare.rs @@ -21,12 +21,22 @@ fn fixture() -> Fixture { let project = temp.path().join("project"); std::fs::create_dir_all(project.join(".runseal/wrappers")) .expect("wrapper dir should be created"); + std::fs::create_dir_all(project.join(".runseal/lib")).expect("lib dir should be created"); std::fs::write( project.join("runseal.toml"), r#" [resources] root = ".local" +[deno] +config = ".runseal/deno.json" +permissions = [ + "--allow-read", + "--allow-write", + "--allow-env", + "--allow-run=runseal", +] + [[injections]] type = "env" @@ -37,12 +47,20 @@ RUNSEAL_REPO_TMP_DIR = "resource://tmp" "#, ) .expect("profile should be written"); + std::fs::write(project.join(".runseal/deno.json"), "{}\n") + .expect("deno config should be written"); + std::fs::write( + project.join(".runseal/lib/runseal.ts"), + std::fs::read_to_string(repo_root().join(".runseal/lib/runseal.ts")) + .expect("repo deno helper should be readable"), + ) + .expect("deno helper should be copied"); 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"), + project.join(".runseal/wrappers/cloudflare.ts"), + std::fs::read_to_string(repo_root().join(".runseal/wrappers/cloudflare.ts")) + .expect("repo cloudflare wrapper should be readable"), ) - .expect("cloudflare seal should be copied"); + .expect("cloudflare wrapper should be copied"); Fixture { _temp: temp, project, diff --git a/app/tests/operator/estate.rs b/app/tests/operator/estate.rs deleted file mode 100644 index d607e67..0000000 --- a/app/tests/operator/estate.rs +++ /dev/null @@ -1,486 +0,0 @@ -#![cfg(unix)] - -#[path = "estate/pr.rs"] -mod pr; - -use std::{ - ffi::OsString, - path::{Path, PathBuf}, - process::Command, -}; - -use tempfile::TempDir; - -struct Fixture { - _temp: TempDir, - project: PathBuf, - bin: PathBuf, - log: 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"); - let log = temp.path().join("commands.log"); - std::fs::create_dir_all(project.join(".runseal/wrappers")) - .expect("wrapper dir should be created"); - std::fs::create_dir_all(&bin).expect("bin dir should be created"); - write_profile(&project); - write_wrappers(&project); - write_git_stub(&bin.join("git")); - write_ssh_stub(&bin.join("ssh")); - write_kubectl_stub(&bin.join("kubectl")); - Fixture { - _temp: temp, - project, - bin, - log, - } -} - -fn write_profile(project: &Path) { - std::fs::write( - project.join("runseal.toml"), - r#" -[resources] -root = ".local" - -[[injections]] -type = "env" - -[injections.vars] -PERISH_TOP_LOCAL_DIR = "resource://" -PERISH_TOP_SSH_DIR = "resource://ssh" -PERISH_TOP_SSH_CONFIG = "resource://ssh/config" -PERISH_TOP_KUBE_DIR = "resource://kube" -PERISH_TOP_SECRETS_DIR = "resource://secrets" -PERISH_TOP_TMP_DIR = "resource://tmp" -"#, - ) - .expect("profile should be written"); -} - -fn write_wrappers(project: &Path) { - let wrappers = project.join(".runseal/wrappers"); - std::fs::write( - wrappers.join("admin.seal"), - ADMIN_SEAL.replace("__SSH_CONFIG_BASE64__", SSH_CONFIG_BASE64), - ) - .expect("admin seal should be written"); - std::fs::write(wrappers.join("ssh.seal"), SSH_SEAL).expect("ssh seal should be written"); - std::fs::write(wrappers.join("kube.seal"), KUBE_SEAL).expect("kube seal should be written"); - std::fs::write(wrappers.join("pr.seal"), PR_SEAL).expect("pr seal should be written"); -} - -fn write_git_stub(path: &Path) { - write_executable( - path, - r#"#!/usr/bin/env sh -set -eu -case "$1" in - checkout) - if [ "${RUNSEAL_TEST_CHECKOUT_FAIL:-}" = "${2:-}" ]; then - printf 'git' >> "$RUNSEAL_TEST_LOG" - for arg in "$@"; do - printf '|%s' "$arg" >> "$RUNSEAL_TEST_LOG" - done - printf '\n' >> "$RUNSEAL_TEST_LOG" - exit 1 - fi - ;; - branch) - if [ "${2:-}" = "--show-current" ]; then - printf '%s\n' "${RUNSEAL_TEST_BRANCH:-main}" - exit 0 - fi - ;; - remote) - if [ "${2:-}" = "get-url" ] && [ "${3:-}" = "origin" ]; then - printf 'git@gitee.com:perishme/perish.top.git\n' - exit 0 - fi - ;; - status) - if [ "${2:-}" = "--short" ]; then - printf '%s\n' "${RUNSEAL_TEST_STATUS- M docs.md}" - exit 0 - fi - ;; -esac -printf 'git' >> "$RUNSEAL_TEST_LOG" -for arg in "$@"; do - printf '|%s' "$arg" >> "$RUNSEAL_TEST_LOG" -done -printf '\n' >> "$RUNSEAL_TEST_LOG" -"#, - ); -} - -fn write_ssh_stub(path: &Path) { - write_executable( - path, - r#"#!/usr/bin/env sh -set -eu -printf 'ssh' >> "$RUNSEAL_TEST_LOG" -for arg in "$@"; do - printf '|%s' "$arg" >> "$RUNSEAL_TEST_LOG" -done -printf '\n' >> "$RUNSEAL_TEST_LOG" -cat >/dev/null -printf 'captured-kube' -"#, - ); -} - -fn write_kubectl_stub(path: &Path) { - write_executable( - path, - r#"#!/usr/bin/env sh -set -eu -printf 'kubectl|KUBECONFIG=%s' "${KUBECONFIG:-}" >> "$RUNSEAL_TEST_LOG" -for arg in "$@"; do - printf '|%s' "$arg" >> "$RUNSEAL_TEST_LOG" -done -printf '\n' >> "$RUNSEAL_TEST_LOG" -"#, - ); -} - -fn write_executable(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_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, String)], -) -> std::process::Output { - let mut command = Command::new(env!("CARGO_BIN_EXE_runseal")); - command - .current_dir(&fx.project) - .env("PATH", prepend_path(&fx.bin)) - .env("RUNSEAL_TEST_LOG", &fx.log) - .arg("-p") - .arg(fx.project.join("runseal.toml")) - .arg(format!(":{name}")) - .args(args); - for (key, value) in envs { - command.env(key, value); - } - command.output().expect("runseal 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 log(fx: &Fixture) -> String { - std::fs::read_to_string(&fx.log).unwrap_or_default() -} - -#[test] -fn admin_init_check() { - let fx = fixture(); - - let init = run_wrapper(&fx, "admin", &["init"]); - assert!( - init.status.success(), - "stderr: {}", - String::from_utf8_lossy(&init.stderr) - ); - - for path in [".local/ssh", ".local/kube", ".local/secrets", ".local/tmp"] { - assert!(fx.project.join(path).is_dir(), "{path} should exist"); - } - assert!(fx.project.join(".local/ssh/config").is_file()); - assert!(fx.project.join(".local/ssh/id_perish_top_root").is_file()); - assert!(fx.project.join(".local/ssh/known_hosts").is_file()); - - std::fs::write(fx.project.join(".local/ssh/id_perish_top_root"), "key") - .expect("root key should be filled"); - let kubeconfig = fx.project.join(".local/kube/hk-zxi.yaml"); - std::fs::write(&kubeconfig, "kube").expect("kubeconfig should be written"); - set_mode(&kubeconfig, 0o600); - - let check = run_wrapper(&fx, "admin", &["check"]); - assert!( - check.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&check.stdout), - String::from_utf8_lossy(&check.stderr) - ); - assert!(String::from_utf8_lossy(&check.stdout).contains("admin check: ok")); -} - -#[test] -fn ssh_remote_args() { - let fx = fixture(); - let init = run_wrapper(&fx, "admin", &["init"]); - assert!(init.status.success()); - - let ok = run_wrapper(&fx, "ssh", &["10m.hk.zxi", "--", "uptime", "-p"]); - assert!( - ok.status.success(), - "stderr: {}", - String::from_utf8_lossy(&ok.stderr) - ); - assert_eq!( - log(&fx), - format!( - "ssh|-F|{}|10m.hk.zxi|uptime|-p\n", - fx.project.join(".local/ssh/config").display() - ) - ); - - let denied = run_wrapper(&fx, "ssh", &["unknown.example"]); - assert!(!denied.status.success()); - assert!(String::from_utf8_lossy(&denied.stderr).contains("host is not declared")); -} - -#[test] -fn ssh_help() { - let fx = fixture(); - - let output = run_wrapper(&fx, "ssh", &["--help"]); - - assert!(output.status.success()); - assert_eq!( - String::from_utf8(output.stdout).expect("stdout should be UTF-8"), - "Usage: runseal :ssh <host> [--run <script> [-- <args>...] | -- <remote-command>...]\n" - ); - assert!(output.stderr.is_empty()); - assert!(log(&fx).is_empty()); -} - -#[test] -fn ssh_run_mode() { - let fx = fixture(); - let init = run_wrapper(&fx, "admin", &["init"]); - assert!(init.status.success()); - let script = fx.project.join("probe.sh"); - std::fs::write(&script, "echo probe").expect("script should be written"); - - let output = run_wrapper( - &fx, - "ssh", - &[ - "10m.hk.zxi", - "--run", - script.to_str().unwrap(), - "--", - "one", - "two", - ], - ); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!( - log(&fx), - format!( - "ssh|-F|{}|10m.hk.zxi|bash|-s|--|one|two\n", - fx.project.join(".local/ssh/config").display() - ) - ); -} - -#[test] -fn admin_bootstrap_kube() { - let fx = fixture(); - let init = run_wrapper(&fx, "admin", &["init"]); - assert!(init.status.success()); - let ops_admin = fx.project.join("infra/k8s/access/ops-admin.yaml"); - let setup = fx - .project - .join("nodes/10m-hk-zxi/k3s/bootstrap/35-emit-kubeconfig.sh"); - std::fs::create_dir_all(ops_admin.parent().expect("ops parent should exist")) - .expect("ops parent should be created"); - std::fs::create_dir_all(setup.parent().expect("setup parent should exist")) - .expect("setup parent should be created"); - std::fs::write(&ops_admin, "ops").expect("ops file should be written"); - std::fs::write(&setup, "setup").expect("setup file should be written"); - - let output = run_wrapper(&fx, "admin", &["bootstrap-kube"]); - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let kubeconfig = fx.project.join(".local/kube/hk-zxi.yaml"); - assert_eq!( - std::fs::read_to_string(&kubeconfig).expect("kubeconfig should be readable"), - "captured-kube" - ); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert_eq!( - std::fs::metadata(&kubeconfig) - .expect("metadata should be readable") - .permissions() - .mode() - & 0o777, - 0o600 - ); - } - assert_eq!( - log(&fx), - format!( - "ssh|-F|{}|10m.hk.zxi|bash|-s|--\nssh|-F|{}|10m.hk.zxi|bash|-s|--|https://k8s.perish.top:6443|hk-zxi\n", - fx.project.join(".local/ssh/config").display(), - fx.project.join(".local/ssh/config").display() - ) - ); -} - -#[test] -fn admin_archive() { - let fx = fixture(); - let init = run_wrapper(&fx, "admin", &["init"]); - assert!(init.status.success()); - std::fs::write( - fx.project.join(".local/secrets/gitee.env"), - "GITEE_TOKEN=test-token\n", - ) - .expect("secret should be written"); - let archive = fx.project.join("local.enc"); - - let export = run_wrapper_env( - &fx, - "admin", - &["export", archive.to_str().unwrap()], - &[("PERISH_TOP_LOCAL_PASSWORD", "secret".to_string())], - ); - assert!( - export.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&export.stdout), - String::from_utf8_lossy(&export.stderr) - ); - assert!(archive.is_file()); - - std::fs::remove_dir_all(fx.project.join(".local")).expect(".local should be removable"); - let import = run_wrapper_env( - &fx, - "admin", - &["import", "--force", archive.to_str().unwrap()], - &[("PERISH_TOP_LOCAL_PASSWORD", "secret".to_string())], - ); - assert!( - import.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&import.stdout), - String::from_utf8_lossy(&import.stderr) - ); - assert_eq!( - std::fs::read_to_string(fx.project.join(".local/secrets/gitee.env")) - .expect("secret should be restored"), - "GITEE_TOKEN=test-token\n" - ); - assert!(String::from_utf8_lossy(&export.stdout).contains("admin export: wrote")); - assert!(String::from_utf8_lossy(&import.stdout).contains("admin import: restored .local")); -} - -#[test] -fn kube_help() { - let fx = fixture(); - - let output = run_wrapper(&fx, "kube", &["--help"]); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(log(&fx), ""); - assert_eq!( - String::from_utf8(output.stdout).expect("stdout should be UTF-8"), - "Usage: runseal :kube [kubectl args...]\n\nAssemble KUBECONFIG from .local/kube/*.yaml and run kubectl with it.\n" - ); -} - -#[test] -fn kube_env() { - let fx = fixture(); - std::fs::create_dir_all(fx.project.join(".local/kube")).expect("kube dir should be created"); - std::fs::write(fx.project.join(".local/kube/b.yaml"), "b").expect("kubeconfig should exist"); - std::fs::write(fx.project.join(".local/kube/a.yaml"), "a").expect("kubeconfig should exist"); - - let output = run_wrapper(&fx, "kube", &["auth", "whoami"]); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!( - log(&fx), - format!( - "kubectl|KUBECONFIG={}:{}|auth|whoami\n", - fx.project - .join(".local/kube/a.yaml") - .canonicalize() - .expect("a path should canonicalize") - .display(), - fx.project - .join(".local/kube/b.yaml") - .canonicalize() - .expect("b path should canonicalize") - .display() - ) - ); -} - -#[test] -fn kube_requires_matching_files() { - let fx = fixture(); - std::fs::create_dir_all(fx.project.join(".local/kube")).expect("kube dir should be created"); - - let output = run_wrapper(&fx, "kube", &["auth", "whoami"]); - assert!( - !output.status.success(), - "wrapper should fail without kube files" - ); - assert_eq!(log(&fx), ""); -} - -fn set_mode(path: &Path, mode: u32) { - use std::os::unix::fs::PermissionsExt; - - let mut permissions = std::fs::metadata(path) - .expect("metadata should be readable") - .permissions(); - permissions.set_mode(mode); - std::fs::set_permissions(path, permissions).expect("mode should be set"); -} - -const SSH_CONFIG_BASE64: &str = "SG9zdCAxMG0uaGsuenhpCiAgSG9zdE5hbWUgNDMuMjUxLjIyNS4xMTMKICBVc2VyIHJvb3QKICBJZGVudGl0eUZpbGUgaWRfcGVyaXNoX3RvcF9yb290CgpIb3N0IDVtLmhrLnp4aQogIEhvc3ROYW1lIDQzLjI1MS4yMjUuODUKICBVc2VyIHJvb3QKICBJZGVudGl0eUZpbGUgaWRfcGVyaXNoX3RvcF9yb290CgpIb3N0IGxhLnVzLmxpc2EKICBIb3N0TmFtZSAxNTQuMjkuMTU4LjEzNAogIFBvcnQgMjc2OTEKICBVc2VyIHJvb3QKICBJZGVudGl0eUZpbGUgaWRfcGVyaXNoX3RvcF9yb290CgpIb3N0IG55LnVzLmxpc2EKICBIb3N0TmFtZSAzOC43Ny4xMzMuMTExCiAgUG9ydCAyMTM2OQogIFVzZXIgcm9vdAogIElkZW50aXR5RmlsZSBpZF9wZXJpc2hfdG9wX3Jvb3QK"; - -const ADMIN_SEAL: &str = include_str!("../fixtures/estate/admin.seal"); - -const SSH_SEAL: &str = include_str!("../fixtures/estate/ssh.seal"); - -const KUBE_SEAL: &str = include_str!("../fixtures/estate/kube.seal"); - -const PR_SEAL: &str = include_str!("../fixtures/estate/pr.seal"); diff --git a/app/tests/operator/guard.rs b/app/tests/operator/guard.rs index 9f1bd52..f251283 100644 --- a/app/tests/operator/guard.rs +++ b/app/tests/operator/guard.rs @@ -2,8 +2,11 @@ use std::{ ffi::OsString, + io::{Read, Write}, + net::TcpListener, path::{Path, PathBuf}, process::Command, + thread, }; use tempfile::TempDir; @@ -23,14 +26,37 @@ fn fixture() -> Fixture { std::fs::create_dir_all(project.join("app/tests")).expect("app tests dir should be created"); std::fs::create_dir_all(&bin).expect("bin dir should be created"); - std::fs::write(project.join("runseal.toml"), "injections = []\n") - .expect("profile should be written"); std::fs::write( - project.join(".runseal/wrappers/guard.seal"), - std::fs::read_to_string(repo_root().join(".runseal/wrappers/guard.seal")) - .expect("repo guard seal should be readable"), + project.join("runseal.toml"), + r#" +injections = [] + +[deno] +config = ".runseal/deno.json" +permissions = [ + "--allow-read=.", + "--allow-env", + "--allow-net=127.0.0.1", + "--allow-run=git,cargo,runseal", +] +"#, + ) + .expect("profile should be written"); + std::fs::write(project.join(".runseal/deno.json"), "{}\n") + .expect("deno config should be written"); + std::fs::create_dir_all(project.join(".runseal/lib")).expect("lib dir should be created"); + std::fs::write( + project.join(".runseal/lib/runseal.ts"), + std::fs::read_to_string(repo_root().join(".runseal/lib/runseal.ts")) + .expect("repo deno helper should be readable"), ) - .expect("guard seal should be copied"); + .expect("deno helper should be copied"); + std::fs::write( + project.join(".runseal/wrappers/guard.ts"), + std::fs::read_to_string(repo_root().join(".runseal/wrappers/guard.ts")) + .expect("repo guard wrapper should be readable"), + ) + .expect("guard wrapper should be copied"); std::fs::write(project.join("app/tests/sample.txt"), "sample\n") .expect("sample test file should be written"); @@ -60,42 +86,6 @@ fi exit 0 "#, ); - write_executable( - &bin.join("curl"), - r#"#!/usr/bin/env sh -set -eu -out="" -while [ "$#" -gt 0 ]; do - case "$1" in - -o) - out="$2" - shift 2 - ;; - -w) - shift 2 - ;; - -s|-S) - shift - ;; - *) - shift - ;; - esac -done -if [ -n "${RUNSEAL_TEST_CURL_BODY:-}" ] && [ -n "$out" ]; then - printf '%s' "$RUNSEAL_TEST_CURL_BODY" > "$out" -fi -printf '%s' "${RUNSEAL_TEST_CURL_STATUS:-404}" -"#, - ); - write_executable( - &bin.join("cat"), - r#"#!/usr/bin/env sh -set -eu -/bin/cat "$@" -"#, - ); - Fixture { _temp: temp, project, @@ -132,6 +122,30 @@ fn prepend_path(first: &Path) -> OsString { std::env::join_paths(paths).expect("PATH should be joinable") } +fn mock_metadata(status: u16, body: &'static str) -> (String, thread::JoinHandle<()>) { + 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 /metadata.json?version=")); + write!( + stream, + "HTTP/1.1 {status} OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", + body.len(), + body + ) + .expect("response should be written"); + }); + (format!("http://{address}/metadata.json"), handle) +} + fn run_guard(fx: &Fixture, args: &[&str], envs: &[(&str, &str)]) -> std::process::Output { let mut command = Command::new(env!("CARGO_BIN_EXE_runseal")); command @@ -174,13 +188,15 @@ fn version_hash() { #[test] fn skip_no_stable() { let fx = fixture(); + let (metadata_url, handle) = mock_metadata(404, ""); let output = run_guard( &fx, &["version-check"], - &[("RUNSEAL_TEST_CURL_STATUS", "404")], + &[("RUNSEAL_STABLE_METADATA_URL", metadata_url.as_str())], ); + handle.join().expect("mock server should finish"); assert!( output.status.success(), "stderr: {}", @@ -195,6 +211,10 @@ fn skip_no_stable() { #[test] fn reject_hash_change_patch() { let fx = fixture(); + let (metadata_url, handle) = mock_metadata( + 200, + r#"{"stableVersion":"0.6.0","guard":{"version":{"hash":"different"}}}"#, + ); let output = run_guard( &fx, @@ -204,14 +224,11 @@ fn reject_hash_change_patch() { "RUNSEAL_TEST_CARGO_METADATA", r#"{"packages":[{"version":"0.6.1"}]}"#, ), - ("RUNSEAL_TEST_CURL_STATUS", "200"), - ( - "RUNSEAL_TEST_CURL_BODY", - r#"{"stableVersion":"0.6.0","guard":{"version":{"hash":"different"}}}"#, - ), + ("RUNSEAL_STABLE_METADATA_URL", metadata_url.as_str()), ], ); + handle.join().expect("mock server should finish"); assert!(!output.status.success()); assert!( String::from_utf8_lossy(&output.stderr) diff --git a/app/tests/operator/init.rs b/app/tests/operator/init.rs index 790f42b..b2a98c1 100644 --- a/app/tests/operator/init.rs +++ b/app/tests/operator/init.rs @@ -48,19 +48,29 @@ fn write_required_files(project: &Path) { "manage.sh", "manage.ps1", "runseal.toml", + ".runseal/deno.json", ".runseal/hooks/pre-commit", ".runseal/hooks/commit-msg", - ".runseal/wrappers/cloudflare.seal", - ".runseal/wrappers/guard.seal", - ".runseal/wrappers/init.seal", - ".runseal/wrappers/pr.seal", - ".runseal/wrappers/release.seal", + ".runseal/lib/runseal.ts", + ".runseal/wrappers/cloudflare.ts", + ".runseal/wrappers/guard.ts", + ".runseal/wrappers/init.ts", + ".runseal/wrappers/pr.ts", + ".runseal/wrappers/release.ts", ".github/workflows/guard.yml", ".github/workflows/release-beta.yml", ".github/workflows/release-stable.yml", + ".github/scripts/release/assets/checksums.sh", ".github/scripts/release/assets/package.sh", ".github/scripts/release/assets/package.ps1", + ".github/scripts/release/assets/verify.sh", + ".github/scripts/release/github/cleanup-artifacts.sh", + ".github/scripts/release/metadata/beta.py", + ".github/scripts/release/metadata/stable.py", + ".github/scripts/release/r2/check.sh", ".github/scripts/release/r2/publish.sh", + ".github/scripts/release/r2/summary.sh", + ".github/scripts/release/r2/verify.sh", ".github/scripts/release/smoke/smoke.sh", ".github/scripts/release/smoke/smoke.ps1", ] { @@ -70,17 +80,29 @@ fn write_required_files(project: &Path) { 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"), + project.join(".runseal/wrappers/init.ts"), + std::fs::read_to_string(repo_root().join(".runseal/wrappers/init.ts")) + .expect("repo init wrapper should be readable"), ) - .expect("init seal should be copied"); + .expect("init wrapper should be copied"); std::fs::write( - project.join(".runseal/wrappers/guard.seal"), - std::fs::read_to_string(repo_root().join(".runseal/wrappers/guard.seal")) - .expect("repo guard seal should be readable"), + project.join(".runseal/wrappers/guard.ts"), + std::fs::read_to_string(repo_root().join(".runseal/wrappers/guard.ts")) + .expect("repo guard wrapper should be readable"), ) - .expect("guard seal should be copied"); + .expect("guard wrapper should be copied"); + std::fs::write( + project.join(".runseal/lib/runseal.ts"), + std::fs::read_to_string(repo_root().join(".runseal/lib/runseal.ts")) + .expect("repo deno helper should be readable"), + ) + .expect("deno helper should be copied"); + std::fs::write( + project.join(".runseal/deno.json"), + std::fs::read_to_string(repo_root().join(".runseal/deno.json")) + .expect("repo deno config should be readable"), + ) + .expect("deno config should be copied"); std::fs::write( project.join(".runseal/hooks/pre-commit"), std::fs::read_to_string(repo_root().join(".runseal/hooks/pre-commit")) @@ -93,8 +115,22 @@ fn write_required_files(project: &Path) { .expect("repo commit-msg hook should be readable"), ) .expect("commit-msg hook should be copied"); - std::fs::write(project.join("runseal.toml"), "injections = []\n") - .expect("profile should be written"); + std::fs::write( + project.join("runseal.toml"), + r#" +injections = [] + +[deno] +config = ".runseal/deno.json" +permissions = [ + "--allow-read=.", + "--allow-write=.", + "--allow-env", + "--allow-run=git,deno,python3,cargo,runseal,flavor,sh,bash,sed,grep", +] +"#, + ) + .expect("profile should be written"); } fn write_stub(path: &Path) { diff --git a/app/tests/operator/repo.rs b/app/tests/operator/repo.rs index de07754..71a97a2 100644 --- a/app/tests/operator/repo.rs +++ b/app/tests/operator/repo.rs @@ -32,7 +32,7 @@ case "${1:-}" in ;; branch) [ "${2:-}" = "--show-current" ] || exit 9 - printf '%s\n' "${RUNSEAL_TEST_BRANCH:-feat/seal}" + printf '%s\n' "${RUNSEAL_TEST_BRANCH:-feat/deno}" ;; remote) [ "${2:-}" = "get-url" ] || exit 9 @@ -99,7 +99,7 @@ case "${1:-}" in 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}]' + printf '%s\n' '[{"number":42,"title":"Deno","state":"OPEN","url":"https://example.test/pull/42","isDraft":false}]' fi ;; create|ready|checks|merge) @@ -216,7 +216,7 @@ fn pr_dry_run_matches() { assert_eq!( stdout(&output), "\ -branch: feat/seal +branch: feat/deno base: main push: True pr: create if missing, otherwise reuse existing @@ -269,7 +269,7 @@ fn pr_reuses_draft() { &["--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}]"#, + r#"[{"number":42,"title":"Deno","state":"OPEN","url":"https://example.test/pull/42","isDraft":true}]"#, )], ); @@ -284,7 +284,7 @@ marked PR #42 ready assert_eq!( command_log(&fx), "\ -gh pr list --head feat/seal --json number,title,state,url,isDraft +gh pr list --head feat/deno --json number,title,state,url,isDraft gh pr ready 42 " ); @@ -301,7 +301,7 @@ fn pr_creates_and_merges() { "pr", &[ "--title", - "Seal migration", + "Deno migration", "--body-file", "body.md", "--base", @@ -311,7 +311,7 @@ fn pr_creates_and_merges() { ("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}]"#, + r#"[{"number":77,"title":"Deno migration","state":"OPEN","url":"https://example.test/pull/77","isDraft":false}]"#, ), ], ); @@ -327,10 +327,10 @@ 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 +git push -u origin feat/deno +gh pr list --head feat/deno --json number,title,state,url,isDraft +gh pr create --base develop --head feat/deno --title Deno migration --body-file body.md +gh pr list --head feat/deno --json number,title,state,url,isDraft gh pr checks 77 --watch --interval 10 gh pr merge 77 --squash --delete-branch " diff --git a/app/tests/transpile.rs b/app/tests/transpile.rs deleted file mode 100644 index ecfdbb6..0000000 --- a/app/tests/transpile.rs +++ /dev/null @@ -1,479 +0,0 @@ -use std::process::Command; -use tempfile::TempDir; - -#[path = "transpile_support/syntax.rs"] -mod syntax; - -fn bin() -> Command { - Command::new(env!("CARGO_BIN_EXE_runseal")) -} - -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 { - bin() - .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 sample_source() -> &'static str { - r#" -channel=${RUNSEAL_CHANNEL:-stable} - -release_run() { - if [ -z "$channel" ]; then - fail "channel missing" - fi - gh workflow run release.yml --ref main -f "channel=$channel" -} - -case "$channel" in - stable) print "stable release" ;; - beta) release_run ;; - *) fail "unknown channel: $channel" ;; -esac -"# -} - -fn powershell_source() -> &'static str { - r#" -$channel = $(if ([string]::IsNullOrEmpty($env:RUNSEAL_CHANNEL)) { 'stable' } else { $env:RUNSEAL_CHANNEL }) -function release_run { - if ([string]::IsNullOrEmpty($channel)) { - throw 'channel missing' - } - & 'gh' 'workflow' 'run' 'release.yml' '--ref' 'main' '-f' ('channel=' + $channel) -} - -switch ($channel) { - 'stable' { - Write-Output 'stable release' - break - } - 'beta' { - release_run - break - } - Default { - throw ('unknown channel: ' + $channel) - break - } -} -"# -} - -fn capture_source() -> &'static str { - r#" -raw=$(gh run list --json databaseId) -print "$raw" -"# -} - -fn powershell_capture_source() -> &'static str { - r#" -$raw = & 'gh' 'run' 'list' '--json' 'databaseId' -Write-Output $raw -"# -} - -fn trim_source() -> &'static str { - r#" -raw=" value " -trimmed=$(runseal @tool string trim "$raw") -print "$trimmed" -"# -} - -fn powershell_trim_source() -> &'static str { - r#" -$raw = ' value ' -$trimmed = & 'runseal' '@tool' 'string' 'trim' $raw -Write-Output $trimmed -"# -} - -fn json_get_source() -> &'static str { - r#" -raw='[{"databaseId":123}]' -run_id=$(runseal @tool json get "$raw" '.[0].databaseId') -print "$run_id" -"# -} - -fn redirect_source() -> &'static str { - r#" -gh run list --json databaseId > build/openapi.json -gh run view 123 2>> build/errors.log -"# -} - -fn powershell_json_get_source() -> &'static str { - r#" -$raw = '[{"databaseId":123}]' -$run_id = & 'runseal' '@tool' 'json' 'get' $raw '.[0].databaseId' -Write-Output $run_id -"# -} - -#[test] -fn help_without_profile() { - let fx = fixture(""); - - let output = bin() - .current_dir(&fx.dir) - .arg("@transpile") - .arg("--help") - .output() - .expect("runseal should run"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("Usage: runseal @transpile")); - assert!(stdout.contains("--input-lang")); -} - -#[test] -fn sealir_without_profile() { - let fx = fixture(sample_source()); - - let output = run_transpile(&fx, "seal", "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - let payload: serde_json::Value = serde_json::from_str(&stdout).expect("stdout should be JSON"); - assert_eq!(payload["version"], 1); - assert!(stdout.contains("default_if_unset_or_empty")); - assert!(stdout.contains("exec_checked")); -} - -#[test] -fn redirect_ir_and_targets() { - let fx = fixture(redirect_source()); - - let sealir = run_transpile(&fx, "seal", "sealir"); - assert!(sealir.status.success()); - let sealir = String::from_utf8(sealir.stdout).expect("stdout should be UTF-8"); - assert!(sealir.contains("exec_write")); - assert!(sealir.contains("stdout")); - assert!(sealir.contains("stderr")); - - 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("gh run list --json databaseId > build/openapi.json")); - assert!(bash.contains("gh run view 123 2>> build/errors.log")); - assert!( - powershell.contains("& 'gh' 'run' 'list' '--json' 'databaseId' > 'build/openapi.json'") - ); - assert!(powershell.contains("& 'gh' 'run' 'view' '123' 2>> 'build/errors.log'")); - syntax::assert_bash(&bash); - syntax::assert_pwsh(&powershell); -} - -#[test] -fn bash_frontend_sealir() { - let fx = fixture(sample_source()); - - let output = run_transpile(&fx, "bash", "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("default_if_unset_or_empty")); - assert!(stdout.contains("exec_checked")); -} - -#[test] -fn powershell_frontend_sealir() { - let fx = fixture(powershell_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("default_if_unset_or_empty")); - assert!(stdout.contains("call_function")); - assert!(stdout.contains("exec_checked")); -} - -#[test] -fn powershell_to_bash() { - let fx = fixture(powershell_source()); - - let output = run_transpile(&fx, "powershell", "bash"); - - assert!(output.status.success()); - 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\"")); - syntax::assert_bash(&stdout); -} - -#[test] -fn bash_capture_ir() { - let fx = fixture(capture_source()); - - let output = run_transpile(&fx, "bash", "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("capture_checked")); - assert!(stdout.contains("databaseId")); -} - -#[test] -fn powershell_capture_ir() { - let fx = fixture(powershell_capture_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("capture_checked")); - assert!(stdout.contains("databaseId")); -} - -#[test] -fn capture_to_targets() { - let fx = fixture(capture_source()); - - let bash = run_transpile(&fx, "bash", "bash"); - let powershell = run_transpile(&fx, "bash", "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("raw=$(gh run list --json databaseId)")); - assert!(powershell.contains("$raw = & 'gh' 'run' 'list' '--json' 'databaseId'")); - syntax::assert_bash(&bash); - syntax::assert_pwsh(&powershell); -} - -#[test] -fn string_trim_tool_roundtrip() { - for input_lang in ["seal", "bash"] { - let fx = fixture(trim_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("capture_checked")); - assert!(stdout.contains("string")); - } - - let fx = fixture(powershell_trim_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("capture_checked")); - assert!(stdout.contains("string")); -} - -#[test] -fn string_trim_emits_tool() { - let fx = fixture(trim_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("trimmed=$(runseal @tool string trim \"$raw\")")); - assert!(powershell.contains("$trimmed = & 'runseal' '@tool' 'string' 'trim' $raw")); - syntax::assert_bash(&bash); - syntax::assert_pwsh(&powershell); -} - -#[test] -fn json_get_tool_roundtrip() { - for input_lang in ["seal", "bash"] { - let fx = fixture(json_get_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("capture_checked")); - assert!(stdout.contains("json")); - assert!(stdout.contains("databaseId")); - } - - let fx = fixture(powershell_json_get_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("capture_checked")); - assert!(stdout.contains("json")); - assert!(stdout.contains("databaseId")); -} - -#[test] -fn json_get_emits_tool() { - let fx = fixture(json_get_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 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] -fn bash_syntax_valid() { - let fx = fixture(sample_source()); - - let output = run_transpile(&fx, "seal", "bash"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - 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")); - syntax::assert_bash(&stdout); -} - -#[test] -fn powershell_readable() { - let fx = fixture(sample_source()); - - let output = run_transpile(&fx, "seal", "powershell"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("$ErrorActionPreference = 'Stop'")); - assert!(stdout.contains("function release_run")); - assert!(stdout.contains("& 'gh' 'workflow' 'run' 'release.yml' '--ref' 'main' '-f'")); - assert!(stdout.contains("('channel=' + $channel)")); - assert!(stdout.contains("switch ($channel)")); - syntax::assert_pwsh(&stdout); -} - -#[test] -fn empty_string_powershell() { - let fx = fixture("print \"\"\n"); - - let output = run_transpile(&fx, "seal", "powershell"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("Write-Output ''")); - syntax::assert_pwsh(&stdout); -} - -#[test] -fn sealir_to_seal() { - let fx = fixture(sample_source()); - let sealir = run_transpile(&fx, "seal", "sealir"); - assert!(sealir.status.success()); - let sealir_text = String::from_utf8(sealir.stdout).expect("stdout should be UTF-8"); - let sealir_path = fx.dir.join("operator.sealir.json"); - std::fs::write(&sealir_path, sealir_text).expect("sealir should be written"); - - let output = bin() - .current_dir(&fx.dir) - .arg("@transpile") - .arg("--input-lang=sealir") - .arg("--output-lang=seal") - .arg(&sealir_path) - .output() - .expect("runseal should run"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("release_run() {")); - assert!(stdout.contains("case $channel in")); -} - -#[test] -fn unsupported_input_fails() { - let fx = fixture("print ok\n"); - - let output = run_transpile(&fx, "python", "powershell"); - - assert!(!output.status.success()); - let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8"); - assert!(stderr.contains("invalid --input-lang")); -} - -#[test] -fn underscore_exec() { - let fx = fixture("tool_name --version\n"); - - let output = run_transpile(&fx, "seal", "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("exec_checked")); - assert!(!stdout.contains("call_function")); -} - -#[test] -fn hyphen_exec() { - let fx = fixture("git-lfs version\n"); - - let output = run_transpile(&fx, "seal", "bash"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("git-lfs version")); -} - -#[test] -fn metacharacters_fail() { - for source in [ - "printf ok | cat\n", - "eval something\n", - "echo ok 2>&1\n", - "exec echo ok\n", - "sh -c 'echo ok'\n", - ] { - let fx = fixture(source); - - let output = run_transpile(&fx, "seal", "sealir"); - - assert!(!output.status.success(), "{source:?} should fail"); - let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8"); - assert!( - stderr.contains("unsupported") || stderr.contains("shell-specific construct"), - "expected unsupported error, got {stderr:?}" - ); - } -} diff --git a/app/tests/transpile_cases.rs b/app/tests/transpile_cases.rs deleted file mode 100644 index 3dc76cf..0000000 --- a/app/tests/transpile_cases.rs +++ /dev/null @@ -1,18 +0,0 @@ -#[path = "transpile_cases/argv.rs"] -mod argv; -#[path = "transpile_cases/command_predicate.rs"] -mod command_predicate; -#[path = "transpile_cases/env.rs"] -mod env; -#[path = "transpile_cases/expansion.rs"] -mod expansion; -#[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; -#[path = "transpile_cases/wrappers.rs"] -mod wrappers; diff --git a/app/tests/transpile_cases/argv.rs b/app/tests/transpile_cases/argv.rs deleted file mode 100644 index a65e243..0000000 --- a/app/tests/transpile_cases/argv.rs +++ /dev/null @@ -1,257 +0,0 @@ -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 argv_source() -> &'static str { - r#" -__seal_argc=$# -__seal_help=false -channel=stable -ref=main -body_file= -dry_run=false -no_merge=false -while [ "$#" -gt 0 ]; do - case "$1" in - --channel) - if [ "$#" -lt 2 ]; then fail "missing value for --channel"; fi - channel=$2 - shift 2 - ;; - --channel=*) - channel=${1#--channel=} - shift - ;; - --ref) - if [ "$#" -lt 2 ]; then fail "missing value for --ref"; fi - ref=$2 - shift 2 - ;; - --ref=*) - ref=${1#--ref=} - shift - ;; - --body-file) - if [ "$#" -lt 2 ]; then fail "missing value for --body-file"; fi - body_file=$2 - shift 2 - ;; - --body-file=*) - body_file=${1#--body-file=} - shift - ;; - --dry-run) - dry_run=true - shift - ;; - --no-merge) - no_merge=true - shift - ;; - --) - shift - break - ;; - -h|--help|help) - __seal_help=true - shift - ;; - *) fail "unknown option: $1" ;; - esac -done -if [ -z "$channel" ]; then - fail "channel missing" -fi -print "$body_file" -"# -} - -fn argv_positional_source() -> &'static str { - r#" -__seal_argc=$# -__seal_help=false -body= -message= -while [ "$#" -gt 0 ]; do - case "$1" in - --body) - if [ "$#" -lt 2 ]; then fail "missing value for --body"; fi - body=$2 - shift 2 - ;; - --body=*) - body=${1#--body=} - shift - ;; - --) - shift - break - ;; - -h|--help|help) - __seal_help=true - shift - ;; - *) - if [ -z "$message" ]; then - message=$1 - shift - else - fail "unexpected argument: $1" - fi - ;; - esac -done -print "$body" -print "$message" -"# -} - -fn argv_multiline_guard_source() -> &'static str { - r#" -__seal_argc=$# -__seal_help=false -body= -while [ "$#" -gt 0 ]; do - case "$1" in - --body) - if [ "$#" -lt 2 ]; then - fail "missing value for --body" - fi - body=$2 - shift 2 - ;; - *) fail "unknown option: $1" ;; - esac -done -print "$body" -"# -} - -#[test] -fn argv_parse_roundtrip() { - for input_lang in ["seal", "bash"] { - let fx = fixture(argv_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("argv_parse")); - assert!(stdout.contains("body_file")); - assert!(stdout.contains("dry_run")); - } -} - -#[test] -fn argv_parse_emits_targets() { - let fx = fixture(argv_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("body_file=${1#--body-file=}")); - assert!(bash.contains("dry_run=true")); - assert!(powershell.contains("$body_file = $__seal_arg.Substring(12)")); - assert!(powershell.contains("$dry_run = 'false'")); - assert!(powershell.contains("$dry_run = 'true'")); - assert!(!powershell.contains("$dry_run = $false")); - assert!(!powershell.contains("$dry_run = $true")); - super::syntax::assert_bash(&bash); - super::syntax::assert_pwsh(&powershell); -} - -#[test] -fn argv_positional_roundtrip() { - let fx = fixture(argv_positional_source()); - let output = run_transpile(&fx, "seal", "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("\"positional\"")); - assert!(stdout.contains("\"name\": \"message\"")); - assert!(stdout.contains("\"extra_error\": \"unexpected argument: $1\"")); -} - -#[test] -fn argv_positional_targets() { - let fx = fixture(argv_positional_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("body=${1#--body=}")); - assert!(bash.contains("if [ -z \"$message\" ]; then")); - assert!(powershell.contains("$body = $__seal_arg.Substring(7)")); - assert!(powershell.contains("if ([string]::IsNullOrEmpty($message)) {")); - assert!(powershell.contains("$message = $__seal_arg")); - super::syntax::assert_bash(&bash); - super::syntax::assert_pwsh(&powershell); -} - -#[test] -fn argv_multiline_guard_roundtrip() { - let fx = fixture(argv_multiline_guard_source()); - let output = run_transpile(&fx, "seal", "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("argv_parse")); - assert!(stdout.contains("\"name\": \"body\"")); -} - -#[test] -fn argv_multiline_guard_targets() { - let fx = fixture(argv_multiline_guard_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("body=$2")); - assert!(bash.contains("shift 2")); - assert!(powershell.contains("$body = $args[$__seal_index + 1]")); - super::syntax::assert_bash(&bash); - super::syntax::assert_pwsh(&powershell); -} diff --git a/app/tests/transpile_cases/command_predicate.rs b/app/tests/transpile_cases/command_predicate.rs deleted file mode 100644 index d219316..0000000 --- a/app/tests/transpile_cases/command_predicate.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::path::PathBuf; -use std::process::Command; - -use tempfile::TempDir; - -use super::syntax; - -fn bin() -> Command { - Command::new(env!("CARGO_BIN_EXE_runseal")) -} - -struct Fixture { - _temp: TempDir, - dir: PathBuf, - source: 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, output_lang: &str) -> std::process::Output { - bin() - .current_dir(&fx.dir) - .arg("@transpile") - .arg("--input-lang=seal") - .arg("--output-lang") - .arg(output_lang) - .arg(&fx.source) - .output() - .expect("runseal should run") -} - -#[test] -fn command_predicate_emits_targets() { - let fx = fixture( - r#" -branch=feat/remote -if git checkout "$branch"; then - print ok -else - git fetch origin "$branch" - git checkout -B "$branch" "origin/$branch" -fi -"#, - ); - - let bash = run_transpile(&fx, "bash"); - let powershell = run_transpile(&fx, "powershell"); - - assert!( - bash.status.success(), - "stderr: {}", - String::from_utf8_lossy(&bash.stderr) - ); - assert!( - powershell.status.success(), - "stderr: {}", - String::from_utf8_lossy(&powershell.stderr) - ); - 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("if git checkout \"$branch\"; then")); - assert!(powershell.contains("& 'git' 'checkout' $branch")); - assert!(powershell.contains("if ($LASTEXITCODE -eq 0)")); - syntax::assert_bash(&bash); - syntax::assert_pwsh(&powershell); -} diff --git a/app/tests/transpile_cases/env.rs b/app/tests/transpile_cases/env.rs deleted file mode 100644 index 391ef86..0000000 --- a/app/tests/transpile_cases/env.rs +++ /dev/null @@ -1,65 +0,0 @@ -use std::path::PathBuf; -use std::process::Command; - -use tempfile::TempDir; - -use super::syntax; - -fn bin() -> Command { - Command::new(env!("CARGO_BIN_EXE_runseal")) -} - -struct Fixture { - _temp: TempDir, - dir: PathBuf, - source: 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, output_lang: &str) -> std::process::Output { - bin() - .current_dir(&fx.dir) - .arg("@transpile") - .arg("--input-lang=seal") - .arg("--output-lang") - .arg(output_lang) - .arg(&fx.source) - .output() - .expect("runseal should run") -} - -#[test] -fn env_overlay_emits_targets() { - let fx = fixture( - r#" -kubeconfig=/tmp/a.yaml -KUBECONFIG="$kubeconfig" kubectl "$@" -"#, - ); - - let bash = run_transpile(&fx, "bash"); - let powershell = run_transpile(&fx, "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("KUBECONFIG=\"$kubeconfig\" kubectl \"$@\"")); - assert!(powershell.contains("$__seal_old_env_KUBECONFIG = $env:KUBECONFIG")); - assert!(powershell.contains("$env:KUBECONFIG = $kubeconfig")); - assert!(powershell.contains("& 'kubectl' @args")); - syntax::assert_bash(&bash); - syntax::assert_pwsh(&powershell); -} diff --git a/app/tests/transpile_cases/expansion.rs b/app/tests/transpile_cases/expansion.rs deleted file mode 100644 index 14dd915..0000000 --- a/app/tests/transpile_cases/expansion.rs +++ /dev/null @@ -1,183 +0,0 @@ -use std::process::Command; - -use tempfile::TempDir; - -use super::syntax; - -fn bin() -> Command { - Command::new(env!("CARGO_BIN_EXE_runseal")) -} - -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 { - bin() - .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 expansion_source() -> &'static str { - r#" -channel=${RUNSEAL_CHANNEL:-stable} -required=${RUNSEAL_TOKEN:?missing token} -target=${1:-origin} -branch=${2:?missing branch} -print "$channel $required $target $branch" -"# -} - -#[test] -fn forms_roundtrip() { - let fx = fixture(expansion_source()); - - let sealir = run_transpile(&fx, "seal", "sealir"); - assert!(sealir.status.success()); - let sealir = String::from_utf8(sealir.stdout).expect("stdout should be UTF-8"); - assert!(sealir.contains("require_non_empty")); - assert!(sealir.contains("default_if_unset_or_empty")); - - let bash = run_transpile(&fx, "seal", "bash"); - assert!(bash.status.success()); - let bash = String::from_utf8(bash.stdout).expect("stdout should be UTF-8"); - assert!(bash.contains("channel=\"${RUNSEAL_CHANNEL:-stable}\"")); - assert!(bash.contains("required=\"${RUNSEAL_TOKEN:?missing token}\"")); - assert!(bash.contains("target=\"${1:-origin}\"")); - assert!(bash.contains("branch=\"${2:?missing branch}\"")); - syntax::assert_bash(&bash); - - let powershell = run_transpile(&fx, "seal", "powershell"); - assert!(powershell.status.success()); - let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); - assert!(powershell.contains( - "$channel = $(if ([string]::IsNullOrEmpty($env:RUNSEAL_CHANNEL)) { 'stable' } else { $env:RUNSEAL_CHANNEL })" - )); - assert!(powershell.contains( - "$required = $(if ([string]::IsNullOrEmpty($env:RUNSEAL_TOKEN)) { throw 'missing token' } else { $env:RUNSEAL_TOKEN })" - )); - assert!(powershell.contains( - "$target = $(if (($args.Count -lt 1) -or [string]::IsNullOrEmpty($1)) { 'origin' } else { $1 })" - )); - assert!(powershell.contains( - "$branch = $(if (($args.Count -lt 2) -or [string]::IsNullOrEmpty($2)) { throw 'missing branch' } else { $2 })" - )); - syntax::assert_pwsh(&powershell); - - let powershell_fx = fixture(&powershell); - let roundtrip = run_transpile(&powershell_fx, "powershell", "sealir"); - assert!(roundtrip.status.success()); - let roundtrip = String::from_utf8(roundtrip.stdout).expect("stdout should be UTF-8"); - assert!(roundtrip.contains("require_non_empty")); - assert!(roundtrip.contains("default_if_unset_or_empty")); -} - -fn run_wrapper(source: &str, args: &[&str], env: &[(&str, &str)]) -> std::process::Output { - let temp = TempDir::new().expect("temp dir should be created"); - let project = temp.path().join("project"); - let wrappers = project.join(".runseal").join("wrappers"); - std::fs::create_dir_all(&wrappers).expect("wrappers should be created"); - std::fs::write(project.join("runseal.toml"), "injections = []\n") - .expect("profile should be written"); - std::fs::write(wrappers.join("expansion.seal"), source).expect("wrapper should be written"); - let mut command = bin(); - command.current_dir(&project).arg(":expansion").args(args); - for (key, value) in env { - command.env(key, value); - } - command.output().expect("runseal should run") -} - -#[test] -fn env_default_runtime() { - let source = "print \"${RUNSEAL_CHANNEL:-stable}\"\n"; - let missing = run_wrapper(source, &[], &[]); - assert!(missing.status.success()); - assert_eq!( - String::from_utf8(missing.stdout).expect("stdout should be UTF-8"), - "stable\n" - ); - let empty = run_wrapper(source, &[], &[("RUNSEAL_CHANNEL", "")]); - assert!(empty.status.success()); - assert_eq!( - String::from_utf8(empty.stdout).expect("stdout should be UTF-8"), - "stable\n" - ); -} - -#[test] -fn positional_default_runtime() { - let source = "print \"${1:-origin}\"\n"; - let missing = run_wrapper(source, &[], &[]); - assert!(missing.status.success()); - assert_eq!( - String::from_utf8(missing.stdout).expect("stdout should be UTF-8"), - "origin\n" - ); - let empty = run_wrapper(source, &[""], &[]); - assert!(empty.status.success()); - assert_eq!( - String::from_utf8(empty.stdout).expect("stdout should be UTF-8"), - "origin\n" - ); -} - -#[test] -fn env_require_runtime() { - let source = "print \"${RUNSEAL_TOKEN:?missing token}\"\n"; - let missing = run_wrapper(source, &[], &[]); - assert!(!missing.status.success()); - assert!( - String::from_utf8(missing.stderr) - .expect("stderr should be UTF-8") - .contains("missing token") - ); - let empty = run_wrapper(source, &[], &[("RUNSEAL_TOKEN", "")]); - assert!(!empty.status.success()); - assert!( - String::from_utf8(empty.stderr) - .expect("stderr should be UTF-8") - .contains("missing token") - ); -} - -#[test] -fn positional_require_runtime() { - let source = "print \"${1:?missing branch}\"\n"; - let missing = run_wrapper(source, &[], &[]); - assert!(!missing.status.success()); - assert!( - String::from_utf8(missing.stderr) - .expect("stderr should be UTF-8") - .contains("missing branch") - ); - let empty = run_wrapper(source, &[""], &[]); - assert!(!empty.status.success()); - assert!( - String::from_utf8(empty.stderr) - .expect("stderr should be UTF-8") - .contains("missing branch") - ); -} diff --git a/app/tests/transpile_cases/regex.rs b/app/tests/transpile_cases/regex.rs deleted file mode 100644 index 0257a22..0000000 --- a/app/tests/transpile_cases/regex.rs +++ /dev/null @@ -1,103 +0,0 @@ -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=$(runseal @tool regex capture "$trigger_output" '/actions/runs/([0-9]+)' 1) -if [ -z "$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 = & 'runseal' '@tool' '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("capture_checked")); - 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("capture_checked")); - 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_cases/release.rs b/app/tests/transpile_cases/release.rs deleted file mode 100644 index f089665..0000000 --- a/app/tests/transpile_cases/release.rs +++ /dev/null @@ -1,172 +0,0 @@ -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 release_source() -> &'static str { - r#" -__seal_argc=$# -__seal_help=false -channel=stable -ref=main -version= -watch=false -dry_run=false -while [ "$#" -gt 0 ]; do - case "$1" in - --channel) - if [ "$#" -lt 2 ]; then fail "missing value for --channel"; fi - channel=$2 - shift 2 - ;; - --channel=*) - channel=${1#--channel=} - shift - ;; - --ref) - if [ "$#" -lt 2 ]; then fail "missing value for --ref"; fi - ref=$2 - shift 2 - ;; - --ref=*) - ref=${1#--ref=} - shift - ;; - --version) - if [ "$#" -lt 2 ]; then fail "missing value for --version"; fi - version=$2 - shift 2 - ;; - --version=*) - version=${1#--version=} - shift - ;; - --watch) - watch=true - shift - ;; - --dry-run) - dry_run=true - shift - ;; - --) - shift - break - ;; - -h|--help|help) - __seal_help=true - shift - ;; - *) fail "unknown option: $1" ;; - esac -done - -if [ -z "$channel" ]; then - fail "--channel is required" -fi - -case "$channel" in - stable) workflow=release-stable.yml ;; - beta) workflow=release-beta.yml ;; - *) fail "unknown channel: $channel" ;; -esac - -if [ "$dry_run" = true ]; then - print "dry run" -else - gh --version - gh auth status - trigger_output=$(gh workflow run "$workflow" --ref "$ref" -f "ref=$ref" -f "version_override=$version") - if [ -n "$trigger_output" ]; then - print "$trigger_output" - fi - print "triggered $workflow for ref $ref" - if [ "$watch" = true ]; then - run_id=$(runseal @tool regex capture "$trigger_output" '/actions/runs/([0-9]+)' 1) - if [ -z "$run_id" ]; then - attempt=0 - raw='[]' - while [ "$attempt" -lt 6 ]; do - raw=$(gh run list --workflow "$workflow" --branch "$ref" --event workflow_dispatch --limit 1 --json databaseId) - if [ "$(runseal @tool json empty "$raw")" = false ]; then - run_id=$(runseal @tool json get "$raw" '.[0].databaseId') - break - fi - sleep 2 - attempt=$(runseal @tool int add "$attempt" 1) - done - fi - gh run watch "$run_id" --interval 10 - fi -fi -"# -} - -#[test] -fn release_fixture_roundtrip() { - let fx = fixture(release_source()); - let sealir = run_transpile(&fx, "seal", "sealir"); - - 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("capture_checked")); - assert!(sealir.contains("regex")); - assert!(sealir.contains("json_not_empty")); - assert!(sealir.contains("runseal")); - assert!(sealir.contains("release-stable.yml")); -} - -#[test] -fn release_fixture_emits_targets() { - let fx = fixture(release_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("trigger_output=$(gh workflow run \"$workflow\"")); - 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("& '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_cases/retry.rs b/app/tests/transpile_cases/retry.rs deleted file mode 100644 index b2f0a67..0000000 --- a/app/tests/transpile_cases/retry.rs +++ /dev/null @@ -1,115 +0,0 @@ -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 retry_source() -> &'static str { - r#" -attempt=0 -raw='[]' -while [ "$attempt" -lt 6 ]; do - raw=$(gh run list --json databaseId) - if [ "$(runseal @tool json empty "$raw")" = false ]; then - run_id=$(runseal @tool json get "$raw" '.[0].databaseId') - break - fi - sleep 2 - attempt=$(runseal @tool int add "$attempt" 1) -done -print "$run_id" -"# -} - -fn powershell_retry_source() -> &'static str { - r#" -$attempt = '0' -$raw = '[]' -while ([int]$attempt -lt '6') { - $raw = & 'gh' 'run' 'list' '--json' 'databaseId' - if ((($raw | ConvertFrom-Json).Count -gt 0)) { - $run_id = & 'runseal' '@tool' 'json' 'get' $raw '.[0].databaseId' - break - } - Start-Sleep -Seconds 2 - $attempt = & 'runseal' '@tool' 'int' 'add' $attempt '1' -} -Write-Output $run_id -"# -} - -#[test] -fn retry_loop_roundtrip() { - for input_lang in ["seal", "bash"] { - let fx = fixture(retry_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("\"type\": \"while\"")); - assert!(stdout.contains("json_not_empty")); - assert!(stdout.contains("capture_checked")); - assert!(stdout.contains("runseal")); - assert!(stdout.contains("\"type\": \"break\"")); - } - - let fx = fixture(powershell_retry_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("\"type\": \"while\"")); - assert!(stdout.contains("json_not_empty")); - assert!(stdout.contains("capture_checked")); -} - -#[test] -fn retry_loop_emits_targets() { - let fx = fixture(retry_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("while [ $attempt -lt 6 ]; do")); - 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("& '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_cases/wrappers.rs b/app/tests/transpile_cases/wrappers.rs deleted file mode 100644 index 8993d25..0000000 --- a/app/tests/transpile_cases/wrappers.rs +++ /dev/null @@ -1,284 +0,0 @@ -use std::path::PathBuf; -use std::process::Command; - -use tempfile::TempDir; - -use super::syntax; - -const WRAPPERS: [&str; 4] = [ - ".runseal/wrappers/cloudflare.seal", - ".runseal/wrappers/init.seal", - ".runseal/wrappers/pr.seal", - ".runseal/wrappers/release.seal", -]; - -fn bin() -> Command { - Command::new(env!("CARGO_BIN_EXE_runseal")) -} - -struct Fixture { - _temp: TempDir, - dir: PathBuf, - source: 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, output_lang: &str) -> std::process::Output { - bin() - .current_dir(&fx.dir) - .arg("@transpile") - .arg("--input-lang=seal") - .arg("--output-lang") - .arg(output_lang) - .arg(&fx.source) - .output() - .expect("runseal should run") -} - -fn repo_root() -> PathBuf { - std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("app dir should have repo parent") - .to_path_buf() -} - -#[test] -fn repo_wrapper_syntax() { - let root = repo_root(); - for wrapper in WRAPPERS { - let source = root.join(wrapper); - - let bash = bin() - .current_dir(&root) - .arg("@transpile") - .arg("--input-lang=seal") - .arg("--output-lang=bash") - .arg(&source) - .output() - .expect("runseal should run"); - assert!( - bash.status.success(), - "{wrapper} bash stderr: {}", - String::from_utf8_lossy(&bash.stderr) - ); - let bash = String::from_utf8(bash.stdout).expect("bash output should be UTF-8"); - syntax::assert_bash(&bash); - - let powershell = bin() - .current_dir(&root) - .arg("@transpile") - .arg("--input-lang=seal") - .arg("--output-lang=powershell") - .arg(&source) - .output() - .expect("runseal should run"); - assert!( - powershell.status.success(), - "{wrapper} powershell stderr: {}", - String::from_utf8_lossy(&powershell.stderr) - ); - let powershell = - String::from_utf8(powershell.stdout).expect("powershell output should be UTF-8"); - syntax::assert_pwsh(&powershell); - } -} - -#[test] -fn wrappers_use_tool_cli() { - for wrapper in WRAPPERS { - let source = wrapper_source(wrapper); - for namespace in [ - "cloudflare", - "fs", - "github", - "int", - "json", - "process", - "regex", - "string", - ] { - assert!( - !source.contains(&format!("seal {namespace}")), - "{wrapper} should use `runseal @tool {namespace}`, not `seal {namespace}`" - ); - } - } -} - -#[test] -fn wrappers_use_tests() { - for wrapper in WRAPPERS { - let source = wrapper_source(wrapper); - for predicate in [ - "if empty ", - "if not_empty ", - "if eq ", - "if neq ", - "if file_exists ", - "if dir_exists ", - "if json_empty ", - "if json_not_empty ", - "while lt ", - ] { - assert!( - !source.contains(predicate), - "{wrapper} should use bash test predicates, not `{predicate}`" - ); - } - } -} - -#[test] -fn wrappers_use_shift() { - for wrapper in WRAPPERS { - let source = wrapper_source(wrapper); - assert!( - !source.contains("seal passthrough"), - "{wrapper} should use bash shift plus `\"$@\"`, not `seal passthrough`" - ); - } -} - -#[test] -fn wrappers_use_argv_blocks() { - for wrapper in WRAPPERS { - let source = wrapper_source(wrapper); - assert!( - !source.contains("seal argv parse"), - "{wrapper} should use a bash while/case argv parser block, not `seal argv parse`" - ); - } -} - -#[test] -fn wrappers_use_check_tool() { - for wrapper in WRAPPERS { - let source = wrapper_source(wrapper); - assert!( - !source.contains("seal capture optional"), - "{wrapper} should use focused `runseal @tool` glue, not `seal capture optional`" - ); - } -} - -#[test] -fn shift_args_targets() { - let fx = fixture( - r#" -shift -runseal @tool cloudflare api request "$@" -"#, - ); - - let bash = run_transpile(&fx, "bash"); - let powershell = run_transpile(&fx, "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("shift")); - assert!(bash.contains("runseal @tool cloudflare api request \"$@\"")); - assert!(powershell.contains("$args = if ($args.Count -gt 1)")); - assert!(powershell.contains("& 'runseal' '@tool' 'cloudflare' 'api' 'request' @args")); - syntax::assert_bash(&bash); - syntax::assert_pwsh(&powershell); -} - -#[test] -fn powershell_binds_positionals() { - let fx = fixture( - r#" -echo_first() { - print "$1" -} - -echo_first "$2" -"#, - ); - - let powershell = run_transpile(&fx, "powershell"); - - assert!(powershell.status.success()); - let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); - assert!(powershell.contains("$1 = if ($args.Count -ge 1) { $args[0] } else { '' }")); - assert!(powershell.contains("$2 = if ($args.Count -ge 2) { $args[1] } else { '' }")); - assert!(powershell.contains("function echo_first {\n $0 = $args.Count\n $1 = if")); - assert!(!powershell.contains("$3 = if ($args.Count -ge 3)")); - syntax::assert_pwsh(&powershell); -} - -#[test] -fn capture_locals() { - let fx = fixture( - r#" -helper() { - print "hello $1" -} - -value=$(helper world) -print "$value" -"#, - ); - - let sealir = run_transpile(&fx, "sealir"); - assert!(sealir.status.success()); - let sealir = String::from_utf8(sealir.stdout).expect("stdout should be UTF-8"); - assert!(sealir.contains("capture_function")); - assert!(sealir.contains("\"function\": \"helper\"")); - - let bash = run_transpile(&fx, "bash"); - let powershell = run_transpile(&fx, "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("value=$(helper world)")); - assert!(powershell.contains("$value = & helper 'world'")); - syntax::assert_bash(&bash); - syntax::assert_pwsh(&powershell); -} - -#[test] -fn capture_locals_pwsh() { - let fx = fixture( - r#" -function helper { - Write-Output ('hello ' + $1) -} - -$value = & helper 'world' -Write-Output $value -"#, - ); - - let output = bin() - .current_dir(&fx.dir) - .arg("@transpile") - .arg("--input-lang=powershell") - .arg("--output-lang=sealir") - .arg(&fx.source) - .output() - .expect("runseal should run"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("capture_function")); - assert!(stdout.contains("\"function\": \"helper\"")); -} - -fn wrapper_source(wrapper: &str) -> String { - std::fs::read_to_string(repo_root().join(wrapper)).expect("wrapper should be readable") -} diff --git a/app/tests/transpile_support/syntax.rs b/app/tests/transpile_support/syntax.rs deleted file mode 100644 index 22bf792..0000000 --- a/app/tests/transpile_support/syntax.rs +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index 1b797bb..0000000 --- a/app/tests/transpile_support/tool.rs +++ /dev/null @@ -1,21 +0,0 @@ -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/docs/examples/README.md b/docs/examples/README.md index c2eb7ff..c3c9c26 100644 --- a/docs/examples/README.md +++ b/docs/examples/README.md @@ -1,10 +1,9 @@ # Examples -These examples capture canonical shapes that are valid but easy to get wrong -from shell intuition alone. +These examples capture canonical runseal-owned shapes that are valid but easy +to get wrong from plain CLI intuition alone. -- [Seal `case` / argv parser shapes](./seal/case.md) - [GitHub tool examples](./tools/github.md) Use these as the repository-owned reference when a live wrapper or operator -flow feels "almost shell" but still needs the exact runseal shape. +flow needs the exact runseal tool or wrapper boundary. diff --git a/docs/examples/seal/case.md b/docs/examples/seal/case.md deleted file mode 100644 index 9fe7a0d..0000000 --- a/docs/examples/seal/case.md +++ /dev/null @@ -1,170 +0,0 @@ -# Seal `case` / Argv Shapes - -This file documents the canonical `.seal` shapes for `case "$1" in` argv -parsing. These are intentionally narrower than general shell scripting. - -## Supported option arm: `--name=value` - -```sh ---body=*) - body=${1#--body=} - shift - ;; -``` - -This is the canonical inline-value string option arm. - -## Supported option arm: `--name <value>` - -Single-line guarded form: - -```sh ---body) - if [ "$#" -lt 2 ]; then fail "missing value for --body"; fi - body=$2 - shift 2 - ;; -``` - -Multi-line guarded form: - -```sh ---body) - if [ "$#" -lt 2 ]; then - fail "missing value for --body" - fi - body=$2 - shift 2 - ;; -``` - -Both are canonical string-option arms. - -The guard predicate is intentionally exact: - -```sh -if [ "$#" -lt 2 ]; then -``` - -This is not a general parser for arbitrary pre-check logic. - -## Supported flag arm - -```sh ---dry-run) - dry_run=true - shift - ;; -``` - -## Supported help arm - -```sh --h|--help|help) - __seal_help=true - shift - ;; -``` - -## Supported positional fallback arm - -This is the one supported positional sink shape: - -```sh -*) - if [ -z "$message" ]; then - message=$1 - shift - else - fail "unexpected argument: $1" - fi - ;; -``` - -Semantics: - -- The first unmatched argument fills one positional target. -- The next unmatched argument fails with a stable operator-facing message. -- This is not the same as "take one arg and break out of parsing." - -## Not supported - -These shapes are intentionally outside the current canonical surface: - -```sh -*) - message=$1 - shift - break - ;; -``` - -```sh -*) - shift - ;; -``` - -```sh ---body) - body=$2 - shift 2 - ;; -``` - -The last form looks natural in shell, but runseal currently requires the -canonical missing-value guard for separated-value option arms. - -## Complete minimal example - -```sh -print() { - printf '%s\n' "$1" -} - -fail() { - print "$1" - exit 1 -} - -__seal_argc=$# -__seal_help=false -body= -message= - -while [ "$#" -gt 0 ]; do - case "$1" in - --body) - if [ "$#" -lt 2 ]; then - fail "missing value for --body" - fi - body=$2 - shift 2 - ;; - --body=*) - body=${1#--body=} - shift - ;; - -h|--help|help) - __seal_help=true - shift - ;; - *) - if [ -z "$message" ]; then - message=$1 - shift - else - fail "unexpected argument: $1" - fi - ;; - esac -done - -if [ "$__seal_help" = true ]; then - print help - exit 0 -fi - -print "$body" -print "$message" -``` diff --git a/docs/examples/tools/github.md b/docs/examples/tools/github.md index 519adfe..ebdab90 100644 --- a/docs/examples/tools/github.md +++ b/docs/examples/tools/github.md @@ -47,7 +47,7 @@ runseal @tool github issue comment create \ ```bash runseal @tool github issue create \ --repo PerishCode/runseal \ - --title "Document canonical Seal argv shapes" \ + --title "Document Deno wrapper policy" \ --body-file body.md ``` diff --git a/runseal.toml b/runseal.toml index d88f0db..eb174bb 100644 --- a/runseal.toml +++ b/runseal.toml @@ -1,6 +1,17 @@ [resources] root = ".local" +[deno] +config = ".runseal/deno.json" +lock = "deno.lock" +permissions = [ + "--allow-read=.", + "--allow-write=.", + "--allow-env", + "--allow-net", + "--allow-run=git,gh,cargo,flavor,sh,bash,pwsh,python3,deno,runseal", +] + [[injections]] type = "env" From 1e9acfc7c78a52ceb07517a3d32d19cdec196831 Mon Sep 17 00:00:00 2001 From: PerishCode <perishcode@gmail.com> Date: Thu, 25 Jun 2026 14:01:25 +0800 Subject: [PATCH 2/5] Install Deno in release workflows --- .github/workflows/release-beta.yml | 12 ++++++++++++ .github/workflows/release-stable.yml | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index a9d567f..108052b 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -44,6 +44,10 @@ jobs: with: ref: ${{ inputs.ref }} + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - name: Validate R2 access run: bash .github/scripts/release/r2/check.sh @@ -69,6 +73,10 @@ jobs: with: components: rustfmt, clippy + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - name: Format run: cargo fmt --all --check @@ -164,6 +172,10 @@ jobs: path: dist/${{ needs.metadata.outputs.release_version }} merge-multiple: true + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - name: Resolve guard version hash id: guard_hash shell: bash diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index 4b83397..80b4b4f 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -43,6 +43,10 @@ jobs: with: ref: ${{ inputs.ref }} + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - name: Validate R2 access run: bash .github/scripts/release/r2/check.sh @@ -68,6 +72,10 @@ jobs: with: components: rustfmt, clippy + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - name: Format run: cargo fmt --all --check @@ -162,6 +170,10 @@ jobs: path: dist/${{ needs.metadata.outputs.release_version }} merge-multiple: true + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - name: Resolve guard version hash id: guard_hash shell: bash From c7ac10857a4dabd8d05fce22a55814d8a73f2888 Mon Sep 17 00:00:00 2001 From: PerishCode <perishcode@gmail.com> Date: Thu, 25 Jun 2026 14:03:42 +0800 Subject: [PATCH 3/5] Preserve shell env for Deno child commands --- .runseal/lib/runseal.ts | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/.runseal/lib/runseal.ts b/.runseal/lib/runseal.ts index e02158b..9a809b2 100644 --- a/.runseal/lib/runseal.ts +++ b/.runseal/lib/runseal.ts @@ -17,7 +17,16 @@ const blockedInheritedEnv = new Set([ "LD_LIBRARY_PATH", ]); -function commandEnv(extra: Record<string, string> | undefined): Record<string, string> { +function hasBlockedInheritedEnv(): boolean { + for (const key of blockedInheritedEnv) { + if (Deno.env.get(key) !== undefined) { + return true; + } + } + return false; +} + +function sanitizedEnv(extra: Record<string, string> | undefined): Record<string, string> { const env = Deno.env.toObject(); for (const key of blockedInheritedEnv) { delete env[key]; @@ -25,6 +34,15 @@ function commandEnv(extra: Record<string, string> | undefined): Record<string, s return { ...env, ...(extra ?? {}) }; } +function commandEnvOptions( + extra: Record<string, string> | undefined, +): Pick<Deno.CommandOptions, "clearEnv" | "env"> { + if (hasBlockedInheritedEnv()) { + return { clearEnv: true, env: sanitizedEnv(extra) }; + } + return extra === undefined ? {} : { env: extra }; +} + export function print(value = ""): void { console.log(value); } @@ -58,8 +76,7 @@ export async function run(command: string, args: string[] = [], options: Command const status = await new Deno.Command(command, { args, cwd: options.cwd, - clearEnv: true, - env: commandEnv(options.env), + ...commandEnvOptions(options.env), stdin: options.stdin ?? "inherit", stdout: options.stdout ?? "inherit", stderr: options.stderr ?? "inherit", @@ -77,8 +94,7 @@ export async function runText( const output = await new Deno.Command(command, { args, cwd: options.cwd, - clearEnv: true, - env: commandEnv(options.env), + ...commandEnvOptions(options.env), stdin: options.stdin ?? "null", stdout: "piped", stderr: options.stderr ?? "inherit", @@ -98,8 +114,7 @@ export async function runInput( const child = new Deno.Command(command, { args, cwd: options.cwd, - clearEnv: true, - env: commandEnv(options.env), + ...commandEnvOptions(options.env), stdin: "piped", stdout: options.stdout ?? "piped", stderr: options.stderr ?? "inherit", From b4efb9a17fe150612af9ee2c959b48ce60558334 Mon Sep 17 00:00:00 2001 From: PerishCode <perishcode@gmail.com> Date: Thu, 25 Jun 2026 14:06:05 +0800 Subject: [PATCH 4/5] Bump version to 0.8.0 --- Cargo.lock | 2 +- app/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 818af29..c7eab5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1060,7 +1060,7 @@ dependencies = [ [[package]] name = "runseal" -version = "0.7.0" +version = "0.8.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/app/Cargo.toml b/app/Cargo.toml index bb7b4a3..8dc7b3a 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "runseal" -version = "0.7.0" +version = "0.8.0" edition = "2024" [dependencies] From 5cdc77efeb0ba84918b05a3f6052ab2a74b5c7e9 Mon Sep 17 00:00:00 2001 From: PerishCode <perishcode@gmail.com> Date: Thu, 25 Jun 2026 14:17:38 +0800 Subject: [PATCH 5/5] Normalize runseal wrapper line endings --- .gitattributes | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fff3c3c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +.runseal/** text eol=lf