A structured-data shell in Gleam, inspired by Nushell
0

Configure Feed

Select the types of activity you want to include in your feed.

Add whyport, now, input, from jwt, and dotted get paths.

Port listeners (`whyport`), epoch time (`now`), multi-line paste (`input`),
JWT parse-only decode (`from jwt`), and `get a.b` cell paths make common
inspection workflows usable without leaving the shell. Document the new
commands in the README examples and language sketch.

author
nandi
date (Jul 26, 2026, 6:45 AM -0700) commit ea3e054f parent e41d2d90 change-id xtrospmt
+1226 -54
+22 -1
README.md
··· 101 101 echo {name: "gleshell", cool: true} 102 102 echo "{\"a\": 1}" | from json | get a 103 103 open data.json | get users | first 104 + echo "{\"user\": {\"name\": \"ada\"}}" | from json | get user.name 104 105 range 3 | to json 106 + 107 + # JWT (parse only — does not verify signature) 108 + echo $token | from jwt | get payload 109 + echo $token | from jwt | get header.alg 110 + from jwt eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.… 111 + 112 + # paste multi-line text into a pipeline (finish with Ctrl+D) 113 + input | from json 114 + input "Paste notes:" | lines | find TODO 115 + # or pipe data into a one-shot: printf '{"a":1}' | gle -c 'input | from json' 105 116 106 117 # variables 107 118 let n = range 3 | length ··· 123 134 ps 124 135 ps | sort-by mem | last 5 125 136 ps --long | where name == beam.smp 137 + 138 + # who is bound to a port (listeners by default; --all ≈ lsof -i) 139 + whyport 22 140 + whyport 4004 141 + whyport --all 4004 142 + 143 + # current time (Unix epoch seconds; prints like ls modified) 144 + now 126 145 ``` 127 146 128 147 ## Language sketch ··· 149 168 150 169 Table/list: `where`/`filter`, `find`, `select`, `get`, `first`, `last`, `take`, `skip`, `sort-by`, `reverse`, `length`, `columns`, `table`, `flatten`, `uniq`, `wrap`, `unwrap`, `keys`, `values` 151 170 152 - Data: `echo`, `range`, `lines`, `to`/`from` (subcommand `json`), `type`, `describe`, `env`, `sys`, `which`, `help`, `about`, `exit` 171 + Data: `echo`, `range`, `lines`, `input` (multi-line paste / stdin until Ctrl+D), 172 + `to`/`from` (subcommands `json`, `jwt`), `type`, `describe`, `env`, `sys`, `ps`, 173 + `whyport`, `now`, `which`, `help`, `about`, `exit` 153 174 154 175 HTTP: `http get|post|put|delete|patch|head` — fetch/send with structured JSON bodies 155 176 and responses (`http get https://example.com`, `http post URL {a: 1}`, `--full`,
+368 -47
src/gleshell/builtins.gleam
··· 77 77 #("quit", cmd_exit), 78 78 #("ignore", cmd_ignore), 79 79 #("identity", cmd_identity), 80 + #("input", cmd_input), 80 81 #("range", cmd_range), 81 82 #("append", cmd_append), 82 83 #("prepend", cmd_prepend), ··· 89 90 #("keys", cmd_keys), 90 91 #("sys", cmd_sys), 91 92 #("ps", cmd_ps), 93 + #("whyport", cmd_whyport), 94 + #("now", cmd_now), 92 95 #("about", cmd_about), 93 96 #("less", cmd_less), 94 97 ]) ··· 197 200 "", 198 201 "Subcommands:", 199 202 " json — parse a JSON string (pipeline input or a string argument)", 203 + " jwt — decode a JWT (JWS compact) into header/payload/signature", 204 + "", 205 + "JWT notes: does not verify the signature; only base64url-decodes and", 206 + "parses the JSON header and claims. Optional \"Bearer \" prefix is", 207 + "stripped. Signature is returned as the original base64url segment.", 200 208 "", 201 209 "Examples:", 202 210 " open data.json | from json", 203 211 " echo '{\"a\": 1}' | from json | get a", 212 + " echo $token | from jwt | get payload", 213 + " echo $token | from jwt | get header.alg", 214 + " from jwt eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…", 204 215 ], 205 216 "\n", 206 217 )) ··· 298 309 ], 299 310 "\n", 300 311 )) 312 + "whyport" -> 313 + Ok(string.join( 314 + [ 315 + "whyport [-a|--all] [-l|--long] <port> — who is bound to a port", 316 + "", 317 + "Answers “why is this port taken?” Default: local listeners only", 318 + "(TCP LISTEN + UDP binds), short columns — not every ESTABLISHED", 319 + "client or TIME_WAIT row, and not full command lines.", 320 + "", 321 + "Flags:", 322 + " -a, --all all sockets touching the port (local or remote),", 323 + " like `lsof -i :<port>` (adds state + remote cols)", 324 + " -l, --long extra columns: family, command, user_id, fd", 325 + "", 326 + "Default columns: protocol, local_address, local_port, pid, name", 327 + "With --all: + remote_address, remote_port, state", 328 + "With --long: + family, command, user_id, fd", 329 + "", 330 + "Accepts the port as an argument or pipeline input. Leading `:` is", 331 + "optional (`whyport 8080` and `whyport :8080` are the same).", 332 + "", 333 + "Examples:", 334 + " whyport 22", 335 + " whyport 4004", 336 + " whyport --all 4004", 337 + " whyport -al 8080", 338 + " echo 3000 | whyport", 339 + ], 340 + "\n", 341 + )) 342 + "now" -> 343 + Ok(string.join( 344 + [ 345 + "now — current Unix time (epoch seconds)", 346 + "", 347 + "Inspired by Nushell `date now`. Returns an int of UTC epoch seconds,", 348 + "the same representation as `ls` modified / `ps` start_time. Display", 349 + "formats it as a local 12-hour datetime (e.g. Jul 26 2026 3:17:35 AM).", 350 + "", 351 + "Examples:", 352 + " now", 353 + " let t = now", 354 + " ls | where modified > 1700000000", 355 + ], 356 + "\n", 357 + )) 358 + "input" -> 359 + Ok(string.join( 360 + [ 361 + "input [prompt] — read multi-line text from the terminal (or stdin)", 362 + "", 363 + "Type or paste freely; finish with Ctrl+D (EOF). The collected text is", 364 + "a string you can pipe into anything — `from json`, `lines`, `save`, …", 365 + "", 366 + "Interactive: after Enter on the command line, paste content, then Ctrl+D.", 367 + "Piped: `printf '…' | gle -c 'input | from json'` drains stdin.", 368 + "", 369 + "Optional prompt string is printed before reading (on its own line).", 370 + "Ctrl+C cancels.", 371 + "", 372 + "Examples:", 373 + " input | from json", 374 + " input | lines | find TODO", 375 + " input \"Paste JWT:\" | from jwt | get payload", 376 + " input | save notes.txt", 377 + " let body = input", 378 + ], 379 + "\n", 380 + )) 301 381 _ -> help_line(name) 302 382 } 303 383 } ··· 329 409 "find [-i] [-v] [--regex pat] <term>… — search list/table/string input for terms", 330 410 ), 331 411 #("select", "select <col>… — keep only named columns"), 332 - #("get", "get <field|index> — get a field or list index"), 412 + #("get", "get <field|path|index> — get a field, dotted path (a.b), or list index"), 333 413 #("first", "first [n] — first row/item (default 1)"), 334 414 #("last", "last [n] — last row/item"), 335 415 #("take", "take <n> — take first n items"), ··· 348 428 ), 349 429 #( 350 430 "from", 351 - "from <format> — parse structured input (subcommands: json)", 431 + "from <format> — parse structured input (subcommands: json, jwt)", 352 432 ), 353 433 #( 354 434 "http", ··· 373 453 #("quit", "quit [code] — alias for exit"), 374 454 #("ignore", "ignore — discard pipeline input; emit nothing"), 375 455 #("identity", "identity — pass pipeline input through unchanged"), 456 + #( 457 + "input", 458 + "input [prompt] — read multi-line text until Ctrl+D (pipe into next stage)", 459 + ), 376 460 #("range", "range <end> | range <start> <end> — integer range list"), 377 461 #("append", "append <values>… — append values to list input"), 378 462 #("prepend", "prepend <values>… — prepend values to list input"), ··· 391 475 "ps", 392 476 "ps [-l|--long] — system processes table (pid, name, cpu, mem, …)", 393 477 ), 478 + #( 479 + "whyport", 480 + "whyport [-a|--all] [-l|--long] <port> — who is bound to a TCP/UDP port", 481 + ), 482 + #("now", "now — current time as Unix epoch seconds (prints as local datetime)"), 394 483 #("about", "about — authorship, ATProto handle, and a little sparkle"), 395 484 #( 396 485 "less", ··· 1065 1154 } 1066 1155 } 1067 1156 [String(key)] -> 1068 - case input { 1069 - Record(_) -> 1070 - case value.get_field(input, key) { 1157 + case value.parse_cell_path(key) { 1158 + Error(e) -> err(env, "get: " <> e) 1159 + Ok(path) -> 1160 + case value.get_path(input, path) { 1071 1161 Ok(v) -> ok(env, v) 1072 1162 Error(e) -> err(env, "get: " <> e) 1073 1163 } 1074 - Table(cols, rows) -> 1075 - case list_index_of(cols, key) { 1076 - Ok(idx) -> { 1077 - let col_vals = 1078 - list.map(rows, fn(row) { 1079 - case list_at(row, idx) { 1080 - Ok(v) -> v 1081 - Error(Nil) -> Nothing 1082 - } 1083 - }) 1084 - ok(env, List(col_vals)) 1085 - } 1086 - Error(Nil) -> err(env, "get: no column '" <> key <> "'") 1087 - } 1088 - List(items) -> { 1089 - let got = 1090 - list.filter_map(items, fn(item) { 1091 - case value.get_field(item, key) { 1092 - Ok(v) -> Ok(v) 1093 - Error(_) -> Error(Nil) 1094 - } 1095 - }) 1096 - ok(env, List(got)) 1097 - } 1098 - _ -> err(env, "get: unsupported input type " <> value.type_name(input)) 1099 1164 } 1100 - _ -> err(env, "get: expected field name or index") 1165 + _ -> err(env, "get: expected field name, dotted path, or index") 1101 1166 } 1102 1167 } 1103 1168 ··· 1345 1410 ) -> BuiltinResult { 1346 1411 case args { 1347 1412 [String("json"), ..rest] -> cmd_from_json(env, input, rest, flags) 1413 + [String("jwt"), ..rest] -> cmd_from_jwt(env, input, rest, flags) 1348 1414 [] -> 1349 - err(env, "from: expected subcommand (try `from json`; see `help from`)") 1415 + err( 1416 + env, 1417 + "from: expected subcommand (try `from json` or `from jwt`; see `help from`)", 1418 + ) 1350 1419 [String(sub), ..] -> err(env, "from: unknown subcommand: " <> sub) 1351 1420 _ -> err(env, "from: expected subcommand name") 1352 1421 } ··· 1406 1475 } 1407 1476 } 1408 1477 1478 + /// Decode a JWT (JWS compact serialization) into a structured record. 1479 + /// Does **not** verify the cryptographic signature — parse-only, like `jwt decode`. 1480 + fn cmd_from_jwt( 1481 + env: Env, 1482 + input: Value, 1483 + args: List(Value), 1484 + _flags: dict.Dict(String, Value), 1485 + ) -> BuiltinResult { 1486 + let source = case args { 1487 + [String(s)] -> s 1488 + [other] -> value.as_string(other) 1489 + [] -> 1490 + case input { 1491 + String(s) -> s 1492 + Nothing -> "" 1493 + other -> value.as_string(other) 1494 + } 1495 + _ -> "" 1496 + } 1497 + case string.trim(source) { 1498 + "" -> err(env, "from: jwt: empty input") 1499 + s -> 1500 + case parse_jwt(s) { 1501 + Ok(v) -> ok(env, v) 1502 + Error(msg) -> err(env, "from: jwt: " <> msg) 1503 + } 1504 + } 1505 + } 1506 + 1507 + /// Parse a compact JWT into `{ header, payload, signature }`. 1508 + /// Signature is the original base64url segment (not decoded to binary). 1509 + fn parse_jwt(token: String) -> Result(Value, String) { 1510 + let cleaned = strip_bearer_prefix(string.trim(token)) 1511 + case string.split(cleaned, ".") { 1512 + [header_b64, payload_b64, signature_b64] -> { 1513 + case decode_jwt_json_part(header_b64, "header") { 1514 + Error(e) -> Error(e) 1515 + Ok(header) -> 1516 + case decode_jwt_json_part(payload_b64, "payload") { 1517 + Error(e) -> Error(e) 1518 + Ok(payload) -> 1519 + Ok( 1520 + Record([ 1521 + #("header", header), 1522 + #("payload", payload), 1523 + #("signature", String(signature_b64)), 1524 + ]), 1525 + ) 1526 + } 1527 + } 1528 + } 1529 + parts -> 1530 + Error( 1531 + "expected 3 dot-separated segments (header.payload.signature), got " 1532 + <> int.to_string(list.length(parts)), 1533 + ) 1534 + } 1535 + } 1536 + 1537 + fn strip_bearer_prefix(s: String) -> String { 1538 + case string.starts_with(string.lowercase(s), "bearer ") { 1539 + True -> string.trim(string.drop_start(s, 7)) 1540 + False -> s 1541 + } 1542 + } 1543 + 1544 + fn decode_jwt_json_part(segment: String, part_name: String) -> Result(Value, String) { 1545 + case segment { 1546 + "" -> Error(part_name <> ": empty segment") 1547 + b64 -> 1548 + case bit_array.base64_url_decode(b64) { 1549 + Error(Nil) -> Error(part_name <> ": invalid base64url") 1550 + Ok(bits) -> 1551 + case bit_array.to_string(bits) { 1552 + Error(Nil) -> Error(part_name <> ": not valid UTF-8 after decode") 1553 + Ok(json_text) -> 1554 + case parse_json_value(json_text) { 1555 + Ok(v) -> Ok(v) 1556 + Error(msg) -> Error(part_name <> ": JSON: " <> msg) 1557 + } 1558 + } 1559 + } 1560 + } 1561 + } 1562 + 1409 1563 // --- http (Nushell-style HTTP client with method subcommands) --- 1410 1564 1411 1565 fn http_help_text() -> String { ··· 2184 2338 ok(env, input) 2185 2339 } 2186 2340 2341 + // --- input (multi-line paste / stdin → string for the pipeline) --- 2342 + 2343 + fn cmd_input( 2344 + env: Env, 2345 + _input: Value, 2346 + args: List(Value), 2347 + _flags: dict.Dict(String, Value), 2348 + ) -> BuiltinResult { 2349 + let prompt = case args { 2350 + [] -> "" 2351 + [String(s)] -> s 2352 + [other] -> value.as_string(other) 2353 + _ -> "" 2354 + } 2355 + case args { 2356 + [_, _, ..] -> 2357 + err(env, "input: expected at most one prompt string (see `help input`)") 2358 + _ -> 2359 + case sys.read_user_input(prompt) { 2360 + Ok(text) -> ok(env, String(text)) 2361 + Error("interrupted") -> 2362 + err(env, "input: interrupted") 2363 + Error("eof") -> ok(env, String("")) 2364 + Error(msg) -> err(env, "input: " <> msg) 2365 + } 2366 + } 2367 + } 2368 + 2187 2369 fn cmd_range( 2188 2370 env: Env, 2189 2371 _input: Value, ··· 2355 2537 ) 2356 2538 } 2357 2539 2540 + // --- now (Nushell-style current datetime) --- 2541 + 2542 + fn cmd_now( 2543 + env: Env, 2544 + _input: Value, 2545 + _args: List(Value), 2546 + _flags: dict.Dict(String, Value), 2547 + ) -> BuiltinResult { 2548 + // Same representation as `ls` modified / `ps` start_time: raw epoch seconds. 2549 + // Display formats bare epoch ints as local datetimes (see display.render). 2550 + ok(env, Int(sys.unix_now())) 2551 + } 2552 + 2358 2553 // --- ps (Nushell-style process table) --- 2359 2554 2360 2555 fn cmd_ps( ··· 2369 2564 ok(env, value.table_from_records(records)) 2370 2565 } 2371 2566 2567 + // --- whyport (who owns a port; --all ≈ `lsof -i :<port>`) --- 2568 + 2569 + fn cmd_whyport( 2570 + env: Env, 2571 + input: Value, 2572 + args: List(Value), 2573 + flags: dict.Dict(String, Value), 2574 + ) -> BuiltinResult { 2575 + let #(all, stolen_a) = find_bool_flag(flags, ["a", "all"]) 2576 + let #(long, stolen_l) = find_bool_flag(flags, ["l", "long"]) 2577 + // Bool flags may steal the port (`whyport --all 4004` → flag value "4004"). 2578 + let port_args = list.flatten([args, stolen_a, stolen_l]) 2579 + case resolve_port_number(input, port_args) { 2580 + Error(msg) -> err(env, msg) 2581 + Ok(port) -> { 2582 + let sockets = 2583 + sys.list_port_sockets(port) 2584 + |> list.filter(fn(s) { whyport_keep_socket(s, port, all) }) 2585 + let records = 2586 + list.map(sockets, fn(s) { port_socket_to_record(s, all, long) }) 2587 + ok(env, whyport_table(records, all, long)) 2588 + } 2589 + } 2590 + } 2591 + 2592 + /// Default: local listeners only. `--all`: any socket touching the port. 2593 + fn whyport_keep_socket(s: sys.PortSocket, port: Int, all: Bool) -> Bool { 2594 + case all { 2595 + True -> True 2596 + False -> 2597 + // Bound here: local port matches, and either TCP LISTEN or a UDP bind 2598 + // (UDP has no LISTEN state; unbound peer means "listening" socket). 2599 + s.local_port == port 2600 + && case s.protocol { 2601 + "tcp" -> s.state == "LISTEN" 2602 + "udp" -> s.remote_port == 0 2603 + _ -> s.state == "LISTEN" || s.remote_port == 0 2604 + } 2605 + } 2606 + } 2607 + 2608 + fn whyport_columns(all: Bool, long: Bool) -> List(String) { 2609 + let base = ["protocol", "local_address", "local_port", "pid", "name"] 2610 + let with_peers = case all { 2611 + False -> base 2612 + True -> 2613 + list.append(base, ["remote_address", "remote_port", "state"]) 2614 + } 2615 + case long { 2616 + False -> with_peers 2617 + True -> 2618 + list.append(with_peers, ["family", "command", "user_id", "fd"]) 2619 + } 2620 + } 2621 + 2622 + fn whyport_table(records: List(Value), all: Bool, long: Bool) -> Value { 2623 + case records { 2624 + [] -> Table(whyport_columns(all, long), []) 2625 + _ -> value.table_from_records(records) 2626 + } 2627 + } 2628 + 2629 + /// Port from arg (`whyport 8080`, `whyport :8080`) or pipeline (`echo 8080 | whyport`). 2630 + fn resolve_port_number( 2631 + input: Value, 2632 + args: List(Value), 2633 + ) -> Result(Int, String) { 2634 + case args { 2635 + [raw] -> parse_port_value(raw) 2636 + [] -> 2637 + case input { 2638 + Nothing -> 2639 + Error( 2640 + "whyport: expected port number (e.g. `whyport 8080`; see `help whyport`)", 2641 + ) 2642 + other -> parse_port_value(other) 2643 + } 2644 + _ -> Error("whyport: expected a single port number") 2645 + } 2646 + } 2647 + 2648 + fn parse_port_value(v: Value) -> Result(Int, String) { 2649 + case v { 2650 + Int(n) -> validate_port(n) 2651 + String(s) -> { 2652 + let cleaned = string.trim(s) 2653 + let without_colon = case string.starts_with(cleaned, ":") { 2654 + True -> string.drop_start(cleaned, 1) 2655 + False -> cleaned 2656 + } 2657 + case int.parse(string.trim(without_colon)) { 2658 + Ok(n) -> validate_port(n) 2659 + Error(Nil) -> Error("whyport: invalid port: " <> cleaned) 2660 + } 2661 + } 2662 + other -> 2663 + Error("whyport: expected port number, got " <> value.type_name(other)) 2664 + } 2665 + } 2666 + 2667 + fn validate_port(n: Int) -> Result(Int, String) { 2668 + case n >= 0 && n <= 65_535 { 2669 + True -> Ok(n) 2670 + False -> 2671 + Error( 2672 + "whyport: port out of range (0–65535): " <> int.to_string(n), 2673 + ) 2674 + } 2675 + } 2676 + 2677 + fn port_socket_to_record(s: sys.PortSocket, all: Bool, long: Bool) -> Value { 2678 + let base = [ 2679 + #("protocol", String(s.protocol)), 2680 + #("local_address", String(s.local_address)), 2681 + #("local_port", Int(s.local_port)), 2682 + #("pid", Int(s.pid)), 2683 + #("name", String(s.name)), 2684 + ] 2685 + let with_peers = case all { 2686 + False -> base 2687 + True -> 2688 + list.append(base, [ 2689 + #("remote_address", String(s.remote_address)), 2690 + #("remote_port", Int(s.remote_port)), 2691 + #("state", String(s.state)), 2692 + ]) 2693 + } 2694 + case long { 2695 + False -> Record(with_peers) 2696 + True -> 2697 + Record( 2698 + list.append(with_peers, [ 2699 + #("family", String(s.family)), 2700 + #("command", String(s.command)), 2701 + #("user_id", Int(s.user_id)), 2702 + #("fd", Int(s.fd)), 2703 + ]), 2704 + ) 2705 + } 2706 + } 2707 + 2372 2708 fn process_to_record(p: sys.ProcessInfo, long: Bool) -> Value { 2373 2709 let base = [ 2374 2710 #("pid", Int(p.pid)), ··· 2509 2845 _, _ -> Error(Nil) 2510 2846 } 2511 2847 } 2512 - 2513 - fn list_index_of(items: List(a), target: a) -> Result(Int, Nil) { 2514 - list_index_of_loop(items, target, 0) 2515 - } 2516 - 2517 - fn list_index_of_loop(items: List(a), target: a, i: Int) -> Result(Int, Nil) { 2518 - case items { 2519 - [] -> Error(Nil) 2520 - [x, ..rest] -> 2521 - case x == target { 2522 - True -> Ok(i) 2523 - False -> list_index_of_loop(rest, target, i + 1) 2524 - } 2525 - } 2526 - }
+18
src/gleshell/display.gleam
··· 33 33 } 34 34 } 35 35 Record(fields) -> render_record(on, fields) 36 + // Bare epoch seconds (e.g. `now`, `ls | get modified` on one row): print 37 + // like the `modified` column — local 12-hour datetime, not a raw integer. 38 + Int(n) -> render_bare_int(on, n) 36 39 other -> color_cell(on, "", other, value.cell_string(other)) 37 40 } 41 + } 42 + 43 + /// Root-level ints that look like Unix timestamps display as local datetimes 44 + /// (same form as `ls` modified). Small ints and non-epoch values stay numeric. 45 + fn render_bare_int(on: Bool, n: Int) -> String { 46 + case is_epoch_seconds(n) { 47 + True -> color.datetime(on, format_datetime(n)) 48 + False -> color.int_(on, int.to_string(n)) 49 + } 50 + } 51 + 52 + /// Plausible wall-clock epoch seconds (2001-09-09 .. 2100-01-01). 53 + /// Used only for bare-value display so `now` and extracted mtimes read as times. 54 + fn is_epoch_seconds(n: Int) -> Bool { 55 + n >= 1_000_000_000 && n < 4_102_444_800 38 56 } 39 57 40 58 /// External programs often embed their own colors. Pass those through.
+34
src/gleshell/sys.gleam
··· 3 3 @external(erlang, "gleshell_ffi", "get_line") 4 4 pub fn get_line(prompt: String) -> Result(String, String) 5 5 6 + /// Multi-line user input for the `input` builtin. 7 + /// Reads until Ctrl+D / EOF (interactive paste) or drains piped stdin. 8 + /// Optional `prompt` is printed first; use `""` for silent. 9 + @external(erlang, "gleshell_ffi", "read_user_input") 10 + pub fn read_user_input(prompt: String) -> Result(String, String) 11 + 6 12 /// Print a line to stdout. In raw TTY REPL mode, newlines become CRLF so 7 13 /// multi-line values (tables, pretty JSON) do not staircase. 8 14 @external(erlang, "gleshell_ffi", "println") ··· 129 135 @external(erlang, "gleshell_ffi", "format_unix_local") 130 136 pub fn format_unix_local(seconds: Int) -> String 131 137 138 + /// Current Unix epoch seconds (UTC wall clock). 139 + @external(erlang, "gleshell_ffi", "unix_now") 140 + pub fn unix_now() -> Int 141 + 132 142 /// One process row from `list_processes` (Nushell `ps` columns). 133 143 /// Memory fields are bytes; `start_time` is Unix epoch seconds (0 if unknown). 134 144 pub type ProcessInfo { ··· 157 167 /// Samples CPU over ~100ms like Nushell `ps`. 158 168 @external(erlang, "gleshell_ffi", "list_processes") 159 169 pub fn list_processes() -> List(ProcessInfo) 170 + 171 + /// One socket row from `list_port_sockets` (like `lsof -i :<port>`). 172 + /// `pid`/`fd` are 0 when the owner is unknown; `state` is empty for UDP. 173 + pub type PortSocket { 174 + PortSocket( 175 + protocol: String, 176 + family: String, 177 + local_address: String, 178 + local_port: Int, 179 + remote_address: String, 180 + remote_port: Int, 181 + state: String, 182 + pid: Int, 183 + name: String, 184 + command: String, 185 + user_id: Int, 186 + fd: Int, 187 + ) 188 + } 189 + 190 + /// Sockets whose local or remote port is `port` (Linux `/proc/net` + fd inodes). 191 + /// Empty list on unsupported OS or when nothing matches. 192 + @external(erlang, "gleshell_ffi", "list_port_sockets") 193 + pub fn list_port_sockets(port: Int) -> List(PortSocket)
+133
src/gleshell/value.gleam
··· 135 135 } 136 136 } 137 137 138 + /// Split a dotted cell path (`"foo.bar"` → `["foo", "bar"]`). 139 + /// Empty segments (e.g. `"a..b"`) are rejected. 140 + pub fn parse_cell_path(path: String) -> Result(List(String), String) { 141 + case path { 142 + "" -> Error("empty path") 143 + _ -> { 144 + let parts = string.split(path, ".") 145 + case list.any(parts, fn(p) { p == "" }) { 146 + True -> Error("invalid path '" <> path <> "' (empty segment)") 147 + False -> Ok(parts) 148 + } 149 + } 150 + } 151 + } 152 + 153 + /// Follow a Nushell-style cell path through records, lists, and tables. 154 + /// Dots nest: `{a: {b: 1}} | get a.b` → `1`. 155 + /// When a list/table is encountered mid-path, the rest of the path is applied 156 + /// to each item (missing fields are skipped, matching plain `get` on lists). 157 + pub fn get_path(value: Value, path: List(String)) -> Result(Value, String) { 158 + case path { 159 + [] -> Ok(value) 160 + [key, ..rest] -> 161 + case get_one(value, key) { 162 + Error(e) -> Error(e) 163 + Ok(next) -> 164 + case rest { 165 + [] -> Ok(next) 166 + _ -> 167 + case next { 168 + List(items) -> 169 + Ok( 170 + List( 171 + list.filter_map(items, fn(item) { 172 + case get_path(item, rest) { 173 + Ok(v) -> Ok(v) 174 + Error(_) -> Error(Nil) 175 + } 176 + }), 177 + ), 178 + ) 179 + Table(_, _) -> 180 + case table_to_records(next) { 181 + Error(e) -> Error(e) 182 + Ok(rows) -> 183 + Ok( 184 + List( 185 + list.filter_map(rows, fn(row) { 186 + case get_path(row, rest) { 187 + Ok(v) -> Ok(v) 188 + Error(_) -> Error(Nil) 189 + } 190 + }), 191 + ), 192 + ) 193 + } 194 + _ -> get_path(next, rest) 195 + } 196 + } 197 + } 198 + } 199 + } 200 + 201 + /// One path segment: field on a record, column on a table, or map over a list. 202 + fn get_one(value: Value, key: String) -> Result(Value, String) { 203 + case value { 204 + Record(_) -> get_field(value, key) 205 + Table(cols, rows) -> 206 + case list_index_of(cols, key) { 207 + Ok(idx) -> { 208 + let col_vals = 209 + list.map(rows, fn(row) { 210 + case list_at(row, idx) { 211 + Ok(v) -> v 212 + Error(Nil) -> Nothing 213 + } 214 + }) 215 + Ok(List(col_vals)) 216 + } 217 + Error(Nil) -> Error("no column '" <> key <> "'") 218 + } 219 + List(items) -> 220 + Ok( 221 + List( 222 + list.filter_map(items, fn(item) { 223 + case get_field(item, key) { 224 + Ok(v) -> Ok(v) 225 + Error(_) -> Error(Nil) 226 + } 227 + }), 228 + ), 229 + ) 230 + other -> Error("cannot get '" <> key <> "' from " <> type_name(other)) 231 + } 232 + } 233 + 234 + fn list_index_of(items: List(String), target: String) -> Result(Int, Nil) { 235 + list_index_of_loop(items, target, 0) 236 + } 237 + 238 + fn list_index_of_loop( 239 + items: List(String), 240 + target: String, 241 + i: Int, 242 + ) -> Result(Int, Nil) { 243 + case items { 244 + [] -> Error(Nil) 245 + [x, ..rest] -> 246 + case x == target { 247 + True -> Ok(i) 248 + False -> list_index_of_loop(rest, target, i + 1) 249 + } 250 + } 251 + } 252 + 253 + fn list_at(items: List(Value), index: Int) -> Result(Value, Nil) { 254 + case index < 0 { 255 + True -> Error(Nil) 256 + False -> list_at_loop(items, index) 257 + } 258 + } 259 + 260 + fn list_at_loop(items: List(Value), index: Int) -> Result(Value, Nil) { 261 + case items { 262 + [] -> Error(Nil) 263 + [x, ..rest] -> 264 + case index { 265 + 0 -> Ok(x) 266 + _ -> list_at_loop(rest, index - 1) 267 + } 268 + } 269 + } 270 + 138 271 pub fn record_from_pairs(pairs: List(#(String, Value))) -> Value { 139 272 Record(pairs) 140 273 }
+453 -6
src/gleshell_ffi.erl
··· 3 3 -include_lib("kernel/include/file.hrl"). 4 4 -export([ 5 5 get_line/1, 6 + read_user_input/1, 6 7 parse_line/2, 7 8 run_as_shell/1, 8 9 spawn_shell/2, ··· 30 31 history_search/2, 31 32 re_contains/3, 32 33 format_unix_local/1, 33 - list_processes/0 34 + unix_now/0, 35 + list_processes/0, 36 + list_port_sockets/1 34 37 ]). 35 38 36 39 -define(ESC, 16#1b). ··· 257 260 {done, eof, []}; 258 261 parse_line(_Cont, Chars) when is_list(Chars) -> 259 262 {done, Chars, []}. 263 + 264 + %% --------------------------------------------------------------------------- 265 + %% Public: multi-line user input for the `input` builtin 266 + %% 267 + %% Interactive (raw REPL or TTY): read until Ctrl+D / EOF so the user can 268 + %% paste a blob and end with Ctrl+D, e.g. `input | from json`. 269 + %% Non-TTY (piped stdin): read the whole stream — `printf '…' | gle -c 'input | …'`. 270 + %% Optional prompt is printed first (empty prompt = silent). 271 + %% --------------------------------------------------------------------------- 272 + 273 + -spec read_user_input(binary()) -> {ok, binary()} | {error, binary()}. 274 + read_user_input(Prompt) when is_binary(Prompt) -> 275 + case Prompt of 276 + <<>> -> 277 + ok; 278 + _ -> 279 + %% Prompt on its own line so paste starts cleanly below it. 280 + case get(gleshell_raw) of 281 + true -> 282 + io:put_chars(to_crlf(<<Prompt/binary, "\n">>)); 283 + _ -> 284 + io:put_chars(<<Prompt/binary, "\n">>) 285 + end 286 + end, 287 + try 288 + case get(gleshell_raw) of 289 + true -> 290 + read_input_raw([]); 291 + _ -> 292 + case stdin_isatty() of 293 + true -> 294 + read_input_lines([]); 295 + false -> 296 + read_input_stream([]) 297 + end 298 + end 299 + catch 300 + _:Reason -> 301 + {error, reason_to_bin(Reason)} 302 + end. 303 + 304 + %% Raw-mode multi-line: echo printable chars, Enter → newline, Ctrl+D ends. 305 + read_input_raw(Acc) -> 306 + case read_key() of 307 + eof -> 308 + io:put_chars("\r\n"), 309 + {ok, codepoints_to_bin(lists:reverse(Acc))}; 310 + ctrl_d -> 311 + io:put_chars("\r\n"), 312 + {ok, codepoints_to_bin(lists:reverse(Acc))}; 313 + ctrl_c -> 314 + io:put_chars("^C\r\n"), 315 + {error, <<"interrupted">>}; 316 + enter -> 317 + io:put_chars("\r\n"), 318 + read_input_raw([$\n | Acc]); 319 + backspace -> 320 + case Acc of 321 + [] -> 322 + read_input_raw(Acc); 323 + [$\n | _] -> 324 + %% Do not erase previous lines with a simple \b. 325 + read_input_raw(Acc); 326 + [_ | Rest] -> 327 + io:put_chars("\b \b"), 328 + read_input_raw(Rest) 329 + end; 330 + {char, C} when is_integer(C), C >= 32 -> 331 + io:put_chars(unicode:characters_to_binary([C])), 332 + read_input_raw([C | Acc]); 333 + {error, _} -> 334 + io:put_chars("\r\n"), 335 + {error, <<"io_error">>}; 336 + _Other -> 337 + read_input_raw(Acc) 338 + end. 339 + 340 + codepoints_to_bin(Cs) -> 341 + unicode:characters_to_binary(Cs). 342 + 343 + %% Cooked/edlin TTY: line-at-a-time until EOF (Ctrl+D on empty line). 344 + read_input_lines(Acc) -> 345 + case io:get_line("") of 346 + eof -> 347 + {ok, iolist_to_binary(lists:reverse(Acc))}; 348 + {error, interrupted} -> 349 + {error, <<"interrupted">>}; 350 + {error, _} -> 351 + {error, <<"io_error">>}; 352 + Line when is_list(Line); is_binary(Line) -> 353 + Bin = unicode:characters_to_binary(Line), 354 + read_input_lines([Bin | Acc]); 355 + Other -> 356 + try 357 + Bin = unicode:characters_to_binary(Other), 358 + read_input_lines([Bin | Acc]) 359 + catch 360 + _:_ -> 361 + {error, <<"io_error">>} 362 + end 363 + end. 364 + 365 + %% Piped / non-TTY stdin: drain the whole stream. 366 + read_input_stream(Acc) -> 367 + case io:get_chars("", 8192) of 368 + eof -> 369 + {ok, iolist_to_binary(lists:reverse(Acc))}; 370 + {error, Reason} -> 371 + {error, reason_to_bin(Reason)}; 372 + Data when is_binary(Data) -> 373 + read_input_stream([Data | Acc]); 374 + Data when is_list(Data) -> 375 + read_input_stream([unicode:characters_to_binary(Data) | Acc]); 376 + Other -> 377 + try 378 + Bin = unicode:characters_to_binary(Other), 379 + read_input_stream([Bin | Acc]) 380 + catch 381 + _:_ -> 382 + {error, <<"io_error">>} 383 + end 384 + end. 385 + 386 + -spec stdin_isatty() -> boolean(). 387 + stdin_isatty() -> 388 + try 389 + case prim_tty:isatty(stdin) of 390 + true -> 391 + true; 392 + _ -> 393 + false 394 + end 395 + catch 396 + _:_ -> 397 + %% Fallback: if we cannot tell, prefer stream read so pipes work. 398 + false 399 + end. 260 400 261 401 %% --------------------------------------------------------------------------- 262 402 %% Shell bootstrap: prefer OTP raw mode for live syntax highlighting. ··· 745 885 [ 746 886 "about", "append", "cat", "cd", "columns", "count", "describe", "echo", 747 887 "env", "exit", "filter", "find", "first", "flatten", "from", "get", 748 - "help", "identity", "ignore", "is-empty", "is_empty", "keys", "last", 749 - "length", "less", "lines", "ls", "open", "prepend", "print", "ps", 750 - "pwd", "quit", "range", "reverse", "save", "select", "skip", "sort-by", 751 - "sort_by", "sys", "table", "take", "to", "type", "typeof", "uniq", 752 - "unwrap", "values", "where", "which", "wrap" 888 + "help", "identity", "ignore", "input", "is-empty", "is_empty", "keys", 889 + "last", "length", "less", "lines", "ls", "now", "open", "prepend", 890 + "print", "ps", "pwd", "quit", "range", "reverse", "save", "select", 891 + "skip", "sort-by", "sort_by", "sys", "table", "take", "to", "type", 892 + "typeof", "uniq", "unwrap", "values", "where", "which", "whyport", 893 + "wrap" 753 894 ]. 754 895 755 896 %% Executable basenames on PATH that match Prefix (deduped, sorted). ··· 3189 3330 iolist_to_binary(io_lib:format("~p", [Reason])). 3190 3331 3191 3332 %% --------------------------------------------------------------------------- 3333 + %% Current Unix epoch seconds (UTC). Used by the `now` builtin. 3334 + %% --------------------------------------------------------------------------- 3335 + 3336 + -spec unix_now() -> integer(). 3337 + unix_now() -> 3338 + os:system_time(second). 3339 + 3340 + %% --------------------------------------------------------------------------- 3192 3341 %% Format Unix epoch seconds as local calendar time: 3193 3342 %% "Jul 3 2026 9:39:40 PM" (abbreviated month, 12-hour clock). 3194 3343 %% Used for `ls` modified column display (data stays as raw Int). ··· 3611 3760 {error, _} -> 3612 3761 0 3613 3762 end. 3763 + 3764 + %% --------------------------------------------------------------------------- 3765 + %% whyport: sockets using a local/remote port (like `lsof -i :<port>`) 3766 + %% --------------------------------------------------------------------------- 3767 + %% 3768 + %% Returns a list of Gleam `PortSocket` records (Erlang tagged tuples): 3769 + %% 3770 + %% {port_socket, Protocol, Family, LocalAddress, LocalPort, 3771 + %% RemoteAddress, RemotePort, State, Pid, Name, Command, UserId, Fd} 3772 + %% 3773 + %% Protocol: <<"tcp">> | <<"udp">> 3774 + %% Family: <<"ipv4">> | <<"ipv6">> 3775 + %% State: LISTEN / ESTABLISHED / … (TCP) or empty for UDP 3776 + %% Pid/Fd: 0 when the owning process is unknown (permissions / TIME_WAIT) 3777 + %% 3778 + %% Linux only via /proc/net/{tcp,tcp6,udp,udp6} + /proc/*/fd socket inodes. 3779 + %% 3780 + -spec list_port_sockets(integer()) -> 3781 + list({ 3782 + port_socket, 3783 + binary(), 3784 + binary(), 3785 + binary(), 3786 + integer(), 3787 + binary(), 3788 + integer(), 3789 + binary(), 3790 + integer(), 3791 + binary(), 3792 + binary(), 3793 + integer(), 3794 + integer() 3795 + }). 3796 + list_port_sockets(Port) when is_integer(Port), Port >= 0, Port =< 65535 -> 3797 + case os:type() of 3798 + {unix, linux} -> 3799 + list_port_sockets_linux(Port); 3800 + _ -> 3801 + [] 3802 + end; 3803 + list_port_sockets(_) -> 3804 + []. 3805 + 3806 + list_port_sockets_linux(Port) -> 3807 + SockMap = socket_inode_map(), 3808 + Sources = [ 3809 + {"/proc/net/tcp", <<"tcp">>, ipv4}, 3810 + {"/proc/net/tcp6", <<"tcp">>, ipv6}, 3811 + {"/proc/net/udp", <<"udp">>, ipv4}, 3812 + {"/proc/net/udp6", <<"udp">>, ipv6} 3813 + ], 3814 + lists:flatmap( 3815 + fun({Path, Proto, Family}) -> 3816 + parse_net_table(Path, Proto, Family, Port, SockMap) 3817 + end, 3818 + Sources 3819 + ). 3820 + 3821 + %% inode => list of {Pid, Fd, Name, Command} 3822 + socket_inode_map() -> 3823 + case file:list_dir("/proc") of 3824 + {ok, Entries} -> 3825 + lists:foldl( 3826 + fun(Entry, Acc) -> 3827 + case is_pid_name(Entry) of 3828 + false -> 3829 + Acc; 3830 + true -> 3831 + Pid = list_to_integer(Entry), 3832 + merge_pid_sockets(Pid, Acc) 3833 + end 3834 + end, 3835 + #{}, 3836 + Entries 3837 + ); 3838 + {error, _} -> 3839 + #{} 3840 + end. 3841 + 3842 + merge_pid_sockets(Pid, Acc) -> 3843 + FdDir = "/proc/" ++ integer_to_list(Pid) ++ "/fd", 3844 + case file:list_dir(FdDir) of 3845 + {ok, Fds} -> 3846 + {Name, Command} = pid_name_command(Pid), 3847 + lists:foldl( 3848 + fun(FdName, Acc1) -> 3849 + case is_pid_name(FdName) of 3850 + false -> 3851 + Acc1; 3852 + true -> 3853 + Fd = list_to_integer(FdName), 3854 + Link = FdDir ++ "/" ++ FdName, 3855 + case file:read_link(Link) of 3856 + {ok, Target} -> 3857 + case socket_inode_from_link(Target) of 3858 + {ok, Inode} -> 3859 + Owner = {Pid, Fd, Name, Command}, 3860 + Prev = maps:get(Inode, Acc1, []), 3861 + Acc1#{Inode => [Owner | Prev]}; 3862 + error -> 3863 + Acc1 3864 + end; 3865 + {error, _} -> 3866 + Acc1 3867 + end 3868 + end 3869 + end, 3870 + Acc, 3871 + Fds 3872 + ); 3873 + {error, _} -> 3874 + Acc 3875 + end. 3876 + 3877 + %% "socket:[12345]" or "socket:[12345]\n" 3878 + socket_inode_from_link(Target) when is_list(Target) -> 3879 + socket_inode_from_link(unicode:characters_to_binary(Target)); 3880 + socket_inode_from_link(Target) when is_binary(Target) -> 3881 + case re:run(Target, <<"^socket:\\[([0-9]+)\\]">>, [{capture, all_but_first, binary}]) of 3882 + {match, [Num]} -> 3883 + {ok, binary_to_integer(Num)}; 3884 + _ -> 3885 + error 3886 + end; 3887 + socket_inode_from_link(_) -> 3888 + error. 3889 + 3890 + pid_name_command(Pid) -> 3891 + case read_stat_fields(Pid) of 3892 + {ok, Fields} -> 3893 + Name = maps:get(comm, Fields, <<>>), 3894 + {Name, read_cmdline(Pid, Name)}; 3895 + error -> 3896 + {<<>>, <<>>} 3897 + end. 3898 + 3899 + parse_net_table(Path, Proto, Family, Port, SockMap) -> 3900 + case file:read_file(Path) of 3901 + {ok, Bin} -> 3902 + Lines = binary:split(Bin, <<"\n">>, [global]), 3903 + %% First line is the header. 3904 + case Lines of 3905 + [_Header | Rows] -> 3906 + lists:flatmap( 3907 + fun(Line) -> 3908 + parse_net_row(Line, Proto, Family, Port, SockMap) 3909 + end, 3910 + Rows 3911 + ); 3912 + [] -> 3913 + [] 3914 + end; 3915 + {error, _} -> 3916 + [] 3917 + end. 3918 + 3919 + parse_net_row(<<>>, _Proto, _Family, _Port, _SockMap) -> 3920 + []; 3921 + parse_net_row(Line, Proto, Family, Port, SockMap) -> 3922 + %% /proc/net/tcp columns (whitespace-separated after optional "sl:" index): 3923 + %% sl local_address rem_address st … uid timeout inode 3924 + Parts = [P || P <- binary:split(string:trim(Line), <<" ">>, [global]), P =/= <<>>], 3925 + case Parts of 3926 + %% drop "0:" style index 3927 + [_Sl, Local, Remote, St | Rest] when length(Rest) >= 6 -> 3928 + %% uid is 7th field after sl (index 7 in 0-based after splitting with sl), 3929 + %% inode is field 9 (0-based: parts after drop of sl: local=0 rem=1 st=2 3930 + %% tx=3 rx=4 tr=5 tm=6 retrnsmt=7 uid=8 timeout=9 inode=10 — wait. 3931 + %% With sl kept: [sl, local, rem, st, tx_rx, tr_tm, retrnsmt, uid, timeout, inode] 3932 + %% Actually tx_queue:rx_queue is one token, tr:tm->when is one. 3933 + %% Parts after split: sl, local, rem, st, tx:rx, tr:tm, retrnsmt, uid, timeout, inode, … 3934 + case Rest of 3935 + [_TxRx, _TrTm, _Retr, UidBin, _Timeout, InodeBin | _] -> 3936 + case {parse_addr_port(Local, Family), parse_addr_port(Remote, Family)} of 3937 + {{ok, LAddr, LPort}, {ok, RAddr, RPort}} -> 3938 + case LPort =:= Port orelse RPort =:= Port of 3939 + false -> 3940 + []; 3941 + true -> 3942 + State = tcp_state_name(Proto, St), 3943 + Uid = 3944 + try 3945 + binary_to_integer(UidBin) 3946 + catch 3947 + _:_ -> 3948 + 0 3949 + end, 3950 + Inode = 3951 + try 3952 + binary_to_integer(InodeBin) 3953 + catch 3954 + _:_ -> 3955 + 0 3956 + end, 3957 + Owners = maps:get(Inode, SockMap, []), 3958 + case Owners of 3959 + [] -> 3960 + [ 3961 + {port_socket, Proto, family_bin(Family), LAddr, LPort, 3962 + RAddr, RPort, State, 0, <<>>, <<>>, Uid, 0} 3963 + ]; 3964 + _ -> 3965 + [ 3966 + {port_socket, Proto, family_bin(Family), LAddr, LPort, 3967 + RAddr, RPort, State, Pid, Name, Command, Uid, Fd} 3968 + || {Pid, Fd, Name, Command} <- lists:reverse(Owners) 3969 + ] 3970 + end 3971 + end; 3972 + _ -> 3973 + [] 3974 + end; 3975 + _ -> 3976 + [] 3977 + end; 3978 + _ -> 3979 + [] 3980 + end. 3981 + 3982 + family_bin(ipv4) -> <<"ipv4">>; 3983 + family_bin(ipv6) -> <<"ipv6">>. 3984 + 3985 + %% Local/remote address in /proc/net is HEXIP:HEXPORT (host byte order for port; 3986 + %% IP is little-endian 32-bit words). 3987 + parse_addr_port(Bin, Family) -> 3988 + case binary:split(Bin, <<":">>) of 3989 + [IpHex, PortHex] -> 3990 + try 3991 + Port = binary_to_integer(PortHex, 16), 3992 + Addr = decode_ip(IpHex, Family), 3993 + {ok, Addr, Port} 3994 + catch 3995 + _:_ -> 3996 + error 3997 + end; 3998 + _ -> 3999 + error 4000 + end. 4001 + 4002 + decode_ip(Hex, ipv4) -> 4003 + <<A, B, C, D>> = <<(binary_to_integer(Hex, 16)):32/little>>, 4004 + iolist_to_binary(io_lib:format("~b.~b.~b.~b", [A, B, C, D])); 4005 + decode_ip(Hex, ipv6) -> 4006 + %% 32 hex chars = 4 little-endian 32-bit words → 16 network-order bytes 4007 + Int = binary_to_integer(Hex, 16), 4008 + <<W0:32, W1:32, W2:32, W3:32>> = <<Int:128/big>>, 4009 + <<B0:8, B1:8, B2:8, B3:8>> = <<W0:32/little>>, 4010 + <<B4:8, B5:8, B6:8, B7:8>> = <<W1:32/little>>, 4011 + <<B8:8, B9:8, B10:8, B11:8>> = <<W2:32/little>>, 4012 + <<B12:8, B13:8, B14:8, B15:8>> = <<W3:32/little>>, 4013 + Bytes = <<B0, B1, B2, B3, B4, B5, B6, B7, B8, B9, B10, B11, B12, B13, B14, B15>>, 4014 + format_ipv6(Bytes). 4015 + 4016 + %% Compact-ish IPv6 text (not full RFC 5952, but readable). 4017 + format_ipv6(<<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>>) -> 4018 + <<"::">>; 4019 + format_ipv6(<<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, A, B, C, D>>) -> 4020 + %% IPv4-mapped 4021 + iolist_to_binary(io_lib:format("::ffff:~b.~b.~b.~b", [A, B, C, D])); 4022 + format_ipv6(<<B0, B1, B2, B3, B4, B5, B6, B7, B8, B9, B10, B11, B12, B13, B14, B15>>) -> 4023 + Groups = [ 4024 + (B0 bsl 8) bor B1, 4025 + (B2 bsl 8) bor B3, 4026 + (B4 bsl 8) bor B5, 4027 + (B6 bsl 8) bor B7, 4028 + (B8 bsl 8) bor B9, 4029 + (B10 bsl 8) bor B11, 4030 + (B12 bsl 8) bor B13, 4031 + (B14 bsl 8) bor B15 4032 + ], 4033 + Parts = [iolist_to_binary(io_lib:format("~.16b", [G])) || G <- Groups], 4034 + iolist_to_binary(lists:join(<<":">>, Parts)). 4035 + 4036 + tcp_state_name(<<"udp">>, _) -> 4037 + <<"">>; 4038 + tcp_state_name(<<"tcp">>, StHex) -> 4039 + try 4040 + case binary_to_integer(StHex, 16) of 4041 + 1 -> <<"ESTABLISHED">>; 4042 + 2 -> <<"SYN_SENT">>; 4043 + 3 -> <<"SYN_RECV">>; 4044 + 4 -> <<"FIN_WAIT1">>; 4045 + 5 -> <<"FIN_WAIT2">>; 4046 + 6 -> <<"TIME_WAIT">>; 4047 + 7 -> <<"CLOSE">>; 4048 + 8 -> <<"CLOSE_WAIT">>; 4049 + 9 -> <<"LAST_ACK">>; 4050 + 10 -> <<"LISTEN">>; 4051 + 11 -> <<"CLOSING">>; 4052 + 12 -> <<"NEW_SYN_RECV">>; 4053 + N -> iolist_to_binary(io_lib:format("UNKNOWN(~b)", [N])) 4054 + end 4055 + catch 4056 + _:_ -> 4057 + StHex 4058 + end; 4059 + tcp_state_name(_, St) -> 4060 + St.
+198
test/gleshell_test.gleam
··· 1 + import gleam/int 1 2 import gleam/list 2 3 import gleam/option.{None, Some} 3 4 import gleam/string ··· 275 276 Nil 276 277 } 277 278 279 + pub fn eval_get_dotted_path_test() { 280 + let env = env.new() 281 + // Nested record: get a.b 282 + let assert eval.Continue(_, String("ada")) = 283 + eval.eval_source(env, "echo {user: {name: \"ada\"}} | get user.name") 284 + // Deeper path 285 + let assert eval.Continue(_, Int(42)) = 286 + eval.eval_source(env, "echo {a: {b: {c: 42}}} | get a.b.c") 287 + // Mid-path list: collect nested field from each item 288 + let assert eval.Continue(_, List([String("x"), String("y")])) = 289 + eval.eval_source( 290 + env, 291 + "echo {items: [{n: \"x\"}, {n: \"y\"}]} | get items.n", 292 + ) 293 + // Missing path errors on records 294 + let assert eval.Continue(_, value.Fail(msg)) = 295 + eval.eval_source(env, "echo {a: 1} | get a.b") 296 + let assert True = string.contains(msg, "get:") 297 + Nil 298 + } 299 + 278 300 pub fn eval_env_record_test() { 279 301 let env = env.new() 280 302 let assert eval.Continue(_, Record(fields)) = eval.eval_source(env, "$env") ··· 393 415 Nil 394 416 } 395 417 418 + pub fn eval_from_jwt_test() { 419 + let env = env.new() 420 + // Classic jwt.io sample (HS256); parse-only — signature not verified. 421 + let token = 422 + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" 423 + 424 + let assert eval.Continue(_, Record(fields)) = 425 + eval.eval_source(env, "echo \"" <> token <> "\" | from jwt") 426 + 427 + // header 428 + let assert Ok(Record(header_fields)) = list_find_field(fields, "header") 429 + let assert True = list_has_field(header_fields, "alg", String("HS256")) 430 + let assert True = list_has_field(header_fields, "typ", String("JWT")) 431 + 432 + // payload claims 433 + let assert Ok(Record(payload_fields)) = list_find_field(fields, "payload") 434 + let assert True = 435 + list_has_field(payload_fields, "sub", String("1234567890")) 436 + let assert True = 437 + list_has_field(payload_fields, "name", String("John Doe")) 438 + let assert True = list_has_field(payload_fields, "iat", Int(1_516_239_022)) 439 + 440 + // signature segment preserved as base64url text 441 + let assert Ok(String(sig)) = list_find_field(fields, "signature") 442 + let assert "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" = sig 443 + 444 + // Argument form + Bearer prefix 445 + let assert eval.Continue(_, Record(_)) = 446 + eval.eval_source(env, "from jwt \"Bearer " <> token <> "\"") 447 + 448 + // Nested get (pipeline and dotted path) 449 + let assert eval.Continue(_, String("HS256")) = 450 + eval.eval_source( 451 + env, 452 + "echo \"" <> token <> "\" | from jwt | get header | get alg", 453 + ) 454 + let assert eval.Continue(_, String("HS256")) = 455 + eval.eval_source( 456 + env, 457 + "echo \"" <> token <> "\" | from jwt | get header.alg", 458 + ) 459 + 460 + // Errors 461 + let assert eval.Continue(env2, value.Fail(msg)) = 462 + eval.eval_source(env, "echo not-a-jwt | from jwt") 463 + let assert True = 464 + string.contains(msg, "segments") || string.contains(msg, "base64") 465 + let assert 1 = env2.last_exit 466 + 467 + let assert eval.Continue(_, value.Fail(empty_msg)) = 468 + eval.eval_source(env, "echo \"\" | from jwt") 469 + let assert True = string.contains(empty_msg, "empty") 470 + 471 + let assert eval.Continue(_, String(help_out)) = 472 + eval.eval_source(env, "help from") 473 + let assert True = string.contains(help_out, "jwt") 474 + Nil 475 + } 476 + 396 477 pub fn eval_to_json_pretty_test() { 397 478 let env = env.new() 398 479 // `to` command + `json` subcommand — pretty by default ··· 486 567 let assert True = string.contains(help_out, "--long") 487 568 Nil 488 569 } 570 + 571 + pub fn eval_whyport_test() { 572 + let env = env.new() 573 + // Default: short listener columns only (no command / remote spam). 574 + let assert eval.Continue(_, Table(cols, _)) = 575 + eval.eval_source(env, "whyport 1") 576 + let assert ["protocol", "local_address", "local_port", "pid", "name"] = cols 577 + 578 + // --all adds peer/state; --long adds command (and more). 579 + let assert eval.Continue(_, Table(all_cols, _)) = 580 + eval.eval_source(env, "whyport --all 1") 581 + let assert True = list.contains(all_cols, "state") 582 + let assert True = list.contains(all_cols, "remote_port") 583 + let assert False = list.contains(all_cols, "command") 584 + 585 + let assert eval.Continue(_, Table(long_cols, _)) = 586 + eval.eval_source(env, "whyport --long 1") 587 + let assert True = list.contains(long_cols, "command") 588 + let assert True = list.contains(long_cols, "family") 589 + let assert False = list.contains(long_cols, "state") 590 + 591 + // Port with nothing listening: empty table with schema. 592 + let assert eval.Continue(_, Table(_, unused_rows)) = 593 + eval.eval_source(env, "whyport 1") 594 + let assert [] = unused_rows 595 + 596 + // epmd listens on 4369 when present — default is listeners only. 597 + let assert eval.Continue(_, Table(_, epmd_rows)) = 598 + eval.eval_source(env, "whyport 4369") 599 + case epmd_rows { 600 + [] -> Nil 601 + _ -> { 602 + // Every default row should be a listener (local_port 4369). 603 + let assert eval.Continue(_, Int(n)) = 604 + eval.eval_source(env, "whyport 4369 | length") 605 + let assert True = n >= 1 606 + // --all should not be smaller than listeners. 607 + let assert eval.Continue(_, Int(all_n)) = 608 + eval.eval_source(env, "whyport --all 4369 | length") 609 + let assert True = all_n >= n 610 + Nil 611 + } 612 + } 613 + 614 + // Leading colon + pipeline + flag that steals the port value 615 + let assert eval.Continue(_, Table(_, _)) = 616 + eval.eval_source(env, "whyport :22") 617 + let assert eval.Continue(_, Table(_, _)) = 618 + eval.eval_source(env, "echo 22 | whyport") 619 + let assert eval.Continue(_, Table(_, _)) = 620 + eval.eval_source(env, "whyport --all 22") 621 + 622 + // Errors 623 + let assert eval.Continue(_, value.Fail(missing)) = 624 + eval.eval_source(env, "whyport") 625 + let assert True = string.contains(missing, "port") 626 + let assert eval.Continue(_, value.Fail(bad)) = 627 + eval.eval_source(env, "whyport notaport") 628 + let assert True = string.contains(bad, "invalid") || string.contains(bad, "port") 629 + let assert eval.Continue(_, value.Fail(range)) = 630 + eval.eval_source(env, "whyport 99999") 631 + let assert True = string.contains(range, "range") 632 + 633 + let assert eval.Continue(_, String("builtin: whyport")) = 634 + eval.eval_source(env, "which whyport") 635 + let assert eval.Continue(_, String(help_out)) = 636 + eval.eval_source(env, "help whyport") 637 + let assert True = string.contains(help_out, "--all") 638 + let assert True = string.contains(help_out, "--long") 639 + Nil 640 + } 641 + 642 + pub fn eval_now_test() { 643 + let env = env.new() 644 + // Data is Unix epoch seconds (same as ls modified); near wall clock. 645 + let assert eval.Continue(_, Int(secs)) = eval.eval_source(env, "now") 646 + let wall = sys.unix_now() 647 + let assert True = secs > 1_700_000_000 648 + let assert True = secs <= wall + 2 && secs >= wall - 5 649 + 650 + // typeof is int 651 + let assert eval.Continue(_, String("int")) = 652 + eval.eval_source(env, "now | typeof") 653 + 654 + // Display formats bare epoch ints like ls modified (local 12-hour). 655 + let text = display.render_with(False, Int(secs)) 656 + let assert False = string.contains(text, int.to_string(secs)) 657 + let assert True = string.contains(text, ":") 658 + let assert True = 659 + string.contains(text, " AM") || string.contains(text, " PM") 660 + 661 + // Small ints still print as numbers 662 + let assert True = string.contains(display.render_with(False, Int(42)), "42") 663 + 664 + let assert eval.Continue(_, String("builtin: now")) = 665 + eval.eval_source(env, "which now") 666 + let assert eval.Continue(_, String(help_out)) = 667 + eval.eval_source(env, "help now") 668 + let assert True = string.contains(help_out, "epoch") 669 + Nil 670 + } 671 + 672 + pub fn eval_input_help_test() { 673 + let env = env.new() 674 + let assert eval.Continue(_, String("builtin: input")) = 675 + eval.eval_source(env, "which input") 676 + let assert eval.Continue(_, String(help_out)) = 677 + eval.eval_source(env, "help input") 678 + let assert True = string.contains(help_out, "Ctrl+D") 679 + let assert True = string.contains(help_out, "from json") 680 + // Too many args without reading stdin 681 + let assert eval.Continue(_, value.Fail(msg)) = 682 + eval.eval_source(env, "input too many args") 683 + let assert True = string.contains(msg, "prompt") 684 + Nil 685 + } 686 + 489 687 490 688 pub fn http_get_live_test() { 491 689 // Live request against postman-echo (JSON). Skip gracefully if offline.