Skip to content

Commit 8f72443

Browse files
committed
fix:修复构建问题,现在ci会在发布前自动构建并内嵌dist
1 parent df6fb1e commit 8f72443

10 files changed

Lines changed: 267 additions & 43 deletions

File tree

.github/workflows/release.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ jobs:
2727
with:
2828
go-version-file: 'go.mod' # 💡 修复点 1:让 Action 自动跟随项目真实的 Go 版本
2929

30+
- name: Set up Node.js
31+
uses: actions/setup-node@v4
32+
with:
33+
node-version: '22'
34+
35+
- name: Build web dist
36+
working-directory: web
37+
run: |
38+
npm ci
39+
npm run build
40+
3041
- name: Run GoReleaser
3142
uses: goreleaser/goreleaser-action@v5
3243
with:
@@ -35,4 +46,4 @@ jobs:
3546
args: release --clean
3647
env:
3748
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # 💡 修复点 2:补回本仓库的内置鉴权令牌
38-
TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
49+
TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}

.goreleaser.yaml

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ builds:
1010
- id: neocode
1111
env:
1212
- CGO_ENABLED=0
13+
tags:
14+
- webembed
1315
ldflags:
1416
- -s -w -X 'neo-code/internal/version.Version={{.Version}}'
1517
goos:
@@ -52,18 +54,6 @@ archives:
5254
{{- else if eq .Arch "386" }}i386
5355
{{- else }}{{ .Arch }}{{ end }}
5456
{{- 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/**/*
6757
6858
- id: neocode-gateway
6959
ids:

README.en.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,14 @@ Then start with your workspace:
113113
neocode --workdir /path/to/your/project
114114
```
115115

116+
To launch the browser-based Web UI:
117+
118+
```bash
119+
neocode web
120+
```
121+
122+
Tagged release builds already embed `web/dist` into the `neocode` binary, so the target machine does not need Node.js or npm. When running from source, missing `web/dist` still triggers the local frontend build path.
123+
116124
### 4. Common commands
117125

118126
```text

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ neocode --workdir /path/to/your/project
118118
neocode web
119119
```
120120

121-
标签发布版会在缺少 `web/dist` 时自动使用发布包内的 `web/` 源码执行 `npm install``npm run build`。这要求用户机器已安装 Node.js npm;如果你使用源码仓库运行,也保留相同的自动构建行为
121+
标签发布版已经将 Web UI 的 `web/dist` 内嵌进 `neocode` 二进制,执行 `neocode web` 时不再要求用户机器安装 Node.js npm。如果你在源码仓库里运行 `go run ./cmd/neocode web`,当本地缺少 `web/dist` 时仍会自动尝试构建前端
122122

123123
### 4. 常用命令
124124

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package cli
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"runtime"
7+
"strings"
8+
"testing"
9+
10+
"gopkg.in/yaml.v3"
11+
)
12+
13+
type goreleaserBuild struct {
14+
ID string `yaml:"id"`
15+
Tags []string `yaml:"tags"`
16+
}
17+
18+
type goreleaserConfig struct {
19+
Builds []goreleaserBuild `yaml:"builds"`
20+
}
21+
22+
// repoRootForReleaseConfigTest 解析仓库根目录,供发布配置回归测试读取工作流与 GoReleaser 文件。
23+
func repoRootForReleaseConfigTest(t *testing.T) string {
24+
t.Helper()
25+
_, currentFile, _, ok := runtime.Caller(0)
26+
if !ok {
27+
t.Fatal("runtime.Caller(0) failed")
28+
}
29+
return filepath.Clean(filepath.Join(filepath.Dir(currentFile), "..", ".."))
30+
}
31+
32+
func TestGoReleaserEmbedsWebAssetsOnlyForNeoCode(t *testing.T) {
33+
repoRoot := repoRootForReleaseConfigTest(t)
34+
raw, err := os.ReadFile(filepath.Join(repoRoot, ".goreleaser.yaml"))
35+
if err != nil {
36+
t.Fatalf("read .goreleaser.yaml: %v", err)
37+
}
38+
39+
var cfg goreleaserConfig
40+
if err := yaml.Unmarshal(raw, &cfg); err != nil {
41+
t.Fatalf("unmarshal .goreleaser.yaml: %v", err)
42+
}
43+
44+
builds := make(map[string]goreleaserBuild, len(cfg.Builds))
45+
for _, build := range cfg.Builds {
46+
builds[build.ID] = build
47+
}
48+
49+
neocodeBuild, ok := builds["neocode"]
50+
if !ok {
51+
t.Fatal("missing neocode build in .goreleaser.yaml")
52+
}
53+
if !slicesContains(neocodeBuild.Tags, "webembed") {
54+
t.Fatalf("neocode build tags = %v, want webembed", neocodeBuild.Tags)
55+
}
56+
57+
gatewayBuild, ok := builds["neocode-gateway"]
58+
if !ok {
59+
t.Fatal("missing neocode-gateway build in .goreleaser.yaml")
60+
}
61+
if slicesContains(gatewayBuild.Tags, "webembed") {
62+
t.Fatalf("neocode-gateway build tags = %v, want no webembed", gatewayBuild.Tags)
63+
}
64+
}
65+
66+
func TestReleaseWorkflowBuildsWebDistBeforeGoReleaser(t *testing.T) {
67+
repoRoot := repoRootForReleaseConfigTest(t)
68+
raw, err := os.ReadFile(filepath.Join(repoRoot, ".github", "workflows", "release.yml"))
69+
if err != nil {
70+
t.Fatalf("read release workflow: %v", err)
71+
}
72+
content := string(raw)
73+
74+
for _, expected := range []string{
75+
"actions/setup-node@v4",
76+
"npm ci",
77+
"npm run build",
78+
} {
79+
if !strings.Contains(content, expected) {
80+
t.Fatalf("release workflow missing %q", expected)
81+
}
82+
}
83+
}
84+
85+
func slicesContains(values []string, target string) bool {
86+
for _, value := range values {
87+
if strings.TrimSpace(value) == target {
88+
return true
89+
}
90+
}
91+
return false
92+
}

internal/cli/web_command.go

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ var (
2525
webCommandStartGatewayServer = startGatewayServer
2626
webCommandBuildFrontend = buildFrontend
2727
webCommandLookPath = exec.LookPath
28+
webCommandEmbeddedAssets = func() (fs.FS, bool) {
29+
if !webassets.IsAvailable() {
30+
return nil, false
31+
}
32+
return webassets.FS, true
33+
}
2834
)
2935

3036
type webCommandOptions struct {
@@ -57,7 +63,7 @@ func newWebCommand() *cobra.Command {
5763
cmd.Flags().StringVar(&options.LogLevel, "log-level", "info", "gateway log level: debug|info|warn|error")
5864
cmd.Flags().StringVar(&options.StaticDir, "static-dir", "", "frontend static files directory override")
5965
cmd.Flags().BoolVar(&options.OpenBrowser, "open-browser", true, "open browser automatically")
60-
cmd.Flags().BoolVar(&options.SkipBuild, "skip-build", false, "skip frontend build (error if dist/ missing)")
66+
cmd.Flags().BoolVar(&options.SkipBuild, "skip-build", false, "skip local frontend build (still works with embedded assets)")
6167
cmd.Flags().StringVar(&options.TokenFile, "token-file", "", "gateway auth token file path")
6268

6369
return cmd
@@ -66,6 +72,7 @@ func newWebCommand() *cobra.Command {
6672
// runWebCommand 执行 web 子命令:解析前端目录 → 构建前端(可选) → 启动 Gateway → 打开浏览器。
6773
func runWebCommand(ctx context.Context, options webCommandOptions) error {
6874
logger := log.New(os.Stderr, "neocode-web: ", log.LstdFlags)
75+
embeddedAssets, embeddedAssetsAvailable := webCommandEmbeddedAssets()
6976

7077
// 如果未指定 workdir,默认使用当前工作目录
7178
if strings.TrimSpace(options.Workdir) == "" {
@@ -76,47 +83,55 @@ func runWebCommand(ctx context.Context, options webCommandOptions) error {
7683

7784
// 1. 解析前端静态文件目录
7885
staticDir, err := resolveWebStaticDir(options.StaticDir)
79-
needBuild := err != nil
80-
81-
// 检查源码是否比 dist 更新(仅在 dist 存在且未指定 --static-dir 时)
82-
if err == nil && options.StaticDir == "" {
83-
webDir := findWebSourceDir()
84-
if webDir != "" && isStaleFrontendBuild(webDir) {
85-
logger.Println("frontend source is newer than build output, rebuilding...")
86-
needBuild = true
86+
switch {
87+
case options.StaticDir != "":
88+
if err != nil {
89+
return fmt.Errorf("invalid --static-dir: %w", err)
8790
}
88-
}
89-
90-
if needBuild {
91+
case err != nil && embeddedAssetsAvailable:
92+
logger.Println("frontend dist not found, falling back to embedded assets")
93+
staticDir = ""
94+
case err != nil:
9195
if options.SkipBuild {
92-
return fmt.Errorf("frontend needs rebuild and --skip-build is set")
96+
return fmt.Errorf("frontend assets missing and --skip-build is set")
9397
}
9498
webDir := findWebSourceDir()
9599
if webDir == "" {
96-
if err != nil {
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-
)
101-
}
102100
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",
101+
"frontend assets unavailable: %w; source builds must run from the project root, provide --static-dir, or use a release binary with embedded web assets",
102+
err,
104103
)
105104
}
106105
if buildErr := webCommandBuildFrontend(webDir, logger); buildErr != nil {
107-
return fmt.Errorf("frontend build failed on this machine after detecting bundled web source: %w", buildErr)
106+
return fmt.Errorf("frontend build failed on this machine after detecting local web source: %w", buildErr)
108107
}
109-
// 构建后重新解析
110108
staticDir, err = resolveWebStaticDir(options.StaticDir)
111109
if err != nil {
112110
return fmt.Errorf("frontend dist not found after build: %w", err)
113111
}
112+
default:
113+
// 检查源码是否比 dist 更新(仅在 dist 存在且未指定 --static-dir 时)
114+
webDir := findWebSourceDir()
115+
if webDir != "" && isStaleFrontendBuild(webDir) {
116+
logger.Println("frontend source is newer than build output, rebuilding...")
117+
if options.SkipBuild {
118+
return fmt.Errorf("frontend needs rebuild and --skip-build is set")
119+
}
120+
if buildErr := webCommandBuildFrontend(webDir, logger); buildErr != nil {
121+
return fmt.Errorf("frontend build failed on this machine after detecting local web source: %w", buildErr)
122+
}
123+
staticDir, err = resolveWebStaticDir(options.StaticDir)
124+
if err != nil {
125+
return fmt.Errorf("frontend dist not found after build: %w", err)
126+
}
127+
}
114128
}
129+
115130
// 2. 确定静态文件来源:外部目录优先,找不到时回退到嵌入资源
116131
var staticFileFS fs.FS
117132
if staticDir == "" {
118-
if webassets.IsAvailable() {
119-
staticFileFS = webassets.FS
133+
if embeddedAssetsAvailable {
134+
staticFileFS = embeddedAssets
120135
logger.Println("serving web UI from embedded assets")
121136
} else {
122137
logger.Println("warning: no web UI assets found (external dist missing and embedded assets not compiled)")

internal/cli/web_command_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"path/filepath"
1010
"strings"
1111
"testing"
12+
"testing/fstest"
1213
)
1314

1415
// writeWebCommandTestFile 写入 web 命令测试所需的最小文件内容,避免各测试重复拼装目录。
@@ -76,6 +77,18 @@ func stubWebCommandHooks(
7677
})
7778
}
7879

80+
// stubWebCommandEmbeddedAssets 替换内嵌前端资源探测逻辑,便于覆盖发布版回退分支。
81+
func stubWebCommandEmbeddedAssets(t *testing.T, assets fs.FS, available bool) {
82+
t.Helper()
83+
original := webCommandEmbeddedAssets
84+
webCommandEmbeddedAssets = func() (fs.FS, bool) {
85+
return assets, available
86+
}
87+
t.Cleanup(func() {
88+
webCommandEmbeddedAssets = original
89+
})
90+
}
91+
7992
func TestFindWebSourceDirUsesCurrentWorkdir(t *testing.T) {
8093
tempDir := t.TempDir()
8194
chdirForWebCommandTest(t, tempDir)
@@ -186,3 +199,90 @@ func TestRunWebCommandBuildsFrontendWhenDistMissing(t *testing.T) {
186199
t.Fatalf("startGatewayServer staticDir = %q, want %q", capturedStaticDir, wantStaticDir)
187200
}
188201
}
202+
203+
func TestRunWebCommandUsesEmbeddedAssetsWhenDistMissing(t *testing.T) {
204+
tempDir := t.TempDir()
205+
chdirForWebCommandTest(t, tempDir)
206+
writeWebCommandTestFile(t, filepath.Join(tempDir, "web", "package.json"), "{}")
207+
208+
buildCalled := false
209+
var capturedStaticDir string
210+
var capturedStaticFS fs.FS
211+
sentinelErr := errors.New("stop after start")
212+
stubWebCommandEmbeddedAssets(t, fstest.MapFS{
213+
"index.html": &fstest.MapFile{Data: []byte("<html></html>")},
214+
}, true)
215+
stubWebCommandHooks(
216+
t,
217+
func(_ context.Context, _ gatewayCommandOptions, staticDir string, staticFS fs.FS, _ func(string)) error {
218+
capturedStaticDir = staticDir
219+
capturedStaticFS = staticFS
220+
return sentinelErr
221+
},
222+
func(_ string, _ *log.Logger) error {
223+
buildCalled = true
224+
return nil
225+
},
226+
nil,
227+
)
228+
229+
err := runWebCommand(context.Background(), webCommandOptions{
230+
HTTPAddress: "127.0.0.1:8080",
231+
LogLevel: "info",
232+
OpenBrowser: false,
233+
Workdir: tempDir,
234+
})
235+
if !errors.Is(err, sentinelErr) {
236+
t.Fatalf("runWebCommand() error = %v, want sentinel error %v", err, sentinelErr)
237+
}
238+
if buildCalled {
239+
t.Fatal("runWebCommand() unexpectedly invoked frontend build when embedded assets were available")
240+
}
241+
if capturedStaticDir != "" {
242+
t.Fatalf("startGatewayServer staticDir = %q, want empty string", capturedStaticDir)
243+
}
244+
if capturedStaticFS == nil {
245+
t.Fatal("startGatewayServer staticFS = nil, want embedded assets FS")
246+
}
247+
}
248+
249+
func TestRunWebCommandSkipBuildStillUsesEmbeddedAssets(t *testing.T) {
250+
tempDir := t.TempDir()
251+
chdirForWebCommandTest(t, tempDir)
252+
253+
buildCalled := false
254+
var capturedStaticFS fs.FS
255+
sentinelErr := errors.New("stop after start")
256+
stubWebCommandEmbeddedAssets(t, fstest.MapFS{
257+
"index.html": &fstest.MapFile{Data: []byte("<html></html>")},
258+
}, true)
259+
stubWebCommandHooks(
260+
t,
261+
func(_ context.Context, _ gatewayCommandOptions, _ string, staticFS fs.FS, _ func(string)) error {
262+
capturedStaticFS = staticFS
263+
return sentinelErr
264+
},
265+
func(_ string, _ *log.Logger) error {
266+
buildCalled = true
267+
return nil
268+
},
269+
nil,
270+
)
271+
272+
err := runWebCommand(context.Background(), webCommandOptions{
273+
HTTPAddress: "127.0.0.1:8080",
274+
LogLevel: "info",
275+
OpenBrowser: false,
276+
SkipBuild: true,
277+
Workdir: tempDir,
278+
})
279+
if !errors.Is(err, sentinelErr) {
280+
t.Fatalf("runWebCommand() error = %v, want sentinel error %v", err, sentinelErr)
281+
}
282+
if buildCalled {
283+
t.Fatal("runWebCommand() unexpectedly invoked frontend build when --skip-build used with embedded assets")
284+
}
285+
if capturedStaticFS == nil {
286+
t.Fatal("startGatewayServer staticFS = nil, want embedded assets FS")
287+
}
288+
}

0 commit comments

Comments
 (0)