diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 600ea22a6..1a3a58708 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -24,9 +24,13 @@ jobs: go-version: '1.26.1' cache-dependency-path: Packages/src/GoCli~/go.sum - - name: Test native Go CLI - working-directory: Packages/src/GoCli~ - run: go test ./... + - name: Install golangci-lint + run: | + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.0 + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + + - name: Check native Go CLI + run: scripts/check-go-cli.sh - name: Build native Go CLI run: scripts/build-go-cli.sh diff --git a/.github/workflows/native-cli-publish.yml b/.github/workflows/native-cli-publish.yml index d395b2754..3c28d6eb5 100644 --- a/.github/workflows/native-cli-publish.yml +++ b/.github/workflows/native-cli-publish.yml @@ -35,9 +35,13 @@ jobs: go-version: '1.26.1' cache-dependency-path: Packages/src/GoCli~/go.sum - - name: Test native CLI - working-directory: Packages/src/GoCli~ - run: go test ./... + - name: Install golangci-lint + run: | + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.0 + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + + - name: Check native CLI + run: scripts/check-go-cli.sh - name: Build native CLI binaries run: scripts/build-go-cli.sh diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 6f180992c..ac02ea290 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -7,6 +7,7 @@ on: - 'Packages/src/**/*.cs' - 'Assets/**/*.cs' - 'Packages/src/GoCli~/**/*.go' + - 'Packages/src/GoCli~/.golangci.yml' - 'Packages/src/GoCli~/go.mod' - 'Packages/src/GoCli~/go.sum' pull_request: @@ -15,6 +16,7 @@ on: - 'Packages/src/**/*.cs' - 'Assets/**/*.cs' - 'Packages/src/GoCli~/**/*.go' + - 'Packages/src/GoCli~/.golangci.yml' - 'Packages/src/GoCli~/go.mod' - 'Packages/src/GoCli~/go.sum' workflow_dispatch: @@ -113,6 +115,10 @@ jobs: go-version: '1.26.1' cache-dependency-path: Packages/src/GoCli~/go.sum - - name: Run Go tests - working-directory: Packages/src/GoCli~ - run: go test ./... + - name: Install golangci-lint + run: | + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.0 + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + + - name: Check Go CLI + run: scripts/check-go-cli.sh diff --git a/Packages/src/GoCli~/.golangci.yml b/Packages/src/GoCli~/.golangci.yml new file mode 100644 index 000000000..c020fe200 --- /dev/null +++ b/Packages/src/GoCli~/.golangci.yml @@ -0,0 +1,12 @@ +version: "2" + +linters: + default: standard + +formatters: + enable: + - gofumpt + - goimports + +run: + timeout: 5m diff --git a/Packages/src/GoCli~/dist/darwin-amd64/uloop-core b/Packages/src/GoCli~/dist/darwin-amd64/uloop-core index 9293407bb..bb5f5bb85 100755 Binary files a/Packages/src/GoCli~/dist/darwin-amd64/uloop-core and b/Packages/src/GoCli~/dist/darwin-amd64/uloop-core differ diff --git a/Packages/src/GoCli~/dist/darwin-amd64/uloop-dispatcher b/Packages/src/GoCli~/dist/darwin-amd64/uloop-dispatcher index 4171e30f8..bda3f37c8 100755 Binary files a/Packages/src/GoCli~/dist/darwin-amd64/uloop-dispatcher and b/Packages/src/GoCli~/dist/darwin-amd64/uloop-dispatcher differ diff --git a/Packages/src/GoCli~/dist/darwin-arm64/uloop-core b/Packages/src/GoCli~/dist/darwin-arm64/uloop-core index 611cdccb1..3c59c47ea 100755 Binary files a/Packages/src/GoCli~/dist/darwin-arm64/uloop-core and b/Packages/src/GoCli~/dist/darwin-arm64/uloop-core differ diff --git a/Packages/src/GoCli~/dist/darwin-arm64/uloop-dispatcher b/Packages/src/GoCli~/dist/darwin-arm64/uloop-dispatcher index 640638874..fd40139fd 100755 Binary files a/Packages/src/GoCli~/dist/darwin-arm64/uloop-dispatcher and b/Packages/src/GoCli~/dist/darwin-arm64/uloop-dispatcher differ diff --git a/Packages/src/GoCli~/dist/windows-amd64/uloop-core.exe b/Packages/src/GoCli~/dist/windows-amd64/uloop-core.exe index 7071d35c3..faf714bc6 100755 Binary files a/Packages/src/GoCli~/dist/windows-amd64/uloop-core.exe and b/Packages/src/GoCli~/dist/windows-amd64/uloop-core.exe differ diff --git a/Packages/src/GoCli~/dist/windows-amd64/uloop-dispatcher.exe b/Packages/src/GoCli~/dist/windows-amd64/uloop-dispatcher.exe index 04b6fb8c7..a0c6eb983 100755 Binary files a/Packages/src/GoCli~/dist/windows-amd64/uloop-dispatcher.exe and b/Packages/src/GoCli~/dist/windows-amd64/uloop-dispatcher.exe differ diff --git a/Packages/src/GoCli~/internal/cli/completion.go b/Packages/src/GoCli~/internal/cli/completion.go index cceb7c570..dd7beaf9f 100644 --- a/Packages/src/GoCli~/internal/cli/completion.go +++ b/Packages/src/GoCli~/internal/cli/completion.go @@ -46,7 +46,7 @@ func tryHandleCompletionRequest(args []string, cache toolsCache, stdout io.Write if args[0] == listOptionsFlag { if len(args) < 2 { - fmt.Fprintln(stderr, "--list-options requires a command name") + writeLine(stderr, "--list-options requires a command name") return true, 1 } printOptionsForCommand(args[1], cache, stdout) @@ -64,7 +64,7 @@ func tryHandleCompletionRequest(args []string, cache toolsCache, stdout io.Write request, err := parseCompletionRequest(args[1:]) if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return true, 1 } @@ -73,32 +73,32 @@ func tryHandleCompletionRequest(args []string, cache toolsCache, stdout io.Write shellName = detectShell() } if shellName == "" { - fmt.Fprintln(stderr, "Could not detect shell. Use --shell bash, --shell zsh, --shell powershell, or --shell pwsh.") + writeLine(stderr, "Could not detect shell. Use --shell bash, --shell zsh, --shell powershell, or --shell pwsh.") return true, 1 } script := getCompletionScript(shellName) if !request.install { - fmt.Fprintln(stdout, script) + writeLine(stdout, script) return true, 0 } configPath, err := getShellConfigPath(shellName) if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return true, 1 } if err := installCompletionScript(configPath, shellName, script); err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return true, 1 } - fmt.Fprintf(stdout, "Completion installed to %s\n", configPath) + writeFormat(stdout, "Completion installed to %s\n", configPath) if isPowerShellShell(shellName) { - fmt.Fprintln(stdout, "Restart PowerShell to enable completion.") + writeLine(stdout, "Restart PowerShell to enable completion.") return true, 0 } - fmt.Fprintf(stdout, "Run 'source %s' or restart your shell to enable completion.\n", configPath) + writeFormat(stdout, "Run 'source %s' or restart your shell to enable completion.\n", configPath) return true, 0 } @@ -172,7 +172,7 @@ func printCommandNames(cache toolsCache, stdout io.Writer) { commands = append(commands, tool.Name) } sort.Strings(commands) - fmt.Fprintln(stdout, strings.Join(commands, "\n")) + writeLine(stdout, strings.Join(commands, "\n")) } func printOptionsForCommand(command string, cache toolsCache, stdout io.Writer) { @@ -192,7 +192,7 @@ func printOptionsForCommand(command string, cache toolsCache, stdout io.Writer) options = append(options, "--"+pascalToKebab(propertyName)) } sort.Strings(options) - fmt.Fprintln(stdout, strings.Join(options, "\n")) + writeLine(stdout, strings.Join(options, "\n")) } func detectShell() string { @@ -322,6 +322,6 @@ func isPowerShellShell(shellName string) bool { } func printCompletionHelp(stdout io.Writer) { - fmt.Fprintln(stdout, "Usage:") - fmt.Fprintln(stdout, " uloop completion [--shell bash|zsh|powershell|pwsh] [--install]") + writeLine(stdout, "Usage:") + writeLine(stdout, " uloop completion [--shell bash|zsh|powershell|pwsh] [--install]") } diff --git a/Packages/src/GoCli~/internal/cli/fix.go b/Packages/src/GoCli~/internal/cli/fix.go index 2fda4de55..bf5c6a1c0 100644 --- a/Packages/src/GoCli~/internal/cli/fix.go +++ b/Packages/src/GoCli~/internal/cli/fix.go @@ -1,7 +1,6 @@ package cli import ( - "fmt" "io" "os" "path/filepath" @@ -17,16 +16,16 @@ var staleLockFileNames = []string{ func runFix(projectRoot string, stdout io.Writer, stderr io.Writer) int { cleaned, err := cleanupStaleLockFiles(projectRoot) if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } if cleaned == 0 { - fmt.Fprintln(stdout, "No lock files found.") + writeLine(stdout, "No lock files found.") return 0 } - fmt.Fprintf(stdout, "\nCleaned up %d lock file(s).\n", cleaned) + writeFormat(stdout, "\nCleaned up %d lock file(s).\n", cleaned) return 0 } diff --git a/Packages/src/GoCli~/internal/cli/launch.go b/Packages/src/GoCli~/internal/cli/launch.go index e842e76f1..ce866d56f 100644 --- a/Packages/src/GoCli~/internal/cli/launch.go +++ b/Packages/src/GoCli~/internal/cli/launch.go @@ -54,7 +54,7 @@ func tryHandleLaunchRequest( options, err := parseLaunchOptions(args[1:], globalProjectPath) if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return true, 1 } @@ -147,47 +147,47 @@ func isInvalidLaunchOptionValue(option string, value string) bool { func runLaunch(ctx context.Context, options launchOptions, startPath string, stdout io.Writer, stderr io.Writer) int { projectRoot, err := resolveLaunchProjectRoot(startPath, options) if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } if options.deleteRecovery { if err := os.RemoveAll(filepath.Join(projectRoot, recoveryDirectoryPath)); err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } } runningProcess, err := findRunningUnityProcess(ctx, projectRoot) if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } if runningProcess != nil { if !options.restart && !options.quit { _ = focusUnityProcess(ctx, runningProcess.pid) - fmt.Fprintf(stdout, "Unity is already running for %s (PID: %d)\n", projectRoot, runningProcess.pid) + writeFormat(stdout, "Unity is already running for %s (PID: %d)\n", projectRoot, runningProcess.pid) return 0 } if err := killUnityProcess(runningProcess.pid); err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } if options.quit { - fmt.Fprintf(stdout, "Unity process stopped (PID: %d)\n", runningProcess.pid) + writeFormat(stdout, "Unity process stopped (PID: %d)\n", runningProcess.pid) return 0 } } if options.quit { - fmt.Fprintln(stdout, "No Unity process is running for this project.") + writeLine(stdout, "No Unity process is running for this project.") return 0 } unityPath, err := resolveUnityExecutablePath(projectRoot) if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } @@ -198,16 +198,16 @@ func runLaunch(ctx context.Context, options launchOptions, startPath string, std command := exec.CommandContext(ctx, unityPath, launchArgs...) if err := command.Start(); err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } - fmt.Fprintf(stdout, "Unity launch started for %s (PID: %d)\n", projectRoot, command.Process.Pid) + writeFormat(stdout, "Unity launch started for %s (PID: %d)\n", projectRoot, command.Process.Pid) if err := waitForLaunchReady(ctx, projectRoot); err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } - fmt.Fprintln(stdout, "Unity is ready.") + writeLine(stdout, "Unity is ready.") return 0 } @@ -238,7 +238,7 @@ func resolveUnityExecutablePath(projectRoot string) (string, error) { } } if len(candidates) == 0 { - return "", fmt.Errorf("Unity launch is not supported on %s", runtime.GOOS) + return "", fmt.Errorf("unity launch is not supported on %s", runtime.GOOS) } return candidates[0], nil } @@ -277,11 +277,11 @@ func readUnityEditorVersion(projectRoot string) (string, error) { } matches := editorVersionPattern.FindStringSubmatch(string(content)) if len(matches) != 2 { - return "", fmt.Errorf("Unity editor version not found in %s", projectVersionFilePath) + return "", fmt.Errorf("unity editor version not found in %s", projectVersionFilePath) } version := strings.TrimSpace(matches[1]) if version == "" { - return "", fmt.Errorf("Unity editor version is empty in %s", projectVersionFilePath) + return "", fmt.Errorf("unity editor version is empty in %s", projectVersionFilePath) } return version, nil } @@ -318,13 +318,13 @@ func waitForLaunchReady(ctx context.Context, projectRoot string) error { } func printLaunchHelp(stdout io.Writer) { - fmt.Fprintln(stdout, "Usage:") - fmt.Fprintln(stdout, " uloop launch [options] [project-path]") - fmt.Fprintln(stdout, "") - fmt.Fprintln(stdout, "Options:") - fmt.Fprintln(stdout, " -r, --restart Kill an existing Unity process for the project before launching") - fmt.Fprintln(stdout, " -q, --quit Kill an existing Unity process for the project without launching") - fmt.Fprintln(stdout, " -d, --delete-recovery Delete Assets/_Recovery before launch") - fmt.Fprintln(stdout, " -p, --platform Pass Unity -buildTarget when launching") - fmt.Fprintln(stdout, " --max-depth Accepted for compatibility when searching from the current directory") + writeLine(stdout, "Usage:") + writeLine(stdout, " uloop launch [options] [project-path]") + writeLine(stdout, "") + writeLine(stdout, "Options:") + writeLine(stdout, " -r, --restart Kill an existing Unity process for the project before launching") + writeLine(stdout, " -q, --quit Kill an existing Unity process for the project without launching") + writeLine(stdout, " -d, --delete-recovery Delete Assets/_Recovery before launch") + writeLine(stdout, " -p, --platform Pass Unity -buildTarget when launching") + writeLine(stdout, " --max-depth Accepted for compatibility when searching from the current directory") } diff --git a/Packages/src/GoCli~/internal/cli/output.go b/Packages/src/GoCli~/internal/cli/output.go new file mode 100644 index 000000000..f61bdfe7c --- /dev/null +++ b/Packages/src/GoCli~/internal/cli/output.go @@ -0,0 +1,16 @@ +package cli + +import ( + "fmt" + "io" +) + +func writeLine(writer io.Writer, values ...any) { + // CLI status output failures are not recoverable after command outcome is decided. + _, _ = fmt.Fprintln(writer, values...) +} + +func writeFormat(writer io.Writer, format string, values ...any) { + // CLI status output failures are not recoverable after command outcome is decided. + _, _ = fmt.Fprintf(writer, format, values...) +} diff --git a/Packages/src/GoCli~/internal/cli/run.go b/Packages/src/GoCli~/internal/cli/run.go index 3cc193b13..036dd3909 100644 --- a/Packages/src/GoCli~/internal/cli/run.go +++ b/Packages/src/GoCli~/internal/cli/run.go @@ -23,7 +23,7 @@ const ( func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) int { remainingArgs, projectPath, err := parseGlobalProjectPath(args) if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } @@ -32,7 +32,7 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder return 0 } if isVersionRequest(remainingArgs) { - fmt.Fprintln(stdout, version) + writeLine(stdout, version) return 0 } @@ -41,7 +41,7 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder startPath, err := os.Getwd() if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } @@ -61,13 +61,13 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder connection, err := project.ResolveConnection(startPath, projectPath) if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } cache, err := loadTools(connection.ProjectRoot) if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } @@ -83,17 +83,17 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder default: tool, ok := findTool(cache, command) if !ok { - fmt.Fprintf(stderr, "Unknown command: %s\n", command) + writeFormat(stderr, "Unknown command: %s\n", command) return 1 } params, nestedProjectPath, err := buildToolParams(commandArgs, tool) if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } if nestedProjectPath != "" && nestedProjectPath != connection.ProjectRoot { - fmt.Fprintln(stderr, "--project-path must be passed before the command in the native CLI") + writeLine(stderr, "--project-path must be passed before the command in the native CLI") return 1 } return runTool(ctx, connection, command, params, stdout, stderr) @@ -102,7 +102,7 @@ func RunProjectLocal(ctx context.Context, args []string, stdout io.Writer, stder func RunLauncher(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) int { if isVersionRequest(args) { - fmt.Fprintln(stdout, version) + writeLine(stdout, version) return 0 } if len(args) == 0 || isHelpRequest(args) { @@ -118,13 +118,13 @@ func RunLauncher(ctx context.Context, args []string, stdout io.Writer, stderr io startPath, err := os.Getwd() if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } remainingArgs, explicitProjectPath, err := parseGlobalProjectPath(args) if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } if handled, code := tryHandleLaunchRequest(ctx, remainingArgs, startPath, explicitProjectPath, stdout, stderr); handled { @@ -136,7 +136,7 @@ func RunLauncher(ctx context.Context, args []string, stdout io.Writer, stderr io projectRoot, err := resolveLauncherProjectRoot(startPath, explicitProjectPath) if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } @@ -145,7 +145,7 @@ func RunLauncher(ctx context.Context, args []string, stdout io.Writer, stderr io localPath = filepath.Join(projectRoot, projectLocalWindowsPath) } if _, err := os.Stat(localPath); err != nil { - fmt.Fprintf(stderr, "Project-local uloop-core CLI was not found at %s\n", localPath) + writeFormat(stderr, "Project-local uloop-core CLI was not found at %s\n", localPath) return 1 } @@ -167,7 +167,7 @@ func runTool(ctx context.Context, connection project.Connection, command string, }) spinner.Stop() if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } writeJSON(stdout, result) @@ -177,7 +177,7 @@ func runTool(ctx context.Context, connection project.Connection, command string, func runCompileWithDomainReloadWait(ctx context.Context, connection project.Connection, params map[string]any, stdout io.Writer, stderr io.Writer) int { requestID, err := ensureCompileRequestID(params) if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } @@ -190,7 +190,7 @@ func runCompileWithDomainReloadWait(ctx context.Context, connection project.Conn } if !shouldWaitForCompileResult(err, outcome) { spinner.Stop() - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } @@ -204,11 +204,11 @@ func runCompileWithDomainReloadWait(ctx context.Context, connection project.Conn }) spinner.Stop() if waitErr != nil { - fmt.Fprintln(stderr, waitErr.Error()) + writeLine(stderr, waitErr.Error()) return 1 } if !completed { - fmt.Fprintln(stderr, "Compile wait timed out after 90000ms. Run 'uloop fix' and retry.") + writeLine(stderr, "Compile wait timed out after 90000ms. Run 'uloop fix' and retry.") return 1 } writeJSON(stdout, result) @@ -222,7 +222,7 @@ func runList(ctx context.Context, connection project.Connection, stdout io.Write }) spinner.Stop() if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } writeJSON(stdout, result) @@ -236,27 +236,27 @@ func runSync(ctx context.Context, connection project.Connection, stdout io.Write }) spinner.Stop() if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } cachePath := filepath.Join(connection.ProjectRoot, cacheDirectoryName, cacheFileName) if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } if err := os.WriteFile(cachePath, result, 0o644); err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } - fmt.Fprintf(stdout, "Tools synced to %s\n", cachePath) + writeFormat(stdout, "Tools synced to %s\n", cachePath) return 0 } func writeJSON(stdout io.Writer, result json.RawMessage) { var pretty any if json.Unmarshal(result, &pretty) != nil { - fmt.Fprintln(stdout, string(result)) + writeLine(stdout, string(result)) return } encoder := json.NewEncoder(stdout) @@ -282,7 +282,7 @@ func execProjectLocal(ctx context.Context, localPath string, args []string, proj if runtime.GOOS != "windows" { err := syscall.Exec(localPath, append([]string{localPath}, args...), os.Environ()) if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } return 0 @@ -308,13 +308,13 @@ func isHelpRequest(args []string) bool { } func printHelp(stdout io.Writer) { - fmt.Fprintf(stdout, "uloop %s\n\nUsage:\n uloop [options]\n\n", version) - fmt.Fprintln(stdout, "Native Go CLI preview. Dynamic Unity tool commands are loaded from .uloop/tools.json.") + writeFormat(stdout, "uloop %s\n\nUsage:\n uloop [options]\n\n", version) + writeLine(stdout, "Native Go CLI preview. Dynamic Unity tool commands are loaded from .uloop/tools.json.") } func printLauncherHelp(stdout io.Writer) { - fmt.Fprintf(stdout, "uloop %s\n\nUsage:\n uloop [options]\n\n", version) - fmt.Fprintln(stdout, "Native Go dispatcher preview. Dispatches to the project-local uloop-core binary.") + writeFormat(stdout, "uloop %s\n\nUsage:\n uloop [options]\n\n", version) + writeLine(stdout, "Native Go dispatcher preview. Dispatches to the project-local uloop-core binary.") } func loadCompletionTools(startPath string, projectPath string) toolsCache { diff --git a/Packages/src/GoCli~/internal/cli/skills.go b/Packages/src/GoCli~/internal/cli/skills.go index 588f8162f..fb3104a11 100644 --- a/Packages/src/GoCli~/internal/cli/skills.go +++ b/Packages/src/GoCli~/internal/cli/skills.go @@ -60,18 +60,18 @@ func tryHandleSkillsRequest(args []string, startPath string, globalProjectPath s subcommand := args[1] options, err := parseSkillsOptions(args[2:]) if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return true, 1 } projectRoot, err := resolveSkillsProjectRoot(startPath, globalProjectPath, options.global) if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return true, 1 } skills, err := collectSkillDefinitions(projectRoot) if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return true, 1 } @@ -91,7 +91,7 @@ func tryHandleSkillsRequest(args []string, startPath string, globalProjectPath s } return true, runSkillsUninstall(projectRoot, skills, options, stdout, stderr) default: - fmt.Fprintf(stderr, "unknown skills command: %s\n", subcommand) + writeFormat(stderr, "unknown skills command: %s\n", subcommand) return true, 1 } } @@ -146,57 +146,57 @@ func runSkillsList(projectRoot string, skills []skillDefinition, options skillCo location = "Global" } - fmt.Fprintln(stdout, "") - fmt.Fprintln(stdout, "uloop Skills Status:") - fmt.Fprintln(stdout, "") + writeLine(stdout, "") + writeLine(stdout, "uloop Skills Status:") + writeLine(stdout, "") for _, target := range targets { baseDir := getSkillsBaseDir(projectRoot, target, options.global) - fmt.Fprintf(stdout, "%s (%s):\n", target.displayName, location) - fmt.Fprintf(stdout, "Location: %s\n", baseDir) - fmt.Fprintln(stdout, strings.Repeat("=", 50)) + writeFormat(stdout, "%s (%s):\n", target.displayName, location) + writeFormat(stdout, "Location: %s\n", baseDir) + writeLine(stdout, strings.Repeat("=", 50)) for _, skill := range skills { status := getSkillStatus(baseDir, skill, !options.flat) - fmt.Fprintf(stdout, " %s %s (%s)\n", statusIcon(status), skill.name, statusText(status)) + writeFormat(stdout, " %s %s (%s)\n", statusIcon(status), skill.name, statusText(status)) } - fmt.Fprintln(stdout, "") + writeLine(stdout, "") } - fmt.Fprintf(stdout, "Total: %d skills\n", len(skills)) + writeFormat(stdout, "Total: %d skills\n", len(skills)) return 0 } func runSkillsInstall(projectRoot string, skills []skillDefinition, options skillCommandOptions, stdout io.Writer, stderr io.Writer) int { - fmt.Fprintln(stdout, "") - fmt.Fprintf(stdout, "Installing uloop skills (%s)...\n", skillLocationName(options.global)) - fmt.Fprintln(stdout, "") + writeLine(stdout, "") + writeFormat(stdout, "Installing uloop skills (%s)...\n", skillLocationName(options.global)) + writeLine(stdout, "") for _, target := range options.targets { result, err := installSkillsForTarget(projectRoot, target, skills, options.global, !options.flat) if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } - fmt.Fprintf(stdout, "%s:\n", target.displayName) - fmt.Fprintf(stdout, " Installed: %d\n", result.installed) - fmt.Fprintf(stdout, " Updated: %d\n", result.updated) - fmt.Fprintf(stdout, " Skipped: %d\n", result.skipped) - fmt.Fprintf(stdout, " Location: %s\n\n", getSkillsBaseDir(projectRoot, target, options.global)) + writeFormat(stdout, "%s:\n", target.displayName) + writeFormat(stdout, " Installed: %d\n", result.installed) + writeFormat(stdout, " Updated: %d\n", result.updated) + writeFormat(stdout, " Skipped: %d\n", result.skipped) + writeFormat(stdout, " Location: %s\n\n", getSkillsBaseDir(projectRoot, target, options.global)) } return 0 } func runSkillsUninstall(projectRoot string, skills []skillDefinition, options skillCommandOptions, stdout io.Writer, stderr io.Writer) int { - fmt.Fprintln(stdout, "") - fmt.Fprintf(stdout, "Uninstalling uloop skills (%s)...\n", skillLocationName(options.global)) - fmt.Fprintln(stdout, "") + writeLine(stdout, "") + writeFormat(stdout, "Uninstalling uloop skills (%s)...\n", skillLocationName(options.global)) + writeLine(stdout, "") for _, target := range options.targets { removed, notFound, err := uninstallSkillsForTarget(projectRoot, target, skills, options.global, !options.flat) if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return 1 } - fmt.Fprintf(stdout, "%s:\n", target.displayName) - fmt.Fprintf(stdout, " Removed: %d\n", removed) - fmt.Fprintf(stdout, " Not found: %d\n", notFound) - fmt.Fprintf(stdout, " Location: %s\n\n", getSkillsBaseDir(projectRoot, target, options.global)) + writeFormat(stdout, "%s:\n", target.displayName) + writeFormat(stdout, " Removed: %d\n", removed) + writeFormat(stdout, " Not found: %d\n", notFound) + writeFormat(stdout, " Location: %s\n\n", getSkillsBaseDir(projectRoot, target, options.global)) } return 0 } @@ -497,20 +497,20 @@ func statusText(status string) string { } func printSkillsHelp(stdout io.Writer) { - fmt.Fprintln(stdout, "Usage:") - fmt.Fprintln(stdout, " uloop skills list [options]") - fmt.Fprintln(stdout, " uloop skills install [options]") - fmt.Fprintln(stdout, " uloop skills uninstall [options]") + writeLine(stdout, "Usage:") + writeLine(stdout, " uloop skills list [options]") + writeLine(stdout, " uloop skills install [options]") + writeLine(stdout, " uloop skills uninstall [options]") } func printSkillsTargetGuidance(command string, stdout io.Writer) { - fmt.Fprintf(stdout, "\nPlease specify at least one target for '%s':\n\n", command) - fmt.Fprintln(stdout, "Available targets:") - fmt.Fprintln(stdout, " --claude") - fmt.Fprintln(stdout, " --codex") - fmt.Fprintln(stdout, " --cursor") - fmt.Fprintln(stdout, " --gemini") - fmt.Fprintln(stdout, " --agents") - fmt.Fprintln(stdout, " --windsurf") - fmt.Fprintln(stdout, " --antigravity") + writeFormat(stdout, "\nPlease specify at least one target for '%s':\n\n", command) + writeLine(stdout, "Available targets:") + writeLine(stdout, " --claude") + writeLine(stdout, " --codex") + writeLine(stdout, " --cursor") + writeLine(stdout, " --gemini") + writeLine(stdout, " --agents") + writeLine(stdout, " --windsurf") + writeLine(stdout, " --antigravity") } diff --git a/Packages/src/GoCli~/internal/cli/update.go b/Packages/src/GoCli~/internal/cli/update.go index 4d0f78e92..42c04fdeb 100644 --- a/Packages/src/GoCli~/internal/cli/update.go +++ b/Packages/src/GoCli~/internal/cli/update.go @@ -2,6 +2,7 @@ package cli import ( "context" + "errors" "fmt" "io" "os/exec" @@ -21,25 +22,25 @@ func tryHandleUpdateRequest(ctx context.Context, args []string, stdout io.Writer return false, 0 } if len(args) > 1 { - fmt.Fprintln(stderr, updateUnsupportedArgMessage) + writeLine(stderr, updateUnsupportedArgMessage) return true, 1 } commandName, commandArgs, err := updateCommandForOS(runtime.GOOS) if err != nil { - fmt.Fprintln(stderr, err.Error()) + writeLine(stderr, err.Error()) return true, 1 } - fmt.Fprintln(stdout, "Updating global uloop launcher...") + writeLine(stdout, "Updating global uloop launcher...") command := exec.CommandContext(ctx, commandName, commandArgs...) command.Stdout = stdout command.Stderr = stderr if err := command.Run(); err != nil { - fmt.Fprintf(stderr, "Update failed: %s\n", err.Error()) + writeFormat(stderr, "Update failed: %s\n", err.Error()) return true, 1 } - fmt.Fprintln(stdout, "uloop launcher update completed.") + writeLine(stdout, "uloop launcher update completed.") return true, 0 } @@ -56,7 +57,7 @@ func updateCommandForOS(goos string) (string, []string, error) { fmt.Sprintf("irm %s | iex", shellQuote(windowsInstallerScriptURL)), }, nil default: - return "", nil, fmt.Errorf(updateUnsupportedOSMessage) + return "", nil, errors.New(updateUnsupportedOSMessage) } } diff --git a/Packages/src/GoCli~/internal/project/project.go b/Packages/src/GoCli~/internal/project/project.go index 36ec69869..967c7af7b 100644 --- a/Packages/src/GoCli~/internal/project/project.go +++ b/Packages/src/GoCli~/internal/project/project.go @@ -104,12 +104,12 @@ func FindProjectRoot(startPath string) (string, error) { } if exists(filepath.Join(currentPath, ".git")) { - return "", fmt.Errorf("Unity project not found. Use --project-path option to specify the target") + return "", fmt.Errorf("unity project not found. Use --project-path option to specify the target") } parentPath := filepath.Dir(currentPath) if parentPath == currentPath { - return "", fmt.Errorf("Unity project not found. Use --project-path option to specify the target") + return "", fmt.Errorf("unity project not found. Use --project-path option to specify the target") } currentPath = parentPath } @@ -149,12 +149,12 @@ func findUnityProjectRootInParents(currentPath string) (string, error) { } if exists(filepath.Join(currentPath, ".git")) { - return "", fmt.Errorf("Unity project not found. Use --project-path option to specify the target") + return "", fmt.Errorf("unity project not found. Use --project-path option to specify the target") } parentPath := filepath.Dir(currentPath) if parentPath == currentPath { - return "", fmt.Errorf("Unity project not found. Use --project-path option to specify the target") + return "", fmt.Errorf("unity project not found. Use --project-path option to specify the target") } currentPath = parentPath } @@ -208,7 +208,7 @@ func resolveProjectRoot(startPath string, explicitProjectPath string) (string, e return "", fmt.Errorf("not a Unity project: %s", projectRoot) } if !hasUloopInstalled(projectRoot) { - return "", fmt.Errorf("Unity CLI Loop is not installed in this project: %s", projectRoot) + return "", fmt.Errorf("the Unity CLI Loop is not installed in this project: %s", projectRoot) } return projectRoot, nil diff --git a/Packages/src/GoCli~/internal/unity/client.go b/Packages/src/GoCli~/internal/unity/client.go index 3e1df67d2..a1918b81a 100644 --- a/Packages/src/GoCli~/internal/unity/client.go +++ b/Packages/src/GoCli~/internal/unity/client.go @@ -67,7 +67,9 @@ func (client *Client) SendWithProgressOutcome(ctx context.Context, method string if err != nil { return SendOutcome{}, formatConnectionAttemptError(client.connection, err) } - defer conn.Close() + defer func() { + _ = conn.Close() + }() if progress != nil { progress("connected") @@ -106,7 +108,7 @@ func (client *Client) SendWithProgressOutcome(ctx context.Context, method string return outcome, err } if response.Error != nil { - return outcome, fmt.Errorf("Unity error: %s", response.Error.Message) + return outcome, fmt.Errorf("unity error: %s", response.Error.Message) } if len(response.Result) == 0 { return outcome, fmt.Errorf("UNITY_NO_RESPONSE") @@ -118,7 +120,7 @@ func (client *Client) SendWithProgressOutcome(ctx context.Context, method string func formatConnectionAttemptError(connection project.Connection, err error) error { return fmt.Errorf( - "Unity CLI Loop server is not reachable for this project.\n\n"+ + "the Unity CLI Loop server is not reachable for this project.\n\n"+ "The CLI could not open the project's IPC endpoint. This is a connection attempt failure before a request was sent; it does not mean an established connection was disconnected.\n\n"+ "Project: %s\n"+ "Endpoint: %s\n"+ diff --git a/Packages/src/GoCli~/internal/unity/client_test.go b/Packages/src/GoCli~/internal/unity/client_test.go index 48f5bd6d2..1ad13437f 100644 --- a/Packages/src/GoCli~/internal/unity/client_test.go +++ b/Packages/src/GoCli~/internal/unity/client_test.go @@ -21,7 +21,7 @@ func TestFormatConnectionAttemptErrorExplainsDialFailureWithoutDisconnectClaim(t message := err.Error() for _, expected := range []string{ - "Unity CLI Loop server is not reachable for this project.", + "the Unity CLI Loop server is not reachable for this project.", "connection attempt failure before a request was sent", "does not mean an established connection was disconnected", "Project: /tmp/MyProject", diff --git a/Packages/src/README.md b/Packages/src/README.md index 8acb8b481..e74b2ec8a 100644 --- a/Packages/src/README.md +++ b/Packages/src/README.md @@ -600,6 +600,22 @@ See [HelloWorld sample](/Assets/Editor/CustomCommandSamples/HelloWorld/Skill/SKI ## Other +### Native Go CLI Development + +Run the native Go CLI checks before changing files under `Packages/src/GoCli~`: + +```bash +scripts/check-go-cli.sh +``` + +The check script verifies formatting with `goimports` and `gofumpt`, runs `go vet ./...`, runs `golangci-lint`, and then runs `go test ./...`. Install `golangci-lint` first if it is not available on your `PATH`. + +Use the existing build script when you need to refresh the checked-in native binaries: + +```bash +scripts/build-go-cli.sh +``` + ### Unity CLI Loop Files `UserSettings/UnityMcpSettings.json` stores per-user editor session state and should always remain local-only. The file name is a historical compatibility name. diff --git a/README.md b/README.md index 8acb8b481..e74b2ec8a 100644 --- a/README.md +++ b/README.md @@ -600,6 +600,22 @@ See [HelloWorld sample](/Assets/Editor/CustomCommandSamples/HelloWorld/Skill/SKI ## Other +### Native Go CLI Development + +Run the native Go CLI checks before changing files under `Packages/src/GoCli~`: + +```bash +scripts/check-go-cli.sh +``` + +The check script verifies formatting with `goimports` and `gofumpt`, runs `go vet ./...`, runs `golangci-lint`, and then runs `go test ./...`. Install `golangci-lint` first if it is not available on your `PATH`. + +Use the existing build script when you need to refresh the checked-in native binaries: + +```bash +scripts/build-go-cli.sh +``` + ### Unity CLI Loop Files `UserSettings/UnityMcpSettings.json` stores per-user editor session state and should always remain local-only. The file name is a historical compatibility name. diff --git a/scripts/check-go-cli.sh b/scripts/check-go-cli.sh new file mode 100755 index 000000000..1198d12dc --- /dev/null +++ b/scripts/check-go-cli.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -eu + +ROOT_DIR=$(CDPATH= cd "$(dirname "$0")/.." && pwd) +GO_CLI_DIR="$ROOT_DIR/Packages/src/GoCli~" + +if ! command -v golangci-lint >/dev/null 2>&1; then + echo "golangci-lint is required. Install it before running Go CLI checks." >&2 + echo "https://golangci-lint.run/welcome/install/" >&2 + exit 1 +fi + +( + cd "$GO_CLI_DIR" + golangci-lint fmt --diff + go vet ./... + golangci-lint run ./... + go test ./... +)