Skip to content

Commit fd50937

Browse files
committed
fix:发布版本neocode web会自动构建依赖
1 parent 39a376d commit fd50937

6 files changed

Lines changed: 246 additions & 20 deletions

File tree

.goreleaser.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,18 @@ archives:
5252
{{- else if eq .Arch "386" }}i386
5353
{{- else }}{{ .Arch }}{{ end }}
5454
{{- if .Arm }}v{{ .Arm }}{{ end }}
55+
files:
56+
- web/package.json
57+
- web/package-lock.json
58+
- web/index.html
59+
- web/components.json
60+
- web/tsconfig.json
61+
- web/tsconfig.app.json
62+
- web/tsconfig.node.json
63+
- web/vite.config.ts
64+
- web/src/**/*
65+
- web/scripts/**/*
66+
- web/vite-plugins/**/*
5567

5668
- id: neocode-gateway
5769
ids:

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,13 @@ $env:OPENAI_API_KEY = "your_key_here"
110110
neocode --workdir /path/to/your/project
111111
```
112112

113+
如果你希望使用浏览器 Web UI,可以直接运行:
114+
```bash
115+
neocode web
116+
```
117+
118+
标签发布版会在缺少 `web/dist` 时自动使用发布包内的 `web/` 源码执行 `npm install``npm run build`。这要求用户机器已安装 Node.js 和 npm;如果你使用源码仓库运行,也保留相同的自动构建行为。
119+
113120
### 4. 常用命令
114121

115122
```text

internal/cli/web_command.go

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,24 +21,30 @@ import (
2121
"neo-code/internal/webassets"
2222
)
2323

24+
var (
25+
webCommandStartGatewayServer = startGatewayServer
26+
webCommandBuildFrontend = buildFrontend
27+
webCommandLookPath = exec.LookPath
28+
)
29+
2430
type webCommandOptions struct {
25-
HTTPAddress string
26-
LogLevel string
27-
StaticDir string
28-
OpenBrowser bool
29-
SkipBuild bool
30-
Workdir string
31-
TokenFile string
31+
HTTPAddress string
32+
LogLevel string
33+
StaticDir string
34+
OpenBrowser bool
35+
SkipBuild bool
36+
Workdir string
37+
TokenFile string
3238
}
3339

3440
// newWebCommand 创建并返回根命令下的 web 子命令,负责构建前端并启动带 Web UI 的 Gateway。
3541
func newWebCommand() *cobra.Command {
3642
options := &webCommandOptions{}
3743

3844
cmd := &cobra.Command{
39-
Use: "web",
40-
Short: "Start NeoCode with Web UI",
41-
Long: "Build frontend assets (if needed) and start the gateway with an integrated web UI.\nOpen http://127.0.0.1:8080 in your browser to use the interactive coding agent.",
45+
Use: "web",
46+
Short: "Start NeoCode with Web UI",
47+
Long: "Build frontend assets (if needed) and start the gateway with an integrated web UI.\nOpen http://127.0.0.1:8080 in your browser to use the interactive coding agent.",
4248
SilenceUsage: true,
4349
Args: cobra.NoArgs,
4450
RunE: func(cmd *cobra.Command, args []string) error {
@@ -88,12 +94,17 @@ func runWebCommand(ctx context.Context, options webCommandOptions) error {
8894
webDir := findWebSourceDir()
8995
if webDir == "" {
9096
if err != nil {
91-
return fmt.Errorf("frontend not found: %w", err)
97+
return fmt.Errorf(
98+
"frontend assets unavailable: %w; release packages must include the web/ source directory, or source builds must run from the project root or use --static-dir",
99+
err,
100+
)
92101
}
93-
return fmt.Errorf("web source directory not found; run from project root or set --static-dir")
102+
return fmt.Errorf(
103+
"web source directory not found; release packages must include web/, or source builds must run from project root or set --static-dir",
104+
)
94105
}
95-
if buildErr := buildFrontend(webDir, logger); buildErr != nil {
96-
return fmt.Errorf("frontend build failed: %w", buildErr)
106+
if buildErr := webCommandBuildFrontend(webDir, logger); buildErr != nil {
107+
return fmt.Errorf("frontend build failed on this machine after detecting bundled web source: %w", buildErr)
97108
}
98109
// 构建后重新解析
99110
staticDir, err = resolveWebStaticDir(options.StaticDir)
@@ -131,7 +142,7 @@ func runWebCommand(ctx context.Context, options webCommandOptions) error {
131142
}
132143
}
133144

134-
return startGatewayServer(ctx, gatewayOpts, staticDir, staticFileFS, onNetworkReady)
145+
return webCommandStartGatewayServer(ctx, gatewayOpts, staticDir, staticFileFS, onNetworkReady)
135146
}
136147

137148
// resolveWebStaticDir 按 --static-dir → <cwd>/web/dist → <exe_dir>/web/dist 顺序查找前端静态文件。
@@ -146,7 +157,7 @@ func resolveWebStaticDir(override string) (string, error) {
146157
}
147158

148159
// 相对于可执行文件(适用于安装的二进制)
149-
if exe, err := os.Executable(); err == nil {
160+
if exe, err := resolveExecutablePath(); err == nil {
150161
exeDir := filepath.Dir(exe)
151162
if dir, err := validateStaticDir(filepath.Join(exeDir, "web", "dist")); err == nil {
152163
return dir, nil
@@ -180,7 +191,7 @@ func findWebSourceDir() string {
180191
candidates := []string{
181192
filepath.Join(".", "web"),
182193
}
183-
if exe, err := os.Executable(); err == nil {
194+
if exe, err := resolveExecutablePath(); err == nil {
184195
exeDir := filepath.Dir(exe)
185196
candidates = append(candidates,
186197
filepath.Join(exeDir, "web"),
@@ -270,9 +281,11 @@ func findNPMBinary() (string, error) {
270281
if runtime.GOOS == "windows" {
271282
name = "npm.cmd"
272283
}
273-
path, err := exec.LookPath(name)
284+
path, err := webCommandLookPath(name)
274285
if err != nil {
275-
return "", fmt.Errorf("npm not found on PATH; install Node.js to build the frontend, or use --static-dir to specify pre-built assets")
286+
return "", fmt.Errorf(
287+
"npm not found on PATH; install Node.js and npm on this machine so `neocode web` can build the bundled frontend automatically, or use --static-dir to specify pre-built assets",
288+
)
276289
}
277290
return path, nil
278291
}

internal/cli/web_command_test.go

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"errors"
6+
"io/fs"
7+
"log"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
"testing"
12+
)
13+
14+
// writeWebCommandTestFile 写入 web 命令测试所需的最小文件内容,避免各测试重复拼装目录。
15+
func writeWebCommandTestFile(t *testing.T, path, content string) {
16+
t.Helper()
17+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
18+
t.Fatalf("mkdir %s: %v", path, err)
19+
}
20+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
21+
t.Fatalf("write %s: %v", path, err)
22+
}
23+
}
24+
25+
// chdirForWebCommandTest 切换当前工作目录,并在测试结束后恢复。
26+
func chdirForWebCommandTest(t *testing.T, dir string) {
27+
t.Helper()
28+
original, err := os.Getwd()
29+
if err != nil {
30+
t.Fatalf("getwd: %v", err)
31+
}
32+
if err := os.Chdir(dir); err != nil {
33+
t.Fatalf("chdir %s: %v", dir, err)
34+
}
35+
t.Cleanup(func() {
36+
if err := os.Chdir(original); err != nil {
37+
t.Fatalf("restore cwd: %v", err)
38+
}
39+
})
40+
}
41+
42+
// stubResolveExecutablePath 替换可执行文件路径解析,便于覆盖发布包布局分支。
43+
func stubResolveExecutablePath(t *testing.T, fn func() (string, error)) {
44+
t.Helper()
45+
original := resolveExecutablePath
46+
resolveExecutablePath = fn
47+
t.Cleanup(func() {
48+
resolveExecutablePath = original
49+
})
50+
}
51+
52+
// stubWebCommandHooks 替换 web 命令测试中的可注入执行点,并在结束后恢复。
53+
func stubWebCommandHooks(
54+
t *testing.T,
55+
startGateway func(context.Context, gatewayCommandOptions, string, fs.FS, func(string)) error,
56+
build func(string, *log.Logger) error,
57+
lookPath func(string) (string, error),
58+
) {
59+
t.Helper()
60+
originalStart := webCommandStartGatewayServer
61+
originalBuild := webCommandBuildFrontend
62+
originalLookPath := webCommandLookPath
63+
if startGateway != nil {
64+
webCommandStartGatewayServer = startGateway
65+
}
66+
if build != nil {
67+
webCommandBuildFrontend = build
68+
}
69+
if lookPath != nil {
70+
webCommandLookPath = lookPath
71+
}
72+
t.Cleanup(func() {
73+
webCommandStartGatewayServer = originalStart
74+
webCommandBuildFrontend = originalBuild
75+
webCommandLookPath = originalLookPath
76+
})
77+
}
78+
79+
func TestFindWebSourceDirUsesCurrentWorkdir(t *testing.T) {
80+
tempDir := t.TempDir()
81+
chdirForWebCommandTest(t, tempDir)
82+
stubResolveExecutablePath(t, func() (string, error) {
83+
return "", errors.New("skip executable lookup")
84+
})
85+
86+
writeWebCommandTestFile(t, filepath.Join(tempDir, "web", "package.json"), "{}")
87+
88+
got := findWebSourceDir()
89+
want := filepath.Join(tempDir, "web")
90+
if got != want {
91+
t.Fatalf("findWebSourceDir() = %q, want %q", got, want)
92+
}
93+
}
94+
95+
func TestFindWebSourceDirFallsBackToExecutableDir(t *testing.T) {
96+
tempDir := t.TempDir()
97+
chdirForWebCommandTest(t, tempDir)
98+
99+
releaseDir := filepath.Join(tempDir, "release")
100+
writeWebCommandTestFile(t, filepath.Join(releaseDir, "web", "package.json"), "{}")
101+
stubResolveExecutablePath(t, func() (string, error) {
102+
return filepath.Join(releaseDir, "neocode.exe"), nil
103+
})
104+
105+
got := findWebSourceDir()
106+
want := filepath.Join(releaseDir, "web")
107+
if got != want {
108+
t.Fatalf("findWebSourceDir() = %q, want %q", got, want)
109+
}
110+
}
111+
112+
func TestResolveWebStaticDirFallsBackToExecutableDir(t *testing.T) {
113+
tempDir := t.TempDir()
114+
chdirForWebCommandTest(t, tempDir)
115+
116+
releaseDir := filepath.Join(tempDir, "release")
117+
writeWebCommandTestFile(t, filepath.Join(releaseDir, "web", "dist", "index.html"), "<html></html>")
118+
stubResolveExecutablePath(t, func() (string, error) {
119+
return filepath.Join(releaseDir, "neocode.exe"), nil
120+
})
121+
122+
got, err := resolveWebStaticDir("")
123+
if err != nil {
124+
t.Fatalf("resolveWebStaticDir returned error: %v", err)
125+
}
126+
want := filepath.Join(releaseDir, "web", "dist")
127+
if got != want {
128+
t.Fatalf("resolveWebStaticDir() = %q, want %q", got, want)
129+
}
130+
}
131+
132+
func TestFindNPMBinaryMissingMessage(t *testing.T) {
133+
stubWebCommandHooks(t, nil, nil, func(string) (string, error) {
134+
return "", errors.New("not found")
135+
})
136+
137+
_, err := findNPMBinary()
138+
if err == nil {
139+
t.Fatal("findNPMBinary() error = nil, want error")
140+
}
141+
message := err.Error()
142+
if !strings.Contains(message, "Node.js and npm") {
143+
t.Fatalf("findNPMBinary() error = %q, want Node.js/npm guidance", message)
144+
}
145+
if !strings.Contains(message, "`neocode web`") {
146+
t.Fatalf("findNPMBinary() error = %q, want neocode web guidance", message)
147+
}
148+
}
149+
150+
func TestRunWebCommandBuildsFrontendWhenDistMissing(t *testing.T) {
151+
tempDir := t.TempDir()
152+
chdirForWebCommandTest(t, tempDir)
153+
writeWebCommandTestFile(t, filepath.Join(tempDir, "web", "package.json"), "{}")
154+
155+
buildCalled := false
156+
var capturedStaticDir string
157+
sentinelErr := errors.New("stop after start")
158+
stubWebCommandHooks(
159+
t,
160+
func(_ context.Context, _ gatewayCommandOptions, staticDir string, _ fs.FS, _ func(string)) error {
161+
capturedStaticDir = staticDir
162+
return sentinelErr
163+
},
164+
func(webDir string, _ *log.Logger) error {
165+
buildCalled = true
166+
writeWebCommandTestFile(t, filepath.Join(webDir, "dist", "index.html"), "<html></html>")
167+
return nil
168+
},
169+
nil,
170+
)
171+
172+
err := runWebCommand(context.Background(), webCommandOptions{
173+
HTTPAddress: "127.0.0.1:8080",
174+
LogLevel: "info",
175+
OpenBrowser: false,
176+
Workdir: tempDir,
177+
})
178+
if !errors.Is(err, sentinelErr) {
179+
t.Fatalf("runWebCommand() error = %v, want sentinel error %v", err, sentinelErr)
180+
}
181+
if !buildCalled {
182+
t.Fatal("runWebCommand() did not invoke frontend build when dist was missing")
183+
}
184+
wantStaticDir := filepath.Join(tempDir, "web", "dist")
185+
if capturedStaticDir != wantStaticDir {
186+
t.Fatalf("startGatewayServer staticDir = %q, want %q", capturedStaticDir, wantStaticDir)
187+
}
188+
}

www/guide/install.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ neocode --workdir /path/to/your/project
7373
neocode web
7474
```
7575

76+
标签发布版执行 `neocode web` 时,如果本地还没有 `web/dist`,会自动使用发布包内的 `web/` 源码执行 `npm install``npm run build`,然后启动 Web UI。该流程要求当前机器已安装 Node.js 和 npm。
77+
7678
## 5. 第一次对话
7779

7880
可以先让 NeoCode 读项目结构:
@@ -116,6 +118,8 @@ go build ./...
116118
go run ./cmd/neocode
117119
```
118120

121+
如果你希望从源码仓库直接验证 Web UI,也可以运行 `go run ./cmd/neocode web`。当 `web/dist` 缺失时,命令会自动尝试构建前端;若构建机没有 Node.js/npm,会直接报出依赖缺失提示。
122+
119123
如果你只想稳定使用,优先使用一键安装方式。源码构建更适合阅读代码、调试功能或参与开发。
120124

121125
## 下一步

www/guide/web-ui.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,15 @@ neocode web
1414
```
1515

1616
启动后浏览器会自动打开 `http://127.0.0.1:8080`。如果端口被占用,会自动尝试 8081 ~ 8090。
17+
如果当前目录或发布包内存在 `web/` 源码但还没有 `web/dist`,命令会自动执行 `npm install``npm run build`。标签发布版使用该能力时,用户机器必须预先安装 Node.js 和 npm。
1718

1819
### 常用参数
1920

2021
| 参数 | 默认值 | 说明 |
2122
|------|--------|------|
2223
| `--http-listen` | `127.0.0.1:8080` | 监听地址(仅允许回环地址) |
2324
| `--open-browser` | `true` | 启动后自动打开浏览器 |
24-
| `--skip-build` | `false` | 跳过前端构建(dist/ 缺失时会报错) |
25+
| `--skip-build` | `false` | 跳过前端构建(dist/ 缺失时会报错;仅在你已准备好预构建资源时使用|
2526
| `--static-dir` || 指定前端静态文件目录 |
2627
| `--log-level` | `info` | 日志级别:debug / info / warn / error |
2728
| `--token-file` || 自定义认证 token 文件路径 |
@@ -56,6 +57,7 @@ Web UI 支持两种运行模式,根据启动方式自动选择:
5657
### 浏览器模式
5758

5859
通过 `neocode web` 或直接在浏览器中访问 Gateway 地址时使用。首次连接需要输入 Gateway URL 和 token,配置保存在 sessionStorage 中。
60+
如果你使用标签发布版,首次运行 `neocode web` 可能先看到前端依赖安装与构建日志;构建完成后会继续启动 Web UI。若机器缺少 Node.js/npm,命令会直接提示安装依赖。
5961

6062
## 核心功能
6163

0 commit comments

Comments
 (0)