package main import ( "context" "errors" "fmt" "io/fs" "net/url" "os" "path/filepath" "strings" "github.com/joho/godotenv" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" "github.com/router-for-me/CLIProxyAPI/v6/internal/store" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" ) // BootstrapResult holds the outcome of the application bootstrap process. type BootstrapResult struct { Config *config.Config ConfigFilePath string TokenStore coreauth.Store } // Bootstrap initializes the environment, determines the storage backend, // loads the configuration, and sets up the token store. func Bootstrap(ctx context.Context, configPathOverride string, isCloudDeploy bool) (*BootstrapResult, error) { var ( err error cfg *config.Config configFilePath string tokenStore coreauth.Store usePostgresStore bool pgStoreDSN string pgStoreSchema string pgStoreLocalPath string useGitStore bool gitStoreRemoteURL string gitStoreUser string gitStorePassword string gitStoreLocalPath string useObjectStore bool objectStoreEndpoint string objectStoreAccess string objectStoreSecret string objectStoreBucket string objectStoreLocalPath string ) wd, err := os.Getwd() if err != nil { return nil, fmt.Errorf("failed to get working directory: %w", err) } // Load environment variables from .env if present. if errLoad := godotenv.Load(filepath.Join(wd, ".env")); errLoad != nil { if !errors.Is(errLoad, os.ErrNotExist) { log.WithError(errLoad).Warn("failed to load .env file") } } lookupEnv := func(keys ...string) (string, bool) { for _, key := range keys { if value, ok := os.LookupEnv(key); ok { if trimmed := strings.TrimSpace(value); trimmed != "" { return trimmed, true } } } return "", false } writableBase := util.WritablePath() if value, ok := lookupEnv("PGSTORE_DSN", "pgstore_dsn"); ok { usePostgresStore = true pgStoreDSN = value } if usePostgresStore { if value, ok := lookupEnv("PGSTORE_SCHEMA", "pgstore_schema"); ok { pgStoreSchema = value } if value, ok := lookupEnv("PGSTORE_LOCAL_PATH", "pgstore_local_path"); ok { pgStoreLocalPath = value } if pgStoreLocalPath == "" { if writableBase != "" { pgStoreLocalPath = writableBase } else { pgStoreLocalPath = wd } } useGitStore = false } if value, ok := lookupEnv("GITSTORE_GIT_URL", "gitstore_git_url"); ok { useGitStore = true gitStoreRemoteURL = value } if value, ok := lookupEnv("GITSTORE_GIT_USERNAME", "gitstore_git_username"); ok { gitStoreUser = value } if value, ok := lookupEnv("GITSTORE_GIT_TOKEN", "gitstore_git_token"); ok { gitStorePassword = value } if value, ok := lookupEnv("GITSTORE_LOCAL_PATH", "gitstore_local_path"); ok { gitStoreLocalPath = value } if value, ok := lookupEnv("OBJECTSTORE_ENDPOINT", "objectstore_endpoint"); ok { useObjectStore = true objectStoreEndpoint = value } if value, ok := lookupEnv("OBJECTSTORE_ACCESS_KEY", "objectstore_access_key"); ok { objectStoreAccess = value } if value, ok := lookupEnv("OBJECTSTORE_SECRET_KEY", "objectstore_secret_key"); ok { objectStoreSecret = value } if value, ok := lookupEnv("OBJECTSTORE_BUCKET", "objectstore_bucket"); ok { objectStoreBucket = value } if value, ok := lookupEnv("OBJECTSTORE_LOCAL_PATH", "objectstore_local_path"); ok { objectStoreLocalPath = value } // Determine and load the configuration file. // Prefer the Postgres store when configured, otherwise fallback to git or local files. if usePostgresStore { if pgStoreLocalPath == "" { pgStoreLocalPath = wd } pgStoreLocalPath = filepath.Join(pgStoreLocalPath, "pgstore") pgStoreInst, err := store.NewPostgresStore(ctx, store.PostgresStoreConfig{ DSN: pgStoreDSN, Schema: pgStoreSchema, SpoolDir: pgStoreLocalPath, }) if err != nil { return nil, fmt.Errorf("failed to initialize postgres token store: %w", err) } tokenStore = pgStoreInst examplePath := filepath.Join(wd, "config.example.yaml") if errBootstrap := pgStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil { return nil, fmt.Errorf("failed to bootstrap postgres-backed config: %w", errBootstrap) } configFilePath = pgStoreInst.ConfigPath() cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy) if err == nil { cfg.AuthDir = pgStoreInst.AuthDir() log.Infof("postgres-backed token store enabled, workspace path: %s", pgStoreInst.WorkDir()) } } else if useObjectStore { if objectStoreLocalPath == "" { if writableBase != "" { objectStoreLocalPath = writableBase } else { objectStoreLocalPath = wd } } objectStoreRoot := filepath.Join(objectStoreLocalPath, "objectstore") resolvedEndpoint := strings.TrimSpace(objectStoreEndpoint) useSSL := true if strings.Contains(resolvedEndpoint, "://") { parsed, errParse := url.Parse(resolvedEndpoint) if errParse != nil { return nil, fmt.Errorf("failed to parse object store endpoint %q: %w", objectStoreEndpoint, errParse) } switch strings.ToLower(parsed.Scheme) { case "http": useSSL = false case "https": useSSL = true default: return nil, fmt.Errorf("unsupported object store scheme %q (only http and https are allowed)", parsed.Scheme) } if parsed.Host == "" { return nil, fmt.Errorf("object store endpoint %q is missing host information", objectStoreEndpoint) } resolvedEndpoint = parsed.Host if parsed.Path != "" && parsed.Path != "/" { resolvedEndpoint = strings.TrimSuffix(parsed.Host+parsed.Path, "/") } } resolvedEndpoint = strings.TrimRight(resolvedEndpoint, "/") objCfg := store.ObjectStoreConfig{ Endpoint: resolvedEndpoint, Bucket: objectStoreBucket, AccessKey: objectStoreAccess, SecretKey: objectStoreSecret, LocalRoot: objectStoreRoot, UseSSL: useSSL, PathStyle: true, } objectStoreInst, err := store.NewObjectTokenStore(objCfg) if err != nil { return nil, fmt.Errorf("failed to initialize object token store: %w", err) } tokenStore = objectStoreInst examplePath := filepath.Join(wd, "config.example.yaml") if errBootstrap := objectStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil { return nil, fmt.Errorf("failed to bootstrap object-backed config: %w", errBootstrap) } configFilePath = objectStoreInst.ConfigPath() cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy) if err == nil { if cfg == nil { cfg = &config.Config{} } cfg.AuthDir = objectStoreInst.AuthDir() log.Infof("object-backed token store enabled, bucket: %s", objectStoreBucket) } } else if useGitStore { if gitStoreLocalPath == "" { if writableBase != "" { gitStoreLocalPath = writableBase } else { gitStoreLocalPath = wd } } gitStoreRoot := filepath.Join(gitStoreLocalPath, "gitstore") authDir := filepath.Join(gitStoreRoot, "auths") gitStoreInst := store.NewGitTokenStore(gitStoreRemoteURL, gitStoreUser, gitStorePassword) gitStoreInst.SetBaseDir(authDir) tokenStore = gitStoreInst if errRepo := gitStoreInst.EnsureRepository(); errRepo != nil { return nil, fmt.Errorf("failed to prepare git token store: %w", errRepo) } configFilePath = gitStoreInst.ConfigPath() if configFilePath == "" { configFilePath = filepath.Join(gitStoreRoot, "config", "config.yaml") } if _, statErr := os.Stat(configFilePath); errors.Is(statErr, fs.ErrNotExist) { examplePath := filepath.Join(wd, "config.example.yaml") if _, errExample := os.Stat(examplePath); errExample != nil { return nil, fmt.Errorf("failed to find template config file: %w", errExample) } if errCopy := misc.CopyConfigTemplate(examplePath, configFilePath); errCopy != nil { return nil, fmt.Errorf("failed to bootstrap git-backed config: %w", errCopy) } if errCommit := gitStoreInst.PersistConfig(context.Background()); errCommit != nil { return nil, fmt.Errorf("failed to commit initial git-backed config: %w", errCommit) } log.Infof("git-backed config initialized from template: %s", configFilePath) } else if statErr != nil { return nil, fmt.Errorf("failed to inspect git-backed config: %w", statErr) } cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy) if err == nil { cfg.AuthDir = gitStoreInst.AuthDir() log.Infof("git-backed token store enabled, repository path: %s", gitStoreRoot) } } else if configPathOverride != "" { configFilePath = configPathOverride cfg, err = config.LoadConfigOptional(configPathOverride, isCloudDeploy) } else { wd, err = os.Getwd() if err != nil { return nil, fmt.Errorf("failed to get working directory: %w", err) } configFilePath = filepath.Join(wd, "config.yaml") cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy) } if err != nil { return nil, fmt.Errorf("failed to load config: %w", err) } if cfg == nil { cfg = &config.Config{} } // If tokenStore is still nil (local file mode), use the file token store if tokenStore == nil { tokenStore = sdkAuth.NewFileTokenStore() } return &BootstrapResult{ Config: cfg, ConfigFilePath: configFilePath, TokenStore: tokenStore, }, nil }