diff --git a/README.md b/README.md index 79f254f..6cef167 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,19 @@ range 5 | length echo {name: "gleshell", cool: true} echo "{\"a\": 1}" | from json | get a open data.json | get users | first +echo "{\"user\": {\"name\": \"ada\"}}" | from json | get user.name range 3 | to json +# JWT (parse only — does not verify signature) +echo $token | from jwt | get payload +echo $token | from jwt | get header.alg +from jwt eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.… + +# paste multi-line text into a pipeline (finish with Ctrl+D) +input | from json +input "Paste notes:" | lines | find TODO +# or pipe data into a one-shot: printf '{"a":1}' | gle -c 'input | from json' + # variables let n = range 3 | length echo $n @@ -123,6 +134,14 @@ which -f sh ps ps | sort-by mem | last 5 ps --long | where name == beam.smp + +# who is bound to a port (listeners by default; --all ≈ lsof -i) +whyport 22 +whyport 4004 +whyport --all 4004 + +# current time (Unix epoch seconds; prints like ls modified) +now ``` ## Language sketch @@ -149,7 +168,9 @@ Filesystem: `ls`, `cd`, `pwd`, `cat` (MIME/extension detect + syntax color on TT Table/list: `where`/`filter`, `find`, `select`, `get`, `first`, `last`, `take`, `skip`, `sort-by`, `reverse`, `length`, `columns`, `table`, `flatten`, `uniq`, `wrap`, `unwrap`, `keys`, `values` -Data: `echo`, `range`, `lines`, `to`/`from` (subcommand `json`), `type`, `describe`, `env`, `sys`, `which`, `help`, `about`, `exit` +Data: `echo`, `range`, `lines`, `input` (multi-line paste / stdin until Ctrl+D), +`to`/`from` (subcommands `json`, `jwt`), `type`, `describe`, `env`, `sys`, `ps`, +`whyport`, `now`, `which`, `help`, `about`, `exit` HTTP: `http get|post|put|delete|patch|head` — fetch/send with structured JSON bodies and responses (`http get https://example.com`, `http post URL {a: 1}`, `--full`, diff --git a/src/gleshell/builtins.gleam b/src/gleshell/builtins.gleam index ba0b520..bda1261 100644 --- a/src/gleshell/builtins.gleam +++ b/src/gleshell/builtins.gleam @@ -77,6 +77,7 @@ pub fn registry() -> dict.Dict(String, Builtin) { #("quit", cmd_exit), #("ignore", cmd_ignore), #("identity", cmd_identity), + #("input", cmd_input), #("range", cmd_range), #("append", cmd_append), #("prepend", cmd_prepend), @@ -89,6 +90,8 @@ pub fn registry() -> dict.Dict(String, Builtin) { #("keys", cmd_keys), #("sys", cmd_sys), #("ps", cmd_ps), + #("whyport", cmd_whyport), + #("now", cmd_now), #("about", cmd_about), #("less", cmd_less), ]) @@ -197,10 +200,18 @@ fn help_for(name: String) -> Result(String, Nil) { "", "Subcommands:", " json — parse a JSON string (pipeline input or a string argument)", + " jwt — decode a JWT (JWS compact) into header/payload/signature", + "", + "JWT notes: does not verify the signature; only base64url-decodes and", + "parses the JSON header and claims. Optional \"Bearer \" prefix is", + "stripped. Signature is returned as the original base64url segment.", "", "Examples:", " open data.json | from json", " echo '{\"a\": 1}' | from json | get a", + " echo $token | from jwt | get payload", + " echo $token | from jwt | get header.alg", + " from jwt eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…", ], "\n", )) @@ -298,6 +309,75 @@ fn help_for(name: String) -> Result(String, Nil) { ], "\n", )) + "whyport" -> + Ok(string.join( + [ + "whyport [-a|--all] [-l|--long] — who is bound to a port", + "", + "Answers “why is this port taken?” Default: local listeners only", + "(TCP LISTEN + UDP binds), short columns — not every ESTABLISHED", + "client or TIME_WAIT row, and not full command lines.", + "", + "Flags:", + " -a, --all all sockets touching the port (local or remote),", + " like `lsof -i :` (adds state + remote cols)", + " -l, --long extra columns: family, command, user_id, fd", + "", + "Default columns: protocol, local_address, local_port, pid, name", + "With --all: + remote_address, remote_port, state", + "With --long: + family, command, user_id, fd", + "", + "Accepts the port as an argument or pipeline input. Leading `:` is", + "optional (`whyport 8080` and `whyport :8080` are the same).", + "", + "Examples:", + " whyport 22", + " whyport 4004", + " whyport --all 4004", + " whyport -al 8080", + " echo 3000 | whyport", + ], + "\n", + )) + "now" -> + Ok(string.join( + [ + "now — current Unix time (epoch seconds)", + "", + "Inspired by Nushell `date now`. Returns an int of UTC epoch seconds,", + "the same representation as `ls` modified / `ps` start_time. Display", + "formats it as a local 12-hour datetime (e.g. Jul 26 2026 3:17:35 AM).", + "", + "Examples:", + " now", + " let t = now", + " ls | where modified > 1700000000", + ], + "\n", + )) + "input" -> + Ok(string.join( + [ + "input [prompt] — read multi-line text from the terminal (or stdin)", + "", + "Type or paste freely; finish with Ctrl+D (EOF). The collected text is", + "a string you can pipe into anything — `from json`, `lines`, `save`, …", + "", + "Interactive: after Enter on the command line, paste content, then Ctrl+D.", + "Piped: `printf '…' | gle -c 'input | from json'` drains stdin.", + "", + "Optional prompt string is printed before reading (on its own line).", + "Ctrl+C cancels.", + "", + "Examples:", + " input | from json", + " input | lines | find TODO", + " input \"Paste JWT:\" | from jwt | get payload", + " input | save notes.txt", + " let body = input", + ], + "\n", + )) _ -> help_line(name) } } @@ -329,7 +409,7 @@ fn help_text() -> dict.Dict(String, String) { "find [-i] [-v] [--regex pat] … — search list/table/string input for terms", ), #("select", "select … — keep only named columns"), - #("get", "get — get a field or list index"), + #("get", "get — get a field, dotted path (a.b), or list index"), #("first", "first [n] — first row/item (default 1)"), #("last", "last [n] — last row/item"), #("take", "take — take first n items"), @@ -348,7 +428,7 @@ fn help_text() -> dict.Dict(String, String) { ), #( "from", - "from — parse structured input (subcommands: json)", + "from — parse structured input (subcommands: json, jwt)", ), #( "http", @@ -373,6 +453,10 @@ fn help_text() -> dict.Dict(String, String) { #("quit", "quit [code] — alias for exit"), #("ignore", "ignore — discard pipeline input; emit nothing"), #("identity", "identity — pass pipeline input through unchanged"), + #( + "input", + "input [prompt] — read multi-line text until Ctrl+D (pipe into next stage)", + ), #("range", "range | range — integer range list"), #("append", "append … — append values to list input"), #("prepend", "prepend … — prepend values to list input"), @@ -391,6 +475,11 @@ fn help_text() -> dict.Dict(String, String) { "ps", "ps [-l|--long] — system processes table (pid, name, cpu, mem, …)", ), + #( + "whyport", + "whyport [-a|--all] [-l|--long] — who is bound to a TCP/UDP port", + ), + #("now", "now — current time as Unix epoch seconds (prints as local datetime)"), #("about", "about — authorship, ATProto handle, and a little sparkle"), #( "less", @@ -1065,39 +1154,15 @@ fn cmd_get( } } [String(key)] -> - case input { - Record(_) -> - case value.get_field(input, key) { + case value.parse_cell_path(key) { + Error(e) -> err(env, "get: " <> e) + Ok(path) -> + case value.get_path(input, path) { Ok(v) -> ok(env, v) Error(e) -> err(env, "get: " <> e) } - Table(cols, rows) -> - case list_index_of(cols, key) { - Ok(idx) -> { - let col_vals = - list.map(rows, fn(row) { - case list_at(row, idx) { - Ok(v) -> v - Error(Nil) -> Nothing - } - }) - ok(env, List(col_vals)) - } - Error(Nil) -> err(env, "get: no column '" <> key <> "'") - } - List(items) -> { - let got = - list.filter_map(items, fn(item) { - case value.get_field(item, key) { - Ok(v) -> Ok(v) - Error(_) -> Error(Nil) - } - }) - ok(env, List(got)) - } - _ -> err(env, "get: unsupported input type " <> value.type_name(input)) } - _ -> err(env, "get: expected field name or index") + _ -> err(env, "get: expected field name, dotted path, or index") } } @@ -1345,8 +1410,12 @@ fn cmd_from( ) -> BuiltinResult { case args { [String("json"), ..rest] -> cmd_from_json(env, input, rest, flags) + [String("jwt"), ..rest] -> cmd_from_jwt(env, input, rest, flags) [] -> - err(env, "from: expected subcommand (try `from json`; see `help from`)") + err( + env, + "from: expected subcommand (try `from json` or `from jwt`; see `help from`)", + ) [String(sub), ..] -> err(env, "from: unknown subcommand: " <> sub) _ -> err(env, "from: expected subcommand name") } @@ -1406,6 +1475,91 @@ fn cmd_from_json( } } +/// Decode a JWT (JWS compact serialization) into a structured record. +/// Does **not** verify the cryptographic signature — parse-only, like `jwt decode`. +fn cmd_from_jwt( + env: Env, + input: Value, + args: List(Value), + _flags: dict.Dict(String, Value), +) -> BuiltinResult { + let source = case args { + [String(s)] -> s + [other] -> value.as_string(other) + [] -> + case input { + String(s) -> s + Nothing -> "" + other -> value.as_string(other) + } + _ -> "" + } + case string.trim(source) { + "" -> err(env, "from: jwt: empty input") + s -> + case parse_jwt(s) { + Ok(v) -> ok(env, v) + Error(msg) -> err(env, "from: jwt: " <> msg) + } + } +} + +/// Parse a compact JWT into `{ header, payload, signature }`. +/// Signature is the original base64url segment (not decoded to binary). +fn parse_jwt(token: String) -> Result(Value, String) { + let cleaned = strip_bearer_prefix(string.trim(token)) + case string.split(cleaned, ".") { + [header_b64, payload_b64, signature_b64] -> { + case decode_jwt_json_part(header_b64, "header") { + Error(e) -> Error(e) + Ok(header) -> + case decode_jwt_json_part(payload_b64, "payload") { + Error(e) -> Error(e) + Ok(payload) -> + Ok( + Record([ + #("header", header), + #("payload", payload), + #("signature", String(signature_b64)), + ]), + ) + } + } + } + parts -> + Error( + "expected 3 dot-separated segments (header.payload.signature), got " + <> int.to_string(list.length(parts)), + ) + } +} + +fn strip_bearer_prefix(s: String) -> String { + case string.starts_with(string.lowercase(s), "bearer ") { + True -> string.trim(string.drop_start(s, 7)) + False -> s + } +} + +fn decode_jwt_json_part(segment: String, part_name: String) -> Result(Value, String) { + case segment { + "" -> Error(part_name <> ": empty segment") + b64 -> + case bit_array.base64_url_decode(b64) { + Error(Nil) -> Error(part_name <> ": invalid base64url") + Ok(bits) -> + case bit_array.to_string(bits) { + Error(Nil) -> Error(part_name <> ": not valid UTF-8 after decode") + Ok(json_text) -> + case parse_json_value(json_text) { + Ok(v) -> Ok(v) + Error(msg) -> Error(part_name <> ": JSON: " <> msg) + } + } + } + } +} + // --- http (Nushell-style HTTP client with method subcommands) --- fn http_help_text() -> String { @@ -2184,6 +2338,34 @@ fn cmd_identity( ok(env, input) } +// --- input (multi-line paste / stdin → string for the pipeline) --- + +fn cmd_input( + env: Env, + _input: Value, + args: List(Value), + _flags: dict.Dict(String, Value), +) -> BuiltinResult { + let prompt = case args { + [] -> "" + [String(s)] -> s + [other] -> value.as_string(other) + _ -> "" + } + case args { + [_, _, ..] -> + err(env, "input: expected at most one prompt string (see `help input`)") + _ -> + case sys.read_user_input(prompt) { + Ok(text) -> ok(env, String(text)) + Error("interrupted") -> + err(env, "input: interrupted") + Error("eof") -> ok(env, String("")) + Error(msg) -> err(env, "input: " <> msg) + } + } +} + fn cmd_range( env: Env, _input: Value, @@ -2355,6 +2537,19 @@ fn cmd_sys( ) } +// --- now (Nushell-style current datetime) --- + +fn cmd_now( + env: Env, + _input: Value, + _args: List(Value), + _flags: dict.Dict(String, Value), +) -> BuiltinResult { + // Same representation as `ls` modified / `ps` start_time: raw epoch seconds. + // Display formats bare epoch ints as local datetimes (see display.render). + ok(env, Int(sys.unix_now())) +} + // --- ps (Nushell-style process table) --- fn cmd_ps( @@ -2369,6 +2564,147 @@ fn cmd_ps( ok(env, value.table_from_records(records)) } +// --- whyport (who owns a port; --all ≈ `lsof -i :`) --- + +fn cmd_whyport( + env: Env, + input: Value, + args: List(Value), + flags: dict.Dict(String, Value), +) -> BuiltinResult { + let #(all, stolen_a) = find_bool_flag(flags, ["a", "all"]) + let #(long, stolen_l) = find_bool_flag(flags, ["l", "long"]) + // Bool flags may steal the port (`whyport --all 4004` → flag value "4004"). + let port_args = list.flatten([args, stolen_a, stolen_l]) + case resolve_port_number(input, port_args) { + Error(msg) -> err(env, msg) + Ok(port) -> { + let sockets = + sys.list_port_sockets(port) + |> list.filter(fn(s) { whyport_keep_socket(s, port, all) }) + let records = + list.map(sockets, fn(s) { port_socket_to_record(s, all, long) }) + ok(env, whyport_table(records, all, long)) + } + } +} + +/// Default: local listeners only. `--all`: any socket touching the port. +fn whyport_keep_socket(s: sys.PortSocket, port: Int, all: Bool) -> Bool { + case all { + True -> True + False -> + // Bound here: local port matches, and either TCP LISTEN or a UDP bind + // (UDP has no LISTEN state; unbound peer means "listening" socket). + s.local_port == port + && case s.protocol { + "tcp" -> s.state == "LISTEN" + "udp" -> s.remote_port == 0 + _ -> s.state == "LISTEN" || s.remote_port == 0 + } + } +} + +fn whyport_columns(all: Bool, long: Bool) -> List(String) { + let base = ["protocol", "local_address", "local_port", "pid", "name"] + let with_peers = case all { + False -> base + True -> + list.append(base, ["remote_address", "remote_port", "state"]) + } + case long { + False -> with_peers + True -> + list.append(with_peers, ["family", "command", "user_id", "fd"]) + } +} + +fn whyport_table(records: List(Value), all: Bool, long: Bool) -> Value { + case records { + [] -> Table(whyport_columns(all, long), []) + _ -> value.table_from_records(records) + } +} + +/// Port from arg (`whyport 8080`, `whyport :8080`) or pipeline (`echo 8080 | whyport`). +fn resolve_port_number( + input: Value, + args: List(Value), +) -> Result(Int, String) { + case args { + [raw] -> parse_port_value(raw) + [] -> + case input { + Nothing -> + Error( + "whyport: expected port number (e.g. `whyport 8080`; see `help whyport`)", + ) + other -> parse_port_value(other) + } + _ -> Error("whyport: expected a single port number") + } +} + +fn parse_port_value(v: Value) -> Result(Int, String) { + case v { + Int(n) -> validate_port(n) + String(s) -> { + let cleaned = string.trim(s) + let without_colon = case string.starts_with(cleaned, ":") { + True -> string.drop_start(cleaned, 1) + False -> cleaned + } + case int.parse(string.trim(without_colon)) { + Ok(n) -> validate_port(n) + Error(Nil) -> Error("whyport: invalid port: " <> cleaned) + } + } + other -> + Error("whyport: expected port number, got " <> value.type_name(other)) + } +} + +fn validate_port(n: Int) -> Result(Int, String) { + case n >= 0 && n <= 65_535 { + True -> Ok(n) + False -> + Error( + "whyport: port out of range (0–65535): " <> int.to_string(n), + ) + } +} + +fn port_socket_to_record(s: sys.PortSocket, all: Bool, long: Bool) -> Value { + let base = [ + #("protocol", String(s.protocol)), + #("local_address", String(s.local_address)), + #("local_port", Int(s.local_port)), + #("pid", Int(s.pid)), + #("name", String(s.name)), + ] + let with_peers = case all { + False -> base + True -> + list.append(base, [ + #("remote_address", String(s.remote_address)), + #("remote_port", Int(s.remote_port)), + #("state", String(s.state)), + ]) + } + case long { + False -> Record(with_peers) + True -> + Record( + list.append(with_peers, [ + #("family", String(s.family)), + #("command", String(s.command)), + #("user_id", Int(s.user_id)), + #("fd", Int(s.fd)), + ]), + ) + } +} + fn process_to_record(p: sys.ProcessInfo, long: Bool) -> Value { let base = [ #("pid", Int(p.pid)), @@ -2509,18 +2845,3 @@ fn list_at(items: List(a), index: Int) -> Result(a, Nil) { _, _ -> Error(Nil) } } - -fn list_index_of(items: List(a), target: a) -> Result(Int, Nil) { - list_index_of_loop(items, target, 0) -} - -fn list_index_of_loop(items: List(a), target: a, i: Int) -> Result(Int, Nil) { - case items { - [] -> Error(Nil) - [x, ..rest] -> - case x == target { - True -> Ok(i) - False -> list_index_of_loop(rest, target, i + 1) - } - } -} diff --git a/src/gleshell/display.gleam b/src/gleshell/display.gleam index 710588b..224dd59 100644 --- a/src/gleshell/display.gleam +++ b/src/gleshell/display.gleam @@ -33,10 +33,28 @@ pub fn render_with(on: Bool, value: Value) -> String { } } Record(fields) -> render_record(on, fields) + // Bare epoch seconds (e.g. `now`, `ls | get modified` on one row): print + // like the `modified` column — local 12-hour datetime, not a raw integer. + Int(n) -> render_bare_int(on, n) other -> color_cell(on, "", other, value.cell_string(other)) } } +/// Root-level ints that look like Unix timestamps display as local datetimes +/// (same form as `ls` modified). Small ints and non-epoch values stay numeric. +fn render_bare_int(on: Bool, n: Int) -> String { + case is_epoch_seconds(n) { + True -> color.datetime(on, format_datetime(n)) + False -> color.int_(on, int.to_string(n)) + } +} + +/// Plausible wall-clock epoch seconds (2001-09-09 .. 2100-01-01). +/// Used only for bare-value display so `now` and extracted mtimes read as times. +fn is_epoch_seconds(n: Int) -> Bool { + n >= 1_000_000_000 && n < 4_102_444_800 +} + /// External programs often embed their own colors. Pass those through. /// Short plain strings still get Nu-style string coloring. fn render_string(on: Bool, s: String) -> String { diff --git a/src/gleshell/sys.gleam b/src/gleshell/sys.gleam index ad23ce2..2806600 100644 --- a/src/gleshell/sys.gleam +++ b/src/gleshell/sys.gleam @@ -3,6 +3,12 @@ @external(erlang, "gleshell_ffi", "get_line") pub fn get_line(prompt: String) -> Result(String, String) +/// Multi-line user input for the `input` builtin. +/// Reads until Ctrl+D / EOF (interactive paste) or drains piped stdin. +/// Optional `prompt` is printed first; use `""` for silent. +@external(erlang, "gleshell_ffi", "read_user_input") +pub fn read_user_input(prompt: String) -> Result(String, String) + /// Print a line to stdout. In raw TTY REPL mode, newlines become CRLF so /// multi-line values (tables, pretty JSON) do not staircase. @external(erlang, "gleshell_ffi", "println") @@ -129,6 +135,10 @@ pub fn stdout_isatty() -> Bool @external(erlang, "gleshell_ffi", "format_unix_local") pub fn format_unix_local(seconds: Int) -> String +/// Current Unix epoch seconds (UTC wall clock). +@external(erlang, "gleshell_ffi", "unix_now") +pub fn unix_now() -> Int + /// One process row from `list_processes` (Nushell `ps` columns). /// Memory fields are bytes; `start_time` is Unix epoch seconds (0 if unknown). pub type ProcessInfo { @@ -157,3 +167,27 @@ pub type ProcessInfo { /// Samples CPU over ~100ms like Nushell `ps`. @external(erlang, "gleshell_ffi", "list_processes") pub fn list_processes() -> List(ProcessInfo) + +/// One socket row from `list_port_sockets` (like `lsof -i :`). +/// `pid`/`fd` are 0 when the owner is unknown; `state` is empty for UDP. +pub type PortSocket { + PortSocket( + protocol: String, + family: String, + local_address: String, + local_port: Int, + remote_address: String, + remote_port: Int, + state: String, + pid: Int, + name: String, + command: String, + user_id: Int, + fd: Int, + ) +} + +/// Sockets whose local or remote port is `port` (Linux `/proc/net` + fd inodes). +/// Empty list on unsupported OS or when nothing matches. +@external(erlang, "gleshell_ffi", "list_port_sockets") +pub fn list_port_sockets(port: Int) -> List(PortSocket) diff --git a/src/gleshell/value.gleam b/src/gleshell/value.gleam index cf03ed9..080a6b2 100644 --- a/src/gleshell/value.gleam +++ b/src/gleshell/value.gleam @@ -135,6 +135,139 @@ pub fn get_field(record: Value, name: String) -> Result(Value, String) { } } +/// Split a dotted cell path (`"foo.bar"` → `["foo", "bar"]`). +/// Empty segments (e.g. `"a..b"`) are rejected. +pub fn parse_cell_path(path: String) -> Result(List(String), String) { + case path { + "" -> Error("empty path") + _ -> { + let parts = string.split(path, ".") + case list.any(parts, fn(p) { p == "" }) { + True -> Error("invalid path '" <> path <> "' (empty segment)") + False -> Ok(parts) + } + } + } +} + +/// Follow a Nushell-style cell path through records, lists, and tables. +/// Dots nest: `{a: {b: 1}} | get a.b` → `1`. +/// When a list/table is encountered mid-path, the rest of the path is applied +/// to each item (missing fields are skipped, matching plain `get` on lists). +pub fn get_path(value: Value, path: List(String)) -> Result(Value, String) { + case path { + [] -> Ok(value) + [key, ..rest] -> + case get_one(value, key) { + Error(e) -> Error(e) + Ok(next) -> + case rest { + [] -> Ok(next) + _ -> + case next { + List(items) -> + Ok( + List( + list.filter_map(items, fn(item) { + case get_path(item, rest) { + Ok(v) -> Ok(v) + Error(_) -> Error(Nil) + } + }), + ), + ) + Table(_, _) -> + case table_to_records(next) { + Error(e) -> Error(e) + Ok(rows) -> + Ok( + List( + list.filter_map(rows, fn(row) { + case get_path(row, rest) { + Ok(v) -> Ok(v) + Error(_) -> Error(Nil) + } + }), + ), + ) + } + _ -> get_path(next, rest) + } + } + } + } +} + +/// One path segment: field on a record, column on a table, or map over a list. +fn get_one(value: Value, key: String) -> Result(Value, String) { + case value { + Record(_) -> get_field(value, key) + Table(cols, rows) -> + case list_index_of(cols, key) { + Ok(idx) -> { + let col_vals = + list.map(rows, fn(row) { + case list_at(row, idx) { + Ok(v) -> v + Error(Nil) -> Nothing + } + }) + Ok(List(col_vals)) + } + Error(Nil) -> Error("no column '" <> key <> "'") + } + List(items) -> + Ok( + List( + list.filter_map(items, fn(item) { + case get_field(item, key) { + Ok(v) -> Ok(v) + Error(_) -> Error(Nil) + } + }), + ), + ) + other -> Error("cannot get '" <> key <> "' from " <> type_name(other)) + } +} + +fn list_index_of(items: List(String), target: String) -> Result(Int, Nil) { + list_index_of_loop(items, target, 0) +} + +fn list_index_of_loop( + items: List(String), + target: String, + i: Int, +) -> Result(Int, Nil) { + case items { + [] -> Error(Nil) + [x, ..rest] -> + case x == target { + True -> Ok(i) + False -> list_index_of_loop(rest, target, i + 1) + } + } +} + +fn list_at(items: List(Value), index: Int) -> Result(Value, Nil) { + case index < 0 { + True -> Error(Nil) + False -> list_at_loop(items, index) + } +} + +fn list_at_loop(items: List(Value), index: Int) -> Result(Value, Nil) { + case items { + [] -> Error(Nil) + [x, ..rest] -> + case index { + 0 -> Ok(x) + _ -> list_at_loop(rest, index - 1) + } + } +} + pub fn record_from_pairs(pairs: List(#(String, Value))) -> Value { Record(pairs) } diff --git a/src/gleshell_ffi.erl b/src/gleshell_ffi.erl index eb97716..8e961b1 100644 --- a/src/gleshell_ffi.erl +++ b/src/gleshell_ffi.erl @@ -3,6 +3,7 @@ -include_lib("kernel/include/file.hrl"). -export([ get_line/1, + read_user_input/1, parse_line/2, run_as_shell/1, spawn_shell/2, @@ -30,7 +31,9 @@ history_search/2, re_contains/3, format_unix_local/1, - list_processes/0 + unix_now/0, + list_processes/0, + list_port_sockets/1 ]). -define(ESC, 16#1b). @@ -258,6 +261,143 @@ parse_line(_Cont, eof) -> parse_line(_Cont, Chars) when is_list(Chars) -> {done, Chars, []}. +%% --------------------------------------------------------------------------- +%% Public: multi-line user input for the `input` builtin +%% +%% Interactive (raw REPL or TTY): read until Ctrl+D / EOF so the user can +%% paste a blob and end with Ctrl+D, e.g. `input | from json`. +%% Non-TTY (piped stdin): read the whole stream — `printf '…' | gle -c 'input | …'`. +%% Optional prompt is printed first (empty prompt = silent). +%% --------------------------------------------------------------------------- + +-spec read_user_input(binary()) -> {ok, binary()} | {error, binary()}. +read_user_input(Prompt) when is_binary(Prompt) -> + case Prompt of + <<>> -> + ok; + _ -> + %% Prompt on its own line so paste starts cleanly below it. + case get(gleshell_raw) of + true -> + io:put_chars(to_crlf(<>)); + _ -> + io:put_chars(<>) + end + end, + try + case get(gleshell_raw) of + true -> + read_input_raw([]); + _ -> + case stdin_isatty() of + true -> + read_input_lines([]); + false -> + read_input_stream([]) + end + end + catch + _:Reason -> + {error, reason_to_bin(Reason)} + end. + +%% Raw-mode multi-line: echo printable chars, Enter → newline, Ctrl+D ends. +read_input_raw(Acc) -> + case read_key() of + eof -> + io:put_chars("\r\n"), + {ok, codepoints_to_bin(lists:reverse(Acc))}; + ctrl_d -> + io:put_chars("\r\n"), + {ok, codepoints_to_bin(lists:reverse(Acc))}; + ctrl_c -> + io:put_chars("^C\r\n"), + {error, <<"interrupted">>}; + enter -> + io:put_chars("\r\n"), + read_input_raw([$\n | Acc]); + backspace -> + case Acc of + [] -> + read_input_raw(Acc); + [$\n | _] -> + %% Do not erase previous lines with a simple \b. + read_input_raw(Acc); + [_ | Rest] -> + io:put_chars("\b \b"), + read_input_raw(Rest) + end; + {char, C} when is_integer(C), C >= 32 -> + io:put_chars(unicode:characters_to_binary([C])), + read_input_raw([C | Acc]); + {error, _} -> + io:put_chars("\r\n"), + {error, <<"io_error">>}; + _Other -> + read_input_raw(Acc) + end. + +codepoints_to_bin(Cs) -> + unicode:characters_to_binary(Cs). + +%% Cooked/edlin TTY: line-at-a-time until EOF (Ctrl+D on empty line). +read_input_lines(Acc) -> + case io:get_line("") of + eof -> + {ok, iolist_to_binary(lists:reverse(Acc))}; + {error, interrupted} -> + {error, <<"interrupted">>}; + {error, _} -> + {error, <<"io_error">>}; + Line when is_list(Line); is_binary(Line) -> + Bin = unicode:characters_to_binary(Line), + read_input_lines([Bin | Acc]); + Other -> + try + Bin = unicode:characters_to_binary(Other), + read_input_lines([Bin | Acc]) + catch + _:_ -> + {error, <<"io_error">>} + end + end. + +%% Piped / non-TTY stdin: drain the whole stream. +read_input_stream(Acc) -> + case io:get_chars("", 8192) of + eof -> + {ok, iolist_to_binary(lists:reverse(Acc))}; + {error, Reason} -> + {error, reason_to_bin(Reason)}; + Data when is_binary(Data) -> + read_input_stream([Data | Acc]); + Data when is_list(Data) -> + read_input_stream([unicode:characters_to_binary(Data) | Acc]); + Other -> + try + Bin = unicode:characters_to_binary(Other), + read_input_stream([Bin | Acc]) + catch + _:_ -> + {error, <<"io_error">>} + end + end. + +-spec stdin_isatty() -> boolean(). +stdin_isatty() -> + try + case prim_tty:isatty(stdin) of + true -> + true; + _ -> + false + end + catch + _:_ -> + %% Fallback: if we cannot tell, prefer stream read so pipes work. + false + end. + %% --------------------------------------------------------------------------- %% Shell bootstrap: prefer OTP raw mode for live syntax highlighting. %% Falls back to edlin interactive shell when raw is unavailable. @@ -745,11 +885,12 @@ fallback_builtin_names() -> [ "about", "append", "cat", "cd", "columns", "count", "describe", "echo", "env", "exit", "filter", "find", "first", "flatten", "from", "get", - "help", "identity", "ignore", "is-empty", "is_empty", "keys", "last", - "length", "less", "lines", "ls", "open", "prepend", "print", "ps", - "pwd", "quit", "range", "reverse", "save", "select", "skip", "sort-by", - "sort_by", "sys", "table", "take", "to", "type", "typeof", "uniq", - "unwrap", "values", "where", "which", "wrap" + "help", "identity", "ignore", "input", "is-empty", "is_empty", "keys", + "last", "length", "less", "lines", "ls", "now", "open", "prepend", + "print", "ps", "pwd", "quit", "range", "reverse", "save", "select", + "skip", "sort-by", "sort_by", "sys", "table", "take", "to", "type", + "typeof", "uniq", "unwrap", "values", "where", "which", "whyport", + "wrap" ]. %% Executable basenames on PATH that match Prefix (deduped, sorted). @@ -3188,6 +3329,14 @@ reason_to_bin(Reason) when is_binary(Reason) -> reason_to_bin(Reason) -> iolist_to_binary(io_lib:format("~p", [Reason])). +%% --------------------------------------------------------------------------- +%% Current Unix epoch seconds (UTC). Used by the `now` builtin. +%% --------------------------------------------------------------------------- + +-spec unix_now() -> integer(). +unix_now() -> + os:system_time(second). + %% --------------------------------------------------------------------------- %% Format Unix epoch seconds as local calendar time: %% "Jul 3 2026 9:39:40 PM" (abbreviated month, 12-hour clock). @@ -3611,3 +3760,301 @@ boot_time_seconds() -> {error, _} -> 0 end. + +%% --------------------------------------------------------------------------- +%% whyport: sockets using a local/remote port (like `lsof -i :`) +%% --------------------------------------------------------------------------- +%% +%% Returns a list of Gleam `PortSocket` records (Erlang tagged tuples): +%% +%% {port_socket, Protocol, Family, LocalAddress, LocalPort, +%% RemoteAddress, RemotePort, State, Pid, Name, Command, UserId, Fd} +%% +%% Protocol: <<"tcp">> | <<"udp">> +%% Family: <<"ipv4">> | <<"ipv6">> +%% State: LISTEN / ESTABLISHED / … (TCP) or empty for UDP +%% Pid/Fd: 0 when the owning process is unknown (permissions / TIME_WAIT) +%% +%% Linux only via /proc/net/{tcp,tcp6,udp,udp6} + /proc/*/fd socket inodes. +%% +-spec list_port_sockets(integer()) -> + list({ + port_socket, + binary(), + binary(), + binary(), + integer(), + binary(), + integer(), + binary(), + integer(), + binary(), + binary(), + integer(), + integer() + }). +list_port_sockets(Port) when is_integer(Port), Port >= 0, Port =< 65535 -> + case os:type() of + {unix, linux} -> + list_port_sockets_linux(Port); + _ -> + [] + end; +list_port_sockets(_) -> + []. + +list_port_sockets_linux(Port) -> + SockMap = socket_inode_map(), + Sources = [ + {"/proc/net/tcp", <<"tcp">>, ipv4}, + {"/proc/net/tcp6", <<"tcp">>, ipv6}, + {"/proc/net/udp", <<"udp">>, ipv4}, + {"/proc/net/udp6", <<"udp">>, ipv6} + ], + lists:flatmap( + fun({Path, Proto, Family}) -> + parse_net_table(Path, Proto, Family, Port, SockMap) + end, + Sources + ). + +%% inode => list of {Pid, Fd, Name, Command} +socket_inode_map() -> + case file:list_dir("/proc") of + {ok, Entries} -> + lists:foldl( + fun(Entry, Acc) -> + case is_pid_name(Entry) of + false -> + Acc; + true -> + Pid = list_to_integer(Entry), + merge_pid_sockets(Pid, Acc) + end + end, + #{}, + Entries + ); + {error, _} -> + #{} + end. + +merge_pid_sockets(Pid, Acc) -> + FdDir = "/proc/" ++ integer_to_list(Pid) ++ "/fd", + case file:list_dir(FdDir) of + {ok, Fds} -> + {Name, Command} = pid_name_command(Pid), + lists:foldl( + fun(FdName, Acc1) -> + case is_pid_name(FdName) of + false -> + Acc1; + true -> + Fd = list_to_integer(FdName), + Link = FdDir ++ "/" ++ FdName, + case file:read_link(Link) of + {ok, Target} -> + case socket_inode_from_link(Target) of + {ok, Inode} -> + Owner = {Pid, Fd, Name, Command}, + Prev = maps:get(Inode, Acc1, []), + Acc1#{Inode => [Owner | Prev]}; + error -> + Acc1 + end; + {error, _} -> + Acc1 + end + end + end, + Acc, + Fds + ); + {error, _} -> + Acc + end. + +%% "socket:[12345]" or "socket:[12345]\n" +socket_inode_from_link(Target) when is_list(Target) -> + socket_inode_from_link(unicode:characters_to_binary(Target)); +socket_inode_from_link(Target) when is_binary(Target) -> + case re:run(Target, <<"^socket:\\[([0-9]+)\\]">>, [{capture, all_but_first, binary}]) of + {match, [Num]} -> + {ok, binary_to_integer(Num)}; + _ -> + error + end; +socket_inode_from_link(_) -> + error. + +pid_name_command(Pid) -> + case read_stat_fields(Pid) of + {ok, Fields} -> + Name = maps:get(comm, Fields, <<>>), + {Name, read_cmdline(Pid, Name)}; + error -> + {<<>>, <<>>} + end. + +parse_net_table(Path, Proto, Family, Port, SockMap) -> + case file:read_file(Path) of + {ok, Bin} -> + Lines = binary:split(Bin, <<"\n">>, [global]), + %% First line is the header. + case Lines of + [_Header | Rows] -> + lists:flatmap( + fun(Line) -> + parse_net_row(Line, Proto, Family, Port, SockMap) + end, + Rows + ); + [] -> + [] + end; + {error, _} -> + [] + end. + +parse_net_row(<<>>, _Proto, _Family, _Port, _SockMap) -> + []; +parse_net_row(Line, Proto, Family, Port, SockMap) -> + %% /proc/net/tcp columns (whitespace-separated after optional "sl:" index): + %% sl local_address rem_address st … uid timeout inode + Parts = [P || P <- binary:split(string:trim(Line), <<" ">>, [global]), P =/= <<>>], + case Parts of + %% drop "0:" style index + [_Sl, Local, Remote, St | Rest] when length(Rest) >= 6 -> + %% uid is 7th field after sl (index 7 in 0-based after splitting with sl), + %% inode is field 9 (0-based: parts after drop of sl: local=0 rem=1 st=2 + %% tx=3 rx=4 tr=5 tm=6 retrnsmt=7 uid=8 timeout=9 inode=10 — wait. + %% With sl kept: [sl, local, rem, st, tx_rx, tr_tm, retrnsmt, uid, timeout, inode] + %% Actually tx_queue:rx_queue is one token, tr:tm->when is one. + %% Parts after split: sl, local, rem, st, tx:rx, tr:tm, retrnsmt, uid, timeout, inode, … + case Rest of + [_TxRx, _TrTm, _Retr, UidBin, _Timeout, InodeBin | _] -> + case {parse_addr_port(Local, Family), parse_addr_port(Remote, Family)} of + {{ok, LAddr, LPort}, {ok, RAddr, RPort}} -> + case LPort =:= Port orelse RPort =:= Port of + false -> + []; + true -> + State = tcp_state_name(Proto, St), + Uid = + try + binary_to_integer(UidBin) + catch + _:_ -> + 0 + end, + Inode = + try + binary_to_integer(InodeBin) + catch + _:_ -> + 0 + end, + Owners = maps:get(Inode, SockMap, []), + case Owners of + [] -> + [ + {port_socket, Proto, family_bin(Family), LAddr, LPort, + RAddr, RPort, State, 0, <<>>, <<>>, Uid, 0} + ]; + _ -> + [ + {port_socket, Proto, family_bin(Family), LAddr, LPort, + RAddr, RPort, State, Pid, Name, Command, Uid, Fd} + || {Pid, Fd, Name, Command} <- lists:reverse(Owners) + ] + end + end; + _ -> + [] + end; + _ -> + [] + end; + _ -> + [] + end. + +family_bin(ipv4) -> <<"ipv4">>; +family_bin(ipv6) -> <<"ipv6">>. + +%% Local/remote address in /proc/net is HEXIP:HEXPORT (host byte order for port; +%% IP is little-endian 32-bit words). +parse_addr_port(Bin, Family) -> + case binary:split(Bin, <<":">>) of + [IpHex, PortHex] -> + try + Port = binary_to_integer(PortHex, 16), + Addr = decode_ip(IpHex, Family), + {ok, Addr, Port} + catch + _:_ -> + error + end; + _ -> + error + end. + +decode_ip(Hex, ipv4) -> + <> = <<(binary_to_integer(Hex, 16)):32/little>>, + iolist_to_binary(io_lib:format("~b.~b.~b.~b", [A, B, C, D])); +decode_ip(Hex, ipv6) -> + %% 32 hex chars = 4 little-endian 32-bit words → 16 network-order bytes + Int = binary_to_integer(Hex, 16), + <> = <>, + <> = <>, + <> = <>, + <> = <>, + <> = <>, + Bytes = <>, + format_ipv6(Bytes). + +%% Compact-ish IPv6 text (not full RFC 5952, but readable). +format_ipv6(<<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>>) -> + <<"::">>; +format_ipv6(<<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, A, B, C, D>>) -> + %% IPv4-mapped + iolist_to_binary(io_lib:format("::ffff:~b.~b.~b.~b", [A, B, C, D])); +format_ipv6(<>) -> + Groups = [ + (B0 bsl 8) bor B1, + (B2 bsl 8) bor B3, + (B4 bsl 8) bor B5, + (B6 bsl 8) bor B7, + (B8 bsl 8) bor B9, + (B10 bsl 8) bor B11, + (B12 bsl 8) bor B13, + (B14 bsl 8) bor B15 + ], + Parts = [iolist_to_binary(io_lib:format("~.16b", [G])) || G <- Groups], + iolist_to_binary(lists:join(<<":">>, Parts)). + +tcp_state_name(<<"udp">>, _) -> + <<"">>; +tcp_state_name(<<"tcp">>, StHex) -> + try + case binary_to_integer(StHex, 16) of + 1 -> <<"ESTABLISHED">>; + 2 -> <<"SYN_SENT">>; + 3 -> <<"SYN_RECV">>; + 4 -> <<"FIN_WAIT1">>; + 5 -> <<"FIN_WAIT2">>; + 6 -> <<"TIME_WAIT">>; + 7 -> <<"CLOSE">>; + 8 -> <<"CLOSE_WAIT">>; + 9 -> <<"LAST_ACK">>; + 10 -> <<"LISTEN">>; + 11 -> <<"CLOSING">>; + 12 -> <<"NEW_SYN_RECV">>; + N -> iolist_to_binary(io_lib:format("UNKNOWN(~b)", [N])) + end + catch + _:_ -> + StHex + end; +tcp_state_name(_, St) -> + St. diff --git a/test/gleshell_test.gleam b/test/gleshell_test.gleam index db14aab..affe050 100644 --- a/test/gleshell_test.gleam +++ b/test/gleshell_test.gleam @@ -1,3 +1,4 @@ +import gleam/int import gleam/list import gleam/option.{None, Some} import gleam/string @@ -275,6 +276,27 @@ pub fn eval_env_var_get_test() { Nil } +pub fn eval_get_dotted_path_test() { + let env = env.new() + // Nested record: get a.b + let assert eval.Continue(_, String("ada")) = + eval.eval_source(env, "echo {user: {name: \"ada\"}} | get user.name") + // Deeper path + let assert eval.Continue(_, Int(42)) = + eval.eval_source(env, "echo {a: {b: {c: 42}}} | get a.b.c") + // Mid-path list: collect nested field from each item + let assert eval.Continue(_, List([String("x"), String("y")])) = + eval.eval_source( + env, + "echo {items: [{n: \"x\"}, {n: \"y\"}]} | get items.n", + ) + // Missing path errors on records + let assert eval.Continue(_, value.Fail(msg)) = + eval.eval_source(env, "echo {a: 1} | get a.b") + let assert True = string.contains(msg, "get:") + Nil +} + pub fn eval_env_record_test() { let env = env.new() let assert eval.Continue(_, Record(fields)) = eval.eval_source(env, "$env") @@ -393,6 +415,65 @@ pub fn eval_from_json_test() { Nil } +pub fn eval_from_jwt_test() { + let env = env.new() + // Classic jwt.io sample (HS256); parse-only — signature not verified. + let token = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + + let assert eval.Continue(_, Record(fields)) = + eval.eval_source(env, "echo \"" <> token <> "\" | from jwt") + + // header + let assert Ok(Record(header_fields)) = list_find_field(fields, "header") + let assert True = list_has_field(header_fields, "alg", String("HS256")) + let assert True = list_has_field(header_fields, "typ", String("JWT")) + + // payload claims + let assert Ok(Record(payload_fields)) = list_find_field(fields, "payload") + let assert True = + list_has_field(payload_fields, "sub", String("1234567890")) + let assert True = + list_has_field(payload_fields, "name", String("John Doe")) + let assert True = list_has_field(payload_fields, "iat", Int(1_516_239_022)) + + // signature segment preserved as base64url text + let assert Ok(String(sig)) = list_find_field(fields, "signature") + let assert "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" = sig + + // Argument form + Bearer prefix + let assert eval.Continue(_, Record(_)) = + eval.eval_source(env, "from jwt \"Bearer " <> token <> "\"") + + // Nested get (pipeline and dotted path) + let assert eval.Continue(_, String("HS256")) = + eval.eval_source( + env, + "echo \"" <> token <> "\" | from jwt | get header | get alg", + ) + let assert eval.Continue(_, String("HS256")) = + eval.eval_source( + env, + "echo \"" <> token <> "\" | from jwt | get header.alg", + ) + + // Errors + let assert eval.Continue(env2, value.Fail(msg)) = + eval.eval_source(env, "echo not-a-jwt | from jwt") + let assert True = + string.contains(msg, "segments") || string.contains(msg, "base64") + let assert 1 = env2.last_exit + + let assert eval.Continue(_, value.Fail(empty_msg)) = + eval.eval_source(env, "echo \"\" | from jwt") + let assert True = string.contains(empty_msg, "empty") + + let assert eval.Continue(_, String(help_out)) = + eval.eval_source(env, "help from") + let assert True = string.contains(help_out, "jwt") + Nil +} + pub fn eval_to_json_pretty_test() { let env = env.new() // `to` command + `json` subcommand — pretty by default @@ -487,6 +568,123 @@ pub fn eval_ps_test() { Nil } +pub fn eval_whyport_test() { + let env = env.new() + // Default: short listener columns only (no command / remote spam). + let assert eval.Continue(_, Table(cols, _)) = + eval.eval_source(env, "whyport 1") + let assert ["protocol", "local_address", "local_port", "pid", "name"] = cols + + // --all adds peer/state; --long adds command (and more). + let assert eval.Continue(_, Table(all_cols, _)) = + eval.eval_source(env, "whyport --all 1") + let assert True = list.contains(all_cols, "state") + let assert True = list.contains(all_cols, "remote_port") + let assert False = list.contains(all_cols, "command") + + let assert eval.Continue(_, Table(long_cols, _)) = + eval.eval_source(env, "whyport --long 1") + let assert True = list.contains(long_cols, "command") + let assert True = list.contains(long_cols, "family") + let assert False = list.contains(long_cols, "state") + + // Port with nothing listening: empty table with schema. + let assert eval.Continue(_, Table(_, unused_rows)) = + eval.eval_source(env, "whyport 1") + let assert [] = unused_rows + + // epmd listens on 4369 when present — default is listeners only. + let assert eval.Continue(_, Table(_, epmd_rows)) = + eval.eval_source(env, "whyport 4369") + case epmd_rows { + [] -> Nil + _ -> { + // Every default row should be a listener (local_port 4369). + let assert eval.Continue(_, Int(n)) = + eval.eval_source(env, "whyport 4369 | length") + let assert True = n >= 1 + // --all should not be smaller than listeners. + let assert eval.Continue(_, Int(all_n)) = + eval.eval_source(env, "whyport --all 4369 | length") + let assert True = all_n >= n + Nil + } + } + + // Leading colon + pipeline + flag that steals the port value + let assert eval.Continue(_, Table(_, _)) = + eval.eval_source(env, "whyport :22") + let assert eval.Continue(_, Table(_, _)) = + eval.eval_source(env, "echo 22 | whyport") + let assert eval.Continue(_, Table(_, _)) = + eval.eval_source(env, "whyport --all 22") + + // Errors + let assert eval.Continue(_, value.Fail(missing)) = + eval.eval_source(env, "whyport") + let assert True = string.contains(missing, "port") + let assert eval.Continue(_, value.Fail(bad)) = + eval.eval_source(env, "whyport notaport") + let assert True = string.contains(bad, "invalid") || string.contains(bad, "port") + let assert eval.Continue(_, value.Fail(range)) = + eval.eval_source(env, "whyport 99999") + let assert True = string.contains(range, "range") + + let assert eval.Continue(_, String("builtin: whyport")) = + eval.eval_source(env, "which whyport") + let assert eval.Continue(_, String(help_out)) = + eval.eval_source(env, "help whyport") + let assert True = string.contains(help_out, "--all") + let assert True = string.contains(help_out, "--long") + Nil +} + +pub fn eval_now_test() { + let env = env.new() + // Data is Unix epoch seconds (same as ls modified); near wall clock. + let assert eval.Continue(_, Int(secs)) = eval.eval_source(env, "now") + let wall = sys.unix_now() + let assert True = secs > 1_700_000_000 + let assert True = secs <= wall + 2 && secs >= wall - 5 + + // typeof is int + let assert eval.Continue(_, String("int")) = + eval.eval_source(env, "now | typeof") + + // Display formats bare epoch ints like ls modified (local 12-hour). + let text = display.render_with(False, Int(secs)) + let assert False = string.contains(text, int.to_string(secs)) + let assert True = string.contains(text, ":") + let assert True = + string.contains(text, " AM") || string.contains(text, " PM") + + // Small ints still print as numbers + let assert True = string.contains(display.render_with(False, Int(42)), "42") + + let assert eval.Continue(_, String("builtin: now")) = + eval.eval_source(env, "which now") + let assert eval.Continue(_, String(help_out)) = + eval.eval_source(env, "help now") + let assert True = string.contains(help_out, "epoch") + Nil +} + +pub fn eval_input_help_test() { + let env = env.new() + let assert eval.Continue(_, String("builtin: input")) = + eval.eval_source(env, "which input") + let assert eval.Continue(_, String(help_out)) = + eval.eval_source(env, "help input") + let assert True = string.contains(help_out, "Ctrl+D") + let assert True = string.contains(help_out, "from json") + // Too many args without reading stdin + let assert eval.Continue(_, value.Fail(msg)) = + eval.eval_source(env, "input too many args") + let assert True = string.contains(msg, "prompt") + Nil +} + + pub fn http_get_live_test() { // Live request against postman-echo (JSON). Skip gracefully if offline. let env = env.new()