Rand Stats

RakuPM

zef:skyter10086

raku-pm — 一个教学用 Raku 包管理器

English | 简体中文 架构与深度设计 · 快速上手

仿照 Raku 官方包管理器 zef 的分层可插拔架构, 用约 3700 行 Raku(19 个模块 + 1 个 CLI,不含空行与注释)实现了一个最小可用 (MVP)的包管理器,用于理解包管理器的核心原理。

仓库地址:https://gitee.com/skyter10086/raku-pm

安装本项目

raku-pm 自己也是个标准的 Raku 发行版,根目录带 META6.json,所以能被 zef 安装:

git clone https://gitee.com/skyter10086/raku-pm.git
cd raku-pm
zef install .          # 先 --dry 干跑也行

装完会多出一个 raku-pm 命令(Windows 下 zef 生成 raku-pm.exe,在 site/bin 里):

raku-pm                            # 无参数:打印帮助,退出码 0
raku-pm help                       # 同上(推荐)
raku-pm install Web::App           # 按模块名装(从本地仓库)
RAKUPM_REPO=repo raku-pm install Web::App   # 显式指定仓库根
raku-pm install https://github.com/raku-community-modules/File-Temp.git
                                   # 从 git 仓库装(含依赖,与仓库安装同流程)
zef uninstall RakuPM               # 卸载

小坑raku-pm --help 在 PowerShell 下会被 zef wrapper 拦截成"Usage: ..."的默认提示(wrapper 用 *% 命名参数吃掉了 --help),所以推荐用 raku-pm help 或直接 raku-pm 看完整帮助。

推荐:直接跑源码,不用 zef 装

raku bin/raku-pm.raku list

这条路实际上比 zef install . 更稳,建议在下面这些情况优先用它:

原因是:直接跑源码时,预编译写在项目本地的 lib/.precomp,用的就是当前这个 raku, 天然自洽;而 zef install 会把包装进 site/,预编译由 zef 当时用的那个 raku 来编 —— 如果 zef 和运行时不是同一个 raku(多版本环境下很常见),指纹就对不上。

想要 raku-pm 这个命令,自己写个 wrapper 即可,不必惊动 zef:

cat > ~/.local/bin/raku-pm <<'EOF'
#!/usr/bin/env bash
exec raku "$HOME/raku-pm/bin/raku-pm.raku" "$@"
EOF
chmod +x ~/.local/bin/raku-pm

(把 $HOME/raku-pm 换成你实际 clone 的路径。)

两个环境变量控制路径,不设就用默认值:

变量作用默认值
RAKUPM_REPO本地仓库根(扫描其下 META6.json当前目录 .
RAKUPM_TARGET安装目标根(store / site / git-cache / 锁文件)~/.raku-pm

从 git URL 安装

raku-pm 可以直接从 git 仓库装一个发行版,并且会递归解析它的依赖, 走的是与仓库安装完全相同的一套流程。

raku-pm install https://github.com/raku-community-modules/File-Temp.git

输出:

==> 克隆到本地缓存:.../git-cache/github.com/raku-community-modules/File-Temp.git
将安装 2 个发行版(自底向上,依赖优先):
  · File::Directory::Tree:ver<0.2>:auth<zef:raku-community-modules>
  · File::Temp:ver<0.0.0-ad3445e>:auth<git:github.com>   ← git: https://github.com/...
==> 下载源码包 [zef]:https://360.zef.pm/F/IL/FILE_DIRECTORY_TREE/8f155b8a....tar.gz
==> 存入存储:File::Directory::Tree 0.2 (24be1140)
==> 已激活 File::Directory::Tree 0.2
==> 存入存储:File::Temp 0.0.0-ad3445e (be25c423)
==> 已激活 File::Temp 0.0.0-ad3445e

两条安装路径的差异只有一处:目标包的源码从哪来

仓库安装git 安装
目标包从哪找各生态索引git clone 出来的目录
依赖从哪找各生态索引各生态索引(同一套 Resolver)
依赖解析递归 + 拓扑排序完全相同
逐包动作构建 → 入 store → 跑测试 → 激活完全相同
写锁整条链整条链

实现上就是这么对齐的:Client 把「逐包落地」抽成了 !install-chain, 接受一个 %source-override(键 "<name>\0<version>")来指明 「这个包的源码目录不在仓库里,用我给的这份」。git 模式只是往里塞了 clone 目录这一项。

三个细节:

要点:

META6.json 里写了什么

META6.json 就是 Raku 世界的"身份证"——zef 靠它知道这个发行版叫什么、有哪些模块、依赖什么。本项目声明了:

设计细节(架构 / 安装流程 / 原子安装与世代回滚 / 锁文件 / 并发 / 缓存回收 / 与 zef 对应等)已迁入 docs/architecture.md

挑版本的三个坑

解析器(Resolver)在真实生态数据上踩过三个坑,这里的处理方式是本项目最"非玩具"的部分。

1. provides 会随版本漂移 —— 必须按版本校验

同一发行版的 provides 不是一成不变的。真实案例:

DBIish 0.5.5–0.6.1 曾把 NativeLibs 打在自己包里,0.6.2 之后拆分成了 独立的 NativeLibs 发行版。

于是模块 NativeLibs 同时出现在两个发行版的 provides 历史里。如果只按 "这个发行版某版本提供过"去挑最高版本,就会挑中 DBIish 0.6.8 —— 而它压根不含 NativeLibs,装完 DB::SQLiteuse NativeLibs 失败。

解决办法是给仓库加了 versions-providing($module, $dist) 接口:只返回该版本确实 提供此模块的版本号。Repository::Ecosystem 直接扫索引行(快路径), role 里还有一份通用兜底(逐个版本物化后检查 provides)。

2. 不同发行版的版本号没有可比性

上例中即便两个候选都合法,也不该直接比版本。规则是:

  1. 发行版名与模块名一致者优先 —— NativeLibs 模块的正主是 NativeLibs 发行版;
  2. 其次比版本高低(走 semver,不是字符串 cmp —— "0.9" > "0.10" 是错的);
  3. 最后按名字字典序,保证结果稳定可复现。

3. :from<native> 是系统本地库,而且各系统长得不一样

DB::SQLite 依赖 sqlite3:from<native>,这是 zef 用来标记系统本地库的 phaser (要的是 libsqlite3,不是 Raku 包)。raku-pm 不打包系统库,处理方式是:

运行时通过 NativeCall 直接加载的库也查(不只 :from<native>)。很多包 (典型如 Duckielibduckdb)是源码里 is native('duckdb') 运行时加载的, META6 里不写 :from<native>,静态依赖检查看不到它,只能等测试阶段加载库失败 才暴露(还附一大段调用栈)。raku-pm 在取到源码后、跑测试前扫描 lib//bin/ 里的 is native('...'),把这些库名也纳入缺失检查,缺了同样直接放弃安装。 随包打进 resources/libraries/ 的库由 Rakudo 经 %?RESOURCES 解析,不算系统缺失, 不会被误杀。

关键在于同一个逻辑名在不同系统上是不同的文件

逻辑名WindowsmacOSLinux
sqlite3sqlite3.dlllibsqlite3.dyliblibsqlite3.so
ssllibssl-3-x64.dlllibssl.dyliblibssl.so
libffilibffi-8.dlllibffi.dyliblibffi.so
zlibzlib1.dlllibz.dyliblibz.so
未知库 foofoo.dlllibfoo.dyliblibfoo.so

注意 Windows 是不需要 lib 前缀的那一支,直接照搬 Unix 规则会全军覆没。

搜索路径同样分系统:

安装命令也分系统,Linux 还细分发行版族(apt / dnf / pacman / apk / zypper 的 包名各不相同,给错命令等于没给)。认不出具体发行版时把各家族命令都列出来, 而不是退化成某一条(更不能退成 Windows 的命令)。

$ raku-pm install DB::SQLite
将安装 5 个发行版(自底向上,依赖优先):
  · BitEnum:ver<0.5>
  · Concurrent::Stack:ver<1.3>
  · NativeLibs:ver<0.0.9>
  · DB:ver<0.5>
  · DB::SQLite:ver<0.7>

本地库依赖检查(Windows,raku-pm 不打包系统库):
    ✓ sqlite3  已在 D:\raptor\sqlite3.dll
    → 全部就绪。

缺失时(以 Linux 为例)会直接放弃安装,不再只警告了事(否则要跑到测试 / 加载阶段才炸,还附一大段内部调用栈):

本地库依赖检查(Linux/Unix,raku-pm 不打包系统库):
    ✗ sqlite3  未找到 —— 本系统需要 libsqlite3.so / libsqlite3.so.0
                  安装:apt install libsqlite3-dev
⚠  Linux/Unix 上缺少 1 个本地库,放弃安装:sqlite3
   请先按上面的命令装好本地库,再重试安装。
   (`raku-pm native <名字>` 可单独查询;如确需跳过本检查可加 --no-native-check,但运行时加载这些库仍会失败)

实现在 RakuPM::NativeLib,也可以单独查:

raku-pm native sqlite3                        # 本机有没有
raku-pm native ssl curl --paths               # 一次查多个,--paths 打印搜索路径
raku-pm native --installed                    # 扫锁文件,列出已装发行版声明的本地库
raku-pm native sqlite3 --os=linux --distro=ubuntu   # 不切机器查另一台该装什么

最后一条能查别的系统,是因为 os / distro 可强制指定——这也让三套规则能在 同一台机器上被测试完整覆盖(t/native-lib.t)。

4. 还有两种「不是 Raku 包」的依赖:外部命令与平台条件依赖

除了 :from<native>,生态里还有两种写法,一样不能当 Raku 模块去查仓库:

:from<bin> / :from<Perl5>——外部命令或别的语言

Doc::TypeGraph 依赖 dot:from<bin>(graphviz 的 dot),Documentable 依赖 node:from<bin>POSIX:from<Perl5> 则是 Perl 5 的模块。raku-pm 的处理与本地库 同构:解析时从 Raku 依赖表里摘出来,安装时在 PATH 里查一下,缺了就提示, 不阻断安装(Raku 代码照常装好):

外部命令依赖检查(raku-pm 不负责安装系统命令):
    ✗ dot  未在 PATH 中找到
⚠  缺少 1 个外部命令:dot
   请先用系统包管理器装好(apt / brew / choco …);Raku 代码照常会装好。

平台条件依赖 by-distro.name

zef 允许把依赖写成「按平台取」:

"depends": [ { "name": { "by-distro.name": { "": "", "mswin32": "Win32::Registry" } } } ]

意思是只有 $*DISTRO.namemswin32 时才需要 Win32::Registry,其它平台取 兜底键 "" —— 也就是空。真实样本是 File::Which 1.0.4;不认这个语法时整块 Hash 会被当成模块名(字符串化后形如 by-distro.name\nmswin32 Win32::Registry), 于是 Linux / WSL 上装任何依赖它的包都会死在「找不到模块」—— raku-pm install Digest::SHA1::Native 就是这么挂的(LibraryMake → File::Which)。

现在的处理:按当前平台取键(优先 $*DISTRO.name,其次 *,最后 "" 兜底), 取不到就当没有这条依赖。注意方向不能反:平台条件依赖的含义是「别的平台 不用装」,把全部分支都装上会让 Linux 去装 Win32::Registry,同样必失败。

{"any": [...]} 多选一:逐个看,跳过外部命令与本地库,取第一个 Raku 模块。

快速开始

export RAKUPM_REPO=repo          # 仓库目录
export RAKUPM_TARGET=~/.raku-pm  # 安装目标(store/ + site/ + 锁文件)

安装当前目录的包(install .

对应 zef 的 zef install .

raku-pm install .                      # 装当前目录
raku-pm install ./some-dist            # 装指定子目录
raku-pm install /abs/path/to/dist      # 绝对路径也行

它读目录里的 META6.json 认出发行版,然后 递归解析依赖 → 自底向上装依赖 → 最后装这个包。目录里没有 META6.json 会直接报错。

别用 RAKUPM_REPO=<发行版根目录> 来替代。 Local 仓库的布局是 「一个仓库目录下挂多个发行版子目录」(repo/DistA/META6.json),它只扫子目录; 而一个发行版的 META6.json 就在自己的根目录上,扫不到。

实现上它和 git 安装共用同一段逻辑(!install-local-dist)—— 两者的本质相同: 目标包不在任何仓库里,Resolver 看不见它,只能由我们把它接到依赖链最后, 区别只是源码目录从哪来(clone 目录 vs 本地目录)。

指定版本(与 zef 语法一致)

安装规格串支持 zef 的那套 phaser 写法,解析器是独立的轻量模块 RakuPM::SpecDistribution.parse-spec 保留为委托)—— 独立出来是因为这套写法后来不止 install 在用:search / installed / store / version / fetch 这些查询与取源码 命令同样要认,而查询路径不该为了解析规格串把 Distribution 整条模块图拖起来。 它正是 identity() 的逆操作:

写法含义
Foo:ver<1.2.3>精确 1.2.3
Foo@1.2.3@ 简写,等价上面
Foo --version=1.2.3传统写法,等价于上面两种
Foo:ver<1.2+>至少 1.2(zef 的 + 后缀),挑满足约束的最高版
Foo:ver<1.2.3>:auth<zef:someone>版本 + 作者
Foo:ver<1.2.3>:auth<zef:x>:api<1>版本 + 作者 + api

优先级:--version > 规格串里的 :ver<> > 位置参数约束

两个要点:

这套写法不止 install 认 —— 查询/取源码命令同款:

命令效果
search 'Foo:ver<1.2+>'仓库与已装两侧都按版本筛;同名在【满足约束】的版本里取最高 —— 最高版本不满足、低版本满足的包也能被搜出来
installed 'Foo:ver<1.0>'已装列表只看满足约束的版本;一个都不满足时明确提示(而不是悄悄空输出)
installed 'Foo:auth<zef:x>'已装列表再按作者筛(auth 筛选作用于仓库行与账本条目)
store 'Foo@1.0'store 缓存只看满足约束的版本,附完整路径
version 'Foo:ver<1.0>'查已装版本时按约束筛(激活标记仍按全局激活版本算)
fetch 'Foo:ver<1.2+>'取源码按约束挑版本(--version 仍为精确语义,优先级同 install)

<> 是 shell 重定向符,各平台怎么写:

  • bash / zsh:整串加引号即可,如 search 'Foo:ver<1.2+>'
  • Windows cmd加引号也不够 —— raku-pm.bat 里是 raku ... %*, cmd 会对 %* 二次解析,而引号在传给 .bat 之前就被剥掉了,于是 search "Foo:ver<1.2.0>" 照样报「命令语法不正确」。改用不带尖括号的 @ 写法,与 :ver<> 完全等价、任何 shell 都不用转义:
    raku-pm search Foo@1.2+
    raku-pm installed Foo@1.0
    

查询命令对「不是合法约束」的 ver(如 search foo@bar 里的 bar)有兜底: 整串当普通关键词,行为与不带版本写法的旧版完全一致 —— 坏约束不该把 所有版本悄悄筛没。

装旧版本需要先挂 REA —— zef 索引只收录当前版本:

raku -Ilib bin/raku-pm.raku repos add rea
raku -Ilib bin/raku-pm.raku install 'Concurrent::Stack:ver<1.1>'
将安装 1 个发行版(自底向上,依赖优先):
  · Concurrent::Stack:ver<1.1>:auth<cpan:JNTHN>

安装

# 从默认生态(https://360.zef.pm)安装真实世界的包及其依赖
raku -Ilib bin/raku-pm.raku install File::Temp

# 安装模块及其依赖(取满足约束的最高版本)
raku -Ilib bin/raku-pm.raku install Web::App

# 指定精确版本(降级也是这个)—— 三种写法等价
raku -Ilib bin/raku-pm.raku install 'Concurrent::Stack:ver<1.1>'
raku -Ilib bin/raku-pm.raku install 'Concurrent::Stack@1.1'
raku -Ilib bin/raku-pm.raku install Concurrent::Stack --version=1.1

# 只解析不落地
raku -Ilib bin/raku-pm.raku install Web::App --dry

# 跳过 t/ 测试(某些包在 Windows 上测不过时用)
raku -Ilib bin/raku-pm.raku install File::Temp --no-test

# 严格模式:锁与解析结果不符就报错(CI 用)
raku -Ilib bin/raku-pm.raku install Web::App --locked

装完之后把 inst#<target>/site 加进模块搜索路径即可使用(raku-pm env 可直接给你这串):

export RAKULIB="inst#$HOME/.raku-pm/site"
raku -e 'use File::Temp; say tempfile'

# 精确选版本(前提是用 --version 装过旧版)
raku -e 'use File::Temp:ver<0.0.10>; say tempfile'

查询

raku -Ilib bin/raku-pm.raku list        # 仓库里可安装的包
raku -Ilib bin/raku-pm.raku search Temp # 仓库索引 + 本地已装的包都搜([方括号] 标注来自哪个仓库)
                                        # 已装但不在任何索引里的(如 RakuPM 自己)标 [已装]
raku -Ilib bin/raku-pm.raku search 'JSON::Fast:ver<1.0+>'  # 查询词也认安装规格串:按版本筛
raku -Ilib bin/raku-pm.raku repos       # 列出已配置的仓库
raku -Ilib bin/raku-pm.raku installed            # 已安装及其版本
raku -Ilib bin/raku-pm.raku installed Foo        # 只看 Foo 这个模块的所有版本(* 为激活)
raku -Ilib bin/raku-pm.raku installed Web::App   # 模块名也认(精确):发行版叫 Web-App 也能找到
raku -Ilib bin/raku-pm.raku installed 'Foo:ver<1.0>'   # 只看满足约束的版本;:auth<> 同理
raku -Ilib bin/raku-pm.raku store                # 本地存储(* 为激活版本,附各版本完整路径)
raku -Ilib bin/raku-pm.raku store Foo            # 只看 Foo 的缓存版本与各自完整路径
raku -Ilib bin/raku-pm.raku store Web::App       # 模块名也认(精确),同 installed
raku -Ilib bin/raku-pm.raku store 'Foo@1.0'      # @ 简写同款,按约束筛缓存版本
raku -Ilib bin/raku-pm.raku version 'Foo:ver<1.0>'  # 查已装版本时按约束筛
raku -Ilib bin/raku-pm.raku which JSON::Fast   # 这个模块实际从哪个仓库加载、版本多少(多份全列出)
raku -Ilib bin/raku-pm.raku which              # 跨仓库冲突总览:哪些发行版装在多个仓库里
raku -Ilib bin/raku-pm.raku lock        # 锁文件内容
raku -Ilib bin/raku-pm.raku verify      # 校验已装包是否被篡改

匹配口径:search / store / installed / install 共用一份实现RakuPM::Repository::Matching,仓库层 role;RakuPM::RepositoryRakuPM::Client::Reporterdoes 它,改一处四处同步)。 关键词只匹配【发行版名 + provided 模块名】,不扫 description —— 描述里常写 "provides method xxx",扫它会把方法名也当成匹配项 (早先 search method 能搜到一堆包就是这个原因)。

  • search:模糊匹配 + 相关度排序(名精确 < 名前缀 < 名子串 < 模块名精确 < 模块名前缀 < 模块名子串);
  • store / installed精确匹配(精确发行名 或 精确模块名, 所以 installed Web::App 能找到发行版 Web-App,但 installed Web 不会误列);
  • install:索引化的精确模块名反查(find-providers,等价上面的「模块名精确」档)。

这段判定是纯函数(只吃 名字 / 模块名 / 关键词),所以单测 t/match.t 不需要构造仓库、不需要 HTTP 桩

仓库管理

raku -Ilib bin/raku-pm.raku repos add rea      # 加 Raku 生态存档(别名)
raku -Ilib bin/raku-pm.raku repos add https://example.com/eco.json
raku -Ilib bin/raku-pm.raku repos remove rea
raku -Ilib bin/raku-pm.raku repos update       # 强制刷新全部索引缓存(zef update 同款)
raku -Ilib bin/raku-pm.raku repos update rea   # 只刷新 rea 这一个

维护

raku -Ilib bin/raku-pm.raku upgrade JSON    # 升级到最新
raku -Ilib bin/raku-pm.raku uninstall JSON  # 卸载

安装 Web::App 的输出示例:

将安装 3 个发行版:
  · JSON:ver<2.0.0>:auth<demo:raku>
  · HTTP-Client:ver<1.2.0>:auth<demo:raku>
  · Web-App:ver<2.0.0>:auth<demo:raku>
==> 存入存储:JSON 2.0.0 (d47078e2)
==> 存入存储:HTTP-Client 1.2.0 (5732165b)
==> 存入存储:Web-App 2.0.0 (167f481e)
==> 已激活 JSON 2.0.0
==> 已激活 HTTP-Client 1.2.0
==> 已激活 Web-App 2.0.0
✓ 完成。锁文件已更新:./_demo/raku-pm.lock

从生态装真实包的输出(含下载与测试阶段):

将安装 2 个发行版(自底向上,依赖优先):
  · File::Directory::Tree:ver<0.2>:auth<zef:raku-community-modules>
  · File::Temp:ver<0.0.12>:auth<zef:raku-community-modules>
==> 下载源码包 [zef]:https://360.zef.pm/F/IL/FILE_DIRECTORY_TREE/8f155b8a....tar.gz
==> 存入存储:File::Directory::Tree 0.2 (24be1140)
  · 运行测试:File::Directory::Tree 0.2
    ✓ 01-basic.rakutest
==> 已激活 File::Directory::Tree 0.2
==> 下载源码包 [zef]:https://360.zef.pm/F/IL/FILE_TEMP/9e97bc86....tar.gz
==> 存入存储:File::Temp 0.0.12 (3ad733c0)
  · 运行测试:File::Temp 0.0.12
    ✓ 01-basic.rakutest
    ✓ 02-gc.rakutest
    ✓ 03-tempfile.rakutest
==> 已激活 File::Temp 0.0.12
✓ 完成。

装一个只在 REA 里有、zef 索引里没有的包(ADT,代数数据类型):

$ raku -Ilib bin/raku-pm.raku install ADT
在所有已配置的仓库里都找不到模块 'ADT'。
  提示:· 用 `raku-pm repos add rea` 加上 Raku 生态存档(收录更全,含历史版本);

$ raku -Ilib bin/raku-pm.raku repos add rea
已添加仓库 'rea' → https://raw.githubusercontent.com/Raku/REA/main/META.json(优先级 #1)

$ raku -Ilib bin/raku-pm.raku install ADT
==> 拉取生态索引 [rea]:https://raw.githubusercontent.com/Raku/REA/main/META.json
将安装 1 个发行版(自底向上,依赖优先):
  · ADT:ver<0.5>:auth<github:timo>
==> 下载源码包 [rea]:https://raw.githubusercontent.com/raku/REA/main/archive/A/ADT/...
==> 存入存储:ADT 0.5 (0cd38e3d)
  · 运行测试:ADT 0.5
    ✓ 01-tree.t
    ✓ 02-EXPORT.t
    ✓ 03-positional.t
    ✓ 04-whitespace.t
==> 已激活 ADT 0.5
✓ 完成。

生产侧(作者侧)

前面各节都是消费侧——「装什么、装到哪」。还有一半生命周期是生产侧: 面向你自己那个发行版目录,维护它的元数据。raku-pm 自己提供的生产者命令:

登录 / 发布:本地目录仓库的发布(publish)已实现;真实生态的上传在 0.57.0 完整 落地——login(凭据管理,含用户名+密码联网登录换 key)+ publish --remote (multipart PUT 到 42.zef.pm/upload,与 fez upload 同端点同形态)。默认上传 端点 42.zef.pm(规范的 fez/zef 上传后端;raku-pm 默认用的 360.zef.pm 只是只读 镜像,无上传 API)。注:42.zef.pm 上传后端是私有服务,能否用取决于该生态服务端。

创建骨架:raku-pm new

new 是作者侧起点,在当前位置生成一个【全新】发行版目录。生成物:

目录名默认 = 模块名里 ::-(mi6 惯例);--into=<路径> 显式指定(. = 当前目录)。 目标目录若存在且非空、又没 --force,直接报错,绝不静默覆盖用户文件。

raku-pm new Foo::Bar                    # 创建 Foo-Bar/ 骨架
raku-pm new Foo::Bar --into=MyProj      # 用指定目录名
raku-pm new Foo::Bar --with-readme --bin=foo --auth=github:me --version=0.2.0
raku-pm new Foo::Bar --dry              # 只预览将要创建的文件

refresh 解决一个高频且很坑的问题:往 lib/ 里加了新模块、或改了模块文件名之后, META6.json 里的 provides 就与磁盘不一致了。手工维护极易漏,而 provides 一旦 不对,别人装出来的包就 use 不到(你自己本地却看着没事)。

raku-pm refresh              # 以磁盘为准重建当前目录的 provides
raku-pm refresh ./some-dist  # 指定目录
raku-pm refresh --dry        # 只预览将要发生的改动,不写文件

输出示例:

==> 重建 META6.json 的 provides:/path/to/Web-App
    磁盘 lib/ 下扫到 3 个模块
  · 加入(磁盘上有、声明里没有):
      + Web-App
      + Web::Legacy
      + Web::Router
  · 剔除(声明里有、磁盘上没有,写了也装不上):
      - Web::Ghost
  ✓ 已更新 /path/to/Web-App/META6.json

语义边界(刻意保守,只做能由磁盘唯一推导的事):

字段refresh 怎么处理
provides完全重建:扫 lib/ → 模块名 → 相对路径(.rakumod/.pm6 都认,.rakudoc 不算模块)
resources / bin不动。资源靠显式声明管理(mi6 也是单用 fez resource);bin 是路径数组,无法从磁盘唯一确定
其余字段原样保留(name / version / auth / depends / tags / source-url …)
内容没变时不写文件(不去白改 mtime)

它不在 RakuPM::Client 树里,也不进写锁、不碰安装目标——只读你给的那个目录, 所以 CLI 在构造 Client 之前就把它分发出去了(不该为了改一个 META6.json 去初始化 target / store / 索引缓存)。

发布前预检:raku-pm check

check上架前安全闸门:在真去连服务器发布之前,先把「客户端能确定的硬错误」 全查一遍——等价于 fez review / mi6 release 前的 meta 校验,但覆盖更全。默认 纯本地、秒级、不联网;加 --remote 才联网查「同版本是否已发布」。

校验项:

raku-pm check                 # 预检当前目录
raku-pm check ./some-dist     # 指定目录
raku-pm check --remote        # 额外联网查同版本是否已发布(同版本不可重传)

报告用 [✗] 标错误、[!] 标警告;任何硬错误都以非零退出,可直接接进 CI 发布门禁。--remote 查不到(离线 / 索引拉取失败)只告警、不阻断——最终的 「同版本不可重传」由服务器强制。

打包 sdist:raku-pm dist

dist 把校验过的发行版卷成可上传的源码归档 <name>-<version>.tar.gz (这是 publish 的输入物)。顺序是:先跑 check 门禁(有硬错误直接拒, 绝不打包一个过不了预检的包)→ 可选地跑构建阶段 → 打 tar.gz。

raku-pm dist                 # 打包当前目录 → ./<name>-<version>.tar.gz
raku-pm dist ./some-dist     # 指定目录
raku-pm dist --no-build      # 跳过构建阶段
raku-pm dist --to=../out     # 放到 ../out 下

部署到本地仓库:raku-pm publish

publishdist 打出的 sdist 部署到一个本地目录仓库,使别人 / CI 能 raku-pm install 从那里装上它。这是 fez publish 在「本地 / 教学」场景下的等价物: 真实生态是把 sdist 上传到远端索引服务器(42.zef.pm,需要服务端支持 + 账号登录), 其中凭据管理(login)+ 请求构造(publish --remote --dry 预览)已在 M2 落地真实 multipart PUT 上传在 M3 实现;本地场景则是把源码放进一个目录仓库 (已用 raku-pm repos add 登记过的目录)。

raku-pm publish                 # 打包当前目录并解包进 RAKUPM_PUBLISH_REPO(或报错提示 --to)
raku-pm publish --to=../my-dists # 解包进指定本地仓库
raku-pm publish --from=../out/Foo-1.0.0.tar.gz --to=../my-dists  # 复用现成包
raku-pm publish --dry           # 只打印落点,不写文件
raku-pm login --api-key=<key>   # 把生态 api-key 存到 ~/.raku-pm/credentials.json
raku-pm login --username=me --password=secret   # 联网登录 42.zef.pm/login 换 key 并保存
raku-pm publish --remote --dry  # 预览真实生态上传请求(不联网)
raku-pm publish --remote        # 真实上传到 42.zef.pm/upload(需先 login 或 RAKUPM_API_KEY)

发布之后,别的机器 raku-pm repos add <repo> 即可 raku-pm install <name> 拿到 你这一份——作者侧工作流 new → refresh → check → dist → publish 至此闭合。

版本编排:raku-pm bump

bump 按 semver 递增 META6.jsonversion,并在 Changes 顶部加一条 ## X.Y.Z - DATE 版本头(没有 Changes 会自动从标题起创建)。约定与项目纪律一致: patch=修 bug / minor=新能力 / major=磁盘布局或不兼容变更

raku-pm bump                 # 默认 --patch:0.1.0 → 0.1.1
raku-pm bump --minor         # 0.1.0 → 0.2.0
raku-pm bump --major         # 0.1.0 → 1.0.0
raku-pm bump 2.5.0           # 位置参数像版本号 → 精确指定:→ 2.5.0
raku-pm bump --to=2.5.0      # 同上(显式写法)
raku-pm bump --date=2026-09-15   # 指定版本头日期(默认今天)
raku-pm bump --dry           # 只预览,不改 META6 / Changes

当前版本无法推断(如 version: '*')时,bump 会报错要求用 --to= 显式指定。

测试

raku -Ilib t/version.t        # 版本解析与约束匹配(15 项)
raku -Ilib t/store-lock.t     # 存储、版本切换、锁文件(11 项)
raku -Ilib t/meta6-local.t    # META6.json 解析与本地仓库(14 项)
raku -Ilib t/depends.t        # depends 的多种写法归一化,含 :from<native>(18 项)
raku -Ilib t/by-distro-depends.t  # 平台条件依赖 by-distro.name:按当前平台取舍,不再把 Hash 当模块名(13 项)
raku -Ilib t/external-deps.t  # :from<bin>/:from<Perl5> 外部命令与 {"any":...} 多选一(17 项)
raku -Ilib t/file-lock.t         # 跨进程文件锁:互斥 / 可重入 / 进程被杀自动释放 / 超时报错(20 项)
raku -Ilib t/client-write-lock.t # Client.with-write-lock:写命令真的串行、嵌套不自锁(9 项)
raku -Ilib t/clean.t          # 缓存回收:受保护版本一个不删 / 默认试算 / --all 与 --name 与 --older-than(31 项)
raku -Ilib t/repos.t          # 多仓库配置的增删改与持久化 + repos update 刷新索引缓存(36 项)
raku -Ilib t/resolve-provides.t  # 挑版本的三个坑:provides 漂移 / 跨发行版比版本 / 环(18 项)
raku -Ilib t/native-lib.t     # 本地库的跨系统文件名、搜索路径、安装命令(50 项)
raku -Ilib t/git-install.t    # git 安装的依赖闭包:递归 / 去重 / 冲突 / 自提供模块(17 项)
raku -Ilib t/git-url-dep.t    # depends 里的裸 git URL:识别进 git-deps + 先 clone 装完再装声明它的包 + 幂等(13 项)
raku -Ilib t/git-version.t    # install-from-git 的版本约定:preserve-version 保留真版本 / 否则 0.0.0-*(3 项)
raku -Ilib t/multi-version.t  # 多版本与 use :ver<>:真的挑到指定版本而不是静默给最新版(27 项)
raku -Ilib t/resources.t      # resources/ 复制 + 装完 %?RESOURCES 可读(12 项)
raku -Ilib t/conflict-msg.t   # 依赖冲突报错带完整请求链(16 项)
raku -Ilib t/build.t          # 构建阶段:Build.pm / builder 字段 / build-depends(28 项)
raku -Ilib t/skip-installed.t # 跳过已装/已入库 + 老格式 store 自动重新入库 + 安装链账本自愈(39 项)
raku -Ilib t/bin-and-sort.t   # bin wrapper + semver 排序 + 自包含 wrapper + PATH 扫描 + which/cygpath 失败自扫兜底 + site-bin promote + Windows .bat + raku 路径 fallback + 跨平台端到端(46 项)
raku -Ilib t/query-installed-store.t  # installed [模块] / store [模块] 查询:版本与完整路径(24 项)
raku -Ilib t/cli-version.t    # version / version <模块> 查已装与激活版本(9 项)
raku -Ilib t/spec.t           # 规格串解析 RakuPM::Spec:各形态 parse + 约束合法性 + Distribution 委托等价(70 项)
raku -Ilib t/search-spec.t    # 查询/取源码命令的规格串:installed/store/version/fetch/search 按版本与 auth 筛(61 项)

t/resolve-provides.t 用内存假仓库复现真实生态的畸形数据(DBIish 捆绑 NativeLibs、 互相依赖、自环、跨发行版版本比较),并对「扫索引快路径」与「role 通用兜底」 两种 versions-providing 实现跑同一组断言,确保二者行为一致。

t/native-lib.t 覆盖本地库在 win32 / darwin / linux 三套系统上的文件名映射、 搜索路径与安装命令。之所以能在同一台机器上测三套,是因为 RakuPM::NativeLibos / distro 可以强制指定(同时也是 raku-pm native --os=... 的实现基础)。

t/git-install.t 用内存假仓库(菱形依赖图:App → LibA/LibB → Deep/Helper) 验证 git 安装的依赖闭包:递归到两层深、共享依赖只装一份、拓扑序正确、 跨根版本冲突能检出、目标包自己提供的模块不重复装。 它测的是 Client.resolve-deps-of,不需要真的 git clone。

t/git-url-dep.t 回归 Draku 那类 depends 写裸 git URL 的包:本地起两个 git 仓库(主包依赖另一个),主包 depends 里写 https://…/GitDep.git。依赖仓库的 git-cache 预置一份拷贝让嵌套 clone 走「复用缓存」分支,全程离线 —— 验证 URL 被收进 git-deps、轮到主包前先把它 clone 装完、账本同时记下两个包、 重复安装幂等。另有纯解析断言覆盖 git:// / git@… / https://…/xxx.git 等 URL 形态与「普通模块不受影响」。

t/spec.t 钉死规格串解析器本身:parse 的各形态(:ver<> / :auth<> / :api<> / @ 简写 / 未知 phaser 不吞模块名)、constraint-valid 的合法与 非法边界(纯单词 "bar" 必须判非法,否则查询会把所有版本筛没),以及 Distribution.parse-spec 委托与 Spec.parse 的逐项等价。

t/search-spec.t 走真 CLI 子进程验证查询命令的规格串支持:installed 的 版本/auth 筛、store 的缓存筛、version 的约束筛、fetch 按约束挑版本取源码 (含 at-least 1.0+ 与取不到时的非 0 退出)、search 仓库与已装两侧筛。 子进程预写只含 local 条目的 repositories.json,全程离线。

t/multi-version.t真的起 raku 子进程use MultiVer:ver<...>, 用「模块自己声明的 :ver<> 里写的版本号」来断言到底加载了哪一份。 这是唯一能证明「没被静默换成最新版」的办法 —— 只查 installed.json 是不够的。

t/build.t 覆盖构建阶段:Legacy Build.pm(成功/返回假/抛异常三种出路)、 META6 builder 字段的类调用、build-depends 先装再构建, 以及最重要的端到端场景 —— Build 生成 resources/libraries/libdemo.dll, 装完后 %?RESOURCES<libraries/libdemo> 读到的就是构建产物 (META6 里声明【无扩展名】的名字,Rakudo 安装时按平台自动补后缀, 这是它对 native 资源的固有约定,zef 行为一致)。

t/skip-installed.t 覆盖「跳过已处理内容」:版本完全一致的已装包 (raku-pm 自己账本 + zef 的 site/home/vendor 仓库链)整个跳过、 已在 store 的包跳过构建与测试、老格式 store 条目(0.2.x 之前的,缺标准 META6.json)自动重新入库,以及 --force 强制全部重跑。

版本约定

META6.json 里的版本号每次改动都递增,遵循 semver

例如 0.1.0 → 0.2.0 就是把安装目录从平铺的 lib/ 换成了 site/CompUnit::Repository::Installation),属于不兼容变更:已装的包要重装, RAKULIB 也要从 <target>/lib 改成 inst#<target>/site。 这类变更会在 release note 里明确写出来。

已实现 vs 待实现

已实现:多索引仓库(配置持久化 + 别名 + 环境变量多值)、远程生态仓库 (索引缓存 + HTTP 下载 + tar 解压)、META6.json 解析、依赖树解析 (递归/冲突/环处理/拓扑排序,按「该版本是否真提供此模块」挑版本)、 git 仓库安装(clone + 递归解析依赖 + 自底向上,与仓库安装同一条流水线)、 装进 CompUnit::Repository::Installation多版本共存 + use :ver<> 精确选版本)、 :from<native> 本地库识别与跨系统探测(三套文件名 + 三套搜索路径 + 按发行版给安装命令)、 多版本共存存储、内容指纹校验、 只装缺失的依赖(依赖已被已装包满足——含 zef 装的——就不再要求仓库里有, 自装的本地包可以被其他本地包当依赖)、 跳过已处理的内容(版本完全一致的已装包 —— 包括 zef 装的 —— 整个跳过; 已在 store 的包跳过构建与测试;老格式 store 条目(缺 META6.json 或资源声明过时)自动重新入库; 账本自愈installed 时对账 + install 跳过路径上也当场补记:site 里有而账本缺记的包自动补记, 自己装的包不会再"消失";--force 全部重跑)、 构建阶段(Build.pm:class Buildbuild 方法,与 META6 "builder" 字段 如 Distribution::Builder::MakeFromJSON,均按 zef 的语义在发行版根目录起子进程执行; build-depends 会在构建前自动装好;--no-build 可跳过)、 resources/ 随包复制并在 META6.json 声明(装完 %?RESOURCES 可用,native 包的 .so/.dll 才能被找到)、 依赖冲突报错带完整请求链(谁要求了什么版本,一眼定位该 pin 哪边)、 锁文件(含严格模式)、指定版本安装、升级、卸载清理、测试阶段、预编译、 META6 bin 字段自动出 wrapper(zef 同款:装完就放到 rakudo 的 site/bin/—— 那个目录默认在 PATH 上——所以 raku-pm 自装后直接敲 raku-pm 就能跑; wrapper 是自包含 bash 脚本,不依赖软链解析/外部 RAKULIB;Windows 额外生成 .bat wrapper 供 cmd/PowerShell 用;macOS 的 BSD sort-V 时自动退回字典序兜底; site/bin 只读时就回落到 ~/.raku-pm/bin/ + raku-pm env 提示)、多版本激活按 semver 取最高(不是字符串字典序)、 查询/取源码命令也认安装规格串(search / installed / store / version / fetch 都能写 Foo:ver<1.2+> / Foo@1.2.3 / :auth<>,解析器抽成零依赖的 RakuPM::Spec,install 与查询共用;非法约束整串当关键词兜底)、 发行版元信息查询四条命令(与 zef 对齐):info <包> 显示版本/描述/依赖/ 源码地址等详情;browse <包> 在浏览器打开主页或源码地址;locate <模块> 定位 模块在磁盘上的真实文件位置(走仓库链解析,多份共存时指出被遮蔽的那些); smoke [包…] 取源码并跑测试(不给规格则测全部已装,可能很慢)。

CompUnit::Repository::Installation(zef 装包用的就是它)里装包,多版本可并存, use Foo:ver<1.2.3> 由 Rakudo 自己解析。

待实现(真实 zef 还有的能力):

  1. 卸载时的依赖回收 ✅ 已实现,见下节。
  2. 依赖树可视化rdepends 反查谁依赖了我。 ✅ 已实现,见下节。
  3. 索引增量更新:整份索引缓存,没做 ETag / If-Modified-Since。已实现(0.36.x 起):HTTP 门面与两个后端都支持条件 GET (If-None-Match → 304 复用缓存),ETag 单独存 <cache>.etag。 注意 RAKUPM_INDEX_TTL 控制复用窗口,TTL 一过仍会去问一次(只是大概率拿 304)。
  4. 降级后自动切换激活版本:见上面「降级的坑」——Raku 的 use(无约束)永远 取最高版本,所以只能靠 --only 卸掉更高的版本来实现,或者引入 raku-pm 自己的 pin 机制(需影响 Rakudo 的加载,成本高,暂不做)。

生产侧(作者侧)——与上面「消费侧」并列的另一半生命周期:

能力状态眼下谁在做
重建 META6.json 的 providesraku-pm refresh(0.37.7)mi6 的 mi6 build
创建发行版骨架raku-pm new(0.39.0)mi6 new / fez init
构建与打包(sdist)raku-pm dist(0.41.0,Build.pm 内联)mi6 build + dist.ini 插件
版本与变更编排(bump / Changesraku-pm bump(0.41.0)mi6 release
上架前预检raku-pm check(0.40.0)fez review
上传凭据管理(login)raku-pm login(0.56.0)fez login
上传到生态raku-pm publish --remote(0.57.0,multipart PUT 到 42.zef.pm)fez upload

设计取向:发布走同协议直连生态 API(不 wrap fez),保证 raku-pm 发的包 fez/zef 都能看到、都能管;publish 会坚持 dry-run 优先 + 二次确认, 因为生态侧规定同一版本不可重传(auth 必须与上传者匹配、拒绝 version=*)。