Files
larksuite-cli/shortcuts/apps/apps_env_pull.go
2026-06-25 21:09:44 +08:00

397 lines
12 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// envKeyPattern matches valid environment variable names: [A-Za-z_][A-Za-z0-9_]*
var envKeyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
type envPullDatabaseInfo struct {
Detected bool
ExpiresAtRaw string
ExpiresAtText string
}
// AppsEnvPull pulls startup env vars for an app into the local .env.local file.
var AppsEnvPull = common.Shortcut{
Service: appsService,
Command: "+env-pull",
Description: "Pull app startup env vars into the local project .env.local",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +env-pull --app-id <app_id>",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID"},
{Name: "project-path", Desc: "local project root path (defaults to current directory)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required")
}
_, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
if err != nil {
return appsValidationParamError("--project-path", "--project-path: %v", err).WithCause(err)
}
if err := checkEnvPullTarget(envFile); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
projectPath, envFile, _ := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(envPullVarsPath(appID)).
Desc("Pull app startup env vars into the local .env.local file").
Body(envPullVarsBody()).
Set("project_path", projectPath).
Set("env_file", envFile)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
_, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
if err != nil {
return appsValidationParamError("--project-path", "--project-path: %v", err).WithCause(err)
}
if err := checkEnvPullTarget(envFile); err != nil {
return err
}
if err := rctx.EnsureScopes([]string{"spark:app:read"}); err != nil {
return err
}
data, err := rctx.CallAPITyped("POST", envPullVarsPath(appID), nil, envPullVarsBody())
if err != nil {
return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`")
}
envVars, databaseInfo, skippedKeys, err := extractEnvPullVars(data)
if err != nil {
return err
}
if envVars == nil {
envVars = map[string]string{}
}
envVars["FORCE_DB_BRANCH"] = "dev"
original, err := readEnvPullFile(envFile)
if err != nil {
return err
}
merged, updated, created := mergeEnvPullFileContent(original, envVars)
if err := ensureEnvPullParentDir(envFile); err != nil {
return err
}
if err := validate.AtomicWrite(envFile, []byte(merged), 0o600); err != nil {
return &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown, Message: fmt.Sprintf("cannot write %s: %v", envFile, err)}, Cause: err}
}
result := buildEnvPullSuccessData(appID, envFile, databaseInfo)
rctx.OutFormat(result, nil, func(w io.Writer) {
writeEnvPullPretty(w, appID, envFile, databaseInfo, skippedKeys)
})
_ = updated
_ = created
return nil
},
}
func envPullVarsPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))
}
func envPullVarsBody() map[string]interface{} {
return map[string]interface{}{
"env": "dev",
}
}
func resolveEnvPullTarget(projectPath string) (string, string, error) {
if strings.TrimSpace(projectPath) == "" {
cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded.
if err != nil {
return "", "", errs.NewInternalError(errs.SubtypeUnknown, "cannot determine working directory: %v", err).WithCause(err)
}
projectPath = cwd
}
if err := validate.RejectControlChars(projectPath, "--project-path"); err != nil {
return "", "", err
}
projectPath = filepath.Clean(projectPath)
return projectPath, filepath.Join(projectPath, ".env.local"), nil
}
func checkEnvPullTarget(envFile string) error {
info, err := os.Lstat(envFile) //nolint:forbidigo // shortcuts cannot import internal/vfs; direct lstat is needed to reject symlinks before write.
if err != nil {
if os.IsNotExist(err) {
return nil
}
return appsValidationParamError("--project-path", "cannot inspect %s: %v", envFile, err).WithCause(err)
}
if info.Mode()&os.ModeSymlink != 0 {
return appsValidationParamError("--project-path", "target %s must be a regular file, not a symlink", envFile)
}
if !info.Mode().IsRegular() {
return appsValidationParamError("--project-path", "target %s must be a regular file", envFile)
}
return nil
}
func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPullDatabaseInfo, []string, error) {
raw := data["env_vars"]
if raw == nil {
raw = data["envVars"]
}
if raw == nil {
if nested, ok := data["data"].(map[string]interface{}); ok {
raw = nested["env_vars"]
if raw == nil {
raw = nested["envVars"]
}
}
}
if raw == nil {
return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars/envVars must be an object or array of key/value entries")
}
var skippedKeys []string
switch typed := raw.(type) {
case map[string]interface{}:
out := make(map[string]string, len(typed))
for key, value := range typed {
if !envKeyPattern.MatchString(key) {
skippedKeys = append(skippedKeys, key)
continue
}
s, ok := value.(string)
if !ok {
continue
}
out[key] = s
}
return out, envPullDatabaseInfo{Detected: hasEnvPullDatabase(out)}, skippedKeys, nil
case []interface{}:
out := make(map[string]string, len(typed))
info := envPullDatabaseInfo{}
for _, item := range typed {
entry, ok := item.(map[string]interface{})
if !ok {
continue
}
key, ok := entry["key"].(string)
if !ok || strings.TrimSpace(key) == "" {
continue
}
if !envKeyPattern.MatchString(key) {
skippedKeys = append(skippedKeys, key)
continue
}
value, ok := entry["value"].(string)
if !ok {
continue
}
out[key] = value
if key == "SUDA_DATABASE_URL" {
info.Detected = true
info.ExpiresAtRaw, info.ExpiresAtText = extractEnvPullDatabaseExpiry(entry["extras"])
}
}
return out, info, skippedKeys, nil
default:
return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars/envVars must be an object or array of key/value entries")
}
}
func readEnvPullFile(envFile string) (string, error) {
data, err := os.ReadFile(envFile) //nolint:forbidigo // shortcuts cannot import internal/vfs; validated local file read for a single env file.
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown, Message: fmt.Sprintf("cannot read %s: %v", envFile, err)}, Cause: err}
}
return string(data), nil
}
func ensureEnvPullParentDir(envFile string) error {
dir := filepath.Dir(envFile)
if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; local mkdir for target env parent dir.
return &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown, Message: fmt.Sprintf("cannot create %s: %v", dir, err)}, Cause: err}
}
return nil
}
func mergeEnvPullFileContent(original string, envVars map[string]string) (string, []string, []string) {
if len(envVars) == 0 {
if original == "" {
return "", nil, nil
}
return ensureTrailingNewline(original), nil, nil
}
normalized := strings.ReplaceAll(original, "\r\n", "\n")
lines := []string{}
if normalized != "" {
lines = strings.Split(normalized, "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
}
used := make(map[string]bool, len(envVars))
updated := make([]string, 0, len(envVars))
for i, line := range lines {
key, ok := parseEnvPullAssignmentLine(line)
if !ok {
continue
}
value, exists := envVars[key]
if !exists {
continue
}
lines[i] = formatEnvPullAssignment(key, value)
updated = append(updated, key)
used[key] = true
}
created := make([]string, 0, len(envVars))
pending := make([]string, 0, len(envVars))
for key := range envVars {
if used[key] {
continue
}
pending = append(pending, key)
}
sort.Strings(pending)
for _, key := range pending {
lines = append(lines, formatEnvPullAssignment(key, envVars[key]))
created = append(created, key)
}
sort.Strings(updated)
content := strings.Join(lines, "\n")
if content != "" {
content += "\n"
}
return content, updated, created
}
func parseEnvPullAssignmentLine(line string) (string, bool) {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
return "", false
}
if strings.HasPrefix(trimmed, "export ") || strings.HasPrefix(trimmed, "export\t") {
remainder := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(trimmed, "export "), "export\t"))
if remainder == "" || strings.HasPrefix(remainder, "=") {
return "", false
}
trimmed = remainder
}
idx := strings.Index(trimmed, "=")
if idx <= 0 {
return "", false
}
key := strings.TrimSpace(trimmed[:idx])
if key == "" || strings.ContainsAny(key, " \t") {
return "", false
}
return key, true
}
func formatEnvPullAssignment(key, value string) string {
return fmt.Sprintf("%s=%s", key, strconv.Quote(value))
}
func buildEnvPullSuccessData(appID, envFile string, databaseInfo envPullDatabaseInfo) map[string]interface{} {
result := map[string]interface{}{
"app_id": appID,
"env_file": envFile,
}
if databaseInfo.ExpiresAtRaw != "" {
result["database_url_expires_at"] = databaseInfo.ExpiresAtRaw
}
return result
}
func hasEnvPullDatabase(envVars map[string]string) bool {
_, ok := envVars["SUDA_DATABASE_URL"]
return ok
}
func extractEnvPullDatabaseExpiry(rawExtras interface{}) (string, string) {
extras, ok := rawExtras.([]interface{})
if !ok {
return "", ""
}
for _, raw := range extras {
entry, ok := raw.(map[string]interface{})
if !ok {
continue
}
key, _ := entry["key"].(string)
if key != "expiresAt" {
continue
}
switch value := entry["value"].(type) {
case string:
rawValue := strings.TrimSpace(value)
ts, err := strconv.ParseInt(rawValue, 10, 64)
if err != nil {
return "", ""
}
return rawValue, time.Unix(ts, 0).Local().Format("2006-01-02 15:04:05 MST")
case float64:
ts := int64(value)
rawValue := strconv.FormatInt(ts, 10)
return rawValue, time.Unix(ts, 0).Local().Format("2006-01-02 15:04:05 MST")
}
}
return "", ""
}
func writeEnvPullPretty(w io.Writer, appID, envFile string, databaseInfo envPullDatabaseInfo, skippedKeys []string) {
fmt.Fprintf(w, "✓ App detected: %s\n", appID)
if databaseInfo.Detected {
fmt.Fprintln(w, "✓ Development database detected")
}
fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile)
if databaseInfo.ExpiresAtText != "" {
fmt.Fprintln(w)
fmt.Fprintf(w, "DATABASE_URL is valid until %s.\n", databaseInfo.ExpiresAtText)
}
if len(skippedKeys) > 0 {
fmt.Fprintln(w)
fmt.Fprintf(w, "⚠ Skipped %d invalid key(s): %s (key names must match [A-Za-z_][A-Za-z0-9_]*)\n", len(skippedKeys), strings.Join(skippedKeys, ", "))
}
fmt.Fprintf(w, "Run `lark-cli apps +env-pull --app-id <app_id>` again to refresh it.\n")
}
func ensureTrailingNewline(s string) string {
if s == "" || strings.HasSuffix(s, "\n") {
return s
}
return s + "\n"
}