《elisp 每日一练》—— 搭建本地私有 Package 仓库

《elisp 每日一练》—— 搭建本地私有 Package 仓库

Masimaro
2026-11-06 / 0 评论 / 1 阅读 / 正在检测是否收录...

在开始之前先给各位追更的读者道个歉,该系列博文有一个“每日一练”,原计划我也打算尽力日更,但是在11月开始之后,工作上的事务慢慢增多了,各种项目的日程都开始紧凑起来,上班期间能休息摸鱼的时间几乎没有了,所以未来我无法做到日更了。将来我们的进度可能1周能更新1到2篇或者3篇,更新时间也不那么固定了。下面我们开始进入正题:

前面的文章中,我们提到,Emacs下载、更新包时非常重要的一个步骤就是调用 package-refresh-contents 函数同步远程服务器中的 package-archives-contents。后续根据这个文件来决定如何更新下载第三方的package。

理论上,只要我们有网络服务,服务器按规则组织package 包,并且拥有 archives-contents 数据,就可以搭建一个自己的软件包仓库。所以本节我们将演示如何一步步搭建自己的软件仓库,一来让各位读者能更进一步的理解安装下载的过程,在一个,未来如果出现断网或者需要在与外网隔绝的环境下工作时有一个快速恢复工作环境到新机器上的手段。

背景知识

Emacs的包管理系统识别一个地址是否是package 仓库的方式十分简单粗暴:

  1. 一个 archive-contents 文件:存放在目录根部,记录了仓库内所有可用包的元数据与版本。
  2. 包文件本体:所有对应的 .el 单文件、.tar 归档文件,或者解压后的包子目录,全部平铺或按规范存放在该目录下。

所以我们要建立备份需要分两步走:第一步对于多文件包,我们需要将它打包为 tar 包。但是仅仅一个文件的.el 文件可以不用打包。另外在 ~/.emacs.d/elpa 目录中我们需要排除 archives 目录,该目录主要记录不同服务器的 archive-contents。

在Windows上可以使用下面的powershell 脚本:

# backup-elpa.ps1
# 只负责:遍历 elpa 目录,多文件包打成 tar,单文件包复制 .el,输出到备份目录。
# 不生成 archive-contents。
# 用法: .\backup-elpa.ps1 -ElpaDir "$env:USERPROFILE\.emacs.d\elpa" -OutDir "E:\elpa-backup"

param(
    [Parameter(Mandatory=$true)]
    [string]$ElpaDir,

    [Parameter(Mandatory=$true)]
    [string]$OutDir
)

$ErrorActionPreference = "Stop"

$ElpaDir = (Resolve-Path $ElpaDir).Path
if (-not (Test-Path $OutDir)) {
    New-Item -ItemType Directory -Path $OutDir | Out-Null
}
$OutDir = (Resolve-Path $OutDir).Path

$tarCmd = Get-Command tar.exe -ErrorAction SilentlyContinue
if (-not $tarCmd) {
    throw "找不到 tar.exe,请确认已安装并在 PATH 中。"
}

$pkgDirs = Get-ChildItem -Path $ElpaDir -Directory
$count = 0

foreach ($dir in $pkgDirs) {
    $pkgName = $dir.Name
    $pkgPath = $dir.FullName

    # 跳过 elpa 下的非包目录
    if ($pkgName -eq "archives" -or $pkgName -eq "gnupg") { continue }

    # 查找 -pkg.el 描述文件
    $pkgDescFile = Get-ChildItem -Path $pkgPath -Filter "*-pkg.el" -File -ErrorAction SilentlyContinue | Select-Object -First 1

    if ($pkgDescFile) {
        # ---- 多文件包:解析名称和版本,打包为 tar ----
        $content = Get-Content -Path $pkgDescFile.FullName -Raw -Encoding UTF8
        $content = $content -replace "^\uFEFF", ""

        $m = [regex]::Match($content, '\(\s*define-package\s+"([^"]+)"\s+"([^"]+)"', [System.Text.RegularExpressions.RegexOptions]::Singleline)
        if (-not $m.Success) {
            Write-Warning "无法解析 $($pkgDescFile.Name),跳过 $pkgName"
            continue
        }
        $name    = $m.Groups[1].Value
        $version = $m.Groups[2].Value

        $tarName = "$name-$version.tar"
        $tarPath = Join-Path $OutDir $tarName
        if (Test-Path $tarPath) { Remove-Item $tarPath -Force }

        # -C 切到 elpa 目录,包内顶层目录名为包名,符合 ELPA 惯例
        $tarArgs = @("-chf", $tarPath, "-C", $ElpaDir, $pkgName)
        & $tarCmd.Source @tarArgs
        if ($LASTEXITCODE -ne 0) {
            Write-Warning "打包 $pkgName 失败 (tar 退出码 $LASTEXITCODE)"
            continue
        }

        Write-Host "打包: $tarName"
        $count++
    }
    else {
        # ---- 单文件包:找主 .el 文件,复制到输出目录 ----
        $elFiles = Get-ChildItem -Path $pkgPath -Filter "*.el" -File |
                   Where-Object { $_.Name -notmatch "-pkg\.el$" -and $_.Name -notmatch "-autoloads\.el$" }
        if ($elFiles.Count -eq 0) {
            Write-Warning "目录 $pkgName 下没有找到 .el 文件,跳过"
            continue
        }
        $mainEl = $elFiles | Select-Object -First 1
        $singlePath = Join-Path $OutDir $mainEl.Name
        Copy-Item -Path $mainEl.FullName -Destination $singlePath -Force

        Write-Host "复制: $($mainEl.Name)"
        $count++
    }
}

Write-Host "完成。共处理 $count 个包,输出目录: $OutDir"

该脚本简单的完成了遍历指定目录,然后跳过 archive 目录,对其他目录的内容进行打包并备份到指定目录。这里我把备份目录设置为 E:/backup-elpa/。

当然这部分脚本也可以使用elisp 来完成,打包成 tar 的步骤可以利用前面介绍的过的同步调用外部进程的方式进行。读者有兴趣可以自己实现。

第二步我们需要准备 archive-contents 文件,一个办法是自己研究目录的结构手写一个,还有就是利用 package 系列函数。这里我们使用 package-upload-file 函数。它接收一个tar包或者单独的el文件,它会复制这个文件到指定位置并添加一条 archive-content 记录。

所以这里我们可以利用如下的elisp代码实现

(defun backup-archive-contents (backup-elpa-dir)
  "根据指定目录中的 tar/el 包生成对应的 archive-contents 文件。"
  (interactive "D备份elpa 所在目录:")
  (let ((package-archive-upload-base (file-name-as-directory
                                      (expand-file-name backup-elpa-dir)))
        (files (directory-files backup-elpa-dir t "\\.\\(tar\\|el\\)\\'")))
    (dolist (f files)
      (let* ((base (file-name-nondirectory f))
             (tmp (make-temp-file "elpa-upload-" nil
                                  (concat "." (file-name-extension f)))))
        (rename-file f tmp t)
        (condition-case err
            (package-upload-file tmp)
          (error
           (message "处理 %s 失败: %s" base (error-message-string err))))
        (when (file-exists-p tmp)
          (delete-file tmp))))
    (message "archive-contents 已生成: %s"
             (expand-file-name "archive-contents" package-archive-upload-base))))

这里因为 package-upload-file 有两个职责:第一,会将指定的文件上传备份到 package-archive-upload-base 所指定的文件位置,这里因为二者实际是同一个目录,所以在上传之前对原始文件重命名,并且在上传之后删除原始文件, 如果二者不在统同一目录可以省略掉这一步。我们只需要它的第二个作用:生成文件。这里其实可以利用elisp代码做到打包tar之后直接上传并生成。但是我们分了两步,希望强化读者对这个步骤的认识。

测试时我们可以利用 package-delete 删除已安装的包。然后设置 package-archives 为本地备份。

(setq package-archives
      '(("local-backup" . "E:/backup-elpa/")))

之后我们就可以尝试使用 package-list-packages 和 package-install 之类的函数测试我们的备份是否生效。

总结

到此我们发现自己的本地备份已经能正常工作了。但是我们只备份了现在安装的包,如果未来要在本地安装新包可能又需要网络环境。但是我们可以利用这个原理爬取整个 melpa 作为本地镜像。

另外需要跟读者讲明的一点是,本节所涉及的脚本和方案仅仅是作为教学目的,带领读者一步一步的组织自己的备份,理解其中的原理。在实践中可以利用 elpa-mirror 这个第三方包完成工作。

0

评论 (0)

取消