Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 93 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* of a pre-protocol fleet after independently confirming all Session/Riff work is idle
* botmux logs [--lines] — view daemon logs
* botmux status — show daemon status
* botmux upgrade|update — upgrade to latest version
* botmux upgrade|update — upgrade to latest version (本地 checkout 则 git pull --ff-only + rebuild + restart)
* botmux device enroll|status|logout — manage the host desktop device credential
* botmux list — interactive session picker (TUI), attach to managed tmux/ZMX sessions
* botmux list --plain — plain table output (for piping / scripts)
Expand Down Expand Up @@ -169,12 +169,20 @@ import {
executeDashboardCommand,
formatDashboardFallbackFailure,
} from './cli/dashboard-command.js';
import { globalInstallUpdateLockTargetIn, installLatestBotmuxSync } from './core/maintenance.js';
import { globalInstallUpdateLockTarget, globalInstallUpdateLockTargetIn, installLatestBotmuxSync } from './core/maintenance.js';
import {
formatGlobalInstallCommand,
resolveGlobalInstallPlan,
UnsupportedGlobalInstallError,
} from './utils/global-install.js';
import { isLocalDevInstall, botmuxCliEntryAt } from './utils/install-info.js';
import {
resolveLocalDevCheckoutDir,
isGitWorktree,
gitPorcelainStatus,
localDevUpdateSteps,
describeSpawnFailure,
} from './utils/local-dev-update.js';
import { cliAuthBind, loadDashboardSecret, signCliAuth } from './dashboard/auth.js';
import {
postWorkflowDaemonMutation,
Expand Down Expand Up @@ -4307,6 +4315,13 @@ function cmdStatus(): void {
}

function cmdUpgrade(): void {
// 本地 checkout(有 .git/src):走 git pull --ff-only → 重新 build → 从本
// checkout 重启,而不是拿全局包管理器去升级(那对 dev 部署无效,见
// install-info.ts 的 isLocalDevInstall 说明)。
if (isLocalDevInstall()) {
cmdUpgradeLocalDev();
return;
}
try {
const plan = resolveGlobalInstallPlan();
console.log(`🔄 升级中:${formatGlobalInstallCommand(plan)}`);
Expand All @@ -4322,6 +4337,82 @@ function cmdUpgrade(): void {
}
}

/** 在 checkout 目录里同步跑一条命令,stdio 直通;失败抛错。 */
function runInCheckout(cwd: string, command: string, args: string[]): void {
const result = spawnSync(command, args, {
cwd,
stdio: 'inherit',
shell: process.platform === 'win32',
});
if (result.error || result.status !== 0) {
throw new Error(describeSpawnFailure(command, args, result));
}
}

/**
* 本地 checkout 的更新流程:git 干净检查 → git pull --ff-only → pnpm build →
* 从本 checkout 的 dist/cli.js restart。dist/ 被 gitignore,只 pull 不 build
* 重启后跑的还是旧代码,故 build 步不可省。定位/干净检查/命令定义与 dashboard
* 共用 src/utils/local-dev-update.ts,避免两边逻辑漂移。
*/
function cmdUpgradeLocalDev(): void {
const dir = resolveLocalDevCheckoutDir();
if (!isGitWorktree(dir)) {
console.error(`❌ ${dir} 不是 git 工作树,无法用 git pull 更新。请手动更新或改用全局安装。`);
process.exit(1);
}
console.log(`🔄 本地 checkout 更新:${dir}`);

// git 干净检查 + pull + build 全程握同一把跨进程 update 锁(与 dashboard 的
// /api/update/run 用的是同一个 target),避免 CLI 与 dashboard 同时对同一
// checkout 交错跑 pnpm build 而互相清理/覆盖 dist。restart 不在锁内——它有
// 自己的 restart lease。
try {
const lockTarget = globalInstallUpdateLockTarget();
// daemon 正常会建好 dataDir;但 CLI 可能在 daemon 尚未起过的机器上先跑
// update,锁文件父目录不存在会让 withFileLockSync 报 ENOENT 而盖掉真实错误。
mkdirSync(dirname(lockTarget), { recursive: true });
withFileLockSync(lockTarget, () => {
// 1) fail closed:有未提交改动就中止(不偷偷 stash),让用户自己决定。
let status: string;
try {
status = gitPorcelainStatus(dir);
} catch (error) {
throw new Error(`读取 git 状态失败:${error instanceof Error ? error.message : error}`);
}
if (status) {
const err = new Error('dirty') as Error & { dirtyStatus?: string };
err.dirtyStatus = status;
throw err;
}
// 2~3) git pull --ff-only(分叉/冲突直接报错停下,不自动 merge)+ pnpm build。
for (const { command, args } of localDevUpdateSteps()) {
console.log(`→ ${command} ${args.join(' ')}`);
runInCheckout(dir, command, args);
}
}, { maxWaitMs: 2_000 });
} catch (error) {
const dirty = (error as { dirtyStatus?: string }).dirtyStatus;
if (dirty) {
console.error('❌ 工作区有未提交改动,已中止更新(不会自动 stash)。请先提交或清理:');
console.error(dirty);
} else {
console.error(`❌ 更新失败:${error instanceof Error ? error.message : error}`);
}
process.exit(1);
}

// 4) 用本 checkout 的 cli.js 重启 daemon(不走 PATH,避免被更靠前的全局 botmux 抢先)。
try {
console.log('→ restart daemon');
runInCheckout(dir, process.execPath, [botmuxCliEntryAt(dir), 'restart']);
console.log('\n✅ 本地更新完成,daemon 已从最新代码重启。');
} catch (error) {
console.error(`❌ 重启失败:${error instanceof Error ? error.message : error}`);
process.exit(1);
}
}

/**
* Call one of the dashboard's loopback HMAC `/__cli/*` endpoints. Thin wrapper
* over {@link callDashboard}, which handles 404 disambiguation and self-heals a
Expand Down
179 changes: 174 additions & 5 deletions src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ import { parseCloseResidual, type ParsedCloseResidual } from './core/close-resid
import { dashboardSecretPath } from './core/dashboard-secret.js';
import { getGitRepoInfo } from './core/session-row-enrichment.js';
import { deleteWhiteboard, listWhiteboards, readWhiteboard, whiteboardEnabled } from './services/whiteboard-store.js';
import { isLocalDevInstall, botmuxVersion, botmuxVersionAt, botmuxCliEntry, botmuxInstallRoot } from './utils/install-info.js';
import { checkNode, detectBotmuxInstalls, resolveCurrentVersion } from './utils/install-diagnostics.js';
import { isLocalDevInstall, botmuxVersion, botmuxVersionAt, botmuxCliEntry, botmuxCliEntryAt, botmuxInstallRoot } from './utils/install-info.js';
import { checkNode, detectBotmuxInstalls, resolveCurrentVersion, resolveCurrentVersionAt } from './utils/install-diagnostics.js';
import {
fetchLatestVersion,
fetchReleasesSince,
Expand All @@ -102,6 +102,14 @@ import {
import { GITHUB_REPO } from './core/restart-report.js';
import { DEFAULT_OVERLOAD_THRESHOLDS } from './core/host-overload-alert.js';
import { spawnDetachedRestart, globalInstallUpdateLockTarget, globalInstallUpdateCwd } from './core/maintenance.js';
import {
resolveLocalDevCheckoutDir,
resolveLocalDevRestartTarget,
isGitWorktree,
gitPorcelainStatus,
gitHeadSha,
localDevUpdateSteps,
} from './utils/local-dev-update.js';
import {
detectGlobalInstallManager,
formatGlobalInstallCommand,
Expand Down Expand Up @@ -1339,6 +1347,15 @@ let updateInFlight = false;
// update, and restart requests do not reuse the removed old runtime realpath.
let lastSuccessfulUpdatePlan: GlobalInstallPlan | undefined;

// Local-dev counterpart: the checkout a successful /api/update/run built, and
// its post-build HEAD. Pinned so the follow-up /api/update/restart applies THIS
// build's target — not a wrapper that a concurrent `pnpm use:here` in another
// worktree may have re-pointed between the two requests (run builds B, wrapper
// flips to C, restart would otherwise restart C or fall back to A). Cleared
// once consumed by a restart. A plain "restart" (no preceding run) still
// resolves the wrapper live.
let pendingLocalDevRestart: { dir: string; head: string } | undefined;

// Cache the upstream version/changelog lookups so the nav-badge check + the
// Settings card don't hammer the npm registry / GitHub on every page load.
// GitHub's unauthenticated API is only 60 req/h per IP, so caching the changelog
Expand Down Expand Up @@ -1448,6 +1465,69 @@ function runGlobalInstall(plan: GlobalInstallPlan): Promise<void> {
});
}

/** Run one local-dev update step (git pull / pnpm build) in `dir`, capturing
* output so a failure surfaces an actionable tail rather than a bare code. */
function runLocalDevStep(dir: string, command: string, args: string[]): Promise<void> {
return new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
cwd: dir,
env: { ...process.env },
stdio: ['ignore', 'pipe', 'pipe'],
shell: process.platform === 'win32', // resolve git / pnpm .cmd shims on win32
});
let tail = '';
const capture = (d: Buffer): void => { tail = (tail + d.toString()).slice(-4000); };
child.stdout?.on('data', capture);
child.stderr?.on('data', capture);
const timer = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error(`\`${command} ${args.join(' ')}\` timed out after 300s`));
}, 300_000);
child.on('error', (e) => { clearTimeout(timer); reject(e); });
child.on('exit', (code) => {
clearTimeout(timer);
if (code === 0) resolve();
else reject(new Error(`\`${command} ${args.join(' ')}\` exited ${code}: ${tail.trim().slice(-800)}`));
});
});
}

/**
* Local-dev update: git-clean check (fail closed) → git pull --ff-only →
* pnpm build, all in the checkout the global wrapper points at. Mirrors the CLI
* `cmdUpgradeLocalDev` via the shared local-dev-update helpers. Returns the
* checkout dir, its version before/after, and whether HEAD advanced; the caller
* applies the restart through the existing lease/intent path. A successful
* build always requires a restart to take effect (dist/ is regenerated), which
* the caller signals independently of `changed`. Rejects with a stable `code`
* on the recoverable, UI-actionable failures.
*/
async function runLocalDevUpdate(): Promise<{ dir: string; changed: boolean; oldVersion: string; newVersion: string; head: string }> {
const dir = resolveLocalDevCheckoutDir();
if (!isGitWorktree(dir)) {
throw Object.assign(new Error(`${dir} is not a git worktree`), { code: 'not_a_worktree', dir });
}
let status: string;
try {
status = gitPorcelainStatus(dir);
} catch (e) {
throw Object.assign(new Error(e instanceof Error ? e.message : String(e)), { code: 'git_status_failed', dir });
}
if (status) {
throw Object.assign(new Error('working tree has uncommitted changes'), {
code: 'dirty_worktree', dir, status,
});
}
const before = gitHeadSha(dir);
const oldVersion = resolveCurrentVersionAt(dir);
for (const { command, args } of localDevUpdateSteps()) {
await runLocalDevStep(dir, command, args);
}
const after = gitHeadSha(dir);
const newVersion = resolveCurrentVersionAt(dir);
return { dir, changed: before === '' || after === '' ? true : before !== after, oldVersion, newVersion, head: after };
}

/**
* Attach to one daemon: hydrate its sessions/schedules into the aggregator,
* THEN open the SSE subscription.
Expand Down Expand Up @@ -3419,14 +3499,19 @@ const server = createServer(async (req, res) => {
...(entry.installTarget ? { installTarget: entry.installTarget } : {}),
lastCheckedAt: entry.lastCheckedAt,
}));
const localDev = isLocalDevInstall();
return jsonRes(res, 200, {
current,
latest,
versionLookupOk: latestResult.lookupOk,
behind: !!latest && isNewerVersion(latest, current),
cliBehind: cliUpdates.some((entry) => entry.updateAvailable),
cliUpdates,
localDevInstall: isLocalDevInstall(),
localDevInstall: localDev,
// Local-dev can self-update via git pull + build only when the checkout
// the wrapper points at is a real git worktree; otherwise the button
// stays disabled (there is nothing to pull).
localDevUpdatable: localDev && isGitWorktree(resolveLocalDevCheckoutDir()),
updateSupported: installPlan !== null,
updateManager: installPlan?.manager ?? installManager,
updateCommand: installPlan ? formatGlobalInstallCommand(installPlan) : null,
Expand Down Expand Up @@ -3455,7 +3540,60 @@ const server = createServer(async (req, res) => {

if (req.method === 'POST' && url.pathname === '/api/update/run') {
if (!authed) return jsonRes(res, 401, { ok: false, error: 'unauthorized' });
if (isLocalDevInstall()) return jsonRes(res, 400, { ok: false, error: 'local_dev_no_update' });
// 本地 checkout:走 git pull --ff-only + pnpm build(与 CLI cmdUpgradeLocalDev
// 共用 local-dev-update 逻辑),而不是全局包管理器安装。重启仍走下方
// /api/update/restart 的 lease/intent 路径。
if (isLocalDevInstall()) {
const node = checkNode();
if (!node.ok) return jsonRes(res, 400, { ok: false, error: 'node_too_old', node });
if (updateInFlight) return jsonRes(res, 409, { ok: false, error: 'update_in_flight' });
updateInFlight = true;
let acquired = false;
let blockedByRestart = false;
let result: { dir: string; changed: boolean; oldVersion: string; newVersion: string; head: string } | undefined;
try {
await withFileLock(globalInstallUpdateLockTarget(), async () => {
acquired = true;
if (hasActiveRestartLease()) { blockedByRestart = true; return; }
result = await runLocalDevUpdate();
}, { maxWaitMs: 2_000 });
} catch (e) {
if (!acquired) return jsonRes(res, 409, { ok: false, error: 'update_in_flight' });
const code = (e as { code?: string }).code;
if (code === 'dirty_worktree') {
return jsonRes(res, 409, {
ok: false, error: 'dirty_worktree',
detail: (e as { status?: string }).status ?? '',
dir: (e as { dir?: string }).dir ?? '',
});
}
if (code === 'not_a_worktree') {
return jsonRes(res, 400, { ok: false, error: 'not_a_worktree', dir: (e as { dir?: string }).dir ?? '' });
}
return jsonRes(res, 500, { ok: false, error: 'install_failed', detail: e instanceof Error ? e.message : String(e) });
} finally {
updateInFlight = false;
}
if (blockedByRestart) return jsonRes(res, 409, { ok: false, error: 'restart_in_flight' });
// Pin THIS build's checkout + HEAD so the follow-up restart applies it,
// even if the wrapper is re-pointed by a concurrent `use:here` before the
// user confirms the restart. Consumed (and re-verified) in /api/update/restart.
if (result) pendingLocalDevRestart = { dir: result.dir, head: result.head };
return jsonRes(res, 200, {
ok: true,
// Versions of the checkout we actually updated (may differ from the
// running process's install root when wrapper→B, dashboard runs A).
oldVersion: result?.oldVersion ?? '',
newVersion: result?.newVersion ?? '',
// changed = HEAD advanced (or version string changed) — for display.
changed: result?.changed === true || result?.oldVersion !== result?.newVersion,
// A successful build regenerates dist/, so a restart is ALWAYS needed
// to apply it — independent of whether HEAD moved (the checkout may
// have been pulled already and only needed a build).
restartRequired: true,
localDev: true,
});
}
let installPlan: GlobalInstallPlan;
try {
const packageRoot = lastSuccessfulUpdatePlan?.activePackageRoot ?? botmuxInstallRoot();
Expand Down Expand Up @@ -3699,6 +3837,30 @@ const server = createServer(async (req, res) => {
if (parsed && typeof parsed === 'object') body = parsed as Record<string, unknown>;
} catch { /* empty / bad body → plain restart */ }
const upd = body.update && typeof body.update === 'object' ? body.update as Record<string, unknown> : null;
// Resolve the local-dev restart target BEFORE claiming the lease so a
// fail-closed drift check can't leave a dangling lease. Prefer the plan a
// preceding /api/update/run pinned (dir + post-build HEAD); a plain manual
// restart (no pending plan) resolves the wrapper live. Verify the target's
// dist/cli.js exists and — for a pinned plan — that HEAD hasn't moved since
// the build; on drift/absence fail closed rather than restart the wrong tree.
let localDevRestartError: { status: number; body: Record<string, unknown> } | undefined;
let localDevTarget: string | undefined;
if (isLocalDevInstall()) {
const pinned = pendingLocalDevRestart;
pendingLocalDevRestart = undefined; // consume regardless of outcome
const decision = resolveLocalDevRestartTarget(pinned, resolveLocalDevCheckoutDir(), {
cliEntryExists: (dir) => existsSync(botmuxCliEntryAt(dir)),
headOf: (dir) => gitHeadSha(dir),
});
if (decision.action === 'fail') {
localDevRestartError = { status: 409, body: { ok: false, error: decision.reason, dir: decision.dir } };
} else if (decision.action === 'restart') {
localDevTarget = decision.dir;
} else {
localDevTarget = undefined; // fallback-running-root
}
}
if (localDevRestartError) return jsonRes(res, localDevRestartError.status, localDevRestartError.body);
let acquired = false;
let leaseId: string | null = null;
let activePackageRoot: string | undefined;
Expand Down Expand Up @@ -3728,7 +3890,14 @@ const server = createServer(async (req, res) => {
});
return;
}
activePackageRoot = (lastSuccessfulUpdatePlan ?? tryResolveGlobalInstallPlan())?.activePackageRoot;
// Local-dev restarts from the target resolved above (pinned build's
// checkout, verified present + at the built HEAD); undefined falls back
// to this dashboard process's own cli.js via spawnDetachedRestart.
if (isLocalDevInstall()) {
activePackageRoot = localDevTarget;
} else {
activePackageRoot = (lastSuccessfulUpdatePlan ?? tryResolveGlobalInstallPlan())?.activePackageRoot;
}
// Send acknowledgement while holding the lock, then release immediately.
// The lease itself prevents concurrent restarts — no need to hold the
// lock across the network round-trip waiting for res.finish.
Expand Down
Loading
Loading