mcpField
D
dev-server

dev-server

github.com/civitai开发7.3k78 次安装今天更新
代码仓库

关于 dev-server

dev-server 是用于集中管理多个 Git worktree 中 Next.js 开发服务的技能。守护进程负责端口分配、环境变量注入和日志汇总,并提供就绪检查与限时探测来诊断响应问题。

主要功能

  1. 01跨 worktree 管理服务
  2. 02分配端口并注入环境变量
  3. 03汇总会话日志
  4. 04检查就绪状态并探测路由
  5. 05分类诊断缓慢或失败响应
  6. 06重启卡住的会话

适用场景

  1. 01并行运行多个 worktree 的开发服务
  2. 02查看由其他 Agent 启动的会话日志
  3. 03清理构建缓存前诊断慢路由
  4. 04收到卡住判定后重启会话

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/civitai/civitai --skill dev-server

安装后在 Claude Code 中输入/dev-server确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

.env.example
.gitignore
cli.mjs
console.mjs
env-modes.example
README.md
scripts/
app-registry.selftest.mjs
branch-watch.selftest.mjs
cli-verbs.selftest.mjs
daemon-home.selftest.mjs
daemon-port.mjs
daemon.mjs
db-host.selftest.mjs
defender-exclusions.ps1
env-chain.selftest.mjs
env-modes.mjs
paths.mjs
port-probe.mjs
probe.integration.selftest.mjs
probe.mjs
probe.selftest.mjs
test-queue.mjs
worktree-remove.integration.selftest.mjs
worktree.mjs
worktree.selftest.mjs

详细介绍

开发服务器技能

集中管理多个 Git 工作树中的 Next.js 开发服务器。守护进程负责分配端口、注入环境变量和聚合日志,因此无论由谁启动服务器,任何 Agent 都能访问开发服务器日志。

快速开始

# Check what's running
node .claude/skills/dev-server/cli.mjs status

# Start a dev server for current worktree
node .claude/skills/dev-server/cli.mjs start

# Start for a specific worktree
node .claude/skills/dev-server/cli.mjs start /path/to/worktree

# Start with a service on production (see Env modes)
node .claude/skills/dev-server/cli.mjs start /path/to/worktree --prod buzz

# View logs
node .claude/skills/dev-server/cli.mjs logs <session-id>

# Stop a session
node .claude/skills/dev-server/cli.mjs stop <session-id>

**检查服务器是否就绪:**启动后,轮询会话状态,检查 ready: true。守护进程会通过配置的健康检查端点或检测日志中的“Ready”模式来标记会话已就绪。

守护进程运行在哪个 Node 上——以及为何会保持不变

守护进程使用 process.execPath 启动(位于 cli.mjs 和 console.mjs 中的 startDaemon),也就是说, 由首次启动守护进程的 CLI 命令所使用的 Node 运行它。之后,它会将自身的环境传递给它管理的每个 next dev 进程。因此,你首次输入上述任一命令时恰好在 PATH 中的 Node,就是整个进程树使用的 Node,直到有人关闭守护进程为止——而且没有任何记录会记下当时使用的是哪个 Node。

这并非纸上谈兵。在一台开发机上的测量结果是:守护进程运行在环境中的 Node 26.7.0 上,而 .nvmrc 固定为 24.19.0,package.json 声明了 engines.node: ">=24.0.0 <25",生产环境则基于 node:24.19.0-alpine3.24 构建。它是在开发环境外的 shell 中启动的,而且该 shell 中也没有 pnpm——因此守护进程自身的自动安装流程会被静默禁用(检测到锁文件或 schema 变更时,它会通过 shell 执行 pnpm install / pnpm run db:generate)。

**无论你的设置如何,解决办法都是:在 node --version 与 .nvmrc 匹配且 PATH 中包含 pnpm 的 shell 中启动它。**在仓库根目录运行 nvm use 即可满足这两个条件。

在 NixOS 上(可选),flake 包装器会为你完成这些设置——它会将 Node 固定为 .nvmrc 中指定的版本, 将 pnpm 加入 PATH,并导出 NixOS 所需的 Prisma 引擎路径:

nix run .#dev-server -- status
nix run .#dev-server -- start
nix run .#dev-server -- logs <session-id>

本文其他地方的每个 node .claude/skills/dev-server/cli.mjs … 调用都使用相同的子命令——包装器只决定由哪个 Node 运行它们,因此这里的内容都不依赖 Nix。

无论采用哪种方式,在信任会话之前,先检查当前环境:

# the daemon's real interpreter, not the one you assume.
# the pid file belongs to the skill dir the daemon RUNS FROM, which is the primary checkout — a
# relative path here reads the wrong tree's file, or none, when you are standing in a worktree.
readlink -f /proc/$(cat <primary-checkout>/.claude/skills/dev-server/daemon.pid)/exe

更换 Node 意味着必须重启守护进程——先运行 cli.mjs shutdown,再从正确的 shell 中重新启动。正在运行的守护进程不会获取新的 PATH。

第二个守护进程:DEV_DAEMON_PORT

守护进程运行在 127.0.0.1:9444。设置 DEV_DAEMON_PORT,即可在旁边再运行一个守护进程:

DEV_DAEMON_PORT=9555 node .claude/skills/dev-server/cli.mjs status

CLI、控制台、scripts/test-unit-run.mjs 和守护进程自身都会通过 scripts/daemon-port.mjs 确定端口,而且客户端启动的守护进程会继承该客户端的环境——因此它们不会对守护进程所在的位置产生分歧。直到 2026-08-19,守护进程都不会读取此变量;设置该变量会让客户端指向一个没有服务监听的端口。

此变量仅作为守护进程的默认值;显式指定的 node scripts/daemon.mjs --port <port> 仍优先。每个守护进程都会占用共享的 daemon.pid,因此该文件记录的是最后启动的那个守护进程。

永远不要用 curl 访问开发端口——改用 probe

node .claude/skills/dev-server/cli.mjs probe /home

无法正常提供服务的开发服务器不会拒绝连接。它会接受请求并缓慢响应,或者接受请求后始终不响应——因此,无限等待的 curl 会一直挂起,直到 300s 的工具超时,然后什么都不返回,让你无从处理。在一次 shell 调用中串联几个这样的命令,十分钟就会这样白白耗掉。因此,PreToolUse hook 会阻止对开发端口发起无限等待的请求;如果你确实想用 curl,可以用 --max-time 作为例外。

probe 会在严格的时间预算内请求该路由两次,读取当前会话中这两次请求对应的日志,并返回结论以及匹配的处理方法。它总会终止。

UPSTREAM-SLOW  http://localhost:3000/home
  UPSTREAM-SLOW — the framework is fine; time is spent in application code (database, tunnel, cache).
  first : 200 in 8.10s  [next.js 30ms | application-code 8.10s]
  repeat: 200 in 8.10s  [next.js 31ms | application-code 8.10s]
  -> Not a cache problem — do NOT purge, it will not help. [...]

对于 ok/cold,退出码为 0;其他情况则为 1,因此它可以在检查中替代 curl。

有两个结论值得先了解。stopped-answering 表示第一次请求得到了处理,而第二次没有——进程仍在运行,只是内部某处卡住了,因此启动另一个会话并不是正确做法,处理方法也会说明这一点。此外,probe 可能会添加 note: more than one request to this route in the window:probe 会给自己的请求附加 ?__probe= 随机数,以便找到对应的日志行,但对于本身已有查询字符串的路由,以及所有处理程序会解析其查询参数的 /api/trpc/* 路由,它会退回到按路径匹配;在繁忙会话中,其他请求的流量确实无法与我们的请求区分开来。这条备注表示读取结果可能并非针对你的请求,这与服务器拒绝说明自身情况不同。

为什么仅凭耗时无法判断问题所在

两种不同的故障会产生相同的表现——页面耗时约 8s,无论热启动还是冷启动都一样,返回 200,日志里也没有明显的错误。健康检查在这两种情况下都会持续通过,因为 /api/health 是一条开销很低、已编译的 API 路由,而这两种故障都不会影响它的功能。

Next 会在它打印的每一条请求日志中区分这两种情况:

 GET /home 200 in 8.1s (next.js: 39ms, proxy.ts: 8ms, application-code: 8.1s)
读数含义解决方法
重复请求中 next.js 占主导构建缓存未生效;框架每次都会重新处理unwedge — 清除缓存并重启
application-code 占主导框架正常;耗时来自下游(数据库、隧道、缓存)修复上游。清除缓存耗时 45s,且毫无作用
重复请求很快冷启动编译,工作正常无需处理

有两种故障响应很快,而且会在应用任何耗时规则之前进行检查,因为页面已损坏却判定为 ok,比不做判定更糟。合并或检出代码后,过期的 node_modules 会在几毫秒内返回 500(STALE-DEPS — 解决方法是运行 pnpm install,清除构建缓存不会安装任何内容);而下面的设置自请求情况会快速渲染出降级页面。

有一种情况会让上述区分失效,probe 会先检查这种情况。 _app 在每次 SSR 渲染时都会自请求 /api/user/settings,并在 APP_SETTINGS_FETCH_TIMEOUT_MS(默认 8s)后中止。这个中止会计入 application-code,因此,仅凭耗时区分时,无法响应自身 API 路由的服务会被判定为“数据库慢”。可通过搜索定位的标记 [_app] settings bootstrap fetch failed 优先级高于耗时,判定结果为 SELF-FETCH-FAILING;随后 probe 会直接请求该端点,并告知你遇到的是哪种原因——自请求指向了错误的端口(NEXTAUTH_URL_INTERNAL 与会话端口不一致),还是该端点确实始终没有响应。

将它与任何慢速依赖区分开的特征是:页面耗时是一个恒定值,等于已配置的超时时间,热启动和冷启动时都一样。慢速依赖的耗时会变化;超时不会。 2026-08-16 的测量结果显示,将 APP_SETTINGS_FETCH_TIMEOUT_MS 从 8000 调到 30000 后,/home 的耗时同步从 8.1s 变为 30.1s;无论取哪个值,清除 .next 都没有帮助。

阅读该判定结果时,还要知道:updateEnvUrlsForPort 在端口 3000 上会提前返回,因此主会话会完全按 .env 中的设置使用 NEXTAUTH_URL_INTERNAL,而每个次级会话都会将其改写为自己的端口。当前这个值是正确的——这只是解释了为什么两种会话可能不同,以及为什么判定结果会要求你将它与会话端口进行比较。

因此,“耗时稳定在 8s 就意味着要清除 .next”这种规则是错误的,而且作为习惯会浪费大量时间:2026-08-16 在这个仓库测得,/home 稳定耗时 8.1s,其中框架耗时 30ms,应用代码耗时 8.1s——清除缓存会删掉数 GB 内容,却毫无作用。看耗时构成,不要只看总耗时。

当无法获取耗时构成时(日志缓冲区溢出、没有会话),判定结果会是 slow-unclassified,并明确说明这一点,而不是猜测。

unwedge — 仅在判定为 WEDGED 后使用
node .claude/skills/dev-server/cli.mjs unwedge <session-id>

停止会话,删除其构建目录,在相同的环境模式下重启,等待就绪后重新探测,并打印各项耗时。它必定需要约 45 秒重新构建,因此不会自动执行,也不会猜测会话:请指定 ID,因为你没指定的会话通常正是别人正在查看的那个。

自测。使用独立脚本,不使用 vitest——运行受你改动影响的脚本;编辑 cli.mjs 后,还要运行 cli-verbs:

node .claude/skills/dev-server/scripts/env-chain.selftest.mjs          # .env layering, both directions
node .claude/skills/dev-server/scripts/app-registry.selftest.mjs       # registry, ports, path identity, the lock
node .claude/skills/dev-server/scripts/db-host.selftest.mjs            # the DB host line never emits a credential
node .claude/skills/dev-server/scripts/cli-verbs.selftest.mjs          # every dispatch target in cli.mjs exists
node .claude/skills/dev-server/scripts/branch-watch.selftest.mjs       # HEAD watching + the restart decision
node .claude/skills/dev-server/scripts/probe.selftest.mjs              # the classifier, pure
node .claude/skills/dev-server/scripts/probe.integration.selftest.mjs  # the real probe() end to end
node .claude/skills/dev-server/scripts/worktree.selftest.mjs           # what `wt stale` / `wt rm` say about a PR, a prune, and the daemon's home
node .claude/skills/dev-server/scripts/worktree-remove.integration.selftest.mjs  # `wt rm`'s daemon guard, against a throwaway repo
node .claude/skills/dev-server/scripts/daemon-home.selftest.mjs        # the daemon runs from the primary, never the calling worktree
node .claude/hooks/check-writable.selftest.mjs                         # the hook, both directions

之所以有集成测试,是因为单元测试无法发现最关键的错误。runSample 曾在传递给 classify 的过程中丢弃了 missingModule,导致整个 stale-deps 判定都成了死代码——而针对它的每个单元测试都通过了,因为测试手动构造了生产代码从未生成过的字段。现在重新引入这个错误,单元测试仍会通过,但集成测试会失败。 任何新增信号都应加入集成测试文件,而不只是单元测试文件。

CLI 命令

命令描述
probe [route]有界请求 + 判定(ok/cold/wedged/stale-deps/upstream-slow/proxy-slow/error-status/self-fetch-failing)。请用它代替 curl
unwedge <session-id>停止、清除构建目录、重启、等待并重新探测(约 45 秒)
status检查守护进程状态并列出所有会话
list列出所有开发会话
start [worktree] [--app name] [--prod a,b] [--dev a,b]启动开发服务器(默认为主应用,即当前目录)
logs [session-id] [--app name]获取会话日志,或此工作树中某个应用的日志
tail [session-id] [--app name]持续跟踪日志
stop <session-id> | stop --app name停止会话或应用
restart <session-id> | restart --app name重启会话或应用
rgb [subcmd]RGB 代理控制(status|start|stop|restart|logs)
app列出正在运行的应用、对应的工作树以及可用内容
app <name> [subcmd] [worktree]应用控制(status|start|stop|restart|logs)
auth [subcmd]Auth hub 控制(status|start|stop|restart|logs)
test run [worktree]排队运行单元测试;返回队列位置以及用于等待测试完成的命令
test wait <run-id>阻塞等待该次运行结束;退出码与该次运行的退出码相同
test list / test show <id> / test logs <id>查看队列状态、某次运行或某次运行的输出
test cancel <id>取消排队中或正在运行的测试
test config [n]查看或设置并发上限(0 会暂停队列)
shutdown关闭守护进程

环境模式 — 会话连接哪些服务

会话会叠加其 .env 文件——以主检出目录的文件为基础,再叠加工作树自身的文件——然后应用各服务的覆盖配置。覆盖配置只会重新声明其指定服务的键,因此链中的其他内容不会变动。

没有 env-modes.local 就表示完全不应用覆盖配置,并非“所有服务都使用开发环境”。该文件已加入 gitignore,因此不会随检出内容一同提供:在它创建之前,每次启动都会像此功能推出前一样,完全使用基础 .env;摘要会显示 (no groups defined — no overlay applied)。

# Every defined group on dev. This is what a bare start does.
node .claude/skills/dev-server/cli.mjs start

# Buzz on production, everything else still dev.
node .claude/skills/dev-server/cli.mjs start --prod buzz

# Several groups, the whole lot, and the whole lot with an exception.
node .claude/skills/dev-server/cli.mjs start --prod db,search
node .claude/skills/dev-server/cli.mjs start --prod all
node .claude/skills/dev-server/cli.mjs start --prod all --dev search

--prod/--dev 只能切换你自己的 env-modes.local 中定义的组,而该文件已加入 gitignore——全新复制的 env-modes.example 不定义任何组,因此在你填入内容之前,--prod all 不会产生任何效果。添加服务需要修改该文件,而非代码。 ⚠️ env-modes.local 不会像现在的 .env 那样逐级回退。 它从正在运行的守护进程的技能目录中读取(env-modes.mjs),而该文件已加入 Git 忽略,因此仅存在于主检出目录中。包含 resolveDaemonHome 更改的 CLI 或控制台会从主检出目录启动守护进程,因此能正常处理该文件;但技能目录已提交到 Git,所以每个工作树都运行自己的副本:在该更改之前创建的工作树仍会在自身目录中启动守护进程,手动运行 node scripts/daemon.mjs 启动的守护进程也是如此。无论哪种情况,它都会读取该工作树的技能目录,找不到 env-modes.local,因此完全不会应用覆盖配置。

有几个服务根本没有可切换到的开发环境对应服务——env-modes.mjs 在 PROD_ONLY_GROUPS 中列出了 orchestrator、payments、s3、clickhouse、notifications、feeds 和 opensearch,并在 UNMOVABLE_GROUPS 中列出了 auth-hub(它是一个共享进程,读取自己的 .env,因此在那里应用覆盖配置会让应用指向一个未运行的 hub)。所以,mode: dev 绝不意味着“这里没有生产环境”。

⚠️ 应用没有环境模式。 --prod/--dev 仅适用于主应用;应用使用自己的 .env 链,不应用覆盖配置。start --app <name> --prod db 会被拒绝,而不会被静默忽略。

默认值。 每个组的默认模式都是 dev。如果开发服务不稳定,可在技能目录自己的 .env 中调整默认值:

DEVSERVER_PROD_GROUPS=buzz

编辑这一行不会改变已经运行的会话——会话会固定使用创建时的默认值,因此切换分支或崩溃重启都不会悄悄改变其环境。代价是,在这些会话重启之前,对其中任一个会话执行不带参数的 start 都会因模式不匹配而被拒绝。这虽然不方便,但符合实际情况:不带参数的 start 现在会解析到其他模式。

--prod / --dev 标志优先于默认值,且仅对本次启动生效——使用 --prod 启动后,接下来不带参数的 start 会恢复为 dev,包括复用已结束的会话时也是如此。如果某个工作树已有运行中的会话,要求使用不同模式会被拒绝,而不会静默返回正在运行的会话;请先停止该会话。

⚠️ dev 不代表安全。 orchestrator、payments、S3、ClickHouse、notifications DB、feeds 代理和 OpenSearch 完全没有开发环境对应服务——即使会话处于完整的 dev 模式,也仍会连接到所有这些服务的生产环境。无论处于何种模式,按下 Generate 都会提交真实任务并消耗真实 Buzz。每次模式摘要后都会打印此列表,正是出于这个原因。

auth hub 不跟随 db 模式。 它是所有会话共享的单个进程,并读取自己的 apps/auth/.env,因此使用 --prod db 的会话会向该文件指定的数据库进行身份验证,然后再根据生产环境解析由此得到的用户 ID。开发环境和生产环境中的用户 ID 属于不同的记录,因此可能会遇到 404,甚至更糟:以另一个真实用户的身份操作。使用登录信息搭配 --prod db 前,请手动将 apps/auth/.env 指向同一个数据库。

在已预热的构建目录中更改 search 或 signals 模式并不能完全清理干净。 这些组会设置 NEXT_PUBLIC_* 值,Turbopack 会将它们内联到客户端代码块中,而构建目录按分支而非模式划分。如果浏览器端表现很重要,更改其中任一模式后请删除 .next——服务端代码会立即读取新值,因此只有客户端编译时使用的值会受到影响。

查看运行中会话模式的位置: status 和 list 会包含 envModes 和 envModeSummary,守护进程日志会在启动时将这些信息打印在 Env: 旁边:

Env: C:\Dev\Repos\work\wt-thing\.env
Env modes: buzz=dev db=prod redis=dev search=dev signals=dev | always prod (no dev target): ...

排队的测试运行

单元测试套件会占满所有核心。运行一次没问题;但五个 Agent 同时各自启动一次,就会让机器不堪重负。守护进程会将它们串行执行。

# 1. Request a run. Returns immediately, whether it started or queued.
node .claude/skills/dev-server/cli.mjs test run
#    Run t3f9a2 queued at position 2 of 3 (1/1 running).
#    Wait for it in the background: node .claude/skills/dev-server/cli.mjs test wait t3f9a2

# 2. Wait for it — in the background, so you can work meanwhile.
node .claude/skills/dev-server/cli.mjs test wait t3f9a2

test wait 会以本次运行自身的退出码退出,因此可在任何检查 pnpm run test:unit:run 的地方替代它。-- 后的额外参数会传递给 vitest,因此 test run . -- path/to/one.test.ts 可以缩小运行范围。

并发数默认为 1,可在技能的 .env 中通过 TEST_CONCURRENCY 配置,也可在运行时使用 test config <n> 配置。0 是合法值,表示暂停——在调高并发数之前不会启动任何任务。在暂停队列后面排队的调用方会收到明确提示,而不是得到一个位置后一直等待。

依赖此功能前,值得了解以下事项:

  • 运行由守护进程管理,而不是由你管理。 Agent 在等待过程中退出不会释放任何资源,因为它并未持有资源。子进程退出时,槽位才会释放。
  • 调用方停止轮询的排队任务会在 10 分钟后被丢弃,因此失效的 Agent 无法占住队列。test wait 每 2s 轮询一次,因此正常等待的调用方不会触发此情况。
  • 运行超过 30 分钟会被终止,并报告为 timeout。如果终止操作没有产生退出状态,宽限期过后也会释放槽位,而不会无限期占用。
  • 守护进程重启会丢弃队列。 如果运行 ID 未知,test wait 会将其视为已结束,并以非零状态退出,提示你重新请求——它不会针对已忘记你的守护进程无限轮询。
  • 排队位置是精确值,不是估算值:它是有序列表中的索引。
  • 日志窗口最多保留最近 2000 行,并会提示日志已被截断。 运行输出超过此数量时,最早的日志行会丢失,因此较晚执行的 test wait 或 test logs 读取到的可能只是片段。两个等待程序都会打印 WARNING: this log is INCOMPLETE …,并指出丢失的行数;运行视图中也会显示 logsDropped——非零值表示不要将当前内容引用为完整运行日志。从一开始就在持续接收日志的等待程序不会受到影响。
  • 两个等待程序使用的都是 exitCodeFor 的退出码。 test wait 和 pnpm run test:unit:run 使用相同规则,因此被信号终止的运行通过任一方式报告的退出码都是 1,而不是 shell 的 255。

会话对象

每个会话包含:

{
  "id": "a1b2c3d4",
  "worktree": "/path/to/worktree",
  "branch": "feature/my-feature",
  "port": 3000,
  "status": "running",
  "ready": true,
  "readyAt": "2024-01-15T10:30:02.000Z",
  "startedAt": "2024-01-15T10:30:00.000Z",
  "url": "http://localhost:3000",
  "envModes": { "db": "dev", "buzz": "dev", "search": "dev", "signals": "dev", "redis": "dev" },
  "envModeSummary": "buzz=dev db=dev ... | always prod (no dev target): orchestrator, payments, ..."
}

状态值:starting、running、stopped、crashed、error

日志条目

{
  "index": 42,
  "timestamp": "2024-01-15T10:30:05.123Z",
  "level": "stdout",
  "message": "Ready on http://localhost:3000"
}

日志级别:stdout、stderr、error、warn、info

仪表盘 TUI

运行 node .claude/skills/dev-server/console.mjs(或 pnpm run dev:daemon)即可打开实时终端仪表盘。

按键操作
1筛选:错误(error + warn 级别)
3筛选:trpc
4筛选:api
5筛选:prisma
6筛选:仅 stdout
7筛选:仅 stderr
8筛选:info(守护进程消息)
/ 或 f全文搜索(输入查询内容,按 Enter 应用)
a显示所有日志(清除筛选条件)
r重启会话
c清除日志缓冲区
x停止会话并退出
R切换 RGB 代理(启动/停止)
A切换 auth hub(启动/停止)
s 或 Tab切换到下一个会话(仅在运行的会话超过一个时显示)
q退出仪表盘(服务器继续运行)
K终止守护进程并退出

筛选条件可切换启用/停用。当前启用的筛选条件会在页脚栏中高亮显示。搜索会将匹配文本标为红色。

RGB 代理

守护进程可选择管理 rgb-proxy 反向代理(将 civitai-dev.{red,green,blue} 指向本地开发服务器)。

配置

编辑 .claude/skills/dev-server/.env:

RGB_PROXY_ENABLED=true            # auto-start proxy when daemon boots
RGB_PROXY_PATH=../rgb-proxy       # path relative to project root

同时确保主 .env 中有 NEXTAUTH_URL=https://civitai-dev.blue 和 SERVER_DOMAIN_*,并确保 hosts 文件将这三个域名映射到 127.0.0.1。首次设置请参阅 .claude/skills/rgb-proxy/SKILL.md。

控制
# Start / stop / restart / status / logs via CLI
node .claude/skills/dev-server/cli.mjs rgb start
node .claude/skills/dev-server/cli.mjs rgb status

# Or via pnpm scripts
pnpm dev:rgb          # start proxy (daemon boots if not already running)
pnpm dev:rgb:stop
pnpm dev:rgb:status

在仪表盘 TUI 中,按 R 切换代理。

Admin / sudo 要求

Redbird 会绑定端口 80 和 443。在 Windows 上,必须从提升权限的终端启动守护进程;在 macOS/Linux 上,使用 sudo 启动。如果启动失败,守护进程会通过 /rgb 状态和 RGB 代理日志显示 lastError。

应用

moderator 和 creator-studio 与主应用一样通过守护进程运行——从当前所在的工作树启动,日志存放在同一位置,端口由同一个分配器预留。

node .claude/skills/dev-server/cli.mjs start --app moderator    # from the worktree you are in
node .claude/skills/dev-server/cli.mjs app                      # what is running, and where
node .claude/skills/dev-server/cli.mjs app moderator logs
node .claude/skills/dev-server/cli.mjs app moderator restart
node .claude/skills/dev-server/cli.mjs app moderator stop

--app <name> 适用于 start、logs、tail、stop 和 restart,因此一个操作即可覆盖整个生命周期; app <name> <subcmd> 只是换一种写法。两者默认使用当前目录的工作树,而 app <name> <subcmd> <worktree> 则指定其他工作树。运行中的应用也会显示在 status 的 apps 下,与主应用会话并列。

应用首选端口
moderator5174
creator-studio5175

首选端口并非固定端口。 第一个启动应用的工作树会获得表中的端口;第二个启动同一应用的工作树会改用下一个空闲端口,而不会因 EADDRINUSE 而退出,因此同一应用的两个分支可以并行运行。无需修改任何内容来适应端口变化:两个应用的 .env 都不会包含自身的端口或基本 URL。所有应用的首选端口都会从端口变化搜索中预留,因此 moderator 端口变化时不会占用 creator-studio 的端口。

🔴 请读取 path 字段,而不只是确认应用已启动。 如果应用服务于错误的检出目录,它仍会返回 200,表面上一切正常——唯一能证明情况不对的就是 path。

.env 会从主检出目录回退读取。 应用以 <primary>/apps/<name>/.env 为基础配置;如果工作树中存在 <worktree>/apps/<name>/.env,则在此基础上叠加该配置。这些文件已加入 gitignore,因此新建的工作树中都没有它们——基础配置正是应用能够启动的关键。

⚠️ 该基础配置可能指向生产数据库,回退机制意味着新建的工作树会直接继承它,无需任何人主动选择。应用启动时会打印 DATABASE_URL 的主机名(绝不打印凭据),并在 app / status 输出中将其列为 dbHost——执行任何写入操作前请先检查。

storage 和 notifications 不在此处。它们虽然已注册,却始终无法启动:没有 vite.config.ts(它们的开发脚本是 tsx watch src/server.ts),也没有 .env,因此每次尝试都会在 .env 预检查时报出 auth-hub 问题后退出。要么正确注册它们,要么完全不要注册。

启动应用时,如果 auth hub 已启用但尚未运行,就会启动它——没有 auth hub,应用就无法让任何人登录。主应用不会启动:应用需要时会通过 REST 访问主应用,而且冷启动 Next.js 编译不是应该擅自替他人触发的操作。如果当前页面需要主应用,请自行启动。

它们会绑定 ::,这是刻意为之

守护进程会为每个 Vite sidecar 添加 --host ::。绑定到 127.0.0.1 时,它们可以通过 v4 响应,但无法通过 [::1] 响应;Windows 会优先将 localhost 解析为 ::1,因此浏览器会收到连接被拒,而会回退到 v4 的 curl 却报告服务器完全正常。--host localhost 也不是解决办法:它只会解析为 ::1,从而影响所有硬编码使用 127.0.0.1 的调用方。:: 接受 v4 映射地址,因此 localhost、127.0.0.1 和 [::1] 都能正常响应,并且与 next dev 在 3000 端口上的绑定方式一致。代价是::: 也会监听 LAN 接口,而之前的绑定仅限于环回地址。

首次请求较慢

Vite 会在开发模式下按需编译路由。冷启动的应用首次收到请求时可能需要25 秒或更久(auth hub 的 /login 最慢);之后的每次请求只需几百毫秒。启动后浏览器停留在白屏上,通常是这种情况,并非卡死。

仪表板只显示主应用会话。使用 --app 启动的应用不会显示在那里——请使用 cli.mjs app 或 cli.mjs status。

Auth Hub

身份验证已拆分为独立的登录中心(apps/auth,SvelteKit,端口为 5173)。主应用现在是一个仅负责验证的分支端:它通过登录中心的 JWKS 验证登录中心的 civ-token,不再自行运行 next-auth 登录。因此,开发环境中进行全新登录需要启动登录中心。守护进程会以边车形式启动并管理它;当设置了 AUTH_HUB_ENABLED 且登录中心尚未运行时,启动应用(start --app <name>)也会同时启动它。

控制
node .claude/skills/dev-server/cli.mjs auth status    # status + JWKS url + lastError
node .claude/skills/dev-server/cli.mjs auth start
node .claude/skills/dev-server/cli.mjs auth restart
node .claude/skills/dev-server/cli.mjs auth logs

在仪表板 TUI 中按 A 可切换其状态;会话行会显示 AUTH: ready。

配置

.claude/skills/dev-server/.env:

AUTH_HUB_ENABLED=true      # auto-start the hub when the daemon boots
AUTH_HUB_PATH=apps/auth    # relative to the PRIMARY checkout, not the tree the daemon runs from
AUTH_HUB_PORT=5173         # hub dev port (matches AUTH_JWT_ISSUER)
一次性环境变量设置

登录中心读取自己的 apps/auth/.env(由 Vite 加载——守护进程不会注入)。该文件已为本地开发环境生成,复用了主应用的开发数据库、Redis、密钥和提供商凭据,并生成了一对全新的 EC P-256(ES256)签名密钥。以下两个文件需要关注:

  • apps/auth/.env — 登录中心签名密钥对(AUTH_JWT_PRIVATE_KEY/_PUBLIC_KEY,PKCS8/SPKI)、AUTH_JWT_ISSUER=http://localhost:5173、共享的 NEXTAUTH_SECRET 和 AUTH_INTERNAL_TOKEN、数据库/Redis、提供商、电子邮件。
  • 根目录 .env — 分支端设置:AUTH_JWT_ISSUER + AUTH_JWKS_URI(→ localhost:5173)+ 匹配的 AUTH_INTERNAL_TOKEN。

重新生成密钥对:openssl ecparam -genkey -name prime256v1 -noout -out sec1.pem && openssl pkcs8 -topk8 -nocrypt -in sec1.pem -out priv.pem && openssl ec -in sec1.pem -pubout -out pub.pem(私钥必须为 PKCS8 格式,即 BEGIN PRIVATE KEY,而非 SEC1 格式,才能用于 jose 的 importPKCS8)。

RGB 代理模式

登录中心在 RGB 代理后也能正常工作。当你通过 https://civitai-dev.{red,green,blue}(而非 localhost:3000)访问开发环境时,这些是不同的可注册域名,而非环回地址,因此除非将它们设为可信域名,否则登录中心会拒绝来自这些域名的第一方登录。登录中心通过 apps/auth/.env 中的 AUTH_DEV_TRUST_HOSTS 将它们设为可信域名(仅限开发环境;已设置为这些颜色域名和 civitai-dev.cyan,与主应用的 SERVER_DOMAIN_* 保持一致)。无论你访问哪个颜色域名,主应用仍会通过 AUTH_JWT_ISSUER=http://localhost:5173 指向登录中心——每个颜色域名都会通过第一方 OAuth 流程,在自己的域名下签发自己的 civ-token。如果新增颜色域名或别名,请将其主机名添加到 AUTH_DEV_TRUST_HOSTS,并重启登录中心(cli.mjs auth restart)。此设置在生产环境中会被完全忽略。

登录
  • 电子邮件魔法链接开箱即用(已设置 EMAIL_*)——无需更改外部控制台设置。
  • 社交提供商(GitHub/Google/Discord/Reddit)还需要在提供商控制台中允许登录中心的重定向 URI:http://localhost:5173/login/<provider>/callback。在添加该 URI 之前,登录会因 redirect_uri_mismatch 而失败。
  • 浏览器中任何旧版 civitai-token Cookie 都仍可在没有登录中心的情况下正常解析(仅验证解码),登录中心启动后,会在下一次请求时升级为 civ-token。

原地切换分支

只需运行 git checkout <branch>。其余工作由守护进程处理——无需 rm -rf .next,也无需手动重新安装。

HEAD 移动时会发生以下情况:

  1. 开发服务器会继续运行。 它不会被终止。其内存中的模块图会在检出操作后保留,因此只有实际发生变化的部分才会重新编译。
  2. 防抖处理(BRANCH_SWITCH_DEBOUNCE,默认在 HEAD 静默 3 秒后执行),以便先等待变基或快速连续切换完成。
  3. 仅在不可避免时重启——pnpm-lock.yaml 发生变化(先安装,再重启)或 prisma/schema.prisma 发生变化(先运行 db:generate,再重启)。这些操作会替换 node_modules 和生成的客户端,而运行中的进程无法使用替换后的内容。其他变化都不会触发重启。
  4. 重新预热,让重新编译发生在守护进程中,而不是等到你下次点击时才进行。
为什么不终止进程

最初的设计就是这样,理由是检出代码时修改了 Turbopack 监视的目录树,导致服务器卡住。但实际测量表明,这个判断是错的——终止进程只会让情况更糟。在同一台机器上使用相同的路由,在两个分支之间切换:

/models/images
终止进程后冷启动42.6s3.1s
保持运行,首次切换23.0s0.3s
保持运行,后续切换7.7-9.2s1.0-1.6s

每次切换期间,服务器都保持正常运行。未要求重新构建的路由始终保持预热状态。

如果检出代码时服务器真的卡住,设置 KILL_ON_BRANCH_SWITCH=true 即可恢复旧行为。

按分支划分构建目录(默认关闭)

PER_BRANCH_DIST_DIR=true 会通过 next.config.mjs 中的 distDir: process.env.NEXT_DIST_DIR || '.next',为每个分支指定独立的 .next/branches/<slug> 目录。只有服务器实际重启时,这项设置才有影响;现在切换分支时进程会保持运行,因此该设置已关闭,而且每个目录都会增长到约 4 GB。共用一个 .next 目录还能让未更改的模块在不同分支间继续有效,而按分支划分目录则会失去这一点。

预热

服务器报告就绪后,会在后台编译 PREWARM_ROUTES 中的路由;每次切换分支后也会再次编译。冷编译首个路由需要约 45s,因为它会拉取整个共享依赖图;预热可以让这项工作提前完成。

默认值为空,而且 skill .env 已被 gitignore,因此全新检出时完全不会预热——请自行设置 PREWARM_ROUTES(参见 .env.example)。启动时预热还需要配置 HEALTH_CHECK_URL:仅凭日志模式检测到就绪状态并不会触发预热。切换分支后的预热则无论如何都会运行。

在一个完全没有缓存的分支上测得:

守护进程预热(后台)/ 45.2s、/models 3.5s、/images 2.9s——在 t+62s 完成
随后打开这些页面0.07-0.14s
打开列表中不包含的路由(/articles)10.3s

添加你实际会访问的路由。列表越长,代价只是需要在后台完成更多工作;但列表中的路由会按顺序处理——并行请求会争用同一个编译器,使首个路由更晚就绪。将列表设为空即可停用预热。

列表页不会预热详情页。 /models 和 /models/[id]/[[...slug]] 是独立路由,需要分别编译——/models 已预热,也无法避免模型页面首次加载时约 30s 的冷启动。因此,请在列表中加入一个具体的模型 ID;ID 可以任意,关键是它解析到的路由。系统会跟随重定向,因此 /models/<id> 可以正常到达 slug 路由。

每次会话启动时,都会重新读取 skill .env。保持进程运行的分支切换不会重启会话,因此对 PREWARM_ROUTES 的修改,只有在触发重启的切换(锁文件或 schema 发生变化)或显式执行 restart 后才会生效。

缓存为什么这么大

这不是按页面计算的。在这个仓库中从空存储开始测得的数据如下:

步骤存储大小响应时间
启动 + /api/health1 MB—
/models(第一个实际页面)3995 MB49.9s
/images2662 MB3.8s
/articles2822 MB8.2s
/bounties3762 MB28.3s

第一个路由编译时会加载整个共享依赖图——pages-router 的 _app 加上它的所有传递依赖——仅此就需要约 4 GB。之后的每个路由只增加几百 MB,而且总量会上下波动而非持续增长:编译更多路由期间,存储从 3995 MB 降到了 2662 MB,因为压缩回收了已被取代的片段。存储的 LOG 证实了这一点:单次提交最多包含 3.4M 个键,并且始终在执行 MERGE/Compaction。

因此,这个应用的工作集大约是每个分支 2.5-4 GB,这些都是真实数据,并非内存泄漏。它很大,是因为依赖图很大(3.4M 个缓存键,node_modules 中约有 210k 个文件)。源映射并非主要原因——在抽样片段中只占 0.3%。

这份空间占用物有所值:同一路由重启后,冷启动需要 45.8s,热启动只需 4.3s。禁用 turbopackFileSystemCacheForDev 会用磁盘空间换来慢 10 倍的重启;对于磁盘空间充足的机器来说,这是不划算的交换。

每次启动时都会根据两个预算,按 LRU 策略淘汰目录:保留最近的 DIST_CACHE_KEEP 个(默认值为 4),并限制总量不超过 DIST_CACHE_MAX_GB(默认值为 40)。真正起作用的是大小上限——每个分支的工作集约为 4 GB,仅靠数量上限会让 4 个分支占用约 16 GB。活动分支绝不会被淘汰;如果仅活动分支就超出预算,守护进程会改为记录警告。

配置项位于 .claude/skills/dev-server/.env,此文件已加入 gitignore——复制 .env.example,其中记录了所有配置项:BRANCH_WATCH_ENABLED、BRANCH_WATCH_INTERVAL、BRANCH_SWITCH_DEBOUNCE、KILL_ON_BRANCH_SWITCH、AUTO_INSTALL、PREWARM_ROUTES、PREWARM_TIMEOUT、PER_BRANCH_DIST_DIR、DIST_CACHE_KEEP、DIST_CACHE_MAX_GB。

工作树

每个工作树对应一个会话,各自使用独立端口(3000、3001,……)。运行以下命令启动一个会话:

node .claude/skills/dev-server/cli.mjs start /path/to/worktree

以下事项已处理:

  • 工作树自己的 .env 会叠加到主工作树的配置之上。 主检出目录的 .env 是每个会话的基础;<worktree>/.env(或显式指定的 --env)会逐项覆盖它。因此,工作树文件只需列出想要更改的配置项;如果不列出任何配置项,就不会产生影响,而不会导致服务中断。会话启动时会记录完整配置链,格式为 Env: <base> <- <overlay>,并在会话状态中作为 envPaths 返回(envPath 仍指向配置链的最顶层)。

    之前它们不会合并——工作树文件会完全取代主工作树的配置,因此一个只覆盖两个配置项的文件会让服务器启动时没有 DATABASE_URL 和密钥,导致的问题看起来与触发它的编辑毫不相关。

    “主检出目录”通过 git 确定,而不是依据启动守护进程的位置。 技能目录已提交,因此每个工作树都有自己的 daemon.mjs 副本——否则,从某个工作树启动的守护进程会将该工作树当作主工作树,并回退到它并不包含的 .env。在任意工作树中运行 git rev-parse --git-common-dir 都会得到相同结果。wt stale / wt rm 会将 git worktree list 的第一项作为主工作树;git 保证该项就是主工作树。在此之前,通过工作树自己的 CLI 副本运行这些命令,会导致所有检查方向颠倒,并将真正的主检出目录列为可删除项。 守护进程进程也从那里运行——脚本、PID 文件和工作目录都通过 resolveDaemonHome 解析。所有树共用一个守护进程,而位于其中一棵树内的守护进程会在整个生命周期内保持该目录处于打开状态,因此无论谁先完成 PR,执行 wt rm 都会因 EBUSY 失败。wt rm 和 wt stale 都会向守护进程询问其脚本路径和工作目录;如果其中任一项位于目标目录内,就会指出该守护进程,而不是归咎于一个游离的 shell;wt stale 会将这棵树排除在 SAFE TO REMOVE 之外。一个正在运行却不肯回应的守护进程——即早于此更改运行、在 / 上只返回一个裸 PID 的守护进程——会阻止对所有树执行这两项操作,因为尚未排除它的影响;wt rm --force 会覆盖此判定,但不会覆盖明确指出的占用者。一个未在运行的守护进程不会阻止任何操作,因为它没有保持任何目录处于打开状态——这由传输层判定,而非依靠正常响应,因此在 / 上报错的活动守护进程仍算作正在运行。

    🔴 只有包含此更改的 CLI 才会应用这些规则。 技能目录已提交,因此每个工作树都会运行自己的 cli.mjs;在此之前创建的树会在守护进程下次退出且该树率先启动守护进程时,重新将其固定到自身。因此,守护进程迁移到主树需要同时执行关闭操作并更新启动器——这不会追溯生效,已在运行的守护进程也会一直不受影响,直到重启为止。

  • 次要端口上的身份验证。 NEXTAUTH_URL、NEXTAUTH_URL_INTERNAL 和 NEXT_PUBLIC_BASE_URL 会被改写为 http://localhost:<port>,因此非 3000 端口的会话也能正常登录,不会跳回主端口。

  • 每个会话独立监视分支并预热。

  • 端口分配会检测守护进程并未管理的监听器。 端口选择器会先连接两个回环地址,再尝试绑定,因此守护进程已失去跟踪的进程所占用的端口——例如被标记为 crashed 的会话,或回环地址上的任何其他本地服务——都会被跳过,而不会分配出去。显式传入端口不再是绕过此限制的方法。它仍无法检测只绑定到非回环地址的监听器(next dev -H <lan-ip>),在 Windows 上也没有任何东西能检测到这种情况。

  • 会话会一直占用其端口,直到停止为止,与状态无关。 状态只是对守护进程无法查看其内部情况的进程所作的报告:它可能会把一个仍在运行并提供服务的会话读作 crashed,也可能在重启过程中、终止与重新绑定之间的瞬间读作已停止。因此,端口预留跟随会话,而不是状态;stop <session-id>(会移除会话)才会释放端口。

  • 对已有会话的工作树执行 start 会接管该会话,并在其自身端口上重启,而不是让会话处于孤立状态后再分配新端口。对于已有工作树,start 和 restart 现在效果相同;不会再因为选错端口而导致端口丢失。

    两者都会检查停止与启动之间的那一刻端口是否确实空闲;如果有其他程序占用,就会将会话移至新端口——无论是终止后仍然存活的孤立进程,还是其他本地服务。这项检查不可省略:next dev 遇到端口占用时不会失败,而是会发出警告并悄悄改用其他端口,因此如果会话在已被占用的端口上启动,它会报告一个 url,并通过针对不归它所有的服务进行的健康检查。

新建的工作树仍需要运行 pnpm install(或创建 node_modules 联接),并运行 git submodule update --init event-engine-common。

缓存无法在工作树之间共享

直接测试过:将一个预热后的 12 GB 缓存从一个工作树复制到另一个工作树,Turbopack 会在启动时崩溃:

FATAL: An unexpected Turbopack error occurred.
Error [TurbopackInternalError]: failed to create junction point at ".next\dev\node_modules\..."
Caused by: removal of existing symbolic link or junction point failed: The directory is not empty. (os error 145)

有两个独立的阻碍:.next/dev/node_modules 中存有复制后无法保留的联接点;缓存键中嵌入了项目的绝对路径(在一个 253 MB 的分段中,C:/Dev/Repos/work/model-share 出现约 3,000 次),因此即使缓存能够启动,大多数条目也会未命中。

多个会话并发共享同一个 .next 更糟:两个开发服务器同时写入同一个 LSM 存储会导致数据损坏。

因此,新工作树需要进行一次冷构建。缓解办法是预热:启动会话,让守护进程在后台处理,而不是等到你第一次点击时才开始。

RGB 代理和多个会话

rgb-proxy/index.mjs 将三种颜色以及 civitai-dev.cyan 别名都绑定到硬编码的 http://localhost:3000。因此,颜色主机名始终会连接到占用端口 3000 的会话;其他会话只能通过 localhost:<port> 访问。TUI 会说明会话属于哪种情况,而不会打印那些可能悄悄指向另一个工作树的 URL。

Windows Defender

实时保护会扫描 node_modules 中约 210k 个文件,以及每次构建缓存写入。请在提升权限的 PowerShell 中运行一次:

powershell -File .claude\skills\dev-server\scripts\defender-exclusions.ps1

自测

守护进程中较复杂的不变量由纯 Node 自测固定下来。修改相关文件后请运行这些测试——它们只需几毫秒,且不需要守护进程:

node .claude/skills/dev-server/scripts/branch-watch.selftest.mjs

每个用例都是根据此仓库测量得到的一种情形,并且都经过变异检查:还原其覆盖的代码会得到错误的数值,而不是卡住。

说明

  • 运行 CLI 命令时,守护进程会自动启动
  • 会话会一直保留,直到显式停止或守护进程关闭
  • 日志保存在内存中(每个会话最多 2000 行)

评论

支持 Markdown