;;;; 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") (defvar *ol-output-timeout* 0.8) (defun valid-config-p (config) (and (listp config) (evenp (length config)) (stringp (getf config :host)) t)) (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 make-config (&key host api-token editor (shell "bash")) (append (when host (list :host host)) (when api-token (list :api-token api-token)) (list :shell shell))) (defun make-fresh-config () (format t "No configuration file has been found. Running Setup~%~%") (setf *config* (make-config :host (prompt "Oneliner Instance Host: " :prefill "https://api.oneliners.wiki") :shell (prompt "With which shell should commands be run: " :prefill "bash"))) (write-config-to-disk) (format t "Configuration has been written to ~a~%. Edit this at any time.~%~%" (config-file))) (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)) (make-fresh-config)) (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 contributor-handle () (getf *config* :handle)) (defun (setf contributor-handle) (newval) (setf (getf *config* :handle) newval)) (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-Za-z_][A-Za-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 &key force-clip (timeout nil timeout-p)) (let ((*ol-output-timeout* (if timeout-p timeout *ol-output-timeout*))) (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 (progn (format t "Tags include: ~{~a ~}~%" init-tags) (append init-tags (ppcre:split " +" (prompt "More tags here, or Enter to skip: "))))) (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 (progn (format t "Tags include: ~{~a ~}~%" init-tags) (append init-tags (ppcre:split " +" (prompt "More tags here, or Enter to skip: " :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))))) (let ((new-item (list :oneliner oneliner :tags tags :brief brief :explanation explanation :runstyle runstyle))) (api:request-with (:host (host) :body (jonathan:to-json new-item) :content-type "application/json") (api:patch--oneliner-entry-edit (getf ol :id) :token (api-token)) (update-history-item n new-item) (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) (contributor-handle) user) (write-config-to-disk) (format t "Access token written to ~a~%You may now make contributions to the wiki!.~%" (config-file)))) (defun change-pw (current new repeated) (unless (equal new repeated) (error "The new password doesn't match the repeated value. Double check.")) (ensure-config) (api:request-with (:host (host)) (api:put--contributor-who-password (contributor-handle) :token (api-token) :value new :repeated new :current current))) (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 prompt-for-signature () (if (y-or-n-p "Provide a contributor signature about yourself? ") (prompt "Go ahead: ") "")) (defun change-signature () (let ((new-sig (prompt-for-signature))) (ensure-config) (api:request-with (:host (host) :body (jonathan:to-json (list :signature new-sig)) :content-type "application/json") (api:put--contributor-who-signature (contributor-handle) :token (api-token)) (format t "Your signature was changed.~%")))) (defun print-contributor (contributor) (format t "~20a ~@[-- ~a~]~%" (getf contributor :handle) (getf contributor :signature))) (defun show-contributor (name) (ensure-config) (api:request-with (:host (host)) (a:when-let (contributor (api:get--contributor-who name)) (print-contributor (jonathan:parse contributor))))) (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 :signature (prompt-for-signature))) :content-type "application/json") (api:post--invite-redeem-code token) (format t "Account made for ~a. You may log in now~%" name)))) ;;TODO: check this .. shouldnt access be a username??? (defun revoke-access () (ensure-config) (api:request-with (:host (host)) (api:delete--access-access (api-token) :token (api-token)) (format t "You were logged out~%"))) (defun update-history-item (n item) (when (uiop:file-exists-p (last-search-file)) (let* ((results (with-open-file (input (last-search-file)) (read input))) (ol (nth (1- n) results))) (when ol (setf (nth (1- n) results) (append item ol)) (cache-search-results-to-last-search-file results))))) (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 newest-oneliners (&optional limit) (ensure-config) (api:request-with (:host (host)) (let ((response (if limit (api:get--oneliners-newest :limit limit) (api:get--oneliners-newest)))) (cache-and-print-search-response response)))) (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) finally (return nil)))) (defun run-with-shell (command &key (shell-name (parent-process-name)) (await-output-p *ol-output-timeout*) (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) (if (and await-output-p (plusp 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))))))