`
### `GET /admin/vercel/config`
-Returns Vercel preconfiguration status.
+Returns Vercel preconfiguration status. Environment variables are preferred, then the saved `vercel` config block is used as a fallback.
```json
{
"has_token": true,
+ "token_preview": "vc****en",
+ "token_source": "config",
"project_id": "prj_xxx",
"team_id": null
}
@@ -685,6 +687,12 @@ Returns sanitized config, including both `keys` and `api_keys`.
"env_source_present": true,
"env_writeback_enabled": true,
"config_path": "/data/config.json",
+ "vercel": {
+ "has_token": true,
+ "token_preview": "vc****en",
+ "project_id": "prj_xxx",
+ "team_id": ""
+ },
"accounts": [
{
"identifier": "user@example.com",
@@ -1096,11 +1104,11 @@ The success payload includes `sample_id`, `dir`, `meta_path`, and `upstream_path
| Field | Required | Notes |
| --- | --- | --- |
-| `vercel_token` | ❌ | If empty or `__USE_PRECONFIG__`, read env |
-| `project_id` | ❌ | Fallback: `VERCEL_PROJECT_ID` |
-| `team_id` | ❌ | Fallback: `VERCEL_TEAM_ID` |
+| `vercel_token` | ❌ | If empty or `__USE_PRECONFIG__`, read env, then saved config |
+| `project_id` | ❌ | Fallback: `VERCEL_PROJECT_ID`, then saved config |
+| `team_id` | ❌ | Fallback: `VERCEL_TEAM_ID`, then saved config |
| `auto_validate` | ❌ | Default `true` |
-| `save_credentials` | ❌ | Default `true` |
+| `save_credentials` | ❌ | Default `true`; saves explicitly supplied Vercel credentials for the next sync |
**Success response**:
diff --git a/API.md b/API.md
index 4c44237..bdfe31b 100644
--- a/API.md
+++ b/API.md
@@ -671,11 +671,13 @@ data: {"type":"message_stop"}
### `GET /admin/vercel/config`
-返回 Vercel 预配置状态。
+返回 Vercel 预配置状态。优先读取环境变量,其次回退到已保存的 `vercel` 配置块。
```json
{
"has_token": true,
+ "token_preview": "vc****en",
+ "token_source": "config",
"project_id": "prj_xxx",
"team_id": null
}
@@ -696,6 +698,12 @@ data: {"type":"message_stop"}
"env_source_present": true,
"env_writeback_enabled": true,
"config_path": "/data/config.json",
+ "vercel": {
+ "has_token": true,
+ "token_preview": "vc****en",
+ "project_id": "prj_xxx",
+ "team_id": ""
+ },
"accounts": [
{
"identifier": "user@example.com",
@@ -1109,11 +1117,11 @@ data: {"type":"message_stop"}
| 字段 | 必填 | 说明 |
| --- | --- | --- |
-| `vercel_token` | ❌ | 空或 `__USE_PRECONFIG__` 则读环境变量 |
-| `project_id` | ❌ | 空则读 `VERCEL_PROJECT_ID` |
-| `team_id` | ❌ | 空则读 `VERCEL_TEAM_ID` |
+| `vercel_token` | ❌ | 空或 `__USE_PRECONFIG__` 则读环境变量,再回退到已保存配置 |
+| `project_id` | ❌ | 空则读 `VERCEL_PROJECT_ID`,再回退到已保存配置 |
+| `team_id` | ❌ | 空则读 `VERCEL_TEAM_ID`,再回退到已保存配置 |
| `auto_validate` | ❌ | 默认 `true` |
-| `save_credentials` | ❌ | 默认 `true` |
+| `save_credentials` | ❌ | 默认 `true`;保存本次显式填写的 Vercel 凭据,供下次同步复用 |
**成功响应**:
diff --git a/internal/config/codec.go b/internal/config/codec.go
index 2fa8f74..ac1876e 100644
--- a/internal/config/codec.go
+++ b/internal/config/codec.go
@@ -48,6 +48,9 @@ func (c Config) MarshalJSON() ([]byte, error) {
if c.ThinkingInjection.Enabled != nil || strings.TrimSpace(c.ThinkingInjection.Prompt) != "" {
m["thinking_injection"] = c.ThinkingInjection
}
+ if strings.TrimSpace(c.Vercel.Token) != "" || strings.TrimSpace(c.Vercel.ProjectID) != "" || strings.TrimSpace(c.Vercel.TeamID) != "" {
+ m["vercel"] = NormalizeVercelConfig(c.Vercel)
+ }
if c.VercelSyncHash != "" {
m["_vercel_sync_hash"] = c.VercelSyncHash
}
@@ -125,6 +128,10 @@ func (c *Config) UnmarshalJSON(b []byte) error {
if err := json.Unmarshal(v, &c.ThinkingInjection); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
+ case "vercel":
+ if err := json.Unmarshal(v, &c.Vercel); err != nil {
+ return fmt.Errorf("invalid field %q: %w", k, err)
+ }
case "_vercel_sync_hash":
if err := json.Unmarshal(v, &c.VercelSyncHash); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
@@ -164,6 +171,7 @@ func (c Config) Clone() Config {
Enabled: cloneBoolPtr(c.ThinkingInjection.Enabled),
Prompt: c.ThinkingInjection.Prompt,
},
+ Vercel: c.Vercel,
VercelSyncHash: c.VercelSyncHash,
VercelSyncTime: c.VercelSyncTime,
AdditionalFields: map[string]any{},
diff --git a/internal/config/config.go b/internal/config/config.go
index b63bd5d..5ed7398 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -20,6 +20,7 @@ type Config struct {
AutoDelete AutoDeleteConfig `json:"auto_delete"`
CurrentInputFile CurrentInputFileConfig `json:"current_input_file,omitempty"`
ThinkingInjection ThinkingInjectionConfig `json:"thinking_injection,omitempty"`
+ Vercel VercelConfig `json:"vercel,omitempty"`
VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"`
AdditionalFields map[string]any `json:"-"`
@@ -99,6 +100,7 @@ func (c *Config) NormalizeCredentials() {
c.Accounts[i].Remark = strings.TrimSpace(c.Accounts[i].Remark)
}
+ c.Vercel = NormalizeVercelConfig(c.Vercel)
c.normalizeModelAliases()
}
@@ -175,3 +177,24 @@ type ThinkingInjectionConfig struct {
Enabled *bool `json:"enabled,omitempty"`
Prompt string `json:"prompt,omitempty"`
}
+
+type VercelConfig struct {
+ Token string `json:"token,omitempty"`
+ ProjectID string `json:"project_id,omitempty"`
+ TeamID string `json:"team_id,omitempty"`
+}
+
+func NormalizeVercelConfig(v VercelConfig) VercelConfig {
+ return VercelConfig{
+ Token: strings.TrimSpace(v.Token),
+ ProjectID: strings.TrimSpace(v.ProjectID),
+ TeamID: strings.TrimSpace(v.TeamID),
+ }
+}
+
+func (c *Config) ClearVercelCredentials() {
+ if c == nil {
+ return
+ }
+ c.Vercel = VercelConfig{}
+}
diff --git a/internal/config/config_edge_test.go b/internal/config/config_edge_test.go
index b87154e..ceb6faa 100644
--- a/internal/config/config_edge_test.go
+++ b/internal/config/config_edge_test.go
@@ -173,6 +173,11 @@ func TestConfigJSONRoundtrip(t *testing.T) {
Runtime: RuntimeConfig{
TokenRefreshIntervalHours: 12,
},
+ Vercel: VercelConfig{
+ Token: " vercel-token ",
+ ProjectID: " prj_123 ",
+ TeamID: " team_123 ",
+ },
VercelSyncHash: "hash123",
VercelSyncTime: 1234567890,
AdditionalFields: map[string]any{
@@ -205,6 +210,9 @@ func TestConfigJSONRoundtrip(t *testing.T) {
if decoded.AutoDelete.Mode != "single" {
t.Fatalf("unexpected auto delete mode: %#v", decoded.AutoDelete.Mode)
}
+ if decoded.Vercel.Token != "vercel-token" || decoded.Vercel.ProjectID != "prj_123" || decoded.Vercel.TeamID != "team_123" {
+ t.Fatalf("unexpected vercel config: %#v", decoded.Vercel)
+ }
if decoded.VercelSyncHash != "hash123" {
t.Fatalf("unexpected vercel sync hash: %q", decoded.VercelSyncHash)
}
diff --git a/internal/httpapi/admin/auth/deps.go b/internal/httpapi/admin/auth/deps.go
index 72063f6..4f8b9c3 100644
--- a/internal/httpapi/admin/auth/deps.go
+++ b/internal/httpapi/admin/auth/deps.go
@@ -15,5 +15,6 @@ type Handler struct {
var writeJSON = adminshared.WriteJSON
var intFrom = adminshared.IntFrom
+var maskSecretPreview = adminshared.MaskSecretPreview
func nilIfEmpty(s string) any { return adminshared.NilIfEmpty(s) }
diff --git a/internal/httpapi/admin/auth/handler_auth.go b/internal/httpapi/admin/auth/handler_auth.go
index 18ef6fa..d7a04d5 100644
--- a/internal/httpapi/admin/auth/handler_auth.go
+++ b/internal/httpapi/admin/auth/handler_auth.go
@@ -61,9 +61,34 @@ func (h *Handler) verify(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) getVercelConfig(w http.ResponseWriter, _ *http.Request) {
+ saved := h.Store.Snapshot().Vercel
+ token, tokenSource := firstConfiguredValue(
+ [2]string{"env", os.Getenv("VERCEL_TOKEN")},
+ [2]string{"config", saved.Token},
+ )
+ projectID, _ := firstConfiguredValue(
+ [2]string{"env", os.Getenv("VERCEL_PROJECT_ID")},
+ [2]string{"config", saved.ProjectID},
+ )
+ teamID, _ := firstConfiguredValue(
+ [2]string{"env", os.Getenv("VERCEL_TEAM_ID")},
+ [2]string{"config", saved.TeamID},
+ )
writeJSON(w, http.StatusOK, map[string]any{
- "has_token": strings.TrimSpace(os.Getenv("VERCEL_TOKEN")) != "",
- "project_id": strings.TrimSpace(os.Getenv("VERCEL_PROJECT_ID")),
- "team_id": nilIfEmpty(strings.TrimSpace(os.Getenv("VERCEL_TEAM_ID"))),
+ "has_token": token != "",
+ "token_preview": maskSecretPreview(token),
+ "token_source": nilIfEmpty(tokenSource),
+ "project_id": projectID,
+ "team_id": nilIfEmpty(teamID),
})
}
+
+func firstConfiguredValue(values ...[2]string) (string, string) {
+ for _, pair := range values {
+ value := strings.TrimSpace(pair[1])
+ if value != "" {
+ return value, strings.TrimSpace(pair[0])
+ }
+ }
+ return "", ""
+}
diff --git a/internal/httpapi/admin/auth/handler_auth_test.go b/internal/httpapi/admin/auth/handler_auth_test.go
new file mode 100644
index 0000000..e3db5b4
--- /dev/null
+++ b/internal/httpapi/admin/auth/handler_auth_test.go
@@ -0,0 +1,38 @@
+package auth
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "ds2api/internal/config"
+)
+
+func TestGetVercelConfigFallsBackToSavedConfig(t *testing.T) {
+ t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"vercel":{"token":"saved-token","project_id":"saved-project","team_id":"saved-team"}}`)
+ t.Setenv("VERCEL_TOKEN", "")
+ t.Setenv("VERCEL_PROJECT_ID", "")
+ t.Setenv("VERCEL_TEAM_ID", "")
+ h := &Handler{Store: config.LoadStore()}
+
+ rec := httptest.NewRecorder()
+ h.getVercelConfig(rec, httptest.NewRequest(http.MethodGet, "/admin/vercel/config", nil))
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
+ }
+ var payload map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if payload["has_token"] != true {
+ t.Fatalf("expected saved token to be detected: %#v", payload)
+ }
+ if payload["token_source"] != "config" || payload["project_id"] != "saved-project" || payload["team_id"] != "saved-team" {
+ t.Fatalf("unexpected preconfig payload: %#v", payload)
+ }
+ if payload["token_preview"] == "saved-token" {
+ t.Fatal("token preview leaked the full token")
+ }
+}
diff --git a/internal/httpapi/admin/configmgmt/handler_config_import.go b/internal/httpapi/admin/configmgmt/handler_config_import.go
index cd1d860..0060591 100644
--- a/internal/httpapi/admin/configmgmt/handler_config_import.go
+++ b/internal/httpapi/admin/configmgmt/handler_config_import.go
@@ -94,6 +94,10 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(incoming.Embeddings.Provider) != "" {
next.Embeddings.Provider = incoming.Embeddings.Provider
}
+ incomingVercel := config.NormalizeVercelConfig(incoming.Vercel)
+ if strings.TrimSpace(incomingVercel.Token) != "" || strings.TrimSpace(incomingVercel.ProjectID) != "" || strings.TrimSpace(incomingVercel.TeamID) != "" {
+ next.Vercel = incomingVercel
+ }
if strings.TrimSpace(incoming.Admin.PasswordHash) != "" {
next.Admin.PasswordHash = incoming.Admin.PasswordHash
}
diff --git a/internal/httpapi/admin/configmgmt/handler_config_read.go b/internal/httpapi/admin/configmgmt/handler_config_read.go
index e039bd1..74157f9 100644
--- a/internal/httpapi/admin/configmgmt/handler_config_read.go
+++ b/internal/httpapi/admin/configmgmt/handler_config_read.go
@@ -19,6 +19,12 @@ func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) {
"env_writeback_enabled": h.Store.IsEnvWritebackEnabled(),
"config_path": h.Store.ConfigPath(),
"model_aliases": snap.ModelAliases,
+ "vercel": map[string]any{
+ "has_token": strings.TrimSpace(snap.Vercel.Token) != "",
+ "token_preview": maskSecretPreview(snap.Vercel.Token),
+ "project_id": snap.Vercel.ProjectID,
+ "team_id": snap.Vercel.TeamID,
+ },
}
accounts := make([]map[string]any, 0, len(snap.Accounts))
for _, acc := range snap.Accounts {
diff --git a/internal/httpapi/admin/shared/helpers.go b/internal/httpapi/admin/shared/helpers.go
index 93b6937..bc78abb 100644
--- a/internal/httpapi/admin/shared/helpers.go
+++ b/internal/httpapi/admin/shared/helpers.go
@@ -78,6 +78,7 @@ func ComputeSyncHash(store ConfigStore) string {
}
snap := store.Snapshot().Clone()
snap.ClearAccountTokens()
+ snap.ClearVercelCredentials()
snap.VercelSyncHash = ""
snap.VercelSyncTime = 0
b, _ := json.Marshal(snap)
@@ -93,6 +94,7 @@ func SyncHashForJSON(s string) string {
cfg.VercelSyncHash = ""
cfg.VercelSyncTime = 0
cfg.ClearAccountTokens()
+ cfg.ClearVercelCredentials()
b, err := json.Marshal(cfg)
if err != nil {
return ""
diff --git a/internal/httpapi/admin/vercel/handler_vercel.go b/internal/httpapi/admin/vercel/handler_vercel.go
index cfd13e1..4b56df4 100644
--- a/internal/httpapi/admin/vercel/handler_vercel.go
+++ b/internal/httpapi/admin/vercel/handler_vercel.go
@@ -23,7 +23,7 @@ func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid json"})
return
}
- opts, err := parseVercelSyncOptions(req)
+ opts, err := parseVercelSyncOptions(req, h.Store.Snapshot().Vercel)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
@@ -50,6 +50,12 @@ func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) {
return
}
savedCreds := h.saveVercelProjectCredentials(r.Context(), client, opts, params, headers, envs)
+ credentialsWarning := ""
+ if saved, err := h.saveLocalVercelCredentials(opts); err == nil && saved {
+ savedCreds = append(savedCreds, "config.vercel")
+ } else if err != nil {
+ credentialsWarning = "保存 Vercel 凭据到本地配置失败: " + err.Error()
+ }
manual, deployURL := triggerVercelDeployment(r.Context(), client, opts.ProjectID, params, headers)
_ = h.Store.SetVercelSync(syncHashForJSON(cfgJSON), time.Now().Unix())
result := map[string]any{"success": true, "validated_accounts": validated}
@@ -66,6 +72,9 @@ func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) {
if len(savedCreds) > 0 {
result["saved_credentials"] = savedCreds
}
+ if credentialsWarning != "" {
+ result["credentials_warning"] = credentialsWarning
+ }
writeJSON(w, http.StatusOK, result)
}
@@ -78,7 +87,7 @@ type vercelSyncOptions struct {
UsePreconfig bool
}
-func parseVercelSyncOptions(req map[string]any) (vercelSyncOptions, error) {
+func parseVercelSyncOptions(req map[string]any, saved config.VercelConfig) (vercelSyncOptions, error) {
vercelToken, _ := req["vercel_token"].(string)
projectID, _ := req["project_id"].(string)
teamID, _ := req["team_id"].(string)
@@ -92,13 +101,13 @@ func parseVercelSyncOptions(req map[string]any) (vercelSyncOptions, error) {
}
usePreconfig := vercelToken == "__USE_PRECONFIG__" || strings.TrimSpace(vercelToken) == ""
if usePreconfig {
- vercelToken = strings.TrimSpace(os.Getenv("VERCEL_TOKEN"))
+ vercelToken = firstNonEmpty(os.Getenv("VERCEL_TOKEN"), saved.Token)
}
if strings.TrimSpace(projectID) == "" {
- projectID = strings.TrimSpace(os.Getenv("VERCEL_PROJECT_ID"))
+ projectID = firstNonEmpty(os.Getenv("VERCEL_PROJECT_ID"), saved.ProjectID)
}
if strings.TrimSpace(teamID) == "" {
- teamID = strings.TrimSpace(os.Getenv("VERCEL_TEAM_ID"))
+ teamID = firstNonEmpty(os.Getenv("VERCEL_TEAM_ID"), saved.TeamID)
}
vercelToken = strings.TrimSpace(vercelToken)
projectID = strings.TrimSpace(projectID)
@@ -116,6 +125,15 @@ func parseVercelSyncOptions(req map[string]any) (vercelSyncOptions, error) {
}, nil
}
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if trimmed := strings.TrimSpace(value); trimmed != "" {
+ return trimmed
+ }
+ }
+ return ""
+}
+
func buildVercelParams(teamID string) url.Values {
params := url.Values{}
if strings.TrimSpace(teamID) != "" {
@@ -178,6 +196,25 @@ func (h *Handler) saveVercelProjectCredentials(ctx context.Context, client *http
return saved
}
+func (h *Handler) saveLocalVercelCredentials(opts vercelSyncOptions) (bool, error) {
+ if !opts.SaveCreds {
+ return false, nil
+ }
+ err := h.Store.Update(func(c *config.Config) error {
+ token := opts.VercelToken
+ if opts.UsePreconfig {
+ token = c.Vercel.Token
+ }
+ c.Vercel = config.NormalizeVercelConfig(config.VercelConfig{
+ Token: token,
+ ProjectID: opts.ProjectID,
+ TeamID: opts.TeamID,
+ })
+ return nil
+ })
+ return err == nil, err
+}
+
func triggerVercelDeployment(ctx context.Context, client *http.Client, projectID string, params url.Values, headers map[string]string) (bool, string) {
projectResp, status, _ := vercelRequest(ctx, client, http.MethodGet, "https://api.vercel.com/v9/projects/"+projectID, params, headers, nil)
if status != http.StatusOK {
@@ -243,7 +280,7 @@ func (h *Handler) vercelStatus(w http.ResponseWriter, r *http.Request) {
func (h *Handler) exportSyncConfig(req map[string]any) (string, string, error) {
override, ok := req["config_override"]
if !ok || override == nil {
- return h.Store.ExportJSONAndBase64()
+ return encodeVercelSyncConfig(h.Store.Snapshot())
}
raw, err := json.Marshal(override)
if err != nil {
@@ -253,8 +290,13 @@ func (h *Handler) exportSyncConfig(req map[string]any) (string, string, error) {
if err := json.Unmarshal(raw, &cfg); err != nil {
return "", "", err
}
+ return encodeVercelSyncConfig(cfg)
+}
+
+func encodeVercelSyncConfig(cfg config.Config) (string, string, error) {
cfg.DropInvalidAccounts()
cfg.ClearAccountTokens()
+ cfg.ClearVercelCredentials()
cfg.VercelSyncHash = ""
cfg.VercelSyncTime = 0
b, err := json.Marshal(cfg)
@@ -272,6 +314,7 @@ func syncHashForJSON(s string) string {
cfg.VercelSyncHash = ""
cfg.VercelSyncTime = 0
cfg.ClearAccountTokens()
+ cfg.ClearVercelCredentials()
b, err := json.Marshal(cfg)
if err != nil {
return ""
diff --git a/internal/httpapi/admin/vercel/handler_vercel_test.go b/internal/httpapi/admin/vercel/handler_vercel_test.go
new file mode 100644
index 0000000..66aa618
--- /dev/null
+++ b/internal/httpapi/admin/vercel/handler_vercel_test.go
@@ -0,0 +1,100 @@
+package vercel
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "ds2api/internal/config"
+)
+
+func TestParseVercelSyncOptionsFallsBackToSavedConfig(t *testing.T) {
+ t.Setenv("VERCEL_TOKEN", "")
+ t.Setenv("VERCEL_PROJECT_ID", "")
+ t.Setenv("VERCEL_TEAM_ID", "")
+
+ opts, err := parseVercelSyncOptions(map[string]any{
+ "vercel_token": "__USE_PRECONFIG__",
+ }, config.VercelConfig{
+ Token: " saved-token ",
+ ProjectID: " saved-project ",
+ TeamID: " saved-team ",
+ })
+ if err != nil {
+ t.Fatalf("parse options error: %v", err)
+ }
+ if opts.VercelToken != "saved-token" || opts.ProjectID != "saved-project" || opts.TeamID != "saved-team" {
+ t.Fatalf("unexpected options: %#v", opts)
+ }
+ if !opts.UsePreconfig {
+ t.Fatal("expected preconfig mode")
+ }
+}
+
+func TestSaveLocalVercelCredentialsStoresExplicitInput(t *testing.T) {
+ t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"]}`)
+ store := config.LoadStore()
+ h := &Handler{Store: store}
+
+ saved, err := h.saveLocalVercelCredentials(vercelSyncOptions{
+ VercelToken: " token ",
+ ProjectID: " project ",
+ TeamID: " team ",
+ SaveCreds: true,
+ })
+ if err != nil {
+ t.Fatalf("save local credentials error: %v", err)
+ }
+ if !saved {
+ t.Fatal("expected credentials to be saved")
+ }
+ got := store.Snapshot().Vercel
+ if got.Token != "token" || got.ProjectID != "project" || got.TeamID != "team" {
+ t.Fatalf("unexpected saved credentials: %#v", got)
+ }
+}
+
+func TestSaveLocalVercelCredentialsPreservesPreconfiguredTokenAndUpdatesProject(t *testing.T) {
+ t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"vercel":{"token":"saved-token","project_id":"old-project","team_id":"old-team"}}`)
+ store := config.LoadStore()
+ h := &Handler{Store: store}
+
+ saved, err := h.saveLocalVercelCredentials(vercelSyncOptions{
+ VercelToken: "resolved-token",
+ ProjectID: "new-project",
+ TeamID: "new-team",
+ SaveCreds: true,
+ UsePreconfig: true,
+ })
+ if err != nil {
+ t.Fatalf("save local credentials error: %v", err)
+ }
+ if !saved {
+ t.Fatal("expected project/team updates to be saved")
+ }
+ got := store.Snapshot().Vercel
+ if got.Token != "saved-token" || got.ProjectID != "new-project" || got.TeamID != "new-team" {
+ t.Fatalf("unexpected saved credentials: %#v", got)
+ }
+}
+
+func TestExportSyncConfigStripsSavedVercelCredentials(t *testing.T) {
+ t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"vercel":{"token":"secret-token","project_id":"project","team_id":"team"}}`)
+ store := config.LoadStore()
+ h := &Handler{Store: store}
+
+ jsonStr, _, err := h.exportSyncConfig(map[string]any{})
+ if err != nil {
+ t.Fatalf("export sync config error: %v", err)
+ }
+ if strings.Contains(jsonStr, "secret-token") || strings.Contains(jsonStr, `"vercel"`) {
+ t.Fatalf("expected sync export to strip Vercel credentials, got %s", jsonStr)
+ }
+ var exported config.Config
+ if err := json.Unmarshal([]byte(jsonStr), &exported); err != nil {
+ t.Fatalf("exported config is invalid JSON: %v", err)
+ }
+ if len(exported.Keys) != 1 || exported.Keys[0] != "k1" {
+ t.Fatalf("unexpected exported config: %#v", exported)
+ }
+}
diff --git a/webui/src/features/vercel/VercelSyncContainer.jsx b/webui/src/features/vercel/VercelSyncContainer.jsx
index 5acfaa8..493ae4d 100644
--- a/webui/src/features/vercel/VercelSyncContainer.jsx
+++ b/webui/src/features/vercel/VercelSyncContainer.jsx
@@ -15,6 +15,8 @@ export default function VercelSyncContainer({ onMessage, authFetch, isVercel = f
setProjectId,
teamId,
setTeamId,
+ saveCredentials,
+ setSaveCredentials,
loading,
result,
preconfig,
@@ -46,6 +48,8 @@ export default function VercelSyncContainer({ onMessage, authFetch, isVercel = f
setProjectId={setProjectId}
teamId={teamId}
setTeamId={setTeamId}
+ saveCredentials={saveCredentials}
+ setSaveCredentials={setSaveCredentials}
loading={loading}
onSync={handleSync}
/>
diff --git a/webui/src/features/vercel/VercelSyncForm.jsx b/webui/src/features/vercel/VercelSyncForm.jsx
index a394435..67cf748 100644
--- a/webui/src/features/vercel/VercelSyncForm.jsx
+++ b/webui/src/features/vercel/VercelSyncForm.jsx
@@ -14,6 +14,8 @@ export default function VercelSyncForm({
setProjectId,
teamId,
setTeamId,
+ saveCredentials,
+ setSaveCredentials,
loading,
onSync,
}) {
@@ -124,6 +126,19 @@ export default function VercelSyncForm({
onChange={e => setTeamId(e.target.value)}
/>
+
+
diff --git a/webui/src/features/vercel/useVercelSyncState.js b/webui/src/features/vercel/useVercelSyncState.js
index 2db27b1..ed0c539 100644
--- a/webui/src/features/vercel/useVercelSyncState.js
+++ b/webui/src/features/vercel/useVercelSyncState.js
@@ -12,6 +12,7 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false })
const [vercelToken, setVercelToken] = useState('')
const [projectId, setProjectId] = useState('')
const [teamId, setTeamId] = useState('')
+ const [saveCredentials, setSaveCredentials] = useState(true)
const [loading, setLoading] = useState(false)
const [result, setResult] = useState(null)
const [preconfig, setPreconfig] = useState(null)
@@ -117,6 +118,7 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false })
vercel_token: tokenToUse,
project_id: projectId,
team_id: teamId || undefined,
+ save_credentials: saveCredentials,
}),
})
const data = await res.json()
@@ -133,7 +135,7 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false })
} finally {
setLoading(false)
}
- }, [apiFetch, fetchSyncStatus, onMessage, preconfig?.has_token, projectId, t, teamId, vercelToken])
+ }, [apiFetch, fetchSyncStatus, onMessage, preconfig?.has_token, projectId, saveCredentials, t, teamId, vercelToken])
return {
vercelToken,
@@ -142,6 +144,8 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false })
setProjectId,
teamId,
setTeamId,
+ saveCredentials,
+ setSaveCredentials,
loading,
result,
preconfig,
diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json
index 3e609d5..c0db122 100644
--- a/webui/src/locales/en.json
+++ b/webui/src/locales/en.json
@@ -462,6 +462,8 @@
"projectIdHint": "Find it in Project Settings → General.",
"teamIdLabel": "Team ID",
"optional": "optional",
+ "saveCredentials": "Remember Vercel credentials",
+ "saveCredentialsHint": "Save the token, project ID, and team ID for the next sync.",
"syncing": "Syncing...",
"syncRedeploy": "Sync & redeploy",
"redeployHint": "This triggers a Vercel redeploy and usually takes 30–60 seconds.",
diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json
index 596824c..7508a39 100644
--- a/webui/src/locales/zh.json
+++ b/webui/src/locales/zh.json
@@ -462,6 +462,8 @@
"projectIdHint": "可在项目设置 (Project Settings) → 常规 (General) 中找到",
"teamIdLabel": "团队 ID",
"optional": "可选",
+ "saveCredentials": "记住 Vercel 凭据",
+ "saveCredentialsHint": "保存访问令牌、项目 ID 和团队 ID,供下次同步直接复用。",
"syncing": "正在同步...",
"syncRedeploy": "同步并重新部署",
"redeployHint": "这将触发 Vercel 的重新部署,大约需要 30-60 秒。",
From 70076c217fc7c90796e52124e14f063a034e2df6 Mon Sep 17 00:00:00 2001
From: "CJACK." <155826701+CJackHwang@users.noreply.github.com>
Date: Mon, 4 May 2026 23:16:33 +0800
Subject: [PATCH 3/3] Update VERSION
---
VERSION | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/VERSION b/VERSION
index cca25a9..1d068c6 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-4.4.1
+4.4.2