org babel 配合 macro 动态生成跨平台的 package 安装脚本

背景

很多人的 package 安装脚本都是简单的做一个 shell 数组 packages=(...) 然后循环安装。但是这样做在跨平台的时候有以下问题:

  1. 软件包的名字在不同的 package manager 下不一定相同
  2. 有时候仅仅按照 <installer-prefix> <pkg-name> 组装的命令完全不够用(e.g. 需要配置 AUR、Brew tap 等)
  3. 有的软件是仅在某个系统中使用的,不希望在别的系统安装

导致最后抽象程度很低,往往要维护多个文件(如 install_xxx.sh),心智负担有点重。(我个人还有个文本文件用来记录每个软件具体安装的目的和一些附加的信息。)

思路

最近刚入坑 Emacs,翻看配置的时候发现 use-package 抽象的很漂亮,于是提醒我了可以用 macro 做一个简单的抽象,然后根据需要 expand 成不同的安装脚本。

同时,我希望这个过程可以和 org babel tangle 有机结合,这样我的 package 描述和安装都可以写在同一个 org 文件。这点受到 Doom Emacs Configuration 的启发。

具体做法

为啥不用 Elisp

琢磨了半天没找到怎么在 eval 代码块的时候不污染 global namspace,而且我并不希望因为这个去给宏起个很长的 prefix 名。

好在利用 noweb 可以直接把 racket 输出的 list 直接给 elisp 代码用。


Packages section 中定义和介绍包,然后用 :noweb-ref 来把它们组合起来。

* Packages
:PROPERTIES:
:header-args:racket: :lang racket :eval no :noweb-ref package-definitions
:END:

** CLI

*** Neovim

#+begin_src racket
(package "Neovim"
         (brew "neovim" --head))
#+end_src

** GUI

*** Discord

#+begin_src racket
(package "Discord"
         (brew "discord" --cask))
#+end_src

*** Emacs

#+begin_src racket
(package "Emacs Plus"
         (brew "emacs-plus"
               #:tap "d12frosted/emacs-plus"))
#+end_src


* Generate script

(这里是生成脚本的地方)

然后在 Generate script 当中放入下面定义宏的代码块:

#+name: get-commands
#+begin_src racket :noweb eval :noweb-prefix no :results output
(require (for-syntax syntax/parse))

(define-syntax (brew stx)
  (define-splicing-syntax-class option
    (pattern (~literal --cask)
             #:with target #'"--cask")
    (pattern (~literal --head)
             #:with target #'"--head"))
  (syntax-parse stx
    [(_ pkg-name:string
        (~alt opt:option
              (~optional (~seq #:tap tap:string))) ...)
     #'(list
        (~? (list "brew" "tap" tap))
        (list "brew" "install" pkg-name (~? opt.target) ...))]))

(define-syntax (package stx)
  (syntax-case stx ()
    ;; No matching arms.
    [(package name) #'(void)]
    ;; (package (<mgr0> <rest0>*) (<mgr> <rest>*)*)
    [(package name (mgr0 rest0 ...) (mgr rest ...) ...)
     (if (equal? (syntax-e #'mgr0) 'brew)
         (syntax-case #'(mgr0 rest0 ...) (::)
           [(mgr :: raw0 raw ...) #'(:: raw0 raw ...)]
           [_ #'(mgr0 rest0 ...)])
         #'(package name (mgr rest ...) ...))]))

(append <<package-definitions>>)
#+end_src

最后用以下两个简单的代码块分别控制使用的 package manager 和生成最终的 tangle 代码块:

#+name: pkgmgr
#+begin_src racket :results output :eval no
'brew
#+end_src
#+name: generate
#+begin_src elisp :noweb eval :noweb-prefix no :results raw
(format-spec
 "#+begin_src shell :tangle ~/Desktop/test.sh :eval no
%p
,#+end_src"
 `((?p . ,(string-join (-map (lambda (line) (string-join line " "))
                          <<get-commands()>>)
                    "\n"))))
#+end_src

最终结果:

#+call: generate()

#+RESULTS:
#+begin_src shell :tangle ~/Desktop/test.sh :eval no
brew install neovim --head
brew install discord --cask
brew tap d12frosted/emacs-plus
brew install emacs-plus
#+end_src

最初设想是跨平台,甚至跨语言(不局限于 Bash,所以宏展开的不是直接的字符串)的生成,然后还可以在最终生成的脚本上下加入一些 prologue 和 epilogue。

做完这些回想了一下感觉有点蠢,因为:

  1. 其实我只有 mac
  2. :var xxx=xxx 不能在宏展开生效,导致必须多做个代码块控制目标 package manager,而不能直接类似 #+call: generate("brew")

本着抛砖引玉的想法发到论坛上,也想请教一下大家有没有更好的想法!

软件部署的痛点是平台兼容性问题,所以才会选择容器,一个安装脚本吃遍全平台。

但是你做的这个东西可以用来当多用途下载器,用来处理不同的数据源。这是很常见的一种设计模式,工厂模式。

2 个赞

是这个意思,相对于容器化部署,这个更多为了个人使用,提供一层抽象表达一些配置文件的通用逻辑。

目前这个随手糊的比较简陋,实际上可以扩展的逻辑很多,比如软件安装本质上感觉是个DAG而不是线性的。另外也可以把各个软件的配置也抽象起来。

想表达的总体思路还是: macro提供统一抽象->展开某种数据结构->生产target平台/语言的代码->tangle+noweb生产具体文件

GitHub - emacs-straight/system-packages: Mirror of the system-packages package from GNU ELPA, current as of 2025-03-11 这个就够了,另外其实搞shell脚本更省事一点

没想到还有这样的包,我回头研究下,感谢提醒!

安利一下用 nix 吧哈哈哈哈。

nix 支持 macOS 和 linux,可以通过写配置文件来配置你的整个 OS 的 dev 环境 (nixOS 或者 nixDarwin)或者不配置系统的全局环境,仅仅配置在你的 home 目录或者说针对你个人用户生效的 dev 环境(nix home-manager)。

写个 Makefile 吧

对我也觉得该了解一下nix/guix看看所以就把这个想法烂尾了哈哈…

个人看法觉得makefile本质上只是重新组织shell,并没有提供一层更好的抽象的能力?也可能我makefile写少了的缘故。

是啊, Makefile 就是专门用来处理依赖的.

你这个需求为啥还要用 Makefile?

不好意思,没有理解你的意思,也可能我没有表达清楚。

我知道Makefile是处理依赖的,我的意思是Makefile本身类似于重新组织shell,但是我想要的是一个抽象层(比如某种声明式DSL),然后shell只是这个抽象层的其中一个可能的输出,所以才去用racket macro实现。

目前我实现的简单内容确实是Makefile就能很好完成的东西,但是我做的初衷不是这个。就像楼上说到的一样,我准备看下guix home,唯一的顾虑是nix/guix抽象的可能过多了,但是这个是另一个话题了。