@cocorograph/hub-agent 0.5.20 → 0.5.23
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/package.json +1 -1
- package/scripts/install.sh +60 -10
- package/src/main.mjs +47 -0
- package/src/tmux.mjs +91 -4
package/package.json
CHANGED
package/scripts/install.sh
CHANGED
|
@@ -32,18 +32,40 @@ color_err() { printf "\033[1;31m✗ %s\033[0m\n" "$1" >&2; }
|
|
|
32
32
|
|
|
33
33
|
present() { command -v "$1" >/dev/null 2>&1; }
|
|
34
34
|
|
|
35
|
+
# 現在 PATH にある brew が、このユーザーで書き込み可能か判定する。
|
|
36
|
+
# 「brew はあるが Cellar が他ユーザー所有で書き込み不可」というケースを検知して
|
|
37
|
+
# user-local Homebrew にフォールバックするための判定関数。
|
|
38
|
+
_existing_brew_writable() {
|
|
39
|
+
local prefix
|
|
40
|
+
prefix="$(brew --prefix 2>/dev/null || echo '')"
|
|
41
|
+
[[ -z "$prefix" ]] && return 1
|
|
42
|
+
# Cellar / opt が書き込み可能か(または未作成でも prefix 自体が書き込み可能か)
|
|
43
|
+
if [[ -w "$prefix/Cellar" ]] || ( [[ ! -e "$prefix/Cellar" ]] && [[ -w "$prefix" ]] ); then
|
|
44
|
+
return 0
|
|
45
|
+
fi
|
|
46
|
+
return 1
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
ensure_brew() {
|
|
36
50
|
if [[ "$(uname)" != "Darwin" ]]; then return 0; fi
|
|
51
|
+
|
|
52
|
+
# 既に brew がインストールされている場合の判定:
|
|
53
|
+
# - ユーザーが書き込み可能 → そのまま使用(system / user-local どちらでも OK)
|
|
54
|
+
# - 書き込み不可(他ユーザー所有の brew が居る)→ $HOME/homebrew に並列で user-local 化
|
|
37
55
|
if present brew; then
|
|
38
|
-
|
|
39
|
-
|
|
56
|
+
if _existing_brew_writable; then
|
|
57
|
+
color_ok "brew already installed and writable ($(brew --prefix))"
|
|
58
|
+
persist_brew_shellenv
|
|
59
|
+
return 0
|
|
60
|
+
fi
|
|
61
|
+
color_warn "既存 brew ($(brew --prefix 2>/dev/null)) は書き込み不可 → \$HOME/homebrew にユーザーローカル Homebrew を並列インストール"
|
|
62
|
+
_install_user_local_brew
|
|
40
63
|
return 0
|
|
41
64
|
fi
|
|
42
65
|
|
|
43
|
-
#
|
|
66
|
+
# brew コマンドが PATH にない場合の判定:
|
|
44
67
|
# - 環境変数 HUB_AGENT_USER_BREW=1 で明示強制
|
|
45
68
|
# - sudo がパスワードなしで通らない = 管理者ではない可能性が高いと判定
|
|
46
|
-
# user-mode では $HOME/homebrew にユーザーローカルインストール(sudo 不要)。
|
|
47
69
|
local use_user_mode=0
|
|
48
70
|
if [[ "${HUB_AGENT_USER_BREW:-0}" == "1" ]]; then
|
|
49
71
|
use_user_mode=1
|
|
@@ -54,11 +76,7 @@ ensure_brew() {
|
|
|
54
76
|
fi
|
|
55
77
|
|
|
56
78
|
if (( use_user_mode == 1 )); then
|
|
57
|
-
|
|
58
|
-
mkdir -p "$HOME/homebrew"
|
|
59
|
-
curl -L https://github.com/Homebrew/brew/tarball/master | tar xz --strip 1 -C "$HOME/homebrew"
|
|
60
|
-
export PATH="$HOME/homebrew/bin:$PATH"
|
|
61
|
-
persist_user_brew_shellenv
|
|
79
|
+
_install_user_local_brew
|
|
62
80
|
else
|
|
63
81
|
color_step "Homebrew をシステムインストール"
|
|
64
82
|
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
|
@@ -71,11 +89,32 @@ ensure_brew() {
|
|
|
71
89
|
present brew || { color_err "brew install failed"; exit 1; }
|
|
72
90
|
}
|
|
73
91
|
|
|
92
|
+
# user-local Homebrew のインストール本体(重複排除のため関数化)。
|
|
93
|
+
# 既に $HOME/homebrew が展開済みなら再展開せず PATH/shellenv だけ整える。
|
|
94
|
+
_install_user_local_brew() {
|
|
95
|
+
if [[ -x "$HOME/homebrew/bin/brew" ]]; then
|
|
96
|
+
color_ok "\$HOME/homebrew は既に展開済み"
|
|
97
|
+
else
|
|
98
|
+
color_step "Homebrew をユーザーローカルインストール (\$HOME/homebrew)"
|
|
99
|
+
mkdir -p "$HOME/homebrew"
|
|
100
|
+
curl -L https://github.com/Homebrew/brew/tarball/master | tar xz --strip 1 -C "$HOME/homebrew"
|
|
101
|
+
fi
|
|
102
|
+
# PATH を user-local 優先で並べる(既存 /usr/local/bin/brew より先)
|
|
103
|
+
export PATH="$HOME/homebrew/bin:$PATH"
|
|
104
|
+
hash -r 2>/dev/null || true # bash のコマンドキャッシュをクリア
|
|
105
|
+
persist_user_brew_shellenv
|
|
106
|
+
}
|
|
107
|
+
|
|
74
108
|
# Apple Silicon Mac で brew のパスを zsh / bash 永続化する。既に追記済みなら no-op。
|
|
75
109
|
# `eval "$(/opt/homebrew/bin/brew shellenv)"` を書くことで、Homebrew が用意する
|
|
76
110
|
# HOMEBREW_PREFIX / PATH / MANPATH / INFOPATH すべてを新規シェルで自動セットする。
|
|
77
111
|
persist_brew_shellenv() {
|
|
78
112
|
local brew_bin=""
|
|
113
|
+
# $HOME/homebrew が PATH 上で優先されているなら、user-local 側で永続化する
|
|
114
|
+
if [[ -x "$HOME/homebrew/bin/brew" ]] && [[ "$(command -v brew)" == "$HOME/homebrew/bin/brew" ]]; then
|
|
115
|
+
persist_user_brew_shellenv
|
|
116
|
+
return 0
|
|
117
|
+
fi
|
|
79
118
|
if [[ -x /opt/homebrew/bin/brew ]]; then
|
|
80
119
|
brew_bin="/opt/homebrew/bin/brew"
|
|
81
120
|
elif [[ -x /usr/local/bin/brew ]]; then
|
|
@@ -261,15 +300,26 @@ do_install_service() {
|
|
|
261
300
|
|
|
262
301
|
macos_perm_guidance() {
|
|
263
302
|
[[ "$(uname)" != "Darwin" ]] && return 0
|
|
264
|
-
|
|
303
|
+
# 現在 PATH 上の node 実体パスを案内に埋め込む(フルディスクアクセス登録時に役立つ)
|
|
304
|
+
local node_path
|
|
305
|
+
node_path="$(command -v node 2>/dev/null || echo '/path/to/node')"
|
|
306
|
+
cat <<EOF
|
|
265
307
|
|
|
266
308
|
📣 macOS の権限ダイアログについて
|
|
267
309
|
hub-agent は tmux/pty を中継するため、初回起動時に macOS から
|
|
268
310
|
「node がローカルネットワーク上の機器を検出することを求めています」
|
|
269
311
|
「node がアクセシビリティを制御することを求めています」
|
|
312
|
+
「node がほかのアプリからのデータへのアクセス権を求めています」
|
|
270
313
|
などのダイアログが出る場合があります。すべて「許可」を選んでください。
|
|
271
314
|
許可は「システム設定 > プライバシーとセキュリティ」から後で変更できます。
|
|
272
315
|
|
|
316
|
+
▶ ダイアログが頻繁に出る場合(特に Claude Code 経由で様々な cwd を使う場合):
|
|
317
|
+
「フルディスクアクセス」に node 本体を追加すると静かになります。
|
|
318
|
+
システム設定 > プライバシーとセキュリティ > フルディスクアクセス
|
|
319
|
+
→ + ボタンで以下のパスを追加:
|
|
320
|
+
${node_path}
|
|
321
|
+
(node バイナリパスが変わったら再登録が必要です)
|
|
322
|
+
|
|
273
323
|
EOF
|
|
274
324
|
}
|
|
275
325
|
|
package/src/main.mjs
CHANGED
|
@@ -32,6 +32,8 @@ import {
|
|
|
32
32
|
killManySessions,
|
|
33
33
|
killSession as killTmuxSession,
|
|
34
34
|
listSessions as listTmuxSessions,
|
|
35
|
+
listWorktreeStubs,
|
|
36
|
+
removeWorktree as removeWorktreeDir,
|
|
35
37
|
} from "./tmux.mjs"
|
|
36
38
|
import { getSessionUsages, getUsage } from "./usage.mjs"
|
|
37
39
|
|
|
@@ -506,16 +508,28 @@ async function dispatch(msg, ctx) {
|
|
|
506
508
|
...s,
|
|
507
509
|
last_event: lastEventByName.get(s.name) || null,
|
|
508
510
|
}))
|
|
511
|
+
// cockpit (PR 1719) で未起動 worktree をサイドバーに可視化するために
|
|
512
|
+
// filesystem 上は存在するが tmux session が無い worktree dir のリストを
|
|
513
|
+
// 同梱する。古い cockpit は worktree_stubs を無視するので互換 OK。
|
|
514
|
+
const liveNames = new Set(enriched.map((s) => s.name))
|
|
515
|
+
let worktreeStubs = []
|
|
516
|
+
try {
|
|
517
|
+
worktreeStubs = await listWorktreeStubs(liveNames)
|
|
518
|
+
} catch (err) {
|
|
519
|
+
ctx.logger?.warn?.({ err: err?.message }, "listWorktreeStubs failed")
|
|
520
|
+
}
|
|
509
521
|
ctx.client.send({
|
|
510
522
|
type: "tmux.sessions",
|
|
511
523
|
request_id: msg.request_id,
|
|
512
524
|
sessions: enriched,
|
|
525
|
+
worktree_stubs: worktreeStubs,
|
|
513
526
|
})
|
|
514
527
|
} catch (err) {
|
|
515
528
|
ctx.client.send({
|
|
516
529
|
type: "tmux.sessions",
|
|
517
530
|
request_id: msg.request_id,
|
|
518
531
|
sessions: [],
|
|
532
|
+
worktree_stubs: [],
|
|
519
533
|
error: err.message,
|
|
520
534
|
})
|
|
521
535
|
}
|
|
@@ -612,6 +626,39 @@ async function dispatch(msg, ctx) {
|
|
|
612
626
|
}
|
|
613
627
|
return
|
|
614
628
|
}
|
|
629
|
+
case "worktree.remove": {
|
|
630
|
+
// body: { request_id, name }
|
|
631
|
+
// cockpit (PR 1719) のサイドバー削除ボタンから呼ばれる。
|
|
632
|
+
// git worktree remove --force を実行し、走行中 session があれば事前に kill する。
|
|
633
|
+
const name = (msg.name || "").trim()
|
|
634
|
+
if (!name) {
|
|
635
|
+
ctx.client.send({
|
|
636
|
+
type: "worktree.remove.result",
|
|
637
|
+
request_id: msg.request_id,
|
|
638
|
+
ok: false,
|
|
639
|
+
error: "name required",
|
|
640
|
+
})
|
|
641
|
+
return
|
|
642
|
+
}
|
|
643
|
+
try {
|
|
644
|
+
const { name: removedName, wt_path: wtPath } = await removeWorktreeDir(name)
|
|
645
|
+
ctx.client.send({
|
|
646
|
+
type: "worktree.remove.result",
|
|
647
|
+
request_id: msg.request_id,
|
|
648
|
+
ok: true,
|
|
649
|
+
name: removedName,
|
|
650
|
+
wt_path: wtPath,
|
|
651
|
+
})
|
|
652
|
+
} catch (err) {
|
|
653
|
+
ctx.client.send({
|
|
654
|
+
type: "worktree.remove.result",
|
|
655
|
+
request_id: msg.request_id,
|
|
656
|
+
ok: false,
|
|
657
|
+
error: err.message,
|
|
658
|
+
})
|
|
659
|
+
}
|
|
660
|
+
return
|
|
661
|
+
}
|
|
615
662
|
case "tmux.kill_session": {
|
|
616
663
|
const names = Array.isArray(msg.session_names)
|
|
617
664
|
? msg.session_names
|
package/src/tmux.mjs
CHANGED
|
@@ -142,14 +142,19 @@ export async function createWorktreeDir(parentDir, branch) {
|
|
|
142
142
|
|
|
143
143
|
/**
|
|
144
144
|
* `~/hub/projects/<project>/.claude/worktrees/<wt>` を走査し、
|
|
145
|
-
*
|
|
145
|
+
* worktree session 名 → 詳細情報の Map を返す。
|
|
146
146
|
*
|
|
147
147
|
* - 各 worktree dir は `.git` ファイル/ディレクトリの存在で git worktree と判定
|
|
148
148
|
* - session 名はディレクトリ名を sanitize したもの (tmux session 名命名規則)
|
|
149
149
|
*
|
|
150
|
+
* 戻り値は `{ parent_session_name, cwd, branch }` の dict。cockpit (cockpit
|
|
151
|
+
* worktree-discovery PR 1719) が tmux.list_sessions 応答に worktree_stubs として
|
|
152
|
+
* 同梱するために branch / cwd まで返す。`branch` は `git -C <wtPath> branch
|
|
153
|
+
* --show-current` を best-effort で取る。
|
|
154
|
+
*
|
|
150
155
|
* 移植元: D00000_cockpit/webapp/lib/workspaces.ts (findWorktrees)
|
|
151
156
|
*/
|
|
152
|
-
async function
|
|
157
|
+
async function buildWorktreeIndex() {
|
|
153
158
|
const out = new Map()
|
|
154
159
|
const projectsBase = hubProjectsBase()
|
|
155
160
|
let projects
|
|
@@ -169,17 +174,99 @@ async function buildWorktreeParentMap() {
|
|
|
169
174
|
}
|
|
170
175
|
for (const wt of wts) {
|
|
171
176
|
if (!wt.isDirectory() || wt.name.startsWith(".")) continue
|
|
177
|
+
const wtPath = path.join(wtBase, wt.name)
|
|
172
178
|
try {
|
|
173
179
|
// .git が存在する = 正規 git worktree
|
|
174
|
-
await fs.stat(path.join(
|
|
180
|
+
await fs.stat(path.join(wtPath, ".git"))
|
|
175
181
|
} catch {
|
|
176
182
|
continue
|
|
177
183
|
}
|
|
178
|
-
|
|
184
|
+
let branch = null
|
|
185
|
+
try {
|
|
186
|
+
const r = await execFileP("git", ["-C", wtPath, "branch", "--show-current"])
|
|
187
|
+
branch = (r.stdout || "").trim() || null
|
|
188
|
+
} catch {
|
|
189
|
+
// best-effort: detached / 取得失敗時は null のままにする
|
|
190
|
+
}
|
|
191
|
+
const sessionName = sanitizeTmuxName(wt.name)
|
|
192
|
+
out.set(sessionName, {
|
|
193
|
+
name: sessionName,
|
|
194
|
+
parent_session_name: sanitizeTmuxName(p.name),
|
|
195
|
+
cwd: wtPath,
|
|
196
|
+
branch,
|
|
197
|
+
})
|
|
179
198
|
}
|
|
180
199
|
}
|
|
181
200
|
return out
|
|
182
201
|
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 後方互換: 旧 `buildWorktreeParentMap` を `buildWorktreeIndex` で薄ラップ。
|
|
205
|
+
* Map<wtName, parentName> 形式を期待していた既存呼び出し (listSessions 内) 向け。
|
|
206
|
+
*/
|
|
207
|
+
async function buildWorktreeParentMap() {
|
|
208
|
+
const idx = await buildWorktreeIndex()
|
|
209
|
+
const out = new Map()
|
|
210
|
+
for (const [name, info] of idx) out.set(name, info.parent_session_name)
|
|
211
|
+
return out
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 走行中の tmux session 名 set を受け取り、それに含まれない worktree (= 未起動 stub)
|
|
216
|
+
* のリストを返す。cockpit `tmux.sessions` 応答の `worktree_stubs` フィールドに乗せる
|
|
217
|
+
* ためのヘルパー。
|
|
218
|
+
*/
|
|
219
|
+
export async function listWorktreeStubs(liveSessionNames) {
|
|
220
|
+
const idx = await buildWorktreeIndex()
|
|
221
|
+
const stubs = []
|
|
222
|
+
for (const [name, info] of idx) {
|
|
223
|
+
if (liveSessionNames.has(name)) continue
|
|
224
|
+
stubs.push(info)
|
|
225
|
+
}
|
|
226
|
+
return stubs
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* `git worktree remove --force <wtPath>` を実行する。事前に live tmux session が
|
|
231
|
+
* あれば kill する。cockpit `worktree.remove` ハンドラから呼ばれる。
|
|
232
|
+
*
|
|
233
|
+
* - name から path を解決するには buildWorktreeIndex で fs を走査する (path 検証も兼ねる)
|
|
234
|
+
* - HUB_PROJECTS_BASE 配下に居ない path は拒否 (path traversal 防止)
|
|
235
|
+
* - 未コミットの変更等で git remove が失敗した場合はそのエラーを上位に投げる
|
|
236
|
+
*/
|
|
237
|
+
export async function removeWorktree(name, opts = {}) {
|
|
238
|
+
const sanitized = sanitizeTmuxName(String(name || "").trim())
|
|
239
|
+
if (!sanitized) throw new Error("worktree name required")
|
|
240
|
+
const idx = await buildWorktreeIndex()
|
|
241
|
+
const info = idx.get(sanitized)
|
|
242
|
+
if (!info) throw new Error("worktree not found")
|
|
243
|
+
const projectsBase = hubProjectsBase()
|
|
244
|
+
const resolved = path.resolve(info.cwd)
|
|
245
|
+
if (!resolved.startsWith(projectsBase + path.sep)) {
|
|
246
|
+
throw new Error("worktree path outside projects base")
|
|
247
|
+
}
|
|
248
|
+
// 走行中の session があれば kill
|
|
249
|
+
try {
|
|
250
|
+
await execFileP(tmuxBin(opts), ["kill-session", "-t", sanitized])
|
|
251
|
+
} catch (err) {
|
|
252
|
+
const msg = err?.message || String(err)
|
|
253
|
+
// session 不在は正常 (停止済みの worktree を消すパスもある)
|
|
254
|
+
if (
|
|
255
|
+
!msg.includes("can't find session") &&
|
|
256
|
+
!msg.includes("no current session") &&
|
|
257
|
+
!msg.includes("no server running")
|
|
258
|
+
) {
|
|
259
|
+
throw err
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// 親 repo を解決し `git -C <parent> worktree remove --force <wtPath>` を実行
|
|
263
|
+
const wtSegment = path.join(".claude", "worktrees", path.basename(resolved))
|
|
264
|
+
const parentRepo = resolved.endsWith(wtSegment)
|
|
265
|
+
? resolved.slice(0, -wtSegment.length - 1)
|
|
266
|
+
: path.resolve(resolved, "..", "..", "..")
|
|
267
|
+
await execFileP("git", ["-C", parentRepo, "worktree", "remove", "--force", resolved])
|
|
268
|
+
return { name: sanitized, wt_path: resolved }
|
|
269
|
+
}
|
|
183
270
|
/**
|
|
184
271
|
* Claude CLI コマンドを組み立てる。
|
|
185
272
|
*
|