Skip to content

Commit 35bf4bf

Browse files
committed
feat: cloud-claude 真实路径挂载、命令代理、环境检测与 Homebrew 发布
- sshfs 挂载目标从 /workspace 改为用户本地 CWD 的同名路径, Claude Code 显示的工作目录与本地完全一致 - 新增文件 IPC 命令代理(execproxy),默认拦截 git 命令在 本地执行,解决远端缺少 .git、GPG 签名等问题 - 新增 cloud-claude env check 子命令,一键检测远端容器的 时区、语言、出口 IP、工具链和 FUSE 支持状态 - 新增 Homebrew tap 自动发布流程(release workflow + Formula 生成) - 修复 entrypoint sudoers 在用户名变更后未同步的问题 - .gitignore 排除 .claude/ 工具缓存 Made-with: Cursor
1 parent 297ef17 commit 35bf4bf

18 files changed

Lines changed: 732 additions & 95 deletions

File tree

.github/workflows/release.yml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,58 @@ jobs:
165165
ARCHIVE="cloud-claude-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz"
166166
gh release upload "${{ github.ref_name }}" "${ARCHIVE}" "${ARCHIVE}.sha256" --clobber
167167
168+
update-homebrew:
169+
needs: [create-release, build-binaries]
170+
runs-on: ubuntu-latest
171+
steps:
172+
- uses: actions/checkout@v4
173+
174+
- name: Download sha256 files from release
175+
env:
176+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
177+
TAG: ${{ github.ref_name }}
178+
run: |
179+
for platform in darwin-amd64 darwin-arm64 linux-amd64 linux-arm64; do
180+
gh release download "${TAG}" -p "cloud-claude-${platform}.tar.gz.sha256"
181+
done
182+
183+
- name: Extract checksums
184+
id: shas
185+
run: |
186+
extract_sha() { awk '{print $1}' "cloud-claude-${1}.tar.gz.sha256"; }
187+
echo "darwin_amd64=$(extract_sha darwin-amd64)" >> "$GITHUB_OUTPUT"
188+
echo "darwin_arm64=$(extract_sha darwin-arm64)" >> "$GITHUB_OUTPUT"
189+
echo "linux_amd64=$(extract_sha linux-amd64)" >> "$GITHUB_OUTPUT"
190+
echo "linux_arm64=$(extract_sha linux-arm64)" >> "$GITHUB_OUTPUT"
191+
192+
- name: Generate Formula
193+
run: |
194+
bash scripts/release/generate-homebrew-formula.sh \
195+
"${{ needs.create-release.outputs.version }}" \
196+
"${{ steps.shas.outputs.darwin_amd64 }}" \
197+
"${{ steps.shas.outputs.darwin_arm64 }}" \
198+
"${{ steps.shas.outputs.linux_amd64 }}" \
199+
"${{ steps.shas.outputs.linux_arm64 }}" \
200+
> cloud-claude.rb
201+
202+
- name: Push to Homebrew tap
203+
env:
204+
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
205+
run: |
206+
git clone "https://x-access-token:${TAP_TOKEN}@github.com/ZaneL1u/homebrew-tap.git" tap-repo
207+
mkdir -p tap-repo/Formula
208+
cp cloud-claude.rb tap-repo/Formula/cloud-claude.rb
209+
cd tap-repo
210+
git config user.name "github-actions[bot]"
211+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
212+
git add Formula/cloud-claude.rb
213+
if git diff --cached --quiet; then
214+
echo "Formula unchanged, skip."
215+
else
216+
git commit -m "cloud-claude ${GITHUB_REF_NAME}"
217+
git push
218+
fi
219+
168220
publish-images:
169221
needs: create-release
170222
uses: ./.github/workflows/build-images.yml

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ web/admin/dist/
1616
docs/.vitepress/dist/
1717
docs/.vitepress/cache/
1818

19+
# Claude CLI cache
20+
.claude.json
21+
.claude/
22+
1923
# IDE
2024
.idea/
2125
*.swp

Makefile

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ export
33

44
DEV_COMPOSE := docker compose -f deploy/compose/control-plane.dev.yml
55

6-
.PHONY: dev dev-api dev-web db test test-go test-smoke build build-cli install-cli clean gateway-image up up-build down logs release
6+
.PHONY: dev dev-api dev-web db test test-go test-smoke build build-cli install-cli clean gateway-image up up-build up-rebuild up-api down logs release
77

88
# ── Development ──────────────────────────────────────────────
99

@@ -89,9 +89,17 @@ up: ## Start production stack (prefer prebuilt latest images)
8989
docker compose up -d
9090

9191
up-build: ## Start production stack from local source build
92+
docker compose -f docker-compose.yml -f docker-compose.build.yaml --profile build-only build
93+
docker compose -f docker-compose.yml -f docker-compose.build.yaml up -d --force-recreate
94+
95+
up-rebuild: ## Rebuild from scratch (no cache) and start
9296
docker compose -f docker-compose.yml -f docker-compose.build.yaml --profile build-only build --no-cache
9397
docker compose -f docker-compose.yml -f docker-compose.build.yaml up -d --force-recreate
9498

99+
up-api: ## Rebuild and restart control-plane only (fastest for backend changes)
100+
docker compose -f docker-compose.yml -f docker-compose.build.yaml build control-plane
101+
docker compose -f docker-compose.yml -f docker-compose.build.yaml up -d --force-recreate --no-deps control-plane
102+
95103
down: ## Stop production stack
96104
docker compose down
97105

cmd/cloud-claude/main.go

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,32 @@ func main() {
4848
initCmd.Flags().String("short-id", "", "用户或主机 short_id")
4949
initCmd.Flags().String("password", "", "登录密码(建议交互式输入)")
5050

51-
rootCmd.AddCommand(initCmd)
51+
envCmd := &cobra.Command{
52+
Use: "env",
53+
Short: "环境相关工具",
54+
SilenceUsage: true,
55+
SilenceErrors: true,
56+
}
57+
58+
envCheckCmd := &cobra.Command{
59+
Use: "check",
60+
Short: "检测远端容器的时区、语言、出口 IP、工具链等环境信息",
61+
SilenceUsage: true,
62+
SilenceErrors: true,
63+
RunE: runEnvCheck,
64+
}
65+
envCmd.AddCommand(envCheckCmd)
66+
67+
rootCmd.AddCommand(initCmd, envCmd)
68+
69+
// DisableFlagParsing 会阻止 cobra 识别子命令,
70+
// 在检测到已知子命令时关闭它以恢复正常路由。
71+
if len(os.Args) > 1 {
72+
switch os.Args[1] {
73+
case "init", "env", "help", "--help", "-h":
74+
rootCmd.DisableFlagParsing = false
75+
}
76+
}
5277

5378
if err := rootCmd.Execute(); err != nil {
5479
fmt.Fprintf(os.Stderr, "错误: %s\n", err)
@@ -110,6 +135,47 @@ func runInit(cmd *cobra.Command, args []string) error {
110135
return nil
111136
}
112137

138+
func runEnvCheck(cmd *cobra.Command, args []string) error {
139+
cfg, err := cloudclaude.LoadConfig()
140+
if err != nil {
141+
return err
142+
}
143+
144+
client := cloudclaude.NewEntryClient(cfg.Gateway)
145+
146+
fmt.Println("正在连接云主机...")
147+
148+
authResp, err := client.AuthenticateAndWait(
149+
cmd.Context(),
150+
cfg.ShortID,
151+
cfg.Password,
152+
func(msg string) {
153+
fmt.Printf("\r%s", msg)
154+
},
155+
)
156+
if err != nil {
157+
return fmt.Errorf("认证失败: %w", err)
158+
}
159+
160+
fmt.Println("\r正在检测远端环境...")
161+
162+
sshCfg := cloudclaude.SSHConfig{
163+
Host: authResp.SSHHost,
164+
Port: authResp.SSHPort,
165+
User: authResp.SSHUser,
166+
Password: authResp.SSHPass,
167+
}
168+
169+
result, err := cloudclaude.RunEnvCheck(sshCfg)
170+
if err != nil {
171+
return fmt.Errorf("环境检测失败: %w", err)
172+
}
173+
174+
fmt.Println()
175+
result.Print()
176+
return nil
177+
}
178+
113179
func runRoot(cmd *cobra.Command, args []string) error {
114180
if len(args) > 0 && (args[0] == "--version" || args[0] == "-v" || args[0] == "version") {
115181
fmt.Printf("cloud-claude %s\n", Version)
@@ -177,7 +243,7 @@ func runRoot(cmd *cobra.Command, args []string) error {
177243
Password: authResp.SSHPass,
178244
}
179245

180-
exitCode, err := cloudclaude.ConnectAndRunClaude(sshCfg, args, cwd)
246+
exitCode, err := cloudclaude.ConnectAndRunClaude(sshCfg, args, cwd, cfg.EffectiveProxyCommands())
181247
if err != nil {
182248
fmt.Fprintln(os.Stderr, "错误: "+err.Error())
183249
os.Exit(exitInternalError)

deploy/docker/managed-user/entrypoint.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ fi
8282

8383
RUN_USER="${CONTAINER_USER:-workspace}"
8484

85+
# 确保 sudoers 始终与实际用户名一致(修复历史容器可能残留的错误配置)
86+
echo "${RUN_USER} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/workspace
87+
chmod 0440 /etc/sudoers.d/workspace
88+
8589
mkdir -p /workspace/.ssh
8690
chown -R "${RUN_USER}:${RUN_USER}" /workspace
8791
chmod 0700 /workspace/.ssh

internal/cloudclaude/config.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,21 @@ const (
1515
filePerm = 0600
1616
)
1717

18+
var DefaultProxyCommands = []string{"git"}
19+
1820
type Config struct {
19-
Gateway string `yaml:"gateway"`
20-
ShortID string `yaml:"short_id"`
21-
Password string `yaml:"password"`
21+
Gateway string `yaml:"gateway"`
22+
ShortID string `yaml:"short_id"`
23+
Password string `yaml:"password"`
24+
ProxyCommands []string `yaml:"proxy_commands,omitempty"`
25+
}
26+
27+
// EffectiveProxyCommands 返回生效的代理命令列表。
28+
func (c *Config) EffectiveProxyCommands() []string {
29+
if len(c.ProxyCommands) > 0 {
30+
return c.ProxyCommands
31+
}
32+
return DefaultProxyCommands
2233
}
2334

2435
func (c *Config) Validate() error {

internal/cloudclaude/envcheck.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package cloudclaude
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"strings"
7+
)
8+
9+
type EnvCheckResult struct {
10+
Hostname string
11+
User string
12+
OS string
13+
Kernel string
14+
Timezone string
15+
Locale string
16+
PublicIP string
17+
DNSIP string
18+
Uptime string
19+
Memory string
20+
Disk string
21+
Claude string
22+
Fuse string
23+
SSHFS string
24+
Jq string
25+
Git string
26+
Sudo string
27+
}
28+
29+
// RunEnvCheck 通过 SSH 连接到容器并收集环境信息。
30+
func RunEnvCheck(cfg SSHConfig) (*EnvCheckResult, error) {
31+
conn, err := sshConnect(cfg)
32+
if err != nil {
33+
return nil, err
34+
}
35+
defer conn.Close()
36+
37+
run := func(cmd string) string {
38+
sess, err := conn.NewSession()
39+
if err != nil {
40+
return "(session error)"
41+
}
42+
defer sess.Close()
43+
var buf bytes.Buffer
44+
sess.Stdout = &buf
45+
if err := sess.Run(cmd); err != nil {
46+
out := strings.TrimSpace(buf.String())
47+
if out != "" {
48+
return out
49+
}
50+
return "(unavailable)"
51+
}
52+
return strings.TrimSpace(buf.String())
53+
}
54+
55+
r := &EnvCheckResult{
56+
Hostname: run("hostname"),
57+
User: run("whoami"),
58+
OS: run("cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d'\"' -f2"),
59+
Kernel: run("uname -r"),
60+
Timezone: run("cat /etc/timezone 2>/dev/null || timedatectl show -p Timezone --value 2>/dev/null || echo unknown"),
61+
Locale: run("grep '^LANG=' /etc/default/locale 2>/dev/null | cut -d= -f2 || locale 2>/dev/null | grep '^LANG=' | cut -d= -f2"),
62+
PublicIP: run("curl -s --max-time 5 https://api.ipify.org 2>/dev/null || curl -s --max-time 5 https://ifconfig.me 2>/dev/null || echo timeout"),
63+
DNSIP: run("dig +short myip.opendns.com @resolver1.opendns.com 2>/dev/null || echo N/A"),
64+
Uptime: run("uptime -p 2>/dev/null || uptime"),
65+
Memory: run("free 2>/dev/null | awk '/Mem:/{pct=int($3/$2*100); printf \"%.1fG / %.1fG (%d%%)\", $3/1048576, $2/1048576, pct}'"),
66+
Disk: run("df -h / 2>/dev/null | awk 'NR==2{printf \"%s / %s (%s)\", $3, $2, $5}'"),
67+
Claude: run("claude --version 2>/dev/null || claude-real --version 2>/dev/null || echo not found"),
68+
Fuse: run("test -c /dev/fuse && echo OK || echo MISSING"),
69+
SSHFS: run("sshfs --version 2>&1 | head -1"),
70+
Jq: run("jq --version 2>/dev/null || echo not found"),
71+
Git: run("git --version 2>/dev/null || echo not found"),
72+
Sudo: run("bash -c 'sudo -n true 2>/dev/null && echo OK || echo NO'"),
73+
}
74+
75+
return r, nil
76+
}
77+
78+
func (r *EnvCheckResult) Print() {
79+
fmt.Println("╭─────────────────────────────────────────╮")
80+
fmt.Println("│ Cloud Claude 环境检测报告 │")
81+
fmt.Println("╰─────────────────────────────────────────╯")
82+
fmt.Println()
83+
84+
section("系统信息")
85+
row("主机名", r.Hostname)
86+
row("用户", r.User)
87+
row("系统", r.OS)
88+
row("内核", r.Kernel)
89+
row("运行时间", r.Uptime)
90+
fmt.Println()
91+
92+
section("区域设置")
93+
row("时区", r.Timezone)
94+
row("语言", r.Locale)
95+
fmt.Println()
96+
97+
section("网络")
98+
row("出口 IP (HTTP)", r.PublicIP)
99+
row("出口 IP (DNS)", r.DNSIP)
100+
ipMatch := r.PublicIP == r.DNSIP
101+
if r.DNSIP == "N/A" || r.DNSIP == "(unavailable)" {
102+
row("IP 一致性", "⚠ DNS 检测不可用,无法比对")
103+
} else if ipMatch {
104+
row("IP 一致性", "✓ HTTP 与 DNS 出口一致(无泄漏)")
105+
} else {
106+
row("IP 一致性", "✗ HTTP 与 DNS 出口不一致,可能存在泄漏!")
107+
}
108+
fmt.Println()
109+
110+
section("工具链")
111+
row("Claude Code", r.Claude)
112+
row("Git", r.Git)
113+
row("jq", r.Jq)
114+
fmt.Println()
115+
116+
section("FUSE / 挂载支持")
117+
row("/dev/fuse", r.Fuse)
118+
row("sshfs", r.SSHFS)
119+
row("sudo 免密", r.Sudo)
120+
fmt.Println()
121+
122+
section("资源")
123+
row("内存", r.Memory)
124+
row("磁盘 (/)", r.Disk)
125+
}
126+
127+
func section(title string) {
128+
fmt.Printf(" ── %s ──\n", title)
129+
}
130+
131+
func row(label, value string) {
132+
fmt.Printf(" %-16s %s\n", label, value)
133+
}
134+

0 commit comments

Comments
 (0)