diff --git a/internal/cli/daemon_commands.go b/internal/cli/daemon_commands.go index 52143b06..f3f143d6 100644 --- a/internal/cli/daemon_commands.go +++ b/internal/cli/daemon_commands.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "net" neturl "net/url" "os" @@ -25,6 +26,9 @@ var ( installHTTPDaemon = urlscheme.InstallHTTPDaemon uninstallHTTPDaemon = urlscheme.UninstallHTTPDaemon getHTTPDaemonStatus = urlscheme.GetHTTPDaemonStatus + + daemonInstallJSONWriter io.Writer = os.Stdout + daemonInstallLogWriter io.Writer = os.Stderr ) type daemonServeCommandOptions struct { @@ -287,13 +291,26 @@ func defaultDaemonInstallCommandRunner(ctx context.Context, options daemonInstal ListenAddress: options.ListenAddress, }) if err != nil { + _, _ = fmt.Fprintf(daemonInstallLogWriter, "daemon install failed: %v\n", err) + _, _ = fmt.Fprintf(daemonInstallLogWriter, "remedy: run `%s daemon install --listen %s`\n", executablePath, options.ListenAddress) return err } - return encodeJSONLine(os.Stdout, map[string]any{ - "status": "ok", - "listen_address": result.ListenAddress, - "autostart_mode": result.AutostartMode, - "hosts_warning": strings.TrimSpace(result.HostsWarning), + _, _ = fmt.Fprintf(daemonInstallLogWriter, "daemon install succeeded: autostart=%s listen=%s\n", result.AutostartMode, result.ListenAddress) + if strings.TrimSpace(result.HostsWarning) != "" { + _, _ = fmt.Fprintf(daemonInstallLogWriter, "hosts warning: %s\n", strings.TrimSpace(result.HostsWarning)) + } + if result.DaemonStarted { + _, _ = fmt.Fprintf(daemonInstallLogWriter, "daemon started in background and is ready on %s\n", result.ListenAddress) + } else if strings.TrimSpace(result.DaemonStartWarning) != "" { + _, _ = fmt.Fprintf(daemonInstallLogWriter, "daemon startup warning: %s\n", strings.TrimSpace(result.DaemonStartWarning)) + } + return encodeJSONLine(daemonInstallJSONWriter, map[string]any{ + "status": "ok", + "listen_address": result.ListenAddress, + "autostart_mode": result.AutostartMode, + "hosts_warning": strings.TrimSpace(result.HostsWarning), + "daemon_started": result.DaemonStarted, + "daemon_start_warning": strings.TrimSpace(result.DaemonStartWarning), }) } diff --git a/internal/cli/daemon_commands_test.go b/internal/cli/daemon_commands_test.go index 4d9ddec2..0d2fd6d3 100644 --- a/internal/cli/daemon_commands_test.go +++ b/internal/cli/daemon_commands_test.go @@ -1,10 +1,11 @@ package cli import ( + "bytes" "context" + "encoding/json" "errors" "net/url" - "os" "strings" "testing" @@ -42,25 +43,34 @@ func TestDaemonInstallDefaultRunnerUsesCurrentExecutable(t *testing.T) { originalRunner := runDaemonInstallCommand originalResolveExecutablePath := resolveExecutablePath originalInstall := installHTTPDaemon + originalJSONWriter := daemonInstallJSONWriter + originalLogWriter := daemonInstallLogWriter t.Cleanup(func() { runDaemonInstallCommand = originalRunner }) t.Cleanup(func() { resolveExecutablePath = originalResolveExecutablePath }) t.Cleanup(func() { installHTTPDaemon = originalInstall }) + t.Cleanup(func() { daemonInstallJSONWriter = originalJSONWriter }) + t.Cleanup(func() { daemonInstallLogWriter = originalLogWriter }) runDaemonInstallCommand = defaultDaemonInstallCommandRunner resolveExecutablePath = func() (string, error) { return "/tmp/neocode", nil } + var stdout bytes.Buffer + var stderr bytes.Buffer + daemonInstallJSONWriter = &stdout + daemonInstallLogWriter = &stderr var captured urlscheme.HTTPDaemonInstallOptions installHTTPDaemon = func(options urlscheme.HTTPDaemonInstallOptions) (urlscheme.HTTPDaemonInstallResult, error) { captured = options return urlscheme.HTTPDaemonInstallResult{ - ListenAddress: options.ListenAddress, - AutostartMode: "test-mode", + ListenAddress: options.ListenAddress, + AutostartMode: "test-mode", + DaemonStarted: true, + DaemonStartWarning: "", }, nil } command := NewRootCommand() - command.SetOut(os.Stdout) command.SetArgs([]string{"daemon", "install", "--listen", "127.0.0.1:19921"}) if err := command.ExecuteContext(context.Background()); err != nil { t.Fatalf("ExecuteContext() error = %v", err) @@ -71,6 +81,57 @@ func TestDaemonInstallDefaultRunnerUsesCurrentExecutable(t *testing.T) { if captured.ListenAddress != "127.0.0.1:19921" { t.Fatalf("listen address = %q, want %q", captured.ListenAddress, "127.0.0.1:19921") } + var payload map[string]any + if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &payload); err != nil { + t.Fatalf("decode stdout json: %v", err) + } + if payload["status"] != "ok" { + t.Fatalf("status = %v, want ok", payload["status"]) + } + if payload["daemon_started"] != true { + t.Fatalf("daemon_started = %v, want true", payload["daemon_started"]) + } + if !strings.Contains(stderr.String(), "daemon install succeeded") { + t.Fatalf("stderr = %q, want success summary", stderr.String()) + } +} + +func TestDaemonInstallDefaultRunnerFailureWritesRemedy(t *testing.T) { + originalRunner := runDaemonInstallCommand + originalResolveExecutablePath := resolveExecutablePath + originalInstall := installHTTPDaemon + originalJSONWriter := daemonInstallJSONWriter + originalLogWriter := daemonInstallLogWriter + t.Cleanup(func() { runDaemonInstallCommand = originalRunner }) + t.Cleanup(func() { resolveExecutablePath = originalResolveExecutablePath }) + t.Cleanup(func() { installHTTPDaemon = originalInstall }) + t.Cleanup(func() { daemonInstallJSONWriter = originalJSONWriter }) + t.Cleanup(func() { daemonInstallLogWriter = originalLogWriter }) + + runDaemonInstallCommand = defaultDaemonInstallCommandRunner + resolveExecutablePath = func() (string, error) { + return "/tmp/neocode", nil + } + var stdout bytes.Buffer + var stderr bytes.Buffer + daemonInstallJSONWriter = &stdout + daemonInstallLogWriter = &stderr + installHTTPDaemon = func(options urlscheme.HTTPDaemonInstallOptions) (urlscheme.HTTPDaemonInstallResult, error) { + return urlscheme.HTTPDaemonInstallResult{}, errors.New("boom") + } + + command := NewRootCommand() + command.SetArgs([]string{"daemon", "install", "--listen", "127.0.0.1:19921"}) + err := command.ExecuteContext(context.Background()) + if err == nil { + t.Fatal("expected install failure") + } + if !strings.Contains(stderr.String(), "remedy: run `/tmp/neocode daemon install --listen 127.0.0.1:19921`") { + t.Fatalf("stderr = %q, want remedy command", stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("stdout should be empty on failure, got %q", stdout.String()) + } } func TestDaemonServeDoesNotExposeTokenFileFlag(t *testing.T) { diff --git a/internal/gateway/adapters/urlscheme/daemon.go b/internal/gateway/adapters/urlscheme/daemon.go index 12f2b3c1..cd3daa5f 100644 --- a/internal/gateway/adapters/urlscheme/daemon.go +++ b/internal/gateway/adapters/urlscheme/daemon.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -30,9 +31,15 @@ const ( daemonAutostartModeDesktop = "desktop_autostart" ) +const ( + daemonHTTPPageTitle = "NeoCode Daemon" + daemonHTTPPageFaviconURL = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20120%20120'%3E%3Crect%20width='120'%20height='120'%20rx='22'%20fill='%230f172a'/%3E%3Cpath%20d='M36%2032h16l32%2056H68L36%2032z'%20fill='%2338bdf8'/%3E%3Cpath%20d='M84%2032H68L36%2088h16l32-56z'%20fill='%230ea5e9'/%3E%3C/svg%3E" +) + var ( httpDaemonDispatchWakeFn = defaultHTTPDaemonDispatchWake httpDaemonGetHTTPClient = defaultHTTPDaemonHTTPClient + httpDaemonStartProcessFn = startHTTPDaemonProcess ) // HTTPDaemonServeOptions 定义 daemon serve 的启动参数。 @@ -49,9 +56,11 @@ type HTTPDaemonInstallOptions struct { // HTTPDaemonInstallResult 返回 daemon install 的结果摘要。 type HTTPDaemonInstallResult struct { - ListenAddress string `json:"listen_address"` - AutostartMode string `json:"autostart_mode"` - HostsWarning string `json:"hosts_warning,omitempty"` + ListenAddress string `json:"listen_address"` + AutostartMode string `json:"autostart_mode"` + HostsWarning string `json:"hosts_warning,omitempty"` + DaemonStarted bool `json:"daemon_started"` + DaemonStartWarning string `json:"daemon_start_warning,omitempty"` } // HTTPDaemonStatusOptions 定义 daemon status 的查询参数。 @@ -134,7 +143,12 @@ func InstallHTTPDaemon(options HTTPDaemonInstallOptions) (HTTPDaemonInstallResul AutostartMode: mode, } if hostsErr := ensureDaemonHostsAlias(); hostsErr != nil { - result.HostsWarning = hostsErr.Error() + result.HostsWarning = buildHostsAliasWarning(hostsErr) + } + if daemonStartErr := ensureHTTPDaemonProcessStarted(executablePath, listenAddress); daemonStartErr != nil { + result.DaemonStartWarning = daemonStartErr.Error() + } else { + result.DaemonStarted = true } return result, nil } @@ -220,7 +234,7 @@ func newHTTPDaemonHandler( ListenAddress: gatewayListenAddress, }) if err != nil { - writeHTTPDaemonError(writer, http.StatusInternalServerError, "dispatch failed", err.Error()) + writeHTTPDaemonDispatchError(writer, gatewayListenAddress, err) return } writeHTTPDaemonSuccess(writer, request, result) @@ -349,9 +363,51 @@ func writeHTTPDaemonError(writer http.ResponseWriter, statusCode int, title stri writer.WriteHeader(statusCode) escapedTitle := html.EscapeString(strings.TrimSpace(title)) escapedDetail := html.EscapeString(strings.TrimSpace(detail)) - _, _ = writer.Write([]byte( - "

" + escapedTitle + "

" + escapedDetail + "

", - )) + content := `

` + escapedTitle + `

` + escapedDetail + `

` + _, _ = writer.Write([]byte(buildHTTPDaemonPageHTML(escapedTitle, content, ""))) +} + +// writeHTTPDaemonDispatchError 输出 wake 派发失败页面,并附带可执行补救指引。 +func writeHTTPDaemonDispatchError(writer http.ResponseWriter, gatewayListenAddress string, err error) { + title := "Dispatch failed" + detail := strings.TrimSpace(err.Error()) + if detail == "" { + detail = "unknown dispatch error" + } + hints := []string{ + "运行 `neocode daemon status` 检查 daemon 状态。", + } + startGatewayCommand := "neocode gateway" + if trimmedGatewayListenAddress := strings.TrimSpace(gatewayListenAddress); trimmedGatewayListenAddress != "" { + startGatewayCommand += " --listen " + trimmedGatewayListenAddress + } + hints = append(hints, "若 gateway 未运行,请手动执行 `"+startGatewayCommand+"`。") + + var dispatchErr *DispatchError + if errors.As(err, &dispatchErr) { + switch dispatchErr.Code { + case ErrorCodeGatewayUnavailable: + title = "Gateway unavailable" + detail = "无法连接到 gateway,daemon 已尝试自动拉起,但当前不可达。" + if strings.Contains(strings.ToLower(dispatchErr.Message), "timed out") { + detail = "gateway 自动拉起后 10 秒内未就绪,无法继续派发。" + } + case ErrorCodeNotSupported: + title = "Terminal launch is not supported" + detail = strings.TrimSpace(dispatchErr.Message) + if detail == "" { + detail = "当前平台不支持自动拉起终端,请手动运行 neocode 续接会话。" + } + default: + if message := strings.TrimSpace(dispatchErr.Message); message != "" { + detail = message + } + } + } + if strings.TrimSpace(err.Error()) != "" { + hints = append(hints, "原始错误: "+strings.TrimSpace(err.Error())) + } + writeHTTPDaemonError(writer, http.StatusInternalServerError, title, strings.Join(hintsWithLead(detail, hints), "\n")) } // writeHTTPDaemonSuccess 输出浏览器可读的成功页面,并提供可复用的 session 链接。 @@ -362,11 +418,81 @@ func writeHTTPDaemonSuccess(writer http.ResponseWriter, request *http.Request, r sessionID := html.EscapeString(strings.TrimSpace(result.SessionID)) reusableURL := buildHTTPDaemonReusableURL(request, result.SessionID) escapedReusableURL := html.EscapeString(reusableURL) - _, _ = writer.Write([]byte( - "

OK

action=" + action + "

session_id=" + sessionID + - "

reusable_url=" + escapedReusableURL + - "

tip=后续若要续接同一会话,请使用带 session_id 的链接。

", - )) + content := `

Wake Accepted

` + + `

action=` + action + `

` + + `

session_id=` + sessionID + `

` + + `

reusable_url

` + + `` + escapedReusableURL + `` + + `
` + + `点击后自动复制 reusable_url
` + + `

后续若要续接同一会话,请使用带 session_id 的链接。

` + _, _ = writer.Write([]byte(buildHTTPDaemonPageHTML("Wake Accepted", content, daemonCopyScript()))) +} + +// buildHTTPDaemonPageHTML 渲染统一的 daemon HTML 页面骨架与基础样式。 +func buildHTTPDaemonPageHTML(title string, contentHTML string, script string) string { + escapedTitle := html.EscapeString(strings.TrimSpace(title)) + if escapedTitle == "" { + escapedTitle = daemonHTTPPageTitle + } + if strings.TrimSpace(contentHTML) == "" { + contentHTML = `

empty content

` + } + return "" + + "" + + "" + escapedTitle + " - " + daemonHTTPPageTitle + "" + + "" + + "
" + contentHTML + + "
" + script + "" +} + +// daemonHTTPPageStyle 返回 daemon 页面使用的基础视觉样式。 +func daemonHTTPPageStyle() string { + return "body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;" + + "background:radial-gradient(circle at top,#e0f2fe 0%,#f8fafc 45%,#f1f5f9 100%);" + + "font-family:'Segoe UI',Tahoma,Helvetica,Arial,sans-serif;color:#0f172a;padding:24px;}" + + ".card{width:min(760px,100%);background:#ffffff;border:1px solid #dbeafe;border-radius:18px;" + + "box-shadow:0 18px 40px rgba(15,23,42,.12);padding:28px;}" + + ".status{margin:0 0 12px;font-size:28px;line-height:1.2;}" + + ".status.ok{color:#0284c7;}.status.error{color:#b91c1c;}" + + ".detail{margin:8px 0;font-size:15px;line-height:1.6;white-space:pre-line;}" + + ".label{margin:18px 0 8px;font-weight:600;font-size:14px;color:#334155;}" + + ".link{display:block;word-break:break-all;color:#0f766e;text-decoration:none;background:#ecfeff;" + + "padding:10px 12px;border-radius:10px;border:1px solid #bae6fd;}" + + ".copy-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-top:12px;}" + + ".copy-btn{border:none;border-radius:10px;padding:10px 14px;background:#0ea5e9;color:#fff;cursor:pointer;" + + "font-weight:600;}" + + ".copy-btn:hover{background:#0284c7;}" + + ".copy-feedback{font-size:13px;color:#475569;}" + + ".tip{margin-top:14px;font-size:14px;color:#334155;}" +} + +// daemonCopyScript 返回成功页的链接复制脚本,优先使用 Clipboard API, +// 在非安全上下文(如 http://neocode)中回退到 execCommand。 +func daemonCopyScript() string { + return `` +} + +// hintsWithLead 拼装错误正文与补救建议列表。 +func hintsWithLead(detail string, hints []string) []string { + lines := []string{strings.TrimSpace(detail)} + for _, hint := range hints { + trimmedHint := strings.TrimSpace(hint) + if trimmedHint == "" { + continue + } + lines = append(lines, "- "+trimmedHint) + } + return lines } // buildHTTPDaemonReusableURL 基于当前请求地址生成包含 session_id 的可复用链接。 @@ -426,6 +552,58 @@ func probeHTTPDaemonRunning(ctx context.Context, listenAddress string) bool { return response.StatusCode == http.StatusOK } +// ensureHTTPDaemonProcessStarted 尝试在 install 完成后立刻拉起 daemon,并返回可读告警。 +func ensureHTTPDaemonProcessStarted(executablePath string, listenAddress string) error { + normalizedListenAddress := normalizeHTTPDaemonListenAddress(listenAddress) + probeCtx, cancel := context.WithTimeout(context.Background(), 1400*time.Millisecond) + alreadyRunning := probeHTTPDaemonRunning(probeCtx, normalizedListenAddress) + cancel() + if alreadyRunning { + return nil + } + + startFn := httpDaemonStartProcessFn + if startFn == nil { + startFn = startHTTPDaemonProcess + } + if err := startFn(executablePath, normalizedListenAddress); err != nil { + return fmt.Errorf("failed to start daemon in background: %w; run `%s daemon serve --listen %s` manually", err, executablePath, normalizedListenAddress) + } + + readyCtx, readyCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer readyCancel() + for { + if probeHTTPDaemonRunning(readyCtx, normalizedListenAddress) { + return nil + } + if readyCtx.Err() != nil { + return fmt.Errorf("daemon background process started but not ready within 3s; run `%s daemon serve --listen %s` manually", executablePath, normalizedListenAddress) + } + time.Sleep(120 * time.Millisecond) + } +} + +// startHTTPDaemonProcess 以后台进程方式启动 daemon serve。 +func startHTTPDaemonProcess(executablePath string, listenAddress string) error { + command := exec.Command(executablePath, "daemon", "serve", "--listen", strings.TrimSpace(listenAddress)) + command.Stdin = nil + command.Stdout = nil + command.Stderr = nil + if err := command.Start(); err != nil { + return err + } + return command.Process.Release() +} + +// buildHostsAliasWarning 构建 hosts 自动写入失败时的手动修复提示。 +func buildHostsAliasWarning(err error) string { + base := fmt.Sprintf("failed to update hosts alias automatically: %v", err) + if runtime.GOOS == "windows" { + return base + "; please run as Administrator: echo 127.0.0.1 neocode >> C:\\Windows\\System32\\drivers\\etc\\hosts" + } + return base + "; please run with sudo: sudo echo '127.0.0.1 neocode' >> /etc/hosts" +} + // ensureDaemonHostsAlias 以 best-effort 方式确保 hosts 文件存在 neocode 别名。 func ensureDaemonHostsAlias() error { hostsPath := daemonHostsFilePath() diff --git a/internal/gateway/adapters/urlscheme/daemon_test.go b/internal/gateway/adapters/urlscheme/daemon_test.go index 1817e072..1380b41e 100644 --- a/internal/gateway/adapters/urlscheme/daemon_test.go +++ b/internal/gateway/adapters/urlscheme/daemon_test.go @@ -7,6 +7,7 @@ import ( "net" "net/http" "net/http/httptest" + "runtime" "strings" "testing" @@ -144,11 +145,26 @@ func TestHTTPDaemonHandlerDispatchesIntent(t *testing.T) { if !strings.Contains(responseBody, "session_id=session-from-runtime") { t.Fatalf("response body = %q, want contains session_id", responseBody) } - if !strings.Contains(responseBody, "reusable_url=") { + if !strings.Contains(responseBody, "reusable_url") { t.Fatalf("response body = %q, want contains reusable_url", responseBody) } - if !strings.Contains(responseBody, "tip=") { - t.Fatalf("response body = %q, want contains tip", responseBody) + if !strings.Contains(responseBody, "copy-reusable-btn") { + t.Fatalf("response body = %q, want contains copy button", responseBody) + } + if !strings.Contains(responseBody, "navigator.clipboard.writeText") { + t.Fatalf("response body = %q, want contains copy script", responseBody) + } + if !strings.Contains(responseBody, "name=\"viewport\"") { + t.Fatalf("response body = %q, want contains viewport meta", responseBody) + } + if !strings.Contains(responseBody, "Wake Accepted - NeoCode Daemon") { + t.Fatalf("response body = %q, want contains title", responseBody) + } + if !strings.Contains(responseBody, "rel=\"icon\"") { + t.Fatalf("response body = %q, want contains favicon", responseBody) + } + if !strings.Contains(responseBody, ".copy-btn") { + t.Fatalf("response body = %q, want contains css", responseBody) } if captured.Intent.Action != protocol.WakeActionRun { t.Fatalf("captured action = %q, want %q", captured.Intent.Action, protocol.WakeActionRun) @@ -161,6 +177,53 @@ func TestHTTPDaemonHandlerDispatchesIntent(t *testing.T) { } } +func TestHTTPDaemonHandlerDispatchErrorIsFriendly(t *testing.T) { + handler := newHTTPDaemonHandler( + func(context.Context, daemonWakeDispatchRequest) (daemonWakeDispatchResult, error) { + return daemonWakeDispatchResult{}, newDispatchError( + ErrorCodeGatewayUnavailable, + "gateway auto-start timed out after 10s", + ) + }, + "/tmp/gateway.sock", + ) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "http://neocode:18921/run?prompt=hello", http.NoBody) + request.Host = "neocode:18921" + handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusInternalServerError) + } + responseBody := recorder.Body.String() + if !strings.Contains(responseBody, "Gateway unavailable") { + t.Fatalf("response body = %q, want gateway unavailable title", responseBody) + } + if !strings.Contains(responseBody, "10 秒内未就绪") { + t.Fatalf("response body = %q, want startup timeout hint", responseBody) + } + if !strings.Contains(responseBody, "neocode daemon status") { + t.Fatalf("response body = %q, want daemon status remedy", responseBody) + } + if !strings.Contains(responseBody, "neocode gateway --listen /tmp/gateway.sock") { + t.Fatalf("response body = %q, want gateway remedy command", responseBody) + } +} + +func TestBuildHostsAliasWarningIncludesManualCommands(t *testing.T) { + warning := buildHostsAliasWarning(errors.New("permission denied")) + if runtime.GOOS == "windows" { + if !strings.Contains(warning, `echo 127.0.0.1 neocode >> C:\Windows\System32\drivers\etc\hosts`) { + t.Fatalf("warning = %q, want windows hosts command", warning) + } + return + } + if !strings.Contains(warning, `sudo echo '127.0.0.1 neocode' >> /etc/hosts`) { + t.Fatalf("warning = %q, want unix hosts command", warning) + } +} + func TestBuildHTTPDaemonReusableURL(t *testing.T) { request := httptest.NewRequest(http.MethodGet, "http://neocode:18921/review?path=README.md&workdir=/repo", http.NoBody) reusableURL := buildHTTPDaemonReusableURL(request, "session-42") diff --git a/internal/gateway/adapters/urlscheme/dispatcher.go b/internal/gateway/adapters/urlscheme/dispatcher.go index 8a517380..dd98994e 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher.go +++ b/internal/gateway/adapters/urlscheme/dispatcher.go @@ -32,7 +32,7 @@ const ( // defaultDispatchIOTimeout 是 URL 派发读写超时时间。 defaultDispatchIOTimeout = 10 * time.Second // defaultGatewayLaunchTimeout 是自动拉起网关后的就绪等待时间。 - defaultGatewayLaunchTimeout = 3 * time.Second + defaultGatewayLaunchTimeout = 10 * time.Second // defaultGatewayLaunchRetryInterval 是拉起后拨号重试间隔。 defaultGatewayLaunchRetryInterval = 100 * time.Millisecond wakeReviewStartupPromptTemplate = "请审查文件 %s" @@ -416,14 +416,14 @@ func (d *Dispatcher) dialGatewayWithFallback( if launchErr := d.launchGateway(ctx, listenAddress, requestID, authToken); launchErr != nil { return nil, newDispatchError( ErrorCodeGatewayUnavailable, - fmt.Sprintf("dial gateway failed: %v; launch gateway failed: %v", err, launchErr), + fmt.Sprintf("dial gateway failed: %v; automatic gateway startup failed: %v", err, launchErr), ) } retriedConnection, retryErr := dialFn(listenAddress) if retryErr != nil { return nil, newDispatchError( ErrorCodeGatewayUnavailable, - fmt.Sprintf("dial gateway failed after single fallback: %v", retryErr), + fmt.Sprintf("dial gateway failed after automatic startup: %v", retryErr), ) } return retriedConnection, nil @@ -566,7 +566,7 @@ func (d *Dispatcher) waitGatewayReady(ctx context.Context, listenAddress string) return nil } if !nowFn().Before(deadline) { - return fmt.Errorf("gateway did not become reachable within %s", effectiveTimeout) + return fmt.Errorf("gateway auto-start timed out after %s", effectiveTimeout) } sleepFn(defaultGatewayLaunchRetryInterval) } diff --git a/internal/gateway/adapters/urlscheme/dispatcher_test.go b/internal/gateway/adapters/urlscheme/dispatcher_test.go index 6c9c7f71..0e364431 100644 --- a/internal/gateway/adapters/urlscheme/dispatcher_test.go +++ b/internal/gateway/adapters/urlscheme/dispatcher_test.go @@ -7,6 +7,7 @@ import ( "net" "strings" "testing" + "time" "neo-code/internal/gateway" "neo-code/internal/gateway/protocol" @@ -510,3 +511,34 @@ func TestDispatchWakeIntentReturnsGatewayFrameError(t *testing.T) { t.Fatalf("error code = %q, want %q", dispatchErr.Code, gateway.ErrorCodeInvalidAction.String()) } } + +func TestGatewayLaunchTimeoutIsTenSeconds(t *testing.T) { + if defaultGatewayLaunchTimeout != 10*time.Second { + t.Fatalf("defaultGatewayLaunchTimeout = %s, want %s", defaultGatewayLaunchTimeout, 10*time.Second) + } +} + +func TestWaitGatewayReadyTimeoutMessage(t *testing.T) { + dispatcher := NewDispatcher() + start := time.Unix(1700000000, 0) + nowCalls := 0 + dispatcher.nowFn = func() time.Time { + nowCalls++ + if nowCalls <= 2 { + return start + } + return start.Add(11 * time.Second) + } + dispatcher.sleepFn = func(time.Duration) {} + dispatcher.dialFn = func(string) (net.Conn, error) { + return nil, errors.New("dial failed") + } + + err := dispatcher.waitGatewayReady(context.Background(), "inmemory") + if err == nil { + t.Fatal("expected wait timeout") + } + if !strings.Contains(err.Error(), "gateway auto-start timed out after") { + t.Fatalf("error = %v, want timeout message", err) + } +} diff --git a/internal/gateway/launcher/launcher_test.go b/internal/gateway/launcher/launcher_test.go index 6e53d35c..74795587 100644 --- a/internal/gateway/launcher/launcher_test.go +++ b/internal/gateway/launcher/launcher_test.go @@ -284,7 +284,7 @@ func TestLaunchTerminalBranches(t *testing.T) { }) t.Run("unsupported platform returns sentinel", func(t *testing.T) { - if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { + if runtime.GOOS == "windows" || runtime.GOOS == "darwin" || runtime.GOOS == "linux" { t.Skip("unsupported branch only applies to non-windows/non-darwin") } err := LaunchTerminal("neocode --session session-1") diff --git a/internal/gateway/launcher/terminal_linux.go b/internal/gateway/launcher/terminal_linux.go index 4f1b7feb..71f4a439 100644 --- a/internal/gateway/launcher/terminal_linux.go +++ b/internal/gateway/launcher/terminal_linux.go @@ -2,9 +2,36 @@ package launcher -import "fmt" +import ( + "fmt" + "os/exec" + "runtime" +) -// launchTerminal 在 Linux/其他平台先返回未实现错误,后续再接入具体终端适配。 +var ( + lookupPathForTerminalLinux = exec.LookPath + execCommandForTerminalLinux = exec.Command +) + +// launchTerminal 在 Linux 上优先尝试 gnome-terminal,再回退到 x-terminal-emulator。 func launchTerminal(command string) error { - return fmt.Errorf("%w: run `%s` manually in your terminal", ErrTerminalUnsupported, command) + if runtime.GOOS != "linux" { + return fmt.Errorf("%w: run `%s` manually in your terminal", ErrTerminalUnsupported, command) + } + if err := launchWithLinuxTerminal("gnome-terminal", []string{"--", "bash", "-lc", command}); err == nil { + return nil + } + if err := launchWithLinuxTerminal("x-terminal-emulator", []string{"-e", "bash", "-lc", command}); err == nil { + return nil + } + return fmt.Errorf("%w: install gnome-terminal/x-terminal-emulator or run `%s` manually", ErrTerminalUnsupported, command) +} + +// launchWithLinuxTerminal 通过指定终端模拟器拉起命令。 +func launchWithLinuxTerminal(binary string, args []string) error { + if _, err := lookupPathForTerminalLinux(binary); err != nil { + return err + } + cmd := execCommandForTerminalLinux(binary, args...) + return cmd.Run() } diff --git a/internal/gateway/launcher/terminal_linux_test.go b/internal/gateway/launcher/terminal_linux_test.go new file mode 100644 index 00000000..f05cd3a4 --- /dev/null +++ b/internal/gateway/launcher/terminal_linux_test.go @@ -0,0 +1,96 @@ +//go:build !windows && !darwin + +package launcher + +import ( + "errors" + "os/exec" + "runtime" + "testing" +) + +func TestLaunchTerminalLinuxUsesGnomeFirst(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("linux-specific behavior") + } + originalLookup := lookupPathForTerminalLinux + originalExec := execCommandForTerminalLinux + t.Cleanup(func() { + lookupPathForTerminalLinux = originalLookup + execCommandForTerminalLinux = originalExec + }) + + lookupPathForTerminalLinux = func(binary string) (string, error) { + if binary == "gnome-terminal" { + return "/usr/bin/gnome-terminal", nil + } + return "", errors.New("not found") + } + called := make([]string, 0, 1) + execCommandForTerminalLinux = func(name string, args ...string) *exec.Cmd { + called = append(called, name) + return exec.Command("sh", "-c", "exit 0") + } + + if err := launchTerminal("neocode --session s-1"); err != nil { + t.Fatalf("launchTerminal() error = %v", err) + } + if len(called) != 1 || called[0] != "gnome-terminal" { + t.Fatalf("called = %#v, want [gnome-terminal]", called) + } +} + +func TestLaunchTerminalLinuxFallsBackToXTerminalEmulator(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("linux-specific behavior") + } + originalLookup := lookupPathForTerminalLinux + originalExec := execCommandForTerminalLinux + t.Cleanup(func() { + lookupPathForTerminalLinux = originalLookup + execCommandForTerminalLinux = originalExec + }) + + lookupPathForTerminalLinux = func(binary string) (string, error) { + if binary == "x-terminal-emulator" { + return "/usr/bin/x-terminal-emulator", nil + } + return "", errors.New("not found") + } + called := make([]string, 0, 2) + execCommandForTerminalLinux = func(name string, args ...string) *exec.Cmd { + called = append(called, name) + return exec.Command("sh", "-c", "exit 0") + } + + if err := launchTerminal("neocode --session s-2"); err != nil { + t.Fatalf("launchTerminal() error = %v", err) + } + if len(called) != 1 || called[0] != "x-terminal-emulator" { + t.Fatalf("called = %#v, want [x-terminal-emulator]", called) + } +} + +func TestLaunchTerminalLinuxUnsupportedWhenNoTerminal(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("linux-specific behavior") + } + originalLookup := lookupPathForTerminalLinux + originalExec := execCommandForTerminalLinux + t.Cleanup(func() { + lookupPathForTerminalLinux = originalLookup + execCommandForTerminalLinux = originalExec + }) + + lookupPathForTerminalLinux = func(binary string) (string, error) { + return "", errors.New("not found") + } + execCommandForTerminalLinux = func(name string, args ...string) *exec.Cmd { + return exec.Command("sh", "-c", "exit 0") + } + + err := launchTerminal("neocode --session s-3") + if !errors.Is(err, ErrTerminalUnsupported) { + t.Fatalf("error = %v, want wrapped %v", err, ErrTerminalUnsupported) + } +} diff --git a/internal/gateway/launcher/terminal_windows.go b/internal/gateway/launcher/terminal_windows.go index a90b25ab..bab04d81 100644 --- a/internal/gateway/launcher/terminal_windows.go +++ b/internal/gateway/launcher/terminal_windows.go @@ -5,16 +5,22 @@ package launcher import ( "fmt" "os/exec" - "strings" ) -// launchTerminal 在 Windows 上通过 `cmd /c start` 拉起独立终端窗口执行命令。 +var ( + lookupPathForTerminalWindows = exec.LookPath + execCommandForTerminalWindows = exec.Command +) + +// launchTerminal 在 Windows 上优先使用 Windows Terminal,失败时回退 cmd /c start。 func launchTerminal(command string) error { - args := append([]string{"/c", "start"}, strings.Fields(command)...) - if len(args) <= 2 { - return fmt.Errorf("empty terminal command") + if _, err := lookupPathForTerminalWindows("wt.exe"); err == nil { + wtCommand := execCommandForTerminalWindows("wt.exe", "new-tab", "cmd", "/k", command) + if runErr := wtCommand.Run(); runErr == nil { + return nil + } } - cmd := exec.Command("cmd", args...) + cmd := execCommandForTerminalWindows("cmd", "/c", "start", "", "cmd", "/k", command) if err := cmd.Run(); err != nil { return fmt.Errorf("launch terminal on windows: %w", err) } diff --git a/internal/gateway/launcher/terminal_windows_test.go b/internal/gateway/launcher/terminal_windows_test.go new file mode 100644 index 00000000..62ab4874 --- /dev/null +++ b/internal/gateway/launcher/terminal_windows_test.go @@ -0,0 +1,108 @@ +//go:build windows + +package launcher + +import ( + "errors" + "os/exec" + "reflect" + "testing" +) + +func TestLaunchTerminalWindowsPrefersWT(t *testing.T) { + originalLookup := lookupPathForTerminalWindows + originalExec := execCommandForTerminalWindows + t.Cleanup(func() { + lookupPathForTerminalWindows = originalLookup + execCommandForTerminalWindows = originalExec + }) + + lookupPathForTerminalWindows = func(file string) (string, error) { + if file == "wt.exe" { + return `C:\\Windows\\System32\\wt.exe`, nil + } + return "", errors.New("unexpected binary") + } + + called := make([][]string, 0, 2) + execCommandForTerminalWindows = func(name string, args ...string) *exec.Cmd { + called = append(called, append([]string{name}, args...)) + return exec.Command("cmd", "/c", "exit", "0") + } + + if err := launchTerminal("neocode --session s-1"); err != nil { + t.Fatalf("launchTerminal() error = %v", err) + } + if len(called) != 1 { + t.Fatalf("call count = %d, want 1", len(called)) + } + want := []string{"wt.exe", "new-tab", "cmd", "/k", "neocode --session s-1"} + if !reflect.DeepEqual(called[0], want) { + t.Fatalf("called[0] = %#v, want %#v", called[0], want) + } +} + +func TestLaunchTerminalWindowsFallsBackToCmdStart(t *testing.T) { + originalLookup := lookupPathForTerminalWindows + originalExec := execCommandForTerminalWindows + t.Cleanup(func() { + lookupPathForTerminalWindows = originalLookup + execCommandForTerminalWindows = originalExec + }) + + lookupPathForTerminalWindows = func(file string) (string, error) { + return "", errors.New("not found") + } + + called := make([][]string, 0, 2) + execCommandForTerminalWindows = func(name string, args ...string) *exec.Cmd { + called = append(called, append([]string{name}, args...)) + return exec.Command("cmd", "/c", "exit", "0") + } + + if err := launchTerminal("neocode --session s-2"); err != nil { + t.Fatalf("launchTerminal() error = %v", err) + } + if len(called) != 1 { + t.Fatalf("call count = %d, want 1", len(called)) + } + want := []string{"cmd", "/c", "start", "", "cmd", "/k", "neocode --session s-2"} + if !reflect.DeepEqual(called[0], want) { + t.Fatalf("called[0] = %#v, want %#v", called[0], want) + } +} + +func TestLaunchTerminalWindowsFallsBackWhenWTFails(t *testing.T) { + originalLookup := lookupPathForTerminalWindows + originalExec := execCommandForTerminalWindows + t.Cleanup(func() { + lookupPathForTerminalWindows = originalLookup + execCommandForTerminalWindows = originalExec + }) + + lookupPathForTerminalWindows = func(file string) (string, error) { + if file == "wt.exe" { + return `C:\\Windows\\System32\\wt.exe`, nil + } + return "", errors.New("not found") + } + + called := make([][]string, 0, 3) + execCommandForTerminalWindows = func(name string, args ...string) *exec.Cmd { + called = append(called, append([]string{name}, args...)) + if name == "wt.exe" { + return exec.Command("cmd", "/c", "exit", "1") + } + return exec.Command("cmd", "/c", "exit", "0") + } + + if err := launchTerminal("neocode --session s-3"); err != nil { + t.Fatalf("launchTerminal() error = %v", err) + } + if len(called) != 2 { + t.Fatalf("call count = %d, want 2", len(called)) + } + if called[0][0] != "wt.exe" || called[1][0] != "cmd" { + t.Fatalf("call order = %#v, want wt.exe then cmd", called) + } +} diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d67e5a95..e0d690b9 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -22,6 +22,13 @@ switch ($Flavor) { } } +function Write-FullInstallRemedy { + Write-Warning "Remedy commands:" + Write-Warning " `"$env:LOCALAPPDATA\\NeoCode\\neocode.exe`" daemon install" + Write-Warning " echo 127.0.0.1 neocode >> C:\\Windows\\System32\\drivers\\etc\\hosts" + Write-Warning " `"$env:LOCALAPPDATA\\NeoCode\\neocode.exe`" daemon status" +} + # 1. 识别物理架构(优先考虑 64 位重定向环境) $RawArch = $env:PROCESSOR_ARCHITEW6432 if ([string]::IsNullOrWhiteSpace($RawArch)) { @@ -122,11 +129,19 @@ try { } catch { Write-Warning "Failed to install HTTP daemon autostart automatically." - Write-Warning "Run '$NeoCodeExecutablePath daemon install' manually after installation." + Write-FullInstallRemedy } } Write-Host "Installed $BinaryName ($Flavor) from $LatestTag." -ForegroundColor Green } +catch { + Write-Error "Installation failed: $($_.Exception.Message)" + Write-Warning "Retry command: powershell -ExecutionPolicy Bypass -File scripts\\install.ps1 -Flavor $Flavor" + if ($Flavor -eq "full") { + Write-FullInstallRemedy + } + throw +} finally { if (Test-Path $TempDir) { Remove-Item -Path $TempDir -Recurse -Force diff --git a/scripts/install.sh b/scripts/install.sh index 26e5093d..c6a8c1d9 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -7,6 +7,15 @@ DEFAULT_FLAVOR="full" flavor="$DEFAULT_FLAVOR" dry_run=0 +print_full_install_remedy() { + cat >&2 <<'REMEDY' +Remedy commands: + /usr/local/bin/neocode daemon install + sudo echo '127.0.0.1 neocode' >> /etc/hosts + /usr/local/bin/neocode daemon status +REMEDY +} + usage() { cat <<'USAGE' Usage: install.sh [--flavor full|gateway] [--dry-run] @@ -59,6 +68,8 @@ case "$flavor" in ;; esac +trap 'status=$?; if [[ $status -ne 0 ]]; then echo "Installation failed (exit ${status})." >&2; echo "Retry: bash scripts/install.sh --flavor ${flavor}" >&2; if [[ "${flavor}" == "full" ]]; then print_full_install_remedy; fi; fi' ERR + os="$(uname -s)" arch="$(uname -m)" @@ -152,6 +163,6 @@ if [[ "${flavor}" == "full" ]]; then echo "Installing HTTP daemon autostart..." if ! /usr/local/bin/neocode daemon install >/dev/null 2>&1; then echo "Warning: failed to install HTTP daemon autostart automatically." >&2 - echo "Run '/usr/local/bin/neocode daemon install' manually after installation." >&2 + print_full_install_remedy fi -fi \ No newline at end of file +fi