worktree-bay 4.0.3 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,6 +48,8 @@ worktree-bay gc
48
48
  > 运行体随起随停(不动 worktree/代码):`worktree-bay stop drill-fix` 停掉(docker 容器 + dev server 一起)、`start` 起回来、`restart` 重启。
49
49
  >
50
50
  > 更细的控制:`claim <feature>` 单独占槽、`add <feature> <service> [branch]` 单加一个服务(branch 可自定义,省略则用功能名)、`down <feature> <service>` 只拆某个服务(省略服务则拆整功能)。
51
+ >
52
+ > dev server 起不来或报错?`worktree-bay logs <feature>`(`--tail N` 调行数、`--prev` 看上一轮)直接看日志尾部排障——日志每次启动滚动,当前文件只含本轮。
51
53
 
52
54
  ## 配置
53
55
 
@@ -132,7 +134,7 @@ worktree-bay completion install
132
134
  }
133
135
  ```
134
136
 
135
- > 服务在哪个工作区目录启动,就用哪个目录的 `worktree-bay.config.json`(cwd 自动向上查找,**无需写死路径**;也可设 `WORKTREE_BAY_CONFIG`)。暴露的工具:`worktree_bay_doctor / ls / up / claim / add / path / run / start / stop / restart / down / gc / init / skill`(`doctor` 列出全部服务名,`ls` JSON 返回各 worktree 路径与 `▸run`,`path` 给某功能某服务目录,`start/stop/restart` 控制运行体,`down` 可只拆单个服务,`skill` 取完整指南)。MCP 调用时自动以非交互模式运行(输出不含颜色/进度控制符)。
137
+ > 服务在哪个工作区目录启动,就用哪个目录的 `worktree-bay.config.json`(cwd 自动向上查找,**无需写死路径**;也可设 `WORKTREE_BAY_CONFIG`)。暴露的工具:`worktree_bay_doctor / ls / up / claim / add / path / run / start / stop / restart / down / logs / gc / init / skill`(`doctor` 列出全部服务名,`ls` JSON 返回各 worktree 路径与 `▸run`,`path` 给某功能某服务目录,`start/stop/restart` 控制运行体,`down` 可只拆单个服务,`logs` 看 dev server 日志排障,`skill` 取完整指南)。MCP 调用时自动以非交互模式运行(输出不含颜色/进度控制符)。
136
138
 
137
139
  ## 许可证
138
140
 
package/dist/cli.js CHANGED
@@ -8,6 +8,7 @@ import { addCommand, upCommand } from './commands/add.js';
8
8
  import { runCommand, shCommand, pathCommand } from './commands/passthrough.js';
9
9
  import { rmCommand } from './commands/rm.js';
10
10
  import { startCommand, stopCommand, restartCommand } from './commands/lifecycle.js';
11
+ import { logsCommand } from './commands/logs.js';
11
12
  import { gcCommand } from './commands/gc.js';
12
13
  import { doctorCommand } from './commands/doctor.js';
13
14
  import { complete, completionCommand, installCompletion } from './commands/completion.js';
@@ -113,6 +114,10 @@ program.command('down <feature> [services...]').description(t('拆除功能的 w
113
114
  catch (e) {
114
115
  die(e.message);
115
116
  } });
117
+ program.command('logs <feature> [services...]').description(t('看功能各服务 dev server 日志尾部(排障 dev server 起不来/报错);省略 services = 全部', 'tail each service\'s dev server log (debug dev servers that won\'t start / error out); omit services = all'))
118
+ .option('--tail <n>', t('显示末尾多少行(默认 40)', 'how many trailing lines (default 40)'))
119
+ .option('--prev', t('看上一轮启动的日志(.prev)', 'show the previous run\'s log (.prev)'))
120
+ .action((f, s, o) => sync((c) => logsCommand(c, f, s ?? [], { tail: o.tail ? Number(o.tail) : undefined, prev: !!o.prev })));
116
121
  program.command('gc').description(t('合并感知回收(默认 dry-run)', 'merge-aware reclaim (dry-run by default)')).option('--apply', t('实际执行回收', 'actually perform the reclaim'))
117
122
  .action(async (o) => { try {
118
123
  await gcCommand(loadConfig(process.cwd()), !!o.apply);
@@ -8,17 +8,17 @@ import { color as c } from '../util/color.js';
8
8
  import { t } from '../i18n.js';
9
9
  // dev server + infra 生命周期:stop/start/restart 同时管 node(managed 进程)与 docker(stop 钩子 + setup 恢复),不动 worktree。
10
10
  // services 为空 = 整功能;否则只这些服务
11
+ // 幂等:未占槽 / 指定服务当前未占用 → 返回空,交调用方按 no-op 处理(重复执行不报错)。
12
+ // 仅对「未知服务名」(typo,根本不在 config)报错。
11
13
  function occupantsOf(cfg, feature, services = []) {
14
+ for (const s of services)
15
+ if (!cfg.services[s])
16
+ throw new Error(t(`未知服务「${s}」。运行 \`worktree-bay doctor\` 查看配置里有哪些服务。`, `unknown service "${s}". Run \`worktree-bay doctor\` to see configured services.`));
12
17
  const slot = slotOfFeature(cfg, feature);
13
18
  if (slot === undefined)
14
- throw new Error(t(`功能「${feature}」未占槽。先 \`worktree-bay up ${feature} <服务...>\` 起它。`, `feature "${feature}" hasn't claimed a slot. Run \`worktree-bay up ${feature} <services...>\` first.`));
19
+ return [];
15
20
  const all = scanOccupancy(cfg).get(slot) ?? [];
16
- if (!services.length)
17
- return all;
18
- for (const s of services)
19
- if (!all.some((o) => o.service === s))
20
- throw new Error(t(`服务「${s}」不在功能「${feature}」里。用 \`worktree-bay ls\` 看已起的服务。`, `service "${s}" is not in feature "${feature}". See \`worktree-bay ls\`.`));
21
- return all.filter((o) => services.includes(o.service));
21
+ return services.length ? all.filter((o) => services.includes(o.service)) : all;
22
22
  }
23
23
  function ctxOf(cfg, o) {
24
24
  const base = { cfg, service: o.service, sp: cfg.services[o.service], slot: o.slot, slug: o.slug, dir: o.dir, repo: repoPath(cfg, o.service) };
@@ -37,7 +37,7 @@ export async function stopCommand(cfg, feature, services = []) {
37
37
  await stopRuntime(ctxOf(cfg, o));
38
38
  }
39
39
  if (!any)
40
- log(t('没有可停止的运行体(相关服务未配置 start/stop)', 'nothing to stop (no start/stop configured for those services)'));
40
+ log(c.dim(t('没有可停止的运行体(功能未占槽,或相关服务未配置 start/stop)', 'nothing to stop (feature has no slot, or those services have no start/stop)')));
41
41
  });
42
42
  }
43
43
  export async function startCommand(cfg, feature, services = []) {
@@ -51,14 +51,16 @@ export async function startCommand(cfg, feature, services = []) {
51
51
  await ensureRuntime(ctxOf(cfg, o));
52
52
  }
53
53
  if (!any)
54
- log(t('没有可启动的运行体(相关服务未配置 start/stop)', 'nothing to start (no start/stop configured for those services)'));
54
+ log(c.dim(t(`没有可启动的运行体(功能未占槽——先 \`worktree-bay up ${feature} <服务...>\`,或相关服务未配 start/stop)`, `nothing to start (feature has no slot run \`worktree-bay up ${feature} <services...>\` first, or those services have no start/stop)`)));
55
55
  });
56
56
  }
57
57
  export async function restartCommand(cfg, feature, services = []) {
58
58
  await withLock(cfg.workspaceRoot, async () => {
59
+ let any = false;
59
60
  for (const o of occupantsOf(cfg, feature, services)) {
60
61
  if (!hasRuntime(cfg, o.service))
61
62
  continue;
63
+ any = true;
62
64
  const ctx = ctxOf(cfg, o);
63
65
  log(c.bold(c.cyan(o.service)) + c.dim(t(' · 重启…', ' · restarting…')));
64
66
  await stopRuntime(ctx);
@@ -69,5 +71,7 @@ export async function restartCommand(cfg, feature, services = []) {
69
71
  } // 等端口释放
70
72
  await ensureRuntime(ctx);
71
73
  }
74
+ if (!any)
75
+ log(c.dim(t('没有可重启的运行体(功能未占槽,或相关服务未配置 start/stop)', 'nothing to restart (feature has no slot, or those services have no start/stop)')));
72
76
  });
73
77
  }
@@ -0,0 +1,40 @@
1
+ import fs from 'node:fs';
2
+ import { scanOccupancy, slotOfFeature } from '../slots.js';
3
+ import { logPath, readLogTail } from '../proc.js';
4
+ import { log } from '../util/log.js';
5
+ import { color as c } from '../util/color.js';
6
+ import { t } from '../i18n.js';
7
+ // 看某功能各服务 dev server(配了 start 的)托管日志的尾部。排障 dev server 起不来/报错时用,
8
+ // 省得自己拼 .worktree-bay/logs/<slug>-<service>.log 路径再读整个大文件。
9
+ // 日志每次启动滚动(startDetached),当前文件只含本轮;--prev 看上一轮(.prev)。
10
+ export function logsCommand(cfg, feature, services = [], opts = {}) {
11
+ for (const s of services)
12
+ if (!cfg.services[s])
13
+ throw new Error(t(`未知服务「${s}」。运行 \`worktree-bay doctor\` 查看配置里有哪些服务。`, `unknown service "${s}". Run \`worktree-bay doctor\` to see configured services.`));
14
+ const slot = slotOfFeature(cfg, feature);
15
+ if (slot === undefined) {
16
+ log(c.dim(t(`功能「${feature}」未占槽,没有可看的日志。`, `feature "${feature}" has no slot; no logs to show.`)));
17
+ return;
18
+ }
19
+ let occ = scanOccupancy(cfg).get(slot) ?? [];
20
+ if (services.length)
21
+ occ = occ.filter((o) => services.includes(o.service));
22
+ occ = occ.filter((o) => cfg.services[o.service].start); // 只有配了 start 的服务才有托管日志
23
+ if (!occ.length) {
24
+ log(c.dim(t('没有可看日志的运行体(这些服务未配置 start dev server)。', 'no logs available (those services have no start dev server configured).')));
25
+ return;
26
+ }
27
+ const tail = opts.tail ?? 40;
28
+ for (const o of occ) {
29
+ const file = logPath(cfg.workspaceRoot, o.slug, o.service) + (opts.prev ? '.prev' : '');
30
+ log(c.bold(c.cyan(o.service)) + c.dim(' ' + file));
31
+ if (!fs.existsSync(file)) {
32
+ log(c.dim(t(opts.prev ? ' (无上一轮日志)' : ' (暂无日志)', opts.prev ? ' (no previous-run log)' : ' (no log yet)')));
33
+ log('');
34
+ continue;
35
+ }
36
+ const body = readLogTail(file, tail);
37
+ log(body || c.dim(t(' (日志为空)', ' (log is empty)')));
38
+ log('');
39
+ }
40
+ }
@@ -9,24 +9,32 @@ import { stopManaged } from '../proc.js';
9
9
  import { log, warn } from '../util/log.js';
10
10
  import { color as c } from '../util/color.js';
11
11
  import { t } from '../i18n.js';
12
- // services 为空 = 整功能;否则只这些服务(顺带校验服务名确实在该功能里)
12
+ // services 为空 = 整功能;否则只这些服务。幂等:未占槽 / 指定服务当前未占用 → 返回空(no-op),
13
+ // 仅对「未知服务名」(typo,根本不在 config)报错。
13
14
  export function resolveRm(cfg, feature, services = []) {
15
+ for (const s of services)
16
+ if (!cfg.services[s])
17
+ throw new Error(t(`未知服务「${s}」。运行 \`worktree-bay doctor\` 查看配置里有哪些服务。`, `unknown service "${s}". Run \`worktree-bay doctor\` to see configured services.`));
14
18
  const slot = slotOfFeature(cfg, feature);
15
19
  if (slot === undefined)
16
- throw new Error(t(`功能「${feature}」未占槽,无需拆除。用 \`worktree-bay ls\` 看在用的功能。`, `feature "${feature}" has no slot — nothing to tear down. See \`worktree-bay ls\`.`));
20
+ return [];
17
21
  const all = scanOccupancy(cfg).get(slot) ?? [];
18
- if (!services.length)
19
- return all;
20
- for (const s of services)
21
- if (!all.some((o) => o.service === s))
22
- throw new Error(t(`服务「${s}」不在功能「${feature}」里。用 \`worktree-bay ls\` 看已起的服务。`, `service "${s}" is not in feature "${feature}". See \`worktree-bay ls\`.`));
23
- return all.filter((o) => services.includes(o.service));
22
+ return services.length ? all.filter((o) => services.includes(o.service)) : all;
24
23
  }
25
24
  export async function rmCommand(cfg, feature, services, force) {
26
25
  await withLock(cfg.workspaceRoot, async () => {
26
+ const slot = slotOfFeature(cfg, feature);
27
+ if (slot === undefined) {
28
+ log(c.green('✓') + ' ' + t(`功能「${feature}」未占槽,无需拆除(已是目标状态)`, `feature "${feature}" has no slot — nothing to tear down (already in target state)`));
29
+ return;
30
+ }
27
31
  let removed = 0;
28
32
  const wholeFeature = services.length === 0;
29
33
  const occs = resolveRm(cfg, feature, services);
34
+ if (occs.length === 0) {
35
+ log(c.dim(t('指定服务当前未占用,无需拆除(已是目标状态)', 'those services aren\'t occupied — nothing to tear down (already in target state)')));
36
+ return;
37
+ }
30
38
  for (const o of occs) {
31
39
  const repo = repoPath(cfg, o.service);
32
40
  const branch = currentBranch(o.dir);
@@ -47,7 +55,6 @@ export async function rmCommand(cfg, feature, services, force) {
47
55
  await withProgress(t(`移除 ${o.service} 的 worktree`, `removing ${o.service} worktree`), () => removeWorktree(repo, o.dir, force));
48
56
  removed++;
49
57
  }
50
- const slot = slotOfFeature(cfg, feature);
51
58
  if (wholeFeature && (scanOccupancy(cfg).get(slot) ?? []).length === 0) {
52
59
  removeLabel(cfg, slot);
53
60
  if (removed === 0)
package/dist/engine.js CHANGED
@@ -26,6 +26,26 @@ export function mergeEnvText(text, kv) {
26
26
  }
27
27
  return out.join('\n');
28
28
  }
29
+ // 把 env 规格渲染并合并进 worktree 的 dotenv 文件。幂等:合并后内容与现有文件一致就【跳过写入】,
30
+ // 避免无谓刷新 mtime 触发 dev server 的 .env 文件 watcher(如 vite)抖动重启——重跑 up/add(值已正确)很常见,
31
+ // 运行中的前端被反复重启会偶发解析失败。返回实际写入的文件名(便于观测/测试)。
32
+ export function writeEnvFiles(dir, env, vars) {
33
+ const written = [];
34
+ for (const [file, kv] of Object.entries(env ?? {})) {
35
+ const fp = path.join(dir, file);
36
+ const exists = fs.existsSync(fp);
37
+ const cur = exists ? fs.readFileSync(fp, 'utf8') : '';
38
+ const rendered = {};
39
+ for (const [k, v] of Object.entries(kv))
40
+ rendered[k] = renderTemplate(v, vars);
41
+ const next = mergeEnvText(cur, rendered);
42
+ if (!exists || next !== cur) {
43
+ fs.writeFileSync(fp, next);
44
+ written.push(file);
45
+ }
46
+ }
47
+ return written;
48
+ }
29
49
  export function resolveUpstreamBase(cfg, slot, up, materialized) {
30
50
  return materialized ? `http://localhost:${portOf(cfg.services[up.service].port, slot)}` : up.fallback;
31
51
  }
@@ -55,14 +75,7 @@ export async function bringUp(ctx, base, branch) {
55
75
  warn(t(`⚠ ${lock} 与主 checkout 不一致,拷来的依赖可能版本错位;建议把该服务的 copy 去掉、改用 setup 跑安装命令。`, `⚠ ${lock} differs from the main checkout; copied dependencies may be the wrong version. Consider dropping copy for this service and installing via setup instead.`));
56
76
  }
57
77
  }
58
- for (const [file, kv] of Object.entries(sp.env ?? {})) {
59
- const fp = path.join(dir, file);
60
- const cur = fs.existsSync(fp) ? fs.readFileSync(fp, 'utf8') : '';
61
- const rendered = {};
62
- for (const [k, v] of Object.entries(kv))
63
- rendered[k] = renderTemplate(v, vars);
64
- fs.writeFileSync(fp, mergeEnvText(cur, rendered));
65
- }
78
+ writeEnvFiles(dir, sp.env, vars);
66
79
  if (sp.setup) {
67
80
  const cmd = renderTemplate(sp.setup, vars);
68
81
  const r = await runShellLive(cmd, { cwd: dir }, t(`setup:${cmd}`, `setup: ${cmd}`));
package/dist/mcp.js CHANGED
@@ -26,8 +26,9 @@ export const INSTRUCTIONS = `worktree-bay 是「功能 = 槽位」的并行开
26
26
 
27
27
  要点:
28
28
  - 一个功能从头到尾用同一个功能名(= 默认分支名)。
29
+ - 每个新任务都用一个【新的功能名】调 worktree_bay_up,让工具自动占一个空槽——【不要】去 worktree_bay_ls 挑一个现成的槽来复用。ls 是给你看占用情况的,不是用来选槽复用的;复用别的功能已占的槽会破坏隔离、互相污染。唯一例外:你是在【继续同一个功能】之前没跑完的工作(功能名相同),这时 up 会幂等复用它自己的槽。
29
30
  - 只起「实际要改」的服务,不要全起;不知道有哪些服务名先调 worktree_bay_doctor。
30
- - 拿不准当前状态先调 worktree_bay_ls
31
+ - 拿不准当前状态先调 worktree_bay_ls;dev server 起不来或报错就调 worktree_bay_logs 看日志尾部排障。
31
32
  - worktree_bay_gc 默认只读(dry-run 列建议),apply=true 才真删,且只删「已合并到主分支且工作区干净」的,保守不误删。
32
33
  - worktree_bay_init 在新工作区生成配置骨架(已存在则不覆盖);worktree_bay_claim 只占槽并打印端口、不建 worktree(一般直接用 up 即可)。
33
34
  - 要写/改 worktree-bay.config.json、或拿不准任何命令/参数/配置细节,先调 worktree_bay_skill 取完整指南(每个配置原语、模板变量、校验规则、完整示例)。`;
@@ -64,6 +65,9 @@ export const TOOLS = [
64
65
  { name: 'worktree_bay_down', description: '拆除 worktree:省略 services 拆整个功能(所有服务),给 services 只拆这些服务(默认查脏/未推保护,force=true 强删)',
65
66
  inputSchema: { type: 'object', properties: { feature: str, services: { type: 'array', items: str }, force: { type: 'boolean' } }, required: ['feature'] },
66
67
  toArgs: (a) => ['down', String(a.feature), ...(a.services ?? []), ...(a.force ? ['-f'] : [])] },
68
+ { name: 'worktree_bay_logs', description: '看功能各服务 dev server 的日志尾部——dev server 起不来/报错时排障用,免得自己拼日志路径。services 省略=全部;tail 指定行数(默认 40);prev=true 看上一轮启动的日志(每次启动会滚动)。',
69
+ inputSchema: { type: 'object', properties: { feature: str, services: { type: 'array', items: str }, tail: { type: 'number' }, prev: { type: 'boolean' } }, required: ['feature'] },
70
+ toArgs: (a) => ['logs', String(a.feature), ...(a.services ?? []), ...(a.tail ? ['--tail', String(a.tail)] : []), ...(a.prev ? ['--prev'] : [])] },
67
71
  { name: 'worktree_bay_gc', description: '合并感知回收:默认 dry-run 只列建议,apply=true 才实际删除「已合并且干净」的功能',
68
72
  inputSchema: { type: 'object', properties: { apply: { type: 'boolean' } } },
69
73
  toArgs: (a) => ['gc', ...(a.apply ? ['--apply'] : [])] },
package/dist/proc.js CHANGED
@@ -34,6 +34,10 @@ export function readLogTail(file, lines = 15) {
34
34
  return '';
35
35
  }
36
36
  }
37
+ // 某 worktree dev server 的日志路径(startDetached 写、logs 命令读,单一来源避免两处拼接漂移)。
38
+ export function logPath(ws, slug, service) {
39
+ return path.join(ws, '.worktree-bay', 'logs', `${slug}-${service}.log`);
40
+ }
37
41
  // 找出监听某端口的进程 pid(shell/pnpm 等中间层会让记录的 pid 漂移,按端口查最可靠)。
38
42
  export function pidOnPort(port) {
39
43
  if (process.platform === 'win32') {
@@ -126,7 +130,16 @@ export function stopUnrecordedOnPort(ws, dir, port) {
126
130
  export function startDetached(ws, dir, service, slug, port, cmd) {
127
131
  const logDir = path.join(ws, '.worktree-bay', 'logs');
128
132
  fs.mkdirSync(logDir, { recursive: true });
129
- const log = path.join(logDir, `${slug}-${service}.log`);
133
+ const log = logPath(ws, slug, service);
134
+ // 每次启动滚动日志:上一轮留一份 .prev,当前日志只含本次运行,排障时不被跨会话的历史淹没。
135
+ // (fs.rename 跨平台覆盖已存在目标;失败也不阻断启动。)
136
+ try {
137
+ if (fs.existsSync(log) && fs.statSync(log).size > 0)
138
+ fs.renameSync(log, log + '.prev');
139
+ }
140
+ catch { /* 滚动失败忽略 */ }
141
+ // 启动头:标清「本次启动」的时间与命令,作为日志里这一轮运行的起点。
142
+ fs.writeFileSync(log, `===== worktree-bay start ${new Date().toISOString()} :: ${cmd} =====\n`);
130
143
  const fd = fs.openSync(log, 'a');
131
144
  // 后台启动、CLI 退出后仍存活、且不弹窗:
132
145
  // - Windows:不要 detached(detached 会新开控制台窗口、且与 windowsHide 冲突);用 windowsHide 抑制窗口,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktree-bay",
3
- "version": "4.0.3",
3
+ "version": "4.1.0",
4
4
  "description": "Per-feature git worktree + port slots for parallel multi-service development: auto deps, env wiring, frontend-to-backend, merge-aware reclaim, plus an MCP server for AI agents.",
5
5
  "keywords": [
6
6
  "git",
package/skill.md CHANGED
@@ -9,6 +9,8 @@
9
9
  - 同槽的前端服务自动把 api base 指向同槽的后端端口。
10
10
  - 槽位占用从文件系统派生(看 `<repo>/.worktrees/s<N>-*` 是否存在),删了 worktree 槽自动空出。
11
11
 
12
+ > **每个新任务都用一个【新功能名】认领新槽**(`up <新功能名> ...`),让工具自动占一个空槽。**不要先 `ls` 再去挑一个现成的槽来复用**——`ls` 是给你看占用情况的,不是用来选槽复用的;复用别的功能已占的槽会破坏隔离、互相污染数据。唯一例外:你在**继续同一个功能**之前没跑完的工作(用同一功能名),此时 `up` 会幂等复用它自己的槽。
13
+
12
14
  ---
13
15
 
14
16
  ## 安装
@@ -39,6 +41,7 @@ worktree-bay completion install # 一键装 shell 补全(可选)
39
41
  | `worktree-bay stop <feature> [services...]` | 停止功能的运行体(停 docker + 杀 node dev server);省略 = 全部,可列多个。保留 worktree |
40
42
  | `worktree-bay restart <feature> [services...]` | 重启运行体(停掉再起);省略 = 全部,可列多个 |
41
43
  | `worktree-bay down <feature> [services...]` | 拆除 worktree(停运行体 + teardown + 删 worktree);**省略 services = 整功能**,也可列多个只拆这些。默认查脏/未推保护,`-f` 强删 |
44
+ | `worktree-bay logs <feature> [services...]` | 看各服务 dev server 日志尾部(排障 dev server 起不来/报错);省略 services = 全部。`--tail N` 行数(默认 40)、`--prev` 看上一轮启动的日志 |
42
45
  | `worktree-bay gc [--apply]` | 合并感知回收:默认 dry-run 只列建议,`--apply` 才删「已合并且干净」的 |
43
46
  | `worktree-bay completion <install\|bash\|zsh\|fish>` | `install` 一键装进 shell;或打印补全脚本 |
44
47
  | `worktree-bay mcp` | 启动 MCP 服务(stdio,轻量脚本,客户端按需 spawn),供 AI 调用 |
@@ -81,7 +84,7 @@ worktree-bay gc # 回收已合并的
81
84
  | `repo` | | string | 仓库目录名(相对 workspaceRoot),**默认 = 服务名** |
82
85
  | `vars` | | object | 自定义模板变量,值里可引用基础变量,如 `{ "project": "myapi-{slug}" }` |
83
86
  | `copy` | | string[] | 挂入时从主 checkout **递归拷贝**到 worktree 的文件/目录(含依赖目录,如 `vendor`)。含符号链接也安全(会跟随拷目标内容) |
84
- | `env` | | object | dotenv 注入:`{ "文件名": { "KEY": "值模板" } }`,把键值**合并**进该文件(保留其它行,文件不存在则建) |
87
+ | `env` | | object | dotenv 注入:`{ "文件名": { "KEY": "值模板" } }`,把键值**合并**进该文件(保留其它行,文件不存在则建)。**幂等**:合并结果与现有内容一致就跳过写入、不刷新 mtime——避免重跑 `up`/`add` 时无谓触发前端 `.env` watcher(如 vite)抖动重启 |
85
88
  | `upstream` | | object | 声明依赖的上游服务:`{ "service": "api", "fallback": "http://localhost:6001" }`,产出 `{upstreamBase}` 变量 |
86
89
  | `setup` | | string | 挂入后执行的 shell 命令(创建/装好运行体,如 `docker compose up -d`、`pnpm install`)。`up` 时跑 |
87
90
  | `teardown` | | string | 拆除时执行的 shell 命令(销毁运行体,如 `docker compose down -v`)。`down`/`rm`/`gc` 时跑 |
@@ -161,8 +164,9 @@ worktree-bay gc # 回收已合并的
161
164
 
162
165
  ## 工作原理要点
163
166
 
167
+ - **全命令幂等**:`up / down / start / stop / restart` 重复执行都收敛到同一目标态、不报错——`up` 重入=复用 worktree + 恢复运行体;`start`/`stop` 已在目标态则跳过/仍逐服务给状态;`down` 对未占槽或已拆的服务是友好 no-op。仅「未知服务名」(typo,不在 config)才报错。
164
168
  - **占用真相 = 文件系统**:扫各服务 `.worktrees/s<N>-*`;`.worktree-bay-slots.json` 只是「功能名→槽号」标签账本(预约)。
165
- - **dev server 托管**:`start` 进程后台 detach 启动、日志落 `.worktree-bay/logs/`、按端口追踪真实 pid;`ls` 行首 `●` 标在跑(绿)/未跑(灰);`stop`/`down` 按端口可靠停。
169
+ - **dev server 托管**:`start` 进程后台 detach 启动、日志落 `.worktree-bay/logs/`、按端口追踪真实 pid。日志**每次启动滚动**(上一轮存一份 `.prev`,当前文件只含本轮 + 一行启动头,排障不被跨会话历史淹没;用 `worktree-bay logs <feature>` 直接看尾部,免拼路径)。`start` 会**阻塞到约定端口被监听才返回**(给 vite 冷启动留 ~25s),所以命令返回即代表就绪、`ls` 行首 `●` 绿即可开工;超时不算失败(可能仍在编译/重启),会提示去看日志。`stop`/`down` 按端口可靠停。
166
170
  - **运行状态判断 = 端口**:`ls`/`start`/`stop` 判「在不在跑」全用 `pidOnPort`(netstat/lsof,与 ls 同源),不用 connect 探测(docker 发布端口两者会不一致)、也不只看 pid 账本(dir 形态会漂移、docker 无账本记录)。`start` 端口已在监听就跳过、不再误报「恢复」。
167
171
  - **stop 严格停「本目录+本端口+本进程」**:① 优先用启动账本(dir 已规范化匹配:相对/绝对/大小写都认);② 账本缺失时**校验后才杀**——Linux/macOS 比对进程 `cwd` 是否为本 worktree,Windows 取不到 cwd 则核对命令行含 `--port <本端口>`(端口按槽唯一,等价确证);都确证不了就**不动它**并如实报告(绝不凭端口盲杀)。每个服务都有状态行:已停 / 端口空闲 / 无法确认未停。
168
172
  - **并发安全**:`claim/add/up/rm/down/gc` 全程持工作区原子锁。
@@ -173,7 +177,9 @@ worktree-bay gc # 回收已合并的
173
177
 
174
178
  ## 给 AI(MCP)
175
179
 
176
- `worktree-bay mcp` 暴露工具:`worktree_bay_doctor / ls / up / claim / add / path / run / start / stop / restart / down / gc / init / skill`。`doctor` 列出全部服务(AI 借此得知服务名);`ls` 以 JSON 返回 `[{slot, feature(可 null), services:[{service, port, dir, running}]}]`(含各 worktree 绝对路径、布尔 `running`);`path` 给某功能某服务的 worktree 目录;`start/stop/restart` 控制运行体(docker+node);`down` 省略 services 拆整功能、给 services 只拆这些。要写或修改 `worktree-bay.config.json`、或拿不准命令/配置细节时,调用 `worktree_bay_skill` 取本指南全文。
180
+ `worktree-bay mcp` 暴露工具:`worktree_bay_doctor / ls / up / claim / add / path / run / start / stop / restart / down / logs / gc / init / skill`。`doctor` 列出全部服务(AI 借此得知服务名);`ls` 以 JSON 返回 `[{slot, feature(可 null), services:[{service, port, dir, running}]}]`(含各 worktree 绝对路径、布尔 `running`);`path` 给某功能某服务的 worktree 目录;`start/stop/restart` 控制运行体(docker+node);`down` 省略 services 拆整功能、给 services 只拆这些;`logs` 看 dev server 日志尾部排障。要写或修改 `worktree-bay.config.json`、或拿不准命令/配置细节时,调用 `worktree_bay_skill` 取本指南全文。
181
+
182
+ **给 AI 的硬约束**:每个新任务用一个【新功能名】`up` 认领新槽,让工具自动占空槽;**不要用 `ls` 去挑现成的槽复用**(破坏隔离、污染数据)。只有继续同一功能未完成的工作才用同名 `up`(幂等复用自己的槽)。dev server 起不来/报错先 `logs` 看日志,别瞎猜。
177
183
 
178
184
  ## 常见坑
179
185