Skip to content

项目任务 ​

在 osdk.toml 里用 [tasks] 声明项目命令,然后用 osdk run <名字> 执行。 目标是替代大多数 Makefile 与 npm scripts 的用途:任务默认就是「伪目标」, 不需要 .PHONY;依赖按拓扑顺序执行;用什么工具版本由 osdk 自己注入。

最简形式 ​

toml
[tasks]
build = "cargo build --release"
test = "cargo test"
osdk run build

这一档覆盖大多数场景,多数项目的多数任务应当停在这里。

多条命令 ​

toml
[tasks.ci]
run = [
  "cargo fmt --check",
  "cargo clippy -- -D warnings",
  "cargo test",
]

数组元素依次执行,任一条失败就停止,后面的命令不再运行。这等价于 && 串联,但不依赖任何 shell 语法,因此在 Windows 与 Unix 上行为一致。

为什么不要用 & ​

& 在不同 shell 下是三件不同的事,同一份配置会在两个平台做两件事,而且 都不报错:

shella & b 的含义
Unix sha 放后台,b 立即开始(并发)
Windows cmda 跑完再跑 b,无条件,前一条的失败被吞掉
PowerShell 7尾随 & 表示后台 Job
PowerShell 5.1语法错误

所以「失败也继续」和「并行执行」各有专门写法,见下面两节。

失败也继续 ​

toml
[tasks.ci]
run = [
  { cmd = "cargo clippy -- -D warnings", ignore_error = true },
  "cargo test",
]

被容忍的失败不会让任务失败,但会打印一条警告——容忍不等于无人知晓。

并行执行 ​

toml
[tasks.check]
run = [
  "cargo fmt --check",
  { tasks = ["clippy", "test", "doc"] },
  "echo all-green",
]

{ tasks = [...] } 里的任务一起执行,全部完成后才继续下一步。osdk 会 等待它们并收集退出码,任一失败就中止——这正是 & 做不到的事,后台进程 无人回收,失败会被悄悄丢弃。

依赖 ​

toml
[tasks.build]
run = "cargo build"
depends = ["fetch-deps"]

前置任务先执行,共享的依赖只跑一次。顺序在 depends 之间不作保证: 若确实需要「先 A,再 B 和 C 一起」,用上一节的 run 步骤表达,而不是 堆叠 depends。

依赖成环会在任何命令执行前报错,并打印出环的路径:

error: task dependency cycle: a -> b -> a

depends 里的名字写错同样在执行前报错,不会跑到一半才发现。

工具依赖 ​

任务不会从 shell 文本里推断工具。请显式声明它需要的 [tools] 键:

toml
[tools]
node = "24"
"npm:eslint" = { version = "9", lazy = true }

[tasks.lint]
run = "eslint ."
tools = ["node", "npm:eslint"]

所选任务图中的任何命令开始前,osdk run 会先校验全部引用,只安装尚未就绪的工具。 通过 depends、并行 { tasks = [...] } 步骤和 run_post 到达的任务也包含在内。 任务的显式依赖会包含 lazy = true 的工具,但不会改变之后裸 osdk install 的行为。

每一项必须是当前 [tools] 配置中的键,不能临时写成 tool@version 请求。当前平台 lock 中已有该工具时使用其中的精确重放请求,否则从配置版本和 option 解析。未知或被平台过滤的键会在 安装或任务执行前报错。--no-deps 只关闭 [deps] 自动兑现,不会忽略任务自己的 tools 依赖。

第三档:脚本文件 ​

一段脚本写到二十行上下,编辑器就不再高亮它、linter 也看不见它,TOML 的转义 开始比省下的括号更费事。这时把它挪进 osdk-tasks/:

osdk-tasks/
  build.ps1          -> osdk run build
  test/units.ps1     -> osdk run test:units
  test/_default.ps1  -> osdk run test

文件名去掉扩展名就是任务名,目录变成 : 分隔的命名空间,_default 指代目录 本身。也可以在 TOML 里显式指向一个脚本:

toml
[tasks.release]
description = "Cut a release"
file = "scripts/release.ps1"

file 相对声明它的那个配置文件解析,不是相对最终的合并根目录——否则全局 配置里的相对路径会指向当前项目,而不是它自己所在的位置。

.lua 文件是特例:osdk 不把它交给操作系统启动,而是使用与 lua = """...""" 完全相同的内嵌 Lua 运行时。它因此不需要系统安装 Lua、shebang 或执行位,并继承相同的 任务目录、环境、参数、host API 与超时语义:

toml
[tasks.generate]
file = "scripts/generate.lua"

默认 osdk-tasks/ / .osdk-tasks/ 目录里发现的 .lua 文件也采用这一行为。

脚本头里的元数据 ​

脚本用注释声明自己的描述和依赖,不必回到 TOML:

powershell
#OSDK description="Build the release artifacts"
#OSDK depends=fetch, lint

解析在第一个非注释行处停止,所以头必须在文件顶部——写在中间没人会去那里找。

改用别的目录 ​

toml
[task]
includes = ["tools/tasks"]

这是替换而不是追加:项目把脚本挪走,通常不希望默认目录还被搜索,悄悄保留 会让搬迁想要退休的任务重新出现。

Windows 可见性 ​

NTFS 没有执行位,所以判据是扩展名在 exe/bat/cmd/com/ps1/vbs 之内,或者 文件以 shebang 开头,二者有其一即可。两者都没有的文件在 Linux/macOS 上能用、 在 Windows 上无法启动。

这类任务仍然会出现在 osdk task list 里并标注原因,而不是消失——一个凭空 不见的任务会让人回去检查拼写,而真正的修法(加扩展名或加 shebang)是猜不到的。

跨平台的写法是同名配对:build(带 shebang)与 build.ps1 放在一起,Windows 取后者,其余平台取前者,任务名都是 build。

.ps1 用哪个 PowerShell ​

cmd 无法直接启动 .ps1,所以这类脚本总是交给 PowerShell 执行。选择是在运行时 探测的:PATH 上有 pwsh(7+)就用它,没有则回退系统自带的 powershell(5.1)。

这不是可有可无的兼容:PowerShell 7 在 Windows 上是独立下载项,全新安装的系统里 只有 5.1。写死 pwsh 会让每个 .ps1 任务在这类机器上以「找不到程序」失败。

两者都支持 -File,所以命令行其余部分完全相同。如果脚本用到了 7 才有的语法, 就在脚本里自己检查 $PSVersionTable.PSVersion。

与 shell 钩子的关系 ​

任务的环境由 osdk 直接注入,因此四种 shell 的激活片段都会在检测到 OSDK_TASK 时主动避让。否则 profile 钩子会在任务内重新按配置推导一遍环境,把任务自己 的 env、以及任务刚装好的工具覆盖掉。

第四档:内嵌 Lua ​

前三档覆盖绝大多数任务。当确实需要条件分支、循环生成命令、跨平台路径运算 时,用 lua:

toml
[tasks.sync]
lua = """
for _, package in ipairs(argv) do
  local code = run("cargo", "test", "-p", package)
  if code ~= 0 then return code end
end
return 0
"""

字段名是 lua 而不是 run,这样一眼能看出任务在哪一档;两者同时出现会被 拒绝,而不是由运行器猜测你想要哪个。

脚本返回 nil 或 true 表示成功,返回数字作为退出码,return false 记为失败。 抛错则任务失败并带上错误信息。

可用的 API ​

osdk.sh(cmd)走平台 shell 执行,返回退出码而非抛错
osdk.run(prog, ...)直接 exec,每个参数独立,不经 shell
osdk.exec(prog, ...)直接执行并捕获输出,返回结果表
osdk.path.join(...)按当前平台的分隔符拼接
osdk.path.exists(p)路径是否存在
osdk.path.is_file/is_dir/is_absolute(p)路径类型判断
osdk.path.parent/basename/extension(p)拆分路径,缺少对应部分时返回 nil
osdk.path.absolute(p) / .relative(target, base?)词法绝对化与相对化,不要求路径已存在
osdk.fs.mkdir/read/write/copy/move/remove/glob跨平台文件操作
osdk.which(program)按任务的 PATH 查找可执行文件,找不到返回 nil
osdk.json.decode/encodeJSON 与 Lua 值互转;encode(value, true) 美化输出
osdk.toml.decode/encodeTOML 与 Lua 值互转
osdk.env(name)读环境变量,未设置返回 nil
osdk.platform.os / .windows / .arch平台判断
osdk.project_root / osdk.dir / osdk.task位置与身份
osdk.args.<名字> / osdk.argv声明的参数与剩余参数

常用名字同时直接放在全局作用域:run、sh、exec、which、env、 join、exists、mkdir、read、write、copy、move、remove、glob, 以及 root、dir、task、args、argv、platform。path 和 fs 短表也可用。 因此简单任务不必反复写 osdk.;显式的 osdk.* 写法会一直保留,适合担心 名字冲突的脚本。

构建命令时优先用 osdk.run:它的每个参数直接成为一个 argv 条目,含空格或 特殊字符的值不会被拆开或重新解释。osdk.sh 适合写固定的一行命令。 相对路径从任务的 dir(默认项目根)解析;osdk.run / osdk.sh 与普通任务 命令一样继承任务环境、受 timeout 约束,并在超时时终止整棵子进程树。

需要读取输出时用 exec。短写法直接列 argv:

lua
local result = exec("git", "rev-parse", "HEAD")
if not result.success then
  print(result.stderr)
  return result.code
end
print(result.stdout)

结果包含 code、success、stdout、stderr、stdout_truncated 和 stderr_truncated。两条输出流各最多保留 4 MiB,达到上限后仍会持续排空,避免 子进程卡在满管道上;要流式显示不限量日志则用 run。

表形式支持额外选项,并且位置数组、argv = {...}、command = "..." 三种命令 写法只能选一种:

lua
local result = exec {
  "tool", "--format", "json",
  cwd = "packages/app",           -- 相对任务 dir
  env = { MODE = "release" },     -- 覆盖任务环境
  stdin = "input\n",
  check = true,                    -- 非零退出直接抛错
}

local piped = exec { command = "tool-a | tool-b" }

跨平台文件与路径 ​

常见文件操作不需要再分 Windows 和 Unix 写两套命令:

lua
local out = join(root, "dist", platform.os)
mkdir(out)
write(join(out, "version.txt"), "1.2.3\n")
copy("assets", join(out, "assets"))       -- 文件或整棵目录

for _, file in ipairs(glob("src/**/*.lua")) do
  print(relative(file, root))
end

read / write 接受 Lua 字符串的原始字节,不强制 UTF-8;write、copy 和 move 会创建目标的父目录。remove 删除文件或整棵目录,目标不存在时返回 false。 move 使用原生 rename,因此源和目标要在同一文件系统。glob 的 pattern 相对 任务 dir,返回按字典序排列的绝对路径;目录复制保留符号链接,Windows 上仍受 系统的符号链接权限约束。

JSON 与 TOML ​

json、toml 是顶层短表,不需要写 osdk.json / osdk.toml:

lua
local package = json.decode(read("package.json"))
package.private = true
write("package.json", json.encode(package, true) .. "\n")

local config = toml.decode(read("tool.toml"))
config.release = { enabled = true }
write("tool.toml", toml.encode(config))

JSON 的 null 会解码成 json.null;创建空数组时用 json.array({}),否则 Lua 的 空 table 没有足够信息区分 JSON {} 与 []。从 JSON 解出的数组已自动保留这个 标记。codec 错误会直接终止任务,不会返回半解析的数据。

标准库与环境变量 ​

加载的是 Lua 5.4 的完整标准库——string、table、math、os、io、 coroutine、utf8 都在,debug 除外。解释器静态链接进二进制,因此 require 只能加载纯 Lua 文件,无法加载 C 扩展模块(lfs、socket 之类 装不进来)。

os.getenv 被重定向到与 osdk.env 相同的来源,两者答案始终一致:先查任务 声明的 env,再回退进程环境。这一步是必要的——任务的 env 只作用于 osdk 启动的子进程,原生 os.getenv 恰好对任务自己声明的变量返回 nil,而 nil 与 「没设置」无法区分,看起来就像 env 表坏了。

(注入到真实进程环境也能让两者一致,但那会把一个任务的 env 泄漏给后续所有 任务、freshness 状态和信任检查,且 set_var 本身在多线程下是数据竞争。)

这不是沙箱 ​

配置文件已经过信任门禁,而同一个文件里的 run 本来就能执行任意 shell 命令, 所以给 Lua 加沙箱保护不了任何东西。这里提供的是语义正确的便利,不是隔离。

构建期需要 C 编译器 ​

Lua 由 mlua 从源码编译并静态链接,运行时零依赖,但构建 osdk 时需要一个 C 编译器。它位于默认开启的 scripts feature 之后;关掉该 feature 的构建里, 内联 Lua 和 .lua 文件任务都会报错并提示改用其他脚本格式或启用该 feature。

体积代价:osdk 约 +335 KB,而 osdk-shim 一字节未变——shim 只负责分派 已安装的工具,从不执行任务,整个引擎不在它的依赖图里。

超时 ​

toml
[tasks.e2e]
run = "pytest tests/e2e"
timeout = "5m"

写法是 30s、5m、1h,或者一个纯数字(按秒算)。写错会在执行前报错, 不会退化成「没有超时」——用户要了限制却没拿到,是最糟的结果。

杀的是整棵进程树 ​

超时触发时,osdk 终止的是任务启动的全部进程,而不只是它直接拉起的那一个。 这个区别恰恰是超时最常见的场景:cmd /c npm test 里,杀掉 cmd.exe 会让任务 立刻「结束」,而它启动的 node 还在跑,端口还占着、文件还锁着。

两个平台的机制不同:

  • Windows 用 job object。子进程在启动时被放进 job,它派生的进程自动继承, 终止 job 即终止全部。同时设置了 KILL_ON_JOB_CLOSE,所以即使 osdk 自身 异常退出,Windows 也会回收整棵树而不是泄漏它。
  • Unix 用 setsid 让子进程成为进程组组长,然后 kill(-pgid) 覆盖整组。 先发 SIGTERM 留 5 秒让程序自己清理,仍不退出再 SIGKILL。

两者都在启动前就把分组建好——进程一旦 fork,就再没有可靠办法找全它的后代。

超时与收尾可以叠加:任务被超时终止后,run_post 仍会执行(它确实启动过)。

收尾步骤:失败了也要执行 ​

toml
[tasks.e2e]
depends = ["start-db"]
run = "pytest tests/e2e"
run_post = "docker compose down"

run_post 在 run 之后执行,run 失败时照样执行——这正是它存在的 理由。写成 run 的最后一行是不行的:任务失败时根本到不了那一行,测试数据库 就留在那儿了。

它属于 run 家族而不是 depends 家族,所以接的是命令。常见的一行清理 不必为它单独定义一个没人会直接调用的任务;确实需要共享时,{ tasks = [...] } 仍然可用:

toml
run_post = [{ tasks = ["stop-db", "notify"] }]

mise 把同一个概念叫 depends_post。这个名字容易误读成某种依赖,而它不是: 前置任务在之前运行并决定本任务跑不跑,收尾在之后运行且决定不了任何事。

什么时候不执行 ​

情况收尾是否执行为什么
run 成功执行—
run 失败执行已经启动过,该清理
前置依赖失败,run 没启动跳过什么都没建起来,没有要拆的
被增量判定为已最新而跳过跳过同上

退出码 ​

取第一个失败:run 挂了给 3、收尾成功,任务仍然是 3——清理干净不等于 测试通过。反过来 run 成功而收尾失败,任务失败,因为机器并没有回到承诺的 状态。

收尾有多个步骤时,即使其中一步失败,后面的步骤仍会执行——半途而废正好会 留下这个功能本该释放的资源。

暂不支持 run_post_windows。需要按平台区分收尾时,用 run_post = [{ tasks = ["cleanup"] }] 指向一个自带 run_windows 的任务。

给任务传参 ​

最简单的情况不需要任何声明:只有一条命令的任务,多余参数直接追加。

toml
[tasks]
test = "cargo test"
$ osdk run test -- --nocapture
# 实际执行 cargo test --nocapture

判据是「命令步骤只有一条」,而不是「run 写成了字符串」。所以 run = "cargo test" 和 run = ["cargo test"] 行为完全一致——把一种写法改成 另一种,不会让参数悄悄不被接收。

步骤多于一条时会拒绝,并说明怎么改:

$ osdk run ci -- --nocapture
error: task `ci` does not accept arguments: it has several steps, so there is no
single place to append them. Add `{{args}}` to an argv step, or declare them
with [[tasks.ci.args]]

这里不猜「追加到最后一条」,是因为多步任务下这个答案并不成立:追加到最后一条? 每一条?{ tasks = [...] } 并行步骤里的任务算不算?npm 能自动追加是因为一个 script 永远只有一条命令。

声明参数 ​

toml
[tasks.deploy]
run = [{ argv = ["kubectl", "apply", "-f", "{{manifest}}", "--context", "{{env}}"] }]

[[tasks.deploy.args]]
name = "env"
help = "目标环境"
choices = ["staging", "prod"]

[[tasks.deploy.args]]
name = "manifest"
default = "k8s/app.yaml"     # 有 default 即为可选

[tasks.deploy.options.replicas]
default = "3"

[tasks.deploy.flags.wait]
help = "等待 rollout 完成"
osdk run deploy -- prod --replicas 5 --wait

位置参数用 [[...]] 数组,因为顺序就是它的语义;选项和开关无所谓顺序, 所以用表。{{args}} 代表所有未被消费的参数,展开成多个独立参数而不是一个 拼接字符串。

校验在任何命令执行前完成:choices 不匹配、缺必需参数,都不会等到跑了一半 才发现。

为什么替换只在 argv 里生效 ​

{{...}} 只在 { argv = [...] } 步骤中替换,不在 run 字符串里替换。 原因是转义:

shell能否安全转义任意值
sh -c可以(单引号 + '\'')
pwsh可以(单引号 + '')
cmd /c不能——%VAR% 的展开发生在引号保护之前

一个在两个平台安全、在第三个平台可注入的功能,比没有这个功能更糟,因为它会 被当成安全的来用。而 argv 步骤里每个元素直接成为一个参数,中间没有任何解析 器,所以不是「我们仔细转义了」,而是「没有可供注入的解析步骤」:

$ osdk run deploy -- prod 'x & echo pwned > bad.txt'
# manifest 的值是完整的一整串,& 不会被解释,bad.txt 不会被创建

需要管道、重定向、通配符时继续用 run = "...",只是那里不做替换;要传参就写 成 argv,或者放进独立脚本。

所有值同时以环境变量提供(osdk_arg_env、osdk_args),这条路没有转义问题—— 值进入子进程的环境块时不经过任何解析器。明知自己在什么 shell 下的人可以在 run 字符串里用它们,风险自负。

增量:输入没变就跳过 ​

toml
[tasks.build]
run = "cargo build --release"
sources = ["Cargo.toml", "crates/**/*.rs"]
outputs = ["target/release/osdk.exe"]

sources 全都比 outputs 旧时,任务被跳过:

$ osdk run build
build: 已是最新,跳过

判据默认比较修改时间。三种可选:

freshness行为适用
"mtime"(默认)比较修改时间便宜,但 git checkout 与 CI 缓存恢复会刷新时间戳
"hash"比较内容哈希不受"内容没变但时间戳变了"影响,代价是读取全部输入
"always"从不跳过显式关掉增量

任务定义本身也算输入:改了 run 的内容,即使源文件没动也会重跑。

省略 outputs 时,osdk 用上次成功运行的时间作为基准,状态存在缓存目录里 (不写进项目,避免每个使用者都要加 .gitignore)。

两个容易踩的地方 ​

写错的 pattern 会报错,不会被当成"没有输入"。

$ osdk run build
error: task `build`: sources: ["src/**/*.typo"] matched no files; a pattern that
matches nothing would make this task's freshness check silently meaningless

一个匹配不到任何文件的 sources 会让新鲜度判断变得空洞——任务要么永远跳过、 要么永远重跑,而配置看上去完全正常。所以这里选择直接失败。

glob 的扫描从字面前缀开始。 crates/**/*.rs 只会进入 crates/,不会遍历 整个项目。这不是微优化:实测中一次从当前目录出发的全量遍历,在 target/ 旁边 要 2,827.91 ms,而正确起点只需 61.26 ms,相差 46 倍。写 pattern 时尽量带上目录 前缀,**/*.rs 这种没有前缀的写法会走遍整棵树。

! 前缀表示排除:

toml
sources = ["src/**/*.rs", "!src/generated/**"]

wait_for:只排序,不调度 ​

toml
[tasks.serve]
run = "npm start"
wait_for = ["migrate"]

wait_for 与 depends 的区别只在一件事上:目标任务没被调度时会怎样。 depends 会把它拉进来执行;wait_for 什么也不做。

它表达的是「如果我们俩都要跑,我排在后面」,而不把对方变成前置条件——适用于 两个任务碰同一个资源、但谁也不真正需要对方的产物。

因此这里写一个不存在的任务名不是错误,正是这个字段存在的场景。代价是拼错 不会被发现;osdk task info 会打印这个字段,顺序不对时可以查。

Windows 变体 ​

toml
[tasks.build]
run = "make"
run_windows = "nmake"

run_windows 在 Windows 上取代(而非追加)run。

默认解释器是 Windows 上的 cmd /c、其他平台的 sh -c。之所以不选 sh 作为 Windows 默认,是因为那会要求用户额外安装 Git for Windows 或 Cygwin, 与 osdk「装上就能用」相悖。注意开发机上很可能恰好有 sh(甚至可能是 osdk 自己生成的 shim),所以依赖它的配置会在本机正常、到干净机器上失败。

要换解释器:

toml
[tasks.deploy]
run = "./deploy.ps1"
shell = "pwsh -Command"

或者给整个配置作用域设默认值:

toml
[task]
shell = "pwsh -Command"

任务能看到什么环境 ​

osdk 在启动任务前主动注入该项目的环境,和 osdk hook-env 给 shell 的 是同一套:工具的 bin 目录前置进 PATH,JAVA_HOME、GOROOT 这类变量按 项目配置导出,再加上包缓存与模型适配的变量。

因此任务里看到的工具版本就是项目声明的版本,无论从哪个目录、哪种 shell 调用,也不要求用户先 osdk activate。 已安装工具会直接从本地选择,不发网络请求;缺失工具只有在任务显式列出其 [tools] 键时才会安装。osdk 不会解析命令或脚本来猜测依赖。

osdk 同时设置两个变量:

变量含义
OSDK_TASK恒为 1,表示当前进程是 osdk 任务
OSDK_TASK_NAME当前任务名

前者的作用是让 profile 里的 osdk 激活钩子识别出「环境已经准备好了」, 不再叠加第二次激活。

任务自己的 env 优先级最高,可以覆盖上面任何一项:

toml
[tasks.test]
run = "cargo test"
env = { RUST_BACKTRACE = "1" }

其他字段 ​

toml
[tasks.release]
description = "打包发布产物"      # 出现在 osdk task list
alias = ["rel"]                   # osdk run rel
tools = ["cargo:cargo-release"]   # 按需从 [tools] 安装
dir = "packaging"                 # 相对配置文件所在目录
hide = true                       # 默认不出现在列表里
quiet = true                      # 抑制 osdk 自身输出,不影响任务输出
when = { os = "linux" }           # 平台过滤,与 [tools] 同一套写法

被 when 排除的任务不会被当成「不存在」:

$ osdk run linuxonly
error: task `linuxonly` is not available on this platform (os=linux)

osdk task list 也会把它单列出来,而不是让它悄悄消失。

命令 ​

命令作用
osdk run <名字>执行任务
osdk run <名字> --dry-run只打印将要执行什么
osdk task list列出任务(--hidden 包含隐藏的)
osdk task info <名字>查看合并后的完整定义
osdk task deps <名字>打印执行顺序
osdk task add <名字> --run <命令>写入项目配置,可重复 --run 表示多步
osdk task rm <名字>从项目配置删除
osdk task edit <名字>用 $EDITOR 打开配置

add 与 rm 都保留文件原有的注释、缩进与条目顺序——配置是人写的, 加一个任务不该顺带重排整份文件。单命令写成一行简写,多命令或带元数据时才 展开成表。

add 会先校验再落盘:一份写不进去的配置比一条被拒绝的命令更糟,因为下一次 运行 osdk 会因为用户没输入过的东西而失败。

注意只有 osdk run <名字>,没有裸的 osdk <名字>:后者会被将来新增的 子命令遮蔽,是 mise 踩过并已建议脚本不要依赖的坑。

任务失败时 osdk run 以该任务的退出码退出,可以直接串进 CI 脚本。

信任 ​

声明任务不需要信任。 osdk 不会自作主张地跑任何任务:没有 postinstall、 没有生命周期钩子、没有任何自动调用,[tasks] 只被 osdk run 和 osdk task 读取。你敲下 osdk run build 这个动作本身就是授权,再要求一次 trust 等于 对同一件事问两遍——而一个总在你已经明确要求的事情上弹出的确认,只会训练人 不读就点同意。

对比 sys.pkg 就清楚了:它在 osdk pkg apply 期间动作,而用户并没有逐个包地 要求过,所以必须事先审阅;任务则永远是因为有人点名才运行。tools 列表同样只是声明; 如果所需工具缺失,osdk run 会在获取它之前应用正常的 install 信任作用域,因此来源覆盖 和 allow_builds 不会在未经审阅时生效;通过受管 shim 执行时仍保留 shim 原有的信任检查。

[task] 仍然需要信任,因为它不是你点名的命令,而是一个环境设置:

$ osdk task list          # 只是打印声明,不需要信任
build
test

$ osdk run test           # 真正要用那个解释器,于是被拦住
error: project config is not trusted: /path/to/osdk.toml
these keys need review because they affect what runs on this machine:
  task -- 决定用什么解释器执行任务,任务的实际行为可能与写出来的不一致

注意被拦住的是 osdk run,不是 osdk task list。后者只报告文件里写了什么、 不执行任何东西,因此即使项目尚未信任也照常可用——否则「先看清再决定要不要 信任」这条路本身就被堵死了。

它的 shell 字段决定作用域内每个任务用什么解释器。一份配置若悄悄写上 shell = "evil --run",之后每次 osdk run 执行的都不再是任务文本写的东西, 而调用处看不出任何异样。审阅之后 osdk trust 即可。

monorepo:子项目的任务 ​

task.roots 显式声明哪些目录是子项目,它们的任务会以 //<路径>:<任务名> 的形式进入同一个任务表:

toml
# 仓库根的 osdk.toml
[task]
roots = ["apps/*", "packages/*"]

[tasks.hello]
run = "echo root"
toml
# apps/api/osdk.toml
[tasks.build]
run = "cargo build"
$ osdk task list
//apps/api:build
//packages/ui:build
hello

$ osdk run //apps/api:build

前缀不是装饰:没有它,packages/ui 的 build 会把 apps/api 的同名任务顶掉。 这套寻址与 [deps].roots 的 //apps/api:uv 是同一套,不是第二种语法。

只有声明过的目录会被读取。 旁边有一份完好 osdk.toml 的目录,若没被任何 pattern 覆盖就不会被发现。对任务来说这条比对依赖更要紧:run 是一条任意命令, 所以「意外发现一个项目」等于「意外发现一段可执行的代码」。pattern 的匹配规则与 [deps].roots 完全一致(逐段匹配、只支持单层 *、拒绝 ..),因为它们共用同一 份实现而不是各写一遍。

跨子项目的依赖 ​

depends 里带 // 前缀就跨子项目,不带就留在本子项目内:

toml
# apps/web/osdk.toml
[tasks.prep]
run = "npm run codegen"

[tasks.build]
run = "npm run build"
depends = ["//packages/ui:build", "prep"]

//packages/ui:build 指向另一个子项目;prep 指的是它旁边那个 prep,即使 packages/ui 里也有一个同名任务。这样一份子项目配置单独读起来就是它字面的意思—— 搬进 monorepo 之前写的 depends = ["prep"] 不需要改。

环会被检出,跨子项目也一样:

$ osdk run //apps/web:build
error: config error: task dependency cycle: //apps/web:build -> //packages/ui:build -> //apps/web:build

两端都报出来,这样你知道该删哪条边。

子项目不能声明 [task] ​

子项目只贡献任务定义。[task] 是作用域级的设置,其中 shell 决定每个 任务用什么解释器——这个权力留给声明 roots 的那份配置,也就是你实际审阅并 osdk trust 过的那份。

$ osdk task list
error: config error: apps/api/osdk.toml: a sub-project cannot declare `[task]`;
runner defaults such as `shell` belong to the config that declares `task.roots`

是报错而不是忽略:一个写下去却没有任何效果、也不给任何提示的设置,比报错更糟 ——作者会以为它生效了。

声明 roots 之后,osdk run 需要信任 ​

roots 就写在 [task] 里,而这张表本来就在信任门禁内(上一节)。所以一旦 声明了子项目,osdk run 会要求先 osdk trust——这不是为 monorepo 新加的一道门, 而是它自动继承了已有的那道。osdk task list 仍然照常可用,因为它只报告不执行。

不做什么 ​

osdk 的任务是任务运行器,不是构建系统。明确不提供 make 的模式规则 (%.o: %.c)与自动变量($@ / $<):它们是「为每个文件生成一条规则」 的语言,一旦引入就要接着提供依赖链推导、VPATH、中间文件生命周期,等于 把 make 的全部复杂度搬过来。mise、just、Task、cargo-make、npm scripts、 deno task、Turborepo 没有任何一个提供模式规则,这是一致的判断。

需要编译一棵源码树时,那是 cargo / tsc / go build 的职责;任务运行器的 职责是以正确的工具版本和环境去调用它们。

基于 MIT 许可发布