├── .envrc ├── .github ├── screenshot.png ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── FEATURE-REQUEST.yml │ └── BUG-REPORT.yml └── workflows │ ├── release.yml │ └── push.yaml ├── tea ├── utils │ ├── types.go │ ├── tick.go │ ├── lists.go │ └── styles.go ├── scenes.go ├── scenes │ ├── errors │ │ └── errors.go │ ├── keys │ │ └── keys.go │ ├── profile │ │ ├── messages.go │ │ ├── rename_profile.go │ │ ├── new_profile.go │ │ ├── profiles.go │ │ └── profile.go │ ├── installation │ │ ├── messages.go │ │ ├── installations.go │ │ └── installation.go │ └── mods │ │ ├── mod_semver.go │ │ ├── mod_version.go │ │ ├── select_mod_version.go │ │ └── mod.go ├── components │ ├── types.go │ ├── error.go │ └── header.go ├── root.go └── tea_test.go ├── ficsit ├── queries │ ├── create_version.graphql │ ├── mod_name.graphql │ ├── finalize_create_version.graphql │ ├── check_version_upload_state.graphql │ ├── version.graphql │ ├── mods.graphql │ └── mod.graphql ├── utils │ └── json.go ├── api_test.go ├── root.go ├── rest.go └── types_rest.go ├── .vscode ├── extensions.json └── launch.json ├── cmd ├── mod │ └── root.go ├── smr │ └── root.go ├── profile │ ├── root.go │ ├── mod │ │ ├── root.go │ │ ├── remove.go │ │ └── add.go │ ├── ls.go │ ├── new.go │ ├── delete.go │ ├── rename.go │ └── mods.go ├── installation │ ├── root.go │ ├── ls.go │ ├── remove.go │ ├── add.go │ ├── set-profile.go │ └── set-vanilla.go ├── version.go ├── cli.go ├── apply.go ├── search.go └── root.go ├── shell.nix ├── main.go ├── utils ├── version.go ├── structures.go ├── progress.go └── io.go ├── cli ├── profiles_test.go ├── provider │ ├── provider.go │ ├── converter.go │ ├── mixed.go │ ├── ficsit.go │ └── local.go ├── cache │ ├── uplugin.go │ ├── mod_details.go │ └── download.go ├── disk │ ├── local.go │ ├── main.go │ └── sftp.go ├── platforms.go ├── localregistry │ ├── migrations.go │ └── registry.go ├── context.go ├── installations_test.go ├── resolving_test.go └── test_helpers.go ├── docker-compose-test.yml ├── flake.nix ├── genqlient.yaml ├── cspell.json ├── cfg └── test_defaults.go ├── .golangci.yml ├── docs ├── ficsit_cli.md ├── ficsit_profile_ls.md ├── ficsit_profile_new.md ├── ficsit_smr.md ├── ficsit_version.md ├── ficsit_profile_delete.md ├── ficsit_apply.md ├── ficsit_profile_mods.md ├── ficsit_profile_rename.md ├── ficsit_installation_ls.md ├── ficsit_installation_add.md ├── ficsit_installation_remove.md ├── ficsit_installation_set-profile.md ├── ficsit_installation_set-vanilla.md ├── ficsit_smr_upload.md ├── ficsit.md ├── ficsit_profile.md ├── ficsit_search.md └── ficsit_installation.md ├── tools.go ├── .goreleaser.yml ├── flake.lock ├── .gitignore └── go.mod /.envrc: -------------------------------------------------------------------------------- 1 | use flake -------------------------------------------------------------------------------- /.github/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satisfactorymodding/ficsit-cli/HEAD/.github/screenshot.png -------------------------------------------------------------------------------- /tea/utils/types.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | type Mod struct { 4 | Name string 5 | Reference string 6 | } 7 | -------------------------------------------------------------------------------- /tea/scenes.go: -------------------------------------------------------------------------------- 1 | package tea 2 | 3 | type Scene int 4 | 5 | const ( 6 | MainMenu Scene = iota 7 | Profiles 8 | ) 9 | -------------------------------------------------------------------------------- /tea/scenes/errors/errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | const ( 4 | ErrorFailedAddMod = "failed to add mod" 5 | ) 6 | -------------------------------------------------------------------------------- /ficsit/queries/create_version.graphql: -------------------------------------------------------------------------------- 1 | mutation CreateVersion ($modId: ModID!) { 2 | versionID: createVersion(modId: $modId) 3 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "golang.go", 4 | "streetsidesoftware.code-spell-checker" 5 | ] 6 | } -------------------------------------------------------------------------------- /cmd/mod/root.go: -------------------------------------------------------------------------------- 1 | package mod 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var Cmd = &cobra.Command{ 8 | Use: "mod", 9 | Short: "Manage mods", 10 | } 11 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { pkgs, unstable }: 2 | 3 | pkgs.mkShell { 4 | nativeBuildInputs = with pkgs.buildPackages; [ 5 | unstable.go_1_21 6 | unstable.golangci-lint 7 | ]; 8 | } 9 | -------------------------------------------------------------------------------- /cmd/smr/root.go: -------------------------------------------------------------------------------- 1 | package smr 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var Cmd = &cobra.Command{ 8 | Use: "smr", 9 | Short: "Manage mods on SMR", 10 | } 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: ✋ Question 4 | url: https://discord.gg/xkVJ73E 5 | about: Submit your question on our discord. -------------------------------------------------------------------------------- /cmd/profile/root.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var Cmd = &cobra.Command{ 8 | Use: "profile", 9 | Short: "Manage profiles", 10 | } 11 | -------------------------------------------------------------------------------- /tea/scenes/keys/keys.go: -------------------------------------------------------------------------------- 1 | package keys 2 | 3 | const ( 4 | KeyControlC = "ctrl+c" 5 | KeyEnter = "enter" 6 | KeyEscape = "esc" 7 | KeyTab = "tab" 8 | KeyQ = "q" 9 | ) 10 | -------------------------------------------------------------------------------- /cmd/profile/mod/root.go: -------------------------------------------------------------------------------- 1 | package mod 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var Cmd = &cobra.Command{ 8 | Use: "mod", 9 | Short: "Manage mods in a profile", 10 | } 11 | -------------------------------------------------------------------------------- /ficsit/queries/mod_name.graphql: -------------------------------------------------------------------------------- 1 | query GetModName ($modId: String!) { 2 | mod: getModByIdOrReference(modIdOrReference: $modId) { 3 | id 4 | mod_reference 5 | name 6 | } 7 | } -------------------------------------------------------------------------------- /cmd/installation/root.go: -------------------------------------------------------------------------------- 1 | package installation 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var Cmd = &cobra.Command{ 8 | Use: "installation", 9 | Short: "Manage installations", 10 | } 11 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/satisfactorymodding/ficsit-cli/cmd" 4 | 5 | var ( 6 | version = "dev" 7 | commit = "none" 8 | ) 9 | 10 | func main() { 11 | cmd.Execute(version, commit) 12 | } 13 | -------------------------------------------------------------------------------- /ficsit/queries/finalize_create_version.graphql: -------------------------------------------------------------------------------- 1 | mutation FinalizeCreateVersion ($modId: ModID!, $versionId: VersionID!, $version: NewVersion!) { 2 | success: finalizeCreateVersion(modId: $modId, versionId: $versionId, version: $version) 3 | } -------------------------------------------------------------------------------- /ficsit/queries/check_version_upload_state.graphql: -------------------------------------------------------------------------------- 1 | query CheckVersionUploadState ($modId: ModID!, $versionId: VersionID!) { 2 | state: checkVersionUploadState(modId: $modId, versionId: $versionId) { 3 | auto_approved 4 | version { 5 | id 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /utils/version.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "regexp" 4 | 5 | var SemVerRegex = regexp.MustCompile(`^(<=|<|>|>=|\^)?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`) 6 | -------------------------------------------------------------------------------- /tea/utils/tick.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "time" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | ) 8 | 9 | type TickMsg struct{} 10 | 11 | func Ticker() tea.Cmd { 12 | return tea.Tick(time.Millisecond*50, func(time.Time) tea.Msg { 13 | return TickMsg{} 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/spf13/viper" 6 | ) 7 | 8 | var versionCmd = &cobra.Command{ 9 | Use: "version", 10 | Short: "Print current version information", 11 | Run: func(cmd *cobra.Command, args []string) { 12 | println(viper.GetString("version"), "-", viper.GetString("commit")) 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /ficsit/queries/version.graphql: -------------------------------------------------------------------------------- 1 | # @genqlient(omitempty: true) 2 | query Version ( 3 | $modId: String!, 4 | $version: String! 5 | ) { 6 | mod: getModByIdOrReference(modIdOrReference: $modId) { 7 | id 8 | version(version: $version) { 9 | id 10 | version 11 | link 12 | hash 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /tea/scenes/profile/messages.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | ) 6 | 7 | type updateProfileList struct{} 8 | 9 | func updateProfileListCmd() tea.Msg { 10 | return updateProfileList{} 11 | } 12 | 13 | type updateProfileNames struct{} 14 | 15 | func updateProfileNamesCmd() tea.Msg { 16 | return updateProfileNames{} 17 | } 18 | -------------------------------------------------------------------------------- /cli/profiles_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/MarvinJWendt/testza" 7 | 8 | "github.com/satisfactorymodding/ficsit-cli/cfg" 9 | ) 10 | 11 | func init() { 12 | cfg.SetDefaults() 13 | } 14 | 15 | func TestProfilesInit(t *testing.T) { 16 | profiles, err := InitProfiles() 17 | testza.AssertNoError(t, err) 18 | testza.AssertNotNil(t, profiles) 19 | } 20 | -------------------------------------------------------------------------------- /docker-compose-test.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | ssh: 5 | image: lscr.io/linuxserver/openssh-server:latest 6 | ports: 7 | - "2222:2222" 8 | volumes: 9 | - ./SatisfactoryDedicatedServer:/home/user/server 10 | environment: 11 | - PUID=1000 12 | - PGID=1000 13 | - PASSWORD_ACCESS=true 14 | - USER_PASSWORD=pass 15 | - USER_NAME=user -------------------------------------------------------------------------------- /tea/scenes/installation/messages.go: -------------------------------------------------------------------------------- 1 | package installation 2 | 3 | import tea "github.com/charmbracelet/bubbletea" 4 | 5 | type updateInstallationList struct{} 6 | 7 | func updateInstallationListCmd() tea.Msg { 8 | return updateInstallationList{} 9 | } 10 | 11 | type updateInstallationNames struct{} 12 | 13 | func updateInstallationNamesCmd() tea.Msg { 14 | return updateInstallationNames{} 15 | } 16 | -------------------------------------------------------------------------------- /ficsit/queries/mods.graphql: -------------------------------------------------------------------------------- 1 | # @genqlient(omitempty: true) 2 | query Mods ($filter: ModFilter) { 3 | mods: getMods (filter: $filter) { 4 | count 5 | mods { 6 | id 7 | name 8 | mod_reference 9 | last_version_date 10 | created_at 11 | views 12 | downloads 13 | popularity 14 | hotness 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /utils/structures.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | func Copy[T any](obj T) (*T, error) { 9 | marshal, err := json.Marshal(obj) 10 | if err != nil { 11 | return nil, fmt.Errorf("failed to marshal object: %w", err) 12 | } 13 | 14 | out := new(T) 15 | if err := json.Unmarshal(marshal, out); err != nil { 16 | return nil, fmt.Errorf("failed to unmarshal object: %w", err) 17 | } 18 | 19 | return out, nil 20 | } 21 | -------------------------------------------------------------------------------- /cli/provider/provider.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | 6 | resolver "github.com/satisfactorymodding/ficsit-resolver" 7 | 8 | "github.com/satisfactorymodding/ficsit-cli/ficsit" 9 | ) 10 | 11 | type Provider interface { 12 | resolver.Provider 13 | Mods(context context.Context, filter ficsit.ModFilter) (*ficsit.ModsResponse, error) 14 | GetMod(context context.Context, modReference string) (*ficsit.GetModResponse, error) 15 | IsOffline() bool 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // needs to attach to running dlv process execute 3 | // dlv debug --headless --listen=:2345 . 4 | // in the root of the package first 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Attach to Process", 9 | "type": "go", 10 | "debugAdapter": "dlv-dap", 11 | "request": "attach", 12 | "mode": "remote", 13 | "remotePath": "${workspaceFolder}", 14 | "host": "127.0.0.1", 15 | "port": 2345 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /ficsit/utils/json.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | //goland:noinspection GoUnusedExportedFunction 10 | func UnmarshalDateTime(b []byte, v *time.Time) error { 11 | trimmed := bytes.Trim(b, "\"") 12 | 13 | if len(trimmed) == 0 { 14 | *v = time.Unix(0, 0) 15 | return nil 16 | } 17 | 18 | parsed, err := time.Parse(time.RFC3339, string(trimmed)) 19 | if err != nil { 20 | return fmt.Errorf("failed to parse date time: %w", err) 21 | } 22 | 23 | *v = parsed 24 | 25 | return nil 26 | } 27 | -------------------------------------------------------------------------------- /cli/cache/uplugin.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | type UPlugin struct { 4 | SemVersion string `json:"SemVersion"` 5 | FriendlyName string `json:"FriendlyName"` 6 | Description string `json:"Description"` 7 | CreatedBy string `json:"CreatedBy"` 8 | GameVersion string `json:"GameVersion"` 9 | Plugins []Plugins `json:"Plugins"` 10 | } 11 | type Plugins struct { 12 | Name string `json:"Name"` 13 | SemVersion string `json:"SemVersion"` 14 | Enabled bool `json:"Enabled"` 15 | BasePlugin bool `json:"BasePlugin"` 16 | Optional bool `json:"Optional"` 17 | } 18 | -------------------------------------------------------------------------------- /cmd/profile/ls.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/satisfactorymodding/ficsit-cli/cli" 7 | ) 8 | 9 | func init() { 10 | Cmd.AddCommand(lsCmd) 11 | } 12 | 13 | var lsCmd = &cobra.Command{ 14 | Use: "ls", 15 | Short: "List all profiles", 16 | Args: cobra.NoArgs, 17 | RunE: func(cmd *cobra.Command, args []string) error { 18 | global, err := cli.InitCLI(false) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | for name := range global.Profiles.Profiles { 24 | println(name) 25 | } 26 | 27 | return nil 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "smr-cli"; 3 | 4 | inputs = { 5 | flake-utils.url = "github:numtide/flake-utils"; 6 | nixpkgs-unstable.url = "flake:nixpkgs/nixpkgs-unstable"; 7 | }; 8 | 9 | outputs = { self, nixpkgs, flake-utils, nixpkgs-unstable }: 10 | flake-utils.lib.eachDefaultSystem 11 | (system: 12 | let 13 | pkgs = nixpkgs.legacyPackages.${system}; 14 | unstable = nixpkgs-unstable.legacyPackages.${system}; in 15 | { 16 | devShells.default = import ./shell.nix { inherit pkgs unstable; }; 17 | } 18 | ); 19 | } -------------------------------------------------------------------------------- /genqlient.yaml: -------------------------------------------------------------------------------- 1 | # https://github.com/Khan/genqlient/blob/main/docs/genqlient.yaml 2 | schema: schema.graphql 3 | operations: 4 | - ficsit/queries/*.graphql 5 | generated: ficsit/types.go 6 | package: ficsit 7 | bindings: 8 | UserID: 9 | type: string 10 | ModReference: 11 | type: string 12 | BootstrapVersionID: 13 | type: string 14 | ModID: 15 | type: string 16 | VersionID: 17 | type: string 18 | GuideID: 19 | type: string 20 | Date: 21 | type: time.Time 22 | unmarshaler: github.com/satisfactorymodding/ficsit-cli/ficsit/utils.UnmarshalDateTime 23 | TagID: 24 | type: string -------------------------------------------------------------------------------- /cmd/profile/new.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/satisfactorymodding/ficsit-cli/cli" 7 | ) 8 | 9 | func init() { 10 | Cmd.AddCommand(newCmd) 11 | } 12 | 13 | var newCmd = &cobra.Command{ 14 | Use: "new ", 15 | Short: "Create a new profile", 16 | Args: cobra.ExactArgs(1), 17 | RunE: func(cmd *cobra.Command, args []string) error { 18 | global, err := cli.InitCLI(false) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | _, err = global.Profiles.AddProfile(args[0]) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | return global.Save() 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /cmd/installation/ls.go: -------------------------------------------------------------------------------- 1 | package installation 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/satisfactorymodding/ficsit-cli/cli" 7 | ) 8 | 9 | func init() { 10 | Cmd.AddCommand(lsCmd) 11 | } 12 | 13 | var lsCmd = &cobra.Command{ 14 | Use: "ls", 15 | Short: "List all installations", 16 | Args: cobra.NoArgs, 17 | RunE: func(cmd *cobra.Command, args []string) error { 18 | global, err := cli.InitCLI(false) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | for _, install := range global.Installations.Installations { 24 | println(install.Path, "-", install.Profile) 25 | } 26 | 27 | return nil 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /cmd/profile/delete.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/satisfactorymodding/ficsit-cli/cli" 7 | ) 8 | 9 | func init() { 10 | Cmd.AddCommand(deleteCmd) 11 | } 12 | 13 | var deleteCmd = &cobra.Command{ 14 | Use: "delete ", 15 | Short: "Delete a profile", 16 | Args: cobra.ExactArgs(1), 17 | RunE: func(cmd *cobra.Command, args []string) error { 18 | global, err := cli.InitCLI(false) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | err = global.Profiles.DeleteProfile(args[0]) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | return global.Save() 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /ficsit/queries/mod.graphql: -------------------------------------------------------------------------------- 1 | query GetMod ($modId: String!) { 2 | mod: getModByIdOrReference(modIdOrReference: $modId) { 3 | id 4 | mod_reference 5 | name 6 | views 7 | downloads 8 | authors { 9 | role 10 | user { 11 | username 12 | } 13 | } 14 | compatibility { 15 | EA { 16 | note 17 | state 18 | } 19 | EXP { 20 | note 21 | state 22 | } 23 | } 24 | full_description 25 | source_url 26 | created_at 27 | } 28 | } -------------------------------------------------------------------------------- /cmd/profile/rename.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/satisfactorymodding/ficsit-cli/cli" 7 | ) 8 | 9 | func init() { 10 | Cmd.AddCommand(renameCmd) 11 | } 12 | 13 | var renameCmd = &cobra.Command{ 14 | Use: "rename ", 15 | Short: "Rename a profile", 16 | Args: cobra.ExactArgs(2), 17 | RunE: func(cmd *cobra.Command, args []string) error { 18 | global, err := cli.InitCLI(false) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | err = global.Profiles.RenameProfile(global, args[0], args[1]) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | return global.Save() 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /cmd/installation/remove.go: -------------------------------------------------------------------------------- 1 | package installation 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/satisfactorymodding/ficsit-cli/cli" 7 | ) 8 | 9 | func init() { 10 | Cmd.AddCommand(removeCmd) 11 | } 12 | 13 | var removeCmd = &cobra.Command{ 14 | Use: "remove ", 15 | Short: "Remove an installation", 16 | Args: cobra.ExactArgs(1), 17 | RunE: func(cmd *cobra.Command, args []string) error { 18 | global, err := cli.InitCLI(false) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | err = global.Installations.DeleteInstallation(args[0]) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | return global.Save() 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml: -------------------------------------------------------------------------------- 1 | name: "💡 Feature Request" 2 | description: File a feature request 3 | labels: ["enhancement"] 4 | body: 5 | - type: textarea 6 | id: problem 7 | attributes: 8 | label: Is your feature request related to a problem? Please describe. 9 | validations: 10 | required: true 11 | - type: textarea 12 | id: solution 13 | attributes: 14 | label: Describe the solution you'd like 15 | validations: 16 | required: true 17 | - type: textarea 18 | id: alternatives 19 | attributes: 20 | label: Describe alternatives you've considered 21 | - type: textarea 22 | id: additional-context 23 | attributes: 24 | label: Additional context -------------------------------------------------------------------------------- /ficsit/api_test.go: -------------------------------------------------------------------------------- 1 | package ficsit 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/Khan/genqlient/graphql" 8 | "github.com/MarvinJWendt/testza" 9 | 10 | "github.com/satisfactorymodding/ficsit-cli/cfg" 11 | ) 12 | 13 | var client graphql.Client 14 | 15 | func init() { 16 | cfg.SetDefaults() 17 | client = InitAPI() 18 | } 19 | 20 | func TestMods(t *testing.T) { 21 | response, err := Mods(context.Background(), client, ModFilter{}) 22 | testza.AssertNoError(t, err) 23 | testza.AssertNotNil(t, response) 24 | testza.AssertNotNil(t, response.Mods) 25 | testza.AssertNotNil(t, response.Mods.Mods) 26 | testza.AssertNotZero(t, response.Mods.Count) 27 | testza.AssertNotZero(t, len(response.Mods.Mods)) 28 | } 29 | -------------------------------------------------------------------------------- /cmd/cli.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log/slog" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/spf13/viper" 8 | 9 | "github.com/satisfactorymodding/ficsit-cli/cli" 10 | "github.com/satisfactorymodding/ficsit-cli/tea" 11 | ) 12 | 13 | var cliCmd = &cobra.Command{ 14 | Use: "cli", 15 | Short: "Start interactive CLI (default)", 16 | RunE: func(cmd *cobra.Command, args []string) error { 17 | slog.Info( 18 | "interactive cli initialized", 19 | slog.String("version", viper.GetString("version")), 20 | slog.String("commit", viper.GetString("commit")), 21 | ) 22 | 23 | global, err := cli.InitCLI(false) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | return tea.RunTea(global) 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /utils/progress.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | type GenericProgress struct { 8 | Completed int64 9 | Total int64 10 | } 11 | 12 | func (gp GenericProgress) Percentage() float64 { 13 | if gp.Total == 0 { 14 | return 0 15 | } 16 | return float64(gp.Completed) / float64(gp.Total) 17 | } 18 | 19 | var _ io.Writer = (*Progresser)(nil) 20 | 21 | type Progresser struct { 22 | Updates chan<- GenericProgress 23 | Total int64 24 | Running int64 25 | } 26 | 27 | func (pt *Progresser) Write(p []byte) (int, error) { 28 | pt.Running += int64(len(p)) 29 | 30 | if pt.Updates != nil { 31 | select { 32 | case pt.Updates <- GenericProgress{Completed: pt.Running, Total: pt.Total}: 33 | default: 34 | } 35 | } 36 | 37 | return len(p), nil 38 | } 39 | -------------------------------------------------------------------------------- /cmd/profile/mod/remove.go: -------------------------------------------------------------------------------- 1 | package mod 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/satisfactorymodding/ficsit-cli/cli" 9 | ) 10 | 11 | func init() { 12 | Cmd.AddCommand(removeCmd) 13 | } 14 | 15 | var removeCmd = &cobra.Command{ 16 | Use: "remove ", 17 | Short: "Remove a mod from a profile", 18 | Args: cobra.ExactArgs(2), 19 | RunE: func(cmd *cobra.Command, args []string) error { 20 | global, err := cli.InitCLI(false) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | profile := global.Profiles.GetProfile(args[0]) 26 | if profile == nil { 27 | return fmt.Errorf("profile with name %s does not exist", args[0]) 28 | } 29 | 30 | profile.RemoveMod(args[1]) 31 | 32 | return global.Save() 33 | }, 34 | } 35 | -------------------------------------------------------------------------------- /cmd/installation/add.go: -------------------------------------------------------------------------------- 1 | package installation 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/satisfactorymodding/ficsit-cli/cli" 7 | ) 8 | 9 | func init() { 10 | Cmd.AddCommand(addCmd) 11 | } 12 | 13 | var addCmd = &cobra.Command{ 14 | Use: "add [profile]", 15 | Short: "Add an installation", 16 | Args: cobra.MinimumNArgs(1), 17 | RunE: func(cmd *cobra.Command, args []string) error { 18 | global, err := cli.InitCLI(false) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | profile := global.Profiles.SelectedProfile 24 | if len(args) > 1 { 25 | profile = args[1] 26 | } 27 | 28 | _, err = global.Installations.AddInstallation(global, args[0], profile) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | return global.Save() 34 | }, 35 | } 36 | -------------------------------------------------------------------------------- /cmd/profile/mods.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/satisfactorymodding/ficsit-cli/cli" 9 | ) 10 | 11 | func init() { 12 | Cmd.AddCommand(modsCmd) 13 | } 14 | 15 | var modsCmd = &cobra.Command{ 16 | Use: "mods ", 17 | Short: "List all mods in a profile", 18 | Args: cobra.ExactArgs(1), 19 | RunE: func(cmd *cobra.Command, args []string) error { 20 | global, err := cli.InitCLI(false) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | profile := global.Profiles.GetProfile(args[0]) 26 | if profile == nil { 27 | return errors.New("profile not found") 28 | } 29 | 30 | for reference, mod := range profile.Mods { 31 | println(reference, mod.Version) 32 | } 33 | 34 | return nil 35 | }, 36 | } 37 | -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | // https://cspell.org/configuration/ 2 | { 3 | // Version of the setting file. Always 0.2 4 | "version": "0.2", 5 | // language - current active spelling language 6 | "language": "en", 7 | // words - list of words to be always considered correct 8 | "words": [ 9 | "armv", 10 | "ficsit", 11 | "gofumpt", 12 | "Goland", 13 | "golangci", 14 | "goquery", 15 | "graphqurl", 16 | "mvdan", 17 | "pgdn", 18 | "pgup", 19 | "wordwrap" 20 | ], 21 | // flagWords - list of words to be always considered incorrect 22 | // This is useful for offensive words and common spelling errors. 23 | // cSpell:disable (don't complain about the words we listed here) 24 | "flagWords": [ 25 | "hte" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /tea/components/types.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "github.com/Khan/genqlient/graphql" 5 | tea "github.com/charmbracelet/bubbletea" 6 | resolver "github.com/satisfactorymodding/ficsit-resolver" 7 | 8 | "github.com/satisfactorymodding/ficsit-cli/cli" 9 | "github.com/satisfactorymodding/ficsit-cli/cli/provider" 10 | ) 11 | 12 | type RootModel interface { 13 | GetGlobal() *cli.GlobalContext 14 | 15 | GetCurrentProfile() *cli.Profile 16 | SetCurrentProfile(profile *cli.Profile) error 17 | 18 | GetCurrentInstallation() *cli.Installation 19 | SetCurrentInstallation(installation *cli.Installation) error 20 | 21 | GetAPIClient() graphql.Client 22 | GetProvider() provider.Provider 23 | GetResolver() resolver.DependencyResolver 24 | 25 | Size() tea.WindowSizeMsg 26 | SetSize(size tea.WindowSizeMsg) 27 | 28 | View() string 29 | Height() int 30 | } 31 | -------------------------------------------------------------------------------- /ficsit/root.go: -------------------------------------------------------------------------------- 1 | package ficsit 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/Khan/genqlient/graphql" 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | type AuthedTransport struct { 12 | Wrapped http.RoundTripper 13 | } 14 | 15 | func (t *AuthedTransport) RoundTrip(req *http.Request) (*http.Response, error) { 16 | key := viper.GetString("api-key") 17 | if key != "" { 18 | req.Header.Set("Authorization", key) 19 | } 20 | 21 | rt, err := t.Wrapped.RoundTrip(req) 22 | if err != nil { 23 | return nil, fmt.Errorf("failed roundtrip: %w", err) 24 | } 25 | 26 | return rt, nil 27 | } 28 | 29 | func InitAPI() graphql.Client { 30 | httpClient := http.Client{ 31 | Transport: &AuthedTransport{ 32 | Wrapped: http.DefaultTransport, 33 | }, 34 | } 35 | 36 | return graphql.NewClient(viper.GetString("api-base")+viper.GetString("graphql-api"), &httpClient) 37 | } 38 | -------------------------------------------------------------------------------- /ficsit/rest.go: -------------------------------------------------------------------------------- 1 | package ficsit 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | const allVersionEndpoint = `/v1/mod/%s/versions/all` 13 | 14 | func GetAllModVersions(modID string) (*AllVersionsResponse, error) { 15 | response, err := http.DefaultClient.Get(viper.GetString("api-base") + fmt.Sprintf(allVersionEndpoint, modID)) 16 | if err != nil { 17 | return nil, fmt.Errorf("failed fetching all versions: %w", err) 18 | } 19 | 20 | defer response.Body.Close() 21 | 22 | body, err := io.ReadAll(response.Body) 23 | if err != nil { 24 | return nil, fmt.Errorf("failed reading response body: %w", err) 25 | } 26 | 27 | allVersions := AllVersionsResponse{} 28 | if err := json.Unmarshal(body, &allVersions); err != nil { 29 | return nil, fmt.Errorf("failed parsing json: %w", err) 30 | } 31 | 32 | return &allVersions, nil 33 | } 34 | -------------------------------------------------------------------------------- /cmd/profile/mod/add.go: -------------------------------------------------------------------------------- 1 | package mod 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/satisfactorymodding/ficsit-cli/cli" 9 | ) 10 | 11 | func init() { 12 | Cmd.AddCommand(addCmd) 13 | } 14 | 15 | var addCmd = &cobra.Command{ 16 | Use: "add [version]", 17 | Short: "Add mod to a profile", 18 | Args: cobra.MinimumNArgs(2), 19 | RunE: func(cmd *cobra.Command, args []string) error { 20 | global, err := cli.InitCLI(false) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | version := ">=0.0.0" 26 | if len(args) > 2 { 27 | version = args[2] 28 | } 29 | 30 | profile := global.Profiles.GetProfile(args[0]) 31 | if profile == nil { 32 | return fmt.Errorf("profile with name %s does not exist", args[0]) 33 | } 34 | 35 | if err := profile.AddMod(args[1], version); err != nil { 36 | return err 37 | } 38 | 39 | return global.Save() 40 | }, 41 | } 42 | -------------------------------------------------------------------------------- /cfg/test_defaults.go: -------------------------------------------------------------------------------- 1 | package cfg 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | func SetDefaults() { 13 | _, file, _, _ := runtime.Caller(0) 14 | viper.SetDefault("cache-dir", filepath.Clean(filepath.Join(filepath.Dir(file), "../", "testdata", "cache"))) 15 | viper.SetDefault("local-dir", filepath.Clean(filepath.Join(filepath.Dir(file), "../", "testdata", "local"))) 16 | viper.SetDefault("base-local-dir", filepath.Clean(filepath.Join(filepath.Dir(file), "../", "testdata"))) 17 | viper.SetDefault("profiles-file", "profiles.json") 18 | viper.SetDefault("installations-file", "installations.json") 19 | viper.SetDefault("dry-run", false) 20 | viper.SetDefault("api-base", "https://api.ficsit.dev") 21 | viper.SetDefault("graphql-api", "/v2/query") 22 | viper.SetDefault("concurrent-downloads", 5) 23 | 24 | slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ 25 | Level: slog.LevelDebug, 26 | }))) 27 | } 28 | -------------------------------------------------------------------------------- /cmd/installation/set-profile.go: -------------------------------------------------------------------------------- 1 | package installation 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/satisfactorymodding/ficsit-cli/cli" 9 | ) 10 | 11 | func init() { 12 | Cmd.AddCommand(setProfileCmd) 13 | } 14 | 15 | var setProfileCmd = &cobra.Command{ 16 | Use: "set-profile ", 17 | Short: "Change the profile of an installation", 18 | Args: cobra.ExactArgs(2), 19 | RunE: func(cmd *cobra.Command, args []string) error { 20 | global, err := cli.InitCLI(false) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | var installation *cli.Installation 26 | for _, install := range global.Installations.Installations { 27 | if install.Path == args[0] { 28 | installation = install 29 | break 30 | } 31 | } 32 | 33 | if installation == nil { 34 | return errors.New("installation not found") 35 | } 36 | 37 | err = installation.SetProfile(global, args[1]) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | return global.Save() 43 | }, 44 | } 45 | -------------------------------------------------------------------------------- /ficsit/types_rest.go: -------------------------------------------------------------------------------- 1 | package ficsit 2 | 3 | type AllVersionsResponse struct { 4 | Error *Error `json:"error,omitempty"` 5 | Data []ModVersion `json:"data,omitempty"` 6 | Success bool `json:"success"` 7 | } 8 | 9 | type ModVersion struct { 10 | ID string `json:"id"` 11 | Version string `json:"version"` 12 | GameVersion string `json:"game_version"` 13 | Dependencies []Dependency `json:"dependencies"` 14 | Targets []Target `json:"targets"` 15 | RequiredOnRemote bool `json:"required_on_remote"` 16 | } 17 | 18 | type Dependency struct { 19 | ModID string `json:"mod_id"` 20 | Condition string `json:"condition"` 21 | Optional bool `json:"optional"` 22 | } 23 | 24 | type Target struct { 25 | VersionID string `json:"version_id"` 26 | TargetName string `json:"target_name"` 27 | Link string `json:"link"` 28 | Hash string `json:"hash"` 29 | Size int64 `json:"size"` 30 | } 31 | 32 | type Error struct { 33 | Message string `json:"message"` 34 | Code int64 `json:"code"` 35 | } 36 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | wrapcheck: 3 | ignoreSigs: 4 | - .Errorf( 5 | - errors.New( 6 | - errors.Unwrap( 7 | - .WithMessage( 8 | - .WithMessagef( 9 | - .WithStack( 10 | 11 | ignorePackageGlobs: 12 | - github.com/satisfactorymodding/ficsit-cli/* 13 | 14 | govet: 15 | check-shadowing: false 16 | enable-all: true 17 | disable: 18 | - shadow 19 | 20 | gocritic: 21 | disabled-checks: 22 | - ifElseChain 23 | 24 | gci: 25 | custom-order: true 26 | sections: 27 | - standard 28 | - default 29 | - prefix(github.com/satisfactorymodding/ficsit-cli) 30 | - blank 31 | - dot 32 | 33 | run: 34 | skip-files: 35 | - ./ficsit/types.go 36 | 37 | linters: 38 | disable-all: true 39 | enable: 40 | - errcheck 41 | - gosimple 42 | - govet 43 | - ineffassign 44 | - staticcheck 45 | - typecheck 46 | - unused 47 | - bidichk 48 | - contextcheck 49 | - durationcheck 50 | - errorlint 51 | - goimports 52 | - misspell 53 | - prealloc 54 | - whitespace 55 | - wrapcheck 56 | - gci 57 | - gocritic 58 | - nonamedreturns 59 | -------------------------------------------------------------------------------- /cmd/installation/set-vanilla.go: -------------------------------------------------------------------------------- 1 | package installation 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/spf13/viper" 8 | 9 | "github.com/satisfactorymodding/ficsit-cli/cli" 10 | ) 11 | 12 | func init() { 13 | setVanillaCmd.Flags().BoolP("off", "o", false, "Disable vanilla") 14 | 15 | Cmd.AddCommand(setVanillaCmd) 16 | } 17 | 18 | var setVanillaCmd = &cobra.Command{ 19 | Use: "set-vanilla ", 20 | Short: "Set the installation to vanilla mode or not", 21 | Args: cobra.ExactArgs(1), 22 | PreRun: func(cmd *cobra.Command, args []string) { 23 | _ = viper.BindPFlag("off", cmd.Flags().Lookup("off")) 24 | }, 25 | RunE: func(cmd *cobra.Command, args []string) error { 26 | global, err := cli.InitCLI(false) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | var installation *cli.Installation 32 | for _, install := range global.Installations.Installations { 33 | if install.Path == args[0] { 34 | installation = install 35 | break 36 | } 37 | } 38 | 39 | if installation == nil { 40 | return errors.New("installation not found") 41 | } 42 | 43 | installation.Vanilla = !viper.GetBool("off") 44 | 45 | return global.Save() 46 | }, 47 | } 48 | -------------------------------------------------------------------------------- /tea/components/error.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "time" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/charmbracelet/lipgloss" 8 | 9 | "github.com/satisfactorymodding/ficsit-cli/tea/utils" 10 | ) 11 | 12 | var _ tea.Model = (*ErrorComponent)(nil) 13 | 14 | type ErrorComponent struct { 15 | message string 16 | labelStyle lipgloss.Style 17 | } 18 | 19 | func NewErrorComponent(message string, duration time.Duration) (*ErrorComponent, tea.Cmd) { 20 | timer := time.NewTimer(duration) 21 | 22 | return &ErrorComponent{ 23 | message: message, 24 | labelStyle: utils.LabelStyle, 25 | }, func() tea.Msg { 26 | <-timer.C 27 | return ErrorComponentTimeoutMsg{} 28 | } 29 | } 30 | 31 | func (e ErrorComponent) Init() tea.Cmd { 32 | return nil 33 | } 34 | 35 | func (e ErrorComponent) Update(_ tea.Msg) (tea.Model, tea.Cmd) { 36 | return e, nil 37 | } 38 | 39 | func (e ErrorComponent) View() string { 40 | return lipgloss.NewStyle(). 41 | Foreground(lipgloss.Color("196")). 42 | BorderStyle(lipgloss.ThickBorder()). 43 | BorderForeground(lipgloss.Color("196")). 44 | Padding(0, 1). 45 | Margin(0, 0, 1, 2). 46 | Render(e.message) 47 | } 48 | 49 | type ErrorComponentTimeoutMsg struct{} 50 | -------------------------------------------------------------------------------- /cmd/apply.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | "sync" 7 | 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/satisfactorymodding/ficsit-cli/cli" 11 | ) 12 | 13 | var applyCmd = &cobra.Command{ 14 | Use: "apply [installation] ...", 15 | Short: "Apply profiles to all installations", 16 | RunE: func(cmd *cobra.Command, args []string) error { 17 | global, err := cli.InitCLI(false) 18 | if err != nil { 19 | return err 20 | } 21 | 22 | var wg sync.WaitGroup 23 | errored := false 24 | for _, installation := range global.Installations.Installations { 25 | if len(args) > 0 { 26 | found := false 27 | 28 | for _, installPath := range args { 29 | if installation.Path == installPath { 30 | found = true 31 | break 32 | } 33 | } 34 | 35 | if !found { 36 | continue 37 | } 38 | } 39 | 40 | wg.Add(1) 41 | 42 | go func(installation *cli.Installation) { 43 | defer wg.Done() 44 | if err := installation.Install(global, nil); err != nil { 45 | errored = true 46 | slog.Error("installation failed", slog.Any("err", err)) 47 | } 48 | }(installation) 49 | } 50 | 51 | wg.Wait() 52 | 53 | if errored { 54 | os.Exit(1) 55 | } 56 | 57 | return nil 58 | }, 59 | } 60 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG-REPORT.yml: -------------------------------------------------------------------------------- 1 | name: "🐛 Bug Report" 2 | description: File a bug report 3 | labels: ["bug"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for taking the time to fill out this bug report! 9 | - type: textarea 10 | id: what-happened 11 | attributes: 12 | label: What happened? 13 | validations: 14 | required: true 15 | - type: input 16 | id: version 17 | attributes: 18 | label: Version 19 | description: What version of our software are you running? 20 | validations: 21 | required: true 22 | - type: dropdown 23 | id: os 24 | attributes: 25 | label: What OS are you seeing the problem on? 26 | options: 27 | - Linux 28 | - Windows 29 | - macOS 30 | validations: 31 | required: true 32 | - type: dropdown 33 | id: arch 34 | attributes: 35 | label: What architecture are you seeing the problem on? 36 | options: 37 | - amd64 38 | - i386 (x86) 39 | - arm64 40 | - armv7 41 | - ppc64le 42 | - type: textarea 43 | id: logs 44 | attributes: 45 | label: Relevant log output 46 | description: Please copy and paste any relevant log output. 47 | render: shell 48 | - type: textarea 49 | id: extra-info 50 | attributes: 51 | label: "Any extra info:" -------------------------------------------------------------------------------- /cli/provider/converter.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | resolver "github.com/satisfactorymodding/ficsit-resolver" 5 | "github.com/spf13/viper" 6 | 7 | "github.com/satisfactorymodding/ficsit-cli/ficsit" 8 | ) 9 | 10 | func convertFicsitVersionsToResolver(versions []ficsit.ModVersion) []resolver.ModVersion { 11 | modVersions := make([]resolver.ModVersion, len(versions)) 12 | for i, modVersion := range versions { 13 | dependencies := make([]resolver.Dependency, len(modVersion.Dependencies)) 14 | for j, dependency := range modVersion.Dependencies { 15 | dependencies[j] = resolver.Dependency{ 16 | ModID: dependency.ModID, 17 | Condition: dependency.Condition, 18 | Optional: dependency.Optional, 19 | } 20 | } 21 | 22 | targets := make([]resolver.Target, len(modVersion.Targets)) 23 | for j, target := range modVersion.Targets { 24 | targets[j] = resolver.Target{ 25 | TargetName: resolver.TargetName(target.TargetName), 26 | Link: viper.GetString("api-base") + target.Link, 27 | Hash: target.Hash, 28 | Size: target.Size, 29 | } 30 | } 31 | 32 | modVersions[i] = resolver.ModVersion{ 33 | Version: modVersion.Version, 34 | GameVersion: modVersion.GameVersion, 35 | Dependencies: dependencies, 36 | Targets: targets, 37 | RequiredOnRemote: modVersion.RequiredOnRemote, 38 | } 39 | } 40 | return modVersions 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Set up Go 14 | uses: actions/setup-go@v2 15 | with: 16 | go-version: 1.21 17 | 18 | - name: Check out code into the Go module directory 19 | uses: actions/checkout@v2 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Download GQL schema 24 | run: "npx graphqurl https://api.ficsit.dev/v2/query --introspect -H 'content-type: application/json' > schema.graphql" 25 | 26 | - uses: kielabokkie/ssh-key-and-known-hosts-action@v1 27 | with: 28 | ssh-private-key: ${{ secrets.AUR_KEY }} 29 | ssh-host: aur.archlinux.org 30 | 31 | - name: Update UPX 32 | run: | 33 | mkdir ./upx 34 | cd ./upx 35 | wget https://github.com/upx/upx/releases/download/v3.96/upx-3.96-amd64_linux.tar.xz 36 | tar -xvf upx-3.96-amd64_linux.tar.xz 37 | sudo install upx-3.96-amd64_linux/upx /usr/local/bin/upx 38 | cd ../ 39 | rm -rf ./upx/ 40 | 41 | - name: Run GoReleaser 42 | uses: goreleaser/goreleaser-action@v2 43 | with: 44 | version: latest 45 | args: release --clean 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | AUR_KEY: ${{ secrets.AUR_KEY }} -------------------------------------------------------------------------------- /tea/utils/lists.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/list" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | var _ list.DefaultItem = (*SimpleItem[tea.Model])(nil) 10 | 11 | type SimpleItem[T tea.Model] struct { 12 | Activate func(msg tea.Msg, currentModel T) (tea.Model, tea.Cmd) 13 | ItemTitle string 14 | } 15 | 16 | type SimpleItemExtra[T tea.Model, E any] struct { 17 | Extra E 18 | SimpleItem[T] 19 | } 20 | 21 | func (n SimpleItem[any]) Title() string { 22 | return n.ItemTitle 23 | } 24 | 25 | func (n SimpleItem[any]) FilterValue() string { 26 | return n.ItemTitle 27 | } 28 | 29 | func (n SimpleItem[any]) Description() string { 30 | return "" 31 | } 32 | 33 | func NewItemDelegate() list.ItemDelegate { 34 | delegate := list.NewDefaultDelegate() 35 | delegate.ShowDescription = false 36 | delegate.SetSpacing(0) 37 | 38 | // TODO Adaptive Colors 39 | // TODO Description Colors 40 | delegate.Styles.NormalTitle = lipgloss.NewStyle(). 41 | Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}). 42 | Padding(0, 0, 0, 2) 43 | 44 | delegate.Styles.DimmedTitle = lipgloss.NewStyle(). 45 | Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}). 46 | Padding(0, 0, 0, 2) 47 | 48 | delegate.Styles.SelectedTitle = lipgloss.NewStyle(). 49 | Border(lipgloss.ThickBorder(), false, false, false, true). 50 | BorderForeground(lipgloss.Color("202")). 51 | Foreground(lipgloss.Color("202")). 52 | Padding(0, 0, 0, 1) 53 | 54 | return delegate 55 | } 56 | -------------------------------------------------------------------------------- /docs/ficsit_cli.md: -------------------------------------------------------------------------------- 1 | ## ficsit cli 2 | 3 | Start interactive CLI (default) 4 | 5 | ``` 6 | ficsit cli [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for cli 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --api-base string URL for API (default "https://api.ficsit.app") 19 | --api-key string API key to use when sending requests 20 | --cache-dir string The cache directory (default "/home/{{Username}}/.cache/ficsit") 21 | --concurrent-downloads int Maximum number of concurrent downloads (default 5) 22 | --dry-run Dry-run. Do not save any changes 23 | --graphql-api string Path for GraphQL API (default "/v2/query") 24 | --installations-file string The installations file (default "installations.json") 25 | --local-dir string The local directory (default "/home/{{Username}}/.local/share/ficsit") 26 | --log string The log level to output (default "info") 27 | --log-file string File to output logs to 28 | --offline Whether to only use local data 29 | --pretty Whether to render pretty terminal output (default true) 30 | --profiles-file string The profiles file (default "profiles.json") 31 | --quiet Do not log anything to console 32 | ``` 33 | 34 | ### SEE ALSO 35 | 36 | * [ficsit](ficsit.md) - cli mod manager for satisfactory 37 | 38 | ###### Auto generated by spf13/cobra on 7-Dec-2023 39 | -------------------------------------------------------------------------------- /docs/ficsit_profile_ls.md: -------------------------------------------------------------------------------- 1 | ## ficsit profile ls 2 | 3 | List all profiles 4 | 5 | ``` 6 | ficsit profile ls [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for ls 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --api-base string URL for API (default "https://api.ficsit.app") 19 | --api-key string API key to use when sending requests 20 | --cache-dir string The cache directory (default "/home/{{Username}}/.cache/ficsit") 21 | --concurrent-downloads int Maximum number of concurrent downloads (default 5) 22 | --dry-run Dry-run. Do not save any changes 23 | --graphql-api string Path for GraphQL API (default "/v2/query") 24 | --installations-file string The installations file (default "installations.json") 25 | --local-dir string The local directory (default "/home/{{Username}}/.local/share/ficsit") 26 | --log string The log level to output (default "info") 27 | --log-file string File to output logs to 28 | --offline Whether to only use local data 29 | --pretty Whether to render pretty terminal output (default true) 30 | --profiles-file string The profiles file (default "profiles.json") 31 | --quiet Do not log anything to console 32 | ``` 33 | 34 | ### SEE ALSO 35 | 36 | * [ficsit profile](ficsit_profile.md) - Manage profiles 37 | 38 | ###### Auto generated by spf13/cobra on 7-Dec-2023 39 | -------------------------------------------------------------------------------- /docs/ficsit_profile_new.md: -------------------------------------------------------------------------------- 1 | ## ficsit profile new 2 | 3 | Create a new profile 4 | 5 | ``` 6 | ficsit profile new [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for new 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --api-base string URL for API (default "https://api.ficsit.app") 19 | --api-key string API key to use when sending requests 20 | --cache-dir string The cache directory (default "/home/{{Username}}/.cache/ficsit") 21 | --concurrent-downloads int Maximum number of concurrent downloads (default 5) 22 | --dry-run Dry-run. Do not save any changes 23 | --graphql-api string Path for GraphQL API (default "/v2/query") 24 | --installations-file string The installations file (default "installations.json") 25 | --local-dir string The local directory (default "/home/{{Username}}/.local/share/ficsit") 26 | --log string The log level to output (default "info") 27 | --log-file string File to output logs to 28 | --offline Whether to only use local data 29 | --pretty Whether to render pretty terminal output (default true) 30 | --profiles-file string The profiles file (default "profiles.json") 31 | --quiet Do not log anything to console 32 | ``` 33 | 34 | ### SEE ALSO 35 | 36 | * [ficsit profile](ficsit_profile.md) - Manage profiles 37 | 38 | ###### Auto generated by spf13/cobra on 7-Dec-2023 39 | -------------------------------------------------------------------------------- /docs/ficsit_smr.md: -------------------------------------------------------------------------------- 1 | ## ficsit smr 2 | 3 | Manage mods on SMR 4 | 5 | ### Options 6 | 7 | ``` 8 | -h, --help help for smr 9 | ``` 10 | 11 | ### Options inherited from parent commands 12 | 13 | ``` 14 | --api-base string URL for API (default "https://api.ficsit.app") 15 | --api-key string API key to use when sending requests 16 | --cache-dir string The cache directory (default "/home/{{Username}}/.cache/ficsit") 17 | --concurrent-downloads int Maximum number of concurrent downloads (default 5) 18 | --dry-run Dry-run. Do not save any changes 19 | --graphql-api string Path for GraphQL API (default "/v2/query") 20 | --installations-file string The installations file (default "installations.json") 21 | --local-dir string The local directory (default "/home/{{Username}}/.local/share/ficsit") 22 | --log string The log level to output (default "info") 23 | --log-file string File to output logs to 24 | --offline Whether to only use local data 25 | --pretty Whether to render pretty terminal output (default true) 26 | --profiles-file string The profiles file (default "profiles.json") 27 | --quiet Do not log anything to console 28 | ``` 29 | 30 | ### SEE ALSO 31 | 32 | * [ficsit](ficsit.md) - cli mod manager for satisfactory 33 | * [ficsit smr upload](ficsit_smr_upload.md) - Upload a new mod version 34 | 35 | ###### Auto generated by spf13/cobra on 7-Dec-2023 36 | -------------------------------------------------------------------------------- /docs/ficsit_version.md: -------------------------------------------------------------------------------- 1 | ## ficsit version 2 | 3 | Print current version information 4 | 5 | ``` 6 | ficsit version [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for version 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --api-base string URL for API (default "https://api.ficsit.app") 19 | --api-key string API key to use when sending requests 20 | --cache-dir string The cache directory (default "/home/{{Username}}/.cache/ficsit") 21 | --concurrent-downloads int Maximum number of concurrent downloads (default 5) 22 | --dry-run Dry-run. Do not save any changes 23 | --graphql-api string Path for GraphQL API (default "/v2/query") 24 | --installations-file string The installations file (default "installations.json") 25 | --local-dir string The local directory (default "/home/{{Username}}/.local/share/ficsit") 26 | --log string The log level to output (default "info") 27 | --log-file string File to output logs to 28 | --offline Whether to only use local data 29 | --pretty Whether to render pretty terminal output (default true) 30 | --profiles-file string The profiles file (default "profiles.json") 31 | --quiet Do not log anything to console 32 | ``` 33 | 34 | ### SEE ALSO 35 | 36 | * [ficsit](ficsit.md) - cli mod manager for satisfactory 37 | 38 | ###### Auto generated by spf13/cobra on 7-Dec-2023 39 | -------------------------------------------------------------------------------- /cli/disk/local.go: -------------------------------------------------------------------------------- 1 | package disk 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "os" 8 | ) 9 | 10 | var _ Disk = (*localDisk)(nil) 11 | 12 | type localDisk struct { 13 | path string 14 | } 15 | 16 | type localEntry struct { 17 | os.DirEntry 18 | } 19 | 20 | func newLocal(path string) (Disk, error) { 21 | return localDisk{path: path}, nil 22 | } 23 | 24 | func (l localDisk) Exists(path string) (bool, error) { 25 | _, err := os.Stat(path) 26 | 27 | if errors.Is(err, os.ErrNotExist) { 28 | return false, nil 29 | } 30 | 31 | if err != nil { 32 | return false, fmt.Errorf("failed checking file existence: %w", err) 33 | } 34 | 35 | return true, nil 36 | } 37 | 38 | func (l localDisk) Read(path string) ([]byte, error) { 39 | return os.ReadFile(path) //nolint 40 | } 41 | 42 | func (l localDisk) Write(path string, data []byte) error { 43 | return os.WriteFile(path, data, 0o777) //nolint 44 | } 45 | 46 | func (l localDisk) Remove(path string) error { 47 | return os.RemoveAll(path) //nolint 48 | } 49 | 50 | func (l localDisk) MkDir(path string) error { 51 | return os.MkdirAll(path, 0o777) //nolint 52 | } 53 | 54 | func (l localDisk) ReadDir(path string) ([]Entry, error) { 55 | dir, err := os.ReadDir(path) 56 | if err != nil { 57 | return nil, err //nolint 58 | } 59 | 60 | entries := make([]Entry, len(dir)) 61 | for i, entry := range dir { 62 | entries[i] = localEntry{ 63 | DirEntry: entry, 64 | } 65 | } 66 | 67 | return entries, nil 68 | } 69 | 70 | func (l localDisk) Open(path string, flag int) (io.WriteCloser, error) { 71 | return os.OpenFile(path, flag, 0o777) //nolint 72 | } 73 | -------------------------------------------------------------------------------- /docs/ficsit_profile_delete.md: -------------------------------------------------------------------------------- 1 | ## ficsit profile delete 2 | 3 | Delete a profile 4 | 5 | ``` 6 | ficsit profile delete [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for delete 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --api-base string URL for API (default "https://api.ficsit.app") 19 | --api-key string API key to use when sending requests 20 | --cache-dir string The cache directory (default "/home/{{Username}}/.cache/ficsit") 21 | --concurrent-downloads int Maximum number of concurrent downloads (default 5) 22 | --dry-run Dry-run. Do not save any changes 23 | --graphql-api string Path for GraphQL API (default "/v2/query") 24 | --installations-file string The installations file (default "installations.json") 25 | --local-dir string The local directory (default "/home/{{Username}}/.local/share/ficsit") 26 | --log string The log level to output (default "info") 27 | --log-file string File to output logs to 28 | --offline Whether to only use local data 29 | --pretty Whether to render pretty terminal output (default true) 30 | --profiles-file string The profiles file (default "profiles.json") 31 | --quiet Do not log anything to console 32 | ``` 33 | 34 | ### SEE ALSO 35 | 36 | * [ficsit profile](ficsit_profile.md) - Manage profiles 37 | 38 | ###### Auto generated by spf13/cobra on 7-Dec-2023 39 | -------------------------------------------------------------------------------- /tea/components/header.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/charmbracelet/lipgloss" 6 | 7 | "github.com/satisfactorymodding/ficsit-cli/tea/utils" 8 | ) 9 | 10 | var _ tea.Model = (*headerComponent)(nil) 11 | 12 | type headerComponent struct { 13 | root RootModel 14 | labelStyle lipgloss.Style 15 | } 16 | 17 | func NewHeaderComponent(root RootModel) tea.Model { 18 | return headerComponent{ 19 | root: root, 20 | labelStyle: utils.LabelStyle, 21 | } 22 | } 23 | 24 | func (h headerComponent) Init() tea.Cmd { 25 | return nil 26 | } 27 | 28 | func (h headerComponent) Update(_ tea.Msg) (tea.Model, tea.Cmd) { 29 | return h, nil 30 | } 31 | 32 | func (h headerComponent) View() string { 33 | out := h.labelStyle.Render("Installation: ") 34 | if h.root.GetCurrentInstallation() != nil { 35 | out += h.root.GetCurrentInstallation().Path 36 | } else { 37 | out += "None" 38 | } 39 | out += "\n" 40 | 41 | out += h.labelStyle.Render("Profile: ") 42 | if h.root.GetCurrentProfile() != nil { 43 | out += h.root.GetCurrentProfile().Name 44 | } else { 45 | out += "None" 46 | } 47 | out += "\n" 48 | 49 | out += h.labelStyle.Render("Vanilla: ") 50 | if h.root.GetCurrentInstallation() != nil { 51 | if h.root.GetCurrentInstallation().Vanilla { 52 | out += "On" 53 | } else { 54 | out += "Off" 55 | } 56 | } else { 57 | out += "N/A" 58 | } 59 | 60 | if h.root.GetProvider().IsOffline() { 61 | out += "\n" 62 | out += h.labelStyle.Render("Offline") 63 | } 64 | 65 | return lipgloss.NewStyle().Margin(1, 0).Render(out) 66 | } 67 | -------------------------------------------------------------------------------- /docs/ficsit_apply.md: -------------------------------------------------------------------------------- 1 | ## ficsit apply 2 | 3 | Apply profiles to all installations 4 | 5 | ``` 6 | ficsit apply [installation] ... [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for apply 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --api-base string URL for API (default "https://api.ficsit.app") 19 | --api-key string API key to use when sending requests 20 | --cache-dir string The cache directory (default "/home/{{Username}}/.cache/ficsit") 21 | --concurrent-downloads int Maximum number of concurrent downloads (default 5) 22 | --dry-run Dry-run. Do not save any changes 23 | --graphql-api string Path for GraphQL API (default "/v2/query") 24 | --installations-file string The installations file (default "installations.json") 25 | --local-dir string The local directory (default "/home/{{Username}}/.local/share/ficsit") 26 | --log string The log level to output (default "info") 27 | --log-file string File to output logs to 28 | --offline Whether to only use local data 29 | --pretty Whether to render pretty terminal output (default true) 30 | --profiles-file string The profiles file (default "profiles.json") 31 | --quiet Do not log anything to console 32 | ``` 33 | 34 | ### SEE ALSO 35 | 36 | * [ficsit](ficsit.md) - cli mod manager for satisfactory 37 | 38 | ###### Auto generated by spf13/cobra on 7-Dec-2023 39 | -------------------------------------------------------------------------------- /docs/ficsit_profile_mods.md: -------------------------------------------------------------------------------- 1 | ## ficsit profile mods 2 | 3 | List all mods in a profile 4 | 5 | ``` 6 | ficsit profile mods [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for mods 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --api-base string URL for API (default "https://api.ficsit.app") 19 | --api-key string API key to use when sending requests 20 | --cache-dir string The cache directory (default "/home/{{Username}}/.cache/ficsit") 21 | --concurrent-downloads int Maximum number of concurrent downloads (default 5) 22 | --dry-run Dry-run. Do not save any changes 23 | --graphql-api string Path for GraphQL API (default "/v2/query") 24 | --installations-file string The installations file (default "installations.json") 25 | --local-dir string The local directory (default "/home/{{Username}}/.local/share/ficsit") 26 | --log string The log level to output (default "info") 27 | --log-file string File to output logs to 28 | --offline Whether to only use local data 29 | --pretty Whether to render pretty terminal output (default true) 30 | --profiles-file string The profiles file (default "profiles.json") 31 | --quiet Do not log anything to console 32 | ``` 33 | 34 | ### SEE ALSO 35 | 36 | * [ficsit profile](ficsit_profile.md) - Manage profiles 37 | 38 | ###### Auto generated by spf13/cobra on 7-Dec-2023 39 | -------------------------------------------------------------------------------- /docs/ficsit_profile_rename.md: -------------------------------------------------------------------------------- 1 | ## ficsit profile rename 2 | 3 | Rename a profile 4 | 5 | ``` 6 | ficsit profile rename [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for rename 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --api-base string URL for API (default "https://api.ficsit.app") 19 | --api-key string API key to use when sending requests 20 | --cache-dir string The cache directory (default "/home/{{Username}}/.cache/ficsit") 21 | --concurrent-downloads int Maximum number of concurrent downloads (default 5) 22 | --dry-run Dry-run. Do not save any changes 23 | --graphql-api string Path for GraphQL API (default "/v2/query") 24 | --installations-file string The installations file (default "installations.json") 25 | --local-dir string The local directory (default "/home/{{Username}}/.local/share/ficsit") 26 | --log string The log level to output (default "info") 27 | --log-file string File to output logs to 28 | --offline Whether to only use local data 29 | --pretty Whether to render pretty terminal output (default true) 30 | --profiles-file string The profiles file (default "profiles.json") 31 | --quiet Do not log anything to console 32 | ``` 33 | 34 | ### SEE ALSO 35 | 36 | * [ficsit profile](ficsit_profile.md) - Manage profiles 37 | 38 | ###### Auto generated by spf13/cobra on 7-Dec-2023 39 | -------------------------------------------------------------------------------- /docs/ficsit_installation_ls.md: -------------------------------------------------------------------------------- 1 | ## ficsit installation ls 2 | 3 | List all installations 4 | 5 | ``` 6 | ficsit installation ls [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for ls 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --api-base string URL for API (default "https://api.ficsit.app") 19 | --api-key string API key to use when sending requests 20 | --cache-dir string The cache directory (default "/home/{{Username}}/.cache/ficsit") 21 | --concurrent-downloads int Maximum number of concurrent downloads (default 5) 22 | --dry-run Dry-run. Do not save any changes 23 | --graphql-api string Path for GraphQL API (default "/v2/query") 24 | --installations-file string The installations file (default "installations.json") 25 | --local-dir string The local directory (default "/home/{{Username}}/.local/share/ficsit") 26 | --log string The log level to output (default "info") 27 | --log-file string File to output logs to 28 | --offline Whether to only use local data 29 | --pretty Whether to render pretty terminal output (default true) 30 | --profiles-file string The profiles file (default "profiles.json") 31 | --quiet Do not log anything to console 32 | ``` 33 | 34 | ### SEE ALSO 35 | 36 | * [ficsit installation](ficsit_installation.md) - Manage installations 37 | 38 | ###### Auto generated by spf13/cobra on 7-Dec-2023 39 | -------------------------------------------------------------------------------- /docs/ficsit_installation_add.md: -------------------------------------------------------------------------------- 1 | ## ficsit installation add 2 | 3 | Add an installation 4 | 5 | ``` 6 | ficsit installation add [profile] [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for add 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --api-base string URL for API (default "https://api.ficsit.app") 19 | --api-key string API key to use when sending requests 20 | --cache-dir string The cache directory (default "/home/{{Username}}/.cache/ficsit") 21 | --concurrent-downloads int Maximum number of concurrent downloads (default 5) 22 | --dry-run Dry-run. Do not save any changes 23 | --graphql-api string Path for GraphQL API (default "/v2/query") 24 | --installations-file string The installations file (default "installations.json") 25 | --local-dir string The local directory (default "/home/{{Username}}/.local/share/ficsit") 26 | --log string The log level to output (default "info") 27 | --log-file string File to output logs to 28 | --offline Whether to only use local data 29 | --pretty Whether to render pretty terminal output (default true) 30 | --profiles-file string The profiles file (default "profiles.json") 31 | --quiet Do not log anything to console 32 | ``` 33 | 34 | ### SEE ALSO 35 | 36 | * [ficsit installation](ficsit_installation.md) - Manage installations 37 | 38 | ###### Auto generated by spf13/cobra on 7-Dec-2023 39 | -------------------------------------------------------------------------------- /docs/ficsit_installation_remove.md: -------------------------------------------------------------------------------- 1 | ## ficsit installation remove 2 | 3 | Remove an installation 4 | 5 | ``` 6 | ficsit installation remove [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for remove 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --api-base string URL for API (default "https://api.ficsit.app") 19 | --api-key string API key to use when sending requests 20 | --cache-dir string The cache directory (default "/home/{{Username}}/.cache/ficsit") 21 | --concurrent-downloads int Maximum number of concurrent downloads (default 5) 22 | --dry-run Dry-run. Do not save any changes 23 | --graphql-api string Path for GraphQL API (default "/v2/query") 24 | --installations-file string The installations file (default "installations.json") 25 | --local-dir string The local directory (default "/home/{{Username}}/.local/share/ficsit") 26 | --log string The log level to output (default "info") 27 | --log-file string File to output logs to 28 | --offline Whether to only use local data 29 | --pretty Whether to render pretty terminal output (default true) 30 | --profiles-file string The profiles file (default "profiles.json") 31 | --quiet Do not log anything to console 32 | ``` 33 | 34 | ### SEE ALSO 35 | 36 | * [ficsit installation](ficsit_installation.md) - Manage installations 37 | 38 | ###### Auto generated by spf13/cobra on 7-Dec-2023 39 | -------------------------------------------------------------------------------- /docs/ficsit_installation_set-profile.md: -------------------------------------------------------------------------------- 1 | ## ficsit installation set-profile 2 | 3 | Change the profile of an installation 4 | 5 | ``` 6 | ficsit installation set-profile [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for set-profile 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --api-base string URL for API (default "https://api.ficsit.app") 19 | --api-key string API key to use when sending requests 20 | --cache-dir string The cache directory (default "/home/{{Username}}/.cache/ficsit") 21 | --concurrent-downloads int Maximum number of concurrent downloads (default 5) 22 | --dry-run Dry-run. Do not save any changes 23 | --graphql-api string Path for GraphQL API (default "/v2/query") 24 | --installations-file string The installations file (default "installations.json") 25 | --local-dir string The local directory (default "/home/{{Username}}/.local/share/ficsit") 26 | --log string The log level to output (default "info") 27 | --log-file string File to output logs to 28 | --offline Whether to only use local data 29 | --pretty Whether to render pretty terminal output (default true) 30 | --profiles-file string The profiles file (default "profiles.json") 31 | --quiet Do not log anything to console 32 | ``` 33 | 34 | ### SEE ALSO 35 | 36 | * [ficsit installation](ficsit_installation.md) - Manage installations 37 | 38 | ###### Auto generated by spf13/cobra on 7-Dec-2023 39 | -------------------------------------------------------------------------------- /cli/disk/main.go: -------------------------------------------------------------------------------- 1 | package disk 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log/slog" 7 | "net/url" 8 | "path/filepath" 9 | ) 10 | 11 | type Disk interface { 12 | // Exists checks if the provided file or directory exists 13 | Exists(path string) (bool, error) 14 | 15 | // Read returns the entire file as a byte buffer 16 | // 17 | // Returns error if provided path is not a file 18 | Read(path string) ([]byte, error) 19 | 20 | // Write writes provided byte buffer to the path 21 | Write(path string, data []byte) error 22 | 23 | // Remove deletes the provided file or directory recursively 24 | Remove(path string) error 25 | 26 | // MkDir creates the provided directory recursively 27 | MkDir(path string) error 28 | 29 | // ReadDir returns all entries within the directory 30 | // 31 | // Returns error if provided path is not a directory 32 | ReadDir(path string) ([]Entry, error) 33 | 34 | // Open opens provided path for writing 35 | Open(path string, flag int) (io.WriteCloser, error) 36 | } 37 | 38 | type Entry interface { 39 | IsDir() bool 40 | Name() string 41 | } 42 | 43 | func FromPath(path string) (Disk, error) { 44 | parsed, err := url.Parse(path) 45 | if err != nil { 46 | return nil, fmt.Errorf("failed to parse path: %w", err) 47 | } 48 | 49 | switch parsed.Scheme { 50 | case "ftp": 51 | slog.Info("connecting to ftp") 52 | return newFTP(path) 53 | case "sftp": 54 | slog.Info("connecting to sftp") 55 | return newSFTP(path) 56 | } 57 | 58 | slog.Info("using local disk", slog.String("path", path)) 59 | return newLocal(path) 60 | } 61 | 62 | // clean returns a unix-style path 63 | func clean(path string) string { 64 | return filepath.ToSlash(filepath.Clean(path)) 65 | } 66 | -------------------------------------------------------------------------------- /docs/ficsit_installation_set-vanilla.md: -------------------------------------------------------------------------------- 1 | ## ficsit installation set-vanilla 2 | 3 | Set the installation to vanilla mode or not 4 | 5 | ``` 6 | ficsit installation set-vanilla [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for set-vanilla 13 | -o, --off Disable vanilla 14 | ``` 15 | 16 | ### Options inherited from parent commands 17 | 18 | ``` 19 | --api-base string URL for API (default "https://api.ficsit.app") 20 | --api-key string API key to use when sending requests 21 | --cache-dir string The cache directory (default "/home/vilsol/.cache/ficsit") 22 | --concurrent-downloads int Maximum number of concurrent downloads (default 5) 23 | --dry-run Dry-run. Do not save any changes 24 | --graphql-api string Path for GraphQL API (default "/v2/query") 25 | --installations-file string The installations file (default "installations.json") 26 | --local-dir string The local directory (default "/home/vilsol/.local/share/ficsit") 27 | --log string The log level to output (default "info") 28 | --log-file string File to output logs to 29 | --offline Whether to only use local data 30 | --pretty Whether to render pretty terminal output (default true) 31 | --profiles-file string The profiles file (default "profiles.json") 32 | --quiet Do not log anything to console 33 | ``` 34 | 35 | ### SEE ALSO 36 | 37 | * [ficsit installation](ficsit_installation.md) - Manage installations 38 | 39 | ###### Auto generated by spf13/cobra on 7-Dec-2023 40 | -------------------------------------------------------------------------------- /cli/platforms.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import "path/filepath" 4 | 5 | type Platform struct { 6 | VersionPath string 7 | LockfilePath string 8 | TargetName string 9 | } 10 | 11 | var platforms = []Platform{ 12 | { 13 | VersionPath: filepath.Join("Engine", "Binaries", "Linux", "UnrealServer-Linux-Shipping.version"), 14 | LockfilePath: filepath.Join("FactoryGame", "Mods"), 15 | TargetName: "LinuxServer", 16 | }, 17 | { 18 | VersionPath: filepath.Join("Engine", "Binaries", "Win64", "UnrealServer-Win64-Shipping.version"), 19 | LockfilePath: filepath.Join("FactoryGame", "Mods"), 20 | TargetName: "WindowsServer", 21 | }, 22 | { 23 | VersionPath: filepath.Join("Engine", "Binaries", "Win64", "FactoryGame-Win64-Shipping.version"), 24 | LockfilePath: filepath.Join("FactoryGame", "Mods"), 25 | TargetName: "Windows", 26 | }, 27 | // Update 9 stuff below 28 | { 29 | VersionPath: filepath.Join("Engine", "Binaries", "Linux", "FactoryServer-Linux-Shipping.version"), 30 | LockfilePath: filepath.Join("FactoryGame", "Mods"), 31 | TargetName: "LinuxServer", 32 | }, 33 | { 34 | VersionPath: filepath.Join("Engine", "Binaries", "Win64", "FactoryServer-Win64-Shipping.version"), 35 | LockfilePath: filepath.Join("FactoryGame", "Mods"), 36 | TargetName: "WindowsServer", 37 | }, 38 | // 1.0 stuff 39 | { 40 | VersionPath: filepath.Join("Engine", "Binaries", "Win64", "FactoryGameSteam-Win64-Shipping.version"), 41 | LockfilePath: filepath.Join("FactoryGame", "Mods"), 42 | TargetName: "Windows", 43 | }, 44 | { 45 | VersionPath: filepath.Join("Engine", "Binaries", "Win64", "FactoryGameEGS-Win64-Shipping.version"), 46 | LockfilePath: filepath.Join("FactoryGame", "Mods"), 47 | TargetName: "Windows", 48 | }, 49 | } 50 | -------------------------------------------------------------------------------- /cli/provider/mixed.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | 6 | resolver "github.com/satisfactorymodding/ficsit-resolver" 7 | 8 | "github.com/satisfactorymodding/ficsit-cli/ficsit" 9 | ) 10 | 11 | type MixedProvider struct { 12 | onlineProvider Provider 13 | offlineProvider Provider 14 | Offline bool 15 | } 16 | 17 | func InitMixedProvider(onlineProvider Provider, offlineProvider Provider) *MixedProvider { 18 | return &MixedProvider{ 19 | onlineProvider: onlineProvider, 20 | offlineProvider: offlineProvider, 21 | Offline: false, 22 | } 23 | } 24 | 25 | func (p MixedProvider) Mods(context context.Context, filter ficsit.ModFilter) (*ficsit.ModsResponse, error) { 26 | if p.Offline { 27 | return p.offlineProvider.Mods(context, filter) 28 | } 29 | return p.onlineProvider.Mods(context, filter) 30 | } 31 | 32 | func (p MixedProvider) GetMod(context context.Context, modReference string) (*ficsit.GetModResponse, error) { 33 | if p.Offline { 34 | return p.offlineProvider.GetMod(context, modReference) 35 | } 36 | return p.onlineProvider.GetMod(context, modReference) 37 | } 38 | 39 | func (p MixedProvider) ModVersionsWithDependencies(context context.Context, modID string) ([]resolver.ModVersion, error) { 40 | if p.Offline { 41 | return p.offlineProvider.ModVersionsWithDependencies(context, modID) // nolint 42 | } 43 | return p.onlineProvider.ModVersionsWithDependencies(context, modID) // nolint 44 | } 45 | 46 | func (p MixedProvider) GetModName(context context.Context, modReference string) (*resolver.ModName, error) { 47 | if p.Offline { 48 | return p.offlineProvider.GetModName(context, modReference) // nolint 49 | } 50 | return p.onlineProvider.GetModName(context, modReference) // nolint 51 | } 52 | 53 | func (p MixedProvider) IsOffline() bool { 54 | return p.Offline 55 | } 56 | -------------------------------------------------------------------------------- /docs/ficsit_smr_upload.md: -------------------------------------------------------------------------------- 1 | ## ficsit smr upload 2 | 3 | Upload a new mod version 4 | 5 | ``` 6 | ficsit smr upload [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | --chunk-size int Size of chunks to split uploaded mod in bytes (default 10000000) 13 | -h, --help help for upload 14 | --stability string Stability of the uploaded mod (alpha, beta, release) (default "release") 15 | ``` 16 | 17 | ### Options inherited from parent commands 18 | 19 | ``` 20 | --api-base string URL for API (default "https://api.ficsit.app") 21 | --api-key string API key to use when sending requests 22 | --cache-dir string The cache directory (default "/home/{{Username}}/.cache/ficsit") 23 | --concurrent-downloads int Maximum number of concurrent downloads (default 5) 24 | --dry-run Dry-run. Do not save any changes 25 | --graphql-api string Path for GraphQL API (default "/v2/query") 26 | --installations-file string The installations file (default "installations.json") 27 | --local-dir string The local directory (default "/home/{{Username}}/.local/share/ficsit") 28 | --log string The log level to output (default "info") 29 | --log-file string File to output logs to 30 | --offline Whether to only use local data 31 | --pretty Whether to render pretty terminal output (default true) 32 | --profiles-file string The profiles file (default "profiles.json") 33 | --quiet Do not log anything to console 34 | ``` 35 | 36 | ### SEE ALSO 37 | 38 | * [ficsit smr](ficsit_smr.md) - Manage mods on SMR 39 | 40 | ###### Auto generated by spf13/cobra on 7-Dec-2023 41 | -------------------------------------------------------------------------------- /cli/provider/ficsit.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/Khan/genqlient/graphql" 8 | resolver "github.com/satisfactorymodding/ficsit-resolver" 9 | 10 | "github.com/satisfactorymodding/ficsit-cli/cli/localregistry" 11 | "github.com/satisfactorymodding/ficsit-cli/ficsit" 12 | ) 13 | 14 | type FicsitProvider struct { 15 | client graphql.Client 16 | } 17 | 18 | func NewFicsitProvider(client graphql.Client) FicsitProvider { 19 | return FicsitProvider{ 20 | client, 21 | } 22 | } 23 | 24 | func (p FicsitProvider) Mods(context context.Context, filter ficsit.ModFilter) (*ficsit.ModsResponse, error) { 25 | return ficsit.Mods(context, p.client, filter) 26 | } 27 | 28 | func (p FicsitProvider) GetMod(context context.Context, modReference string) (*ficsit.GetModResponse, error) { 29 | return ficsit.GetMod(context, p.client, modReference) 30 | } 31 | 32 | func (p FicsitProvider) ModVersionsWithDependencies(_ context.Context, modID string) ([]resolver.ModVersion, error) { 33 | response, err := ficsit.GetAllModVersions(modID) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | if response.Error != nil { 39 | return nil, errors.New(response.Error.Message) 40 | } 41 | 42 | localregistry.Add(modID, response.Data) 43 | 44 | return convertFicsitVersionsToResolver(response.Data), nil 45 | } 46 | 47 | func (p FicsitProvider) GetModName(context context.Context, modReference string) (*resolver.ModName, error) { 48 | response, err := ficsit.GetModName(context, p.client, modReference) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | return &resolver.ModName{ 54 | ID: response.Mod.Id, 55 | ModReference: response.Mod.Mod_reference, 56 | Name: response.Mod.Name, 57 | }, nil 58 | } 59 | 60 | func (p FicsitProvider) IsOffline() bool { 61 | return false 62 | } 63 | -------------------------------------------------------------------------------- /docs/ficsit.md: -------------------------------------------------------------------------------- 1 | ## ficsit 2 | 3 | cli mod manager for satisfactory 4 | 5 | ### Options 6 | 7 | ``` 8 | --api-base string URL for API (default "https://api.ficsit.app") 9 | --api-key string API key to use when sending requests 10 | --cache-dir string The cache directory (default "/home/{{Username}}/.cache/ficsit") 11 | --concurrent-downloads int Maximum number of concurrent downloads (default 5) 12 | --dry-run Dry-run. Do not save any changes 13 | --graphql-api string Path for GraphQL API (default "/v2/query") 14 | -h, --help help for ficsit 15 | --installations-file string The installations file (default "installations.json") 16 | --local-dir string The local directory (default "/home/{{Username}}/.local/share/ficsit") 17 | --log string The log level to output (default "info") 18 | --log-file string File to output logs to 19 | --offline Whether to only use local data 20 | --pretty Whether to render pretty terminal output (default true) 21 | --profiles-file string The profiles file (default "profiles.json") 22 | --quiet Do not log anything to console 23 | ``` 24 | 25 | ### SEE ALSO 26 | 27 | * [ficsit apply](ficsit_apply.md) - Apply profiles to all installations 28 | * [ficsit cli](ficsit_cli.md) - Start interactive CLI (default) 29 | * [ficsit installation](ficsit_installation.md) - Manage installations 30 | * [ficsit profile](ficsit_profile.md) - Manage profiles 31 | * [ficsit search](ficsit_search.md) - Search mods 32 | * [ficsit smr](ficsit_smr.md) - Manage mods on SMR 33 | * [ficsit version](ficsit_version.md) - Print current version information 34 | 35 | ###### Auto generated by spf13/cobra on 7-Dec-2023 36 | -------------------------------------------------------------------------------- /docs/ficsit_profile.md: -------------------------------------------------------------------------------- 1 | ## ficsit profile 2 | 3 | Manage profiles 4 | 5 | ### Options 6 | 7 | ``` 8 | -h, --help help for profile 9 | ``` 10 | 11 | ### Options inherited from parent commands 12 | 13 | ``` 14 | --api-base string URL for API (default "https://api.ficsit.app") 15 | --api-key string API key to use when sending requests 16 | --cache-dir string The cache directory (default "/home/{{Username}}/.cache/ficsit") 17 | --concurrent-downloads int Maximum number of concurrent downloads (default 5) 18 | --dry-run Dry-run. Do not save any changes 19 | --graphql-api string Path for GraphQL API (default "/v2/query") 20 | --installations-file string The installations file (default "installations.json") 21 | --local-dir string The local directory (default "/home/{{Username}}/.local/share/ficsit") 22 | --log string The log level to output (default "info") 23 | --log-file string File to output logs to 24 | --offline Whether to only use local data 25 | --pretty Whether to render pretty terminal output (default true) 26 | --profiles-file string The profiles file (default "profiles.json") 27 | --quiet Do not log anything to console 28 | ``` 29 | 30 | ### SEE ALSO 31 | 32 | * [ficsit](ficsit.md) - cli mod manager for satisfactory 33 | * [ficsit profile delete](ficsit_profile_delete.md) - Delete a profile 34 | * [ficsit profile ls](ficsit_profile_ls.md) - List all profiles 35 | * [ficsit profile mods](ficsit_profile_mods.md) - List all mods in a profile 36 | * [ficsit profile new](ficsit_profile_new.md) - Create a new profile 37 | * [ficsit profile rename](ficsit_profile_rename.md) - Rename a profile 38 | 39 | ###### Auto generated by spf13/cobra on 7-Dec-2023 40 | -------------------------------------------------------------------------------- /docs/ficsit_search.md: -------------------------------------------------------------------------------- 1 | ## ficsit search 2 | 3 | Search mods 4 | 5 | ``` 6 | ficsit search [query] [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | --format string Order field of the search (default "list") 13 | -h, --help help for search 14 | --limit int Limit of the search (default 10) 15 | --offset int Offset of the search 16 | --order string Sort order of the search (default "desc") 17 | --order-by string Order field of the search (default "last_version_date") 18 | ``` 19 | 20 | ### Options inherited from parent commands 21 | 22 | ``` 23 | --api-base string URL for API (default "https://api.ficsit.app") 24 | --api-key string API key to use when sending requests 25 | --cache-dir string The cache directory (default "/home/{{Username}}/.cache/ficsit") 26 | --concurrent-downloads int Maximum number of concurrent downloads (default 5) 27 | --dry-run Dry-run. Do not save any changes 28 | --graphql-api string Path for GraphQL API (default "/v2/query") 29 | --installations-file string The installations file (default "installations.json") 30 | --local-dir string The local directory (default "/home/{{Username}}/.local/share/ficsit") 31 | --log string The log level to output (default "info") 32 | --log-file string File to output logs to 33 | --offline Whether to only use local data 34 | --pretty Whether to render pretty terminal output (default true) 35 | --profiles-file string The profiles file (default "profiles.json") 36 | --quiet Do not log anything to console 37 | ``` 38 | 39 | ### SEE ALSO 40 | 41 | * [ficsit](ficsit.md) - cli mod manager for satisfactory 42 | 43 | ###### Auto generated by spf13/cobra on 7-Dec-2023 44 | -------------------------------------------------------------------------------- /tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | // +build tools 3 | 4 | package main 5 | 6 | import ( 7 | "os" 8 | "os/user" 9 | "path/filepath" 10 | "runtime" 11 | "strings" 12 | 13 | "github.com/spf13/cobra/doc" 14 | 15 | "github.com/satisfactorymodding/ficsit-cli/cmd" 16 | 17 | _ "github.com/Khan/genqlient/generate" 18 | ) 19 | 20 | //go:generate go run github.com/Khan/genqlient 21 | //go:generate go run -tags tools tools.go 22 | 23 | func main() { 24 | var err error 25 | _ = os.RemoveAll("./docs/") 26 | 27 | if err = os.Mkdir("./docs/", 0o777); err != nil { 28 | panic(err) 29 | } 30 | 31 | err = doc.GenMarkdownTree(cmd.RootCmd, "./docs/") 32 | if err != nil { 33 | panic(err) 34 | } 35 | 36 | // replace user dir information with generic username 37 | baseCacheDir, err := os.UserCacheDir() 38 | if err != nil { 39 | panic(err) 40 | } 41 | 42 | var baseLocalDir string 43 | 44 | switch runtime.GOOS { 45 | case "windows": 46 | baseLocalDir = os.Getenv("APPDATA") 47 | case "linux": 48 | baseLocalDir = filepath.Join(os.Getenv("HOME"), ".local", "share") 49 | case "darwin": 50 | baseLocalDir = filepath.Join(os.Getenv("HOME"), "Library", "Application Support") 51 | default: 52 | panic("unsupported platform: " + runtime.GOOS) 53 | } 54 | 55 | docFiles, err := os.ReadDir("./docs/") 56 | if err != nil { 57 | panic(err) 58 | } 59 | 60 | user, err := user.Current() 61 | if err != nil { 62 | panic(err) 63 | } 64 | 65 | for _, f := range docFiles { 66 | fPath := "./docs/" + f.Name() 67 | oldContents, err := os.ReadFile(fPath) 68 | if err != nil { 69 | panic(err) 70 | } 71 | 72 | newContents := strings.ReplaceAll( 73 | string(oldContents), 74 | baseCacheDir, 75 | strings.ReplaceAll(baseCacheDir, user.Username, "{{Username}}"), 76 | ) 77 | 78 | newContents = strings.ReplaceAll( 79 | newContents, 80 | baseLocalDir, 81 | strings.ReplaceAll(baseLocalDir, user.Username, "{{Username}}"), 82 | ) 83 | 84 | os.WriteFile(fPath, []byte(newContents), 0o777) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /docs/ficsit_installation.md: -------------------------------------------------------------------------------- 1 | ## ficsit installation 2 | 3 | Manage installations 4 | 5 | ### Options 6 | 7 | ``` 8 | -h, --help help for installation 9 | ``` 10 | 11 | ### Options inherited from parent commands 12 | 13 | ``` 14 | --api-base string URL for API (default "https://api.ficsit.app") 15 | --api-key string API key to use when sending requests 16 | --cache-dir string The cache directory (default "/home/{{Username}}/.cache/ficsit") 17 | --concurrent-downloads int Maximum number of concurrent downloads (default 5) 18 | --dry-run Dry-run. Do not save any changes 19 | --graphql-api string Path for GraphQL API (default "/v2/query") 20 | --installations-file string The installations file (default "installations.json") 21 | --local-dir string The local directory (default "/home/{{Username}}/.local/share/ficsit") 22 | --log string The log level to output (default "info") 23 | --log-file string File to output logs to 24 | --offline Whether to only use local data 25 | --pretty Whether to render pretty terminal output (default true) 26 | --profiles-file string The profiles file (default "profiles.json") 27 | --quiet Do not log anything to console 28 | ``` 29 | 30 | ### SEE ALSO 31 | 32 | * [ficsit](ficsit.md) - cli mod manager for satisfactory 33 | * [ficsit installation add](ficsit_installation_add.md) - Add an installation 34 | * [ficsit installation ls](ficsit_installation_ls.md) - List all installations 35 | * [ficsit installation remove](ficsit_installation_remove.md) - Remove an installation 36 | * [ficsit installation set-profile](ficsit_installation_set-profile.md) - Change the profile of an installation 37 | * [ficsit installation set-vanilla](ficsit_installation_set-vanilla.md) - Set the installation to vanilla mode or not 38 | 39 | ###### Auto generated by spf13/cobra on 7-Dec-2023 40 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: ficsit 2 | 3 | before: 4 | hooks: 5 | - go generate -x -tags tools ./... 6 | - go mod tidy 7 | 8 | builds: 9 | - id: with-upx 10 | env: 11 | - CGO_ENABLED=0 12 | goos: 13 | - linux 14 | - darwin 15 | goarch: 16 | - amd64 17 | - arm 18 | - arm64 19 | - ppc64le 20 | - 386 21 | goarm: 22 | - 7 23 | hooks: 24 | post: 'upx {{ .Path }}' 25 | - id: without-upx 26 | env: 27 | - CGO_ENABLED=0 28 | goos: 29 | - windows 30 | goarch: 31 | - amd64 32 | - arm64 33 | - 386 34 | 35 | universal_binaries: 36 | - replace: true 37 | ids: 38 | - with-upx 39 | 40 | archives: 41 | - format: binary 42 | allow_different_binary_count: true 43 | name_template: "{{ .Binary }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}{{ if .Mips }}_{{ .Mips }}{{ end }}" 44 | 45 | nfpms: 46 | - vendor: Satisfactory Modding 47 | homepage: https://ficsit.app/ 48 | maintainer: Satisfactory Modding Team 49 | description: CLI tool for Ficsit (Satisfactory Modding) 50 | license: GNU General Public License v3.0 51 | file_name_template: "{{ .PackageName }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}" 52 | formats: 53 | - apk 54 | - deb 55 | - rpm 56 | 57 | checksum: 58 | name_template: 'checksums.txt' 59 | 60 | snapshot: 61 | name_template: "{{ .Tag }}-next" 62 | 63 | changelog: 64 | sort: asc 65 | filters: 66 | exclude: 67 | - '^docs:' 68 | - '^test:' 69 | 70 | aurs: 71 | - name: ficsit-cli-bin 72 | description: A CLI for managing mods for the game Satisfactory 73 | homepage: https://github.com/satisfactorymodding/ficsit-cli 74 | maintainers: 75 | - 'Vilsol ' 76 | license: GPL3 77 | private_key: '{{ .Env.AUR_KEY }}' 78 | git_url: 'ssh://aur@aur.archlinux.org/ficsit-cli-bin.git' 79 | package: | 80 | install -Dm755 "./${pkgname}_${pkgver}_${CARCH}.binary" "${pkgdir}/usr/bin/ficsit" -------------------------------------------------------------------------------- /tea/utils/styles.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/list" 5 | "github.com/charmbracelet/lipgloss" 6 | ) 7 | 8 | var ( 9 | ListStyles = list.DefaultStyles() 10 | LabelStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("202")) 11 | TitleStyle = list.DefaultStyles().Title.Background(lipgloss.Color("#b34100")) 12 | NonListTitleStyle = TitleStyle.Copy().MarginLeft(2).Background(lipgloss.Color("#b34100")) 13 | ) 14 | 15 | var ( 16 | CompatWorksStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00b12d")) 17 | CompatDamagedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#e69000")) 18 | CompatBrokenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#e60000")) 19 | CompatUntestedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#838383")) 20 | ) 21 | 22 | var ( 23 | LogoForegroundStyles = []lipgloss.Style{ 24 | lipgloss.NewStyle().Foreground(lipgloss.Color("#ff5f00")).Background(lipgloss.Color("#ff5f00")), 25 | lipgloss.NewStyle().Foreground(lipgloss.Color("#e65400")).Background(lipgloss.Color("#e65400")), 26 | lipgloss.NewStyle().Foreground(lipgloss.Color("#cc4b00")).Background(lipgloss.Color("#cc4b00")), 27 | lipgloss.NewStyle().Foreground(lipgloss.Color("#b34100")).Background(lipgloss.Color("#b34100")), 28 | lipgloss.NewStyle().Foreground(lipgloss.Color("#993800")).Background(lipgloss.Color("#993800")), 29 | lipgloss.NewStyle(), 30 | } 31 | LogoBackgroundStyles = []lipgloss.Style{ 32 | lipgloss.NewStyle().Foreground(lipgloss.Color("255")), 33 | lipgloss.NewStyle().Foreground(lipgloss.Color("252")), 34 | lipgloss.NewStyle().Foreground(lipgloss.Color("249")), 35 | lipgloss.NewStyle().Foreground(lipgloss.Color("246")), 36 | lipgloss.NewStyle().Foreground(lipgloss.Color("243")), 37 | lipgloss.NewStyle().Foreground(lipgloss.Color("240")), 38 | } 39 | ) 40 | 41 | func init() { 42 | ListStyles.Title = TitleStyle 43 | ListStyles.HelpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(2).PaddingBottom(1) 44 | ListStyles.PaginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(2) 45 | } 46 | -------------------------------------------------------------------------------- /cmd/search.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/spf13/viper" 9 | 10 | "github.com/satisfactorymodding/ficsit-cli/ficsit" 11 | ) 12 | 13 | func init() { 14 | searchCmd.PersistentFlags().Int("offset", 0, "Offset of the search") 15 | searchCmd.PersistentFlags().Int("limit", 10, "Limit of the search") 16 | searchCmd.PersistentFlags().String("order", "desc", "Sort order of the search") 17 | searchCmd.PersistentFlags().String("order-by", "last_version_date", "Order field of the search") 18 | searchCmd.PersistentFlags().String("format", "list", "Order field of the search") 19 | 20 | _ = viper.BindPFlag("offset", searchCmd.PersistentFlags().Lookup("offset")) 21 | _ = viper.BindPFlag("limit", searchCmd.PersistentFlags().Lookup("limit")) 22 | _ = viper.BindPFlag("order", searchCmd.PersistentFlags().Lookup("order")) 23 | _ = viper.BindPFlag("order-by", searchCmd.PersistentFlags().Lookup("order-by")) 24 | _ = viper.BindPFlag("format", searchCmd.PersistentFlags().Lookup("format")) 25 | } 26 | 27 | var searchCmd = &cobra.Command{ 28 | Use: "search [query]", 29 | Short: "Search mods", 30 | Args: cobra.MaximumNArgs(1), 31 | RunE: func(cmd *cobra.Command, args []string) error { 32 | client := ficsit.InitAPI() 33 | 34 | search := "" 35 | if len(args) > 0 { 36 | search = args[0] 37 | } 38 | 39 | response, err := ficsit.Mods(cmd.Context(), client, ficsit.ModFilter{ 40 | Limit: viper.GetInt("limit"), 41 | Offset: viper.GetInt("offset"), 42 | Order: ficsit.Order(viper.GetString("order")), 43 | Order_by: ficsit.ModFields(viper.GetString("order-by")), 44 | Search: search, 45 | }) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | modList := response.Mods.Mods 51 | 52 | switch viper.GetString("format") { 53 | default: 54 | for _, mod := range modList { 55 | println(fmt.Sprintf("%s (%s)", mod.Name, mod.Mod_reference)) 56 | } 57 | case "json": 58 | result, err := json.MarshalIndent(modList, "", " ") 59 | if err != nil { 60 | return fmt.Errorf("failed converting mods to json: %w", err) 61 | } 62 | println(string(result)) 63 | } 64 | 65 | return nil 66 | }, 67 | } 68 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1694529238, 9 | "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1700014976, 24 | "narHash": "sha256-dSGpS2YeJrXW5aH9y7Abd235gGufY3RuZFth6vuyVtU=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "592047fc9e4f7b74a4dc85d1b9f5243dfe4899e3", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "id": "nixpkgs", 32 | "type": "indirect" 33 | } 34 | }, 35 | "nixpkgs-unstable": { 36 | "locked": { 37 | "lastModified": 1701040486, 38 | "narHash": "sha256-vawYwoHA5CwvjfqaT3A5CT9V36Eq43gxdwpux32Qkjw=", 39 | "owner": "NixOS", 40 | "repo": "nixpkgs", 41 | "rev": "45827faa2132b8eade424f6bdd48d8828754341a", 42 | "type": "github" 43 | }, 44 | "original": { 45 | "id": "nixpkgs", 46 | "ref": "nixpkgs-unstable", 47 | "type": "indirect" 48 | } 49 | }, 50 | "root": { 51 | "inputs": { 52 | "flake-utils": "flake-utils", 53 | "nixpkgs": "nixpkgs", 54 | "nixpkgs-unstable": "nixpkgs-unstable" 55 | } 56 | }, 57 | "systems": { 58 | "locked": { 59 | "lastModified": 1681028828, 60 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 61 | "owner": "nix-systems", 62 | "repo": "default", 63 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 64 | "type": "github" 65 | }, 66 | "original": { 67 | "owner": "nix-systems", 68 | "repo": "default", 69 | "type": "github" 70 | } 71 | } 72 | }, 73 | "root": "root", 74 | "version": 7 75 | } 76 | -------------------------------------------------------------------------------- /tea/scenes/mods/mod_semver.go: -------------------------------------------------------------------------------- 1 | package mods 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/charmbracelet/bubbles/textinput" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | 10 | "github.com/satisfactorymodding/ficsit-cli/tea/components" 11 | "github.com/satisfactorymodding/ficsit-cli/tea/scenes/keys" 12 | "github.com/satisfactorymodding/ficsit-cli/tea/utils" 13 | ) 14 | 15 | var _ tea.Model = (*modSemver)(nil) 16 | 17 | type modSemver struct { 18 | root components.RootModel 19 | parent tea.Model 20 | error *components.ErrorComponent 21 | mod utils.Mod 22 | title string 23 | input textinput.Model 24 | } 25 | 26 | func NewModSemver(root components.RootModel, parent tea.Model, mod utils.Mod) tea.Model { 27 | model := modSemver{ 28 | root: root, 29 | parent: parent, 30 | input: textinput.New(), 31 | title: lipgloss.NewStyle().Padding(0, 2).Render(utils.TitleStyle.Render(mod.Name)), 32 | mod: mod, 33 | } 34 | 35 | model.input.Placeholder = ">=1.2.3" 36 | model.input.Focus() 37 | model.input.Width = root.Size().Width 38 | 39 | return model 40 | } 41 | 42 | func (m modSemver) Init() tea.Cmd { 43 | return textinput.Blink 44 | } 45 | 46 | func (m modSemver) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 47 | switch msg := msg.(type) { 48 | case tea.KeyMsg: 49 | switch keypress := msg.String(); keypress { 50 | case keys.KeyControlC: 51 | return m, tea.Quit 52 | case keys.KeyEscape: 53 | return m.parent, nil 54 | case keys.KeyEnter: 55 | err := m.root.GetCurrentProfile().AddMod(m.mod.Reference, m.input.Value()) 56 | if err != nil { 57 | errorComponent, cmd := components.NewErrorComponent(err.Error(), time.Second*5) 58 | m.error = errorComponent 59 | return m, cmd 60 | } 61 | return m.parent, nil 62 | default: 63 | var cmd tea.Cmd 64 | m.input, cmd = m.input.Update(msg) 65 | return m, cmd 66 | } 67 | case tea.WindowSizeMsg: 68 | m.root.SetSize(msg) 69 | case components.ErrorComponentTimeoutMsg: 70 | m.error = nil 71 | default: 72 | var cmd tea.Cmd 73 | m.input, cmd = m.input.Update(msg) 74 | return m, cmd 75 | } 76 | 77 | return m, nil 78 | } 79 | 80 | func (m modSemver) View() string { 81 | inputView := lipgloss.NewStyle().Padding(1, 2).Render(m.input.View()) 82 | 83 | if m.error != nil { 84 | return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.title, m.error.View(), inputView) 85 | } 86 | 87 | return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.title, inputView) 88 | } 89 | -------------------------------------------------------------------------------- /tea/scenes/profile/rename_profile.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/charmbracelet/bubbles/textinput" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | 11 | "github.com/satisfactorymodding/ficsit-cli/cli" 12 | "github.com/satisfactorymodding/ficsit-cli/tea/components" 13 | "github.com/satisfactorymodding/ficsit-cli/tea/scenes/keys" 14 | "github.com/satisfactorymodding/ficsit-cli/tea/utils" 15 | ) 16 | 17 | var _ tea.Model = (*renameProfile)(nil) 18 | 19 | type renameProfile struct { 20 | root components.RootModel 21 | parent tea.Model 22 | error *components.ErrorComponent 23 | title string 24 | oldName string 25 | input textinput.Model 26 | } 27 | 28 | func NewRenameProfile(root components.RootModel, parent tea.Model, profileData *cli.Profile) tea.Model { 29 | model := renameProfile{ 30 | root: root, 31 | parent: parent, 32 | input: textinput.New(), 33 | title: utils.NonListTitleStyle.Render(fmt.Sprintf("Rename Profile: %s", profileData.Name)), 34 | oldName: profileData.Name, 35 | } 36 | 37 | model.input.SetValue(profileData.Name) 38 | model.input.Focus() 39 | model.input.Width = root.Size().Width 40 | 41 | return model 42 | } 43 | 44 | func (m renameProfile) Init() tea.Cmd { 45 | return textinput.Blink 46 | } 47 | 48 | func (m renameProfile) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 49 | switch msg := msg.(type) { 50 | case tea.KeyMsg: 51 | switch keypress := msg.String(); keypress { 52 | case keys.KeyControlC: 53 | return m, tea.Quit 54 | case keys.KeyEscape: 55 | return m.parent, nil 56 | case keys.KeyEnter: 57 | if err := m.root.GetGlobal().Profiles.RenameProfile(m.root.GetGlobal(), m.oldName, m.input.Value()); err != nil { 58 | errorComponent, cmd := components.NewErrorComponent(err.Error(), time.Second*5) 59 | m.error = errorComponent 60 | return m, cmd 61 | } 62 | 63 | return m.parent, updateProfileNamesCmd 64 | default: 65 | var cmd tea.Cmd 66 | m.input, cmd = m.input.Update(msg) 67 | return m, cmd 68 | } 69 | case tea.WindowSizeMsg: 70 | m.root.SetSize(msg) 71 | case components.ErrorComponentTimeoutMsg: 72 | m.error = nil 73 | default: 74 | var cmd tea.Cmd 75 | m.input, cmd = m.input.Update(msg) 76 | return m, cmd 77 | } 78 | 79 | return m, nil 80 | } 81 | 82 | func (m renameProfile) View() string { 83 | inputView := lipgloss.NewStyle().Padding(1, 2).Render(m.input.View()) 84 | 85 | if m.error != nil { 86 | return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.title, m.error.View(), inputView) 87 | } 88 | 89 | return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.title, inputView) 90 | } 91 | -------------------------------------------------------------------------------- /tea/root.go: -------------------------------------------------------------------------------- 1 | package tea 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/Khan/genqlient/graphql" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | resolver "github.com/satisfactorymodding/ficsit-resolver" 10 | 11 | "github.com/satisfactorymodding/ficsit-cli/cli" 12 | "github.com/satisfactorymodding/ficsit-cli/cli/provider" 13 | "github.com/satisfactorymodding/ficsit-cli/tea/components" 14 | "github.com/satisfactorymodding/ficsit-cli/tea/scenes" 15 | ) 16 | 17 | type rootModel struct { 18 | headerComponent tea.Model 19 | global *cli.GlobalContext 20 | dependencyResolver resolver.DependencyResolver 21 | currentSize tea.WindowSizeMsg 22 | } 23 | 24 | func newModel(global *cli.GlobalContext) *rootModel { 25 | m := &rootModel{ 26 | global: global, 27 | currentSize: tea.WindowSizeMsg{ 28 | Width: 20, 29 | Height: 14, 30 | }, 31 | dependencyResolver: resolver.NewDependencyResolver(global.Provider), 32 | } 33 | 34 | m.headerComponent = components.NewHeaderComponent(m) 35 | 36 | return m 37 | } 38 | 39 | func (m *rootModel) GetCurrentProfile() *cli.Profile { 40 | return m.global.Profiles.GetProfile(m.global.Profiles.SelectedProfile) 41 | } 42 | 43 | func (m *rootModel) SetCurrentProfile(profile *cli.Profile) error { 44 | m.global.Profiles.SelectedProfile = profile.Name 45 | 46 | if m.GetCurrentInstallation() != nil { 47 | if err := m.GetCurrentInstallation().SetProfile(m.global, profile.Name); err != nil { 48 | return fmt.Errorf("failed setting profile on installation: %w", err) 49 | } 50 | } 51 | 52 | return nil 53 | } 54 | 55 | func (m *rootModel) GetCurrentInstallation() *cli.Installation { 56 | return m.global.Installations.GetInstallation(m.global.Installations.SelectedInstallation) 57 | } 58 | 59 | func (m *rootModel) SetCurrentInstallation(installation *cli.Installation) error { 60 | m.global.Installations.SelectedInstallation = installation.Path 61 | m.global.Profiles.SelectedProfile = installation.Profile 62 | return nil 63 | } 64 | 65 | func (m *rootModel) GetAPIClient() graphql.Client { 66 | return m.global.APIClient 67 | } 68 | 69 | func (m *rootModel) GetProvider() provider.Provider { 70 | return m.global.Provider 71 | } 72 | 73 | func (m *rootModel) GetResolver() resolver.DependencyResolver { 74 | return m.dependencyResolver 75 | } 76 | 77 | func (m *rootModel) Size() tea.WindowSizeMsg { 78 | return m.currentSize 79 | } 80 | 81 | func (m *rootModel) SetSize(size tea.WindowSizeMsg) { 82 | m.currentSize = size 83 | } 84 | 85 | func (m *rootModel) View() string { 86 | return m.headerComponent.View() 87 | } 88 | 89 | func (m *rootModel) Height() int { 90 | return lipgloss.Height(m.View()) + 1 91 | } 92 | 93 | func (m *rootModel) GetGlobal() *cli.GlobalContext { 94 | return m.global 95 | } 96 | 97 | func RunTea(global *cli.GlobalContext) error { 98 | if _, err := tea.NewProgram(scenes.NewMainMenu(newModel(global)), tea.WithAltScreen(), tea.WithMouseCellMotion()).Run(); err != nil { 99 | return fmt.Errorf("internal tea error: %w", err) 100 | } 101 | return nil 102 | } 103 | -------------------------------------------------------------------------------- /cli/localregistry/migrations.go: -------------------------------------------------------------------------------- 1 | package localregistry 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | ) 7 | 8 | var migrations = []func(*sql.Tx) error{ 9 | initialSetup, 10 | addRequiredOnRemote, 11 | } 12 | 13 | func applyMigrations(db *sql.DB) error { 14 | // user_version will store the 1-indexed migration that was last applied 15 | var nextMigration int 16 | err := db.QueryRow("PRAGMA user_version;").Scan(&nextMigration) 17 | if err != nil { 18 | return fmt.Errorf("failed to get user_version: %w", err) 19 | } 20 | 21 | for i := nextMigration; i < len(migrations); i++ { 22 | err := applyMigration(db, i) 23 | if err != nil { 24 | return fmt.Errorf("failed to apply migration %d: %w", i, err) 25 | } 26 | } 27 | 28 | return nil 29 | } 30 | 31 | func applyMigration(db *sql.DB, migrationIndex int) error { 32 | tx, err := db.Begin() 33 | if err != nil { 34 | return fmt.Errorf("failed to start transaction: %w", err) 35 | } 36 | // Will noop if the transaction was committed 37 | defer tx.Rollback() //nolint:errcheck 38 | 39 | err = migrations[migrationIndex](tx) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | _, err = tx.Exec(fmt.Sprintf("PRAGMA user_version = %d;", migrationIndex+1)) 45 | if err != nil { 46 | return fmt.Errorf("failed to set user_version: %w", err) 47 | } 48 | 49 | err = tx.Commit() 50 | if err != nil { 51 | return fmt.Errorf("failed to commit transaction: %w", err) 52 | } 53 | 54 | return nil 55 | } 56 | 57 | func initialSetup(tx *sql.Tx) error { 58 | // Create the initial user 59 | _, err := tx.Exec(` 60 | CREATE TABLE IF NOT EXISTS "versions" ( 61 | "id" TEXT NOT NULL PRIMARY KEY, 62 | "mod_reference" TEXT NOT NULL, 63 | "version" TEXT NOT NULL, 64 | "game_version" TEXT NOT NULL 65 | ); 66 | CREATE INDEX IF NOT EXISTS "mod_reference" ON "versions" ("mod_reference"); 67 | CREATE UNIQUE INDEX IF NOT EXISTS "mod_version" ON "versions" ("mod_reference", "version"); 68 | 69 | CREATE TABLE IF NOT EXISTS "dependencies" ( 70 | "version_id" TEXT NOT NULL, 71 | "dependency" TEXT NOT NULL, 72 | "condition" TEXT NOT NULL, 73 | "optional" INT NOT NULL, 74 | FOREIGN KEY ("version_id") REFERENCES "versions" ("id") ON DELETE CASCADE, 75 | PRIMARY KEY ("version_id", "dependency") 76 | ); 77 | 78 | CREATE TABLE IF NOT EXISTS "targets" ( 79 | "version_id" TEXT NOT NULL, 80 | "target_name" TEXT NOT NULL, 81 | "link" TEXT NOT NULL, 82 | "hash" TEXT NOT NULL, 83 | "size" INT NOT NULL, 84 | FOREIGN KEY ("version_id") REFERENCES "versions" ("id") ON DELETE CASCADE, 85 | PRIMARY KEY ("version_id", "target_name") 86 | ); 87 | `) 88 | 89 | if err != nil { 90 | return fmt.Errorf("failed to create initial tables: %w", err) 91 | } 92 | 93 | return nil 94 | } 95 | 96 | func addRequiredOnRemote(tx *sql.Tx) error { 97 | _, err := tx.Exec(` 98 | ALTER TABLE "versions" ADD COLUMN "required_on_remote" INT NOT NULL DEFAULT 1; 99 | `) 100 | 101 | if err != nil { 102 | return fmt.Errorf("failed to add required_on_remote column: %w", err) 103 | } 104 | 105 | return nil 106 | } 107 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/go,jetbrains+all 2 | # Edit at https://www.gitignore.io/?templates=go,jetbrains+all 3 | 4 | ### Go ### 5 | # Binaries for programs and plugins 6 | *.exe 7 | *.exe~ 8 | *.dll 9 | *.so 10 | *.dylib 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | # vendor/ 20 | 21 | ### Go Patch ### 22 | /vendor/ 23 | /Godeps/ 24 | 25 | ### VS Code 26 | # Reference: https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | !.vscode/*.code-snippets 33 | # Local History for Visual Studio Code 34 | .history/ 35 | # Built Visual Studio Code Extensions 36 | *.vsix 37 | 38 | ### JetBrains+all ### 39 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 40 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 41 | 42 | # User-specific stuff 43 | .idea/**/workspace.xml 44 | .idea/**/tasks.xml 45 | .idea/**/usage.statistics.xml 46 | .idea/**/dictionaries 47 | .idea/**/shelf 48 | 49 | # Generated files 50 | .idea/**/contentModel.xml 51 | 52 | # Sensitive or high-churn files 53 | .idea/**/dataSources/ 54 | .idea/**/dataSources.ids 55 | .idea/**/dataSources.local.xml 56 | .idea/**/sqlDataSources.xml 57 | .idea/**/dynamic.xml 58 | .idea/**/uiDesigner.xml 59 | .idea/**/dbnavigator.xml 60 | 61 | # Gradle 62 | .idea/**/gradle.xml 63 | .idea/**/libraries 64 | 65 | # Gradle and Maven with auto-import 66 | # When using Gradle or Maven with auto-import, you should exclude module files, 67 | # since they will be recreated, and may cause churn. Uncomment if using 68 | # auto-import. 69 | # .idea/modules.xml 70 | # .idea/*.iml 71 | # .idea/modules 72 | # *.iml 73 | # *.ipr 74 | 75 | # CMake 76 | cmake-build-*/ 77 | 78 | # Mongo Explorer plugin 79 | .idea/**/mongoSettings.xml 80 | 81 | # File-based project format 82 | *.iws 83 | 84 | # IntelliJ 85 | out/ 86 | 87 | # mpeltonen/sbt-idea plugin 88 | .idea_modules/ 89 | 90 | # JIRA plugin 91 | atlassian-ide-plugin.xml 92 | 93 | # Cursive Clojure plugin 94 | .idea/replstate.xml 95 | 96 | # Crashlytics plugin (for Android Studio and IntelliJ) 97 | com_crashlytics_export_strings.xml 98 | crashlytics.properties 99 | crashlytics-build.properties 100 | fabric.properties 101 | 102 | # Editor-based Rest Client 103 | .idea/httpRequests 104 | 105 | # Android studio 3.1+ serialized cache file 106 | .idea/caches/build_file_checksums.ser 107 | 108 | ### JetBrains+all Patch ### 109 | # Ignores the whole .idea folder and all .iml files 110 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 111 | 112 | .idea/ 113 | 114 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 115 | 116 | *.iml 117 | modules.xml 118 | .idea/misc.xml 119 | *.ipr 120 | 121 | # Sonarlint plugin 122 | .idea/sonarlint 123 | 124 | # End of https://www.gitignore.io/api/go,jetbrains+all 125 | 126 | dist/ 127 | /testdata 128 | /.graphqlconfig 129 | schema.graphql 130 | *.log 131 | .direnv 132 | /SatisfactoryDedicatedServer -------------------------------------------------------------------------------- /tea/scenes/profile/new_profile.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/charmbracelet/bubbles/help" 7 | "github.com/charmbracelet/bubbles/key" 8 | "github.com/charmbracelet/bubbles/textinput" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | 12 | "github.com/satisfactorymodding/ficsit-cli/tea/components" 13 | "github.com/satisfactorymodding/ficsit-cli/tea/scenes/keys" 14 | "github.com/satisfactorymodding/ficsit-cli/tea/utils" 15 | ) 16 | 17 | var _ tea.Model = (*newProfile)(nil) 18 | 19 | type keyMap struct { 20 | Back key.Binding 21 | Quit key.Binding 22 | Enter key.Binding 23 | } 24 | 25 | func (k keyMap) ShortHelp() []key.Binding { 26 | return []key.Binding{k.Enter, k.Back} 27 | } 28 | 29 | func (k keyMap) FullHelp() [][]key.Binding { 30 | return [][]key.Binding{ 31 | {k.Enter, k.Back}, 32 | } 33 | } 34 | 35 | type newProfile struct { 36 | root components.RootModel 37 | parent tea.Model 38 | error *components.ErrorComponent 39 | help help.Model 40 | title string 41 | keys keyMap 42 | input textinput.Model 43 | } 44 | 45 | func NewNewProfile(root components.RootModel, parent tea.Model) tea.Model { 46 | model := newProfile{ 47 | root: root, 48 | parent: parent, 49 | input: textinput.New(), 50 | title: utils.NonListTitleStyle.Render("New Profile"), 51 | help: help.New(), 52 | keys: keyMap{ 53 | Back: key.NewBinding( 54 | key.WithKeys(keys.KeyEscape, keys.KeyControlC), 55 | key.WithHelp(keys.KeyEscape, "back"), 56 | ), 57 | Enter: key.NewBinding( 58 | key.WithKeys(keys.KeyEnter), 59 | key.WithHelp(keys.KeyEnter, "create"), 60 | ), 61 | Quit: key.NewBinding( 62 | key.WithKeys(keys.KeyControlC), 63 | ), 64 | }, 65 | } 66 | 67 | model.input.Focus() 68 | model.input.Width = root.Size().Width 69 | 70 | return model 71 | } 72 | 73 | func (m newProfile) Init() tea.Cmd { 74 | return textinput.Blink 75 | } 76 | 77 | func (m newProfile) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 78 | switch msg := msg.(type) { 79 | case tea.KeyMsg: 80 | switch { 81 | case key.Matches(msg, m.keys.Quit): 82 | return m, tea.Quit 83 | case key.Matches(msg, m.keys.Back): 84 | return m.parent, nil 85 | case key.Matches(msg, m.keys.Enter): 86 | if _, err := m.root.GetGlobal().Profiles.AddProfile(m.input.Value()); err != nil { 87 | errorComponent, cmd := components.NewErrorComponent(err.Error(), time.Second*5) 88 | m.error = errorComponent 89 | return m, cmd 90 | } 91 | 92 | return m.parent, updateProfileListCmd 93 | default: 94 | var cmd tea.Cmd 95 | m.input, cmd = m.input.Update(msg) 96 | return m, cmd 97 | } 98 | case tea.WindowSizeMsg: 99 | m.root.SetSize(msg) 100 | case components.ErrorComponentTimeoutMsg: 101 | m.error = nil 102 | default: 103 | var cmd tea.Cmd 104 | m.input, cmd = m.input.Update(msg) 105 | return m, cmd 106 | } 107 | 108 | return m, nil 109 | } 110 | 111 | func (m newProfile) View() string { 112 | style := lipgloss.NewStyle().Padding(1, 2) 113 | inputView := style.Render(m.input.View()) 114 | 115 | if m.error != nil { 116 | return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.title, m.error.View(), inputView) 117 | } 118 | 119 | infoBox := lipgloss.NewStyle(). 120 | BorderStyle(lipgloss.ThickBorder()). 121 | BorderForeground(lipgloss.Color("39")). 122 | Padding(0, 1). 123 | Margin(0, 0, 0, 2). 124 | Render("Enter the name of the profile") 125 | 126 | return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.title, inputView, infoBox, style.Render(m.help.View(m.keys))) 127 | } 128 | -------------------------------------------------------------------------------- /.github/workflows/push.yaml: -------------------------------------------------------------------------------- 1 | name: push 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: Build 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | os: [ubuntu-latest, windows-latest] 12 | runs-on: ${{ matrix.os }} 13 | steps: 14 | - name: Set up Go 15 | uses: actions/setup-go@v2 16 | with: 17 | go-version: 1.21.4 18 | 19 | - name: Check out code into the Go module directory 20 | uses: actions/checkout@v2 21 | 22 | - name: Download GQL schema 23 | run: "npx graphqurl https://api.ficsit.dev/v2/query --introspect -H 'content-type: application/json' > schema.graphql" 24 | 25 | - name: Go Generate 26 | run: go generate -tags tools -x ./... 27 | 28 | - name: Build 29 | run: go build -v -o ficsit-cli . 30 | env: 31 | CGO_ENABLED: 1 32 | 33 | - uses: actions/upload-artifact@v4 34 | with: 35 | name: cli-${{ matrix.os }} 36 | path: ficsit-cli 37 | 38 | lint: 39 | name: Lint 40 | runs-on: ubuntu-latest 41 | steps: 42 | - name: Set up Go 43 | uses: actions/setup-go@v2 44 | with: 45 | go-version: 1.21.4 46 | 47 | - name: Check out code into the Go module directory 48 | uses: actions/checkout@v2 49 | 50 | - name: Download GQL schema 51 | run: "npx graphqurl https://api.ficsit.dev/v2/query --introspect -H 'content-type: application/json' > schema.graphql" 52 | 53 | - name: Go Generate 54 | run: go generate -tags tools -x ./... 55 | 56 | - name: golangci-lint 57 | uses: golangci/golangci-lint-action@v2 58 | with: 59 | version: v1.62.2 60 | skip-pkg-cache: true 61 | skip-build-cache: true 62 | 63 | test: 64 | name: Test 65 | strategy: 66 | fail-fast: false 67 | matrix: 68 | os: [ubuntu-latest, windows-latest] 69 | runs-on: ${{ matrix.os }} 70 | steps: 71 | - name: Set up Go 72 | uses: actions/setup-go@v2 73 | with: 74 | go-version: 1.21.4 75 | 76 | - name: Check out code into the Go module directory 77 | uses: actions/checkout@v2 78 | 79 | - name: Setup steamcmd 80 | uses: CyberAndrii/setup-steamcmd@v1 81 | 82 | - name: Install Satisfactory Dedicated Server 83 | run: steamcmd +login anonymous +force_install_dir ${{ github.workspace }}/SatisfactoryDedicatedServer +app_update 1690800 validate +quit 84 | 85 | - name: Change directory permissions 86 | if: ${{ matrix.os == 'ubuntu-latest' }} 87 | run: mkdir -p ${{ github.workspace }}/SatisfactoryDedicatedServer/FactoryGame/Mods && chmod -R 777 ${{ github.workspace }}/SatisfactoryDedicatedServer 88 | 89 | - name: List directory (linux) 90 | if: ${{ matrix.os == 'ubuntu-latest' }} 91 | run: ls -lR 92 | 93 | - name: List directory (windows) 94 | if: ${{ matrix.os == 'windows-latest' }} 95 | run: tree /F 96 | 97 | - name: Boot ftp and sftp 98 | if: ${{ matrix.os == 'ubuntu-latest' }} 99 | run: docker compose -f docker-compose-test.yml up -d 100 | 101 | - name: Download GQL schema 102 | run: "npx graphqurl https://api.ficsit.dev/v2/query --introspect -H 'content-type: application/json' > schema.graphql" 103 | 104 | - name: Go Generate 105 | run: go generate -tags tools -x ./... 106 | 107 | - name: Test 108 | run: go test -race -v ./... 109 | env: 110 | SF_DEDICATED_SERVER: ${{ github.workspace }}/SatisfactoryDedicatedServer 111 | -------------------------------------------------------------------------------- /cli/context.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | 7 | "github.com/Khan/genqlient/graphql" 8 | "github.com/spf13/viper" 9 | 10 | "github.com/satisfactorymodding/ficsit-cli/cli/cache" 11 | "github.com/satisfactorymodding/ficsit-cli/cli/localregistry" 12 | "github.com/satisfactorymodding/ficsit-cli/cli/provider" 13 | "github.com/satisfactorymodding/ficsit-cli/ficsit" 14 | ) 15 | 16 | type GlobalContext struct { 17 | Installations *Installations 18 | Profiles *Profiles 19 | APIClient graphql.Client 20 | Provider provider.Provider 21 | } 22 | 23 | var globalContext *GlobalContext 24 | 25 | func InitCLI(apiOnly bool) (*GlobalContext, error) { 26 | if globalContext != nil { 27 | return globalContext, nil 28 | } 29 | 30 | apiClient := ficsit.InitAPI() 31 | 32 | mixedProvider := provider.InitMixedProvider(provider.NewFicsitProvider(apiClient), provider.NewLocalProvider()) 33 | 34 | if viper.GetBool("offline") { 35 | mixedProvider.Offline = true 36 | } 37 | 38 | if !apiOnly { 39 | profiles, err := InitProfiles() 40 | if err != nil { 41 | return nil, fmt.Errorf("failed to initialize profiles: %w", err) 42 | } 43 | 44 | installations, err := InitInstallations() 45 | if err != nil { 46 | return nil, fmt.Errorf("failed to initialize installations: %w", err) 47 | } 48 | 49 | _, err = cache.LoadCacheMods() 50 | if err != nil { 51 | return nil, fmt.Errorf("failed to load cache: %w", err) 52 | } 53 | 54 | err = localregistry.Init() 55 | if err != nil { 56 | return nil, fmt.Errorf("failed to initialize local registry: %w", err) 57 | } 58 | 59 | globalContext = &GlobalContext{ 60 | Installations: installations, 61 | Profiles: profiles, 62 | APIClient: apiClient, 63 | Provider: mixedProvider, 64 | } 65 | } else { 66 | globalContext = &GlobalContext{ 67 | APIClient: apiClient, 68 | Provider: mixedProvider, 69 | } 70 | } 71 | 72 | return globalContext, nil 73 | } 74 | 75 | // ReInit will initialize the context 76 | // 77 | // Used only by tests 78 | func (g *GlobalContext) ReInit() error { 79 | profiles, err := InitProfiles() 80 | if err != nil { 81 | return fmt.Errorf("failed to initialize profiles: %w", err) 82 | } 83 | 84 | installations, err := InitInstallations() 85 | if err != nil { 86 | return fmt.Errorf("failed to initialize installations: %w", err) 87 | } 88 | 89 | g.Installations = installations 90 | g.Profiles = profiles 91 | 92 | return g.Save() 93 | } 94 | 95 | // Wipe will remove any trace of ficsit anywhere 96 | func (g *GlobalContext) Wipe() error { 97 | slog.Info("wiping global context") 98 | 99 | // Wipe all installations 100 | for _, installation := range g.Installations.Installations { 101 | if err := installation.Wipe(); err != nil { 102 | return fmt.Errorf("failed wiping installation: %w", err) 103 | } 104 | 105 | if err := g.Installations.DeleteInstallation(installation.Path); err != nil { 106 | return fmt.Errorf("failed deleting installation: %w", err) 107 | } 108 | } 109 | 110 | // Wipe all profiles 111 | for _, profile := range g.Profiles.Profiles { 112 | if err := g.Profiles.DeleteProfile(profile.Name); err != nil { 113 | return fmt.Errorf("failed deleting profile: %w", err) 114 | } 115 | } 116 | 117 | return g.Save() 118 | } 119 | 120 | func (g *GlobalContext) Save() error { 121 | if err := g.Installations.Save(); err != nil { 122 | return fmt.Errorf("failed to save installations: %w", err) 123 | } 124 | 125 | if err := g.Profiles.Save(); err != nil { 126 | return fmt.Errorf("failed to save profiles: %w", err) 127 | } 128 | 129 | return nil 130 | } 131 | -------------------------------------------------------------------------------- /tea/tea_test.go: -------------------------------------------------------------------------------- 1 | package tea 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "os" 7 | "runtime" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/MarvinJWendt/testza" 13 | tea "github.com/charmbracelet/bubbletea" 14 | "github.com/charmbracelet/x/exp/teatest" 15 | 16 | "github.com/satisfactorymodding/ficsit-cli/cfg" 17 | "github.com/satisfactorymodding/ficsit-cli/cli" 18 | "github.com/satisfactorymodding/ficsit-cli/tea/scenes" 19 | ) 20 | 21 | func init() { 22 | cfg.SetDefaults() 23 | } 24 | 25 | func TestTea(t *testing.T) { 26 | if runtime.GOOS == "windows" { 27 | // Windows just sucks 28 | return 29 | } 30 | 31 | serverLocation := os.Getenv("SF_DEDICATED_SERVER") 32 | if serverLocation == "" { 33 | return 34 | } 35 | 36 | ctx, err := cli.InitCLI(false) 37 | testza.AssertNoError(t, err) 38 | 39 | ctx.Provider = cli.MockProvider{} 40 | 41 | err = ctx.Wipe() 42 | testza.AssertNoError(t, err) 43 | 44 | err = ctx.ReInit() 45 | testza.AssertNoError(t, err) 46 | 47 | root := newModel(ctx) 48 | m := scenes.NewMainMenu(root) 49 | 50 | tm := teatest.NewTestModel( 51 | t, m, 52 | teatest.WithInitialTermSize(70, 35), 53 | ) 54 | 55 | t.Cleanup(func() { 56 | if err := tm.Quit(); err != nil { 57 | t.Fatal(err) 58 | } 59 | }) 60 | 61 | time.Sleep(time.Second) 62 | 63 | // Go to Installations 64 | press(tm, tea.KeyEnter) 65 | 66 | // Create new installation 67 | write(tm, "n") 68 | 69 | // Enter installation path 70 | write(tm, serverLocation) 71 | 72 | // Accept path 73 | press(tm, tea.KeyEnter) 74 | 75 | // Go back to main menu 76 | write(tm, "q") 77 | 78 | // Go to all mods 79 | press(tm, tea.KeyDown) 80 | press(tm, tea.KeyDown) 81 | press(tm, tea.KeyDown) 82 | press(tm, tea.KeyEnter) 83 | 84 | // Filter for mod 85 | write(tm, "/") 86 | write(tm, "Refined Power") 87 | press(tm, tea.KeyEnter) 88 | 89 | // Select mod 90 | press(tm, tea.KeyEnter) 91 | 92 | // Install mod 93 | press(tm, tea.KeyEnter) 94 | 95 | // Go back to main menu 96 | write(tm, "q") 97 | 98 | // Apply changes 99 | press(tm, tea.KeyDown) 100 | press(tm, tea.KeyDown) 101 | press(tm, tea.KeyDown) 102 | 103 | eat(tm) 104 | press(tm, tea.KeyEnter) 105 | 106 | i := 0 107 | buffer := "" 108 | for { 109 | s := read(tm) 110 | buffer += "\n-------------------------\n" + s 111 | 112 | if strings.Contains(s, "Done! Press Enter to return") { 113 | break 114 | } 115 | 116 | if strings.Contains(s, "Cancelled! Press Enter to return") { 117 | testza.AssertNoError(t, errors.New("installation cancelled")) 118 | println(buffer) 119 | break 120 | } 121 | 122 | i++ 123 | if i >= 60 { 124 | testza.AssertNoError(t, errors.New("failed installing")) 125 | println(buffer) 126 | return 127 | } 128 | 129 | time.Sleep(time.Second) 130 | } 131 | 132 | eat(tm) 133 | 134 | // Go back to main menu 135 | press(tm, tea.KeyEnter) 136 | 137 | // Exit program 138 | press(tm, tea.KeyDown) 139 | press(tm, tea.KeyDown) 140 | press(tm, tea.KeyEnter) 141 | } 142 | 143 | // dump the current tea buffer to stderr 144 | func dump(tm *teatest.TestModel) { // nolint 145 | _, _ = io.Copy(os.Stderr, tm.Output()) 146 | } 147 | 148 | // eat the current tea buffer 149 | func eat(tm *teatest.TestModel) { 150 | _, _ = io.ReadAll(tm.Output()) 151 | } 152 | 153 | // read reads the current tea buffer 154 | func read(tm *teatest.TestModel) string { 155 | out, _ := io.ReadAll(tm.Output()) 156 | return string(out) 157 | } 158 | 159 | func press(tm *teatest.TestModel, key tea.KeyType) { 160 | println("Pressing", key.String()) 161 | tm.Send(tea.KeyMsg{Type: key}) 162 | time.Sleep(time.Millisecond * 250) 163 | } 164 | 165 | func write(tm *teatest.TestModel, txt string) { 166 | println("Writing", txt) 167 | tm.Type(txt) 168 | time.Sleep(time.Millisecond * 250) 169 | } 170 | -------------------------------------------------------------------------------- /tea/scenes/profile/profiles.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | "github.com/charmbracelet/bubbles/list" 6 | "github.com/charmbracelet/bubbles/spinner" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | 10 | "github.com/satisfactorymodding/ficsit-cli/tea/components" 11 | "github.com/satisfactorymodding/ficsit-cli/tea/scenes/keys" 12 | "github.com/satisfactorymodding/ficsit-cli/tea/utils" 13 | ) 14 | 15 | var _ tea.Model = (*profiles)(nil) 16 | 17 | type profiles struct { 18 | root components.RootModel 19 | list list.Model 20 | parent tea.Model 21 | } 22 | 23 | func NewProfiles(root components.RootModel, parent tea.Model) tea.Model { 24 | l := list.New(profilesToList(root), utils.NewItemDelegate(), root.Size().Width, root.Size().Height-root.Height()) 25 | l.SetShowStatusBar(true) 26 | l.SetFilteringEnabled(true) 27 | l.SetSpinner(spinner.MiniDot) 28 | l.Title = "Profiles" 29 | l.Styles = utils.ListStyles 30 | l.SetSize(l.Width(), l.Height()) 31 | l.KeyMap.Quit.SetHelp("q", "back") 32 | 33 | l.AdditionalShortHelpKeys = func() []key.Binding { 34 | return []key.Binding{ 35 | key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "new profile")), 36 | key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), 37 | } 38 | } 39 | 40 | l.AdditionalFullHelpKeys = l.AdditionalShortHelpKeys 41 | 42 | return &profiles{ 43 | root: root, 44 | list: l, 45 | parent: parent, 46 | } 47 | } 48 | 49 | func (m profiles) Init() tea.Cmd { 50 | return nil 51 | } 52 | 53 | func (m profiles) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 54 | switch msg := msg.(type) { 55 | case tea.KeyMsg: 56 | if m.list.SettingFilter() { 57 | var cmd tea.Cmd 58 | m.list, cmd = m.list.Update(msg) 59 | return m, cmd 60 | } 61 | 62 | switch keypress := msg.String(); keypress { 63 | case "n": 64 | newModel := NewNewProfile(m.root, m) 65 | return newModel, newModel.Init() 66 | case keys.KeyControlC: 67 | return m, tea.Quit 68 | case "q": 69 | if m.parent != nil { 70 | m.parent.Update(m.root.Size()) 71 | return m.parent, nil 72 | } 73 | return m, tea.Quit 74 | case keys.KeyEnter: 75 | i, ok := m.list.SelectedItem().(utils.SimpleItem[profiles]) 76 | if ok { 77 | if i.Activate != nil { 78 | newModel, cmd := i.Activate(msg, m) 79 | if newModel != nil || cmd != nil { 80 | if newModel == nil { 81 | newModel = m 82 | } 83 | return newModel, cmd 84 | } 85 | return m, nil 86 | } 87 | } 88 | return m, nil 89 | } 90 | case tea.WindowSizeMsg: 91 | top, right, bottom, left := lipgloss.NewStyle().Margin(m.root.Height(), 2, 0).GetMargin() 92 | m.list.SetSize(msg.Width-left-right, msg.Height-top-bottom) 93 | m.root.SetSize(msg) 94 | case updateProfileList: 95 | m.list.ResetSelected() 96 | cmd := m.list.SetItems(profilesToList(m.root)) 97 | return m, cmd 98 | case updateProfileNames: 99 | cmd := m.list.SetItems(profilesToList(m.root)) 100 | return m, cmd 101 | } 102 | 103 | var cmd tea.Cmd 104 | m.list, cmd = m.list.Update(msg) 105 | return m, cmd 106 | } 107 | 108 | func (m profiles) View() string { 109 | return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.list.View()) 110 | } 111 | 112 | func profilesToList(root components.RootModel) []list.Item { 113 | items := make([]list.Item, len(root.GetGlobal().Profiles.Profiles)) 114 | 115 | i := 0 116 | for _, profile := range root.GetGlobal().Profiles.Profiles { 117 | temp := profile 118 | items[i] = utils.SimpleItem[profiles]{ 119 | ItemTitle: temp.Name, 120 | Activate: func(msg tea.Msg, currentModel profiles) (tea.Model, tea.Cmd) { 121 | newModel := NewProfile(root, currentModel, temp) 122 | return newModel, newModel.Init() 123 | }, 124 | } 125 | i++ 126 | } 127 | 128 | return items 129 | } 130 | -------------------------------------------------------------------------------- /tea/scenes/installation/installations.go: -------------------------------------------------------------------------------- 1 | package installation 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | "github.com/charmbracelet/bubbles/list" 6 | "github.com/charmbracelet/bubbles/spinner" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | 10 | "github.com/satisfactorymodding/ficsit-cli/tea/components" 11 | "github.com/satisfactorymodding/ficsit-cli/tea/scenes/keys" 12 | "github.com/satisfactorymodding/ficsit-cli/tea/utils" 13 | ) 14 | 15 | var _ tea.Model = (*installations)(nil) 16 | 17 | type installations struct { 18 | root components.RootModel 19 | list list.Model 20 | parent tea.Model 21 | } 22 | 23 | func NewInstallations(root components.RootModel, parent tea.Model) tea.Model { 24 | l := list.New(installationsToList(root), utils.NewItemDelegate(), root.Size().Width, root.Size().Height-root.Height()) 25 | l.SetShowStatusBar(true) 26 | l.SetFilteringEnabled(true) 27 | l.SetSpinner(spinner.MiniDot) 28 | l.Title = "Installations" 29 | l.Styles = utils.ListStyles 30 | l.SetSize(l.Width(), l.Height()) 31 | l.KeyMap.Quit.SetHelp("q", "back") 32 | 33 | l.AdditionalShortHelpKeys = func() []key.Binding { 34 | return []key.Binding{ 35 | key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "new installation")), 36 | key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), 37 | } 38 | } 39 | 40 | l.AdditionalFullHelpKeys = l.AdditionalShortHelpKeys 41 | 42 | return &installations{ 43 | root: root, 44 | list: l, 45 | parent: parent, 46 | } 47 | } 48 | 49 | func (m installations) Init() tea.Cmd { 50 | return nil 51 | } 52 | 53 | func (m installations) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 54 | switch msg := msg.(type) { 55 | case tea.KeyMsg: 56 | if m.list.SettingFilter() { 57 | var cmd tea.Cmd 58 | m.list, cmd = m.list.Update(msg) 59 | return m, cmd 60 | } 61 | 62 | switch keypress := msg.String(); keypress { 63 | case "n": 64 | newModel := NewNewInstallation(m.root, m) 65 | return newModel, newModel.Init() 66 | case keys.KeyControlC: 67 | return m, tea.Quit 68 | case "q": 69 | if m.parent != nil { 70 | m.parent.Update(m.root.Size()) 71 | return m.parent, nil 72 | } 73 | return m, tea.Quit 74 | case keys.KeyEnter: 75 | i, ok := m.list.SelectedItem().(utils.SimpleItem[installations]) 76 | if ok { 77 | if i.Activate != nil { 78 | newModel, cmd := i.Activate(msg, m) 79 | if newModel != nil || cmd != nil { 80 | if newModel == nil { 81 | newModel = m 82 | } 83 | return newModel, cmd 84 | } 85 | return m, nil 86 | } 87 | } 88 | return m, nil 89 | } 90 | case tea.WindowSizeMsg: 91 | top, right, bottom, left := lipgloss.NewStyle().Margin(m.root.Height(), 2, 0).GetMargin() 92 | m.list.SetSize(msg.Width-left-right, msg.Height-top-bottom) 93 | m.root.SetSize(msg) 94 | case updateInstallationList: 95 | m.list.ResetSelected() 96 | cmd := m.list.SetItems(installationsToList(m.root)) 97 | return m, cmd 98 | case updateInstallationNames: 99 | cmd := m.list.SetItems(installationsToList(m.root)) 100 | return m, cmd 101 | } 102 | 103 | var cmd tea.Cmd 104 | m.list, cmd = m.list.Update(msg) 105 | return m, cmd 106 | } 107 | 108 | func (m installations) View() string { 109 | return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.list.View()) 110 | } 111 | 112 | func installationsToList(root components.RootModel) []list.Item { 113 | items := make([]list.Item, len(root.GetGlobal().Installations.Installations)) 114 | 115 | i := 0 116 | for _, installation := range root.GetGlobal().Installations.Installations { 117 | temp := installation 118 | items[i] = utils.SimpleItem[installations]{ 119 | ItemTitle: temp.Path, 120 | Activate: func(msg tea.Msg, currentModel installations) (tea.Model, tea.Cmd) { 121 | newModel := NewInstallation(root, currentModel, temp) 122 | return newModel, newModel.Init() 123 | }, 124 | } 125 | i++ 126 | } 127 | 128 | return items 129 | } 130 | -------------------------------------------------------------------------------- /utils/io.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "archive/zip" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "fmt" 8 | "io" 9 | "os" 10 | "path/filepath" 11 | "sync" 12 | 13 | "github.com/satisfactorymodding/ficsit-cli/cli/disk" 14 | ) 15 | 16 | func SHA256Data(f io.Reader) (string, error) { 17 | h := sha256.New() 18 | if _, err := io.Copy(h, f); err != nil { 19 | return "", fmt.Errorf("failed to compute hash: %w", err) 20 | } 21 | 22 | return hex.EncodeToString(h.Sum(nil)), nil 23 | } 24 | 25 | func ExtractMod(f io.ReaderAt, size int64, location string, hash string, updates chan<- GenericProgress, d disk.Disk) error { 26 | hashFile := filepath.Join(location, ".smm") 27 | 28 | exists, err := d.Exists(hashFile) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | if exists { 34 | hashBytes, err := d.Read(hashFile) 35 | if err != nil { 36 | return fmt.Errorf("failed to read .smm mod hash file: %w", err) 37 | } 38 | 39 | if hash == string(hashBytes) { 40 | return nil 41 | } 42 | } 43 | 44 | exists, err = d.Exists(location) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | if exists { 50 | if err := d.Remove(location); err != nil { 51 | return fmt.Errorf("failed to remove directory: %s: %w", location, err) 52 | } 53 | 54 | if err := d.MkDir(location); err != nil { 55 | return fmt.Errorf("failed to create mod directory: %s: %w", location, err) 56 | } 57 | } 58 | 59 | reader, err := zip.NewReader(f, size) 60 | if err != nil { 61 | return fmt.Errorf("failed to read file as zip: %w", err) 62 | } 63 | 64 | totalSize := int64(0) 65 | 66 | for _, file := range reader.File { 67 | totalSize += int64(file.UncompressedSize64) 68 | } 69 | 70 | totalExtracted := int64(0) 71 | 72 | for _, file := range reader.File { 73 | if !file.FileInfo().IsDir() { 74 | outFileLocation := filepath.Join(location, file.Name) 75 | 76 | if err := d.MkDir(filepath.Dir(outFileLocation)); err != nil { 77 | return fmt.Errorf("failed to create mod directory: %s: %w", location, err) 78 | } 79 | 80 | channelUsers := sync.WaitGroup{} 81 | 82 | var fileUpdates chan GenericProgress 83 | if updates != nil { 84 | fileUpdates = make(chan GenericProgress) 85 | channelUsers.Add(1) 86 | beforeProgress := totalExtracted 87 | go func() { 88 | defer channelUsers.Done() 89 | for fileUpdate := range fileUpdates { 90 | updates <- GenericProgress{ 91 | Completed: beforeProgress + fileUpdate.Completed, 92 | Total: totalSize, 93 | } 94 | } 95 | }() 96 | } 97 | 98 | if err := writeZipFile(outFileLocation, file, d, fileUpdates); err != nil { 99 | channelUsers.Wait() 100 | return err 101 | } 102 | 103 | channelUsers.Wait() 104 | 105 | totalExtracted += int64(file.UncompressedSize64) 106 | } 107 | } 108 | 109 | if err := d.Write(hashFile, []byte(hash)); err != nil { 110 | return fmt.Errorf("failed to write .smm mod hash file: %w", err) 111 | } 112 | 113 | if updates != nil { 114 | updates <- GenericProgress{Completed: totalSize, Total: totalSize} 115 | } 116 | 117 | return nil 118 | } 119 | 120 | func writeZipFile(outFileLocation string, file *zip.File, d disk.Disk, updates chan<- GenericProgress) error { 121 | if updates != nil { 122 | defer close(updates) 123 | } 124 | 125 | outFile, err := d.Open(outFileLocation, os.O_CREATE|os.O_RDWR) 126 | if err != nil { 127 | return fmt.Errorf("failed to write to file: %s: %w", outFileLocation, err) 128 | } 129 | 130 | defer outFile.Close() 131 | 132 | inFile, err := file.Open() 133 | if err != nil { 134 | return fmt.Errorf("failed to process mod zip: %w", err) 135 | } 136 | defer inFile.Close() 137 | 138 | progressInWriter := &Progresser{ 139 | Total: int64(file.UncompressedSize64), 140 | Updates: updates, 141 | } 142 | 143 | if _, err := io.Copy(io.MultiWriter(outFile, progressInWriter), inFile); err != nil { 144 | return fmt.Errorf("failed to write to file: %s: %w", outFileLocation, err) 145 | } 146 | 147 | return nil 148 | } 149 | -------------------------------------------------------------------------------- /cli/provider/local.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | resolver "github.com/satisfactorymodding/ficsit-resolver" 10 | 11 | "github.com/satisfactorymodding/ficsit-cli/cli/cache" 12 | "github.com/satisfactorymodding/ficsit-cli/cli/localregistry" 13 | "github.com/satisfactorymodding/ficsit-cli/ficsit" 14 | ) 15 | 16 | type LocalProvider struct{} 17 | 18 | func NewLocalProvider() LocalProvider { 19 | return LocalProvider{} 20 | } 21 | 22 | func (p LocalProvider) Mods(_ context.Context, filter ficsit.ModFilter) (*ficsit.ModsResponse, error) { 23 | cachedMods, err := cache.GetCacheMods() 24 | if err != nil { 25 | return nil, fmt.Errorf("failed to get cache: %w", err) 26 | } 27 | 28 | mods := make([]ficsit.ModsModsGetModsModsMod, 0) 29 | 30 | cachedMods.Range(func(modReference string, cachedMod cache.Mod) bool { 31 | if len(filter.References) > 0 { 32 | skip := true 33 | 34 | for _, a := range filter.References { 35 | if a == modReference { 36 | skip = false 37 | break 38 | } 39 | } 40 | 41 | if skip { 42 | return true 43 | } 44 | } 45 | 46 | mods = append(mods, ficsit.ModsModsGetModsModsMod{ 47 | Id: modReference, 48 | Name: cachedMod.Name, 49 | Mod_reference: modReference, 50 | Last_version_date: time.Now(), 51 | Created_at: time.Now(), 52 | Downloads: 0, 53 | Popularity: 0, 54 | Hotness: 0, 55 | }) 56 | 57 | return true 58 | }) 59 | 60 | if filter.Limit == 0 { 61 | filter.Limit = 25 62 | } 63 | 64 | low := filter.Offset 65 | high := filter.Offset + filter.Limit 66 | 67 | if low > len(mods) { 68 | return &ficsit.ModsResponse{ 69 | Mods: ficsit.ModsModsGetMods{ 70 | Count: 0, 71 | Mods: []ficsit.ModsModsGetModsModsMod{}, 72 | }, 73 | }, nil 74 | } 75 | 76 | if high > len(mods) { 77 | high = len(mods) 78 | } 79 | 80 | mods = mods[low:high] 81 | 82 | return &ficsit.ModsResponse{ 83 | Mods: ficsit.ModsModsGetMods{ 84 | Count: len(mods), 85 | Mods: mods, 86 | }, 87 | }, nil 88 | } 89 | 90 | func (p LocalProvider) GetMod(_ context.Context, modReference string) (*ficsit.GetModResponse, error) { 91 | cachedMod, err := cache.GetCacheMod(modReference) 92 | if err != nil { 93 | return nil, fmt.Errorf("failed to get cache: %w", err) 94 | } 95 | 96 | authors := make([]ficsit.GetModModAuthorsUserMod, 0) 97 | 98 | for _, author := range strings.Split(cachedMod.Author, ",") { 99 | authors = append(authors, ficsit.GetModModAuthorsUserMod{ 100 | Role: "Unknown", 101 | User: ficsit.GetModModAuthorsUserModUser{ 102 | Username: author, 103 | }, 104 | }) 105 | } 106 | 107 | return &ficsit.GetModResponse{ 108 | Mod: ficsit.GetModMod{ 109 | Id: modReference, 110 | Name: cachedMod.Name, 111 | Mod_reference: modReference, 112 | Created_at: time.Now(), 113 | Views: 0, 114 | Downloads: 0, 115 | Authors: authors, 116 | Full_description: "", 117 | Source_url: "", 118 | }, 119 | }, nil 120 | } 121 | 122 | func (p LocalProvider) ModVersionsWithDependencies(_ context.Context, modID string) ([]resolver.ModVersion, error) { 123 | modVersions, err := localregistry.GetModVersions(modID) 124 | if err != nil { 125 | return nil, fmt.Errorf("failed to get local mod versions: %w", err) 126 | } 127 | 128 | // TODO: only list as available the versions that have at least one target cached 129 | 130 | return convertFicsitVersionsToResolver(modVersions), nil 131 | } 132 | 133 | func (p LocalProvider) GetModName(_ context.Context, modReference string) (*resolver.ModName, error) { 134 | cachedMod, err := cache.GetCacheMod(modReference) 135 | if err != nil { 136 | return nil, fmt.Errorf("failed to get cache: %w", err) 137 | } 138 | 139 | return &resolver.ModName{ 140 | ID: modReference, 141 | Name: cachedMod.Name, 142 | ModReference: modReference, 143 | }, nil 144 | } 145 | 146 | func (p LocalProvider) IsOffline() bool { 147 | return true 148 | } 149 | -------------------------------------------------------------------------------- /tea/scenes/mods/mod_version.go: -------------------------------------------------------------------------------- 1 | package mods 2 | 3 | import ( 4 | "log/slog" 5 | "time" 6 | 7 | "github.com/charmbracelet/bubbles/key" 8 | "github.com/charmbracelet/bubbles/list" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | 12 | "github.com/satisfactorymodding/ficsit-cli/tea/components" 13 | "github.com/satisfactorymodding/ficsit-cli/tea/scenes/errors" 14 | "github.com/satisfactorymodding/ficsit-cli/tea/scenes/keys" 15 | "github.com/satisfactorymodding/ficsit-cli/tea/utils" 16 | ) 17 | 18 | var _ tea.Model = (*modVersionMenu)(nil) 19 | 20 | type modVersionMenu struct { 21 | root components.RootModel 22 | list list.Model 23 | parent tea.Model 24 | } 25 | 26 | func NewModVersion(root components.RootModel, parent tea.Model, mod utils.Mod) tea.Model { 27 | model := modVersionMenu{ 28 | root: root, 29 | parent: parent, 30 | } 31 | 32 | items := []list.Item{ 33 | utils.SimpleItem[modVersionMenu]{ 34 | ItemTitle: "Select Version", 35 | Activate: func(msg tea.Msg, currentModel modVersionMenu) (tea.Model, tea.Cmd) { 36 | newModel := NewModVersionList(root, currentModel.parent, mod) 37 | return newModel, newModel.Init() 38 | }, 39 | }, 40 | utils.SimpleItem[modVersionMenu]{ 41 | ItemTitle: "Enter Custom SemVer", 42 | Activate: func(msg tea.Msg, currentModel modVersionMenu) (tea.Model, tea.Cmd) { 43 | newModel := NewModSemver(root, currentModel.parent, mod) 44 | return newModel, newModel.Init() 45 | }, 46 | }, 47 | } 48 | 49 | if root.GetCurrentProfile().HasMod(mod.Reference) { 50 | items = append([]list.Item{ 51 | utils.SimpleItem[modVersionMenu]{ 52 | ItemTitle: "Latest", 53 | Activate: func(msg tea.Msg, currentModel modVersionMenu) (tea.Model, tea.Cmd) { 54 | err := root.GetCurrentProfile().AddMod(mod.Reference, ">=0.0.0") 55 | if err != nil { 56 | slog.Error(errors.ErrorFailedAddMod, slog.Any("err", err)) 57 | cmd := currentModel.list.NewStatusMessage(errors.ErrorFailedAddMod) 58 | return currentModel, cmd 59 | } 60 | return currentModel.parent, nil 61 | }, 62 | }, 63 | }, items...) 64 | } 65 | 66 | model.list = list.New(items, utils.NewItemDelegate(), root.Size().Width, root.Size().Height-root.Height()) 67 | model.list.SetShowStatusBar(false) 68 | model.list.SetFilteringEnabled(false) 69 | model.list.Title = mod.Name 70 | model.list.Styles = utils.ListStyles 71 | model.list.SetSize(model.list.Width(), model.list.Height()) 72 | model.list.StatusMessageLifetime = time.Second * 3 73 | model.list.KeyMap.Quit.SetHelp("q", "back") 74 | model.list.AdditionalShortHelpKeys = func() []key.Binding { 75 | return []key.Binding{ 76 | key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), 77 | } 78 | } 79 | 80 | return model 81 | } 82 | 83 | func (m modVersionMenu) Init() tea.Cmd { 84 | return nil 85 | } 86 | 87 | func (m modVersionMenu) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 88 | switch msg := msg.(type) { 89 | case tea.KeyMsg: 90 | switch keypress := msg.String(); keypress { 91 | case keys.KeyControlC: 92 | return m, tea.Quit 93 | case "q": 94 | if m.parent != nil { 95 | m.parent.Update(m.root.Size()) 96 | return m.parent, nil 97 | } 98 | return m, tea.Quit 99 | case keys.KeyEnter: 100 | i, ok := m.list.SelectedItem().(utils.SimpleItem[modVersionMenu]) 101 | if ok { 102 | if i.Activate != nil { 103 | newModel, cmd := i.Activate(msg, m) 104 | if newModel != nil || cmd != nil { 105 | if newModel == nil { 106 | newModel = m 107 | } 108 | return newModel, cmd 109 | } 110 | return m, nil 111 | } 112 | } 113 | return m, nil 114 | default: 115 | var cmd tea.Cmd 116 | m.list, cmd = m.list.Update(msg) 117 | return m, cmd 118 | } 119 | case tea.WindowSizeMsg: 120 | top, right, bottom, left := lipgloss.NewStyle().Margin(2, 2).GetMargin() 121 | m.list.SetSize(msg.Width-left-right, msg.Height-top-bottom) 122 | m.root.SetSize(msg) 123 | } 124 | 125 | return m, nil 126 | } 127 | 128 | func (m modVersionMenu) View() string { 129 | return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.list.View()) 130 | } 131 | -------------------------------------------------------------------------------- /cli/disk/sftp.go: -------------------------------------------------------------------------------- 1 | package disk 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log/slog" 9 | "net/url" 10 | "os" 11 | 12 | "github.com/pkg/sftp" 13 | "golang.org/x/crypto/ssh" 14 | ) 15 | 16 | var _ Disk = (*sftpDisk)(nil) 17 | 18 | type sftpDisk struct { 19 | client *sftp.Client 20 | path string 21 | } 22 | 23 | type sftpEntry struct { 24 | os.FileInfo 25 | } 26 | 27 | func (f sftpEntry) IsDir() bool { 28 | return f.FileInfo.IsDir() 29 | } 30 | 31 | func (f sftpEntry) Name() string { 32 | return f.FileInfo.Name() 33 | } 34 | 35 | func newSFTP(path string) (Disk, error) { 36 | u, err := url.Parse(path) 37 | if err != nil { 38 | return nil, fmt.Errorf("failed to parse sftp url: %w", err) 39 | } 40 | 41 | password, ok := u.User.Password() 42 | var auth []ssh.AuthMethod 43 | if ok { 44 | auth = append(auth, ssh.Password(password)) 45 | } 46 | 47 | conn, err := ssh.Dial("tcp", u.Host, &ssh.ClientConfig{ 48 | User: u.User.Username(), 49 | Auth: auth, 50 | 51 | // TODO Somehow use systems hosts file 52 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), 53 | }) 54 | if err != nil { 55 | return nil, fmt.Errorf("failed to connect to ssh server: %w", err) 56 | } 57 | 58 | client, err := sftp.NewClient(conn) 59 | if err != nil { 60 | return nil, fmt.Errorf("failed to create sftp client: %w", err) 61 | } 62 | 63 | slog.Info("logged into sftp") 64 | 65 | return sftpDisk{ 66 | path: path, 67 | client: client, 68 | }, nil 69 | } 70 | 71 | func (l sftpDisk) Exists(path string) (bool, error) { 72 | slog.Debug("checking if file exists", slog.String("path", clean(path)), slog.String("schema", "sftp")) 73 | 74 | s, err := l.client.Stat(clean(path)) 75 | if err != nil { 76 | if errors.Is(err, os.ErrNotExist) { 77 | return false, nil 78 | } 79 | 80 | return false, fmt.Errorf("failed to check if file exists: %w", err) 81 | } 82 | 83 | return s != nil, nil 84 | } 85 | 86 | func (l sftpDisk) Read(path string) ([]byte, error) { 87 | slog.Debug("reading file", slog.String("path", clean(path)), slog.String("schema", "sftp")) 88 | 89 | f, err := l.client.Open(clean(path)) 90 | if err != nil { 91 | return nil, fmt.Errorf("failed to retrieve path: %w", err) 92 | } 93 | 94 | defer f.Close() 95 | 96 | data, err := io.ReadAll(f) 97 | if err != nil { 98 | return nil, fmt.Errorf("failed to read file: %w", err) 99 | } 100 | 101 | return data, nil 102 | } 103 | 104 | func (l sftpDisk) Write(path string, data []byte) error { 105 | slog.Debug("writing to file", slog.String("path", clean(path)), slog.String("schema", "sftp")) 106 | 107 | file, err := l.client.Create(clean(path)) 108 | if err != nil { 109 | return fmt.Errorf("failed to create file: %w", err) 110 | } 111 | 112 | defer file.Close() 113 | 114 | if _, err = io.Copy(file, bytes.NewReader(data)); err != nil { 115 | return fmt.Errorf("failed to write file: %w", err) 116 | } 117 | 118 | return nil 119 | } 120 | 121 | func (l sftpDisk) Remove(path string) error { 122 | slog.Debug("deleting path", slog.String("path", clean(path)), slog.String("schema", "sftp")) 123 | if err := l.client.Remove(clean(path)); err != nil { 124 | if err := l.client.RemoveAll(clean(path)); err != nil { 125 | return fmt.Errorf("failed to delete path: %w", err) 126 | } 127 | } 128 | 129 | return nil 130 | } 131 | 132 | func (l sftpDisk) MkDir(path string) error { 133 | slog.Debug("making directory", slog.String("path", clean(path)), slog.String("schema", "sftp")) 134 | 135 | if err := l.client.MkdirAll(clean(path)); err != nil { 136 | return fmt.Errorf("failed to make directory: %w", err) 137 | } 138 | 139 | return nil 140 | } 141 | 142 | func (l sftpDisk) ReadDir(path string) ([]Entry, error) { 143 | slog.Debug("reading directory", slog.String("path", clean(path)), slog.String("schema", "sftp")) 144 | 145 | dir, err := l.client.ReadDir(clean(path)) 146 | if err != nil { 147 | return nil, fmt.Errorf("failed to list files in directory: %w", err) 148 | } 149 | 150 | entries := make([]Entry, len(dir)) 151 | for i, entry := range dir { 152 | entries[i] = sftpEntry{ 153 | FileInfo: entry, 154 | } 155 | } 156 | 157 | return entries, nil 158 | } 159 | 160 | func (l sftpDisk) Open(path string, _ int) (io.WriteCloser, error) { 161 | slog.Debug("opening for writing", slog.String("path", clean(path)), slog.String("schema", "sftp")) 162 | 163 | f, err := l.client.Create(clean(path)) 164 | if err != nil { 165 | slog.Error("failed to open file", slog.Any("err", err)) 166 | } 167 | 168 | return f, nil 169 | } 170 | -------------------------------------------------------------------------------- /tea/scenes/installation/installation.go: -------------------------------------------------------------------------------- 1 | package installation 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/charmbracelet/bubbles/key" 8 | "github.com/charmbracelet/bubbles/list" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | 12 | "github.com/satisfactorymodding/ficsit-cli/cli" 13 | "github.com/satisfactorymodding/ficsit-cli/tea/components" 14 | "github.com/satisfactorymodding/ficsit-cli/tea/scenes/keys" 15 | "github.com/satisfactorymodding/ficsit-cli/tea/utils" 16 | ) 17 | 18 | var _ tea.Model = (*installation)(nil) 19 | 20 | type installation struct { 21 | list list.Model 22 | root components.RootModel 23 | parent tea.Model 24 | installation *cli.Installation 25 | error *components.ErrorComponent 26 | hadRenamed bool 27 | } 28 | 29 | func NewInstallation(root components.RootModel, parent tea.Model, installationData *cli.Installation) tea.Model { 30 | model := installation{ 31 | root: root, 32 | parent: parent, 33 | installation: installationData, 34 | } 35 | 36 | items := []list.Item{ 37 | utils.SimpleItem[installation]{ 38 | ItemTitle: "Select", 39 | Activate: func(msg tea.Msg, currentModel installation) (tea.Model, tea.Cmd) { 40 | if err := root.SetCurrentInstallation(installationData); err != nil { 41 | errorComponent, cmd := components.NewErrorComponent(err.Error(), time.Second*5) 42 | currentModel.error = errorComponent 43 | return currentModel, cmd 44 | } 45 | 46 | return currentModel.parent, nil 47 | }, 48 | }, 49 | utils.SimpleItem[installation]{ 50 | ItemTitle: "Delete", 51 | Activate: func(msg tea.Msg, currentModel installation) (tea.Model, tea.Cmd) { 52 | if err := root.GetGlobal().Installations.DeleteInstallation(installationData.Path); err != nil { 53 | errorComponent, cmd := components.NewErrorComponent(err.Error(), time.Second*5) 54 | currentModel.error = errorComponent 55 | return currentModel, cmd 56 | } 57 | 58 | return currentModel.parent, updateInstallationListCmd 59 | }, 60 | }, 61 | } 62 | 63 | model.list = list.New(items, utils.NewItemDelegate(), root.Size().Width, root.Size().Height-root.Height()) 64 | model.list.SetShowStatusBar(false) 65 | model.list.SetFilteringEnabled(false) 66 | model.list.Title = fmt.Sprintf("Installation: %s", installationData.Path) 67 | model.list.Styles = utils.ListStyles 68 | model.list.SetSize(model.list.Width(), model.list.Height()) 69 | model.list.StatusMessageLifetime = time.Second * 3 70 | model.list.KeyMap.Quit.SetHelp("q", "back") 71 | model.list.AdditionalShortHelpKeys = func() []key.Binding { 72 | return []key.Binding{ 73 | key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), 74 | } 75 | } 76 | 77 | return model 78 | } 79 | 80 | func (m installation) Init() tea.Cmd { 81 | return nil 82 | } 83 | 84 | func (m installation) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 85 | switch msg := msg.(type) { 86 | case tea.KeyMsg: 87 | switch keypress := msg.String(); keypress { 88 | case keys.KeyControlC: 89 | return m, tea.Quit 90 | case "q": 91 | if m.parent != nil { 92 | m.parent.Update(m.root.Size()) 93 | 94 | if m.hadRenamed { 95 | return m.parent, updateInstallationNamesCmd 96 | } 97 | 98 | return m.parent, nil 99 | } 100 | return m, nil 101 | case keys.KeyEnter: 102 | i, ok := m.list.SelectedItem().(utils.SimpleItem[installation]) 103 | if ok { 104 | if i.Activate != nil { 105 | newModel, cmd := i.Activate(msg, m) 106 | if newModel != nil || cmd != nil { 107 | if newModel == nil { 108 | newModel = m 109 | } 110 | return newModel, cmd 111 | } 112 | return m, nil 113 | } 114 | } 115 | return m, nil 116 | default: 117 | var cmd tea.Cmd 118 | m.list, cmd = m.list.Update(msg) 119 | return m, cmd 120 | } 121 | case tea.WindowSizeMsg: 122 | top, right, bottom, left := lipgloss.NewStyle().Margin(2, 2).GetMargin() 123 | m.list.SetSize(msg.Width-left-right, msg.Height-top-bottom) 124 | m.root.SetSize(msg) 125 | case updateInstallationNames: 126 | m.hadRenamed = true 127 | m.list.Title = fmt.Sprintf("Installation: %s", m.installation.Path) 128 | case components.ErrorComponentTimeoutMsg: 129 | m.error = nil 130 | } 131 | 132 | return m, nil 133 | } 134 | 135 | func (m installation) View() string { 136 | if m.error != nil { 137 | err := m.error.View() 138 | m.list.SetSize(m.list.Width(), m.root.Size().Height-m.root.Height()-lipgloss.Height(err)) 139 | return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), err, m.list.View()) 140 | } 141 | 142 | m.list.SetSize(m.list.Width(), m.root.Size().Height-m.root.Height()) 143 | return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.list.View()) 144 | } 145 | -------------------------------------------------------------------------------- /tea/scenes/mods/select_mod_version.go: -------------------------------------------------------------------------------- 1 | package mods 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/charmbracelet/bubbles/key" 9 | "github.com/charmbracelet/bubbles/list" 10 | "github.com/charmbracelet/bubbles/spinner" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/lipgloss" 13 | 14 | "github.com/satisfactorymodding/ficsit-cli/tea/components" 15 | "github.com/satisfactorymodding/ficsit-cli/tea/scenes/keys" 16 | "github.com/satisfactorymodding/ficsit-cli/tea/utils" 17 | ) 18 | 19 | var _ tea.Model = (*selectModVersionList)(nil) 20 | 21 | type selectModVersionList struct { 22 | root components.RootModel 23 | list list.Model 24 | parent tea.Model 25 | items chan []list.Item 26 | err chan string 27 | error *components.ErrorComponent 28 | } 29 | 30 | func NewModVersionList(root components.RootModel, parent tea.Model, mod utils.Mod) tea.Model { 31 | l := list.New([]list.Item{}, utils.NewItemDelegate(), root.Size().Width, root.Size().Height-root.Height()) 32 | l.SetShowStatusBar(true) 33 | l.SetFilteringEnabled(false) 34 | l.SetSpinner(spinner.MiniDot) 35 | l.Title = fmt.Sprintf("Versions (%s)", mod.Name) 36 | l.Styles = utils.ListStyles 37 | l.SetSize(l.Width(), l.Height()) 38 | l.KeyMap.Quit.SetHelp("q", "back") 39 | l.AdditionalShortHelpKeys = func() []key.Binding { 40 | return []key.Binding{ 41 | key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), 42 | } 43 | } 44 | 45 | m := &selectModVersionList{ 46 | root: root, 47 | list: l, 48 | parent: parent, 49 | items: make(chan []list.Item), 50 | err: make(chan string), 51 | } 52 | 53 | go func() { 54 | items := make([]list.Item, 0) 55 | versions, err := root.GetProvider().ModVersionsWithDependencies(context.TODO(), mod.Reference) 56 | if err != nil { 57 | m.err <- err.Error() 58 | return 59 | } 60 | 61 | for _, version := range versions { 62 | tempVersion := version 63 | items = append(items, utils.SimpleItem[selectModVersionList]{ 64 | ItemTitle: tempVersion.Version, 65 | Activate: func(msg tea.Msg, currentModel selectModVersionList) (tea.Model, tea.Cmd) { 66 | err := root.GetCurrentProfile().AddMod(mod.Reference, tempVersion.Version) 67 | if err != nil { 68 | errorComponent, cmd := components.NewErrorComponent(err.Error(), time.Second*5) 69 | currentModel.error = errorComponent 70 | return currentModel, cmd 71 | } 72 | return currentModel.parent, nil 73 | }, 74 | }) 75 | } 76 | 77 | m.items <- items 78 | }() 79 | 80 | return m 81 | } 82 | 83 | func (m selectModVersionList) Init() tea.Cmd { 84 | return utils.Ticker() 85 | } 86 | 87 | func (m selectModVersionList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 88 | switch msg := msg.(type) { 89 | case tea.KeyMsg: 90 | switch keypress := msg.String(); keypress { 91 | case keys.KeyControlC: 92 | return m, tea.Quit 93 | case "q": 94 | if m.parent != nil { 95 | m.parent.Update(m.root.Size()) 96 | return m.parent, nil 97 | } 98 | return m, tea.Quit 99 | case keys.KeyEnter: 100 | i, ok := m.list.SelectedItem().(utils.SimpleItem[selectModVersionList]) 101 | if ok { 102 | if i.Activate != nil { 103 | newModel, cmd := i.Activate(msg, m) 104 | if newModel != nil || cmd != nil { 105 | if newModel == nil { 106 | newModel = m 107 | } 108 | return newModel, cmd 109 | } 110 | return m, nil 111 | } 112 | } 113 | return m, nil 114 | default: 115 | var cmd tea.Cmd 116 | m.list, cmd = m.list.Update(msg) 117 | return m, cmd 118 | } 119 | case tea.WindowSizeMsg: 120 | top, right, bottom, left := lipgloss.NewStyle().Margin(m.root.Height(), 2, 0).GetMargin() 121 | m.list.SetSize(msg.Width-left-right, msg.Height-top-bottom) 122 | m.root.SetSize(msg) 123 | case spinner.TickMsg: 124 | var cmd tea.Cmd 125 | m.list, cmd = m.list.Update(msg) 126 | return m, cmd 127 | case utils.TickMsg: 128 | select { 129 | case items := <-m.items: 130 | m.list.StopSpinner() 131 | cmd := m.list.SetItems(items) 132 | return m, cmd 133 | case err := <-m.err: 134 | errorComponent, cmd := components.NewErrorComponent(err, time.Second*5) 135 | m.error = errorComponent 136 | return m, cmd 137 | default: 138 | start := m.list.StartSpinner() 139 | return m, tea.Batch(utils.Ticker(), start) 140 | } 141 | } 142 | 143 | return m, nil 144 | } 145 | 146 | func (m selectModVersionList) View() string { 147 | if m.error != nil { 148 | err := m.error.View() 149 | m.list.SetSize(m.list.Width(), m.root.Size().Height-m.root.Height()-lipgloss.Height(err)) 150 | return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), err, m.list.View()) 151 | } 152 | 153 | m.list.SetSize(m.list.Width(), m.root.Size().Height-m.root.Height()) 154 | return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.list.View()) 155 | } 156 | -------------------------------------------------------------------------------- /tea/scenes/profile/profile.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/charmbracelet/bubbles/key" 8 | "github.com/charmbracelet/bubbles/list" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | 12 | "github.com/satisfactorymodding/ficsit-cli/cli" 13 | "github.com/satisfactorymodding/ficsit-cli/tea/components" 14 | "github.com/satisfactorymodding/ficsit-cli/tea/scenes/keys" 15 | "github.com/satisfactorymodding/ficsit-cli/tea/utils" 16 | ) 17 | 18 | var _ tea.Model = (*profile)(nil) 19 | 20 | type profile struct { 21 | list list.Model 22 | root components.RootModel 23 | parent tea.Model 24 | profile *cli.Profile 25 | error *components.ErrorComponent 26 | hadRenamed bool 27 | } 28 | 29 | func NewProfile(root components.RootModel, parent tea.Model, profileData *cli.Profile) tea.Model { 30 | model := profile{ 31 | root: root, 32 | parent: parent, 33 | profile: profileData, 34 | } 35 | 36 | items := []list.Item{ 37 | utils.SimpleItem[profile]{ 38 | ItemTitle: "Select", 39 | Activate: func(msg tea.Msg, currentModel profile) (tea.Model, tea.Cmd) { 40 | if err := root.SetCurrentProfile(profileData); err != nil { 41 | errorComponent, cmd := components.NewErrorComponent(err.Error(), time.Second*5) 42 | currentModel.error = errorComponent 43 | return currentModel, cmd 44 | } 45 | 46 | return currentModel.parent, nil 47 | }, 48 | }, 49 | } 50 | 51 | if profileData.Name != cli.DefaultProfileName { 52 | items = append(items, 53 | utils.SimpleItem[profile]{ 54 | ItemTitle: "Rename", 55 | Activate: func(msg tea.Msg, currentModel profile) (tea.Model, tea.Cmd) { 56 | newModel := NewRenameProfile(root, currentModel, profileData) 57 | return newModel, newModel.Init() 58 | }, 59 | }, 60 | utils.SimpleItem[profile]{ 61 | ItemTitle: "Delete", 62 | Activate: func(msg tea.Msg, currentModel profile) (tea.Model, tea.Cmd) { 63 | if err := root.GetGlobal().Profiles.DeleteProfile(profileData.Name); err != nil { 64 | errorComponent, cmd := components.NewErrorComponent(err.Error(), time.Second*5) 65 | currentModel.error = errorComponent 66 | return currentModel, cmd 67 | } 68 | 69 | return currentModel.parent, updateProfileListCmd 70 | }, 71 | }, 72 | ) 73 | } 74 | 75 | model.list = list.New(items, utils.NewItemDelegate(), root.Size().Width, root.Size().Height-root.Height()) 76 | model.list.SetShowStatusBar(false) 77 | model.list.SetFilteringEnabled(false) 78 | model.list.Title = fmt.Sprintf("Profile: %s", profileData.Name) 79 | model.list.Styles = utils.ListStyles 80 | model.list.SetSize(model.list.Width(), model.list.Height()) 81 | model.list.StatusMessageLifetime = time.Second * 3 82 | model.list.KeyMap.Quit.SetHelp("q", "back") 83 | model.list.AdditionalShortHelpKeys = func() []key.Binding { 84 | return []key.Binding{ 85 | key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), 86 | } 87 | } 88 | 89 | return model 90 | } 91 | 92 | func (m profile) Init() tea.Cmd { 93 | return nil 94 | } 95 | 96 | func (m profile) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 97 | switch msg := msg.(type) { 98 | case tea.KeyMsg: 99 | switch keypress := msg.String(); keypress { 100 | case keys.KeyControlC: 101 | return m, tea.Quit 102 | case "q": 103 | if m.parent != nil { 104 | m.parent.Update(m.root.Size()) 105 | 106 | if m.hadRenamed { 107 | return m.parent, updateProfileNamesCmd 108 | } 109 | 110 | return m.parent, nil 111 | } 112 | return m, nil 113 | case keys.KeyEnter: 114 | i, ok := m.list.SelectedItem().(utils.SimpleItem[profile]) 115 | if ok { 116 | if i.Activate != nil { 117 | newModel, cmd := i.Activate(msg, m) 118 | if newModel != nil || cmd != nil { 119 | if newModel == nil { 120 | newModel = m 121 | } 122 | return newModel, cmd 123 | } 124 | return m, nil 125 | } 126 | } 127 | return m, nil 128 | default: 129 | var cmd tea.Cmd 130 | m.list, cmd = m.list.Update(msg) 131 | return m, cmd 132 | } 133 | case tea.WindowSizeMsg: 134 | top, right, bottom, left := lipgloss.NewStyle().Margin(2, 2).GetMargin() 135 | m.list.SetSize(msg.Width-left-right, msg.Height-top-bottom) 136 | m.root.SetSize(msg) 137 | case updateProfileNames: 138 | m.hadRenamed = true 139 | m.list.Title = fmt.Sprintf("Profile: %s", m.profile.Name) 140 | case components.ErrorComponentTimeoutMsg: 141 | m.error = nil 142 | } 143 | 144 | return m, nil 145 | } 146 | 147 | func (m profile) View() string { 148 | if m.error != nil { 149 | err := m.error.View() 150 | m.list.SetSize(m.list.Width(), m.root.Size().Height-m.root.Height()-lipgloss.Height(err)) 151 | return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), err, m.list.View()) 152 | } 153 | 154 | m.list.SetSize(m.list.Width(), m.root.Size().Height-m.root.Height()) 155 | return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.list.View()) 156 | } 157 | -------------------------------------------------------------------------------- /cli/cache/mod_details.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "archive/zip" 5 | "encoding/base64" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "log/slog" 11 | "os" 12 | "path/filepath" 13 | "strings" 14 | 15 | "github.com/mircearoata/pubgrub-go/pubgrub/semver" 16 | "github.com/puzpuzpuz/xsync/v3" 17 | "github.com/spf13/viper" 18 | ) 19 | 20 | const IconFilename = "Resources/Icon128.png" // This is the path UE expects for the icon 21 | 22 | type Mod struct { 23 | ModReference string 24 | Name string 25 | Author string 26 | Icon *string 27 | LatestVersion string 28 | } 29 | 30 | var loadedMods *xsync.MapOf[string, Mod] 31 | 32 | func GetCacheMods() (*xsync.MapOf[string, Mod], error) { 33 | if loadedMods != nil { 34 | return loadedMods, nil 35 | } 36 | return LoadCacheMods() 37 | } 38 | 39 | func GetCacheMod(mod string) (Mod, error) { 40 | cache, err := GetCacheMods() 41 | if err != nil { 42 | return Mod{}, err 43 | } 44 | value, _ := cache.Load(mod) 45 | return value, nil 46 | } 47 | 48 | func LoadCacheMods() (*xsync.MapOf[string, Mod], error) { 49 | loadedMods = xsync.NewMapOf[string, Mod]() 50 | downloadCache := filepath.Join(viper.GetString("cache-dir"), "downloadCache") 51 | if _, err := os.Stat(downloadCache); os.IsNotExist(err) { 52 | return loadedMods, nil 53 | } 54 | 55 | items, err := os.ReadDir(downloadCache) 56 | if err != nil { 57 | return nil, fmt.Errorf("failed reading download cache: %w", err) 58 | } 59 | 60 | for _, item := range items { 61 | if item.IsDir() { 62 | continue 63 | } 64 | 65 | _, err = addFileToCache(item.Name()) 66 | if err != nil { 67 | slog.Error("failed to add file to cache", slog.String("file", item.Name()), slog.Any("err", err)) 68 | } 69 | } 70 | return loadedMods, nil 71 | } 72 | 73 | func addFileToCache(filename string) (*Mod, error) { 74 | cacheFile, err := readCacheFile(filename) 75 | if err != nil { 76 | return nil, fmt.Errorf("failed to read cache file: %w", err) 77 | } 78 | 79 | loadedMods.Compute(cacheFile.ModReference, func(oldValue Mod, loaded bool) (Mod, bool) { 80 | if !loaded { 81 | return *cacheFile, false 82 | } 83 | oldVersion, err := semver.NewVersion(oldValue.LatestVersion) 84 | if err != nil { 85 | slog.Error("failed to parse version", slog.String("version", oldValue.LatestVersion), slog.Any("err", err)) 86 | return *cacheFile, false 87 | } 88 | newVersion, err := semver.NewVersion(cacheFile.LatestVersion) 89 | if err != nil { 90 | slog.Error("failed to parse version", slog.String("version", cacheFile.LatestVersion), slog.Any("err", err)) 91 | return oldValue, false 92 | } 93 | if newVersion.Compare(oldVersion) > 0 { 94 | return *cacheFile, false 95 | } 96 | return oldValue, false 97 | }) 98 | 99 | return cacheFile, nil 100 | } 101 | 102 | func readCacheFile(filename string) (*Mod, error) { 103 | downloadCache := filepath.Join(viper.GetString("cache-dir"), "downloadCache") 104 | path := filepath.Join(downloadCache, filename) 105 | stat, err := os.Stat(path) 106 | if err != nil { 107 | return nil, fmt.Errorf("failed to stat file: %w", err) 108 | } 109 | 110 | zipFile, err := os.Open(path) 111 | if err != nil { 112 | return nil, fmt.Errorf("failed to open file: %w", err) 113 | } 114 | defer zipFile.Close() 115 | 116 | size := stat.Size() 117 | reader, err := zip.NewReader(zipFile, size) 118 | if err != nil { 119 | return nil, fmt.Errorf("failed to read zip: %w", err) 120 | } 121 | 122 | var upluginFile *zip.File 123 | for _, file := range reader.File { 124 | if strings.HasSuffix(file.Name, ".uplugin") { 125 | upluginFile = file 126 | break 127 | } 128 | } 129 | if upluginFile == nil { 130 | return nil, errors.New("no uplugin file found in zip") 131 | } 132 | 133 | upluginReader, err := upluginFile.Open() 134 | if err != nil { 135 | return nil, fmt.Errorf("failed to open uplugin file: %w", err) 136 | } 137 | 138 | var uplugin UPlugin 139 | data, err := io.ReadAll(upluginReader) 140 | if err != nil { 141 | return nil, fmt.Errorf("failed to read uplugin file: %w", err) 142 | } 143 | if err := json.Unmarshal(data, &uplugin); err != nil { 144 | return nil, fmt.Errorf("failed to unmarshal uplugin file: %w", err) 145 | } 146 | 147 | modReference := strings.TrimSuffix(upluginFile.Name, ".uplugin") 148 | 149 | var iconFile *zip.File 150 | for _, file := range reader.File { 151 | if file.Name == IconFilename { 152 | iconFile = file 153 | break 154 | } 155 | } 156 | var icon *string 157 | if iconFile != nil { 158 | iconReader, err := iconFile.Open() 159 | if err != nil { 160 | return nil, fmt.Errorf("failed to open icon file: %w", err) 161 | } 162 | defer iconReader.Close() 163 | 164 | data, err := io.ReadAll(iconReader) 165 | if err != nil { 166 | return nil, fmt.Errorf("failed to read icon file: %w", err) 167 | } 168 | iconData := base64.StdEncoding.EncodeToString(data) 169 | icon = &iconData 170 | } 171 | 172 | return &Mod{ 173 | ModReference: modReference, 174 | Name: uplugin.FriendlyName, 175 | Author: uplugin.CreatedBy, 176 | Icon: icon, 177 | LatestVersion: uplugin.SemVersion, 178 | }, nil 179 | } 180 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/satisfactorymodding/ficsit-cli 2 | 3 | go 1.21 4 | 5 | toolchain go1.21.4 6 | 7 | require ( 8 | github.com/JohannesKaufmann/html-to-markdown v1.4.2 9 | github.com/Khan/genqlient v0.6.0 10 | github.com/MarvinJWendt/testza v0.5.2 11 | github.com/PuerkitoBio/goquery v1.8.1 12 | github.com/avast/retry-go v3.0.0+incompatible 13 | github.com/charmbracelet/bubbles v0.17.1 14 | github.com/charmbracelet/bubbletea v0.25.0 15 | github.com/charmbracelet/glamour v0.6.0 16 | github.com/charmbracelet/lipgloss v0.9.1 17 | github.com/charmbracelet/x/exp/teatest v0.0.0-20231215171016-7ba2b450712d 18 | github.com/dustin/go-humanize v1.0.1 19 | github.com/jackc/puddle/v2 v2.2.1 20 | github.com/jlaffaye/ftp v0.2.0 21 | github.com/lmittmann/tint v1.0.3 22 | github.com/mircearoata/pubgrub-go v0.3.4 23 | github.com/muesli/reflow v0.3.0 24 | github.com/pkg/sftp v1.13.6 25 | github.com/pterm/pterm v0.12.71 26 | github.com/puzpuzpuz/xsync/v3 v3.0.2 27 | github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f 28 | github.com/samber/slog-multi v1.0.2 29 | github.com/satisfactorymodding/ficsit-resolver v0.0.6 30 | github.com/spf13/cobra v1.8.0 31 | github.com/spf13/viper v1.18.1 32 | goftp.io/server/v2 v2.0.1 33 | golang.org/x/crypto v0.21.0 34 | golang.org/x/sync v0.6.0 35 | modernc.org/sqlite v1.32.0 36 | ) 37 | 38 | require ( 39 | atomicgo.dev/assert v0.0.2 // indirect 40 | atomicgo.dev/cursor v0.2.0 // indirect 41 | atomicgo.dev/keyboard v0.2.9 // indirect 42 | atomicgo.dev/schedule v0.1.0 // indirect 43 | github.com/agnivade/levenshtein v1.1.1 // indirect 44 | github.com/alecthomas/chroma v0.10.0 // indirect 45 | github.com/alexflint/go-arg v1.4.3 // indirect 46 | github.com/alexflint/go-scalar v1.2.0 // indirect 47 | github.com/andybalholm/cascadia v1.3.2 // indirect 48 | github.com/atotto/clipboard v0.1.4 // indirect 49 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 50 | github.com/aymanbagabas/go-udiff v0.2.0 // indirect 51 | github.com/aymerick/douceur v0.2.0 // indirect 52 | github.com/charmbracelet/harmonica v0.2.0 // indirect 53 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect 54 | github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect 55 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 56 | github.com/dlclark/regexp2 v1.10.0 // indirect 57 | github.com/fsnotify/fsnotify v1.7.0 // indirect 58 | github.com/google/go-cmp v0.6.0 // indirect 59 | github.com/google/uuid v1.6.0 // indirect 60 | github.com/gookit/color v1.5.4 // indirect 61 | github.com/gorilla/css v1.0.1 // indirect 62 | github.com/hashicorp/errwrap v1.1.0 // indirect 63 | github.com/hashicorp/go-multierror v1.1.1 // indirect 64 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 65 | github.com/hashicorp/hcl v1.0.0 // indirect 66 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 67 | github.com/klauspost/cpuid/v2 v2.2.6 // indirect 68 | github.com/kr/fs v0.1.0 // indirect 69 | github.com/lithammer/fuzzysearch v1.1.8 // indirect 70 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 71 | github.com/magiconair/properties v1.8.7 // indirect 72 | github.com/mattn/go-isatty v0.0.20 // indirect 73 | github.com/mattn/go-localereader v0.0.1 // indirect 74 | github.com/mattn/go-runewidth v0.0.15 // indirect 75 | github.com/microcosm-cc/bluemonday v1.0.26 // indirect 76 | github.com/mitchellh/mapstructure v1.5.0 // indirect 77 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 78 | github.com/muesli/cancelreader v0.2.2 // indirect 79 | github.com/muesli/termenv v0.15.2 // indirect 80 | github.com/ncruces/go-strftime v0.1.9 // indirect 81 | github.com/olekukonko/tablewriter v0.0.5 // indirect 82 | github.com/pelletier/go-toml/v2 v2.1.0 // indirect 83 | github.com/pkg/errors v0.9.1 // indirect 84 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 85 | github.com/rivo/uniseg v0.4.4 // indirect 86 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 87 | github.com/sagikazarmark/locafero v0.4.0 // indirect 88 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 89 | github.com/samber/lo v1.38.1 // indirect 90 | github.com/sergi/go-diff v1.3.1 // indirect 91 | github.com/sourcegraph/conc v0.3.0 // indirect 92 | github.com/spf13/afero v1.11.0 // indirect 93 | github.com/spf13/cast v1.6.0 // indirect 94 | github.com/spf13/pflag v1.0.5 // indirect 95 | github.com/subosito/gotenv v1.6.0 // indirect 96 | github.com/vektah/gqlparser/v2 v2.5.10 // indirect 97 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 98 | github.com/yuin/goldmark v1.6.0 // indirect 99 | github.com/yuin/goldmark-emoji v1.0.2 // indirect 100 | go.uber.org/multierr v1.11.0 // indirect 101 | golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb // indirect 102 | golang.org/x/mod v0.16.0 // indirect 103 | golang.org/x/net v0.22.0 // indirect 104 | golang.org/x/sys v0.22.0 // indirect 105 | golang.org/x/term v0.18.0 // indirect 106 | golang.org/x/text v0.14.0 // indirect 107 | golang.org/x/tools v0.19.0 // indirect 108 | gopkg.in/ini.v1 v1.67.0 // indirect 109 | gopkg.in/yaml.v2 v2.4.0 // indirect 110 | gopkg.in/yaml.v3 v3.0.1 // indirect 111 | modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect 112 | modernc.org/libc v1.55.3 // indirect 113 | modernc.org/mathutil v1.6.0 // indirect 114 | modernc.org/memory v1.8.0 // indirect 115 | modernc.org/strutil v1.2.0 // indirect 116 | modernc.org/token v1.1.0 // indirect 117 | ) 118 | -------------------------------------------------------------------------------- /tea/scenes/mods/mod.go: -------------------------------------------------------------------------------- 1 | package mods 2 | 3 | import ( 4 | "log/slog" 5 | "time" 6 | 7 | "github.com/charmbracelet/bubbles/key" 8 | "github.com/charmbracelet/bubbles/list" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | 12 | "github.com/satisfactorymodding/ficsit-cli/tea/components" 13 | "github.com/satisfactorymodding/ficsit-cli/tea/scenes/errors" 14 | "github.com/satisfactorymodding/ficsit-cli/tea/scenes/keys" 15 | "github.com/satisfactorymodding/ficsit-cli/tea/utils" 16 | ) 17 | 18 | var _ tea.Model = (*modMenu)(nil) 19 | 20 | type modMenu struct { 21 | root components.RootModel 22 | list list.Model 23 | parent tea.Model 24 | } 25 | 26 | func NewModMenu(root components.RootModel, parent tea.Model, mod utils.Mod) tea.Model { 27 | model := modMenu{ 28 | root: root, 29 | parent: parent, 30 | } 31 | 32 | var items []list.Item 33 | if root.GetCurrentProfile().HasMod(mod.Reference) { 34 | items = []list.Item{ 35 | utils.SimpleItem[modMenu]{ 36 | ItemTitle: "Remove Mod", 37 | Activate: func(msg tea.Msg, currentModel modMenu) (tea.Model, tea.Cmd) { 38 | root.GetCurrentProfile().RemoveMod(mod.Reference) 39 | return currentModel.parent, currentModel.parent.Init() 40 | }, 41 | }, 42 | utils.SimpleItem[modMenu]{ 43 | ItemTitle: "Change Version", 44 | Activate: func(msg tea.Msg, currentModel modMenu) (tea.Model, tea.Cmd) { 45 | newModel := NewModVersion(root, currentModel.parent, mod) 46 | return newModel, newModel.Init() 47 | }, 48 | }, 49 | } 50 | 51 | if root.GetCurrentProfile().IsModEnabled(mod.Reference) { 52 | items = append(items, utils.SimpleItem[modMenu]{ 53 | ItemTitle: "Disable Mod", 54 | Activate: func(msg tea.Msg, currentModel modMenu) (tea.Model, tea.Cmd) { 55 | root.GetCurrentProfile().SetModEnabled(mod.Reference, false) 56 | return currentModel.parent, currentModel.parent.Init() 57 | }, 58 | }) 59 | } else { 60 | items = append(items, utils.SimpleItem[modMenu]{ 61 | ItemTitle: "Enable Mod", 62 | Activate: func(msg tea.Msg, currentModel modMenu) (tea.Model, tea.Cmd) { 63 | root.GetCurrentProfile().SetModEnabled(mod.Reference, true) 64 | return currentModel.parent, currentModel.parent.Init() 65 | }, 66 | }) 67 | } 68 | } else { 69 | items = []list.Item{ 70 | utils.SimpleItem[modMenu]{ 71 | ItemTitle: "Install Mod", 72 | Activate: func(msg tea.Msg, currentModel modMenu) (tea.Model, tea.Cmd) { 73 | err := root.GetCurrentProfile().AddMod(mod.Reference, ">=0.0.0") 74 | if err != nil { 75 | slog.Error(errors.ErrorFailedAddMod, slog.Any("err", err)) 76 | cmd := currentModel.list.NewStatusMessage(errors.ErrorFailedAddMod) 77 | return currentModel, cmd 78 | } 79 | return currentModel.parent, nil 80 | }, 81 | }, 82 | utils.SimpleItem[modMenu]{ 83 | ItemTitle: "Install Mod with specific version", 84 | Activate: func(msg tea.Msg, currentModel modMenu) (tea.Model, tea.Cmd) { 85 | newModel := NewModVersion(root, currentModel.parent, mod) 86 | return newModel, newModel.Init() 87 | }, 88 | }, 89 | } 90 | } 91 | 92 | items = append(items, utils.SimpleItem[modMenu]{ 93 | ItemTitle: "View Mod info", 94 | Activate: func(msg tea.Msg, currentModel modMenu) (tea.Model, tea.Cmd) { 95 | newModel := NewModInfo(root, currentModel, mod) 96 | return newModel, newModel.Init() 97 | }, 98 | }) 99 | 100 | model.list = list.New(items, utils.NewItemDelegate(), root.Size().Width, root.Size().Height-root.Height()) 101 | model.list.SetShowStatusBar(false) 102 | model.list.SetFilteringEnabled(false) 103 | model.list.Title = mod.Name 104 | model.list.Styles = utils.ListStyles 105 | model.list.SetSize(model.list.Width(), model.list.Height()) 106 | model.list.StatusMessageLifetime = time.Second * 3 107 | model.list.KeyMap.Quit.SetHelp("q", "back") 108 | model.list.AdditionalShortHelpKeys = func() []key.Binding { 109 | return []key.Binding{ 110 | key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), 111 | } 112 | } 113 | 114 | return model 115 | } 116 | 117 | func (m modMenu) Init() tea.Cmd { 118 | return nil 119 | } 120 | 121 | func (m modMenu) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 122 | switch msg := msg.(type) { 123 | case tea.KeyMsg: 124 | switch keypress := msg.String(); keypress { 125 | case keys.KeyControlC: 126 | return m, tea.Quit 127 | case "q": 128 | if m.parent != nil { 129 | m.parent.Update(m.root.Size()) 130 | return m.parent, nil 131 | } 132 | return m, tea.Quit 133 | case keys.KeyEnter: 134 | i, ok := m.list.SelectedItem().(utils.SimpleItem[modMenu]) 135 | if ok { 136 | if i.Activate != nil { 137 | newModel, cmd := i.Activate(msg, m) 138 | if newModel != nil || cmd != nil { 139 | if newModel == nil { 140 | newModel = m 141 | } 142 | return newModel, cmd 143 | } 144 | return m, nil 145 | } 146 | } 147 | return m, nil 148 | default: 149 | var cmd tea.Cmd 150 | m.list, cmd = m.list.Update(msg) 151 | return m, cmd 152 | } 153 | case tea.WindowSizeMsg: 154 | top, right, bottom, left := lipgloss.NewStyle().Margin(2, 2).GetMargin() 155 | m.list.SetSize(msg.Width-left-right, msg.Height-top-bottom) 156 | m.root.SetSize(msg) 157 | } 158 | 159 | return m, nil 160 | } 161 | 162 | func (m modMenu) View() string { 163 | return lipgloss.JoinVertical(lipgloss.Left, m.root.View(), m.list.View()) 164 | } 165 | -------------------------------------------------------------------------------- /cli/installations_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "runtime" 7 | "testing" 8 | "time" 9 | 10 | "github.com/MarvinJWendt/testza" 11 | "goftp.io/server/v2" 12 | "goftp.io/server/v2/driver/file" 13 | 14 | "github.com/satisfactorymodding/ficsit-cli/cfg" 15 | ) 16 | 17 | // NOTE: 18 | // 19 | // This code contains sleep. 20 | // This is because github actions are special. 21 | // They don't properly sync to disk. 22 | // And Go is faster than their disk. 23 | // So tests are flaky :) 24 | // DO NOT REMOVE THE SLEEP! 25 | 26 | func init() { 27 | cfg.SetDefaults() 28 | } 29 | 30 | func TestInstallationsInit(t *testing.T) { 31 | installations, err := InitInstallations() 32 | testza.AssertNoError(t, err) 33 | testza.AssertNotNil(t, installations) 34 | } 35 | 36 | func TestAddLocalInstallation(t *testing.T) { 37 | ctx, err := InitCLI(false) 38 | testza.AssertNoError(t, err) 39 | 40 | err = ctx.Wipe() 41 | testza.AssertNoError(t, err) 42 | 43 | err = ctx.ReInit() 44 | testza.AssertNoError(t, err) 45 | 46 | ctx.Provider = MockProvider{} 47 | 48 | profileName := "InstallationTest" 49 | profile, err := ctx.Profiles.AddProfile(profileName) 50 | testza.AssertNoError(t, err) 51 | testza.AssertNoError(t, profile.AddMod("AreaActions", "1.6.5")) 52 | testza.AssertNoError(t, profile.AddMod("RefinedPower", "3.2.10")) 53 | 54 | serverLocation := os.Getenv("SF_DEDICATED_SERVER") 55 | if serverLocation != "" { 56 | time.Sleep(time.Second) 57 | testza.AssertNoError(t, os.RemoveAll(filepath.Join(serverLocation, "FactoryGame", "Mods"))) 58 | time.Sleep(time.Second) 59 | 60 | installation, err := ctx.Installations.AddInstallation(ctx, serverLocation, profileName) 61 | testza.AssertNoError(t, err) 62 | testza.AssertNotNil(t, installation) 63 | 64 | err = installation.Install(ctx, installWatcher()) 65 | testza.AssertNoError(t, err) 66 | 67 | installation.Vanilla = true 68 | err = installation.Install(ctx, installWatcher()) 69 | testza.AssertNoError(t, err) 70 | time.Sleep(time.Second) 71 | } 72 | 73 | err = ctx.Wipe() 74 | testza.AssertNoError(t, err) 75 | } 76 | 77 | func TestAddFTPInstallation(t *testing.T) { 78 | if runtime.GOOS == "windows" { 79 | // Not supported 80 | return 81 | } 82 | 83 | ctx, err := InitCLI(false) 84 | testza.AssertNoError(t, err) 85 | 86 | err = ctx.Wipe() 87 | testza.AssertNoError(t, err) 88 | 89 | err = ctx.ReInit() 90 | testza.AssertNoError(t, err) 91 | 92 | ctx.Provider = MockProvider{} 93 | 94 | profileName := "InstallationTest" 95 | profile, err := ctx.Profiles.AddProfile(profileName) 96 | testza.AssertNoError(t, err) 97 | testza.AssertNoError(t, profile.AddMod("AreaActions", "1.6.5")) 98 | testza.AssertNoError(t, profile.AddMod("RefinedPower", "3.2.10")) 99 | 100 | serverLocation := os.Getenv("SF_DEDICATED_SERVER") 101 | if serverLocation != "" { 102 | driver, err := file.NewDriver(serverLocation) 103 | testza.AssertNoError(t, err) 104 | 105 | s, err := server.NewServer(&server.Options{ 106 | Driver: driver, 107 | Auth: &server.SimpleAuth{ 108 | Name: "user", 109 | Password: "pass", 110 | }, 111 | Port: 2121, 112 | Perm: server.NewSimplePerm("root", "root"), 113 | }) 114 | testza.AssertNoError(t, err) 115 | defer testza.AssertNoError(t, s.Shutdown()) 116 | 117 | go func() { 118 | testza.AssertNoError(t, s.ListenAndServe()) 119 | }() 120 | 121 | time.Sleep(time.Second) 122 | testza.AssertNoError(t, os.RemoveAll(filepath.Join(serverLocation, "FactoryGame", "Mods"))) 123 | time.Sleep(time.Second) 124 | 125 | installation, err := ctx.Installations.AddInstallation(ctx, "ftp://user:pass@localhost:2121/", profileName) 126 | testza.AssertNoError(t, err) 127 | testza.AssertNotNil(t, installation) 128 | 129 | err = installation.Install(ctx, installWatcher()) 130 | testza.AssertNoError(t, err) 131 | 132 | installation.Vanilla = true 133 | err = installation.Install(ctx, installWatcher()) 134 | testza.AssertNoError(t, err) 135 | time.Sleep(time.Second) 136 | } 137 | 138 | err = ctx.Wipe() 139 | testza.AssertNoError(t, err) 140 | } 141 | 142 | func TestAddSFTPInstallation(t *testing.T) { 143 | if runtime.GOOS == "windows" { 144 | // Not supported 145 | return 146 | } 147 | 148 | ctx, err := InitCLI(false) 149 | testza.AssertNoError(t, err) 150 | 151 | err = ctx.Wipe() 152 | testza.AssertNoError(t, err) 153 | 154 | err = ctx.ReInit() 155 | testza.AssertNoError(t, err) 156 | 157 | ctx.Provider = MockProvider{} 158 | 159 | profileName := "InstallationTest" 160 | profile, err := ctx.Profiles.AddProfile(profileName) 161 | testza.AssertNoError(t, err) 162 | testza.AssertNoError(t, profile.AddMod("AreaActions", "1.6.5")) 163 | testza.AssertNoError(t, profile.AddMod("RefinedPower", "3.2.10")) 164 | 165 | serverLocation := os.Getenv("SF_DEDICATED_SERVER") 166 | if serverLocation != "" { 167 | time.Sleep(time.Second) 168 | testza.AssertNoError(t, os.RemoveAll(filepath.Join(serverLocation, "FactoryGame", "Mods"))) 169 | time.Sleep(time.Second) 170 | 171 | installation, err := ctx.Installations.AddInstallation(ctx, "sftp://user:pass@localhost:2222/home/user/server", profileName) 172 | testza.AssertNoError(t, err) 173 | testza.AssertNotNil(t, installation) 174 | 175 | err = installation.Install(ctx, installWatcher()) 176 | testza.AssertNoError(t, err) 177 | 178 | installation.Vanilla = true 179 | err = installation.Install(ctx, installWatcher()) 180 | testza.AssertNoError(t, err) 181 | time.Sleep(time.Second) 182 | } 183 | 184 | err = ctx.Wipe() 185 | testza.AssertNoError(t, err) 186 | } 187 | -------------------------------------------------------------------------------- /cli/localregistry/registry.go: -------------------------------------------------------------------------------- 1 | package localregistry 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "path/filepath" 9 | "sync" 10 | 11 | "github.com/spf13/viper" 12 | 13 | "github.com/satisfactorymodding/ficsit-cli/ficsit" 14 | 15 | // sqlite driver 16 | _ "modernc.org/sqlite" 17 | ) 18 | 19 | var db *sql.DB 20 | var dbWriteMutex = sync.Mutex{} 21 | 22 | func Init() error { 23 | dbPath := filepath.Join(viper.GetString("cache-dir"), "registry.db") 24 | 25 | err := os.MkdirAll(filepath.Dir(dbPath), 0o777) 26 | if err != nil { 27 | return fmt.Errorf("failed to create local registry directory: %w", err) 28 | } 29 | 30 | db, err = sql.Open("sqlite", dbPath) 31 | if err != nil { 32 | return fmt.Errorf("failed to open database: %w", err) 33 | } 34 | 35 | // Set pragmas here because modernc.org/sqlite does not support them in the connection string 36 | _, err = db.Exec(` 37 | PRAGMA journal_mode = WAL; 38 | PRAGMA foreign_keys = ON; 39 | PRAGMA busy_timeout = 5000; 40 | `) 41 | if err != nil { 42 | return fmt.Errorf("failed to setup connection pragmas: %w", err) 43 | } 44 | 45 | err = applyMigrations(db) 46 | if err != nil { 47 | return fmt.Errorf("failed to apply migrations: %w", err) 48 | } 49 | 50 | return nil 51 | } 52 | 53 | func Add(modReference string, modVersions []ficsit.ModVersion) { 54 | dbWriteMutex.Lock() 55 | defer dbWriteMutex.Unlock() 56 | 57 | tx, err := db.Begin() 58 | if err != nil { 59 | slog.Error("failed to start local registry transaction", slog.Any("err", err)) 60 | return 61 | } 62 | // In case the transaction is not committed, revert and release 63 | defer tx.Rollback() //nolint:errcheck 64 | 65 | _, err = tx.Exec("DELETE FROM versions WHERE mod_reference = ?", modReference) 66 | if err != nil { 67 | slog.Error("failed to delete existing mod versions from local registry", slog.Any("err", err)) 68 | return 69 | } 70 | 71 | for _, modVersion := range modVersions { 72 | l := slog.With(slog.String("mod", modReference), slog.String("version", modVersion.Version)) 73 | 74 | _, err = tx.Exec("INSERT INTO versions (id, mod_reference, version, game_version, required_on_remote) VALUES (?, ?, ?, ?, ?)", modVersion.ID, modReference, modVersion.Version, modVersion.GameVersion, modVersion.RequiredOnRemote) 75 | if err != nil { 76 | l.Error("failed to insert mod version into local registry", slog.Any("err", err)) 77 | return 78 | } 79 | for _, dependency := range modVersion.Dependencies { 80 | _, err = tx.Exec("INSERT INTO dependencies (version_id, dependency, condition, optional) VALUES (?, ?, ?, ?)", modVersion.ID, dependency.ModID, dependency.Condition, dependency.Optional) 81 | if err != nil { 82 | l.Error("failed to insert dependency into local registry", slog.String("dependency", dependency.ModID), slog.Any("err", err)) 83 | return 84 | } 85 | } 86 | for _, target := range modVersion.Targets { 87 | _, err = tx.Exec("INSERT INTO targets (version_id, target_name, link, hash, size) VALUES (?, ?, ?, ?, ?)", modVersion.ID, target.TargetName, target.Link, target.Hash, target.Size) 88 | if err != nil { 89 | l.Error("failed to insert target into local registry", slog.Any("target", target.TargetName), slog.Any("err", err)) 90 | return 91 | } 92 | } 93 | } 94 | 95 | err = tx.Commit() 96 | if err != nil { 97 | slog.Error("failed to commit local registry transaction", slog.Any("err", err)) 98 | return 99 | } 100 | } 101 | 102 | func GetModVersions(modReference string) ([]ficsit.ModVersion, error) { 103 | versionRows, err := db.Query("SELECT id, version, game_version, required_on_remote FROM versions WHERE mod_reference = ?", modReference) 104 | if err != nil { 105 | return nil, fmt.Errorf("failed to fetch mod versions from local registry: %w", err) 106 | } 107 | defer versionRows.Close() 108 | 109 | var versions []ficsit.ModVersion 110 | for versionRows.Next() { 111 | var version ficsit.ModVersion 112 | err = versionRows.Scan(&version.ID, &version.Version, &version.GameVersion, &version.RequiredOnRemote) 113 | if err != nil { 114 | return nil, fmt.Errorf("failed to scan version row: %w", err) 115 | } 116 | 117 | dependencies, err := getVersionDependencies(version.ID) 118 | if err != nil { 119 | return nil, err 120 | } 121 | 122 | version.Dependencies = dependencies 123 | 124 | targets, err := getVersionTargets(version.ID) 125 | if err != nil { 126 | return nil, err 127 | } 128 | 129 | version.Targets = targets 130 | 131 | versions = append(versions, version) 132 | } 133 | 134 | return versions, nil 135 | } 136 | 137 | func getVersionDependencies(versionID string) ([]ficsit.Dependency, error) { 138 | var dependencies []ficsit.Dependency 139 | dependencyRows, err := db.Query("SELECT dependency, condition, optional FROM dependencies WHERE version_id = ?", versionID) 140 | if err != nil { 141 | return nil, fmt.Errorf("failed to fetch dependencies from local registry: %w", err) 142 | } 143 | defer dependencyRows.Close() 144 | 145 | for dependencyRows.Next() { 146 | var dependency ficsit.Dependency 147 | err = dependencyRows.Scan(&dependency.ModID, &dependency.Condition, &dependency.Optional) 148 | if err != nil { 149 | return nil, fmt.Errorf("failed to scan dependency row: %w", err) 150 | } 151 | dependencies = append(dependencies, dependency) 152 | } 153 | 154 | return dependencies, nil 155 | } 156 | 157 | func getVersionTargets(versionID string) ([]ficsit.Target, error) { 158 | var targets []ficsit.Target 159 | targetRows, err := db.Query("SELECT target_name, link, hash, size FROM targets WHERE version_id = ?", versionID) 160 | if err != nil { 161 | return nil, fmt.Errorf("failed to fetch targets from local registry: %w", err) 162 | } 163 | defer targetRows.Close() 164 | 165 | for targetRows.Next() { 166 | var target ficsit.Target 167 | err = targetRows.Scan(&target.TargetName, &target.Link, &target.Hash, &target.Size) 168 | if err != nil { 169 | return nil, fmt.Errorf("failed to scan target row: %w", err) 170 | } 171 | targets = append(targets, target) 172 | } 173 | 174 | return targets, nil 175 | } 176 | -------------------------------------------------------------------------------- /cli/cache/download.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | "net/http" 9 | "os" 10 | "path/filepath" 11 | "sync" 12 | "time" 13 | 14 | "github.com/avast/retry-go" 15 | "github.com/puzpuzpuz/xsync/v3" 16 | "github.com/spf13/viper" 17 | 18 | "github.com/satisfactorymodding/ficsit-cli/utils" 19 | ) 20 | 21 | type downloadGroup struct { 22 | err error 23 | wait chan bool 24 | hash string 25 | updates []chan<- utils.GenericProgress 26 | size int64 27 | } 28 | 29 | var downloadSync = *xsync.NewMapOf[string, *downloadGroup]() 30 | 31 | func DownloadOrCache(cacheKey string, hash string, url string, updates chan<- utils.GenericProgress, downloadSemaphore chan int) (*os.File, int64, error) { 32 | group, loaded := downloadSync.LoadOrCompute(cacheKey, func() *downloadGroup { 33 | return &downloadGroup{ 34 | hash: hash, 35 | updates: make([]chan<- utils.GenericProgress, 0), 36 | wait: make(chan bool), 37 | } 38 | }) 39 | 40 | if updates != nil { 41 | _, _ = downloadSync.Compute(cacheKey, func(oldValue *downloadGroup, loaded bool) (*downloadGroup, bool) { 42 | oldValue.updates = append(oldValue.updates, updates) 43 | return oldValue, false 44 | }) 45 | } 46 | 47 | downloadCache := filepath.Join(viper.GetString("cache-dir"), "downloadCache") 48 | if err := os.MkdirAll(downloadCache, 0o777); err != nil { 49 | if !os.IsExist(err) { 50 | return nil, 0, fmt.Errorf("failed creating download cache: %w", err) 51 | } 52 | } 53 | 54 | location := filepath.Join(downloadCache, cacheKey) 55 | 56 | if loaded { 57 | if group.hash != hash { 58 | return nil, 0, errors.New("hash mismatch in download group") 59 | } 60 | 61 | <-group.wait 62 | 63 | if group.err != nil { 64 | return nil, 0, group.err 65 | } 66 | 67 | f, err := os.Open(location) 68 | if err != nil { 69 | return nil, 0, fmt.Errorf("failed to open file: %s: %w", location, err) 70 | } 71 | 72 | return f, group.size, nil 73 | } 74 | 75 | defer downloadSync.Delete(cacheKey) 76 | 77 | upstreamUpdates := make(chan utils.GenericProgress) 78 | defer close(upstreamUpdates) 79 | 80 | upstreamWaiter := make(chan bool) 81 | 82 | var wg sync.WaitGroup 83 | wg.Add(1) 84 | go func() { 85 | defer wg.Done() 86 | 87 | outer: 88 | for { 89 | select { 90 | case update, ok := <-upstreamUpdates: 91 | if !ok { 92 | break outer 93 | } 94 | 95 | for _, u := range group.updates { 96 | u <- update 97 | } 98 | case <-upstreamWaiter: 99 | break outer 100 | } 101 | } 102 | }() 103 | 104 | var size int64 105 | 106 | err := retry.Do(func() error { 107 | var err error 108 | size, err = downloadInternal(cacheKey, location, hash, url, upstreamUpdates, downloadSemaphore) 109 | if err != nil { 110 | return fmt.Errorf("internal download error: %w", err) 111 | } 112 | return nil 113 | }, 114 | retry.Attempts(5), 115 | retry.Delay(time.Second), 116 | retry.DelayType(retry.FixedDelay), 117 | retry.OnRetry(func(n uint, err error) { 118 | if n > 0 { 119 | slog.Info("retrying download", slog.Uint64("n", uint64(n)), slog.String("cacheKey", cacheKey)) 120 | } 121 | }), 122 | ) 123 | if err != nil { 124 | group.err = err 125 | close(group.wait) 126 | return nil, 0, err // nolint 127 | } 128 | 129 | close(upstreamWaiter) 130 | wg.Wait() 131 | 132 | group.size = size 133 | close(group.wait) 134 | 135 | f, err := os.Open(location) 136 | if err != nil { 137 | return nil, 0, fmt.Errorf("failed to open file: %s: %w", location, err) 138 | } 139 | 140 | return f, size, nil 141 | } 142 | 143 | func downloadInternal(cacheKey string, location string, hash string, url string, updates chan<- utils.GenericProgress, downloadSemaphore chan int) (int64, error) { 144 | stat, err := os.Stat(location) 145 | if err == nil { 146 | matches, err := compareHash(hash, location) 147 | if err != nil { 148 | return 0, err 149 | } 150 | 151 | if matches { 152 | return stat.Size(), nil 153 | } 154 | 155 | if err := os.Remove(location); err != nil { 156 | return 0, fmt.Errorf("failed to delete file: %s: %w", location, err) 157 | } 158 | } else if !os.IsNotExist(err) { 159 | return 0, fmt.Errorf("failed to stat file: %s: %w", location, err) 160 | } 161 | 162 | if updates != nil { 163 | headResp, err := http.Head(url) 164 | if err != nil { 165 | return 0, fmt.Errorf("failed to head: %s: %w", url, err) 166 | } 167 | defer headResp.Body.Close() 168 | updates <- utils.GenericProgress{Total: headResp.ContentLength} 169 | } 170 | 171 | if downloadSemaphore != nil { 172 | downloadSemaphore <- 1 173 | defer func() { <-downloadSemaphore }() 174 | } 175 | 176 | out, err := os.Create(location) 177 | if err != nil { 178 | return 0, fmt.Errorf("failed creating file at: %s: %w", location, err) 179 | } 180 | defer out.Close() 181 | 182 | resp, err := http.Get(url) 183 | if err != nil { 184 | return 0, fmt.Errorf("failed to fetch: %s: %w", url, err) 185 | } 186 | defer resp.Body.Close() 187 | 188 | if resp.StatusCode != http.StatusOK { 189 | return 0, fmt.Errorf("bad status: %s on url: %s", resp.Status, url) 190 | } 191 | 192 | progresser := &utils.Progresser{ 193 | Total: resp.ContentLength, 194 | Updates: updates, 195 | } 196 | 197 | _, err = io.Copy(io.MultiWriter(out, progresser), resp.Body) 198 | if err != nil { 199 | return 0, fmt.Errorf("failed writing file to disk: %w", err) 200 | } 201 | 202 | _ = out.Sync() 203 | 204 | if updates != nil { 205 | updates <- utils.GenericProgress{Completed: resp.ContentLength, Total: resp.ContentLength} 206 | } 207 | 208 | _, err = addFileToCache(cacheKey) 209 | if err != nil { 210 | return 0, fmt.Errorf("failed to add file to cache: %w", err) 211 | } 212 | 213 | return resp.ContentLength, nil 214 | } 215 | 216 | func compareHash(hash string, location string) (bool, error) { 217 | existingHash := "" 218 | 219 | if hash != "" { 220 | f, err := os.Open(location) 221 | if err != nil { 222 | return false, fmt.Errorf("failed to open file: %s: %w", location, err) 223 | } 224 | defer f.Close() 225 | 226 | existingHash, err = utils.SHA256Data(f) 227 | if err != nil { 228 | return false, fmt.Errorf("could not compute hash for file: %s: %w", location, err) 229 | } 230 | } 231 | 232 | return hash == existingHash, nil 233 | } 234 | -------------------------------------------------------------------------------- /cli/resolving_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "log/slog" 5 | "math" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | "time" 10 | 11 | "github.com/MarvinJWendt/testza" 12 | resolver "github.com/satisfactorymodding/ficsit-resolver" 13 | 14 | "github.com/satisfactorymodding/ficsit-cli/cfg" 15 | ) 16 | 17 | func init() { 18 | cfg.SetDefaults() 19 | } 20 | 21 | func installWatcher() chan<- InstallUpdate { 22 | c := make(chan InstallUpdate) 23 | go func() { 24 | for i := range c { 25 | if i.Progress.Total == i.Progress.Completed { 26 | if i.Type != InstallUpdateTypeOverall { 27 | slog.Info("progress completed", slog.String("mod_reference", i.Item.Mod), slog.String("version", i.Item.Version), slog.Any("type", i.Type)) 28 | } else { 29 | slog.Info("overall completed") 30 | } 31 | } 32 | } 33 | }() 34 | return c 35 | } 36 | 37 | func TestClientOnlyMod(t *testing.T) { 38 | ctx, err := InitCLI(false) 39 | testza.AssertNoError(t, err) 40 | 41 | err = ctx.Wipe() 42 | testza.AssertNoError(t, err) 43 | 44 | ctx.Provider = MockProvider{} 45 | 46 | profileName := "ClientOnlyModTest" 47 | profile, err := ctx.Profiles.AddProfile(profileName) 48 | profile.RequiredTargets = []resolver.TargetName{resolver.TargetNameWindows, resolver.TargetNameWindowsServer, resolver.TargetNameLinuxServer} 49 | testza.AssertNoError(t, err) 50 | testza.AssertNoError(t, profile.AddMod("ClientOnlyMod", "<=0.0.1")) 51 | 52 | serverLocation := os.Getenv("SF_DEDICATED_SERVER") 53 | if serverLocation != "" { 54 | time.Sleep(time.Second) 55 | testza.AssertNoError(t, os.RemoveAll(filepath.Join(serverLocation, "FactoryGame", "Mods"))) 56 | time.Sleep(time.Second) 57 | 58 | installation, err := ctx.Installations.AddInstallation(ctx, serverLocation, profileName) 59 | testza.AssertNoError(t, err) 60 | testza.AssertNotNil(t, installation) 61 | 62 | err = installation.Install(ctx, installWatcher()) 63 | testza.AssertNoError(t, err) 64 | } 65 | } 66 | 67 | func TestServerOnlyMod(t *testing.T) { 68 | ctx, err := InitCLI(false) 69 | testza.AssertNoError(t, err) 70 | 71 | err = ctx.Wipe() 72 | testza.AssertNoError(t, err) 73 | 74 | ctx.Provider = MockProvider{} 75 | 76 | profileName := "ServerOnlyModTest" 77 | profile, err := ctx.Profiles.AddProfile(profileName) 78 | profile.RequiredTargets = []resolver.TargetName{resolver.TargetNameWindows, resolver.TargetNameWindowsServer, resolver.TargetNameLinuxServer} 79 | testza.AssertNoError(t, err) 80 | testza.AssertNoError(t, profile.AddMod("ServerOnlyMod", "<=0.0.1")) 81 | 82 | serverLocation := os.Getenv("SF_DEDICATED_SERVER") 83 | if serverLocation != "" { 84 | time.Sleep(time.Second) 85 | testza.AssertNoError(t, os.RemoveAll(filepath.Join(serverLocation, "FactoryGame", "Mods"))) 86 | time.Sleep(time.Second) 87 | 88 | installation, err := ctx.Installations.AddInstallation(ctx, serverLocation, profileName) 89 | testza.AssertNoError(t, err) 90 | testza.AssertNotNil(t, installation) 91 | 92 | err = installation.Install(ctx, installWatcher()) 93 | testza.AssertNoError(t, err) 94 | } 95 | } 96 | 97 | func TestRemoveWhenNotSupported(t *testing.T) { 98 | ctx, err := InitCLI(false) 99 | testza.AssertNoError(t, err) 100 | 101 | err = ctx.Wipe() 102 | testza.AssertNoError(t, err) 103 | 104 | ctx.Provider = MockProvider{} 105 | 106 | profileName := "ClientOnlyModTest" 107 | profile, err := ctx.Profiles.AddProfile(profileName) 108 | profile.RequiredTargets = []resolver.TargetName{resolver.TargetNameWindows, resolver.TargetNameWindowsServer, resolver.TargetNameLinuxServer} 109 | testza.AssertNoError(t, err) 110 | testza.AssertNoError(t, profile.AddMod("LaterClientOnlyMod", "0.0.1")) 111 | 112 | serverLocation := os.Getenv("SF_DEDICATED_SERVER") 113 | if serverLocation != "" { 114 | time.Sleep(time.Second) 115 | testza.AssertNoError(t, os.RemoveAll(filepath.Join(serverLocation, "FactoryGame", "Mods"))) 116 | time.Sleep(time.Second) 117 | 118 | installation, err := ctx.Installations.AddInstallation(ctx, serverLocation, profileName) 119 | testza.AssertNoError(t, err) 120 | testza.AssertNotNil(t, installation) 121 | 122 | err = installation.Install(ctx, installWatcher()) 123 | testza.AssertNoError(t, err) 124 | 125 | _, err = os.Stat(filepath.Join(serverLocation, "FactoryGame", "Mods", "LaterClientOnlyMod")) 126 | testza.AssertNoError(t, err) 127 | 128 | testza.AssertNoError(t, profile.AddMod("LaterClientOnlyMod", "0.0.2")) 129 | 130 | err = installation.Install(ctx, installWatcher()) 131 | testza.AssertNoError(t, err) 132 | 133 | _, err = os.Stat(filepath.Join(serverLocation, "FactoryGame", "Mods", "LaterClientOnlyMod")) 134 | testza.AssertNotNil(t, err) 135 | testza.AssertErrorIs(t, err, os.ErrNotExist) 136 | } 137 | } 138 | 139 | func TestUpdateMods(t *testing.T) { 140 | ctx, err := InitCLI(false) 141 | testza.AssertNoError(t, err) 142 | 143 | err = ctx.Wipe() 144 | testza.AssertNoError(t, err) 145 | 146 | ctx.Provider = MockProvider{} 147 | 148 | depResolver := resolver.NewDependencyResolver(ctx.Provider) 149 | 150 | oldLockfile, err := depResolver.ResolveModDependencies(map[string]string{ 151 | "FicsitRemoteMonitoring": "0.9.8", 152 | }, nil, math.MaxInt, nil) 153 | 154 | testza.AssertNoError(t, err) 155 | testza.AssertNotNil(t, oldLockfile) 156 | testza.AssertLen(t, oldLockfile.Mods, 2) 157 | 158 | profileName := "UpdateTest" 159 | profile, err := ctx.Profiles.AddProfile(profileName) 160 | testza.AssertNoError(t, err) 161 | testza.AssertNoError(t, profile.AddMod("FicsitRemoteMonitoring", "<=0.10.0")) 162 | 163 | serverLocation := os.Getenv("SF_DEDICATED_SERVER") 164 | if serverLocation != "" { 165 | installation, err := ctx.Installations.AddInstallation(ctx, serverLocation, profileName) 166 | testza.AssertNoError(t, err) 167 | testza.AssertNotNil(t, installation) 168 | 169 | err = installation.WriteLockFile(ctx, oldLockfile) 170 | testza.AssertNoError(t, err) 171 | 172 | err = installation.Install(ctx, installWatcher()) 173 | testza.AssertNoError(t, err) 174 | 175 | lockFile, err := installation.LockFile(ctx) 176 | testza.AssertNoError(t, err) 177 | 178 | testza.AssertEqual(t, 2, len(lockFile.Mods)) 179 | testza.AssertEqual(t, "0.9.8", (lockFile.Mods)["FicsitRemoteMonitoring"].Version) 180 | 181 | err = installation.UpdateMods(ctx, []string{"FicsitRemoteMonitoring"}) 182 | testza.AssertNoError(t, err) 183 | 184 | lockFile, err = installation.LockFile(ctx) 185 | testza.AssertNoError(t, err) 186 | 187 | testza.AssertEqual(t, 2, len(lockFile.Mods)) 188 | testza.AssertEqual(t, "0.10.0", (lockFile.Mods)["FicsitRemoteMonitoring"].Version) 189 | 190 | err = installation.Install(ctx, installWatcher()) 191 | testza.AssertNoError(t, err) 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | "path/filepath" 8 | "runtime" 9 | "time" 10 | 11 | "github.com/lmittmann/tint" 12 | "github.com/pterm/pterm" 13 | slogmulti "github.com/samber/slog-multi" 14 | "github.com/spf13/cobra" 15 | "github.com/spf13/viper" 16 | 17 | "github.com/satisfactorymodding/ficsit-cli/cmd/installation" 18 | "github.com/satisfactorymodding/ficsit-cli/cmd/mod" 19 | "github.com/satisfactorymodding/ficsit-cli/cmd/profile" 20 | "github.com/satisfactorymodding/ficsit-cli/cmd/smr" 21 | ) 22 | 23 | var RootCmd = &cobra.Command{ 24 | Use: "ficsit", 25 | Short: "cli mod manager for satisfactory", 26 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 27 | viper.SetConfigName("config") 28 | viper.AddConfigPath(".") 29 | viper.SetEnvPrefix("ficsit") 30 | viper.AutomaticEnv() 31 | 32 | _ = viper.ReadInConfig() 33 | 34 | handlers := make([]slog.Handler, 0) 35 | if viper.GetBool("pretty") { 36 | pterm.EnableStyling() 37 | } else { 38 | pterm.DisableStyling() 39 | } 40 | 41 | const ( 42 | ansiReset = "\033[0m" 43 | ansiBold = "\033[1m" 44 | ansiWhite = "\033[38m" 45 | ansiBrightMagenta = "\033[95m" 46 | ) 47 | 48 | level := slog.LevelInfo 49 | if err := (&level).UnmarshalText([]byte(viper.GetString("log"))); err != nil { 50 | return fmt.Errorf("failed parsing level: %w", err) 51 | } 52 | 53 | if !viper.GetBool("quiet") { 54 | handlers = append(handlers, tint.NewHandler(os.Stdout, &tint.Options{ 55 | Level: level, 56 | AddSource: true, 57 | TimeFormat: time.RFC3339Nano, 58 | ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr { 59 | if attr.Key == slog.LevelKey { 60 | level := attr.Value.Any().(slog.Level) 61 | if level == slog.LevelDebug { 62 | attr.Value = slog.StringValue(ansiBrightMagenta + "DBG" + ansiReset) 63 | } 64 | } else if attr.Key == slog.MessageKey { 65 | attr.Value = slog.StringValue(ansiBold + ansiWhite + fmt.Sprint(attr.Value.Any()) + ansiReset) 66 | } 67 | return attr 68 | }, 69 | })) 70 | } 71 | 72 | if viper.GetString("log-file") != "" { 73 | logFile, err := os.OpenFile(viper.GetString("log-file"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o777) 74 | if err != nil { 75 | return fmt.Errorf("failed to open log file: %w", err) 76 | } 77 | 78 | handlers = append(handlers, slog.NewJSONHandler(logFile, &slog.HandlerOptions{})) 79 | } 80 | 81 | slog.SetDefault(slog.New( 82 | slogmulti.Fanout(handlers...), 83 | )) 84 | 85 | return nil 86 | }, 87 | } 88 | 89 | func Execute(version string, commit string) { 90 | // Execute tea as default 91 | cmd, _, err := RootCmd.Find(os.Args[1:]) 92 | 93 | // Allow opening via explorer 94 | cobra.MousetrapHelpText = "" 95 | 96 | cli := len(os.Args) >= 2 && os.Args[1] == "cli" 97 | if (len(os.Args) <= 1 || (os.Args[1] != "help" && os.Args[1] != "--help" && os.Args[1] != "-h")) && (err != nil || cmd == RootCmd) { 98 | args := append([]string{"cli"}, os.Args[1:]...) 99 | RootCmd.SetArgs(args) 100 | cli = true 101 | } 102 | 103 | // Always be quiet in CLI mode 104 | if cli { 105 | viper.Set("quiet", true) 106 | } 107 | 108 | viper.Set("version", version) 109 | viper.Set("commit", commit) 110 | 111 | if err := RootCmd.Execute(); err != nil { 112 | slog.Error(err.Error()) 113 | os.Exit(1) 114 | } 115 | } 116 | 117 | func init() { 118 | RootCmd.AddCommand(cliCmd) 119 | RootCmd.AddCommand(applyCmd) 120 | RootCmd.AddCommand(versionCmd) 121 | RootCmd.AddCommand(searchCmd) 122 | RootCmd.AddCommand(profile.Cmd) 123 | RootCmd.AddCommand(installation.Cmd) 124 | RootCmd.AddCommand(mod.Cmd) 125 | RootCmd.AddCommand(smr.Cmd) 126 | 127 | var baseLocalDir string 128 | 129 | switch runtime.GOOS { 130 | case "windows": 131 | baseLocalDir = os.Getenv("APPDATA") 132 | case "linux": 133 | baseLocalDir = filepath.Join(os.Getenv("HOME"), ".local", "share") 134 | case "darwin": 135 | baseLocalDir = filepath.Join(os.Getenv("HOME"), "Library", "Application Support") 136 | default: 137 | panic("unsupported platform: " + runtime.GOOS) 138 | } 139 | 140 | viper.Set("base-local-dir", baseLocalDir) 141 | 142 | baseCacheDir, err := os.UserCacheDir() 143 | if err != nil { 144 | panic(err) 145 | } 146 | 147 | RootCmd.PersistentFlags().String("log", "info", "The log level to output") 148 | RootCmd.PersistentFlags().String("log-file", "", "File to output logs to") 149 | RootCmd.PersistentFlags().Bool("quiet", false, "Do not log anything to console") 150 | RootCmd.PersistentFlags().Bool("pretty", true, "Whether to render pretty terminal output") 151 | 152 | RootCmd.PersistentFlags().Bool("dry-run", false, "Dry-run. Do not save any changes") 153 | 154 | RootCmd.PersistentFlags().String("cache-dir", filepath.Clean(filepath.Join(baseCacheDir, "ficsit")), "The cache directory") 155 | RootCmd.PersistentFlags().String("local-dir", filepath.Clean(filepath.Join(baseLocalDir, "ficsit")), "The local directory") 156 | RootCmd.PersistentFlags().String("profiles-file", "profiles.json", "The profiles file") 157 | RootCmd.PersistentFlags().String("installations-file", "installations.json", "The installations file") 158 | 159 | RootCmd.PersistentFlags().String("api-base", "https://api.ficsit.app", "URL for API") 160 | RootCmd.PersistentFlags().String("graphql-api", "/v2/query", "Path for GraphQL API") 161 | RootCmd.PersistentFlags().String("api-key", "", "API key to use when sending requests") 162 | 163 | RootCmd.PersistentFlags().Bool("offline", false, "Whether to only use local data") 164 | RootCmd.PersistentFlags().Int("concurrent-downloads", 5, "Maximum number of concurrent downloads") 165 | 166 | _ = viper.BindPFlag("log", RootCmd.PersistentFlags().Lookup("log")) 167 | _ = viper.BindPFlag("log-file", RootCmd.PersistentFlags().Lookup("log-file")) 168 | _ = viper.BindPFlag("quiet", RootCmd.PersistentFlags().Lookup("quiet")) 169 | _ = viper.BindPFlag("pretty", RootCmd.PersistentFlags().Lookup("pretty")) 170 | 171 | _ = viper.BindPFlag("dry-run", RootCmd.PersistentFlags().Lookup("dry-run")) 172 | 173 | _ = viper.BindPFlag("cache-dir", RootCmd.PersistentFlags().Lookup("cache-dir")) 174 | _ = viper.BindPFlag("local-dir", RootCmd.PersistentFlags().Lookup("local-dir")) 175 | _ = viper.BindPFlag("profiles-file", RootCmd.PersistentFlags().Lookup("profiles-file")) 176 | _ = viper.BindPFlag("installations-file", RootCmd.PersistentFlags().Lookup("installations-file")) 177 | 178 | _ = viper.BindPFlag("api-base", RootCmd.PersistentFlags().Lookup("api-base")) 179 | _ = viper.BindPFlag("graphql-api", RootCmd.PersistentFlags().Lookup("graphql-api")) 180 | _ = viper.BindPFlag("api-key", RootCmd.PersistentFlags().Lookup("api-key")) 181 | 182 | _ = viper.BindPFlag("offline", RootCmd.PersistentFlags().Lookup("offline")) 183 | _ = viper.BindPFlag("concurrent-downloads", RootCmd.PersistentFlags().Lookup("concurrent-downloads")) 184 | } 185 | -------------------------------------------------------------------------------- /cli/test_helpers.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | resolver "github.com/satisfactorymodding/ficsit-resolver" 8 | 9 | "github.com/satisfactorymodding/ficsit-cli/cli/provider" 10 | "github.com/satisfactorymodding/ficsit-cli/ficsit" 11 | ) 12 | 13 | var _ provider.Provider = (*MockProvider)(nil) 14 | 15 | type MockProvider struct { 16 | resolver.MockProvider 17 | } 18 | 19 | func (m MockProvider) Mods(_ context.Context, f ficsit.ModFilter) (*ficsit.ModsResponse, error) { 20 | if f.Offset > 0 { 21 | return &ficsit.ModsResponse{ 22 | Mods: ficsit.ModsModsGetMods{ 23 | Count: 5, 24 | Mods: []ficsit.ModsModsGetModsModsMod{}, 25 | }, 26 | }, nil 27 | } 28 | 29 | return &ficsit.ModsResponse{ 30 | Mods: ficsit.ModsModsGetMods{ 31 | Count: 5, 32 | Mods: []ficsit.ModsModsGetModsModsMod{ 33 | { 34 | Id: "9LguyCdDUrpT9N", 35 | Name: "Ficsit Remote Monitoring", 36 | Mod_reference: "FicsitRemoteMonitoring", 37 | Last_version_date: time.Now(), 38 | Created_at: time.Now(), 39 | }, 40 | { 41 | Id: "DGiLzB3ZErWu2V", 42 | Name: "Refined Power", 43 | Mod_reference: "RefinedPower", 44 | Last_version_date: time.Now(), 45 | Created_at: time.Now(), 46 | }, 47 | { 48 | Id: "B24emzbs6xVZQr", 49 | Name: "RefinedRDLib", 50 | Mod_reference: "RefinedRDLib", 51 | Last_version_date: time.Now(), 52 | Created_at: time.Now(), 53 | }, 54 | { 55 | Id: "6vQ6ckVYFiidDh", 56 | Name: "Area Actions", 57 | Mod_reference: "AreaActions", 58 | Last_version_date: time.Now(), 59 | Created_at: time.Now(), 60 | }, 61 | { 62 | Id: "As2uJmQLLxjXLG", 63 | Name: "ModularUI", 64 | Mod_reference: "ModularUI", 65 | Last_version_date: time.Now(), 66 | Created_at: time.Now(), 67 | }, 68 | }, 69 | }, 70 | }, nil 71 | } 72 | 73 | var windowsTarget = resolver.Target{ 74 | TargetName: "Windows", 75 | Link: "https://api.ficsit.dev/v1/version/7QcfNdo5QAAyoC/Windows/download", 76 | Hash: "698df20278b3de3ec30405569a22050c6721cc682389312258c14948bd8f38ae", 77 | } 78 | 79 | var windowsServerTarget = resolver.Target{ 80 | TargetName: "WindowsServer", 81 | Link: "https://api.ficsit.dev/v1/version/7QcfNdo5QAAyoC/WindowsServer/download", 82 | Hash: "7be01ed372e0cf3287a04f5cb32bb9dcf6f6e7a5b7603b7e43669ec4c6c1457f", 83 | } 84 | 85 | var linuxServerTarget = resolver.Target{ 86 | TargetName: "LinuxServer", 87 | Link: "https://api.ficsit.dev/v1/version/7QcfNdo5QAAyoC/LinuxServer/download", 88 | Hash: "bdbd4cb1b472a5316621939ae2fe270fd0e3c0f0a75666a9cbe74ff1313c3663", 89 | } 90 | 91 | var commonTargets = []resolver.Target{ 92 | windowsTarget, 93 | windowsServerTarget, 94 | linuxServerTarget, 95 | } 96 | 97 | func (m MockProvider) ModVersionsWithDependencies(ctx context.Context, modID string) ([]resolver.ModVersion, error) { 98 | switch modID { 99 | case "AreaActions": 100 | return []resolver.ModVersion{ 101 | { 102 | Version: "1.6.7", 103 | Dependencies: []resolver.Dependency{ 104 | { 105 | ModID: "SML", 106 | Condition: "^3.4.1", 107 | Optional: false, 108 | }, 109 | }, 110 | Targets: commonTargets, 111 | RequiredOnRemote: true, 112 | }, 113 | { 114 | Version: "1.6.6", 115 | Dependencies: []resolver.Dependency{ 116 | { 117 | ModID: "SML", 118 | Condition: "^3.2.0", 119 | Optional: false, 120 | }, 121 | }, 122 | Targets: commonTargets, 123 | RequiredOnRemote: true, 124 | }, 125 | { 126 | Version: "1.6.5", 127 | Dependencies: []resolver.Dependency{ 128 | { 129 | ModID: "SML", 130 | Condition: "^3.0.0", 131 | Optional: false, 132 | }, 133 | }, 134 | Targets: commonTargets, 135 | RequiredOnRemote: true, 136 | }, 137 | }, nil 138 | case "FicsitRemoteMonitoring": 139 | return []resolver.ModVersion{ 140 | { 141 | Version: "0.10.1", 142 | Dependencies: []resolver.Dependency{ 143 | { 144 | ModID: "SML", 145 | Condition: "^3.6.0", 146 | Optional: false, 147 | }, 148 | }, 149 | Targets: commonTargets, 150 | RequiredOnRemote: true, 151 | }, 152 | { 153 | Version: "0.10.0", 154 | Dependencies: []resolver.Dependency{ 155 | { 156 | ModID: "SML", 157 | Condition: "^3.5.0", 158 | Optional: false, 159 | }, 160 | }, 161 | Targets: commonTargets, 162 | RequiredOnRemote: true, 163 | }, 164 | { 165 | Version: "0.9.8", 166 | Dependencies: []resolver.Dependency{ 167 | { 168 | ModID: "SML", 169 | Condition: "^3.4.1", 170 | Optional: false, 171 | }, 172 | }, 173 | Targets: commonTargets, 174 | RequiredOnRemote: true, 175 | }, 176 | }, nil 177 | case "ClientOnlyMod": 178 | return []resolver.ModVersion{ 179 | { 180 | Version: "0.0.1", 181 | Dependencies: []resolver.Dependency{ 182 | { 183 | ModID: "SML", 184 | Condition: "^3.6.0", 185 | Optional: false, 186 | }, 187 | }, 188 | Targets: []resolver.Target{ 189 | windowsTarget, 190 | }, 191 | RequiredOnRemote: false, 192 | }, 193 | }, nil 194 | case "ServerOnlyMod": 195 | return []resolver.ModVersion{ 196 | { 197 | Version: "0.0.1", 198 | Dependencies: []resolver.Dependency{ 199 | { 200 | ModID: "SML", 201 | Condition: "^3.6.0", 202 | Optional: false, 203 | }, 204 | }, 205 | Targets: []resolver.Target{ 206 | windowsServerTarget, 207 | linuxServerTarget, 208 | }, 209 | RequiredOnRemote: false, 210 | }, 211 | }, nil 212 | case "LaterClientOnlyMod": 213 | return []resolver.ModVersion{ 214 | { 215 | Version: "0.0.1", 216 | Dependencies: []resolver.Dependency{ 217 | { 218 | ModID: "SML", 219 | Condition: "^3.6.0", 220 | Optional: false, 221 | }, 222 | }, 223 | Targets: commonTargets, 224 | RequiredOnRemote: true, 225 | }, 226 | { 227 | Version: "0.0.2", 228 | Dependencies: []resolver.Dependency{ 229 | { 230 | ModID: "SML", 231 | Condition: "^3.6.0", 232 | Optional: false, 233 | }, 234 | }, 235 | Targets: []resolver.Target{ 236 | windowsTarget, 237 | }, 238 | RequiredOnRemote: false, 239 | }, 240 | }, nil 241 | } 242 | 243 | return m.MockProvider.ModVersionsWithDependencies(ctx, modID) // nolint 244 | } 245 | 246 | func (m MockProvider) GetMod(_ context.Context, _ string) (*ficsit.GetModResponse, error) { 247 | // Currently used only by TUI 248 | return nil, nil 249 | } 250 | 251 | func (m MockProvider) IsOffline() bool { 252 | return false 253 | } 254 | --------------------------------------------------------------------------------