feat: onboarding 2-keys + Windows install w/o admin (v0.7.3)
All checks were successful
PR Check / check (pull_request) Successful in 56s

Two user-reported pain points:

1. First-run setup proposed only MiniMax (no MiMo step). Onboarding
   now offers both keys side-by-side under a single "apikey" step,
   with per-key Validate buttons. At least one must be valid to
   proceed; the rest of the providers (OpenAI/Anthropic/Z.AI/Ollama)
   are not shown in the wizard — they're configured later via the
   Config tab. Active provider = MiniMax if valid, else MiMo.

2. Windows install instructions failed: Move-Item to C:\Windows
   requires admin. Replaced with a no-admin 4-line snippet that
   installs to %LOCALAPPDATA%\Muyue and calls a new subcommand
   `muyue install-shortcuts` to create Desktop + Start Menu .lnk
   files and add the install dir to the user PATH. Shortcut creation
   uses WScript.Shell COM via PowerShell — keeps Go binary
   dependency-free. Folder paths resolved through
   [Environment]::GetFolderPath so OneDrive/redirected profiles
   work too.

- cmd/muyue/commands/install_shortcuts.go: new file
- web/src/components/OnboardingWizard.jsx: 2-key apikey step
- .gitea/workflows/ci-main.yml: updated install snippet
- internal/version/version.go: 0.7.2 → 0.7.3
- CHANGELOG.md: v0.7.3 entry
This commit is contained in:
Muyue
2026-04-27 12:12:18 +02:00
parent a1da9da3db
commit 1442b4fd8a
5 changed files with 280 additions and 31 deletions

View File

@@ -138,11 +138,12 @@ jobs:
echo "sudo mv muyue-darwin-arm64 /usr/local/bin/muyue" echo "sudo mv muyue-darwin-arm64 /usr/local/bin/muyue"
echo "\`\`\`" echo "\`\`\`"
echo "" echo ""
echo "**Windows (x86_64)**" echo "**Windows (x86_64)** — sans privilèges admin, crée les raccourcis Bureau + Menu Démarrer :"
echo "\`\`\`powershell" echo "\`\`\`powershell"
echo "Invoke-WebRequest -Uri \"${DL_URL}/muyue-windows-amd64.zip\" -OutFile \"muyue.zip\"" echo "\$dest = \"\$env:LOCALAPPDATA\\Muyue\""
echo "Expand-Archive -Path \"muyue.zip\" -DestinationPath \".\"" echo "Invoke-WebRequest -Uri \"${DL_URL}/muyue-windows-amd64.zip\" -OutFile \"\$env:TEMP\\muyue.zip\""
echo "Move-Item muyue-windows-amd64.exe C:\\Windows\\muyue.exe" echo "Expand-Archive -Path \"\$env:TEMP\\muyue.zip\" -DestinationPath \$dest -Force"
echo "& \"\$dest\\muyue-windows-amd64.exe\" install-shortcuts"
echo "\`\`\`" echo "\`\`\`"
} > /tmp/stable_changelog.md } > /tmp/stable_changelog.md
echo "path=/tmp/stable_changelog.md" >> $GITHUB_OUTPUT echo "path=/tmp/stable_changelog.md" >> $GITHUB_OUTPUT

View File

@@ -4,6 +4,30 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## v0.7.3
### Onboarding — focus MiniMax + MiMo
- L'étape `apikey` du wizard de premier lancement propose désormais **les deux clés** (MiniMax + MiMo) côte à côte ; au moins une doit être validée pour continuer.
- Les autres fournisseurs (OpenAI, Anthropic, Z.AI, Ollama) ne sont plus proposés dans le wizard — l'utilisateur les configure ensuite via l'onglet **Configuration** s'il le souhaite. Justification : pour les nouveaux utilisateurs, deux choix simples > six choix qui ralentissent le démarrage.
- Si MiniMax est validé, il devient le provider actif. Sinon, c'est MiMo. Si les deux sont validés, MiniMax reste actif (peut être basculé via `/model change` plus tard).
### Install Windows — pas d'admin + raccourcis automatiques
- **Avant** : la 3ᵉ ligne du snippet d'install (`Move-Item ... C:\Windows\muyue.exe`) échouait avec `UnauthorizedAccessException` sur PowerShell sans élévation.
- **Maintenant** : 4 lignes, toutes exécutables sans admin :
```powershell
$dest = "$env:LOCALAPPDATA\Muyue"
Invoke-WebRequest -Uri ".../muyue-windows-amd64.zip" -OutFile "$env:TEMP\muyue.zip"
Expand-Archive -Path "$env:TEMP\muyue.zip" -DestinationPath $dest -Force
& "$dest\muyue-windows-amd64.exe" install-shortcuts
```
- Nouvelle commande `muyue install-shortcuts` (Windows uniquement) :
- crée `Muyue.lnk` sur le Bureau et dans le Menu Démarrer (résolus via `[Environment]::GetFolderPath`, robuste OneDrive / profils non-standards) ;
- utilise WScript.Shell COM via PowerShell pour générer les `.lnk` (pas de dépendance Go ajoutée) ;
- ajoute le dossier d'install au `PATH` utilisateur (scope User, pas de modif système).
- Une icône custom pourra être branchée plus tard en remplaçant la ressource embed du `.exe` ; pour l'instant, l'icône Windows par défaut du binaire est utilisée.
## v0.7.2 ## v0.7.2
### Amélioration ### Amélioration

View File

@@ -0,0 +1,151 @@
package commands
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"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(" Executable : %s\n", exe)
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, exe, 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, exe, 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 (open a new terminal to pick it up)\n", installDir)
}
fmt.Println("\nDone — double-click the Muyue icon on your Desktop to launch.")
return nil
},
}
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
}

View File

@@ -7,7 +7,7 @@ import (
const ( const (
Name = "muyue" Name = "muyue"
Version = "0.7.2" Version = "0.7.3"
Author = "La Légion de Muyue" Author = "La Légion de Muyue"
) )

View File

@@ -23,8 +23,12 @@ export default function OnboardingWizard({ api, onComplete }) {
language: 'fr', language: 'fr',
keyboard: 'azerty', keyboard: 'azerty',
apikey: '', apikey: '',
apikey_mimo: '',
editor: '', editor: '',
}) })
const [keyValidMimo, setKeyValidMimo] = useState(false)
const [errorMimo, setErrorMimo] = useState(null)
const [validatingMimo, setValidatingMimo] = useState(false)
const [editorList, setEditorList] = useState(BASE_EDITORS) const [editorList, setEditorList] = useState(BASE_EDITORS)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
@@ -52,7 +56,7 @@ export default function OnboardingWizard({ api, onComplete }) {
case 'name': return answers.name.trim().length > 0 case 'name': return answers.name.trim().length > 0
case 'language': return !!answers.language case 'language': return !!answers.language
case 'keyboard': return !!answers.keyboard case 'keyboard': return !!answers.keyboard
case 'apikey': return keyValid && !scanning case 'apikey': return (keyValid || keyValidMimo) && !scanning
case 'editor': return true case 'editor': return true
case 'done': return true case 'done': return true
default: return true default: return true
@@ -173,6 +177,33 @@ export default function OnboardingWizard({ api, onComplete }) {
setValidating(false) setValidating(false)
} }
const handleValidateKeyMimo = async () => {
if (!answers.apikey_mimo.trim()) return
setValidatingMimo(true)
setErrorMimo(null)
try {
await api.validateProvider({
name: 'mimo',
api_key: answers.apikey_mimo,
model: 'mimo-v2.5-pro',
base_url: 'https://token-plan-ams.xiaomimimo.com/v1',
})
setKeyValidMimo(true)
// Save MiMo. If MiniMax wasn't validated yet, MiMo becomes the active provider.
await api.saveProvider({
name: 'mimo',
api_key: answers.apikey_mimo,
model: 'mimo-v2.5-pro',
base_url: 'https://token-plan-ams.xiaomimimo.com/v1',
active: !keyValid,
})
} catch (err) {
setErrorMimo(err.message || 'Clé invalide')
setKeyValidMimo(false)
}
setValidatingMimo(false)
}
const handleSave = async () => { const handleSave = async () => {
@@ -201,6 +232,15 @@ export default function OnboardingWizard({ api, onComplete }) {
active: true, active: true,
}) })
} }
if (answers.apikey_mimo.trim()) {
await api.saveProvider({
name: 'mimo',
api_key: answers.apikey_mimo,
model: 'mimo-v2.5-pro',
base_url: 'https://token-plan-ams.xiaomimimo.com/v1',
active: !answers.apikey.trim(),
})
}
onComplete() onComplete()
} catch (err) { } catch (err) {
setError(err.message || 'Erreur lors de la sauvegarde') setError(err.message || 'Erreur lors de la sauvegarde')
@@ -283,38 +323,71 @@ export default function OnboardingWizard({ api, onComplete }) {
{current.key === 'apikey' && ( {current.key === 'apikey' && (
<div className="onboarding-step"> <div className="onboarding-step">
<div className="onboarding-title">Clé API MiniMax</div> <div className="onboarding-title">Clés API</div>
<div className="onboarding-desc"> <div className="onboarding-desc">
Entrez votre clé API MiniMax pour activer l'assistant IA. La clé est obligatoire pour continuer. Renseignez au moins l'une des deux clés pour activer l'assistant. Les autres fournisseurs (OpenAI, Anthropic, Ollama, Z.AI) se configurent plus tard depuis l'onglet Configuration.
</div> </div>
<input
className="onboarding-input" <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4 }}>
placeholder="sk-xxxxxxxxxxxxxxxx" <label style={{ fontSize: 12, color: 'var(--text-tertiary)', fontWeight: 600 }}>MiniMax</label>
type="password" <input
value={answers.apikey} className="onboarding-input"
onChange={e => { setAnswers(a => ({ ...a, apikey: e.target.value })); setKeyValid(false); setError(null) }} placeholder="sk-xxxxxxxxxxxxxxxx (MiniMax)"
autoFocus type="password"
/> value={answers.apikey}
{error && !keyValid && <div className="onboarding-required">{error}</div>} onChange={e => { setAnswers(a => ({ ...a, apikey: e.target.value })); setKeyValid(false); setError(null) }}
{keyValid && !scanning && <div className="onboarding-valid">Clé valide ✓ — Appuyez sur Entrée pour continuer</div>} autoFocus
/>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button
className="sm primary"
onClick={handleValidateKey}
disabled={validating || !answers.apikey.trim()}
>
{validating ? 'Validation...' : 'Valider MiniMax'}
</button>
{keyValid && <span className="onboarding-valid">✓ MiniMax OK</span>}
{error && !keyValid && <span className="onboarding-required">{error}</span>}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 12 }}>
<label style={{ fontSize: 12, color: 'var(--text-tertiary)', fontWeight: 600 }}>MiMo (Xiaomi)</label>
<input
className="onboarding-input"
placeholder="sk-xxxxxxxxxxxxxxxx (MiMo)"
type="password"
value={answers.apikey_mimo}
onChange={e => { setAnswers(a => ({ ...a, apikey_mimo: e.target.value })); setKeyValidMimo(false); setErrorMimo(null) }}
/>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button
className="sm primary"
onClick={handleValidateKeyMimo}
disabled={validatingMimo || !answers.apikey_mimo.trim()}
>
{validatingMimo ? 'Validation...' : 'Valider MiMo'}
</button>
{keyValidMimo && <span className="onboarding-valid">✓ MiMo OK</span>}
{errorMimo && !keyValidMimo && <span className="onboarding-required">{errorMimo}</span>}
</div>
</div>
{scanning && ( {scanning && (
<div className="onboarding-scanning"> <div className="onboarding-scanning" style={{ marginTop: 8 }}>
<Loader size={14} className="spin-icon" /> <Loader size={14} className="spin-icon" />
<span>{scanMessage}</span> <span>{scanMessage}</span>
</div> </div>
)} )}
{requiredError && <div className="onboarding-required">Veuillez valider votre clé API pour continuer</div>} {requiredError && (
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}> <div className="onboarding-required" style={{ marginTop: 8 }}>
<button Veuillez valider au moins une clé (MiniMax ou MiMo) pour continuer.
className="sm primary" </div>
onClick={handleValidateKey} )}
disabled={validating || !answers.apikey.trim()} {(keyValid || keyValidMimo) && !scanning && (
> <div className="onboarding-valid" style={{ marginTop: 8 }}>
{validating ? 'Validation...' : 'Valider la clé'} Au moins une clé est valide — appuyez sur Suivant pour continuer.
</button> </div>
</div>
{!keyValid && !error && answers.apikey.trim() && (
<div className="onboarding-hint">Entrez votre clé puis cliquez "Valider la clé"</div>
)} )}
</div> </div>
)} )}