Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,12 +352,21 @@ Caveats:
## Terminal DWIM command

I often like using a separate terminal to run most commands, instead of emacs.
This package provides a `devops-open-terminal-dwim` command, which opens the current source block in a terminal. (Only `ghostty` supported at the moment, but more terminals planned.)
This package provides a `devops-open-terminal-dwim` command, which opens a terminal at the heading's target (or at `default-directory` when the heading has none).

Any `var` references become environment variables loaded into the (usually remote)
shell. The source block content is copied to the clipboard, so you can do
`devops-open-terminal-dwim`, then paste, and you will be running the command at the correct location.

`devops-terminal-program` picks the terminal:

- `ghostty` (default): opens an external [Ghostty](https://ghostty.org) window, running `ssh` for a remote target.
- `ghostel`: opens a [ghostel](https://github.com/dakra/ghostel) terminal inside Emacs, reaching a remote target through TRAMP. Each target gets its own buffer (`*ghostel /ssh:web1:/srv/app/*`), reused on the next call. When that terminal is already open, the `export` lines and the block body are pasted at its prompt, so you only press `RET`. A new terminal gets the variables in its environment, and you yank the body yourself (`C-y`): its shell isn't at a prompt yet, and a paste would run line by line.

```elisp
(setq devops-terminal-program 'ghostel)
```

## devops-lob

`devops.el` uses org's Library of Babel (LOB) to save commonly used server commands.
Expand Down
72 changes: 72 additions & 0 deletions devops-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -1706,6 +1706,78 @@ override (issue #14)."
'("ghostty" "-e" "ssh" "-t" "example.com"
"cd /srv/app && $SHELL"))))

(ert-deftest devops--ghostel-buffer-name-test ()
"Each target gets its own ghostel terminal, local or remote."
(should (equal (devops--ghostel-buffer-name "/ssh:deploy@example.com:/srv/app/")
"*ghostel /ssh:deploy@example.com:/srv/app/*"))
(should-not (equal (devops--ghostel-buffer-name "/ssh:web1:/srv/")
(devops--ghostel-buffer-name "/ssh:web2:/srv/"))))

(ert-deftest devops--ghostel-environment-test ()
"Env vars become KEY=VALUE entries, any value printed as text."
(should (equal (devops--ghostel-environment '(("FOO" . "a b") ("N" . 3)))
'("FOO=a b" "N=3")))
(should-not (devops--ghostel-environment nil)))

(ert-deftest devops--terminal-input-test ()
"Exports, shell-quoted, come before the body; nothing to paste is nil."
(should (equal (devops--terminal-input '(("FOO" . "a b")) "echo $FOO")
"export FOO=a\\ b\necho $FOO"))
(should (equal (devops--terminal-input nil "ls") "ls"))
(should (equal (devops--terminal-input '(("N" . 3)) nil) "export N=3"))
(should-not (devops--terminal-input nil nil)))

(defvar ghostel-buffer-name)
(defvar ghostel-environment)

(defmacro devops-test--with-fake-ghostel (&rest body)
"Run BODY with a stand-in for the ghostel package.
`ghostel' records the buffer name and environment it was called with in
`started' and returns a buffer of that name; `ghostel-paste-string'
records its text in `pasted'. Buffers it made are killed afterwards."
(declare (indent 0) (debug t))
;; `features' is not special, so it is set and restored, not let-bound.
`(let ((saved-features features)
(ghostel-buffer-name "*ghostel*")
(ghostel-environment '("KEEP=1"))
(started nil)
(pasted nil))
(cl-letf (((symbol-function 'ghostel)
(lambda (&optional _arg)
(push (list ghostel-buffer-name ghostel-environment) started)
(get-buffer-create ghostel-buffer-name)))
((symbol-function 'ghostel-paste-string)
(lambda (text) (push (list (buffer-name) text) pasted))))
(unwind-protect
(progn
(push 'ghostel features)
,@body)
(setq features saved-features)
(dolist (s started)
(when-let* ((buf (get-buffer (car s))))
(kill-buffer buf)))))))

(ert-deftest devops--open-ghostel-new-terminal-test ()
"A new terminal gets the env vars in its environment; nothing is pasted."
(devops-test--with-local-target dir
(devops-test--with-fake-ghostel
(devops--open-ghostel dir '(("FOO" . "bar")) "echo $FOO")
(should (equal started
`((,(devops--ghostel-buffer-name (file-name-as-directory dir))
("FOO=bar" "KEEP=1")))))
(should-not pasted))))

(ert-deftest devops--open-ghostel-open-terminal-test ()
"A terminal already open gets exports and body pasted, not its environment."
(devops-test--with-local-target dir
(devops-test--with-fake-ghostel
(let ((name (devops--ghostel-buffer-name (file-name-as-directory dir))))
(get-buffer-create name)
(push (list name nil) started) ; so the macro kills it
(devops--open-ghostel dir '(("FOO" . "bar")) "echo $FOO")
(should (equal (car started) `(,name ("KEEP=1"))))
(should (equal pasted `((,name "export FOO=bar\necho $FOO"))))))))

;;; Drift detection (devops-drift.el)

(defmacro devops-test--with-drift-check (target org-fmt &rest body)
Expand Down
83 changes: 71 additions & 12 deletions devops.el
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,12 @@
:prefix "devops-")

(defcustom devops-terminal-program 'ghostty
"Terminal program to use for externally opening target locations."
:type '(choice (const ghostty))
"Terminal program `devops-open-terminal-dwim' opens target locations in.
`ghostty' is an external terminal, which reaches a remote target by
running ssh. `ghostel' is a terminal inside Emacs, from the ghostel
package, which reaches it through TRAMP."
:type '(choice (const :tag "Ghostty (external)" ghostty)
(const :tag "Ghostel (inside Emacs)" ghostel))
:group 'devops)

(defcustom devops-enable-session-async nil
Expand Down Expand Up @@ -875,13 +879,63 @@ is an alist of (NAME . VALUE) exported in that shell."
`("ghostty" ,(concat "--working-directory=" dir) "-e" "bash" "-c" ,(concat env-exports " && exec $SHELL"))
`("ghostty" ,(concat "--working-directory=" dir))))))

(defun devops--open-terminal-at-dir (dir &optional env-vars)
"Open `devops-terminal-program' in DIR with ENV-VARS exported."
(defvar ghostel-buffer-name)
(defvar ghostel-environment)
(declare-function ghostel "ghostel" (&optional arg))
(declare-function ghostel-paste-string "ghostel" (string))

(defun devops--ghostel-buffer-name (dir)
"Return the name of the ghostel terminal for DIR, one per target."
(format "*ghostel %s*" (abbreviate-file-name dir)))

(defun devops--ghostel-environment (env-vars)
"Return ENV-VARS, an alist of (NAME . VALUE), as `ghostel-environment'."
(mapcar (lambda (pair) (format "%s=%s" (car pair) (cdr pair)))
env-vars))

(defun devops--terminal-input (env-vars body)
"Return the text to paste at a shell prompt, or nil.
ENV-VARS, an alist of (NAME . VALUE), become `export' lines ahead of
BODY, which may be nil."
(let ((lines (append (mapcar (lambda (pair)
(format "export %s=%s" (car pair)
(shell-quote-argument
(format "%s" (cdr pair)))))
env-vars)
(and body (list body)))))
(and lines (string-join lines "\n"))))

(defun devops--open-ghostel (dir env-vars body)
"Pop to the ghostel terminal for DIR, with ENV-VARS exported.
A new terminal starts with ENV-VARS in its environment. A terminal
already open gets ENV-VARS and BODY pasted at its prompt, not run.
A new one does not, since its shell would run each pasted line as it
started; BODY is in the kill ring for it."
(unless (require 'ghostel nil t)
(user-error "Package ghostel is not installed"))
(let* ((default-directory (file-name-as-directory (expand-file-name dir)))
(ghostel-buffer-name (devops--ghostel-buffer-name default-directory))
(open (get-buffer ghostel-buffer-name))
(ghostel-environment (append (unless open
(devops--ghostel-environment env-vars))
ghostel-environment))
(buf (ghostel)))
(when-let* ((open)
(text (devops--terminal-input env-vars body)))
(with-current-buffer buf
(ghostel-paste-string text)))))

(defun devops--open-terminal-at-dir (dir &optional env-vars body)
"Open `devops-terminal-program' in DIR with ENV-VARS exported.
BODY, a src block's body or nil, is for the program to offer at the
prompt."
(pcase devops-terminal-program
('ghostty
(let ((ghostty (devops--ghostty-command dir env-vars)))
(message "Calling process:\n%s" (string-join ghostty " "))
(apply #'start-process "devops-terminal" nil ghostty)))))
(apply #'start-process "devops-terminal" nil ghostty)))
('ghostel
(devops--open-ghostel dir env-vars body))))

(defun devops-src-block-env-vars ()
"Return alist of evaluated :var params from current src block."
Expand Down Expand Up @@ -909,16 +963,21 @@ is an alist of (NAME . VALUE) exported in that shell."

;;;###autoload
(defun devops-open-terminal-dwim ()
"Open terminal at contextual directory.
In a src block, if the : copies body to clipboard and exports :var env vars."
"Open `devops-terminal-program' at the heading's target.
With no target, open it in `default-directory'. In a shell src block,
export the block's :var arguments as environment variables and copy its
body to the kill ring. Ghostel also pastes them at the prompt of a
terminal already open at the target."
(interactive)
(let* ((dir (devops--heading-target-dir))
(let* ((dir (or (devops--heading-target-dir) default-directory))
(env-vars (devops-src-block-env-vars))
(lang (org-element-property :language (org-element-at-point))))
(when (or (string= lang "shell") (string= lang "sh"))
(kill-new (devops--src-block-body))
(lang (org-element-property :language (org-element-at-point)))
(body (and (member lang '("shell" "sh"))
(devops--src-block-body))))
(when body
(kill-new body)
(message "Source block copied to kill ring."))
(devops--open-terminal-at-dir dir env-vars)))
(devops--open-terminal-at-dir dir env-vars body)))

(defun devops-unload-function ()
"Turn `devops-mode' off, removing its advice before unloading.
Expand Down
Loading