From dd75adbf73c00664e2d154080e23135b5e6bbfe7 Mon Sep 17 00:00:00 2001 From: AmberCXX Date: Fri, 8 May 2026 15:57:37 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20add=20forge-hub=20sync=20=E2=80=94?= =?UTF-8?q?=20lightweight=20runtime=20sync=20without=20full=20install?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git pull 后源码更新,但 ~/.forge-hub/ 运行时文件不会自动同步, 导致通道插件调用新 HubAPI 方法(如 isAllowed)时报错。 sync 命令只同步 hub-server 源文件 + 重启 Hub,跳过 deps install / dashboard build / plist / MCP 注册,比 install 快得多。 维护地图.md 同步更新:源码更新后跑 forge-hub sync 对齐运行时。 Co-Authored-By: Claude Opus 4.7 --- cli.ts | 51 +++++++++++++++++++ ...64\346\212\244\345\234\260\345\233\276.md" | 1 + 2 files changed, 52 insertions(+) diff --git a/cli.ts b/cli.ts index 987064c..6364c8e 100755 --- a/cli.ts +++ b/cli.ts @@ -282,6 +282,53 @@ function installCmd(): void { `); } +// ── sync ──────────────────────────────────────────────────────────────────── + +function syncCmd(): void { + log("🔄 Forge Hub sync\n"); + + // 1. Re-stage package snapshot from current source + const packageRoot = stagePackageRuntime(); + const serverSrc = path.join(packageRoot, "hub-server"); + + // 2. Copy hub-server .ts/.json/.lock files to runtime + cpDir(serverSrc, HUB_DIR, [".ts", ".json", ".lock"]); + cpDir(path.join(serverSrc, "channels"), CHANNELS_RUNTIME, [".ts"]); + + // Security: maintain 700 permissions + try { + fs.chmodSync(HUB_DIR, 0o700); + fs.chmodSync(CHANNELS_RUNTIME, 0o700); + } catch (err) { + console.warn(`⚠️ chmod 700 失败: ${String(err)}`); + } + log("✓ hub-server runtime synced(channels/ 已更新)"); + + // 3. Restart hub via launchctl + if (os.platform() === "darwin") { + const uid = os.userInfo().uid; + const label = `gui/${uid}/com.forge-hub`; + try { + // kickstart -k: forcibly restart by plist path + execFileSync("launchctl", ["kickstart", "-k", "-p", LAUNCHD_PLIST], { stdio: "ignore" }); + log("✓ Hub 已重启"); + } catch { + // Fallback: bootout + bootstrap if kickstart by path fails + try { + execFileSync("launchctl", ["bootout", label], { stdio: "ignore" }); + } catch { /* might not be bootstrapped */ } + try { + execFileSync("launchctl", ["bootstrap", label, LAUNCHD_PLIST], { stdio: "ignore" }); + log("✓ Hub 已重启"); + } catch { + log(`⚠️ 无法重启 Hub。手动执行:launchctl bootout ${label} && launchctl bootstrap ${label} ${LAUNCHD_PLIST}`); + } + } + } + + log("\n✅ Sync 完成。hub-server 运行时已对齐源码。"); +} + // ── uninstall ─────────────────────────────────────────────────────────────── function uninstallCmd(): void { @@ -680,6 +727,9 @@ if (import.meta.main) { case "install": installCmd(); break; + case "sync": + syncCmd(); + break; case "uninstall": uninstallCmd(); break; @@ -696,6 +746,7 @@ USAGE: COMMANDS: install 一键部署到 ~/.forge-hub/ + ~/.claude/channels/hub/ + launchd + MCP 注册 + sync 仅同步 hub-server 运行时文件(git pull 后跑这个,不重装 deps/dashboard) uninstall 反向操作(保留 state) doctor 诊断 install 状态 + connectivity --help 显示此帮助 diff --git "a/\347\273\264\346\212\244\345\234\260\345\233\276.md" "b/\347\273\264\346\212\244\345\234\260\345\233\276.md" index ec0e4ad..c2c0868 100644 --- "a/\347\273\264\346\212\244\345\234\260\345\233\276.md" +++ "b/\347\273\264\346\212\244\345\234\260\345\233\276.md" @@ -158,3 +158,4 @@ Claude Code permission_request - 安全相关失败默认 fail closed;如果不能 fail closed,文档和日志必须讲清代价。 - 安装和运行时路径不要靠记忆,查 [部署.md](部署.md) 和 [运行时状态.md](运行时状态.md)。 - Preview / experimental 可以探索,但不要让默认安装路径变复杂。 +- **源码更新后跑 `forge-hub sync` 对齐运行时**。git pull / 切换分支后 `~/.forge-hub/` 文件不会自动更新,源码与运行时版本不一致会导致通道插件报错。`forge-hub sync` 只同步 hub-server 源文件 + 重启 Hub,不重装 deps/dashboard/plist/MCP。 From 6f83190d253a9827234a34bb0ff9aee0d0f6c0b4 Mon Sep 17 00:00:00 2001 From: AmberCXX Date: Sat, 9 May 2026 10:41:10 +0800 Subject: [PATCH 2/3] fix: clean channels runtime dir before sync to drop stale files Per review feedback on PR #25: syncCmd missed the cleanDirContents step before cpDir for channels. If a channel file is deleted or renamed in source, the old .ts file would otherwise remain in ~/.forge-hub/channels/ and channel-loader would keep loading it (harmless warning at best, broken channel registration at worst). Mirrors installCmd behavior at L79-82. Co-Authored-By: Claude Opus 4.7 --- cli.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/cli.ts b/cli.ts index 6364c8e..24cbe25 100755 --- a/cli.ts +++ b/cli.ts @@ -293,6 +293,7 @@ function syncCmd(): void { // 2. Copy hub-server .ts/.json/.lock files to runtime cpDir(serverSrc, HUB_DIR, [".ts", ".json", ".lock"]); + cleanDirContents(CHANNELS_RUNTIME); cpDir(path.join(serverSrc, "channels"), CHANNELS_RUNTIME, [".ts"]); // Security: maintain 700 permissions From 59f0b3e88cc8f58e6c950a1974da15ffa22e03ee Mon Sep 17 00:00:00 2001 From: AmberCXX Date: Sat, 16 May 2026 15:50:26 +0800 Subject: [PATCH 3/3] fix(sync): align launchctl restart with installCmd + skip user runtime configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per @LinekForge review on #25: 1. launchctl syntax in syncCmd was wrong on both paths: - `kickstart -k -p ` — `-p` is not a kickstart flag; kickstart takes a service target (gui//com.forge-hub), not a plist path - fallback `bootstrap ` — bootstrap's first arg must be the domain (gui/), not the full label Both paths silently entered catch, so sync never actually restarted Hub. Replaced with installCmd's verified `bootout label` + `bootstrap domain plist` pattern. 2. cpDir to HUB_DIR copied every hub-server/*.json, which would silently overwrite user runtime configs (hub-config.json / lock-phrase.json / lock.json) if those names ever land in hub-server/. Added SYNC_SKIP set, threaded through cpDir as an optional skipNames param (back-compat default empty set, no other call site affected). --- cli.ts | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/cli.ts b/cli.ts index 24cbe25..c71a498 100755 --- a/cli.ts +++ b/cli.ts @@ -47,6 +47,10 @@ export const HUB_INSTALL_PRESERVE_ENTRIES = [ ".DS_Store", ]; +// 用户运行时配置——sync 时绝不允许从源码 cpDir 覆盖。 +// (install 走 cleanDirContents preserve set,sync 不 clean 但要防同名 .json 静默覆盖。) +const SYNC_SKIP = new Set(["hub-config.json", "lock-phrase.json", "lock.json"]); + // 找包根目录(从 cli.ts 的位置反推) const PKG_ROOT = path.dirname(fileURLToPath(import.meta.url)); @@ -292,7 +296,8 @@ function syncCmd(): void { const serverSrc = path.join(packageRoot, "hub-server"); // 2. Copy hub-server .ts/.json/.lock files to runtime - cpDir(serverSrc, HUB_DIR, [".ts", ".json", ".lock"]); + // SYNC_SKIP 防止 hub-server/ 下未来若出现同名 .json 静默覆盖用户运行时配置(hub-config.json 等)。 + cpDir(serverSrc, HUB_DIR, [".ts", ".json", ".lock"], SYNC_SKIP); cleanDirContents(CHANNELS_RUNTIME); cpDir(path.join(serverSrc, "channels"), CHANNELS_RUNTIME, [".ts"]); @@ -306,24 +311,20 @@ function syncCmd(): void { log("✓ hub-server runtime synced(channels/ 已更新)"); // 3. Restart hub via launchctl + // 复用 installCmd 的 bootout + bootstrap 模式(kickstart -k -p 不是合法 launchctl 语法; + // bootstrap 第一参数是 domain 不是完整 label)。 if (os.platform() === "darwin") { const uid = os.userInfo().uid; - const label = `gui/${uid}/com.forge-hub`; + const domain = `gui/${uid}`; + const label = `${domain}/com.forge-hub`; + try { + execFileSync("launchctl", ["bootout", label], { stdio: "ignore" }); + } catch { /* might not be bootstrapped */ } try { - // kickstart -k: forcibly restart by plist path - execFileSync("launchctl", ["kickstart", "-k", "-p", LAUNCHD_PLIST], { stdio: "ignore" }); + execFileSync("launchctl", ["bootstrap", domain, LAUNCHD_PLIST], { stdio: "inherit" }); log("✓ Hub 已重启"); } catch { - // Fallback: bootout + bootstrap if kickstart by path fails - try { - execFileSync("launchctl", ["bootout", label], { stdio: "ignore" }); - } catch { /* might not be bootstrapped */ } - try { - execFileSync("launchctl", ["bootstrap", label, LAUNCHD_PLIST], { stdio: "ignore" }); - log("✓ Hub 已重启"); - } catch { - log(`⚠️ 无法重启 Hub。手动执行:launchctl bootout ${label} && launchctl bootstrap ${label} ${LAUNCHD_PLIST}`); - } + log(`⚠️ 无法重启 Hub。手动执行:launchctl bootout ${label} && launchctl bootstrap ${domain} ${LAUNCHD_PLIST}`); } } @@ -484,10 +485,11 @@ async function doctorCmd(): Promise { // ── helpers ───────────────────────────────────────────────────────────────── -function cpDir(src: string, dst: string, exts: string[]): void { +function cpDir(src: string, dst: string, exts: string[], skipNames = new Set()): void { if (!fs.existsSync(src)) die(`source 不存在: ${src}`); fs.mkdirSync(dst, { recursive: true }); for (const f of fs.readdirSync(src)) { + if (skipNames.has(f)) continue; const sp = path.join(src, f); const dp = path.join(dst, f); const stat = fs.statSync(sp);