npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@workwright/cli

v0.1.2

Published

Fail-closed release manager for a single Workwright Botmux runtime

Downloads

33

Readme

@workwright/cli

面向单台 Ubuntu/Lightsail VM、user-level Botmux 的 fail-closed release manager。它把本仓库已有的 runtime scripts/lib 安装进 versioned release,并以 exact target、verified ingress fence、atomic pointer、append-only ledger 和 semantic health gate 管理升级与回滚。

Source merge、npm publish 与 VM rollout 是三个独立状态。安装前必须从批准的 registry 验证 exact version,或使用维护者提供并经过 SHA-256 校验的 tarball;不能从 GitHub branch 状态推断 package 已发布。

首次纳管

目标 VM需在 trusted absolute /usr/bin/node提供 Node.js >=22(manager entrypoint使用 absolute shebang,不经 PATH解析 node),Botmux已完成 setup,且 ~/.botmux/bots.json至少包含一个 bot:

# Run as one fail-fast unit: no npm or Workwright command runs after a failed path check.
(
  set -eu
  umask 077
  case "$HOME" in /*) ;; *) exit 1 ;; esac
  test ! -L "$HOME"
  test -d "$HOME"
  test -O "$HOME"
  home_mode="$(/usr/bin/stat -c '%a' "$HOME")"
  test "$((8#$home_mode & 0022))" -eq 0
  test "$(/usr/bin/readlink -f -- "$HOME")" = "$HOME"
  operator_uid="$(/usr/bin/id -u)"
  assert_trusted_ancestors() {
    local child="$1" parent parent_mode parent_uid child_uid
    while :; do
      parent="$(/usr/bin/dirname -- "$child")" || return 1
      test "$parent" != "$child" || break
      test ! -L "$parent" || return 1
      test -d "$parent" || return 1
      parent_uid="$(/usr/bin/stat -c '%u' "$parent")" || return 1
      case "$parent_uid" in 0|"$operator_uid") ;; *) return 1 ;; esac
      parent_mode="$(/usr/bin/stat -c '%a' "$parent")" || return 1
      if test "$((8#$parent_mode & 0022))" -ne 0; then
        test "$((8#$parent_mode & 01000))" -ne 0 || return 1
        child_uid="$(/usr/bin/stat -c '%u' "$child")" || return 1
        case "$child_uid" in 0|"$operator_uid") ;; *) return 1 ;; esac
      fi
      child="$parent"
    done
  }
  assert_trusted_ancestors "$HOME"

  test ! -L /usr/bin/node
  test -f /usr/bin/node
  test -x /usr/bin/node
  node_uid="$(/usr/bin/stat -c '%u' /usr/bin/node)"
  case "$node_uid" in 0|"$operator_uid") ;; *) exit 1 ;; esac
  node_mode="$(/usr/bin/stat -c '%a' /usr/bin/node)"
  test "$((8#$node_mode & 0022))" -eq 0
  assert_trusted_ancestors /usr/bin/node
  node_identity="$(/usr/bin/stat -c '%d:%i:%s:%y:%z:%f:%u' /usr/bin/node)"
  run_node() {
    local current_node_uid current_node_mode current_node_identity
    test ! -L /usr/bin/node || return 1
    test -f /usr/bin/node || return 1
    test -x /usr/bin/node || return 1
    current_node_uid="$(/usr/bin/stat -c '%u' /usr/bin/node)" || return 1
    case "$current_node_uid" in 0|"$operator_uid") ;; *) return 1 ;; esac
    current_node_mode="$(/usr/bin/stat -c '%a' /usr/bin/node)" || return 1
    test "$((8#$current_node_mode & 0022))" -eq 0 || return 1
    current_node_identity="$(/usr/bin/stat -c '%d:%i:%s:%y:%z:%f:%u' /usr/bin/node)" || return 1
    test "$current_node_identity" = "$node_identity" || return 1
    assert_trusted_ancestors /usr/bin/node || return 1
    /usr/bin/node "$@"
  }

  package_verifier=
  IFS= read -r -d '' package_verifier <<'NODE' || :
const fs = require('node:fs');
const path = require('node:path');
const [entryArg, expectedName, expectedVersion, binKey, ownerPolicy] = process.argv.slice(1);
const uid = process.getuid();
const allowedOwner = owner => owner === uid || (ownerPolicy === 'trusted' && owner === 0);
const inside = (root, value) => {
  const relative = path.relative(root, value);
  return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
};
const identity = stat => [stat.dev, stat.ino, stat.uid, stat.mode, stat.size, stat.mtimeMs].join(':');
const readPackage = file => {
  const before = fs.lstatSync(file);
  if (!before.isFile() || before.isSymbolicLink() || before.size > 2 * 1024 * 1024) process.exit(1);
  const value = JSON.parse(fs.readFileSync(file, 'utf8'));
  if (identity(before) !== identity(fs.lstatSync(file))) process.exit(1);
  return value;
};
const entry = fs.realpathSync(entryArg);
const entryInfo = fs.statSync(entry);
if (!entryInfo.isFile()) process.exit(1);
const entryIdentity = identity(entryInfo);
let root = path.dirname(entry);
let metadata;
let pkg;
for (let depth = 0; depth < 64; depth += 1) {
  metadata = path.join(root, 'package.json');
  try { pkg = readPackage(metadata); } catch { pkg = undefined; }
  if (pkg?.name === expectedName) break;
  const parent = path.dirname(root);
  if (parent === root) process.exit(1);
  root = parent;
}
if (pkg?.name !== expectedName) process.exit(1);
root = fs.realpathSync(root);
const rootRaw = fs.lstatSync(root);
if (!rootRaw.isDirectory() || rootRaw.isSymbolicLink() || !allowedOwner(rootRaw.uid)
    || (rootRaw.mode & 0o022) !== 0) process.exit(1);
const pending = [root];
const snapshot = [];
while (pending.length) {
  const file = pending.pop();
  const before = fs.lstatSync(file);
  if (snapshot.length >= 250000) process.exit(1);
  if (before.isSymbolicLink()) {
    if (!allowedOwner(before.uid)) process.exit(1);
    const lexical = path.resolve(path.dirname(file), fs.readlinkSync(file));
    const target = fs.realpathSync(file);
    if (!inside(root, lexical) || !inside(root, target)) process.exit(1);
    const targetStat = fs.statSync(target);
    if ((!targetStat.isFile() && !targetStat.isDirectory()) || !allowedOwner(targetStat.uid)
        || (targetStat.mode & 0o022) !== 0) process.exit(1);
  } else if (before.isDirectory()) {
    if (!allowedOwner(before.uid) || (before.mode & 0o022) !== 0) process.exit(1);
    for (const name of fs.readdirSync(file).sort()) pending.push(path.join(file, name));
  } else if (before.isFile()) {
    if (!allowedOwner(before.uid) || (before.mode & 0o022) !== 0) process.exit(1);
  } else process.exit(1);
  snapshot.push([file, identity(before)]);
}
for (const [file, before] of snapshot) {
  try { if (identity(fs.lstatSync(file)) !== before) process.exit(1); } catch { process.exit(1); }
}
pkg = readPackage(metadata);
if (pkg.name !== expectedName || (expectedVersion !== '*' && pkg.version !== expectedVersion)) process.exit(1);
const declared = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.[binKey];
if (typeof declared !== 'string' || !declared || path.isAbsolute(declared)) process.exit(1);
const lexicalDeclared = path.resolve(root, declared);
if (!inside(root, lexicalDeclared) || fs.realpathSync(lexicalDeclared) !== entry) process.exit(1);
if (identity(fs.statSync(entry)) !== entryIdentity) process.exit(1);
process.stdout.write(root);
NODE
  verify_package() {
    run_node -e "$package_verifier" "$@"
  }

  npm_requested="$(command -v npm)"
  case "$npm_requested" in /*) ;; *) exit 1 ;; esac
  npm_requested_type="$(/usr/bin/stat -c '%F' "$npm_requested")"
  case "$npm_requested_type" in 'regular file'|'symbolic link') ;; *) exit 1 ;; esac
  npm_requested_uid="$(/usr/bin/stat -c '%u' "$npm_requested")"
  case "$npm_requested_uid" in 0|"$operator_uid") ;; *) exit 1 ;; esac
  assert_trusted_ancestors "$npm_requested"
  npm_realpath="$(/usr/bin/readlink -f -- "$npm_requested")"
  test -f "$npm_realpath"
  test -x "$npm_realpath"
  npm_uid="$(/usr/bin/stat -c '%u' "$npm_realpath")"
  case "$npm_uid" in 0|"$operator_uid") ;; *) exit 1 ;; esac
  npm_mode="$(/usr/bin/stat -c '%a' "$npm_realpath")"
  test "$((8#$npm_mode & 0022))" -eq 0
  assert_trusted_ancestors "$npm_realpath"
  npm_identity="$(/usr/bin/stat -c '%d:%i:%s:%y:%z:%f:%u' "$npm_realpath")"
  npm_package_root="$(verify_package "$npm_realpath" npm '*' npm trusted)"
  assert_trusted_ancestors "$npm_package_root"
  run_npm() {
    test "$(/usr/bin/stat -c '%d:%i:%s:%y:%z:%f:%u' "$npm_realpath")" = "$npm_identity" || return 1
    assert_trusted_ancestors "$npm_realpath" || return 1
    test "$(verify_package "$npm_realpath" npm '*' npm trusted)" = "$npm_package_root" || return 1
    run_node "$npm_realpath" "$@"
  }
  for directory in \
    "$HOME/.local" \
    "$HOME/.local/npm-global" \
    "$HOME/.local/npm-global/lib" \
    "$HOME/.local/npm-global/lib/node_modules" \
    "$HOME/.local/bin"
  do
    if [ -e "$directory" ] || [ -L "$directory" ]; then
      test ! -L "$directory"
      test -d "$directory"
      test -O "$directory"
      test "$(/usr/bin/stat -c '%a' "$directory")" = "700"
    else
      /usr/bin/install -d -m 0700 "$directory"
    fi
  done
  export npm_config_prefix="$HOME/.local/npm-global"

  test "$(run_npm prefix --global)" = "$HOME/.local/npm-global"
  test -O "$HOME/.local/npm-global"
  test "$(/usr/bin/stat -c '%a' "$HOME/.local")" = "700"
  test "$(/usr/bin/stat -c '%a' "$HOME/.local/npm-global")" = "700"
  test "$(/usr/bin/stat -c '%a' "$HOME/.local/npm-global/lib/node_modules")" = "700"
  test "$(/usr/bin/stat -c '%a' "$HOME/.local/bin")" = "700"
  test "$(run_node -p 'Number(process.versions.node.split(".")[0]) >= 22')" = "true"
  (umask 077; run_npm install --global --ignore-scripts --prefix "$HOME/.local/npm-global" -- @workwright/[email protected])

  bootstrap_package="$HOME/.local/npm-global/lib/node_modules/@workwright/cli"
  test ! -L "$bootstrap_package"
  test -d "$bootstrap_package"
  test -O "$bootstrap_package"
  bootstrap_link="$HOME/.local/npm-global/bin/workwright"
  test -L "$bootstrap_link"
  test "$(/usr/bin/stat -c '%u' "$bootstrap_link")" = "$operator_uid"
  bootstrap_cli="$(/usr/bin/readlink -f -- "$bootstrap_link")"
  test "$bootstrap_cli" = "$(/usr/bin/readlink -f -- "$bootstrap_package/bin/workwright.js")"
  test ! -L "$bootstrap_cli"
  test -f "$bootstrap_cli"
  test -x "$bootstrap_cli"
  test "$(/usr/bin/stat -c '%u' "$bootstrap_cli")" = "$operator_uid"
  bootstrap_mode="$(/usr/bin/stat -c '%a' "$bootstrap_cli")"
  test "$((8#$bootstrap_mode & 0022))" -eq 0
  assert_trusted_ancestors "$bootstrap_cli"
  test "$(verify_package "$bootstrap_cli" @workwright/cli 0.1.2 workwright current)" = "$bootstrap_package"
  run_node "$bootstrap_cli" plan --release 0.1.2 --botmux 3.17.0
  run_node "$bootstrap_cli" install --release 0.1.2 --botmux 3.17.0
)

npm global prefix必须由当前用户拥有、当前用户可写,且不能 group/other-writable;不要用 sudo npm i -g。不要把 prefix直接设为 $HOME/.local:npm bootstrap的 bin/workwright 会与 manager拥有的 $HOME/.local/bin/workwright 冲突。首次纳管不修改~/.npmrc,也不通过PATH调用刚安装的CLI;它固定使用纳管前捕获并验证的canonical npm script,并在每次npm调用前验证声明该bin的整个npm package tree。安装后也会递归验证exact @workwright/cli package tree及其声明的npm-prefix entrypoint。两类tree都拒绝untrusted owner、group/other-write、special node、escaping symlink与扫描中的identity drift。成功后再把$HOME/.local/bin:$HOME/.local/npm-global/bin顺序写入对应shell profile;managed stable bin优先,独立npm bootstrap bin作为初装与delegation入口。

bootstrap在任何mkdir或npm调用前先要求$HOME是absolute canonical path、plain directory、current-user-owned且mode & 0022 == 0。它还逐级验证canonical ancestor:每层parent都必须由root或current UID拥有;group/other-writable parent还必须有sticky bit,并且直接child也必须由root或current UID拥有。普通0775 parent与other-UID-owned 0755 parent都会拒绝。$HOME本身或其路径中的symlink都会直接中止,脚本不会尝试自动修权限。

CLI runtime trust的最低mode边界是拒绝 mode & 0022(group/other-write);fresh bootstrap示例有意采用更严格的exact 0700 baseline,把整个 $HOME/.local delivery namespace保持private。已有安全但更宽的read/execute mode不应被脚本静默修改;operator应先核对后决定是否沿用或迁移到新的0700 tree。0775等group-writable ancestor不满足最低边界,必须人工处理。

上面的 bootstrap block只适用于 fresh、已由operator确认的目录。已有 npm prefix、Botmux package tree、legacy ~/.workwright/runtime或~/bin entrypoint必须先人工核对owner、mode、symlink target和authority,再选择新的private prefix或受控迁移;不要用递归 chmod猜测修复。CLI不会自动 chmod、接管或删除无法证明安全的legacy tree,而是在执行 npm/Botmux child前fail closed。

bootstrap使用operator预先批准的npm executable、registry与auth config作为外部信任边界。后续dist.integrity校验只证明下载的tarball与该chosen registry返回的metadata一致,不声明其来自npmjs.org,也不替operator选择registry/account。

@workwright/cli 没有 preinstall、install 或 postinstall script;安装 npm package 本身不修改 Botmux/runtime。只有显式执行 install、upgrade 或 rollback 才进入 mutation transaction。

首次成功安装后执行 smoke check:

test -L "$HOME/.local/bin/workwright"
readlink -f "$HOME/.local/bin/workwright"
workwright status --json
workwright doctor

npm global bin 中的 bootstrap CLI 和 ~/.local/bin/workwright 不构成两个独立 control plane:已有 managed current 时,npm 入口的 operational command会先校验 release,再委派给 current/bin/workwright.js。因此 shell 仍缓存 npm bin 路径时,也不会继续执行旧 manager schema。

命令与 exact target

workwright plan --release 0.2.0 --botmux 3.17.0
workwright install --release 0.1.0 --botmux 3.17.0
workwright upgrade --release 0.2.0 --botmux 3.17.0
workwright status --json
workwright doctor
workwright rollback
workwright rollback --to 0.1.0

latest、npm dist-tag、semver range、v0.1.0 和 abbreviated Git SHA 都会被拒绝。完整 Git SHA release 必须使用已知 SHA-256 的 tarball:

workwright upgrade \
  --release 0123456789abcdef0123456789abcdef01234567 \
  --botmux 3.17.0 \
  --artifact ./workwright-cli.tgz \
  --sha256 <64-CHAR-SHA256>

plan 会实际取得并校验指定 Workwright source/artifact identity;不会静默忽略 --artifact 或 --sha256。它不运行 package lifecycle script:以 --ignore-scripts=true 检查 staged package、protocol 和 PM2 shape,不做 node-pty native load probe。PM2 God不存在时使用 no-birth evidence返回 empty fleet,不调用会 lazy-start persistent God的 pm2 jlist;markers与 /proc evidence矛盾时 fail closed。PM2 probe和Doctor会先证明 ~/.botmux 是 current-user-owned、无 symlink ancestor且 group/other bits均为零的 private namespace;只有在这个边界内,才兼容 PM2 6.0.14 的 PM2_HOME=0775、pm2.pid=0664和socket 0775,同时仍拒绝 descendant wrong owner/type/symlink、other-write与process identity mismatch。真正的 normal-lifecycle compatibility probe只在持有 deployment lock且每个 child都有独立 durable command fence的 install、upgrade 或 rollback transaction内执行。

Live fleet 支持矩阵

| 当前 Botmux fleet | 行为 | |---|---| | 所有 live daemon 都发布 target 要求的 exact supervisorShutdownProtocol,且 PM2 policy 可证明安全 | 允许:authenticated idle 后调用当前 exact Botmux 的 stop,验证 fleet stopped,再 mutation/start | | 任一 live daemon 缺 descriptor、protocol 不匹配或 PM2 policy 不安全 | 拒绝;即使传 --bootstrap-botmux 也不会继续 | | 没有 live daemon,但仍有 offline/stopped/errored legacy PM2 row | 要求显式 --bootstrap-botmux;由已 staged/verified 的 target CLI 做 non-live row retirement,不调用能力未知的 old CLI。若 previous package 不能通过 exact artifact、rollback 与 health-contract preflight,仍在 mutation 前拒绝 | | 没有 live daemon,也没有 legacy PM2 row | 可继续首次 start |

因此,CLI 不自动支持 Botmux 2.108 → 3.17。live 场景下,本实现没有证据证明 2.108 daemon 具备 3.17 的 authenticated supervisor shutdown/admission-drain contract;idle snapshot 到 legacy stop 之间仍可能进入新 turn。即使通过本 CLI 之外、machine-verifiable 的 ingress/停服 runbook 把旧 fleet 变成 verified stopped,CLI 也只会重新评估,并且仍要求 previous package 通过 exact rollback artifact 与 health-contract preflight。人工 maintenance window 或 --bootstrap-botmux flag 本身不算 fence。

3.17 contract 下,botmux stop 在 daemon 内取得 per-bot admission mutation lease、drain 已进入的 admission,再由 CLI 验证 PM2 core rows stopped 且没有 live daemon descriptor。transaction 在这之后才允许修改 global package。代价是 upgrade/rollback 有明确的短暂停服窗口;CLI 不会主动消费或重放外部平台尚未投递的消息。

Transaction 顺序

每次 mutation 按以下顺序执行:

  1. 校验 Workwright release 与 Botmux 都是 exact target;检查 ~/.botmux secure-host parent/secret,以及 Workwright runtime root、releases/backups/locks、~/.local/bin、ledger/pending/lifecycle fence 的 current-UID ownership、plain-node、ancestor 与 mode trust boundary。已有 symlink ancestor 或 group/other-writable owned path 会在 lock 创建前被拒绝。
  2. 以 exclusive mkdir取得包含 PID、Linux /proc process-start identity 与 boot identity 的 atomic local deployment lock。任何 pre-existing lock(包括看起来 stale 的 lock)都 fail closed,不做自动 rename、unlink 或 stale recovery,避免 delayed breaker误删另一个进程刚创建的 live lock。随后在 botmux.service.d/50-workwright-maintenance.conf持久安装 ConditionPathExists=!<runtime>/maintenance-active,执行 systemctl --user daemon-reload,并用 systemctl show/cat machine-verify exact loaded drop-in;user systemd或unit不可用时不开始 transaction。
  3. 把 npm script固定为 canonical realpath,证明其来自package.json声明的exact npm bin,并在每次npm process前重新扫描整个imported package tree;同时 capture/revalidate trusted absolute Node。npm/PM2/Botmux JavaScript CLI全部通过 nodeRealpath scriptRealpath ...执行,不让 #!/usr/bin/env node重新信任 PATH;PM2/Botmux和Doctor的package-backed probe同样先验证整个Botmux tree。所有 Workwright child spawn(包括 managed CLI delegation)在同步 spawn窗口把继承umask收紧为 operator_umask | 0077,spawn返回后立即恢复parent;npm lifecycle grandchildren继承同一private umask。pending会持久化 Node/npm executable full identity、npm package root及 prefix/root directory dev/ino,recovery只使用原 canonical authority。Doctor同样通过 trusted absolute Python执行。Workwright registry artifact fetch只能使用 captured Node/npm package authority;tarball extraction使用 capture后再次验证的 trusted absolute tar。operator提供的 --artifact会先复制进 mode 0700的 private temp,再对 private bytes校验 SHA-256并解包,不会 hash原路径后再次读取原路径。
  4. 每个可能产生持续副作用的 child在 spawn紧邻处写入独立 durable lifecycle-fence.json,spawn后立即记录 PGID、leader PID与 process-start identity。staged normal install、独立 native probe、global install/uninstall、botmux stop/botmux start及 recovery/compensation都使用同一规则;script-disabled npm pack不提前占用 fence。process group未证明退出,或 owner在 durable spawn handshake完成前消失时,后续 mutation fail closed。
  5. 只下载一次 exact Botmux tarball:读取 npm dist.integrity、计算 SHA-256,在临时 prefix 以 normal lifecycle 安装并 load-probe node-pty。staged与global install后都会递归验证整个Botmux package tree为current-owner且无group/other-write;npm生成的symlink只在target仍位于同一package tree且target安全时允许。后续 global install 使用同一份 tarball bytes,digest drift 会在 npm mutation 前被拒绝;legacy或lifecycle重新引入的insecure mode不会被自动修复。
  6. 通过 daemon HMAC GET /api/sessions 读取真实 session status。working、starting、未知 schema/status 或 descriptor ownership 不可证明都会拒绝;不会依据 process name、age 或 stale metadata 猜 busy。Doctor 还会按 exact BOTMUX_SESSION_ID config把 Codex subagent sibling归属到对应 active Botmux session,不把继承同一 session identity 的子 agent误报成 orphan。
  7. 从 releases/<id>.partial 生成逐文件 SHA-256 manifest并 rename;复制时清除所有 group/other write bits,reuse/rollback verification也会拒绝 insecure mode。随后创建 allowlisted state backup、prepared ledger 和 durable pending-transaction.json,再以同一 transaction ID创建并 fsync mode 0600的 maintenance-active sentinel;sentinel建立前不调用 stop。
  8. 由当前 exact Botmux 建立 stopped ingress fence;stop返回后,以 upstream-compatible {pid,procStart,bootId} payload用 O_EXCL取得 ~/.botmux/pm2-fleet-mutation.lock,并在持锁后重新确认 fleet stopped。stop→lock gap若被另一个 start占用,会释放锁并在任何 package mutation前保留 pending/sentinel后中止。fleet lock覆盖 exact Botmux install和atomic current切换;它在 intentional target botmux start前释放,sentinel继续阻止 systemd autostart。
  9. health gate 验证 package version、runtime build ID、新 daemon 与 dashboard process generation、authenticated sessions、descriptor/PM2 PID、exact exec path与per-role cwd(dashboard=botmux.packageDir,daemon=paths.botmuxDir)、dashboard /__health、restart/unstable counters,以及连续 120 秒稳定窗口。workwright-doctor exit 1 可 degraded commit,exit 2 阻止 commit。health通过且completion ledger durable后,才按 exact transaction ID删除并 fsync sentinel,最后清除 pending;若在这两次cleanup之间 crash,reconciliation接受已删除 sentinel但仍存在的 committed pending。

命令使用 argv array、cwd=$HOME、bounded timeout/output。POSIX mutating command运行在独立 process group;timeout/overflow时执行 group TERM→KILL并确认 quiesced。preflight阶段无法证明 descendant已停止时保留 command fence;global mutation阶段还会把同一 process-group identity写入 pending transaction。后续 reconciliation在证明该 identity已消失前执行零 package mutation。若进程在 spawn handshake窗口 hard-crash而 durable child identity缺失,则进入 manual adjudication,不会猜测 child从未启动。

Recovery 与 rollback

transaction backup 会保存 target tarball;存在 previous Botmux 时,也保存 preflighted previous tarball及 SHA-256。进程若在 prepared 之后退出,下一次 mutation 先读取 pending record、exact-tx maintenance sentinel和ledger,不会从当前目录状态重新猜 previous version。crash发生在 pending durable但 sentinel尚未建立的唯一安全窗口时,recovery仅允许 prepared phase补建 sentinel;后续phase缺失、wrong-tx、wrong-owner或wrong-mode sentinel一律 fail closed。committed-but-sentinel/pending未清理的transaction会先重新health,再按 sentinel→pending顺序完成cleanup。

MVP不会自动删除任何 pre-existing deployment lock <runtime>/locks/deploy.lock 或 ~/.botmux/pm2-fleet-mutation.lock,即使其PID/boot identity显示stale;这避免 reduced stale-breaker误删另一个 Workwright或 native Botmux刚创建的新lock。遇到该错误时,operator必须先确认没有 Workwright、Botmux或 npm lifecycle process仍在运行,再人工删除对应的exact lock path并重新运行受控recovery。manager只会 inode-bound地释放自己本次 O_EXCL取得的lock。

升级失败时,CLI 先把 failed state 写入 atomic failed-state archive,再 verified-stop 当前 fleet,使用 durable exact previous tarball恢复 package、切回 ledger previous release、start 并再次 health check。首次安装没有 previous package时,失败补偿会停止可能已启动的新 fleet、uninstall 由 transaction 创建的 Botmux package,并移除 owned current pointer;任何一步无法证明 ownership/quiescence都会保留 pending并要求人工处理。

deployments.jsonl 是 append-only,记录 prepared、committed、rolled_back、failed。rollback 的默认 target来自 ledger previous known-good,也可用 --to <exact-release>;绝不按 release 目录排序猜测。

Botmux/application state 可能经历不可逆 migration,因此 CLI 永不自动回灌 state backup。人工恢复前必须检查 RESTORE.md、backup-plan.json、state-before/state-manifest.json 和 failed-state/state-manifest.json,并按对应 Botmux migration runbook判断。Lightsail snapshot仍只是关键任务的人工兜底,不是日常 rollout transaction。

Backup 与文件布局

state archive 是显式 allowlist:bots.json、.env、.dashboard-port、.dashboard-secret、.dashboard-token、完整 data/ tree,以及存在时的 user systemd botmux.service。目录为 0700,复制文件与manifest为0600并记录 SHA-256;symlink、special file和超过 2 GiB 的archive会被拒绝。logs、pm2、node_modules 与 runtime release artifact 不在 state archive 中。

~/.local/share/workwright/runtime/
├── releases/<exact-id>/        # scripts/lib + checksum manifest;不含 state/config/secret
├── current -> releases/<id>    # atomic release pointer
├── deployments.jsonl          # append-only ledger
├── pending-transaction.json   # crash reconciliation record;commit后删除
├── lifecycle-fence.json        # lifecycle spawn/quiescence fence;正常完成后删除
├── maintenance-active          # exact-tx systemd autostart sentinel;completion ledger后删除
├── backups/<transaction-id>/  # state archive、failed state、exact npm artifacts
└── locks/deploy.lock/
~/.local/bin/                   # 指向 current 内 allowlisted entrypoint 的 stable symlink
~/.botmux/
└── pm2-fleet-mutation.lock     # 仅 mutation窗口存在;与 Botmux自身 fleet lock兼容
~/.config/systemd/user/botmux.service.d/
└── 50-workwright-maintenance.conf  # persistent ConditionPathExists guard

测试或人工裁决可使用 absolute path override:WORKWRIGHT_RUNTIME_DIR、WORKWRIGHT_BIN_DIR、WORKWRIGHT_BOTMUX_DIR;relative override会被拒绝。

已有 managed ~/.workwright/runtime 且 XDG root没有 managed marker时,CLI继续在 legacy root原位管理,并把新 stable entrypoint写入 ~/.local/bin;不会自动删除旧 runtime、~/bin entrypoint或用户文件。若 legacy 与 XDG 两边同时包含 managed state,CLI拒绝静默选择 authority,必须由 operator人工核对后以 WORKWRIGHT_RUNTIME_DIR 指定。

~/.local/bin中不存在的 allowlisted entrypoint会创建 symlink;已由本 manager管理的 symlink会刷新。每个 create/refresh先写并 fsync per-action entrypoints.json intent,目录 mutation fsync完成后才记录 completed;crash recovery仅在能证明 link ownership未变化时撤销。任何 unknown user file/symlink都会 fail closed并要求 operator人工移动,不会自动 takeover、删除或备份。generic stable allowlist不暴露 customer-specific pull-all.sh、workwright-repo-sync、workwright-report-card,也不暴露 deprecated、zero-mutation workwright-bootstrap。

已知限制

  • 仅支持单台 Linux VM 与 user-level Botmux;不创建 Docker、Kamal、Ansible、SSM、root/system service 或 fleet rollout。
  • 依赖 target package 内可验证的 CLI、PM2、runtime build ID、supervisor shutdown protocol、daemon descriptor/session API 与 dashboard health contract;缺失或未知时停止。
  • GitHub Release provenance、多机 canary和Lightsail snapshot编排属于后续交付层。
  • deployment lock与Botmux fleet lock都采用 pre-existing lock manual fail-closed策略,不自动判断或清理 stale lock。人工删除 <runtime>/locks/deploy.lock 或 ~/.botmux/pm2-fleet-mutation.lock 前,operator必须先从进程、pending/fence与systemd状态证明所有 Workwright、Botmux和 npm lifecycle mutation均已停止,然后重新运行受控recovery。
  • upstream Botmux目前不会读取 Workwright pending-transaction.json/maintenance-active。active boundary期间直接运行 native botmux start属于不支持的operator操作;不要手工删除 sentinel或pending。先运行 workwright status --json或workwright doctor确认 boundary,再通过下一次受控 install/upgrade/rollback调用触发 reconciliation。systemd autostart由persistent drop-in阻止,但此contract不声称拦截绕过systemd/Workwright的人工 native start。
  • status --json 会显示 safe pending/fence/maintenance/lock identity与phase;malformed或untrusted lock会成为对应 error和exit 2,不会伪装成“无锁”。active deployment boundary期间,status与doctor跳过 package-backed Botmux probe,避免与 npm mutation竞态。它们不输出 backup artifact path、secret或raw doctor details。

npm release

.github/workflows/publish-runtime-cli.yml 只响应 runtime-cli-v<package-version> tag。workflow 会验证 tag 与 packages/runtime-cli/package.json 的 exact version 一致、tagged commit 属于 origin/main,然后运行 package tests、syntax check 和 pack dry-run;全部通过后才调用 npm publish --access public。

GitHub feature branch
  -> PR + Ubuntu CI
  -> merge main
  -> runtime-cli-v<exact-version> tag
  -> GitHub Actions OIDC
  -> npm @workwright/cli@<exact-version>
  -> per-VM workwright plan / upgrade
  -> status + Doctor + runtime surface smoke

每个箭头都是独立状态边界。GitHub merge 不证明 npm artifact 已存在,npm publish 不证明任何 VM 已升级,VM command 返回成功也不替代 post-rollout health 与实际 chat/integration surface 验收。

npm package 的 Trusted Publisher 必须配置为:

  • Provider: GitHub Actions
  • Organization or user: workwright
  • Repository: platform
  • Workflow filename: publish-runtime-cli.yml
  • Allowed action: npm publish
  • Environment: 留空

该 workflow 使用 GitHub OIDC 的短期凭证,不需要 repository secret 或长期 NPM_TOKEN。完成 npm 侧配置后,维护者按以下顺序发布新版本:

# 先通过 PR 更新 package.json version,并等待 main CI 通过。
git switch main
git pull --ff-only origin main
test "$(node -p "require('./packages/runtime-cli/package.json').version")" = "0.2.0"
git tag -a runtime-cli-v0.2.0 -m "release(runtime-cli): 0.2.0"
git push origin runtime-cli-v0.2.0

tag workflow 成功后,先从 registry 独立确认 exact artifact,再逐台 rollout:

test "$(npm view @workwright/[email protected] version --registry=https://registry.npmjs.org/)" = "0.2.0"

workwright plan --release 0.2.0 --botmux 3.17.0
workwright upgrade --release 0.2.0 --botmux 3.17.0
workwright status --json
workwright doctor

plan 必须先证明 workload idle、target/rollback artifacts 和 runtime contract;upgrade 才进入 maintenance transaction。升级后还要验证 systemd/dashboard 以及实际 Lark、Codex 或其它已启用 surface。多台 VM 当前逐台执行同一 exact 流程;本 CLI 不提供 fleet canary/orchestration。

npm publish 成功只证明 registry artifact 已发布。若 Trusted Publisher 尚未配置或 identity 不匹配,publish job 会失败,不得改用长期 bypass-2FA token 绕过。

开发验证

npm test
npm run check
npm pack --dry-run

# Ubuntu CI opt-in registry integration; installs exact Botmux only in private temp prefixes.
WORKWRIGHT_REAL_BOTMUX_INTEGRATION=1 npm run test:real-botmux