package main import ( "context" "flag" "fmt" "io" "log" "os" "strings" "sub2api-cn-relay-manager/internal/config" "sub2api-cn-relay-manager/internal/host/sub2api" "sub2api-cn-relay-manager/internal/pack" "sub2api-cn-relay-manager/internal/provision" "sub2api-cn-relay-manager/internal/store/sqlite" ) type installPackFunc func(context.Context, installPackCLIRequest) (provision.PackInstallResult, error) type importProviderFunc func(context.Context, importCLIRequest) (provision.ImportReport, error) type previewProviderFunc func(context.Context, previewCLIRequest) (provision.PreviewReport, error) type rollbackProviderFunc func(context.Context, rollbackCLIRequest) (rollbackSummary, error) type reconcileProviderFunc func(context.Context, reconcileCLIRequest) (provision.ReconcileResult, error) type installPackCLIRequest struct { HostBaseURL string HostAPIKey string HostBearerToken string PackPath string } type importCLIRequest struct { HostBaseURL string HostAPIKey string HostBearerToken string PackDir string ProviderID string Keys []string Mode string AccessMode string AccessAPIKey string SubscriptionUsers []string SubscriptionDays int } type previewCLIRequest struct { HostBaseURL string HostAPIKey string HostBearerToken string PackDir string ProviderID string Keys []string Mode string } type rollbackCLIRequest struct { HostBaseURL string HostAPIKey string HostBearerToken string PackDir string ProviderID string } type reconcileCLIRequest struct { HostBaseURL string HostAPIKey string HostBearerToken string PackDir string ProviderID string AccessAPIKey string } type rollbackSummary struct { Accounts int Plans int Channels int Groups int } func main() { if err := execute(context.Background(), log.Writer(), os.Args[1:], func(context.Context) (config.StartupConfig, error) { return config.LoadStartupFromEnv() }, runInstallPack, runImportProvider, runPreviewProvider, runRollbackProvider, runReconcileProvider, runBatchImport); err != nil { log.Fatalf("run cli: %v", err) } } func execute( ctx context.Context, output io.Writer, args []string, loadConfig func(context.Context) (config.StartupConfig, error), installPack installPackFunc, importProvider importProviderFunc, previewProvider previewProviderFunc, rollbackProvider rollbackProviderFunc, reconcileProvider reconcileProviderFunc, batchImport batchImportFunc, ) error { if len(args) > 0 && args[0] == "batch-import" { req, err := parseBatchImportCLIArgs(args[1:]) if err != nil { return err } result, err := batchImport(ctx, req) if err != nil { return err } _, err = fmt.Fprintf(output, "run_id=%s\nresult_page=%s\n", result.RunID, result.ResultPage) return err } if len(args) > 0 && args[0] == "install-pack" { req, err := parseInstallPackCLIArgs(args[1:]) if err != nil { return err } result, err := installPack(ctx, req) if err != nil { return err } _, err = fmt.Fprintf(output, "pack_id=%s\nversion=%s\nhost_version=%s\nproviders=%d\nalready_installed=%t\n", result.Pack.PackID, result.Pack.Version, result.HostVersion, len(result.Providers), result.AlreadyInstalled) return err } if len(args) > 0 && args[0] == "import-provider" { req, err := parseImportCLIArgs(args[1:]) if err != nil { return err } report, err := importProvider(ctx, req) if err != nil { _, _ = fmt.Fprintf(output, "batch_status=%s\nprovider_status=%s\naccess_status=%s\n", report.BatchStatus, report.ProviderStatus, report.AccessStatus) return err } _, err = fmt.Fprintf(output, "batch_status=%s\nprovider_status=%s\naccess_status=%s\naccounts=%d\n", report.BatchStatus, report.ProviderStatus, report.AccessStatus, len(report.Accounts)) return err } if len(args) > 0 && args[0] == "preview-provider" { req, err := parsePreviewCLIArgs(args[1:]) if err != nil { return err } report, err := previewProvider(ctx, req) if err != nil { return err } _, err = fmt.Fprintf(output, "accepted_keys=%d\ngroup=%s\nchannel=%s\nplan=%s\n", len(report.AcceptedKeys), report.Decisions["group"].Action, report.Decisions["channel"].Action, report.Decisions["plan"].Action) return err } if len(args) > 0 && args[0] == "rollback-provider" { req, err := parseRollbackCLIArgs(args[1:]) if err != nil { return err } summary, err := rollbackProvider(ctx, req) if err != nil { return err } _, err = fmt.Fprintf(output, "deleted_accounts=%d\ndeleted_plans=%d\ndeleted_channels=%d\ndeleted_groups=%d\n", summary.Accounts, summary.Plans, summary.Channels, summary.Groups) return err } if len(args) > 0 && args[0] == "reconcile-provider" { req, err := parseReconcileCLIArgs(args[1:]) if err != nil { return err } result, err := reconcileProvider(ctx, req) if err != nil { return err } _, err = fmt.Fprintf(output, "status=%s\nmissing_count=%d\nextra_count=%d\nprobe_failures=%d\naccess_status=%s\n", result.Status, result.MissingCount, result.ExtraCount, result.ProbeFailureCount, result.AccessStatus) return err } cfg, err := loadConfig(ctx) if err != nil { return err } _, err = fmt.Fprintf(output, "sub2api-cn-relay-manager cli ready\nlisten_addr=%s\nsqlite_dsn=%s\n", cfg.Server.ListenAddr, cfg.Database.SQLiteDSN) return err } func parseInstallPackCLIArgs(args []string) (installPackCLIRequest, error) { fs := flag.NewFlagSet("install-pack", flag.ContinueOnError) fs.SetOutput(io.Discard) var req installPackCLIRequest fs.StringVar(&req.HostBaseURL, "host-base-url", "", "") fs.StringVar(&req.HostAPIKey, "host-api-key", "", "") fs.StringVar(&req.HostBearerToken, "host-bearer-token", "", "") fs.StringVar(&req.PackPath, "pack-path", "", "") if err := fs.Parse(args); err != nil { return installPackCLIRequest{}, err } switch { case strings.TrimSpace(req.HostBaseURL) == "": return installPackCLIRequest{}, fmt.Errorf("--host-base-url is required") case strings.TrimSpace(req.PackPath) == "": return installPackCLIRequest{}, fmt.Errorf("--pack-path is required") } return req, nil } func parseImportCLIArgs(args []string) (importCLIRequest, error) { fs := flag.NewFlagSet("import-provider", flag.ContinueOnError) fs.SetOutput(io.Discard) var req importCLIRequest var keysCSV string var subscriptionUsersCSV string fs.StringVar(&req.HostBaseURL, "host-base-url", "", "") fs.StringVar(&req.HostAPIKey, "host-api-key", "", "") fs.StringVar(&req.HostBearerToken, "host-bearer-token", "", "") fs.StringVar(&req.PackDir, "pack-dir", "", "") fs.StringVar(&req.ProviderID, "provider-id", "", "") fs.StringVar(&keysCSV, "keys", "", "") fs.StringVar(&req.Mode, "mode", provision.ImportModePartial, "") fs.StringVar(&req.AccessMode, "access-mode", provision.AccessModeSelfService, "") fs.StringVar(&req.AccessAPIKey, "access-api-key", "", "") fs.StringVar(&subscriptionUsersCSV, "subscription-users", "", "") fs.IntVar(&req.SubscriptionDays, "subscription-days", 30, "") if err := fs.Parse(args); err != nil { return importCLIRequest{}, err } req.Keys = splitCSV(keysCSV) req.SubscriptionUsers = splitCSV(subscriptionUsersCSV) switch { case strings.TrimSpace(req.HostBaseURL) == "": return importCLIRequest{}, fmt.Errorf("--host-base-url is required") case strings.TrimSpace(req.PackDir) == "": return importCLIRequest{}, fmt.Errorf("--pack-dir is required") case strings.TrimSpace(req.ProviderID) == "": return importCLIRequest{}, fmt.Errorf("--provider-id is required") case len(req.Keys) == 0: return importCLIRequest{}, fmt.Errorf("--keys is required") case strings.TrimSpace(req.AccessAPIKey) == "": return importCLIRequest{}, fmt.Errorf("--access-api-key is required") } return req, nil } func parsePreviewCLIArgs(args []string) (previewCLIRequest, error) { fs := flag.NewFlagSet("preview-provider", flag.ContinueOnError) fs.SetOutput(io.Discard) var req previewCLIRequest var keysCSV string fs.StringVar(&req.HostBaseURL, "host-base-url", "", "") fs.StringVar(&req.HostAPIKey, "host-api-key", "", "") fs.StringVar(&req.HostBearerToken, "host-bearer-token", "", "") fs.StringVar(&req.PackDir, "pack-dir", "", "") fs.StringVar(&req.ProviderID, "provider-id", "", "") fs.StringVar(&keysCSV, "keys", "", "") fs.StringVar(&req.Mode, "mode", provision.ImportModePartial, "") if err := fs.Parse(args); err != nil { return previewCLIRequest{}, err } req.Keys = splitCSV(keysCSV) switch { case strings.TrimSpace(req.HostBaseURL) == "": return previewCLIRequest{}, fmt.Errorf("--host-base-url is required") case strings.TrimSpace(req.PackDir) == "": return previewCLIRequest{}, fmt.Errorf("--pack-dir is required") case strings.TrimSpace(req.ProviderID) == "": return previewCLIRequest{}, fmt.Errorf("--provider-id is required") case len(req.Keys) == 0: return previewCLIRequest{}, fmt.Errorf("--keys is required") } return req, nil } func parseRollbackCLIArgs(args []string) (rollbackCLIRequest, error) { fs := flag.NewFlagSet("rollback-provider", flag.ContinueOnError) fs.SetOutput(io.Discard) var req rollbackCLIRequest fs.StringVar(&req.HostBaseURL, "host-base-url", "", "") fs.StringVar(&req.HostAPIKey, "host-api-key", "", "") fs.StringVar(&req.HostBearerToken, "host-bearer-token", "", "") fs.StringVar(&req.PackDir, "pack-dir", "", "") fs.StringVar(&req.ProviderID, "provider-id", "", "") if err := fs.Parse(args); err != nil { return rollbackCLIRequest{}, err } switch { case strings.TrimSpace(req.HostBaseURL) == "": return rollbackCLIRequest{}, fmt.Errorf("--host-base-url is required") case strings.TrimSpace(req.PackDir) == "": return rollbackCLIRequest{}, fmt.Errorf("--pack-dir is required") case strings.TrimSpace(req.ProviderID) == "": return rollbackCLIRequest{}, fmt.Errorf("--provider-id is required") } return req, nil } func parseReconcileCLIArgs(args []string) (reconcileCLIRequest, error) { fs := flag.NewFlagSet("reconcile-provider", flag.ContinueOnError) fs.SetOutput(io.Discard) var req reconcileCLIRequest fs.StringVar(&req.HostBaseURL, "host-base-url", "", "") fs.StringVar(&req.HostAPIKey, "host-api-key", "", "") fs.StringVar(&req.HostBearerToken, "host-bearer-token", "", "") fs.StringVar(&req.PackDir, "pack-dir", "", "") fs.StringVar(&req.ProviderID, "provider-id", "", "") fs.StringVar(&req.AccessAPIKey, "access-api-key", "", "") if err := fs.Parse(args); err != nil { return reconcileCLIRequest{}, err } switch { case strings.TrimSpace(req.HostBaseURL) == "": return reconcileCLIRequest{}, fmt.Errorf("--host-base-url is required") case strings.TrimSpace(req.PackDir) == "": return reconcileCLIRequest{}, fmt.Errorf("--pack-dir is required") case strings.TrimSpace(req.ProviderID) == "": return reconcileCLIRequest{}, fmt.Errorf("--provider-id is required") } return req, nil } func runInstallPack(ctx context.Context, req installPackCLIRequest) (provision.PackInstallResult, error) { loadedPack, err := pack.LoadPath(req.PackPath) if err != nil { return provision.PackInstallResult{}, err } client, err := sub2api.NewClient(req.HostBaseURL, sub2api.WithAPIKey(req.HostAPIKey), sub2api.WithBearerToken(req.HostBearerToken)) if err != nil { return provision.PackInstallResult{}, err } startupConfig, err := config.LoadStartupFromEnv() if err != nil { return provision.PackInstallResult{}, err } store, err := sqlite.Open(ctx, startupConfig.Database.SQLiteDSN) if err != nil { return provision.PackInstallResult{}, err } defer store.Close() service := provision.NewPackInstallService(store, client) return service.Install(ctx, provision.PackInstallRequest{Pack: loadedPack}) } func runImportProvider(ctx context.Context, req importCLIRequest) (provision.ImportReport, error) { loadedPack, err := pack.LoadDir(req.PackDir) if err != nil { return provision.ImportReport{}, err } providerManifest, err := findProvider(loadedPack, req.ProviderID) if err != nil { return provision.ImportReport{}, err } client, err := sub2api.NewClient(req.HostBaseURL, sub2api.WithAPIKey(req.HostAPIKey), sub2api.WithBearerToken(req.HostBearerToken)) if err != nil { return provision.ImportReport{}, err } startupConfig, err := config.LoadStartupFromEnv() if err != nil { return provision.ImportReport{}, err } store, err := sqlite.Open(ctx, startupConfig.Database.SQLiteDSN) if err != nil { return provision.ImportReport{}, err } defer store.Close() subscriptions := make([]provision.SubscriptionTarget, 0, len(req.SubscriptionUsers)) for _, userID := range req.SubscriptionUsers { subscriptions = append(subscriptions, provision.SubscriptionTarget{UserID: userID, DurationDays: req.SubscriptionDays}) } runtimeService := provision.NewRuntimeImportService(store, client) result, err := runtimeService.Import(ctx, provision.RuntimeImportRequest{ HostBaseURL: req.HostBaseURL, Pack: loadedPack, Provider: providerManifest, Mode: req.Mode, Keys: req.Keys, Access: provision.AccessRequest{ Mode: req.AccessMode, ProbeAPIKey: req.AccessAPIKey, Subscriptions: subscriptions, }, }) return result.Report, err } func runPreviewProvider(ctx context.Context, req previewCLIRequest) (provision.PreviewReport, error) { loadedPack, err := pack.LoadDir(req.PackDir) if err != nil { return provision.PreviewReport{}, err } providerManifest, err := findProvider(loadedPack, req.ProviderID) if err != nil { return provision.PreviewReport{}, err } client, err := sub2api.NewClient(req.HostBaseURL, sub2api.WithAPIKey(req.HostAPIKey), sub2api.WithBearerToken(req.HostBearerToken)) if err != nil { return provision.PreviewReport{}, err } service := provision.NewPreviewService(client) return service.PreviewImport(ctx, provision.PreviewRequest{ Provider: providerManifest, Mode: req.Mode, Keys: req.Keys, }) } func runRollbackProvider(ctx context.Context, req rollbackCLIRequest) (rollbackSummary, error) { loadedPack, err := pack.LoadDir(req.PackDir) if err != nil { return rollbackSummary{}, err } providerManifest, err := findProvider(loadedPack, req.ProviderID) if err != nil { return rollbackSummary{}, err } client, err := sub2api.NewClient(req.HostBaseURL, sub2api.WithAPIKey(req.HostAPIKey), sub2api.WithBearerToken(req.HostBearerToken)) if err != nil { return rollbackSummary{}, err } service := provision.NewRollbackService(client) report, err := service.Rollback(ctx, provision.RollbackRequest{Provider: providerManifest}) if err != nil { return rollbackSummary{}, err } return rollbackSummary{ Accounts: report.AccountsDeleted, Plans: report.PlansDeleted, Channels: report.ChannelsDeleted, Groups: report.GroupsDeleted, }, nil } func runReconcileProvider(ctx context.Context, req reconcileCLIRequest) (provision.ReconcileResult, error) { loadedPack, err := pack.LoadDir(req.PackDir) if err != nil { return provision.ReconcileResult{}, err } providerManifest, err := findProvider(loadedPack, req.ProviderID) if err != nil { return provision.ReconcileResult{}, err } client, err := sub2api.NewClient(req.HostBaseURL, sub2api.WithAPIKey(req.HostAPIKey), sub2api.WithBearerToken(req.HostBearerToken)) if err != nil { return provision.ReconcileResult{}, err } startupConfig, err := config.LoadStartupFromEnv() if err != nil { return provision.ReconcileResult{}, err } store, err := sqlite.Open(ctx, startupConfig.Database.SQLiteDSN) if err != nil { return provision.ReconcileResult{}, err } defer store.Close() service := provision.NewReconcileService(store, client) return service.Reconcile(ctx, provision.ReconcileRequest{HostBaseURL: req.HostBaseURL, AccessProbeAPIKey: req.AccessAPIKey, Pack: loadedPack, Provider: providerManifest}) } func findProvider(loaded pack.LoadedPack, providerID string) (pack.ProviderManifest, error) { for _, provider := range loaded.Providers { if provider.ProviderID == strings.TrimSpace(providerID) { return provider, nil } } return pack.ProviderManifest{}, fmt.Errorf("provider %q not found in pack %q", providerID, loaded.Manifest.PackID) } func splitCSV(value string) []string { parts := strings.Split(value, ",") result := make([]string, 0, len(parts)) for _, part := range parts { trimmed := strings.TrimSpace(part) if trimmed != "" { result = append(result, trimmed) } } return result }