diff --git a/commands/service_profile_create.go b/commands/service_profile_create.go new file mode 100644 index 00000000000..321081cd27e --- /dev/null +++ b/commands/service_profile_create.go @@ -0,0 +1,99 @@ +// This file is part of arduino-cli. +// +// Copyright 2025 ARDUINO SA (http://www.arduino.cc/) +// +// This software is released under the GNU General Public License version 3, +// which covers the main part of arduino-cli. +// The terms of this license can be found at: +// https://www.gnu.org/licenses/gpl-3.0.en.html +// +// You can be released from the requirements of the above licenses by purchasing +// a commercial license. Buying such a license is mandatory if you want to +// modify or otherwise use the software for commercial activities involving the +// Arduino software without disclosing the source code of your own applications. +// To purchase a commercial license, send an email to license@arduino.cc. + +package commands + +import ( + "context" + "errors" + "fmt" + + "github.com/arduino/arduino-cli/commands/cmderrors" + "github.com/arduino/arduino-cli/commands/internal/instances" + "github.com/arduino/arduino-cli/internal/arduino/sketch" + "github.com/arduino/arduino-cli/internal/i18n" + "github.com/arduino/arduino-cli/pkg/fqbn" + rpc "github.com/arduino/arduino-cli/rpc/cc/arduino/cli/commands/v1" + "github.com/arduino/go-paths-helper" +) + +// ProfileCreate creates a new project file if it does not exist. If a profile name with the associated FQBN is specified, +// it is added to the project. +func (s *arduinoCoreServerImpl) ProfileCreate(ctx context.Context, req *rpc.ProfileCreateRequest) (*rpc.ProfileCreateResponse, error) { + if req.GetProfileName() == "" { + return nil, &cmderrors.MissingProfileError{} + } + if req.GetFqbn() == "" { + return nil, &cmderrors.MissingFQBNError{} + } + + // Returns an error if the main file is missing from the sketch so there is no need to check if the path exists + sk, err := sketch.New(paths.New(req.GetSketchPath())) + if err != nil { + return nil, err + } + + fqbn, err := fqbn.Parse(req.GetFqbn()) + if err != nil { + return nil, &cmderrors.InvalidFQBNError{Cause: err} + } + + // Check that the profile name is unique + if profile, _ := sk.GetProfile(req.ProfileName); profile != nil { + return nil, &cmderrors.ProfileAlreadyExitsError{Profile: req.ProfileName} + } + + pme, release, err := instances.GetPackageManagerExplorer(req.GetInstance()) + if err != nil { + return nil, err + } + defer release() + if pme.Dirty() { + return nil, &cmderrors.InstanceNeedsReinitialization{} + } + + // Automatically detect the target platform if it is installed on the user's machine + _, targetPlatform, _, _, _, err := pme.ResolveFQBN(fqbn) + if err != nil { + if targetPlatform == nil { + return nil, &cmderrors.PlatformNotFoundError{ + Platform: fmt.Sprintf("%s:%s", fqbn.Vendor, fqbn.Architecture), + Cause: errors.New(i18n.Tr("platform not installed")), + } + } + return nil, &cmderrors.InvalidFQBNError{Cause: err} + } + + newProfile := &sketch.Profile{Name: req.GetProfileName(), FQBN: req.GetFqbn()} + // TODO: what to do with the PlatformIndexURL? + newProfile.Platforms = append(newProfile.Platforms, &sketch.ProfilePlatformReference{ + Packager: targetPlatform.Platform.Package.Name, + Architecture: targetPlatform.Platform.Architecture, + Version: targetPlatform.Version, + }) + + sk.Project.Profiles = append(sk.Project.Profiles, newProfile) + if req.DefaultProfile { + sk.Project.DefaultProfile = newProfile.Name + } + + projectFilePath := sk.GetProjectPath() + err = projectFilePath.WriteFile([]byte(sk.Project.AsYaml())) + if err != nil { + return nil, err + } + + return &rpc.ProfileCreateResponse{}, nil +} diff --git a/commands/service_profile_init.go b/commands/service_profile_init.go deleted file mode 100644 index ae3d9e9f6e1..00000000000 --- a/commands/service_profile_init.go +++ /dev/null @@ -1,104 +0,0 @@ -// This file is part of arduino-cli. -// -// Copyright 2025 ARDUINO SA (http://www.arduino.cc/) -// -// This software is released under the GNU General Public License version 3, -// which covers the main part of arduino-cli. -// The terms of this license can be found at: -// https://www.gnu.org/licenses/gpl-3.0.en.html -// -// You can be released from the requirements of the above licenses by purchasing -// a commercial license. Buying such a license is mandatory if you want to -// modify or otherwise use the software for commercial activities involving the -// Arduino software without disclosing the source code of your own applications. -// To purchase a commercial license, send an email to license@arduino.cc. - -package commands - -import ( - "context" - "errors" - "fmt" - - "github.com/arduino/arduino-cli/commands/cmderrors" - "github.com/arduino/arduino-cli/commands/internal/instances" - "github.com/arduino/arduino-cli/internal/arduino/sketch" - "github.com/arduino/arduino-cli/internal/i18n" - "github.com/arduino/arduino-cli/pkg/fqbn" - rpc "github.com/arduino/arduino-cli/rpc/cc/arduino/cli/commands/v1" - "github.com/arduino/go-paths-helper" -) - -// ProfileCreate creates a new project file if it does not exist. If a profile name with the associated FQBN is specified, -// it is added to the project. -func (s *arduinoCoreServerImpl) ProfileCreate(ctx context.Context, req *rpc.ProfileCreateRequest) (*rpc.ProfileCreateResponse, error) { - // Returns an error if the main file is missing from the sketch so there is no need to check if the path exists - sk, err := sketch.New(paths.New(req.GetSketchPath())) - if err != nil { - return nil, err - } - projectFilePath := sk.GetProjectPath() - - if !projectFilePath.Exist() { - err := projectFilePath.WriteFile([]byte("profiles: {}\n")) - if err != nil { - return nil, err - } - } - - if req.GetProfileName() != "" { - if req.GetFqbn() == "" { - return nil, &cmderrors.MissingFQBNError{} - } - fqbn, err := fqbn.Parse(req.GetFqbn()) - if err != nil { - return nil, &cmderrors.InvalidFQBNError{Cause: err} - } - - // Check that the profile name is unique - if profile, _ := sk.GetProfile(req.ProfileName); profile != nil { - return nil, &cmderrors.ProfileAlreadyExitsError{Profile: req.ProfileName} - } - - pme, release, err := instances.GetPackageManagerExplorer(req.GetInstance()) - if err != nil { - return nil, err - } - defer release() - if pme.Dirty() { - return nil, &cmderrors.InstanceNeedsReinitialization{} - } - - // Automatically detect the target platform if it is installed on the user's machine - _, targetPlatform, _, _, _, err := pme.ResolveFQBN(fqbn) - if err != nil { - if targetPlatform == nil { - return nil, &cmderrors.PlatformNotFoundError{ - Platform: fmt.Sprintf("%s:%s", fqbn.Vendor, fqbn.Architecture), - Cause: errors.New(i18n.Tr("platform not installed")), - } - } - return nil, &cmderrors.InvalidFQBNError{Cause: err} - } - - newProfile := &sketch.Profile{Name: req.GetProfileName(), FQBN: req.GetFqbn()} - // TODO: what to do with the PlatformIndexURL? - newProfile.Platforms = append(newProfile.Platforms, &sketch.ProfilePlatformReference{ - Packager: targetPlatform.Platform.Package.Name, - Architecture: targetPlatform.Platform.Architecture, - Version: targetPlatform.Version, - }) - - sk.Project.Profiles = append(sk.Project.Profiles, newProfile) - if req.DefaultProfile { - sk.Project.DefaultProfile = newProfile.Name - } - - err = projectFilePath.WriteFile([]byte(sk.Project.AsYaml())) - if err != nil { - return nil, err - } - } - - return &rpc.ProfileCreateResponse{}, nil -} diff --git a/internal/cli/arguments/completion.go b/internal/cli/arguments/completion.go index 9b6678fe9ac..902b26ffb73 100644 --- a/internal/cli/arguments/completion.go +++ b/internal/cli/arguments/completion.go @@ -20,6 +20,7 @@ import ( "github.com/arduino/arduino-cli/internal/cli/instance" rpc "github.com/arduino/arduino-cli/rpc/cc/arduino/cli/commands/v1" + "github.com/arduino/go-paths-helper" "go.bug.st/f" ) @@ -172,3 +173,19 @@ func GetAvailablePorts(ctx context.Context, srv rpc.ArduinoCoreServiceServer) [] // Transform the data structure for the completion (DetectedPort -> Port) return f.Map(list.GetPorts(), (*rpc.DetectedPort).GetPort) } + +// GetProfileLibraries is an helper function useful to autocomplete. +// It returns a list of libraries present in the specified profile. +func GetProfileLibraries(ctx context.Context, srv rpc.ArduinoCoreServiceServer, sketchPath *paths.Path, profile string) []string { + resp, err := srv.ProfileLibList(ctx, &rpc.ProfileLibListRequest{ + SketchPath: sketchPath.String(), + ProfileName: profile, + }) + if err != nil { + return nil + } + res := f.Map(resp.GetLibraries(), func(lib *rpc.ProfileLibraryReference) string { + return lib.GetIndexLibrary().GetName() + }) + return res +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index eb5c381abf5..11744ca5bc5 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -36,6 +36,7 @@ import ( "github.com/arduino/arduino-cli/internal/cli/lib" "github.com/arduino/arduino-cli/internal/cli/monitor" "github.com/arduino/arduino-cli/internal/cli/outdated" + "github.com/arduino/arduino-cli/internal/cli/profile" "github.com/arduino/arduino-cli/internal/cli/sketch" "github.com/arduino/arduino-cli/internal/cli/update" "github.com/arduino/arduino-cli/internal/cli/updater" @@ -162,6 +163,7 @@ func NewCommand(srv rpc.ArduinoCoreServiceServer) *cobra.Command { cmd.AddCommand(burnbootloader.NewCommand(srv)) cmd.AddCommand(version.NewCommand(srv)) cmd.AddCommand(feedback.NewCommand()) + cmd.AddCommand(profile.NewCommand(srv)) cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, i18n.Tr("Print the logs on the standard output.")) cmd.Flag("verbose").Hidden = true diff --git a/internal/cli/feedback/result/rpc.go b/internal/cli/feedback/result/rpc.go index 9af38ca88db..31a87eb564d 100644 --- a/internal/cli/feedback/result/rpc.go +++ b/internal/cli/feedback/result/rpc.go @@ -1130,6 +1130,12 @@ type ProfileLibraryReference_LocalLibraryResult struct { Path string `json:"path,omitempty"` } +func (*ProfileLibraryReference_LocalLibraryResult) isProfileLibraryReference() {} + +func (l *ProfileLibraryReference_LocalLibraryResult) String() string { + return fmt.Sprintf("lib: %s", l.Path) +} + func NewProfileLibraryReference_LocalLibraryResult(resp *rpc.ProfileLibraryReference_LocalLibrary) *ProfileLibraryReference_LocalLibraryResult { return &ProfileLibraryReference_LocalLibraryResult{ Path: resp.GetPath(), @@ -1142,6 +1148,15 @@ type ProfileLibraryReference_IndexLibraryResult struct { IsDependency bool `json:"is_dependency,omitempty"` } +func (*ProfileLibraryReference_IndexLibraryResult) isProfileLibraryReference() {} + +func (l *ProfileLibraryReference_IndexLibraryResult) String() string { + if l.IsDependency { + return fmt.Sprintf("dependency: %s@%s", l.Name, l.Version) + } + return fmt.Sprintf("%s@%s", l.Name, l.Version) +} + func NewProfileLibraryReference_IndexLibraryResult(resp *rpc.ProfileLibraryReference_IndexLibrary) *ProfileLibraryReference_IndexLibraryResult { return &ProfileLibraryReference_IndexLibraryResult{ Name: resp.GetName(), @@ -1149,3 +1164,33 @@ func NewProfileLibraryReference_IndexLibraryResult(resp *rpc.ProfileLibraryRefer IsDependency: resp.GetIsDependency(), } } + +type ProfileLibraryReference struct { + Kind string `json:"kind,omitempty"` + Library ProfileLibraryReference_Library `json:"library,omitempty"` +} + +type ProfileLibraryReference_Library interface { + isProfileLibraryReference() + fmt.Stringer +} + +func NewProfileLibraryReference(resp *rpc.ProfileLibraryReference) *ProfileLibraryReference { + if lib := resp.GetIndexLibrary(); lib != nil { + return &ProfileLibraryReference{ + Library: NewProfileLibraryReference_IndexLibraryResult(lib), + Kind: "index", + } + } + if lib := resp.GetLocalLibrary(); lib != nil { + return &ProfileLibraryReference{ + Library: NewProfileLibraryReference_LocalLibraryResult(lib), + Kind: "local", + } + } + return nil +} + +func (p *ProfileLibraryReference) String() string { + return p.Library.String() +} diff --git a/internal/cli/profile/profile.go b/internal/cli/profile/profile.go new file mode 100644 index 00000000000..2542b0fe0a2 --- /dev/null +++ b/internal/cli/profile/profile.go @@ -0,0 +1,38 @@ +// This file is part of arduino-cli. +// +// Copyright 2025 ARDUINO SA (http://www.arduino.cc/) +// +// This software is released under the GNU General Public License version 3, +// which covers the main part of arduino-cli. +// The terms of this license can be found at: +// https://www.gnu.org/licenses/gpl-3.0.en.html +// +// You can be released from the requirements of the above licenses by purchasing +// a commercial license. Buying such a license is mandatory if you want to +// modify or otherwise use the software for commercial activities involving the +// Arduino software without disclosing the source code of your own applications. +// To purchase a commercial license, send an email to license@arduino.cc. + +package profile + +import ( + "os" + + "github.com/arduino/arduino-cli/internal/i18n" + rpc "github.com/arduino/arduino-cli/rpc/cc/arduino/cli/commands/v1" + "github.com/spf13/cobra" +) + +func NewCommand(srv rpc.ArduinoCoreServiceServer) *cobra.Command { + profileCommand := &cobra.Command{ + Use: "profile", + Short: i18n.Tr("Build profile operations."), + Long: i18n.Tr("Build profile operations."), + Example: " " + os.Args[0] + " profile init", + } + + profileCommand.AddCommand(initProfileCreateCommand(srv)) + profileCommand.AddCommand(initProfileLibCommand(srv)) + profileCommand.AddCommand(initProfileSetDefaultCommand(srv)) + return profileCommand +} diff --git a/internal/cli/profile/profile_create.go b/internal/cli/profile/profile_create.go new file mode 100644 index 00000000000..3457f0818af --- /dev/null +++ b/internal/cli/profile/profile_create.go @@ -0,0 +1,86 @@ +// This file is part of arduino-cli. +// +// Copyright 2025 ARDUINO SA (http://www.arduino.cc/) +// +// This software is released under the GNU General Public License version 3, +// which covers the main part of arduino-cli. +// The terms of this license can be found at: +// https://www.gnu.org/licenses/gpl-3.0.en.html +// +// You can be released from the requirements of the above licenses by purchasing +// a commercial license. Buying such a license is mandatory if you want to +// modify or otherwise use the software for commercial activities involving the +// Arduino software without disclosing the source code of your own applications. +// To purchase a commercial license, send an email to license@arduino.cc. + +package profile + +import ( + "context" + "os" + + "github.com/arduino/arduino-cli/internal/cli/arguments" + "github.com/arduino/arduino-cli/internal/cli/feedback" + "github.com/arduino/arduino-cli/internal/cli/instance" + "github.com/arduino/arduino-cli/internal/i18n" + rpc "github.com/arduino/arduino-cli/rpc/cc/arduino/cli/commands/v1" + "github.com/arduino/go-paths-helper" + "github.com/spf13/cobra" +) + +func initProfileCreateCommand(srv rpc.ArduinoCoreServiceServer) *cobra.Command { + var setAsDefault bool + var fqbnArg arguments.Fqbn + var profileArg arguments.Profile + createCommand := &cobra.Command{ + Use: "create --profile --fqbn [flags] []", + Short: i18n.Tr("Create or update a profile in the sketch project file."), + Example: "" + + " # " + i18n.Tr("Creates or updates the sketch project file in the current directory.") + "\n" + + " " + os.Args[0] + " profile create -m uno -b arduino:avr:uno", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + runProfileCreateCommand(cmd.Context(), args, srv, profileArg.Get(), fqbnArg.String(), setAsDefault) + }, + } + fqbnArg.AddToCommand(createCommand, srv) + profileArg.AddToCommand(createCommand, srv) + createCommand.MarkFlagRequired("profile") + createCommand.MarkFlagRequired("fqbn") + createCommand.Flags().BoolVar(&setAsDefault, "set-default", false, i18n.Tr("Set the profile as the default one.")) + return createCommand +} + +func runProfileCreateCommand(ctx context.Context, args []string, srv rpc.ArduinoCoreServiceServer, profile, fqbn string, setAsDefault bool) { + path := "" + if len(args) > 0 { + path = args[0] + } + + sketchPath := arguments.InitSketchPath(path) + + inst := instance.CreateAndInit(ctx, srv) + + _, err := srv.ProfileCreate(ctx, &rpc.ProfileCreateRequest{ + Instance: inst, + SketchPath: sketchPath.String(), + ProfileName: profile, + Fqbn: fqbn, + DefaultProfile: setAsDefault}) + if err != nil { + feedback.Fatal(i18n.Tr("Error initializing the project file: %v", err), feedback.ErrGeneric) + } + feedback.PrintResult(profileResult{ProjectFilePath: sketchPath.Join("sketch.yaml")}) +} + +type profileResult struct { + ProjectFilePath *paths.Path `json:"project_path"` +} + +func (ir profileResult) Data() any { + return ir +} + +func (ir profileResult) String() string { + return i18n.Tr("Project file created in: %s", ir.ProjectFilePath) +} diff --git a/internal/cli/profile/profile_lib.go b/internal/cli/profile/profile_lib.go new file mode 100644 index 00000000000..74e7d6ca242 --- /dev/null +++ b/internal/cli/profile/profile_lib.go @@ -0,0 +1,37 @@ +// This file is part of arduino-cli. +// +// Copyright 2025 ARDUINO SA (http://www.arduino.cc/) +// +// This software is released under the GNU General Public License version 3, +// which covers the main part of arduino-cli. +// The terms of this license can be found at: +// https://www.gnu.org/licenses/gpl-3.0.en.html +// +// You can be released from the requirements of the above licenses by purchasing +// a commercial license. Buying such a license is mandatory if you want to +// modify or otherwise use the software for commercial activities involving the +// Arduino software without disclosing the source code of your own applications. +// To purchase a commercial license, send an email to license@arduino.cc. + +package profile + +import ( + "os" + + "github.com/arduino/arduino-cli/internal/i18n" + rpc "github.com/arduino/arduino-cli/rpc/cc/arduino/cli/commands/v1" + "github.com/spf13/cobra" +) + +func initProfileLibCommand(srv rpc.ArduinoCoreServiceServer) *cobra.Command { + libCommand := &cobra.Command{ + Use: "lib", + Short: i18n.Tr("Commands to manage libraries in sketch profiles."), + Example: "" + + " " + os.Args[0] + " profile lib add AudioZero -m my_profile\n" + + " " + os.Args[0] + " profile lib remove Arduino_JSON --profile my_profile\n", + } + libCommand.AddCommand(initLibAddCommand(srv)) + libCommand.AddCommand(initLibRemoveCommand(srv)) + return libCommand +} diff --git a/internal/cli/profile/profile_lib_add.go b/internal/cli/profile/profile_lib_add.go new file mode 100644 index 00000000000..5687dfc0a87 --- /dev/null +++ b/internal/cli/profile/profile_lib_add.go @@ -0,0 +1,121 @@ +// This file is part of arduino-cli. +// +// Copyright 2025 ARDUINO SA (http://www.arduino.cc/) +// +// This software is released under the GNU General Public License version 3, +// which covers the main part of arduino-cli. +// The terms of this license can be found at: +// https://www.gnu.org/licenses/gpl-3.0.en.html +// +// You can be released from the requirements of the above licenses by purchasing +// a commercial license. Buying such a license is mandatory if you want to +// modify or otherwise use the software for commercial activities involving the +// Arduino software without disclosing the source code of your own applications. +// To purchase a commercial license, send an email to license@arduino.cc. + +package profile + +import ( + "context" + "fmt" + "os" + + "github.com/arduino/arduino-cli/internal/cli/arguments" + "github.com/arduino/arduino-cli/internal/cli/feedback" + "github.com/arduino/arduino-cli/internal/cli/feedback/result" + "github.com/arduino/arduino-cli/internal/cli/instance" + "github.com/arduino/arduino-cli/internal/cli/lib" + "github.com/arduino/arduino-cli/internal/i18n" + rpc "github.com/arduino/arduino-cli/rpc/cc/arduino/cli/commands/v1" + "github.com/spf13/cobra" + "go.bug.st/f" +) + +func initLibAddCommand(srv rpc.ArduinoCoreServiceServer) *cobra.Command { + var sketchDir string + var noDeps bool + var noOverwrite bool + var profileArg arguments.Profile + addCommand := &cobra.Command{ + Use: fmt.Sprintf("add %s[@%s]...", i18n.Tr("LIBRARY"), i18n.Tr("VERSION_NUMBER")), + Short: i18n.Tr("Adds a library to a sketch profile."), + Example: "" + + " " + os.Args[0] + " profile lib add AudioZero -m my_profile\n" + + " " + os.Args[0] + " profile lib add Arduino_JSON@0.2.0 --profile my_profile\n", + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + runLibAddCommand(cmd.Context(), args, srv, profileArg.Get(), sketchDir, noDeps, noOverwrite) + }, + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return arguments.GetInstallableLibs(cmd.Context(), srv), cobra.ShellCompDirectiveDefault + }, + } + profileArg.AddToCommand(addCommand, srv) + addCommand.Flags().StringVar(&sketchDir, "sketch-path", "", i18n.Tr("Location of the sketch.")) + addCommand.Flags().BoolVar(&noDeps, "no-deps", false, i18n.Tr("Do not add dependencies.")) + addCommand.Flags().BoolVar(&noOverwrite, "no-overwrite", false, i18n.Tr("Do not overwrite already added libraries.")) + return addCommand +} + +func runLibAddCommand(ctx context.Context, args []string, srv rpc.ArduinoCoreServiceServer, profile, sketchDir string, noAddDeps, noOverwrite bool) { + sketchPath := arguments.InitSketchPath(sketchDir) + + instance := instance.CreateAndInit(ctx, srv) + libRefs, err := lib.ParseLibraryReferenceArgsAndAdjustCase(ctx, srv, instance, args) + if err != nil { + feedback.Fatal(i18n.Tr("Arguments error: %v", err), feedback.ErrBadArgument) + } + addDeps := !noAddDeps + for _, lib := range libRefs { + resp, err := srv.ProfileLibAdd(ctx, &rpc.ProfileLibAddRequest{ + Instance: instance, + SketchPath: sketchPath.String(), + ProfileName: profile, + Library: &rpc.ProfileLibraryReference{ + Library: &rpc.ProfileLibraryReference_IndexLibrary_{ + IndexLibrary: &rpc.ProfileLibraryReference_IndexLibrary{ + Name: lib.Name, + Version: lib.Version, + }, + }, + }, + AddDependencies: &addDeps, + NoOverwrite: &noOverwrite, + }) + if err != nil { + feedback.Fatal(i18n.Tr("Error adding %s: %v", lib.Name, err), feedback.ErrGeneric) + } + feedback.PrintResult(libAddResult{ + AddedLibraries: f.Map(resp.GetAddedLibraries(), result.NewProfileLibraryReference), + SkippedLibraries: f.Map(resp.GetSkippedLibraries(), result.NewProfileLibraryReference), + ProfileName: resp.ProfileName, + }) + } +} + +type libAddResult struct { + AddedLibraries []*result.ProfileLibraryReference `json:"added_libraries"` + SkippedLibraries []*result.ProfileLibraryReference `json:"skipped_libraries"` + ProfileName string `json:"profile_name"` +} + +func (lr libAddResult) Data() any { + return lr +} + +func (lr libAddResult) String() string { + res := "" + if len(lr.AddedLibraries) > 0 { + res += fmt.Sprintln(i18n.Tr("The following libraries were added to the profile %s:", lr.ProfileName)) + for _, l := range lr.AddedLibraries { + res += fmt.Sprintf(" - %s\n", l) + } + } + if len(lr.SkippedLibraries) > 0 { + res += fmt.Sprintln(i18n.Tr("The following libraries were already present in the profile %s and were not modified:", lr.ProfileName)) + for _, l := range lr.SkippedLibraries { + res += fmt.Sprintf(" - %s\n", l) + } + } + return res +} diff --git a/internal/cli/profile/profile_lib_remove.go b/internal/cli/profile/profile_lib_remove.go new file mode 100644 index 00000000000..82cf41c8c71 --- /dev/null +++ b/internal/cli/profile/profile_lib_remove.go @@ -0,0 +1,110 @@ +// This file is part of arduino-cli. +// +// Copyright 2025 ARDUINO SA (http://www.arduino.cc/) +// +// This software is released under the GNU General Public License version 3, +// which covers the main part of arduino-cli. +// The terms of this license can be found at: +// https://www.gnu.org/licenses/gpl-3.0.en.html +// +// You can be released from the requirements of the above licenses by purchasing +// a commercial license. Buying such a license is mandatory if you want to +// modify or otherwise use the software for commercial activities involving the +// Arduino software without disclosing the source code of your own applications. +// To purchase a commercial license, send an email to license@arduino.cc. + +package profile + +import ( + "context" + "fmt" + "os" + + "github.com/arduino/arduino-cli/internal/cli/arguments" + "github.com/arduino/arduino-cli/internal/cli/feedback" + "github.com/arduino/arduino-cli/internal/cli/feedback/result" + "github.com/arduino/arduino-cli/internal/cli/instance" + "github.com/arduino/arduino-cli/internal/cli/lib" + "github.com/arduino/arduino-cli/internal/i18n" + rpc "github.com/arduino/arduino-cli/rpc/cc/arduino/cli/commands/v1" + "github.com/spf13/cobra" + "go.bug.st/f" +) + +func initLibRemoveCommand(srv rpc.ArduinoCoreServiceServer) *cobra.Command { + var sketchDir string + var profileArg arguments.Profile + var noDeps bool + removeCommand := &cobra.Command{ + Use: fmt.Sprintf("remove %s[@%s]...", i18n.Tr("LIBRARY"), i18n.Tr("VERSION_NUMBER")), + Short: i18n.Tr("Removes a library from a sketch profile with its dependencies if no longer needed."), + Example: "" + + " " + os.Args[0] + " profile lib remove AudioZero -m my_profile\n" + + " " + os.Args[0] + " profile lib remove Arduino_JSON@0.2.0 --profile my_profile\n", + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + runLibRemoveCommand(cmd.Context(), srv, args, profileArg.Get(), sketchDir, noDeps) + }, + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + ctx := cmd.Context() + sketchPath := arguments.InitSketchPath(sketchDir) + completions := arguments.GetProfileLibraries(ctx, srv, sketchPath, profileArg.Get()) + return completions, cobra.ShellCompDirectiveNoFileComp + }, + } + profileArg.AddToCommand(removeCommand, srv) + removeCommand.Flags().StringVar(&sketchDir, "sketch-path", "", i18n.Tr("Location of the sketch.")) + removeCommand.Flags().BoolVar(&noDeps, "no-deps", false, i18n.Tr("Do not remove unused dependencies.")) + return removeCommand +} + +func runLibRemoveCommand(ctx context.Context, srv rpc.ArduinoCoreServiceServer, args []string, profile, sketchDir string, noDeps bool) { + sketchPath := arguments.InitSketchPath(sketchDir) + + instance := instance.CreateAndInit(ctx, srv) + libRefs, err := lib.ParseLibraryReferenceArgsAndAdjustCase(ctx, srv, instance, args) + if err != nil { + feedback.Fatal(i18n.Tr("Arguments error: %v", err), feedback.ErrBadArgument) + } + removeDeps := !noDeps + for _, lib := range libRefs { + resp, err := srv.ProfileLibRemove(ctx, &rpc.ProfileLibRemoveRequest{ + Instance: instance, + SketchPath: sketchPath.String(), + ProfileName: profile, + Library: &rpc.ProfileLibraryReference{ + Library: &rpc.ProfileLibraryReference_IndexLibrary_{ + IndexLibrary: &rpc.ProfileLibraryReference_IndexLibrary{ + Name: lib.Name, + Version: lib.Version, + }, + }, + }, + RemoveDependencies: &removeDeps, + }) + if err != nil { + feedback.Fatal(fmt.Sprintf("%s: %v", + i18n.Tr("Error removing library %[1]s from the profile", lib.Name), err), feedback.ErrGeneric) + } + feedback.PrintResult(libRemoveResult{ + RemovedLibraries: f.Map(resp.GetRemovedLibraries(), result.NewProfileLibraryReference), + ProfileName: resp.ProfileName}) + } +} + +type libRemoveResult struct { + RemovedLibraries []*result.ProfileLibraryReference `json:"removed_libraries"` + ProfileName string `json:"profile_name"` +} + +func (lr libRemoveResult) Data() interface{} { + return lr +} + +func (lr libRemoveResult) String() string { + res := fmt.Sprintln(i18n.Tr("The following libraries were removed from the profile %s:", lr.ProfileName)) + for _, lib := range lr.RemovedLibraries { + res += fmt.Sprintf(" - %s\n", lib) + } + return res +} diff --git a/internal/cli/profile/profile_set-default.go b/internal/cli/profile/profile_set-default.go new file mode 100644 index 00000000000..a49eeaa6125 --- /dev/null +++ b/internal/cli/profile/profile_set-default.go @@ -0,0 +1,69 @@ +// This file is part of arduino-cli. +// +// Copyright 2025 ARDUINO SA (http://www.arduino.cc/) +// +// This software is released under the GNU General Public License version 3, +// which covers the main part of arduino-cli. +// The terms of this license can be found at: +// https://www.gnu.org/licenses/gpl-3.0.en.html +// +// You can be released from the requirements of the above licenses by purchasing +// a commercial license. Buying such a license is mandatory if you want to +// modify or otherwise use the software for commercial activities involving the +// Arduino software without disclosing the source code of your own applications. +// To purchase a commercial license, send an email to license@arduino.cc. + +package profile + +import ( + "context" + "os" + + "github.com/arduino/arduino-cli/internal/cli/arguments" + "github.com/arduino/arduino-cli/internal/cli/feedback" + "github.com/arduino/arduino-cli/internal/i18n" + rpc "github.com/arduino/arduino-cli/rpc/cc/arduino/cli/commands/v1" + "github.com/spf13/cobra" +) + +func initProfileSetDefaultCommand(srv rpc.ArduinoCoreServiceServer) *cobra.Command { + var sketchDir string + setDefaultCommand := &cobra.Command{ + Use: "set-default", + Short: i18n.Tr("Set the default build profile."), + Long: i18n.Tr("Set the default build profile."), + Example: "" + + " " + os.Args[0] + " profile set-default my_profile\n", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + runSetDefaultCommand(cmd.Context(), args, srv, sketchDir) + }, + } + setDefaultCommand.Flags().StringVar(&sketchDir, "sketch-path", "", i18n.Tr("Location of the sketch.")) + return setDefaultCommand +} + +func runSetDefaultCommand(ctx context.Context, args []string, srv rpc.ArduinoCoreServiceServer, sketchDir string) { + profileName := args[0] + sketchPath := arguments.InitSketchPath(sketchDir) + + _, err := srv.ProfileSetDefault(ctx, &rpc.ProfileSetDefaultRequest{SketchPath: sketchPath.String(), ProfileName: profileName}) + if err != nil { + feedback.Fatal(i18n.Tr("Cannot set %s as default profile: %v", profileName, err), feedback.ErrGeneric) + } + feedback.PrintResult(&profileSetDefaultResult{ + DefaultProfile: profileName, + }) +} + +type profileSetDefaultResult struct { + DefaultProfile string `json:"default_profile"` +} + +func (r *profileSetDefaultResult) String() string { + return i18n.Tr("Default profile set to: %s", r.DefaultProfile) +} + +func (r *profileSetDefaultResult) Data() any { + return r +} diff --git a/internal/integrationtest/profiles/profiles_test.go b/internal/integrationtest/profiles/profiles_test.go index aafd914db2c..78a89ec23f1 100644 --- a/internal/integrationtest/profiles/profiles_test.go +++ b/internal/integrationtest/profiles/profiles_test.go @@ -19,6 +19,7 @@ import ( "testing" "github.com/arduino/arduino-cli/internal/integrationtest" + "github.com/arduino/go-paths-helper" "github.com/stretchr/testify/require" "go.bug.st/testifyjson/requirejson" ) @@ -150,3 +151,244 @@ func TestCompileWithDefaultProfile(t *testing.T) { jsonOut.Query(".builder_result.build_properties").MustContain(`[ "build.fqbn=arduino:avr:nano" ]`) } } + +func createTempSketch(t *testing.T, cli *integrationtest.ArduinoCLI, sketchName string) *paths.Path { + sketchDir := cli.SketchbookDir().Join(sketchName) + t.Cleanup(func() { require.NoError(t, sketchDir.RemoveAll()) }) + _, _, err := cli.Run("sketch", "new", sketchDir.String()) + require.NoError(t, err) + return sketchDir +} + +func TestProfileCreate(t *testing.T) { + env, cli := integrationtest.CreateArduinoCLIWithEnvironment(t) + defer env.CleanUp() + + // Init the environment explicitly + _, _, err := cli.Run("core", "update-index") + require.NoError(t, err) + _, _, err = cli.Run("core", "install", "arduino:avr@1.8.6") + require.NoError(t, err) + + t.Run("WithInvalidSketchDir", func(t *testing.T) { + invalidSketchDir := cli.SketchbookDir().Join("tempSketch") + + _, stderr, err := cli.Run("profile", "create", invalidSketchDir.String(), "-m", "test", "-b", "arduino:avr:uno") + require.Error(t, err) + require.Contains(t, string(stderr), "no such file or directory") + + require.NoError(t, invalidSketchDir.MkdirAll()) + t.Cleanup(func() { require.NoError(t, invalidSketchDir.RemoveAll()) }) + + _, stderr, err = cli.Run("profile", "create", invalidSketchDir.String(), "-m", "test", "-b", "arduino:avr:uno") + require.Error(t, err) + require.Contains(t, string(stderr), "main file missing from sketch") + }) + + t.Run("WithNotInstalledPlatform", func(t *testing.T) { + sketchDir := createTempSketch(t, cli, "TestSketch") + _, stderr, err := cli.Run("profile", "create", sketchDir.String(), "-m", "uno", "-b", "arduino:samd:zero") + require.Error(t, err) + require.Contains(t, string(stderr), "platform not installed") + }) + + t.Run("WithoutSketchYAML", func(t *testing.T) { + sketchDir := createTempSketch(t, cli, "TestSketch") + projectFile := sketchDir.Join("sketch.yaml") + + stdout, _, err := cli.Run("profile", "create", sketchDir.String(), "-m", "test", "-b", "arduino:avr:uno") + require.NoError(t, err) + require.Contains(t, string(stdout), "Project file created in: "+projectFile.String()) + require.FileExists(t, projectFile.String()) + fileContent, err := projectFile.ReadFile() + require.NoError(t, err) + require.Contains(t, string(fileContent), "profiles:\n test:\n") + + t.Run("AddNewProfile", func(t *testing.T) { + // Add a new profile + _, _, err := cli.Run("profile", "create", sketchDir.String(), "-m", "uno", "-b", "arduino:avr:uno") + require.NoError(t, err) + fileContent, err := projectFile.ReadFile() + require.NoError(t, err) + require.Contains(t, string(fileContent), " uno:\n fqbn: arduino:avr:uno\n platforms:\n - platform: arduino:avr (1.8.6)\n") + require.NotContains(t, string(fileContent), "default_profile: uno") + }) + + t.Run("AddAndSetDefaultProfile", func(t *testing.T) { + // Add a new profile and set it as default + _, _, err := cli.Run("profile", "create", sketchDir.String(), "-m", "leonardo", "-b", "arduino:avr:leonardo", "--set-default") + require.NoError(t, err) + fileContent, err := projectFile.ReadFile() + require.NoError(t, err) + require.Contains(t, string(fileContent), " leonardo:\n fqbn: arduino:avr:leonardo\n platforms:\n - platform: arduino:avr (1.8.6)\n") + require.Contains(t, string(fileContent), "default_profile: leonardo") + }) + + t.Run("WrongFQBN", func(t *testing.T) { + // Adding a profile with an incorrect FQBN should return an error + _, stderr, err := cli.Run("profile", "create", sketchDir.String(), "-m", "wrong_fqbn", "-b", "foo:bar") + require.Error(t, err) + require.Contains(t, string(stderr), "Invalid FQBN") + }) + + t.Run("MissingFQBN", func(t *testing.T) { + // Add a profile with no FQBN should return an error + _, _, err := cli.Run("profile", "create", sketchDir.String(), "-m", "Uno") + require.Error(t, err) + }) + + t.Run("AlreadyExistingProfile", func(t *testing.T) { + // Adding a profile with a name that already exists should return an error + _, stderr, err := cli.Run("profile", "create", sketchDir.String(), "-m", "uno", "-b", "arduino:avr:uno") + require.Error(t, err) + require.Contains(t, string(stderr), "Profile 'uno' already exists") + }) + }) +} + +func TestProfileLib(t *testing.T) { + env, cli := integrationtest.CreateArduinoCLIWithEnvironment(t) + defer env.CleanUp() + + // Init the environment explicitly + _, _, err := cli.Run("core", "update-index") + require.NoError(t, err) + _, _, err = cli.Run("core", "install", "arduino:avr") + require.NoError(t, err) + + t.Run("AddLibToDefaultProfile", func(t *testing.T) { + sk := createTempSketch(t, cli, "AddLibSketch") + + _, _, err = cli.Run("profile", "create", sk.String(), "-m", "uno", "-b", "arduino:avr:uno", "--set-default") + require.NoError(t, err) + + out, _, err := cli.Run("profile", "lib", "add", "Arduino_Modulino@0.7.0", "--sketch-path", sk.String(), "--json") + require.NoError(t, err) + requirejson.Parse(t, out).Query(".added_libraries").MustContain(` + [ + { "kind": "index", + "library": {"name": "Arduino_Modulino", "version": "0.7.0" } + } + ]`) + + fileContent, err := sk.Join("sketch.yaml").ReadFile() + require.NoError(t, err) + require.Contains(t, string(fileContent), " - Arduino_Modulino (0.7.0)\n") + // dependency added as well + require.Contains(t, string(fileContent), " - dependency: Arduino_LSM6DSOX (") + + t.Run("ChangeLibVersionToDefaultProfile", func(t *testing.T) { + out, _, err := cli.Run("profile", "lib", "add", "Arduino_Modulino@0.6.0", "--sketch-path", sk.String(), "--json") + require.NoError(t, err) + outjson := requirejson.Parse(t, out) + outjson.Query(".added_libraries").MustContain(` + [ + { "kind": "index", + "library": {"name": "Arduino_Modulino", "version": "0.6.0"} + } + ]`) + outjson.Query(".skipped_libraries").MustContain(` + [ + { "kind": "index", + "library": {"name":"Arduino_LSM6DSOX"} + } + ]`) + + fileContent, err := sk.Join("sketch.yaml").ReadFile() + require.NoError(t, err) + require.Contains(t, string(fileContent), " - Arduino_Modulino (0.6.0)\n") + }) + + t.Run("RemoveLibFromDefaultProfile", func(t *testing.T) { + _, _, err = cli.Run("profile", "lib", "remove", "Arduino_Modulino", "--sketch-path", sk.String()) + require.NoError(t, err) + fileContent, err := sk.Join("sketch.yaml").ReadFile() + require.NoError(t, err) + require.NotContains(t, string(fileContent), "Arduino_Modulino") + // dependency removed as well + require.NotContains(t, string(fileContent), "Arduino_LSM6DSOX") + }) + + t.Run("AddInexistentLibToDefaultProfile", func(t *testing.T) { + _, stderr, err := cli.Run("profile", "lib", "add", "foobar123", "--sketch-path", sk.String()) + require.Error(t, err) + require.Equal(t, "Error adding foobar123: Library 'foobar123@latest' not found\n", string(stderr)) + }) + + t.Run("RemoveLibNotInProfile", func(t *testing.T) { + _, stderr, err := cli.Run("profile", "lib", "remove", "Arduino_JSON", "--sketch-path", sk.String()) + require.Error(t, err) + require.Equal(t, "Error removing library Arduino_JSON from the profile: could not remove library: Library 'Arduino_JSON' not found\n", string(stderr)) + }) + }) + +} + +func TestProfileLibAddRemoveFromSpecificProfile(t *testing.T) { + env, cli := integrationtest.CreateArduinoCLIWithEnvironment(t) + defer env.CleanUp() + + // Init the environment explicitly + _, _, err := cli.Run("core", "update-index") + require.NoError(t, err) + _, _, err = cli.Run("core", "install", "arduino:avr") + require.NoError(t, err) + sk := createTempSketch(t, cli, "Simple") + + _, _, err = cli.Run("profile", "create", sk.String(), "-m", "uno", "-b", "arduino:avr:uno") + require.NoError(t, err) + // Add a second profile + _, _, err = cli.Run("profile", "create", sk.String(), "-m", "my_profile", "-b", "arduino:avr:uno") + require.NoError(t, err) + + // Add library to a specific profile + _, _, err = cli.Run("profile", "lib", "add", "Arduino_Modulino@0.7.0", "-m", "my_profile", "--sketch-path", sk.String(), "--no-deps") + require.NoError(t, err) + fileContent, err := sk.Join("sketch.yaml").ReadFile() + require.NoError(t, err) + require.Contains(t, string(fileContent), " my_profile:\n fqbn: arduino:avr:uno\n platforms:\n - platform: arduino:avr (1.8.6)\n libraries:\n - Arduino_Modulino (0.7.0)\n") + + // Remove library from a specific profile + _, _, err = cli.Run("profile", "lib", "remove", "Arduino_Modulino", "-m", "my_profile", "--sketch-path", sk.String()) + require.NoError(t, err) + fileContent, err = sk.Join("sketch.yaml").ReadFile() + require.NoError(t, err) + require.NotContains(t, string(fileContent), "Arduino_Modulino") +} + +func TestProfileSetDefault(t *testing.T) { + env, cli := integrationtest.CreateArduinoCLIWithEnvironment(t) + defer env.CleanUp() + + // Init the environment explicitly + _, _, err := cli.Run("core", "update-index") + require.NoError(t, err) + _, _, err = cli.Run("core", "install", "arduino:avr") + require.NoError(t, err) + sk := createTempSketch(t, cli, "Simple") + + // Create two profiles and set both as default (the second one should override the first) + _, _, err = cli.Run("profile", "create", sk.String(), "-m", "my_profile", "-b", "arduino:avr:uno", "--set-default") + require.NoError(t, err) + _, _, err = cli.Run("profile", "create", sk.String(), "-m", "uno", "-b", "arduino:avr:uno", "--set-default") + require.NoError(t, err) + + fileContent, err := sk.Join("sketch.yaml").ReadFileAsLines() + require.NoError(t, err) + require.Contains(t, fileContent, "default_profile: uno") + require.NotContains(t, fileContent, "default_profile: my_profile") + + // Change default profile, and test JSON output + out, _, err := cli.Run("profile", "set-default", "my_profile", "--sketch-path", sk.String(), "--json") + require.NoError(t, err) + fileContent, err = sk.Join("sketch.yaml").ReadFileAsLines() + require.NoError(t, err) + require.NotContains(t, fileContent, "default_profile: uno") + require.Contains(t, fileContent, "default_profile: my_profile") + requirejson.Parse(t, out).Query(".default_profile").MustEqual(`"my_profile"`) + + // Changing to an inexistent profile returns an error + _, stderr, err := cli.Run("profile", "set-default", "inexistent_profile", "--sketch-path", sk.String()) + require.Error(t, err) + require.Equal(t, "Cannot set inexistent_profile as default profile: Profile 'inexistent_profile' not found\n", string(stderr)) +}