# CLI 全面修复计划

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 修复 qycw-cli 的所有 API 对接错误，实现真正的一键安装（自动建库、生成 .env、生成密钥、Admin 账号初始化、Nginx 配置），并删除废弃的 idc-cli。

**Architecture:** 所有改动集中在 `cli/` 模块。新增 `cli/internal/db/` 包用 database/sql 替代对 mysql CLI 二进制的依赖；其余修改为原地修改现有文件。

**Tech Stack:** Go 1.26, Bubbletea TUI, github.com/go-sql-driver/mysql, crypto/rand, crypto/ed25519

## Global Constraints

- 模块名: `qycw-cli`（go.mod 不改）
- 配置路径: Linux `/etc/qycw/config.json`，Windows `%PROGRAMDATA%\qycw\config.json`
- 服务名常量从 `idc-finance` 改为 `qycw`
- 后端二进制名: `auth-server`（Linux）/ `auth-server.exe`（Windows）
- 安装目录: `/opt/qycw`（Linux）/ `C:\qycw`（Windows）— 同步改名（原 idc-finance）
- .env 写入路径: `<installDir>/.env`
- Nginx 配置路径: `/etc/nginx/conf.d/qycw.conf`（Linux only，有域名时）
- 所有 API 字段名以 `backend/internal/instances/model.go` 为准

---

## 涉及文件总览

| 操作 | 路径 |
|------|------|
| 修改 | `cli/internal/api/client.go` |
| 修改 | `cli/install.go` |
| 修改 | `cli/update.go` |
| 修改 | `cli/service.go` |
| 修改 | `cli/main.go` |
| 修改 | `cli/go.mod` |
| 新建 | `cli/internal/db/db.go` |
| 删除 | `backend/cmd/idc-cli/`（整目录） |

---

### Task 1: 修复 API 客户端四个接口

**Files:**
- Modify: `cli/internal/api/client.go`

**Interfaces:**
- Produces: `CheckLicense`, `RegisterInstance`, `Heartbeat`, `DownloadRelease` — 与后端接口完全对齐

- [ ] **Step 1: 修复 CheckLicense（GET→POST，路由换掉）**

```go
// cli/internal/api/client.go

// CheckLicenseResponse 保持不变（valid/type/version/message 字段后端只返回前三个，message 留空无害）

func (c *Client) CheckLicense(code string) (*CheckLicenseResponse, error) {
	payload := struct {
		LicenseCode string `json:"licenseCode"`
	}{LicenseCode: code}
	var resp CheckLicenseResponse
	if err := c.post("/api/v1/licenses/detail", payload, &resp); err != nil {
		return nil, err
	}
	return &resp, nil
}
```

- [ ] **Step 2: 修复 RegisterInstance 字段名**

将 `RegisterInstanceRequest` 的字段名全部对齐后端 `instances.RegisterRequest`：

```go
type RegisterInstanceRequest struct {
	LicenseCode  string `json:"licenseCode"`
	InstanceID   string `json:"instanceId"`
	InstanceName string `json:"instanceName"` // 原: Hostname/hostname
	NodeRole     string `json:"nodeRole"`     // 原: Role/role
	SystemName   string `json:"systemName"`   // 原: OS/os
	SystemArch   string `json:"systemArch"`   // 原: Arch/arch
	BackendURL   string `json:"backendUrl"`
}

func (c *Client) RegisterInstance(licenseCode, instanceID, instanceName, nodeRole, systemName, systemArch, backendURL string) (*RegisterInstanceResponse, error) {
	payload := RegisterInstanceRequest{
		LicenseCode:  licenseCode,
		InstanceID:   instanceID,
		InstanceName: instanceName,
		NodeRole:     nodeRole,
		SystemName:   systemName,
		SystemArch:   systemArch,
		BackendURL:   backendURL,
	}
	var resp RegisterInstanceResponse
	if err := c.post("/api/v1/instances/register", payload, &resp); err != nil {
		return nil, err
	}
	return &resp, nil
}
```

- [ ] **Step 3: 修复 DownloadRelease（加 ?instanceId= 参数）**

```go
func (c *Client) DownloadRelease(token, instanceID, version, target string, w io.Writer, progress func(int64, int64)) error {
	url := fmt.Sprintf("%s/api/v1/instances/downloads/%s/%s?instanceId=%s",
		c.baseURL, version, target, instanceID)
	req, err := http.NewRequest("POST", url, nil)
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", "Bearer "+token)
	// ... 其余不变
```

- [ ] **Step 4: 修复 Heartbeat（字段名 + nonce + 去掉 role 参数）**

在文件顶部 import 增加 `"crypto/rand"` 和 `"encoding/hex"`（如果还没有的话）。

```go
func (c *Client) Heartbeat(instanceID, reportedVersion, backendURL string) (*HeartbeatResponse, error) {
	nonce := make([]byte, 16)
	rand.Read(nonce)
	payload := map[string]string{
		"instanceId":      instanceID,
		"reportedVersion": reportedVersion,
		"backendUrl":      backendURL,
		"nonce":           hex.EncodeToString(nonce),
	}
	var resp HeartbeatResponse
	if err := c.post("/api/v1/instances/heartbeat", payload, &resp); err != nil {
		return nil, err
	}
	return &resp, nil
}
```

- [ ] **Step 5: 更新 main.go 中 Heartbeat 调用方（去掉 role 参数）**

文件 `cli/main.go:196`，将：
```go
hb, err := api.New(center, cfg.Token).Heartbeat(cfg.InstanceID, version, "", cfg.BackendURL)
```
改为：
```go
hb, err := api.New(center, cfg.Token).Heartbeat(cfg.InstanceID, version, cfg.BackendURL)
```

- [ ] **Step 6: 更新 install.go 中 RegisterInstance 调用方**

文件 `cli/install.go:130`，将：
```go
regResp, err := client.RegisterInstance(
    p.LicenseCode, instanceID, hostname,
    "master", runtime.GOOS, runtime.GOARCH, backendURL,
)
```
改为（参数名含义不变，只是映射到正确字段）：
```go
regResp, err := client.RegisterInstance(
    p.LicenseCode, instanceID, hostname,
    "master", runtime.GOOS, runtime.GOARCH, backendURL,
)
```
（函数签名参数顺序未变，无需改动 install.go 的调用方。）

- [ ] **Step 7: 编译验证**

```bash
cd C:/Users/25764/Desktop/auth/cli
go build ./...
```

Expected: 无报错

- [ ] **Step 8: Commit**

```bash
cd C:/Users/25764/Desktop/auth
git add cli/internal/api/client.go cli/main.go
git commit -m "fix: align CLI API client with backend endpoints"
```

---

### Task 2: 新增 DB 辅助包 + 添加 MySQL 依赖

**Files:**
- Create: `cli/internal/db/db.go`
- Modify: `cli/go.mod`

**Interfaces:**
- Produces: `db.CreateDatabase(host, port, user, pass, dbname string) error`
- Produces: `db.RunSQL(dsn, sqlContent string) error`

- [ ] **Step 1: 添加 go-sql-driver/mysql 依赖**

```bash
cd C:/Users/25764/Desktop/auth/cli
go get github.com/go-sql-driver/mysql@v1.8.1
go mod tidy
```

Expected: `go.mod` 新增 `github.com/go-sql-driver/mysql v1.8.1`

- [ ] **Step 2: 新建 cli/internal/db/db.go**

```go
package db

import (
	"database/sql"
	"fmt"

	_ "github.com/go-sql-driver/mysql"
)

// CreateDatabase 连接 MySQL（不指定库名）并执行 CREATE DATABASE IF NOT EXISTS。
// 要求 user 具有 CREATE 权限。
func CreateDatabase(host, port, user, pass, dbname string) error {
	dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/", user, pass, host, port)
	db, err := sql.Open("mysql", dsn)
	if err != nil {
		return fmt.Errorf("连接 MySQL 失败: %w", err)
	}
	defer db.Close()
	if err := db.Ping(); err != nil {
		return fmt.Errorf("无法连接 MySQL (%s:%s): %w", host, port, err)
	}
	query := fmt.Sprintf(
		"CREATE DATABASE IF NOT EXISTS `%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci",
		dbname,
	)
	if _, err := db.Exec(query); err != nil {
		return fmt.Errorf("创建数据库失败: %w", err)
	}
	return nil
}

// RunSQL 在指定数据库 DSN 上执行一段 SQL（用于更新迁移）。
func RunSQL(dsn, sqlContent string) error {
	db, err := sql.Open("mysql", dsn)
	if err != nil {
		return fmt.Errorf("连接数据库失败: %w", err)
	}
	defer db.Close()
	if err := db.Ping(); err != nil {
		return fmt.Errorf("数据库不可达: %w", err)
	}
	if _, err := db.Exec(sqlContent); err != nil {
		return fmt.Errorf("执行 SQL 失败: %w", err)
	}
	return nil
}
```

- [ ] **Step 3: 编译验证**

```bash
cd C:/Users/25764/Desktop/auth/cli
go build ./...
```

Expected: 无报错

- [ ] **Step 4: Commit**

```bash
cd C:/Users/25764/Desktop/auth
git add cli/internal/db/db.go cli/go.mod cli/go.sum
git commit -m "feat: add db helper package with MySQL dependency"
```

---

### Task 3: 修复一键安装（建库 + .env + 密钥生成 + Admin 初始化 + Redis 检查 + Nginx）

**Files:**
- Modify: `cli/install.go`

**Interfaces:**
- Consumes: `db.CreateDatabase` (Task 2), `db.RunSQL` (Task 2)

- [ ] **Step 1: 在 install.go 顶部添加新 import**

在现有 import 块中加入：

```go
import (
    // 已有的...
    "crypto/ed25519"
    "encoding/base64"
    "net"
    "strings"
    "text/template"

    mydb "qycw-cli/internal/db"
)
```

- [ ] **Step 2: 在 collectInstallParams 中新增 Redis 地址提示字段**

在 `fields` 切片的 `!frontendOnly` 分支，追加：

```go
field{"Redis 地址（默认 127.0.0.1:6379）", &p.RedisAddr, false, "127.0.0.1:6379"},
```

同时在 `InstallParams` 结构体中新增字段：

```go
type InstallParams struct {
    LicenseCode  string
    AuthCenter   string
    Domain       string
    Port         string
    DBUser       string
    DBPass       string
    DBName       string
    RedisAddr    string  // 新增
    BackendOnly  bool
    FrontendOnly bool
}
```

- [ ] **Step 3: 在 doInstall 中插入「建库」步骤（在注册实例之后、下载之前）**

在 `add("注册实例", ...)` 之后、`installDir := defaultInstallDir()` 之前插入：

```go
// 3. Create database
if !p.FrontendOnly {
    dbHost := "127.0.0.1"
    dbPort := "3306"
    if err := mydb.CreateDatabase(dbHost, dbPort, p.DBUser, p.DBPass, p.DBName); err != nil {
        add("创建数据库", false, err.Error())
        return results
    }
    add("创建数据库", true, p.DBName)
}
```

- [ ] **Step 4: 在「解压后端包」之后、「保存配置」之前插入「生成密钥 + 写 .env」步骤**

先定义密钥生成辅助函数（放在文件末尾）：

```go
func genEd25519Key() (string, error) {
    _, priv, err := ed25519.GenerateKey(nil)
    if err != nil {
        return "", err
    }
    return base64.StdEncoding.EncodeToString(priv.Seed()), nil
}

func genAESKey() (string, error) {
    key := make([]byte, 32)
    if _, err := rand.Read(key); err != nil {
        return "", err
    }
    return base64.StdEncoding.EncodeToString(key), nil
}

func genPassword(n int) string {
    const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
    b := make([]byte, n)
    rand.Read(b)
    for i := range b {
        b[i] = charset[int(b[i])%len(charset)]
    }
    return string(b)
}
```

然后定义写 .env 的函数：

```go
var envTemplate = `APP_ENV=production
HTTP_ADDR=:{{.Port}}
FRONTEND_ORIGIN={{.FrontendOrigin}}
MYSQL_DSN={{.DBUser}}:{{.DBPass}}@tcp(127.0.0.1:3306)/{{.DBName}}?charset=utf8mb4&parseTime=true&loc=Local&multiStatements=true
REDIS_ADDR={{.RedisAddr}}
REDIS_PASSWORD=
REDIS_DB=0
SESSION_COOKIE_NAME=auth_session
SESSION_TTL=168h
COOKIE_SECURE={{.CookieSecure}}
RATE_LIMIT_ENABLED=true
LICENSE_SIGNING_PRIVATE_KEY={{.SigningKey}}
LICENSE_SIGNING_KEY_ID=key-{{.KeyID}}
LICENSE_CODE_ENCRYPTION_KEY={{.EncryptionKey}}
RELEASE_DIR={{.InstallDir}}/releases
ADMIN_BOOTSTRAP_USERNAME={{.AdminUser}}
ADMIN_BOOTSTRAP_PASSWORD={{.AdminPass}}
INSTANCE_TOKEN_TTL=8760h
LICENSE_LEASE_TTL=60m
`

type envVars struct {
    Port           string
    FrontendOrigin string
    DBUser         string
    DBPass         string
    DBName         string
    RedisAddr      string
    CookieSecure   string
    SigningKey      string
    KeyID          string
    EncryptionKey  string
    InstallDir     string
    AdminUser      string
    AdminPass      string
}

func writeEnvFile(installDir string, vars envVars) error {
    tmpl, err := template.New("env").Parse(envTemplate)
    if err != nil {
        return err
    }
    f, err := os.OpenFile(filepath.Join(installDir, ".env"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
    if err != nil {
        return err
    }
    defer f.Close()
    return tmpl.Execute(f, vars)
}
```

在 `doInstall` 中「解压后端包」成功之后插入：

```go
// 生成密钥
signingKey, err := genEd25519Key()
if err != nil {
    add("生成签名密钥", false, err.Error())
    return results
}
encKey, err := genAESKey()
if err != nil {
    add("生成加密密钥", false, err.Error())
    return results
}
adminPass := genPassword(16)
keyID := fmt.Sprintf("%d", time.Now().Year())

frontendOrigin := "http://localhost:" + p.Port
cookieSecure := "false"
if p.Domain != "" {
    frontendOrigin = "https://" + p.Domain
    cookieSecure = "true"
}

redisAddr := p.RedisAddr
if redisAddr == "" {
    redisAddr = "127.0.0.1:6379"
}

vars := envVars{
    Port:           p.Port,
    FrontendOrigin: frontendOrigin,
    DBUser:         p.DBUser,
    DBPass:         p.DBPass,
    DBName:         p.DBName,
    RedisAddr:      redisAddr,
    CookieSecure:   cookieSecure,
    SigningKey:      signingKey,
    KeyID:          keyID,
    EncryptionKey:  encKey,
    InstallDir:     installDir,
    AdminUser:      "admin",
    AdminPass:      adminPass,
}
if err := writeEnvFile(installDir, vars); err != nil {
    add("写入配置文件", false, err.Error())
    return results
}
add("写入配置文件", true, filepath.Join(installDir, ".env"))
```

- [ ] **Step 5: 检查 Redis 可达性（警告，不中断安装）**

在「写入配置文件」之后插入：

```go
// Redis 可达性检查（仅警告）
conn, rErr := net.DialTimeout("tcp", redisAddr, 3*time.Second)
if rErr != nil {
    add("Redis 检查", false, fmt.Sprintf("无法连接 %s，请确保 Redis 已启动", redisAddr))
} else {
    conn.Close()
    add("Redis 检查", true, redisAddr+" 可达")
}
```

- [ ] **Step 6: 写 Nginx 配置（Linux + 有域名时）**

```go
func writeNginxConfig(domain, installDir, port string) error {
    conf := fmt.Sprintf(`server {
    listen 80;
    server_name %s;

    root %s/frontend;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    location /api/ {
        proxy_pass http://127.0.0.1:%s;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}
`, domain, installDir, port)
    return os.WriteFile("/etc/nginx/conf.d/qycw.conf", []byte(conf), 0o644)
}
```

在「Redis 检查」之后插入：

```go
if runtime.GOOS == "linux" && p.Domain != "" && !p.BackendOnly {
    if err := writeNginxConfig(p.Domain, installDir, port); err != nil {
        add("Nginx 配置", false, err.Error())
    } else {
        exec.Command("nginx", "-s", "reload").Run()
        add("Nginx 配置", true, "/etc/nginx/conf.d/qycw.conf")
    }
}
```

- [ ] **Step 7: 安装完成后在结果中显示 Admin 账号**

在 `doInstall` 返回 results 之前加入：

```go
add("管理员账号", true, fmt.Sprintf("用户名: admin  密码: %s  (请妥善保存)", adminPass))
```

- [ ] **Step 8: 编译验证**

```bash
cd C:/Users/25764/Desktop/auth/cli
go build ./...
```

Expected: 无报错

- [ ] **Step 9: Commit**

```bash
cd C:/Users/25764/Desktop/auth
git add cli/install.go
git commit -m "feat: one-click install - auto create db, write .env, generate keys, nginx config"
```

---

### Task 4: 修复更新迁移（用 database/sql 替代 mysql CLI）

**Files:**
- Modify: `cli/update.go`

**Interfaces:**
- Consumes: `db.RunSQL` (Task 2)
- The `runMigration(cfg, sql)` signature stays the same; only the implementation changes.

- [ ] **Step 1: 替换 runMigration 实现**

删除 `update.go` 中现有的 `runMigration` 函数（`cli/update.go:147-191`），替换为：

```go
func runMigration(cfg *config.Config, sqlContent string) error {
	host := cfg.DBHost
	if host == "" {
		host = "127.0.0.1"
	}
	port := cfg.DBPort
	if port == "" {
		port = "3306"
	}
	dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=true&multiStatements=true",
		cfg.DBUser, cfg.DBPass, host, port, cfg.DBName,
	)
	return mydb.RunSQL(dsn, sqlContent)
}
```

同时在 update.go 顶部 import 中加入：

```go
mydb "qycw-cli/internal/db"
```

并移除不再需要的 `os/exec` import（如果 update.go 不再用 exec）。检查 update.go 中是否还有其他 `exec.Command` 调用（`serviceStopCmd`/`serviceStartCmd` 等都在 service.go），确认后移除。

- [ ] **Step 2: 编译验证**

```bash
cd C:/Users/25764/Desktop/auth/cli
go build ./...
```

Expected: 无报错。若提示 `imported and not used: "os/exec"`，移除该 import。

- [ ] **Step 3: Commit**

```bash
cd C:/Users/25764/Desktop/auth
git add cli/update.go
git commit -m "fix: replace mysql CLI dependency with database/sql for migrations"
```

---

### Task 5: 服务名重命名 + systemd EnvironmentFile

**Files:**
- Modify: `cli/service.go`
- Modify: `cli/install.go`（`registerAndStartService` 函数）

- [ ] **Step 1: service.go — 将 serviceName 从 "idc-finance" 改为 "qycw"**

```go
// cli/service.go 第12行
const serviceName = "qycw"
```

- [ ] **Step 2: install.go — registerAndStartService 加 EnvironmentFile + 修正安装目录名**

将 `registerAndStartService` 中的 Linux systemd unit 模板改为：

```go
case "linux":
    unit := fmt.Sprintf(`[Unit]
Description=QYCW 授权系统
After=network.target

[Service]
EnvironmentFile=%s/.env
ExecStart=%s
Restart=always
RestartSec=5
WorkingDirectory=%s

[Install]
WantedBy=multi-user.target
`, filepath.Dir(binPath), binPath, filepath.Dir(binPath))
    if err := os.WriteFile("/etc/systemd/system/"+serviceName+".service", []byte(unit), 0o644); err != nil {
        return err
    }
    exec.Command("systemctl", "daemon-reload").Run()
    return exec.Command("systemctl", "enable", "--now", serviceName).Run()
```

- [ ] **Step 3: install.go — 修正 defaultInstallDir()**

```go
func defaultInstallDir() string {
    if runtime.GOOS == "windows" {
        return `C:\qycw`
    }
    return "/opt/qycw"
}
```

- [ ] **Step 4: 编译验证**

```bash
cd C:/Users/25764/Desktop/auth/cli
go build ./...
```

Expected: 无报错

- [ ] **Step 5: Commit**

```bash
cd C:/Users/25764/Desktop/auth
git add cli/service.go cli/install.go
git commit -m "fix: rename service to qycw, add EnvironmentFile to systemd unit"
```

---

### Task 6: 删除废弃的 backend/cmd/idc-cli/

**Files:**
- Delete: `backend/cmd/idc-cli/` (整个目录)
- Delete: `backend/idc-cli.exe`, `backend/idc-cli-new.exe` (编译产物)

- [ ] **Step 1: 删除目录和编译产物**

```bash
rm -rf "C:/Users/25764/Desktop/auth/backend/cmd/idc-cli"
rm -f "C:/Users/25764/Desktop/auth/backend/idc-cli.exe"
rm -f "C:/Users/25764/Desktop/auth/backend/idc-cli-new.exe"
```

- [ ] **Step 2: 确认 backend 仍可编译**

```bash
cd C:/Users/25764/Desktop/auth/backend
go build ./...
```

Expected: 无报错（idc-cli 是独立 main package，删除不影响其他包）

- [ ] **Step 3: Commit**

```bash
cd C:/Users/25764/Desktop/auth
git add -A backend/cmd/idc-cli backend/idc-cli.exe backend/idc-cli-new.exe
git commit -m "chore: remove deprecated idc-cli - superseded by qycw-cli"
```

---

## 自检 Checklist

- [x] CheckLicense 路由: `POST /api/v1/licenses/detail` ✓
- [x] RegisterInstance 字段: `instanceName/nodeRole/systemName/systemArch` ✓
- [x] DownloadRelease: `?instanceId=` 查询参数 ✓
- [x] Heartbeat: `reportedVersion` + `nonce` + 无 `role` ✓
- [x] 自动建库: `db.CreateDatabase` ✓
- [x] .env 写入: `writeEnvFile` ✓
- [x] 签名密钥生成: `genEd25519Key` + `genAESKey` ✓
- [x] Admin 初始化: `ADMIN_BOOTSTRAP_USERNAME/PASSWORD` 写入 .env ✓
- [x] Redis 检查: TCP dial 验证 ✓
- [x] Nginx 配置: Linux + 有域名时写入 ✓
- [x] 更新迁移: `db.RunSQL` 替代 mysql CLI ✓
- [x] systemd EnvironmentFile: 后端能读到 .env ✓
- [x] 服务名: `qycw` ✓
- [x] idc-cli: 已删除 ✓

