2021-01-15 16:40:46 +01:00
|
|
|
(uiop:define-package ambrevar/shell
|
2020-11-07 16:06:06 +01:00
|
|
|
(:documentation "Shell-like utilities.")
|
2021-01-16 01:10:50 +01:00
|
|
|
;; (:nicknames #:$)
|
2020-11-07 17:53:23 +01:00
|
|
|
(:use #:common-lisp)
|
|
|
|
(:use #:trivia)
|
2021-01-16 12:01:00 +01:00
|
|
|
(:import-from #:ambrevar/file)
|
2020-11-07 17:07:04 +01:00
|
|
|
(:import-from #:serapeum #:export-always))
|
2021-01-15 19:10:04 +01:00
|
|
|
(in-package ambrevar/shell)
|
2020-11-07 16:06:06 +01:00
|
|
|
(eval-when (:compile-toplevel :load-toplevel :execute)
|
|
|
|
(trivial-package-local-nicknames:add-package-local-nickname :alex :alexandria)
|
|
|
|
(trivial-package-local-nicknames:add-package-local-nickname :sera :serapeum))
|
|
|
|
|
2021-01-12 12:36:09 +01:00
|
|
|
(export-always 'date)
|
|
|
|
(defun date ()
|
|
|
|
(local-time:format-rfc1123-timestring nil (local-time:now)))
|
|
|
|
|
2021-01-12 12:40:57 +01:00
|
|
|
(export-always 'env)
|
2021-01-20 13:08:09 +01:00
|
|
|
(defun env (&optional key)
|
|
|
|
"Return the environment variables as a proper alist.
|
|
|
|
With KEY, return the corresponding environment variable value, and the whole
|
|
|
|
list as a second value."
|
|
|
|
(let ((result
|
|
|
|
(mapcar (lambda (line)
|
|
|
|
(let ((key-value (str:split "=" line :limit 2)))
|
|
|
|
(cons (first key-value)
|
|
|
|
(str:split (uiop:inter-directory-separator)
|
|
|
|
(second key-value)))))
|
|
|
|
(str:split (string #\newline) (cmd:$cmd "env")))))
|
|
|
|
(if key
|
|
|
|
(alex:assoc-value result key :test #'string=)
|
|
|
|
result)))
|
2021-01-12 12:40:57 +01:00
|
|
|
|
2021-01-16 01:13:15 +01:00
|
|
|
(export-always 'file->string)
|
|
|
|
(defun file->string (path)
|
|
|
|
(sera:chomp (alex:read-file-into-string path)))
|
|
|
|
|
2020-11-07 17:07:04 +01:00
|
|
|
(defun assert-program (program &rest more-programs) ; TODO: Is this useful for a REPL?
|
|
|
|
(sera:and-let* ((missing-programs
|
|
|
|
(delete-if #'sera:resolve-executable (cons program more-programs))))
|
|
|
|
(error "Missing programs: ~{~a~,^, ~}" missing-programs)))
|
|
|
|
|
2020-11-18 11:44:46 +01:00
|
|
|
(export-always 'delete-empty-directory-upward)
|
|
|
|
(defun delete-empty-directory-upward (directory)
|
|
|
|
"Delete directory and its parents until non-empty.
|
|
|
|
Return the first non-deleted directory."
|
|
|
|
(or (and (ignore-errors (uiop:delete-empty-directory directory))
|
2020-11-30 12:43:18 +01:00
|
|
|
(delete-empty-directory-upward
|
2020-11-18 11:44:46 +01:00
|
|
|
(uiop:pathname-parent-directory-pathname
|
|
|
|
(uiop:ensure-directory-pathname directory))))
|
|
|
|
directory))
|
|
|
|
|
2020-11-07 17:07:04 +01:00
|
|
|
(export-always 'make-directory)
|
|
|
|
(defun make-directory (path)
|
|
|
|
"Including parents."
|
|
|
|
(uiop:ensure-all-directories-exist (list (uiop:ensure-directory-pathname path)))
|
|
|
|
path)
|
2020-11-07 17:53:23 +01:00
|
|
|
|
2021-01-14 17:09:00 +01:00
|
|
|
(defun system-signal (pid-or-pids &key (signal 'term) options)
|
2020-11-15 18:13:01 +01:00
|
|
|
"OPTIONS are PKILL options."
|
2020-11-07 17:53:23 +01:00
|
|
|
;; TODO: Is it possible to kill a group without pkill, maybe with osicat?
|
2020-11-15 18:13:01 +01:00
|
|
|
(uiop:run-program
|
|
|
|
(append
|
|
|
|
(list (if options "pkill" "kill") (format nil "-~a" signal))
|
|
|
|
options
|
2021-01-14 17:09:00 +01:00
|
|
|
(mapcar #'princ-to-string (alex:ensure-list pid-or-pids)))))
|
|
|
|
|
|
|
|
(defun kill (pid-or-pids)
|
|
|
|
"Kill PID-OR-PIDS (with SIGKILL)."
|
|
|
|
(system-signal pid-or-pids :signal 'kill))
|
|
|
|
|
|
|
|
(defun term (pid-or-pids)
|
|
|
|
"Cleanly terminate PID-OR-PIDS."
|
|
|
|
(system-signal pid-or-pids :signal 'term))
|
2020-11-15 18:13:01 +01:00
|
|
|
|
|
|
|
(defun terminate-process-with-group (process-info)
|
2020-11-07 17:53:23 +01:00
|
|
|
(when (uiop:process-alive-p process-info)
|
2021-01-14 17:09:00 +01:00
|
|
|
(system-signal
|
|
|
|
(list (format nil "~a" (osicat-posix:getpgid (uiop:process-info-pid process-info))))
|
|
|
|
:signal 'term ;TODO: -KILL instead?
|
|
|
|
:options '("-g"))
|
2020-11-07 17:53:23 +01:00
|
|
|
(uiop:terminate-process process-info)))
|
|
|
|
|
|
|
|
(defvar *process-list* '()
|
|
|
|
"List of processes started from `run'.")
|
|
|
|
|
2021-01-13 14:58:34 +01:00
|
|
|
;; (export-always 'terminate-dangling-processes)
|
|
|
|
;; (defun terminate-dangling-processes ()
|
|
|
|
;; (mapc #'terminate-process-with-group *process-list*)
|
|
|
|
;; ;; TODO: Maybe don't flush the list in case some processes failed to be terminated.
|
|
|
|
;; ;; Use -KILL to fix this?
|
|
|
|
;; (setf *process-list* nil))
|
|
|
|
|
|
|
|
(export-always '*shell*)
|
|
|
|
(defvar *shell* "sh"
|
|
|
|
"Default shell to use in `sh' and friends.
|
|
|
|
Also see `*shell-command-flags*'.")
|
|
|
|
|
|
|
|
(export-always '*shell-command-flag*)
|
|
|
|
(defvar *shell-command-flag* "-c"
|
|
|
|
"Flag to pass a command string to the `*shell*'.")
|
|
|
|
|
|
|
|
(defun format-shell-command (shell-command)
|
|
|
|
(format nil "~a ~a '~a'"
|
|
|
|
*shell* *shell-command-flag* shell-command))
|
2020-11-07 17:53:23 +01:00
|
|
|
|
|
|
|
(export-always 'sh)
|
|
|
|
(defun sh (shell-command)
|
2021-01-13 14:58:34 +01:00
|
|
|
"Wrapper around `cmd:cmd' to execute SHELL-COMMAND in `*shell*'."
|
|
|
|
(cmd:cmd (format-shell-command shell-command)))
|
|
|
|
|
|
|
|
(export-always 'sh&)
|
|
|
|
(defun sh& (shell-command)
|
|
|
|
"Wrapper around `cmd:cmd&' to execute SHELL-COMMAND in `*shell*'."
|
|
|
|
(cmd:cmd& (format-shell-command shell-command)))
|
|
|
|
|
|
|
|
(export-always '$sh)
|
|
|
|
(defun $sh (shell-command)
|
|
|
|
"Wrapper around `cmd:$cmd' to execute SHELL-COMMAND in `*shell*'."
|
|
|
|
(cmd:$cmd (format-shell-command shell-command)))
|
|
|
|
|
2021-01-13 19:07:03 +01:00
|
|
|
(export-always 'cmd&)
|
2021-01-13 14:58:34 +01:00
|
|
|
(defun cmd& (cmd) ; TODO: Support (cmd &rest args) lambda list.
|
|
|
|
"Like `cmd:cmd&' but run and monitor in a shell.
|
|
|
|
When Lisp is exited, the command is automatically terminated.
|
|
|
|
List of background process is maintained in `*process-list*'."
|
|
|
|
(let* ((cmd (format nil
|
|
|
|
"bash -c 'set -o monitor; { ~a ; kill $$ ; } & read dummy; kill %1'" cmd))
|
|
|
|
(handle (cmd:cmd& cmd :input :stream)))
|
|
|
|
(push handle *process-list*)
|
|
|
|
handle))
|
2020-11-07 18:27:51 +01:00
|
|
|
|
2021-01-13 19:07:03 +01:00
|
|
|
(export-always 'cmd>)
|
2021-01-13 19:20:14 +01:00
|
|
|
(defun cmd> (cmd &rest args) ; TODO: "|" is not convenient in CL, use "/" or "-"? See `cmd<>'.
|
2021-01-13 19:07:03 +01:00
|
|
|
"Like `cmd:cmd&' but return the output as a stream.
|
|
|
|
Return the process handler as second value.
|
|
|
|
Use
|
|
|
|
|
|
|
|
(uiop:slurp-stream-string STREAM :stripped t)
|
|
|
|
|
|
|
|
to get the final output as a string."
|
|
|
|
(let ((handler (apply #'cmd:cmd& cmd (append args
|
|
|
|
(list :output :stream)))))
|
|
|
|
(values (uiop:process-info-output handler)
|
|
|
|
handler)))
|
|
|
|
|
2021-01-13 19:20:14 +01:00
|
|
|
(export-always 'cmd<>)
|
|
|
|
(defun cmd<> (cmd &rest args)
|
|
|
|
"Like `cmd>' but last argument is passed as `:input'.
|
|
|
|
This can be usefully chained, e.g. with arrow macros.
|
|
|
|
Example:
|
|
|
|
|
|
|
|
(->>
|
|
|
|
(cmd> \"cat\" \".../share/hunspell/en_GB-large.dic\")
|
|
|
|
(cmd<> \"sort\")
|
|
|
|
(cmd<> \"uniq\" \"-c\")
|
|
|
|
(cmd<> \"sort\" \"-nr\")
|
|
|
|
(cmd<> \"head\" \"-3\"))"
|
|
|
|
(let* ((butlast-args (butlast args))
|
|
|
|
(last-arg (first (last args)))
|
|
|
|
(handler (apply #'cmd:cmd& cmd (append butlast-args
|
|
|
|
(list :output :stream
|
|
|
|
:input last-arg)))))
|
|
|
|
(values (uiop:process-info-output handler)
|
|
|
|
handler)))
|
|
|
|
|
|
|
|
(export-always 'cmd<)
|
|
|
|
(defun cmd< (cmd &rest args)
|
|
|
|
"Like `cmd<>' but output to standard output."
|
|
|
|
(let* ((butlast-args (butlast args))
|
|
|
|
(last-arg (first (last args))))
|
|
|
|
(apply #'cmd:cmd cmd (append butlast-args
|
|
|
|
(list :input last-arg)))))
|
|
|
|
|
|
|
|
(export-always '$cmd<)
|
|
|
|
(defun $cmd< (cmd &rest args)
|
|
|
|
"Like `cmd<>' but return string."
|
|
|
|
(let* ((butlast-args (butlast args))
|
|
|
|
(last-arg (first (last args))))
|
|
|
|
(apply #'cmd:$cmd cmd (append butlast-args
|
|
|
|
(list :input last-arg)))))
|
|
|
|
|
2021-01-20 12:31:53 +01:00
|
|
|
(defun %cmd- (final-caller &rest args)
|
2021-01-13 19:57:19 +01:00
|
|
|
"See `cmd<->'."
|
|
|
|
(let ((arg-lists (sera:split-sequence :<> args)))
|
|
|
|
(sera:nlet call ((arg-lists arg-lists)
|
|
|
|
(last-result-stream nil))
|
|
|
|
(let* ((args (first arg-lists)))
|
|
|
|
(if (rest arg-lists)
|
|
|
|
(let ((handler
|
|
|
|
(apply #'cmd:cmd&
|
|
|
|
(first args)
|
|
|
|
(append (rest args)
|
|
|
|
(list :output :stream)
|
|
|
|
(when last-result-stream
|
|
|
|
(list :input last-result-stream))))))
|
|
|
|
(call (rest arg-lists) (uiop:process-info-output handler)))
|
|
|
|
(apply final-caller (first args)
|
|
|
|
(append (rest args)
|
|
|
|
(when last-result-stream
|
|
|
|
(list :input last-result-stream)))))))))
|
|
|
|
|
2021-01-19 12:21:27 +01:00
|
|
|
(export-always 'cmd-)
|
|
|
|
(defun cmd- (&rest args)
|
|
|
|
"Call `:-'-separated commands in args, passing the result of one onto the next.
|
2021-01-13 19:57:19 +01:00
|
|
|
Example:
|
|
|
|
|
2021-01-19 12:21:27 +01:00
|
|
|
(cmd- \"cat\" \".../share/hunspell/en_GB-large.dic\"
|
|
|
|
:- \"sort\"
|
|
|
|
:- \"uniq\" \"-c\"
|
|
|
|
:- \"sort\" \"-nr\"
|
|
|
|
:- \"head\" \"-3\")"
|
|
|
|
(apply #'%cmd- #'cmd:cmd args))
|
2021-01-13 19:57:19 +01:00
|
|
|
|
2021-01-19 12:21:27 +01:00
|
|
|
(defun $cmd- (&rest args)
|
|
|
|
"Like `cmd-' but return a string."
|
|
|
|
(apply #'%cmd- #'cmd:$cmd args))
|
2021-01-13 19:57:19 +01:00
|
|
|
|
2021-01-19 12:21:27 +01:00
|
|
|
(defun cmd-& (&rest args)
|
|
|
|
"Like `cmd-' but return a `process-info' object."
|
|
|
|
(apply #'%cmd- #'cmd:cmd& args))
|
2021-01-13 19:57:19 +01:00
|
|
|
|
2021-01-19 12:21:27 +01:00
|
|
|
(defun cmd-> (&rest args)
|
|
|
|
"Like `cmd-' but return a stream."
|
|
|
|
(apply #'%cmd- #'cmd> args))
|
2021-01-13 19:57:19 +01:00
|
|
|
|
2021-01-21 19:39:05 +01:00
|
|
|
(defvar *visual-command* '("htop"))
|
|
|
|
(defvar *command-wrappers* '("sudo" "env"))
|
|
|
|
(defun visual-command-p (command)
|
|
|
|
"Return true if the COMMAND list runs one of the programs in `*visual-command*'.
|
|
|
|
`*command-wrappers*' are supported, i.e.
|
|
|
|
|
|
|
|
env FOO=BAR sudo -i powertop
|
|
|
|
|
|
|
|
works."
|
|
|
|
(labels ((basename (arg)
|
|
|
|
(namestring (pathname-name arg)))
|
|
|
|
(flag? (arg)
|
|
|
|
(str:starts-with? "-" arg))
|
|
|
|
(variable? (arg)
|
|
|
|
(and (< 1 (length arg))
|
|
|
|
(str:contains? "=" (subseq arg 1))))
|
|
|
|
(first-positional-argument (command)
|
|
|
|
"Return the argument that's not a flag, not a variable setting and
|
|
|
|
not in `*command-wrappers*'."
|
|
|
|
(when command
|
|
|
|
(if (or (flag? (first command))
|
|
|
|
(variable? (first command))
|
|
|
|
(find (basename (first command))
|
|
|
|
*command-wrappers*
|
|
|
|
:test #'string=))
|
|
|
|
(first-positional-argument (rest command))
|
|
|
|
(first command)))))
|
|
|
|
(sera:and-let* ((cmd (first-positional-argument command)))
|
|
|
|
(find (basename cmd)
|
|
|
|
*visual-command*
|
|
|
|
:test #'string=))))
|
|
|
|
|
|
|
|
(defun vterm-terminal (cmd)
|
|
|
|
(list
|
|
|
|
"emacsclient" "--eval"
|
|
|
|
(let ((*print-case* :downcase))
|
|
|
|
(write-to-string
|
|
|
|
`(progn
|
|
|
|
(vterm)
|
|
|
|
(vterm-insert ,(str:join " " cmd))
|
|
|
|
(vterm-send-return))))))
|
|
|
|
|
|
|
|
(defvar *terminal* '("/gnu/store/751mcahyg3b5dpwkbfvzg6x1vdn9i49a-xterm-363/bin/xterm" "-e")
|
|
|
|
"The terminal is either a list of arguments after which will be prepended to
|
|
|
|
the visual command to run, or a function of one argument, the list of commands,
|
|
|
|
returning the new list of commands.")
|
|
|
|
|
|
|
|
(defun maybe-launch-visual-command (cmd)
|
|
|
|
(if (visual-command-p cmd)
|
|
|
|
(cmd:cmd
|
|
|
|
(if (functionp *terminal*)
|
|
|
|
(funcall *terminal* cmd)
|
|
|
|
(append *terminal* cmd)))
|
|
|
|
(cmd:cmd cmd)))
|
|
|
|
|
|
|
|
(setf *terminal* #'vterm-terminal)
|
|
|
|
|
|
|
|
;; (maybe-launch-visual-command '("htop"))
|
|
|
|
|
2021-01-13 19:07:14 +01:00
|
|
|
(export-always 'tee)
|
2021-01-17 13:05:24 +01:00
|
|
|
(defun tee (input-stream) ; TODO: Real `tee' with separate process.
|
2021-01-13 19:07:14 +01:00
|
|
|
"Return the INPUT-STREAM and its string representation as a second value."
|
|
|
|
(let ((result
|
|
|
|
(uiop:slurp-stream-string input-stream :stripped t)))
|
|
|
|
(values
|
|
|
|
(make-string-input-stream result)
|
|
|
|
result)))
|
|
|
|
|
2020-11-07 18:27:51 +01:00
|
|
|
(export-always 'disk-usage)
|
|
|
|
(defun disk-usage (files)
|
|
|
|
"Return disk usage of FILES in octets.
|
|
|
|
As a second value, return a list of (FILE SIZE) pairs, biggest file first."
|
|
|
|
(let ((pairs (mapcar (lambda (f)
|
|
|
|
(list f (or (trivial-file-size:file-size-in-octets f)
|
|
|
|
0)))
|
|
|
|
files)))
|
|
|
|
(values
|
|
|
|
(reduce #'+ (mapcar #'second pairs))
|
|
|
|
(sort
|
|
|
|
pairs
|
|
|
|
#'> :key #'second))))
|
2020-11-15 18:13:01 +01:00
|
|
|
|
|
|
|
(export-always 'tokenize)
|
|
|
|
(defun tokenize (string)
|
|
|
|
"Return list of STRING lines, where each line is a list of each word."
|
|
|
|
(mapcar (lambda (line)
|
2021-01-13 14:58:34 +01:00
|
|
|
(sera:tokens line))
|
2020-11-15 18:13:01 +01:00
|
|
|
(str:split (string #\newline) string)))
|
|
|
|
|
|
|
|
(export-always 'port-process)
|
|
|
|
(defun port-process (port)
|
|
|
|
"Return process PID using PORT, NIL is none.
|
|
|
|
Return process name as second value."
|
|
|
|
(sera:and-let* ((ss-line (first
|
|
|
|
(tokenize
|
2021-01-13 14:58:34 +01:00
|
|
|
(cmd:$cmd "ss"
|
|
|
|
"--no-header"
|
|
|
|
"--listening"
|
|
|
|
"--tcp"
|
|
|
|
"--processes"
|
|
|
|
(format nil "( dport = :~a or sport = :~a )" port port)))))
|
2020-11-15 18:13:01 +01:00
|
|
|
(process-desc (first (last ss-line)))
|
|
|
|
(process-desc-no-prefix (second (str:split ":" process-desc)))
|
|
|
|
(process-props (first
|
|
|
|
(read-from-string
|
|
|
|
(str:replace-all "," " " process-desc-no-prefix))))
|
|
|
|
(process-name (first process-props))
|
|
|
|
(process-pid-prop (find-if (lambda (prop) (str:starts-with? "PID=" (string prop))) process-props))
|
|
|
|
(process-pid-string (second (str:split "=" (string process-pid-prop))))
|
|
|
|
(process-pid (parse-integer process-pid-string)))
|
|
|
|
(values
|
|
|
|
process-pid
|
|
|
|
process-name)))
|
2020-11-18 11:09:23 +01:00
|
|
|
|
2021-01-13 15:08:20 +01:00
|
|
|
(export-always 'sha1)
|
|
|
|
(defun sha1 (file) ; TODO: Use pure CL / FFI version?
|
2020-11-18 11:09:23 +01:00
|
|
|
"Return checksum of FILE."
|
2021-01-16 01:14:35 +01:00
|
|
|
(first (first (tokenize (cmd:$cmd "sha1sum" (write-to-string (ambrevar/file:path file)))))))
|
2020-11-30 12:43:24 +01:00
|
|
|
|
2020-12-11 20:14:41 +01:00
|
|
|
(export-always 'move-file)
|
|
|
|
(defun move-file (source destination)
|
|
|
|
"`rename-file' does not work cross-device, in particular it does not work on
|
|
|
|
different Btrfs subvolumes."
|
|
|
|
(uiop:run-program (list "mv"
|
|
|
|
;; Use native-namestring in case path contains escaped
|
|
|
|
;; character, like "\\[".
|
|
|
|
(uiop:native-namestring source)
|
|
|
|
(uiop:native-namestring destination))))
|
2020-12-20 12:12:45 +01:00
|
|
|
|
|
|
|
(defun loopback? (interface)
|
|
|
|
(or (string= "link/loopback" (first (second interface)))
|
|
|
|
(string= "lo:" (second (first interface)))))
|
|
|
|
|
|
|
|
(defun ipv4 (interface)
|
|
|
|
(when (string= "inet" (first (third interface)))
|
2021-01-13 19:56:30 +01:00
|
|
|
(values (ppcre:regex-replace "/.*" (second (third interface)) ""))))
|
2020-12-20 12:12:45 +01:00
|
|
|
|
|
|
|
;; TODO: Make class for interfaces? Can iolib / usocket be helpful here?
|
|
|
|
(defun interfaces (&optional interface)
|
|
|
|
"Return IP of current INTERFACE.
|
|
|
|
INTERFACE is a string in the form of `wlp2s0'."
|
2021-01-13 14:58:34 +01:00
|
|
|
(let* ((raw-list (tokenize (cmd:$cmd "ip" "address" interface))))
|
2020-12-20 12:12:45 +01:00
|
|
|
(sera:collecting
|
|
|
|
(sera:do-splits ((l r) (raw-list (lambda (line)
|
|
|
|
(ppcre:scan "[0-9]+:" (first line)))))
|
|
|
|
(sera:and-let* ((split (subseq raw-list (max 0 (1- l)) r)))
|
|
|
|
(collect split))))))
|
|
|
|
|
|
|
|
(defun interfaces-with-ipv4 ()
|
|
|
|
"Return list of interfaces with ipv4, excluding the loopback."
|
|
|
|
(remove-if (complement #'ipv4)
|
|
|
|
(remove-if #'loopback? (interfaces))))
|
|
|
|
|
|
|
|
(defun current-interface ()
|
|
|
|
(first (interfaces-with-ipv4)))
|
|
|
|
|
|
|
|
(export-always 'current-ip)
|
|
|
|
(defun current-ip ()
|
2020-12-26 10:22:01 +01:00
|
|
|
(ipv4 (current-interface)))
|