// Command cheapo lists and watches free game offers (Epic Games Store, Steam). // // Data is read from public storefront backends, no browser and no login // needed. Reference implementations: vogler/free-games-claimer, // jaixmario/free-games-notifier. package main import ( "context" "encoding/json" "fmt" "os" "os/signal" "syscall" "time" "github.com/spf13/cobra" "gogs.netdome.biz/paul/cheapo/internal/game" "gogs.netdome.biz/paul/cheapo/internal/ops" "gogs.netdome.biz/paul/cheapo/internal/providers" "gogs.netdome.biz/paul/cheapo/internal/watch" ) var ( flagCountry string flagLocale string flagJSON bool flagStore string ) func main() { root := &cobra.Command{ Use: "cheapo", Short: "cheapo — free games lister (Go edition)", Long: `cheapo lists and watches free game offers (Epic Games Store, Steam). Data is read from public storefront backends, no browser and no login needed. References: https://github.com/vogler/free-games-claimer https://github.com/jaixmario/free-games-notifier`, SilenceUsage: true, } root.PersistentFlags().StringVar(&flagCountry, "country", "DE", "ISO country code for store region; determines currency/availability (epic only)") root.PersistentFlags().StringVar(&flagLocale, "locale", "de", "display locale for Epic (formatting only, e.g. de -> '22,39 €', en-US -> '€22.39'; currency comes from --country)") root.PersistentFlags().BoolVar(&flagJSON, "json", false, "output as JSON") root.PersistentFlags().StringVar(&flagStore, "store", "all", "which store(s): epic, steam, gog, all") root.AddCommand(listCmd(), watchCmd(), mcpCmd(), chattoSendCmd(), chattoRoomsCmd()) if err := root.Execute(); err != nil { os.Exit(1) } } // newProviders returns the providers selected by --store. func newProviders() ([]game.Provider, error) { return providers.New(providers.Options{ Store: flagStore, Country: flagCountry, Locale: flagLocale, }) } // fetchList runs ops.List over the providers selected by --store. A failing // provider is reported on stderr but does not abort the others. func fetchList(ctx context.Context, mode ops.Mode) ([]game.Game, error) { provs, err := newProviders() if err != nil { return nil, err } games, warnings, err := ops.List(ctx, provs, mode) if err != nil { return nil, err } for _, w := range warnings { fmt.Fprintf(os.Stderr, "⚠ %s\n", w) } return games, nil } // printGames renders games in CLI text form. func printGames(cmd *cobra.Command, games []game.Game) error { if flagJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(games) } if len(games) == 0 { fmt.Fprintln(cmd.OutOrStdout(), "No matches.") return nil } out := cmd.OutOrStdout() for _, g := range games { fmt.Fprintf(out, "šŸŽ® [%s] %s\n", g.Store, g.Title) if g.Kind != "" && g.Kind != "free-now" { fmt.Fprintf(out, " Kind: %s\n", g.Kind) } if g.Seller != "" { fmt.Fprintf(out, " By: %s\n", g.Seller) } if g.DiscountPct > 0 { if g.FinalPriceFmt != "" { fmt.Fprintf(out, " Price: %s → %s (āˆ’%d %%)\n", g.PriceFmt, g.FinalPriceFmt, g.DiscountPct) } else { fmt.Fprintf(out, " Price: %s (āˆ’%d %%)\n", g.PriceFmt, g.DiscountPct) } } else if g.PriceFmt != "" && g.Store != "steam" && g.PriceFmt != "0" { fmt.Fprintf(out, " Instead of: %s\n", g.PriceFmt) } if g.Kind == "upcoming" && !g.StartDate.IsZero() { fmt.Fprintf(out, " Free from: %s (until %s)\n", g.StartDate.Local().Format("02.01.2006 15:04"), g.EndDate.Local().Format("02.01.2006 15:04")) } else if !g.EndDate.IsZero() { fmt.Fprintf(out, " Free until: %s\n", g.EndDate.UTC().Format(time.RFC3339)) } fmt.Fprintf(out, " %s\n", g.URL) fmt.Fprintln(out) } return nil } func listCmd() *cobra.Command { var ( upcoming bool deals bool ) cmd := &cobra.Command{ Use: "list", Aliases: []string{"ls"}, Short: "List games currently free (optionally upcoming or deals)", RunE: func(cmd *cobra.Command, args []string) error { mode := ops.Free if upcoming { mode = ops.Upcoming } else if deals { mode = ops.Deals } games, err := fetchList(cmd.Context(), mode) if err != nil { return err } return printGames(cmd, games) }, } cmd.Flags().BoolVar(&upcoming, "upcoming", false, "Show announced upcoming free games (Epic only)") cmd.Flags().BoolVar(&deals, "deals", false, "Show discounted games (deals) instead of free games") return cmd } func watchCmd() *cobra.Command { var ( interval time.Duration once bool ) cmd := &cobra.Command{ Use: "watch", Short: "Wait for new free games (polling)", Long: `Polls all selected stores and reports every game that becomes newly free. The first pass records the current state as the baseline (no reports). A store that is currently unreachable does not abort watching. Exit with Ctrl+C.`, RunE: func(cmd *cobra.Command, args []string) error { ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) defer stop() provs, err := newProviders() if err != nil { return err } if once { var all []game.Game for _, p := range provs { games, err := p.GetFreeGames(ctx) if err != nil { fmt.Fprintf(os.Stderr, "⚠ %s: %v\n", p.Name(), err) continue } all = append(all, games...) } return printGames(cmd, all) } w := watch.NewWatcher(interval, provs...) return w.Run(ctx, false, watch.NotifierFunc(func(games []game.Game) { fmt.Fprintf(os.Stderr, "\nšŸ”” New free games (%s):\n", time.Now().Format("15:04:05")) for _, g := range games { end := "—" if !g.EndDate.IsZero() { end = g.EndDate.Local().Format("02.01.") } fmt.Fprintf(os.Stderr, " šŸŽ® [%s] %s — until %s — %s\n", g.Store, g.Title, end, g.URL) } })) }, } cmd.Flags().DurationVar(&interval, "interval", 15*time.Minute, "Polling interval") cmd.Flags().BoolVar(&once, "once", false, "Check once and exit") return cmd }