;;;; main.lisp -- oneliners.cli entrypoint (defpackage oneliners.cli (:use :cl) (:local-nicknames (#:api #:oneliners.api-client) (#:a #:alexandria) (#:rl #:cl-readline))) (in-package :oneliners.cli) ;;; CONFIG AND RESULTS FILE LOCATIONS (defvar *config* nil "A configuration plist") (defun make-config (&key host api-token editor (shell "bash")) (append (when host (list :host host)) (when api-token (list :api-token api-token)) (when editor (list :editor editor)) (list :shell shell))) (defun valid-config-p (config) (and (listp config) (evenp (length config)) (stringp (getf config :host)) t)) (defun write-default-config-to-disk () (let ((conf-file (config-file))) (ensure-directories-exist conf-file) (with-open-file (out conf-file :direction :output) (print (make-config :host "http://localhost:8888") out)))) (defun write-config-to-disk () (let ((conf-file (config-file))) (ensure-directories-exist conf-file) (with-open-file (out conf-file :direction :output :if-exists :supersede) (print *config* out)))) (defun fetch-config-from-disk () (let ((conf (uiop:with-safe-io-syntax () (uiop:read-file-form (config-file))))) (assert (valid-config-p conf) () "Invalid configuration file") (setf *config* conf))) (defun ensure-config () (unless (uiop:file-exists-p (config-file)) (write-default-config-to-disk)) (fetch-config-from-disk)) (defun host () (getf *config* :host)) (defun api-token () (getf *config* :api-token)) (defun (setf api-token) (newval) (setf (getf *config* :api-token) newval)) (defun get-shell () (getf *config* :shell)) (defun config-file () (merge-pathnames ".config/oneliners.config" (user-homedir-pathname))) (defun last-search-file () (merge-pathnames ".last_oneliners_search" (user-homedir-pathname))) ;;; UTILITIES (defun make-temp-file-name () (namestring (merge-pathnames (format nil "~a" (gensym "oneliners")) (uiop:temporary-directory)))) (defun string-from-editor (&optional contents) (let ((filename (make-temp-file-name))) (when contents (a:write-string-into-file contents filename :if-exists :supersede)) (unwind-protect (magic-ed:magic-ed filename :eval nil :output :string) (uiop:delete-file-if-exists filename)))) (defun executable-on-system-p (name) "A hack that heuristically determins whether or not an executable with the provided name is on the system. It is not perfect. It consults the environment PATH, and looks for the command in any of the directories that appear in the value of that variable." #+unix (loop for path in (str:split ":" (uiop:getenv "PATH")) for directory = (cons :absolute (cdr (str:split "/" path))) thereis (uiop:file-exists-p (make-pathname :name name :directory directory)))) (defun tags-from-oneliner (oneliner) (remove-if-not #'executable-on-system-p (ppcre:split " +" oneliner))) (rl:register-hook :signal (lambda () (uiop:quit))) (defun prompt (prompt &key (expect (constantly t)) retry-text (prefill "")) ;; register a prefill hook (rl:register-hook :pre-input (lambda () (rl:insert-text prefill) (rl:redisplay))) (unwind-protect (loop with prompt-text = prompt with should-retry-p = t while should-retry-p for line = (rl:readline :prompt prompt-text) when (funcall expect line) do (setf should-retry-p nil) when retry-text do (setf prompt-text retry-text) finally (return line)) ;; unregisters the hook. (rl:register-hook :pre-input nil))) (defun cached-result (&optional n) (when (uiop:file-exists-p (last-search-file)) (let ((contents (with-open-file (input (last-search-file)) (read input)))) (if n (nth n contents) contents)))) (defmacro with-cached-result ((olvar n) &body body) (a:with-gensyms (nvar) `(let ((,nvar ,n)) (assert (plusp ,nvar) () "Item number must be 1 or greater") (a:if-let (,olvar (cached-result (1- ,nvar))) (progn ,@body) (format t "The last search was shorter than ~a" ,nvar))))) (defun print-item-explanation (number) (with-cached-result (ol number) (when (getf ol :explanation) (princ #\newline) (princ (getf ol :explanation))))) ;;; API REQUEST FUNCTIONS (defun flag-item (item-number) (with-cached-result (ol item-number) (ensure-config) (api:request-with (:host (host)) (api:put--oneliner-entry-flag (getf ol :id) :token (api-token) :value "true")))) (defun unflag-item (item-number) (with-cached-result (ol item-number) (ensure-config) (api:request-with (:host (host)) (api:put--oneliner-entry-flag (getf ol :id) :token (api-token) :value "false")))) (defun lock-item (item-number) (with-cached-result (ol item-number) (ensure-config) (api:request-with (:host (host)) (api:put--oneliner-oneliner-locked (getf ol :id) :token (api-token) :value "true")))) (defun unlock-item (item-number) (with-cached-result (ol item-number) (ensure-config) (api:request-with (:host (host)) (api:put--oneliner-oneliner-locked (getf ol :id) :token (api-token) :value "false")))) (defun collect-positional-arguments (oneliner) (remove-duplicates (sort (ppcre:all-matches-as-strings "#[1-9][0-9]*" oneliner) #'string<) :test #'equal)) (defun collect-named-arguments (oneliner) (remove-duplicates (ppcre:all-matches-as-strings "#[A-Z_][A-Z0-9_]*" oneliner) :test #'equal)) (defun handle-run-oneliner (ol &optional clip) (if clip (progn (trivial-clipboard:text ol) (format t "Copied oneliner to clipboard~%")) (progn (ensure-config) (format t "Attempting to run:~%") (princ ol) (princ #\newline) (princ #\newline) (run-with-shell ol :shell-name (or (get-shell) "bash"))))) (defun bind-vars-and-run-oneliner (ol args &optional force-clip) (let* ((oneliner (getf ol :oneliner)) (runstyle (getf ol :runstyle)) (pos-args (collect-positional-arguments oneliner)) (named-args (collect-named-arguments oneliner))) (when (or (not (getf ol :isflagged)) (y-or-n-p "This oneliner is flagged. Are you sure you want to run it?")) ;; substitute positional args (loop for param in pos-args for arg in args do (setf oneliner (str:replace-all param arg oneliner))) ;; substitute named args (setf args (mapcar (lambda (s) (str:split "=" s)) (nthcdr (length pos-args) args))) (loop for var in named-args for bound = (assoc (subseq var 1) args :test #'equal) when bound do (setf oneliner (str:replace-all var (second bound) oneliner))) (handle-run-oneliner oneliner (or force-clip (equalp runstyle "manual")))))) (defun run-item (item-number args &optional force-clip) (with-cached-result (ol item-number) (bind-vars-and-run-oneliner ol args force-clip))) (defun valid-oneliner-string-p (string) (and (not (find #\newline string)) (tags-from-oneliner string))) (defun valid-brief-description-p (string) (<= (length string) 72)) (defun valid-runstyle-p (string) (member string '("auto" "manual") :test 'equalp)) (defun aliases () (getf *config* :aliases)) (defun (setf aliases) (newval) (setf (getf *config* :aliases) newval)) (defun alias-item (item alias) (with-cached-result (ol item) (ensure-config) (a:if-let (found (assoc alias (aliases))) (setf (cdr found) ol) (push (cons alias ol) (aliases))) (write-config-to-disk))) (defun lookup-alias (alias) (cdr (assoc alias (aliases)))) (defun run-alias (alias args &optional force-clip) (ensure-config) (a:when-let (ol (lookup-alias alias)) (bind-vars-and-run-oneliner ol args force-clip))) (defun add-new-oneliner () (ensure-config) (assert (api-token) () "Cannot add a oneliner without an api token.") (let* ((oneliner (prompt "Oneliner: " :expect 'valid-oneliner-string-p :retry-text "Oneliners must contain at least one command: ")) (init-tags (tags-from-oneliner oneliner)) (brief (prompt "Brief Description: " :expect 'valid-brief-description-p :retry-text "Too long. Must be <= 72 characters: ")) (tags (append init-tags (ppcre:split " +" (prompt (format nil "Tags in addition to ~{~a ~} ?" init-tags))))) (runstyle (string-upcase (prompt "Runstyle (auto or manual): " :expect 'valid-runstyle-p :retry-text "Must be (auto or manual): " :prefill "auto"))) (explanation (when (y-or-n-p "Provide an explanation?") (string-from-editor (format nil "~a~%~%" oneliner))))) (api:request-with (:host (host) :body (jonathan:to-json (list :oneliner oneliner :tags tags :brief brief :explanation explanation :runstyle runstyle)) :content-type "application/json") (api:post--oneliner :token (api-token)) (format t "Added~%")))) (defun edit-item (n) (ensure-config) (assert (api-token) () "Cannot edit a oneliner without an api token.") (with-cached-result (ol n) (let* ((oneliner (prompt "Oneliner: " :expect 'valid-oneliner-string-p :retry-text "Oneliners must contain at least one command: " :prefill (getf ol :oneliner))) (brief (prompt "Brief Description: " :expect 'valid-brief-description-p :retry-text "Too long. Must be <= 72 characters: " :prefill (getf ol :brief))) (init-tags (tags-from-oneliner oneliner)) (tags (append init-tags (ppcre:split " +" (prompt (format nil "Tags in addition to ~{~a ~}? " init-tags) :prefill (str:join " " (set-difference (getf ol :tags) init-tags :test 'equal)))))) (runstyle (string-upcase (prompt "Runstyle (auto or manual): " :expect 'valid-runstyle-p :retry-text "Must be (auto or manual): " :prefill (getf ol :runstyle)))) (explanation (when (y-or-n-p "Provide an explanation?") (string-from-editor (getf ol :explanation))))) (api:request-with (:host (host) :body (jonathan:to-json (list :oneliner oneliner :tags tags :brief brief :explanation explanation :runstyle runstyle)) :content-type "application/json") (api:patch--oneliner-entry-edit (getf ol :id) :token (api-token)) (format t "OK~%"))))) (defun request-invite-code () (ensure-config) (api:request-with (:host (host)) (let ((invite (jonathan:parse (api:post--invite :token (api-token))))) (format t "Invite Code: ~a~%Expires: ~a~%" (getf invite :code) (getf invite :expires))))) (defun login (user pass) (ensure-config) (a:when-let (response (jonathan:parse (api:request-with (:host (host) :body (jonathan:to-json (list :password pass :handle user)) :content-type "application/json") (api:post--access)))) (setf (api-token) (getf response :token)) (write-config-to-disk) (format t "Access token written to ~a~%" (config-file)))) (defparameter +agree-to-the-unlicense+ "By creating this contributor account, I agree that my contributions be released into the public domain, for the benefit of the public at large, and to the detriment of my heirs and successors. I intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to my contributions under software copyright law copyright law. More specifically, I agree to release all of my contributions using The Unlicense. (see https://unlicense.org/)") (defun redeem-invite (token name pass) (ensure-config ) (when (yes-or-no-p +agree-to-the-unlicense+) (api:request-with (:host (host) :body (jonathan:to-json (list :handle name :password1 pass :password2 pass)) :content-type "application/json") (api:post--invite-redeem-code token) (format t "Account made for ~a. You may log in now~%" name)))) (defun revoke-access () (ensure-config) (api:request-with (:host (host)) (api:delete--access-access (api-token) :token (api-token)))) (defun cache-search-results-to-last-search-file (results) (with-open-file (output (last-search-file) :direction :output :if-exists :supersede) (print results output))) (defun print-oneliner-result-for-user (number oneliner) (dotimes (n 80) (princ #\_)) (terpri) (format t "~3a~a~a~a ~a" number (if (getf oneliner :isflagged) "⚠" " ") (if (getf oneliner :islocked) "🔒" " ") (if (equalp "manual" (getf oneliner :runstyle)) "📋" " ") (getf oneliner :brief)) (format t "~% by: ~12a tags: ~{~a~^ ~}" (getf oneliner :createdby) (getf oneliner :tags)) (format t "~%~% ~a~%~%" (getf oneliner :oneliner))) (defun cache-and-print-search-response (response) (cache-search-results-to-last-search-file (loop for number from 1 for oneliner in (getf (jonathan:parse response) :oneliners) collect (list* :result-number number oneliner) do (print-oneliner-result-for-user number oneliner)))) (defun all-flagged-oneliners (&optional limit) (ensure-config) (api:request-with (:host (host)) (let ((response (if limit (api:get--oneliners-all-flagged :limit limit) (api:get--oneliners-all-flagged)))) (cache-and-print-search-response response)))) (defun search-for-oneliners (terms limit not-flagged-p) (assert (loop for term in terms never (find #\, term) )) (ensure-config) (let ((response (api:request-with (:host (host)) (api:get--oneliners :tags (str:join "," terms) :limit limit :notflagged (if not-flagged-p "true" "false"))))) (cache-and-print-search-response response))) ;;; RUNNING THINGS IN THE SHELL. (defun parent-process-name () "Prints the name of the parent process of the current process." (let ((ppidfile (format nil "/proc/~a/status" (osicat-posix:getppid)))) (first (last (ppcre:split "\\s" (with-open-file (input ppidfile) (read-line input))))))) (defmacro wait-until ((&key (timeout 1) (poll-every 0.01)) &body check) "Run CHECK every POLL-EVERY seconds until either TIMEOUT seconds have passed or CHECK returns non-nil." (let ((clockvar (gensym)) (var (gensym))) `(loop for ,clockvar from 0 by ,poll-every to ,timeout for ,var = (progn ,@check) when ,var return ,var do (sleep ,poll-every)))) (defun run-with-shell (command &key (shell-name (parent-process-name)) (await-output-p 0.8) (output-stream *standard-output*)) "run COMMAND, a string, in a fresh shell environment, initialized with SHELL-NAME. The output from the command read line by line and is printed to OUTPUT-STREAM. " (let ((shell (uiop:launch-program shell-name :input :stream :output :stream))) (symbol-macrolet ((shell-input (uiop:process-info-input shell)) (shell-output (uiop:process-info-output shell))) (write-line command shell-input) (finish-output shell-input) (when await-output-p (wait-until (:timeout await-output-p :poll-every 0.005) (listen shell-output)) (loop while (listen shell-output) do (princ (read-line shell-output) output-stream) (terpri output-stream))))))