All checks were successful
PR Check / check (pull_request) Successful in 58s
Three Windows install/launch issues reported by the user:
1. Double-click on Desktop shortcut → dialog "This is a command line
tool. You need to open cmd.exe and run it from there."
Cause: charmbracelet/huh detects no TTY when launched via Explorer
and aborts. Fix:
- cmd/muyue/commands/root.go: skip RunFirstTimeSetup when
os.Stdin is not a character device; persist config.Default()
and let the React onboarding wizard handle first-run UX.
- ci-{main,develop}.yml: build Windows binaries with
-ldflags="-H=windowsgui" so the .exe is a GUI subsystem app —
no console window flashes on double-click.
2. CLI sub-commands (`muyue scan`, `muyue install-shortcuts`, etc.)
would lose all output under -H=windowsgui when launched from
cmd.exe / PowerShell. Mitigation:
- cmd/muyue/console_windows.go (new, build-tagged): on init(),
call kernel32!AttachConsole(ATTACH_PARENT_PROCESS). If the
parent has a console, rebind os.Stdout/os.Stderr/os.Stdin to
it and call log.SetOutput(os.Stderr) so existing log.Printf
calls surface. If no parent console (Explorer), exit silently.
3. After install, `muyue` not recognized in PowerShell.
Causes: (a) the extracted binary is muyue-windows-amd64.exe, not
muyue.exe; (b) the user PATH update by install-shortcuts doesn't
propagate to the existing PowerShell session.
Fix in install-shortcuts:
- Copy self to <installDir>/muyue.exe (rename impossible — the
running .exe is locked on Windows) so `muyue` resolves once
PATH is set.
- Update Desktop + Start Menu .lnk to target the canonical
muyue.exe rather than the platform-suffixed binary.
- Print the line `$env:Path += ';<installDir>'` for the user to
paste, refreshing the current session immediately.
- ci-main.yml install snippet bumps to 5 lines, last being
`$env:Path += ";$dest"`.
- internal/version/version.go: 0.7.4 → 0.7.5
- CHANGELOG.md: v0.7.5 entry covers all three fixes
190 lines
6.2 KiB
Go
190 lines
6.2 KiB
Go
package commands
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// installShortcutsCmd creates desktop + Start Menu shortcuts on Windows so
|
|
// non-technical users can launch Muyue without opening a terminal. It also
|
|
// adds the install directory to the user's PATH (per-user, no admin).
|
|
//
|
|
// Implementation note: shortcut (.lnk) creation on Windows is most reliable
|
|
// via WScript.Shell COM. We invoke it via PowerShell — keeps the Go binary
|
|
// dependency-free and works on any Windows 10+ host.
|
|
var installShortcutsCmd = &cobra.Command{
|
|
Use: "install-shortcuts",
|
|
Short: "Create Desktop + Start Menu shortcuts (Windows only) and add Muyue to PATH",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if runtime.GOOS != "windows" {
|
|
fmt.Println("install-shortcuts is a Windows-only command (no-op on this platform)")
|
|
return nil
|
|
}
|
|
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return fmt.Errorf("locate executable: %w", err)
|
|
}
|
|
exe, _ = filepath.Abs(exe)
|
|
installDir := filepath.Dir(exe)
|
|
|
|
fmt.Println("Installing Muyue shortcuts...")
|
|
fmt.Printf(" Source : %s\n", exe)
|
|
|
|
// Provide a clean `muyue.exe` next to the platform-suffixed binary so
|
|
// users can type `muyue` once the install dir is on PATH. Copy (not
|
|
// rename) because the running .exe is locked on Windows.
|
|
canonicalExe := filepath.Join(installDir, "muyue.exe")
|
|
if !strings.EqualFold(exe, canonicalExe) {
|
|
if err := copyFile(exe, canonicalExe); err != nil {
|
|
fmt.Fprintf(os.Stderr, " Copy : warning — could not create muyue.exe: %v\n", err)
|
|
canonicalExe = exe
|
|
} else {
|
|
fmt.Printf(" Canonical : %s\n", canonicalExe)
|
|
}
|
|
}
|
|
|
|
desktop, err := userShellFolder("Desktop")
|
|
if err != nil {
|
|
return fmt.Errorf("locate Desktop folder: %w", err)
|
|
}
|
|
startMenu, err := userShellFolder("Programs")
|
|
if err != nil {
|
|
return fmt.Errorf("locate Start Menu Programs folder: %w", err)
|
|
}
|
|
|
|
desktopLnk := filepath.Join(desktop, "Muyue.lnk")
|
|
startLnk := filepath.Join(startMenu, "Muyue.lnk")
|
|
|
|
if err := createWindowsShortcut(desktopLnk, canonicalExe, installDir, "Muyue — AI-powered dev environment"); err != nil {
|
|
return fmt.Errorf("create desktop shortcut: %w", err)
|
|
}
|
|
fmt.Printf(" Desktop : %s\n", desktopLnk)
|
|
|
|
if err := createWindowsShortcut(startLnk, canonicalExe, installDir, "Muyue — AI-powered dev environment"); err != nil {
|
|
return fmt.Errorf("create Start Menu shortcut: %w", err)
|
|
}
|
|
fmt.Printf(" Start Menu : %s\n", startLnk)
|
|
|
|
if err := addUserPATH(installDir); err != nil {
|
|
fmt.Fprintf(os.Stderr, " PATH : warning — could not add %s to user PATH: %v\n", installDir, err)
|
|
} else {
|
|
fmt.Printf(" PATH : added %s\n", installDir)
|
|
}
|
|
|
|
fmt.Println("\nDone — double-click the Muyue icon on your Desktop to launch.")
|
|
fmt.Println("\nTo use 'muyue' from this PowerShell session right now, run:")
|
|
fmt.Printf(" $env:Path += ';%s'\n", installDir)
|
|
fmt.Println("(New terminals will pick up the user PATH automatically.)")
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// copyFile duplicates src to dst, overwriting an existing dst (used to drop a
|
|
// `muyue.exe` next to the platform-suffixed binary so the command is callable
|
|
// as `muyue` from PATH).
|
|
func copyFile(src, dst string) error {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
out, err := os.Create(dst)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
if _, err := io.Copy(out, in); err != nil {
|
|
return err
|
|
}
|
|
return out.Sync()
|
|
}
|
|
|
|
func init() {
|
|
rootCmd.AddCommand(installShortcutsCmd)
|
|
}
|
|
|
|
// userShellFolder asks Windows for a user shell folder via PowerShell —
|
|
// resilient to OneDrive redirection and non-default profile locations.
|
|
// `which` is one of: Desktop, Programs (Start Menu Programs), StartMenu.
|
|
func userShellFolder(which string) (string, error) {
|
|
ps := fmt.Sprintf(`[Environment]::GetFolderPath('%s')`, which)
|
|
out, err := exec.Command("powershell", "-NoLogo", "-NoProfile", "-Command", ps).Output()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
path := stripTrailingWhitespace(string(out))
|
|
if path == "" {
|
|
return "", fmt.Errorf("empty path for %s", which)
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
func stripTrailingWhitespace(s string) string {
|
|
for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r' || s[len(s)-1] == ' ' || s[len(s)-1] == '\t') {
|
|
s = s[:len(s)-1]
|
|
}
|
|
return s
|
|
}
|
|
|
|
// createWindowsShortcut generates a .lnk via WScript.Shell COM. The arguments
|
|
// are passed through PowerShell variables (not interpolated into the script
|
|
// body) to avoid quoting issues with paths containing spaces or special chars.
|
|
func createWindowsShortcut(lnkPath, target, workingDir, description string) error {
|
|
script := `
|
|
$lnk = $env:MUYUE_LNK
|
|
$target = $env:MUYUE_TARGET
|
|
$workdir = $env:MUYUE_WORKDIR
|
|
$desc = $env:MUYUE_DESC
|
|
$wsh = New-Object -ComObject WScript.Shell
|
|
$sc = $wsh.CreateShortcut($lnk)
|
|
$sc.TargetPath = $target
|
|
$sc.WorkingDirectory = $workdir
|
|
$sc.Description = $desc
|
|
$sc.IconLocation = "$target,0"
|
|
$sc.Save()
|
|
`
|
|
cmd := exec.Command("powershell", "-NoLogo", "-NoProfile", "-Command", script)
|
|
cmd.Env = append(os.Environ(),
|
|
"MUYUE_LNK="+lnkPath,
|
|
"MUYUE_TARGET="+target,
|
|
"MUYUE_WORKDIR="+workingDir,
|
|
"MUYUE_DESC="+description,
|
|
)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("powershell: %v: %s", err, string(out))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// addUserPATH appends installDir to the user's PATH if not already present.
|
|
// Uses PowerShell to read/write the User-scope environment via .NET API,
|
|
// which broadcasts WM_SETTINGCHANGE so new processes pick it up.
|
|
func addUserPATH(installDir string) error {
|
|
script := `
|
|
$dir = $env:MUYUE_INSTALL_DIR
|
|
$current = [Environment]::GetEnvironmentVariable('Path', 'User')
|
|
if ($current -eq $null) { $current = '' }
|
|
$parts = $current -split ';' | Where-Object { $_ -ne '' }
|
|
if ($parts -notcontains $dir) {
|
|
$new = if ($current -eq '') { $dir } else { "$current;$dir" }
|
|
[Environment]::SetEnvironmentVariable('Path', $new, 'User')
|
|
}
|
|
`
|
|
cmd := exec.Command("powershell", "-NoLogo", "-NoProfile", "-Command", script)
|
|
cmd.Env = append(os.Environ(), "MUYUE_INSTALL_DIR="+installDir)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("powershell: %v: %s", err, string(out))
|
|
}
|
|
return nil
|
|
}
|