worktree-bay 2.3.8 → 2.4.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/dist/cli.js +22 -0
- package/dist/commands/completion.js +3 -3
- package/dist/commands/lifecycle.js +65 -0
- package/dist/lock.js +66 -6
- package/dist/mcp.js +2 -1
- package/dist/util/color.js +11 -2
- package/dist/util/exec.js +2 -2
- package/dist/util/progress.js +2 -2
- package/package.json +1 -1
- package/skill.md +3 -0
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { lsCommand } from './commands/ls.js';
|
|
|
7
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
|
+
import { startCommand, stopCommand, restartCommand } from './commands/lifecycle.js';
|
|
10
11
|
import { gcCommand } from './commands/gc.js';
|
|
11
12
|
import { doctorCommand } from './commands/doctor.js';
|
|
12
13
|
import { complete, completionCommand, installCompletion } from './commands/completion.js';
|
|
@@ -82,6 +83,27 @@ program.command('run <feature> <service> <name> [args...]').description(t('在
|
|
|
82
83
|
.action((f, s, n, args) => sync((c) => runCommand(c, f, s, n, args ?? [])));
|
|
83
84
|
program.command('sh <feature> <service>').description(t('进入服务运行体的 shell', 'open a shell inside the service runtime'))
|
|
84
85
|
.action((f, s) => sync((c) => shCommand(c, f, s)));
|
|
86
|
+
program.command('start <feature> [service]').description(t('启动该功能的 dev server(worktree 已在,只起 start 进程,不动 worktree)', 'start the feature\'s dev server(s) (worktree already exists; runs the start process only)'))
|
|
87
|
+
.action(async (f, s) => { try {
|
|
88
|
+
await startCommand(loadConfig(process.cwd()), f, s);
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
die(e.message);
|
|
92
|
+
} });
|
|
93
|
+
program.command('stop <feature> [service]').description(t('停止该功能的 dev server(保留 worktree)', 'stop the feature\'s dev server(s) (keeps the worktree)'))
|
|
94
|
+
.action(async (f, s) => { try {
|
|
95
|
+
await stopCommand(loadConfig(process.cwd()), f, s);
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
die(e.message);
|
|
99
|
+
} });
|
|
100
|
+
program.command('restart <feature> [service]').description(t('重启该功能的 dev server(停掉再起)', 'restart the feature\'s dev server(s) (stop then start)'))
|
|
101
|
+
.action(async (f, s) => { try {
|
|
102
|
+
await restartCommand(loadConfig(process.cwd()), f, s);
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
die(e.message);
|
|
106
|
+
} });
|
|
85
107
|
program.command('down <feature>').description(t('拆除整个功能的所有服务 worktree(= rm <feature>)', 'tear down all of a feature\'s service worktrees (= rm <feature>)')).option('-f, --force', t('跳过脏/未推检查强制删除', 'skip dirty/unpushed checks and force-remove'))
|
|
86
108
|
.action(async (f, o) => { try {
|
|
87
109
|
await rmCommand(loadConfig(process.cwd()), f, undefined, !!o.force);
|
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { readLabels } from '../slots.js';
|
|
5
5
|
import { log } from '../util/log.js';
|
|
6
6
|
import { t } from '../i18n.js';
|
|
7
|
-
const SUBCMDS = ['init', 'doctor', 'claim', 'up', 'add', 'ls', 'path', 'gc', 'down', 'rm', 'run', 'sh', 'completion', 'mcp', 'skill', 'version', 'help'];
|
|
7
|
+
const SUBCMDS = ['init', 'doctor', 'claim', 'up', 'add', 'ls', 'path', 'gc', 'down', 'rm', 'run', 'sh', 'start', 'stop', 'restart', 'completion', 'mcp', 'skill', 'version', 'help'];
|
|
8
8
|
// words = 命令名 + 光标前已输入完的词(不含当前正在补的词)
|
|
9
9
|
export function complete(cfg, words) {
|
|
10
10
|
const prev = words.slice(1);
|
|
@@ -14,10 +14,10 @@ export function complete(cfg, words) {
|
|
|
14
14
|
return []; // 无配置(不在工作区内):子命令已补全,feature/service 无从读取
|
|
15
15
|
const sub = prev[0];
|
|
16
16
|
const pos = prev.length;
|
|
17
|
-
const featureSubs = ['up', 'add', 'rm', 'down', 'run', 'sh', 'path'];
|
|
17
|
+
const featureSubs = ['up', 'add', 'rm', 'down', 'run', 'sh', 'path', 'start', 'stop', 'restart'];
|
|
18
18
|
if (featureSubs.includes(sub) && pos === 1)
|
|
19
19
|
return Object.values(readLabels(cfg));
|
|
20
|
-
if (['add', 'run', 'sh', 'path'].includes(sub) && pos === 2)
|
|
20
|
+
if (['add', 'run', 'sh', 'path', 'start', 'stop', 'restart'].includes(sub) && pos === 2)
|
|
21
21
|
return Object.keys(cfg.services);
|
|
22
22
|
if (sub === 'run' && pos === 3)
|
|
23
23
|
return Object.keys(cfg.services[prev[2]]?.run ?? {}); // run <feature> <service> <name>:补该服务的 run 命令名
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { repoPath } from '../config.js';
|
|
2
|
+
import { withLock } from '../lock.js';
|
|
3
|
+
import { scanOccupancy, slotOfFeature } from '../slots.js';
|
|
4
|
+
import { buildVars, ensureStarted } from '../engine.js';
|
|
5
|
+
import { stopManaged } from '../proc.js';
|
|
6
|
+
import { portInUse } from '../ports.js';
|
|
7
|
+
import { log, warn } from '../util/log.js';
|
|
8
|
+
import { color as c } from '../util/color.js';
|
|
9
|
+
import { t } from '../i18n.js';
|
|
10
|
+
// dev server 生命周期:start/stop/restart(只管 start 配置起的进程,不动 worktree)
|
|
11
|
+
function occupantsOf(cfg, feature, service) {
|
|
12
|
+
const slot = slotOfFeature(cfg, feature);
|
|
13
|
+
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.`));
|
|
15
|
+
const all = scanOccupancy(cfg).get(slot) ?? [];
|
|
16
|
+
return service ? all.filter((o) => o.service === service) : all;
|
|
17
|
+
}
|
|
18
|
+
function ctxOf(cfg, o) {
|
|
19
|
+
const sp = cfg.services[o.service];
|
|
20
|
+
const base = { cfg, service: o.service, sp, slot: o.slot, slug: o.slug, dir: o.dir, repo: repoPath(cfg, o.service) };
|
|
21
|
+
return { ...base, vars: buildVars(cfg, base) };
|
|
22
|
+
}
|
|
23
|
+
export async function startCommand(cfg, feature, service) {
|
|
24
|
+
await withLock(cfg.workspaceRoot, async () => {
|
|
25
|
+
let any = false;
|
|
26
|
+
for (const o of occupantsOf(cfg, feature, service)) {
|
|
27
|
+
if (!cfg.services[o.service].start)
|
|
28
|
+
continue;
|
|
29
|
+
any = true;
|
|
30
|
+
log(c.bold(c.cyan(o.service)));
|
|
31
|
+
await ensureStarted(ctxOf(cfg, o));
|
|
32
|
+
}
|
|
33
|
+
if (!any)
|
|
34
|
+
warn(t('没有可启动的 dev server(相关服务未配置 start)', 'nothing to start (those services have no start configured)'));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export async function stopCommand(cfg, feature, service) {
|
|
38
|
+
await withLock(cfg.workspaceRoot, async () => {
|
|
39
|
+
let any = false;
|
|
40
|
+
for (const o of occupantsOf(cfg, feature, service)) {
|
|
41
|
+
const stopped = stopManaged(cfg.workspaceRoot, o.dir);
|
|
42
|
+
if (stopped) {
|
|
43
|
+
any = true;
|
|
44
|
+
log(`${c.green('✓')} ` + t(`已停止 ${o.service} dev server(pid ${stopped.pid})`, `stopped ${o.service} dev server (pid ${stopped.pid})`));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!any)
|
|
48
|
+
log(t('没有在跑的 dev server', 'no running dev server'));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export async function restartCommand(cfg, feature, service) {
|
|
52
|
+
await withLock(cfg.workspaceRoot, async () => {
|
|
53
|
+
for (const o of occupantsOf(cfg, feature, service)) {
|
|
54
|
+
const sp = cfg.services[o.service];
|
|
55
|
+
if (!sp.start)
|
|
56
|
+
continue;
|
|
57
|
+
log(c.bold(c.cyan(o.service)) + c.dim(t(' · 重启…', ' · restarting…')));
|
|
58
|
+
stopManaged(cfg.workspaceRoot, o.dir);
|
|
59
|
+
const port = Number(ctxOf(cfg, o).vars.port);
|
|
60
|
+
for (let i = 0; i < 40 && (await portInUse(port)); i++)
|
|
61
|
+
await new Promise((r) => setTimeout(r, 100)); // 等端口释放(最多 ~4s)
|
|
62
|
+
await ensureStarted(ctxOf(cfg, o));
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
package/dist/lock.js
CHANGED
|
@@ -1,25 +1,85 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { t } from './i18n.js';
|
|
4
|
+
import { warn } from './util/log.js';
|
|
5
|
+
// 工作区原子锁(mkdir)。锁目录内写 owner=pid:
|
|
6
|
+
// - 锁主进程已死 / 无主(陈旧锁,多为上次进程被杀残留)→ 秒级清除重试,不再空等 30s;
|
|
7
|
+
// - 锁被另一个在跑的 worktree-bay 持有 → 立刻提示在等谁,再按需等待,超时给明确指引。
|
|
8
|
+
function pidAlive(pid) { if (!pid)
|
|
9
|
+
return false; try {
|
|
10
|
+
process.kill(pid, 0);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
} }
|
|
16
|
+
function ownerPid(lockDir) {
|
|
17
|
+
try {
|
|
18
|
+
const n = Number(fs.readFileSync(path.join(lockDir, 'owner'), 'utf8').trim());
|
|
19
|
+
return n || null;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
4
25
|
export async function withLock(ws, fn) {
|
|
5
|
-
const
|
|
6
|
-
|
|
26
|
+
const baseDir = path.join(ws, '.worktree-bay');
|
|
27
|
+
const lockDir = path.join(baseDir, 'lock');
|
|
28
|
+
fs.mkdirSync(baseDir, { recursive: true });
|
|
7
29
|
const start = Date.now();
|
|
30
|
+
let notified = false;
|
|
31
|
+
let ownerMissingSince = 0;
|
|
8
32
|
for (;;) {
|
|
9
33
|
try {
|
|
10
34
|
fs.mkdirSync(lockDir);
|
|
11
35
|
break;
|
|
12
|
-
}
|
|
36
|
+
} // 抢到锁
|
|
13
37
|
catch {
|
|
14
|
-
|
|
15
|
-
|
|
38
|
+
const pid = ownerPid(lockDir);
|
|
39
|
+
if (pid === null) {
|
|
40
|
+
// 无 owner 文件:可能是陈旧锁(旧版/被杀残留),也可能是别人刚抢到还没写 owner(竞态)。
|
|
41
|
+
// 给 ~600ms 宽限,仍无主就当陈旧锁清掉。
|
|
42
|
+
if (ownerMissingSince === 0)
|
|
43
|
+
ownerMissingSince = Date.now();
|
|
44
|
+
if (Date.now() - ownerMissingSince > 600) {
|
|
45
|
+
try {
|
|
46
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
catch { /* 让下轮重试 */ }
|
|
49
|
+
ownerMissingSince = 0;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
ownerMissingSince = 0;
|
|
55
|
+
if (!pidAlive(pid)) {
|
|
56
|
+
try {
|
|
57
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
catch { /* 重试 */ }
|
|
60
|
+
continue;
|
|
61
|
+
} // 锁主已死 → 陈旧锁,清掉
|
|
62
|
+
if (!notified) {
|
|
63
|
+
warn(t(`正在等待另一个 worktree-bay(pid ${pid})释放工作区锁…`, `waiting for another worktree-bay (pid ${pid}) to release the workspace lock…`));
|
|
64
|
+
notified = true;
|
|
65
|
+
}
|
|
66
|
+
if (Date.now() - start > 30000)
|
|
67
|
+
throw new Error(t(`等待工作区锁超时:另一个 worktree-bay(pid ${pid})仍持有。若它已卡死,结束该进程或删掉 ${lockDir} 再试。`, `timed out waiting for the workspace lock: another worktree-bay (pid ${pid}) still holds it. If stuck, kill it or delete ${lockDir}, then retry.`));
|
|
68
|
+
}
|
|
16
69
|
await new Promise((r) => setTimeout(r, 50));
|
|
17
70
|
}
|
|
18
71
|
}
|
|
72
|
+
try {
|
|
73
|
+
fs.writeFileSync(path.join(lockDir, 'owner'), String(process.pid));
|
|
74
|
+
}
|
|
75
|
+
catch { /* 忽略:owner 仅用于陈旧检测 */ }
|
|
19
76
|
try {
|
|
20
77
|
return await fn();
|
|
21
78
|
}
|
|
22
79
|
finally {
|
|
23
|
-
|
|
80
|
+
try {
|
|
81
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
catch { /* 已被清理 */ }
|
|
24
84
|
}
|
|
25
85
|
}
|
package/dist/mcp.js
CHANGED
|
@@ -58,7 +58,8 @@ export const TOOLS = [
|
|
|
58
58
|
inputSchema: { type: 'object', properties: {} }, toArgs: () => ['skill'] },
|
|
59
59
|
];
|
|
60
60
|
function runCli(args) {
|
|
61
|
-
|
|
61
|
+
// 强制非交互:给 AI 的输出不带颜色/spinner 控制符,保留完整日志
|
|
62
|
+
const r = spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', env: { ...process.env, WORKTREE_BAY_NONINTERACTIVE: '1' } });
|
|
62
63
|
return [r.stdout, r.stderr].filter(Boolean).join('\n').trim() || '(无输出)';
|
|
63
64
|
}
|
|
64
65
|
export function handle(msg) {
|
package/dist/util/color.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
1
|
+
// 终端是否「类 TTY」(可上色/动画)。Git Bash/mintty 下 Node 的 isTTY 会误报 false,
|
|
2
|
+
// 但其实是交互终端、支持 ANSI(MSYSTEM/TERM_PROGRAM 可识别)。MCP/脚本可设 WORKTREE_BAY_NONINTERACTIVE 强制纯文本。
|
|
3
|
+
export function ttyLike(stream) {
|
|
4
|
+
if (process.env.WORKTREE_BAY_NONINTERACTIVE)
|
|
5
|
+
return false;
|
|
6
|
+
if (stream?.isTTY)
|
|
7
|
+
return true;
|
|
8
|
+
return !!(process.env.MSYSTEM || process.env.TERM_PROGRAM === 'mintty');
|
|
9
|
+
}
|
|
10
|
+
// 轻量 ANSI 着色:仅在类 TTY 且未设 NO_COLOR 时上色,否则原样返回(管道/CI/重定向/MCP 不带控制符)。
|
|
11
|
+
const enabled = (ttyLike(process.stdout) || ttyLike(process.stderr)) && !process.env.NO_COLOR && process.env.TERM !== 'dumb';
|
|
3
12
|
const paint = (code) => (s) => (enabled ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
4
13
|
export const color = {
|
|
5
14
|
enabled,
|
package/dist/util/exec.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawnSync, spawn } from 'node:child_process';
|
|
2
2
|
import { t } from '../i18n.js';
|
|
3
|
-
import { color as c } from './color.js';
|
|
3
|
+
import { color as c, ttyLike } from './color.js';
|
|
4
4
|
export function run(cmd, args, opts = {}) { const r = spawnSync(cmd, args, { cwd: opts.cwd, stdio: 'inherit', shell: false }); return { code: r.status ?? 1 }; }
|
|
5
5
|
export function runShell(line, opts = {}) { const r = spawnSync(line, { cwd: opts.cwd, stdio: 'inherit', shell: true }); return { code: r.status ?? 1 }; }
|
|
6
6
|
export function spliceArgv(template, cmd) { const out = []; for (const el of template) {
|
|
@@ -14,7 +14,7 @@ const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '
|
|
|
14
14
|
// 折叠式执行 setup/teardown 这类外部命令:TTY 下把输出收进单行临时进度(spinner + 秒数 + 最后一行),
|
|
15
15
|
// 成功收成「✓ label(Ns)」,失败才吐完整日志便于排查;非 TTY(CI/管道/MCP)原样透传保留完整日志。
|
|
16
16
|
export function runShellLive(line, opts, label) {
|
|
17
|
-
if (!process.stderr
|
|
17
|
+
if (!ttyLike(process.stderr)) {
|
|
18
18
|
const r = spawnSync(line, { cwd: opts.cwd, stdio: 'inherit', shell: true });
|
|
19
19
|
return Promise.resolve({ code: r.status ?? 1 });
|
|
20
20
|
}
|
package/dist/util/progress.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { log } from './log.js';
|
|
2
|
-
import { color as c } from './color.js';
|
|
2
|
+
import { color as c, ttyLike } from './color.js';
|
|
3
3
|
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
4
4
|
// 给长耗时异步步骤一个「在干活」的反馈:TTY 下转圈 + 秒数(写 stderr,用 \r 原地刷新),
|
|
5
5
|
// 非 TTY(管道 / CI / MCP)下退化为「开始…」「✓ 完成(Ns)」两行,避免刷屏。
|
|
6
6
|
export async function withProgress(label, fn) {
|
|
7
7
|
const t0 = Date.now();
|
|
8
8
|
const secs = () => ((Date.now() - t0) / 1000).toFixed(1);
|
|
9
|
-
if (!process.stderr
|
|
9
|
+
if (!ttyLike(process.stderr)) {
|
|
10
10
|
log(` → ${label} …`);
|
|
11
11
|
const r = await fn();
|
|
12
12
|
log(` ${c.green('✓')} ${label}${c.dim(`(${secs()}s)`)}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "worktree-bay",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.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
|
@@ -35,6 +35,9 @@ worktree-bay completion install # 一键装 shell 补全(可选)
|
|
|
35
35
|
| `worktree-bay path <feature> <service>` | 打印某服务 worktree 的绝对路径(可 `cd $(worktree-bay path f api)`) |
|
|
36
36
|
| `worktree-bay run <feature> <service> <name> [args...]` | 在某服务运行体里跑配置的 `run.<name>`(如 test),透传 args |
|
|
37
37
|
| `worktree-bay sh <feature> <service>` | 进入某服务运行体的 shell |
|
|
38
|
+
| `worktree-bay start <feature> [service]` | 启动该功能的 dev server(worktree 已在,只起 `start` 进程,不动 worktree) |
|
|
39
|
+
| `worktree-bay stop <feature> [service]` | 停止该功能的 dev server(保留 worktree) |
|
|
40
|
+
| `worktree-bay restart <feature> [service]` | 重启 dev server(停掉再起;改了配置/端口卡住时用) |
|
|
38
41
|
| `worktree-bay down <feature> [-f]` | 拆除整个功能的所有服务 worktree(= `rm <feature>`) |
|
|
39
42
|
| `worktree-bay rm <feature> [service] [-f]` | 拆除某服务或整槽。默认查脏/未推保护,`-f` 强删 |
|
|
40
43
|
| `worktree-bay gc [--apply]` | 合并感知回收:默认 dry-run 只列建议,`--apply` 才删「已合并且干净」的 |
|