Repository: conan-io/conan
Pull request: Feature: workspace create
Changelog: Feature: New ``conan workspace create`` orchestrated.
Changelog: Fix: Removed ``Workspaces`` product definition and make ``conan workspace build`` work computing the right build-order.
Docs: https://github.com/conan-io/docs/pull/4138
Close https://github.com/conan-io/conan/issues/18364




Problem statement:
[question] Conan workspaces CI
### What is your question?

Hello,
I have recently started using the workspaces as it fits my monorepo style project. I'd like to ask using the workspaces how can I automate the CI process in respect the packages and products. From what I understand when we declare new packages or products in the conanws.yml section they implicitly become editable which means they will be marked as editable when built and not in the local cache. My first question is how can then I use `conan create` and ensure the package goes into local cache so that we can later do `conan upload` and upload to a registry. Secondly, let's say I have 2 products and 4 packages which the products use. My question is how a CI using workspaces has to generally be structured. The idea is we would like to ensure correct rebuilding of products and dependencies based on packages. Since packages can depend on one another an products depend on packages the CI will trigger when a folder has changed. Let's say a package has changed and a product depends on this package. We would like to rebuild the package upload it and then rebuild any product which uses the package and upload as well. My 2 struggles are: how to perhaps use the graph API to detect such changes and rebuild correctly, 2 how to use the workspaces correctly so that we can actually create the packages and upload them to a remote registry. I've read the CI tutorial and it makes sense but I struggle to conceptualize how it could be applied to a monorepo with multiple products and packages which depend on each other and the build order matters. Thank you in advance!

### Have you read the CONTRIBUTING guide?

- [x] I've read the CONTRIBUTING guide

Hints from issue discussion:
Hi @IYanakiev34 

Thanks for your question (though I think it is actually a feature request too).

At the moment, the ``workspace`` implements the standard or "canonical" flow, in which the developers use the ``workspace`` to develop their code locally, but the creation of packages typically happens in CI. So developer only care about their local workspace, and when they are done they commit their changes to git, push, and the CI will build those as individual packages, not as a workspace.

Then, the CI will take care of building the packages. This building can be managed as concurrent builds of different packages, or if the Pull Request mechanism admits managing several PRs to different repositories in a coordinated way, the same principles can apply. The CI recommendations are documented in https://docs.conan.io/2/ci_tutorial/tutorial.html, where the ``conan graph build-order`` is used to obtain the order to build things.


Then, a secondary use case or user flow that hasn't been implemented yet, is a ``conan workspace create`` or similar command, that will do an orchestrated creation of packages from a workspace in the cache. This shouldn't be very difficult, I already started the task in some branch some time ago, and I have plans to resume working on it soon. This ``conan workspace create`` might be used locally by devs to ensure that the package creation process works fine locally, and maybe in the CI side it might not be necessary or maybe it it also useful there, depending on how the rest of the CI is managed, how other packages that do not belong to the monolithic workspace are managed and evolved, etc.


https://github.com/conan-io/conan/pull/18390 provides better ``conan workspace build`` functionality, being smarter, no need to define products, and more efficient and clean if there are multiple defined products. Also it handles better cases for build/host packages. Planned for 2.18. It also has a ``conan workspace create`` orchestrated individual package creation of the full workspace.

Commit message: test for conan workspace build too

Patch to review:
diff --git a/conan/api/output.py b/conan/api/output.py
index cf291ec9875..535586c00cf 100644
--- a/conan/api/output.py
+++ b/conan/api/output.py
@@ -175,6 +175,12 @@ def write(self, data, fg=None, bg=None, newline=False):
 
         return self
 
+    def box(self, msg):
+        color = Color.BRIGHT_GREEN
+        self.writeln("\n**************************************************", fg=color)
+        self.writeln(f'*{msg: ^48}*', fg=color)
+        self.writeln(f"**************************************************\n", fg=color)
+
     def _write_message(self, msg, fg=None, bg=None, newline=True):
         if isinstance(msg, dict):
             # For traces we can receive a dict already, we try to transform then into more natural
diff --git a/conan/api/subapi/command.py b/conan/api/subapi/command.py
index abf5dd9cc36..272b5e09962 100644
--- a/conan/api/subapi/command.py
+++ b/conan/api/subapi/command.py
@@ -1,4 +1,5 @@
 import os
+import shlex
 
 from conan.api.output import ConanOutput
 from conan.errors import ConanException
@@ -12,7 +13,7 @@ def __init__(self, conan_api):
 
     def run(self, cmd):
         if isinstance(cmd, str):
-            cmd = cmd.split()
+            cmd = shlex.split(cmd)
         if isinstance(cmd, list):
             current_cmd = cmd[0]
             args = cmd[1:]
diff --git a/conan/api/subapi/workspace.py b/conan/api/subapi/workspace.py
index 96e00f77e34..1e3d7a0ef87 100644
--- a/conan/api/subapi/workspace.py
+++ b/conan/api/subapi/workspace.py
@@ -8,8 +8,10 @@
 from conan.api.model import RecipeReference
 from conan.api.output import ConanOutput
 from conan.cli import make_abs_path
+from conan.cli.printers.graph import print_graph_basic, print_graph_packages
 from conan.errors import ConanException
 from conan.internal.conan_app import ConanApp
+from conan.internal.graph.install_graph import ProfileArgs
 from conan.internal.model.workspace import Workspace, WORKSPACE_YML, WORKSPACE_PY, WORKSPACE_FOLDER
 from conan.tools.scm import Git
 from conan.internal.graph.graph import RECIPE_EDITABLE, DepsGraph, CONTEXT_HOST, RECIPE_VIRTUAL, Node, \
@@ -74,6 +76,7 @@ class WorkspaceAPI:
     TEST_ENABLED = False
 
     def __init__(self, conan_api):
+        self._enabled = True
         self._conan_api = conan_api
         self._folder = _find_ws_folder()
         if self._folder:
@@ -85,6 +88,9 @@ def __init__(self, conan_api):
                 ConanOutput().warning(f"Workspace is a dev-only feature, exclusively for testing")
                 self._ws = _load_workspace(self._folder, conan_api)  # Error if not loading
 
+    def enable(self, value):
+        self._enabled = value
+
     @property
     def name(self):
         self._check_ws()
@@ -101,7 +107,7 @@ def editable_packages(self):
         """
         @return: Returns {RecipeReference: {"path": full abs-path, "output_folder": abs-path}}
         """
-        if not self._folder:
+        if not self._folder or not self._enabled:
             return
         editables = self._ws.packages()
         editables = {RecipeReference.loads(r): v.copy() for r, v in editables.items()}
@@ -115,21 +121,6 @@ def editable_packages(self):
                                                                    v["output_folder"]))
         return editables
 
-    def select_editables(self, paths):
-        filtered_refs = [self.editable_from_path(p) for p in paths or []]
-        editables = self.editable_packages
-        requires = [ref for ref in editables]
-        if filtered_refs:
-            ConanOutput().info(f"Filtering and installing only selected editable packages")
-            requires = [ref for ref in requires if ref in filtered_refs]
-            ConanOutput().info(f"Filtered references: {requires}")
-        return requires
-
-    @property
-    def products(self):
-        self._check_ws()
-        return self._ws.products()
-
     def open(self, require, remotes, cwd=None):
         app = ConanApp(self._conan_api)
         ref = RecipeReference.loads(require)
@@ -167,7 +158,7 @@ def _check_ws(self):
                                  f"'{WORKSPACE_PY}' or '{WORKSPACE_YML}' file")
 
     def add(self, path, name=None, version=None, user=None, channel=None, cwd=None,
-            output_folder=None, remotes=None, product=False):
+            output_folder=None, remotes=None):
         """
         Add a new editable package to the current workspace (the current workspace must exist)
         @param path: The path to the folder containing the conanfile.py that defines the package
@@ -178,7 +169,6 @@ def add(self, path, name=None, version=None, user=None, channel=None, cwd=None,
         @param cwd:
         @param output_folder:
         @param remotes:
-        @param product:
         @return: The reference of the added package
         """
         self._check_ws()
@@ -191,7 +181,7 @@ def add(self, path, name=None, version=None, user=None, channel=None, cwd=None,
         ref.validate_ref()
         output_folder = make_abs_path(output_folder) if output_folder else None
         # Check the conanfile is there, and name/version matches
-        self._ws.add(ref, full_path, output_folder, product)
+        self._ws.add(ref, full_path, output_folder)
         return ref
 
     @staticmethod
@@ -230,15 +220,8 @@ def info(self):
         self._check_ws()
         return {"name": self.name,
                 "folder": self._folder,
-                "products": self.products,
                 "packages": self._ws.packages()}
 
-    def editable_from_path(self, path):
-        editables = self._ws.packages()
-        for ref, info in editables.items():
-            if info["path"].replace("\\", "/") == path:
-                return RecipeReference.loads(ref)
-
     def collapse_editables(self, deps_graph, profile_host, profile_build):
         ConanOutput().title("Collapsing workspace editables")
 
@@ -282,3 +265,55 @@ def collapse_editables(self, deps_graph, profile_host, profile_build):
             result.add_edge(root, t.node, r)
 
         return result
+
+    def export(self, lockfile=None, remotes=None):
+        self._check_ws()
+        exported = []
+        for ref, info in self.editable_packages.items():
+            exported_ref = self._conan_api.export.export(info["path"], ref.name, str(ref.version),
+                                                         ref.user, ref.channel,
+                                                         lockfile=lockfile, remotes=remotes)
+            ref, _ = exported_ref
+            exported.append(ref)
+        return exported
+
+
+    def select_packages(self, packages):
+        self._check_ws()
+        editable = self.editable_packages
+        packages = packages or []
+        selected_editables = {}
+        for ref, info in editable.items():
+            if packages and not any(ref.matches(p, False) for p in packages):
+                continue
+            selected_editables[ref] = info
+        if not selected_editables:
+            raise ConanException("There are no selected packages defined in the workspace")
+
+        return selected_editables
+
+    def build_order(self, packages, profile_host, profile_build, build_mode, lockfile, remotes, args,
+                    update=False):
+        ConanOutput().title(f"Computing dependency graph for each pkgs")
+        conan_api = self._conan_api
+        from conan.internal.graph.install_graph import InstallGraph
+        install_order = InstallGraph(None)
+
+        for ref, info in packages.items():
+            ConanOutput().info(f"Computing the dependency graph for package: {ref}")
+
+            deps_graph = conan_api.graph.load_graph_requires([ref], None,
+                                                             profile_host, profile_build, lockfile,
+                                                             remotes, update)
+            deps_graph.report_graph_error()
+            print_graph_basic(deps_graph)
+            conan_api.graph.analyze_binaries(deps_graph, build_mode, remotes=remotes, update=update,
+                                             lockfile=lockfile)
+            print_graph_packages(deps_graph)
+
+            ConanOutput().info(f"Aggregating build-order for package: {ref}")
+            install_graph = InstallGraph(deps_graph, order_by="recipe",
+                                         profile_args=ProfileArgs.from_args(args))
+            install_order.merge(install_graph)
+
+        return install_order
diff --git a/conan/cli/commands/workspace.py b/conan/cli/commands/workspace.py
index f1ea664e132..448bb7807fe 100644
--- a/conan/cli/commands/workspace.py
+++ b/conan/cli/commands/workspace.py
@@ -2,6 +2,7 @@
 import os
 
 from conan.api.conan_api import ConanAPI
+from conan.api.model import RecipeReference
 from conan.api.output import ConanOutput, cli_out_write
 from conan.api.subapi.workspace import WorkspaceAPI
 from conan.cli import make_abs_path
@@ -11,6 +12,7 @@
 from conan.cli.printers import print_profiles
 from conan.cli.printers.graph import print_graph_packages, print_graph_basic
 from conan.errors import ConanException
+from conan.internal.graph.install_graph import ProfileArgs
 
 
 @conan_subcommand(formatters={"text": cli_out_write})
@@ -58,7 +60,6 @@ def workspace_add(conan_api: ConanAPI, parser, subparser, *args):
                        help='Look in the specified remote or remotes server')
     group.add_argument("-nr", "--no-remote", action="store_true",
                        help='Do not use remote, resolve exclusively in the cache')
-    subparser.add_argument("--product", action="store_true", help="Add the package as a product")
     args = parser.parse_args(*args)
     if args.path and args.ref:
         raise ConanException("Do not use both 'path' and '--ref' argument")
@@ -70,7 +71,7 @@ def workspace_add(conan_api: ConanAPI, parser, subparser, *args):
         path = conan_api.workspace.open(args.ref, remotes, cwd=cwd)
     ref = conan_api.workspace.add(path,
                                   args.name, args.version, args.user, args.channel,
-                                  cwd, args.output_folder, remotes=remotes, product=args.product)
+                                  cwd, args.output_folder, remotes=remotes)
     ConanOutput().success("Reference '{}' added to workspace".format(ref))
 
 
@@ -109,7 +110,7 @@ def workspace_build(conan_api: ConanAPI, parser, subparser, *args):
     """
     Build the current workspace, starting from the "products"
     """
-    subparser.add_argument("path", nargs="?",
+    subparser.add_argument("--pkg", action="append",
                            help='Path to a package folder in the user workspace')
     add_common_install_arguments(subparser)
     add_lockfile_args(subparser)
@@ -120,48 +121,47 @@ def workspace_build(conan_api: ConanAPI, parser, subparser, *args):
     # The lockfile by default if not defined will be read from the root workspace folder
     ws_folder = conan_api.workspace.folder()
     lockfile = conan_api.lockfile.get_lockfile(lockfile=args.lockfile, conanfile_path=ws_folder,
-                                               cwd=None,
-                                               partial=args.lockfile_partial, overrides=overrides)
+                                               cwd=None, partial=args.lockfile_partial,
+                                               overrides=overrides)
     profile_host, profile_build = conan_api.profiles.get_profiles_from_args(args)
     print_profiles(profile_host, profile_build)
 
-    build_mode = args.build or []
-    if "editable" not in build_mode:
+    buildmode = args.build or []
+    if "editable" not in buildmode:
         ConanOutput().info("Adding '--build=editable' as build mode")
-        build_mode.append("editable")
-
-    if args.path:
-        products = [args.path]
-    else:  # all products
-        products = conan_api.workspace.products
-        if not products:
-            raise ConanException("There are no products defined in the workspace, can't build\n"
-                                 "You can use 'conan build <path> --build=editable' to build")
-        ConanOutput().title(f"Building workspace products {products}")
-
-    editables = conan_api.workspace.editable_packages
-    # TODO: This has to be improved to avoid repetition when there are multiple products
-    for product in products:
-        ConanOutput().subtitle(f"Building workspace product: {product}")
-        product_ref = conan_api.workspace.editable_from_path(product)
-        if product_ref is None:
-            raise ConanException(f"Product '{product}' not defined in the workspace as editable")
-        editable = editables[product_ref]
-        editable_path = editable["path"]
-        deps_graph = conan_api.graph.load_graph_consumer(editable_path, None, None, None, None,
-                                                         profile_host, profile_build, lockfile,
-                                                         remotes, args.update)
-        deps_graph.report_graph_error()
-        print_graph_basic(deps_graph)
-        conan_api.graph.analyze_binaries(deps_graph, build_mode, remotes=remotes, update=args.update,
-                                         lockfile=lockfile)
-        print_graph_packages(deps_graph)
-        conan_api.install.install_binaries(deps_graph=deps_graph, remotes=remotes)
-        conan_api.install.install_consumer(deps_graph, None, os.path.dirname(editable_path),
-                                           editable.get("output_folder"))
-        ConanOutput().title(f"Calling build() for the product {product_ref}")
-        conanfile = deps_graph.root.conanfile
-        conan_api.local.build(conanfile)
+    buildmode.append("editable")
+
+    all_editables = conan_api.workspace.editable_packages
+    packages = conan_api.workspace.select_packages(args.pkg)
+    ConanOutput().box("Workspace computing the build order")
+    install_order = conan_api.workspace.build_order(packages, profile_host, profile_build, buildmode,
+                                                    lockfile, remotes, args, update=args.update)
+
+    ConanOutput().box("Workspace building each package")
+    order = install_order.install_build_order()
+
+    profile_args = ProfileArgs.from_args(args)
+    for level in order["order"]:
+        for elem in level:
+            ref = RecipeReference.loads(elem["ref"])
+            for package_level in elem["packages"]:
+                for package in package_level:
+                    if package["binary"] == "Build":  # Build external to Workspace
+                        cmd = f'install {package["build_args"]} {profile_args}'
+                        ConanOutput().box(f"Workspace building external {ref}")
+                        ConanOutput().info(f"Build command: {cmd}\n")
+                        conan_api.command.run(cmd)
+                    elif package["binary"] == "EditableBuild":
+                        path = all_editables[ref]["path"]
+                        build = "--build-require" if package["context"] == "build" else ""
+                        ref_args = " ".join(f"--{k}={getattr(ref, k)}"
+                                            for k in ("name", "version", "user", "channel")
+                                            if getattr(ref, k, None))
+                        # TODO: Missing --lockfile-overrides arg here
+                        cmd = f'build "{path}" {profile_args} {build} {ref_args}'
+                        ConanOutput().box(f"Workspace building {ref}")
+                        ConanOutput().info(f"Build command: {cmd}\n")
+                        conan_api.command.run(cmd)
 
 
 @conan_subcommand()
@@ -170,8 +170,8 @@ def workspace_install(conan_api: ConanAPI, parser, subparser, *args):
     Install the workspace as a monolith, installing only external dependencies to the workspace,
     generating a single result (generators, etc) for the whole workspace.
     """
-    subparser.add_argument("path", nargs="*",
-                           help="Install only these editable packages, not all")
+    subparser.add_argument("--pkg", action="append",
+                           help='Path to a package folder in the user workspace')
     subparser.add_argument("-g", "--generator", action="append", help='Generators to use')
     subparser.add_argument("-of", "--output-folder",
                            help='The root output folder for generated and build files')
@@ -191,11 +191,8 @@ def workspace_install(conan_api: ConanAPI, parser, subparser, *args):
     profile_host, profile_build = conan_api.profiles.get_profiles_from_args(args)
     print_profiles(profile_host, profile_build)
 
-    conan_api.workspace.info()  # FIXME: Just to force error if WS not enabled
     # Build a dependency graph with all editables as requirements
-    requires = conan_api.workspace.select_editables(args.path)
-    if not requires:
-        raise ConanException("This workspace cannot be installed, it doesn't have any editable")
+    requires = conan_api.workspace.select_packages(args.pkg)
     deps_graph = conan_api.graph.load_graph_requires(requires, [],
                                                      profile_host, profile_build, lockfile,
                                                      remotes, args.build, args.update)
@@ -217,7 +214,7 @@ def workspace_install(conan_api: ConanAPI, parser, subparser, *args):
 
 
 @conan_subcommand()
-def workspace_clean(conan_api: ConanAPI, parser, subparser, *args):
+def workspace_clean(conan_api: ConanAPI, parser, subparser, *args):  # noqa
     """
     Clean the temporary build folders when possible
     """
@@ -237,6 +234,71 @@ def workspace_init(conan_api: ConanAPI, parser, subparser, *args):
     conan_api.workspace.init(args.path)
 
 
+@conan_subcommand()
+def workspace_create(conan_api: ConanAPI, parser, subparser, *args):
+    """
+    Build the current workspace, starting from the "products"
+    """
+    subparser.add_argument("--pkg", action="append",
+                           help='Path to a package folder in the user workspace')
+    add_common_install_arguments(subparser)
+    add_lockfile_args(subparser)
+    args = parser.parse_args(*args)
+    # Basic collaborators: remotes, lockfile, profiles
+    remotes = conan_api.remotes.list(args.remote) if not args.no_remote else []
+    overrides = eval(args.lockfile_overrides) if args.lockfile_overrides else None
+    # The lockfile by default if not defined will be read from the root workspace folder
+    ws_folder = conan_api.workspace.folder()
+    lockfile = conan_api.lockfile.get_lockfile(lockfile=args.lockfile, conanfile_path=ws_folder,
+                                               cwd=None,
+                                               partial=args.lockfile_partial, overrides=overrides)
+    profile_host, profile_build = conan_api.profiles.get_profiles_from_args(args)
+    print_profiles(profile_host, profile_build)
+
+    ConanOutput().box("Exporting workspace packages recipes to Conan cache")
+    exported_refs = conan_api.workspace.export()
+
+    build_mode = args.build if args.build else []
+    build_mode.extend(f"missing:{r}" for r in exported_refs)
+
+    all_packages = conan_api.workspace.editable_packages
+    packages = conan_api.workspace.select_packages(args.pkg)
+    conan_api.workspace.enable(False)
+
+    install_order = conan_api.workspace.build_order(packages, profile_host, profile_build, build_mode,
+                                                    lockfile, remotes, args, update=args.update)
+
+    ConanOutput().box("Workspace creating each package")
+    order = install_order.install_build_order()
+
+    profile_args = ProfileArgs.from_args(args)
+    for level in order["order"]:
+        for elem in level:
+            ref = RecipeReference.loads(elem["ref"])
+            for package_level in elem["packages"]:
+                for package in package_level:
+                    if package["binary"] != "Build":
+                        continue
+
+                    if ref not in all_packages:
+                        # Build external to Workspace
+                        cmd = f'install {package["build_args"]} {profile_args}'
+                        ConanOutput().box(f"Workspace building external {ref}")
+                        ConanOutput().info(f"Build command: {cmd}\n")
+                        conan_api.command.run(cmd)
+                    else:  # Package in workspace
+                        path = packages[ref]["path"]
+                        # TODO: Missing --lockfile-overrides arg here
+                        build = "--build-require" if package["context"] == "build" else ""
+                        ref_args = " ".join(f"--{k}={getattr(ref, k)}"
+                                            for k in ("name", "version", "user", "channel")
+                                            if getattr(ref, k, None))
+                        cmd = f'create "{path}" {profile_args} {build} {ref_args}'
+                        ConanOutput().box(f"Workspace create {ref}")
+                        ConanOutput().info(f"Conan create command: {cmd}\n")
+                        conan_api.command.run(cmd)
+
+
 @conan_command(group="Consumer")
 def workspace(conan_api, parser, *args):  # noqa
     """
diff --git a/conan/internal/api/new/workspace.py b/conan/internal/api/new/workspace.py
index d89c6acef12..95ea84111c5 100644
--- a/conan/internal/api/new/workspace.py
+++ b/conan/internal/api/new/workspace.py
@@ -11,8 +11,6 @@
     path: libb
   app1/0.1:
     path: app1
-products:
-- app1
 """
 
 cmake = """\
diff --git a/conan/internal/graph/install_graph.py b/conan/internal/graph/install_graph.py
index 7d7d9aec72c..6c74f27ac97 100644
--- a/conan/internal/graph/install_graph.py
+++ b/conan/internal/graph/install_graph.py
@@ -359,6 +359,9 @@ def from_args(args):
                             getattr(args, f"{f}_{context}") or []]
         return ProfileArgs(" ".join(pr_args))
 
+    def __str__(self):
+        return self._args
+
     @staticmethod
     def deserialize(data):
         return ProfileArgs(data.get("args"))
diff --git a/conan/internal/model/workspace.py b/conan/internal/model/workspace.py
index 35ff09777be..2fe436564e1 100644
--- a/conan/internal/model/workspace.py
+++ b/conan/internal/model/workspace.py
@@ -38,15 +38,13 @@ def _conan_load_data(self):
             raise ConanException("Invalid yml format at {}: {}".format(WORKSPACE_YML, e))
         return data or {}
 
-    def add(self, ref, path, output_folder, product=False):
+    def add(self, ref, path, output_folder):
         assert os.path.isfile(path)
         path = self._conan_rel_path(os.path.dirname(path))
         editable = {"path": path}
         if output_folder:
             editable["output_folder"] = self._conan_rel_path(output_folder)
         self.conan_data.setdefault("packages", {})[str(ref)] = editable
-        if product:
-            self.conan_data.setdefault("products", []).append(path)
         save(os.path.join(self.folder, WORKSPACE_YML), yaml.dump(self.conan_data))
 
     def remove(self, path):
@@ -59,8 +57,6 @@ def remove(self, path):
         if not found_ref:
             raise ConanException(f"No editable package to remove from this path: {path}")
         self.conan_data["packages"].pop(found_ref)
-        if path in self.conan_data.get("products", []):
-            self.conan_data["products"].remove(path)
         save(os.path.join(self.folder, WORKSPACE_YML), yaml.dump(self.conan_data))
         return found_ref
 
@@ -88,9 +84,6 @@ def _conan_rel_path(self, path):
     def packages(self):
         return self.conan_data.get("packages", {})
 
-    def products(self):
-        return self.conan_data.get("products", [])
-
     def load_conanfile(self, conanfile_path):
         conanfile_path = os.path.join(self.folder, conanfile_path, "conanfile.py")
         from conan.internal.loader import ConanFileLoader
diff --git a/test/functional/workspace/test_workspace.py b/test/functional/workspace/test_workspace.py
index 873a133201e..0c1d01c8bed 100644
--- a/test/functional/workspace/test_workspace.py
+++ b/test/functional/workspace/test_workspace.py
@@ -1,6 +1,5 @@
 import os
 import platform
-import textwrap
 
 import pytest
 
@@ -20,7 +19,7 @@ def test_build():
     c.save({}, clean_first=True)
     c.run("new workspace -d requires=mymath/0.1")
     c.run("workspace build")
-    assert "Calling build() for the product app1/0.1" in c.out
+    assert "conanfile.py (app1/0.1): Calling build()" in c.out
     assert "conanfile.py (app1/0.1): Running CMake.build()" in c.out
     # it works without failing
 
diff --git a/test/integration/workspace/test_workspace.py b/test/integration/workspace/test_workspace.py
index a2b7d870af1..f1ea47553c8 100644
--- a/test/integration/workspace/test_workspace.py
+++ b/test/integration/workspace/test_workspace.py
@@ -236,7 +236,7 @@ def test_remove_product(self):
         c = TestClient(light=True)
         c.save({"conanws.yml": "",
                 "mydeppkg/conanfile.py": GenConanfile("mydeppkg", "0.1")})
-        c.run("workspace add mydeppkg --product")
+        c.run("workspace add mydeppkg")
         c.run("workspace remove mydeppkg")
         c.run("workspace info")
         assert "mydeppkg" not in c.out
@@ -368,31 +368,6 @@ def test_workspace_build_editables(self):
 
 
 class TestWorkspaceBuild:
-    def test_dynamic_products(self):
-        c = TestClient(light=True)
-
-        workspace = textwrap.dedent("""\
-            import os
-            from conan import Workspace
-
-            class MyWorkspace(Workspace):
-                def products(self):
-                    result = []
-                    for f in os.listdir(self.folder):
-                        if os.path.isdir(os.path.join(self.folder, f)):
-                            if f.startswith("product"):
-                                result.append(f)
-                    return result
-            """)
-
-        c.save({"conanws.py": workspace,
-                "lib1/conanfile.py": GenConanfile("lib1", "0.1"),
-                "product_app1/conanfile.py": GenConanfile("app1", "0.1")})
-        c.run("workspace add lib1")
-        c.run("workspace add product_app1")
-        c.run("workspace info --format=json")
-        info = json.loads(c.stdout)
-        assert info["products"] == ["product_app1"]
 
     def test_build(self):
         c = TestClient(light=True)
@@ -402,27 +377,25 @@ def test_build(self):
                 "pkgb/conanfile.py": GenConanfile("pkgb", "0.1").with_build_msg("BUILD PKGB!")
                .with_requires("pkga/0.1")})
         c.run("workspace add pkga")
-        c.run("workspace add pkgb --product")
-        c.run("workspace info --format=json")
-        assert json.loads(c.stdout)["products"] == ["pkgb"]
+        c.run("workspace add pkgb")
         c.run("workspace build")
         c.assert_listed_binary({"pkga/0.1": ("da39a3ee5e6b4b0d3255bfef95601890afd80709",
                                              "EditableBuild")})
-        assert "pkga/0.1: WARN: BUILD PKGA!" in c.out
+        assert "conanfile.py (pkga/0.1): WARN: BUILD PKGA!" in c.out
         assert "conanfile.py (pkgb/0.1): WARN: BUILD PKGB!" in c.out
 
         # It is also possible to build a specific package by path
         # equivalent to ``conan build <path> --build=editable``
         # This can be done even if it is not a product
-        c.run("workspace build pkgc", assert_error=True)
-        assert "ERROR: Product 'pkgc' not defined in the workspace as editable" in c.out
-        c.run("workspace build pkgb")
+        c.run("workspace build --pkg=pkgc/*", assert_error=True)
+        assert "ERROR: There are no selected packages defined in the workspace" in c.out
+        c.run("workspace build --pkg=pkgb/*")
         c.assert_listed_binary({"pkga/0.1": ("da39a3ee5e6b4b0d3255bfef95601890afd80709",
                                              "EditableBuild")})
-        assert "pkga/0.1: WARN: BUILD PKGA!" in c.out
+        assert "conanfile.py (pkga/0.1): WARN: BUILD PKGA!" in c.out
         assert "conanfile.py (pkgb/0.1): WARN: BUILD PKGB!" in c.out
 
-        c.run("workspace build pkga")
+        c.run("workspace build --pkg=pkga/*")
         assert "conanfile.py (pkga/0.1): Calling build()" in c.out
         assert "conanfile.py (pkga/0.1): WARN: BUILD PKGA!" in c.out
 
@@ -430,7 +403,51 @@ def test_error_if_no_products(self):
         c = TestClient(light=True)
         c.save({"conanws.yml": ""})
         c.run("workspace build", assert_error=True)
-        assert "There are no products defined in the workspace, can't build" in c.out
+        assert "ERROR: There are no selected packages defined in the workspace" in c.out
+
+    def test_build_external_missing(self):
+        c = TestClient(light=True)
+        c.save({"conanws.yml": ""})
+
+        c.save({"mymath/conanfile.py": GenConanfile("mymath", "0.1").with_build_msg("MyMATH!!"),
+                "pkga/conanfile.py": GenConanfile("pkga", "0.1").with_build_msg("BUILD PKGA!")
+                                                                .with_requires("mymath/0.1"),
+                "pkgb/conanfile.py": GenConanfile("pkgb", "0.1").with_build_msg("BUILD PKGB!")
+               .with_requires("pkga/0.1")})
+        c.run("export mymath")
+        c.run("workspace add pkga")
+        c.run("workspace add pkgb")
+        c.run("workspace build", assert_error=True)
+        assert "ERROR: Missing prebuilt package for 'mymath/0.1'" in c.out
+        c.run("workspace build --build=missing")
+        assert "Workspace building external mymath/0.1" in c.out
+
+    def test_build_dynamic_name_version(self):
+        conanfile = textwrap.dedent("""\
+            from conan import ConanFile
+            class MyConan(ConanFile):
+                def build(self):
+                    self.output.info(f"Building {self.name} AND {self.version}!!!")
+            """)
+        workspace = textwrap.dedent("""\
+            import os
+            from conan import Workspace
+
+            class MyWorkspace(Workspace):
+                def packages(self):
+                    result = {}
+                    for f in os.listdir(self.folder):
+                        if os.path.isdir(os.path.join(self.folder, f)):
+                            result[f"{f}/0.1"] = {"path": f}
+                    return result
+           """)
+        c = TestClient(light=True)
+        c.save({"conanws.py": workspace,
+                "pkga/conanfile.py": conanfile})
+        c.run("workspace info")
+        assert "pkga/0.1" in c.out
+        c.run("workspace build")
+        assert "conanfile.py (pkga/0.1): Building pkga AND 0.1!!!" in c.out
 
 
 class TestNew:
@@ -519,7 +536,7 @@ def root_conanfile(self):
                 "dep/conanfile.py": GenConanfile("dep", "0.1"),
                 "conanws.py": conanfilews})
         c.run("workspace install", assert_error=True)
-        assert "ERROR: This workspace cannot be installed, it doesn't have any editable" in c.out
+        assert "ERROR: There are no selected packages defined in the workspace" in c.out
         c.run("workspace add dep")
         c.run("workspace install", assert_error=True)
         assert "ERROR: Conanfile in conanws.py shouldn't have 'requires'" in c.out
@@ -539,7 +556,7 @@ def test_install_partial(self):
         c.run("workspace add liba")
         c.run("workspace add libb")
         c.run("workspace add libc")
-        for arg in ("libb", "libb liba"):
+        for arg in ("--pkg=libb/*", "--pkg=libb/* --pkg=liba/*"):
             c.run(f"workspace install {arg} -g CMakeDeps -of=build")
             assert "dep1/0.1" in c.out
             assert "dep2/0.1" not in c.out
@@ -580,9 +597,9 @@ def test_clean(self):
         pkgb = GenConanfile("pkgb", "0.1").with_requires("pkga/0.1").with_settings("build_type")
         c.save({"pkga/conanfile.py": pkga ,
                 "pkgb/conanfile.py": pkgb,
-                "pkgc/conanfile.py": GenConanfile("pkgc", "0.1")})
+                "pkgc/conanfile.py": GenConanfile("pkgc", "0.1").with_requires("pkgb/0.1")})
         c.run("workspace add pkga -of=build/pkga")
-        c.run("workspace add pkgb -of=build/pkgb --product")
+        c.run("workspace add pkgb -of=build/pkgb")
         c.run("workspace add pkgc")
         c.run("workspace build")
         assert os.path.exists(os.path.join(c.current_folder, "build", "pkga"))
@@ -622,11 +639,9 @@ def test_relative_paths():
     # cd mywks
     with c.chdir("mywks"):
         c.run("workspace add ../liba")
-        c.run("workspace add ../app1 --product")
+        c.run("workspace add ../app1")
         c.run("workspace info")
         expected = textwrap.dedent("""\
-            products
-              ../app1
             packages
               app1/0.1
                 path: ../app1
@@ -639,11 +654,9 @@ def test_relative_paths():
     # cd otherwks
     with c.chdir("otherwks"):
         c.run("workspace add ../other/libb")
-        c.run("workspace add ../other/app2 --product")
+        c.run("workspace add ../other/app2")
         c.run("workspace info")
         expected = textwrap.dedent("""\
-            products
-              ../other/app2
             packages
               app2/0.1
                 path: ../other/app2
@@ -659,31 +672,140 @@ def test_host_build_require():
     c = TestClient()
     protobuf = textwrap.dedent("""\
         from conan import ConanFile
-        from conan.tools.files import save
+        from conan.tools.files import save, copy
         class Protobuf(ConanFile):
             name = "protobuf"
             version = "0.1"
             settings = "os"
 
             def layout(self):
-                self.folders.build = f"mybuild_folder_{self.settings.os}"
+                self.folders.build = f"mybuild/folder_{self.settings.os}"
+                self.cpp.build.bindirs = ["."]
 
             def build(self):
                 save(self, "myprotobuf.txt", f"MYOS={self.settings.os}!!!")
+
+            def package(self):
+                copy(self, "myprotobuf.txt", src=self.build_folder, dst=self.package_folder)
+
+            def package_info(self):
+                self.cpp_info.bindirs = ["."]
         """)
     app = textwrap.dedent("""\
+        import os
         from conan import ConanFile
+        from conan.tools.files import load
         class Protobuf(ConanFile):
             name = "app"
             version = "0.1"
             requires = "protobuf/0.1"
             tool_requires = "protobuf/0.1"
+
+            def build(self):
+                binhost = self.dependencies.host["protobuf"].cpp_info.bindir
+                binbuild = self.dependencies.build["protobuf"].cpp_info.bindir
+                host = load(self, os.path.join(binhost, "myprotobuf.txt"))
+                build = load(self, os.path.join(binbuild, "myprotobuf.txt"))
+                self.output.info(f"BUILDING WITH BINHOST: {host}")
+                self.output.info(f"BUILDING WITH BINBUILD: {build}")
         """)
     c.save({"protobuf/conanfile.py": protobuf,
             "app/conanfile.py": app})
     c.run("workspace init .")
     c.run("workspace add protobuf")
+    c.run("workspace add app")
     c.run("install app --build=editable -s:b os=Linux -s:h os=Windows")
 
-    assert c.load("protobuf/mybuild_folder_Linux/myprotobuf.txt") == "MYOS=Linux!!!"
-    assert c.load("protobuf/mybuild_folder_Windows/myprotobuf.txt") == "MYOS=Windows!!!"
+    assert c.load("protobuf/mybuild/folder_Linux/myprotobuf.txt") == "MYOS=Linux!!!"
+    assert c.load("protobuf/mybuild/folder_Windows/myprotobuf.txt") == "MYOS=Windows!!!"
+
+    shutil.rmtree(os.path.join(c.current_folder, "protobuf", "mybuild"))
+    assert not os.path.exists(os.path.join(c.current_folder, "protobuf", "mybuild"))
+    c.run("workspace build -s:b os=Linux -s:h os=Windows")
+    assert "conanfile.py (app/0.1): BUILDING WITH BINHOST: MYOS=Windows!!!" in c.out
+    assert "conanfile.py (app/0.1): BUILDING WITH BINBUILD: MYOS=Linux!!!" in c.out
+    assert c.load("protobuf/mybuild/folder_Linux/myprotobuf.txt") == "MYOS=Linux!!!"
+    assert c.load("protobuf/mybuild/folder_Windows/myprotobuf.txt") == "MYOS=Windows!!!"
+
+    shutil.rmtree(os.path.join(c.current_folder, "protobuf", "mybuild"))
+    assert not os.path.exists(os.path.join(c.current_folder, "protobuf", "mybuild"))
+    c.run("workspace create -s:b os=Linux -s:h os=Windows")
+    assert "app/0.1: BUILDING WITH BINHOST: MYOS=Windows!!!" in c.out
+    assert "app/0.1: BUILDING WITH BINBUILD: MYOS=Linux!!!" in c.out
+
+
+class TestCreate:
+    def test_create(self):
+        c = TestClient(light=True)
+        c.save({"conanws.yml": ""})
+
+        c.save({"pkga/conanfile.py": GenConanfile("pkga", "0.1").with_build_msg("BUILD PKGA!"),
+                "pkga/test_package/conanfile.py": GenConanfile().with_test("pass"),
+                "pkgb/conanfile.py": GenConanfile("pkgb", "0.1").with_build_msg("BUILD PKGB!")
+               .with_requires("pkga/0.1"),
+                "pkgb/test_package/conanfile.py": GenConanfile().with_test("pass"),
+                "pkgc/conanfile.py": GenConanfile("pkgc", "0.1").with_build_msg("BUILD PKGC!")
+               .with_requires("pkgb/0.1"),
+                "pkgc/test_package/conanfile.py": GenConanfile().with_test("pass"),
+                })
+        c.run("workspace add pkga")
+        c.run("workspace add pkgb")
+        c.run("workspace add pkgc")
+        c.run("workspace create")
+        assert "pkga/0.1 (test package): Running test()" in c.out
+        assert "pkgb/0.1 (test package): Running test()" in c.out
+        assert "pkgc/0.1 (test package): Running test()" in c.out
+
+    def test_create_dynamic_name(self):
+        workspace = textwrap.dedent("""\
+            import os
+            from conan import Workspace
+
+            class MyWorkspace(Workspace):
+                def packages(self):
+                    result = {}
+                    for f in os.listdir(self.folder):
+                        if os.path.isdir(os.path.join(self.folder, f)):
+                            result[f"{f}/0.1"] = {"path": f}
+                    return result
+           """)
+        c = TestClient(light=True)
+        c.save({"conanws.py": workspace,
+                "pkga/conanfile.py": GenConanfile()})
+        c.run("workspace info")
+        assert "pkga/0.1" in c.out
+        c.run("workspace create")
+        assert "Workspace create pkga/0.1" in c.out
+
+
+    def test_host_build_require(self):
+        c = TestClient()
+        protobuf = textwrap.dedent("""\
+            from conan import ConanFile
+            class Protobuf(ConanFile):
+                name = "protobuf"
+                version = "0.1"
+                settings = "os"
+
+                def build(self):
+                    self.output.info(f"Building for: {self.settings.os}!!!")
+            """)
+        app = textwrap.dedent("""\
+            from conan import ConanFile
+            class Protobuf(ConanFile):
+                name = "app"
+                version = "0.1"
+                requires = "protobuf/0.1"
+                tool_requires = "protobuf/0.1"
+            """)
+        c.save({"protobuf/conanfile.py": protobuf,
+                "app/conanfile.py": app})
+        c.run("workspace init .")
+        c.run("workspace add protobuf")
+        c.run("workspace add app")
+
+        c.run("workspace create -s:b os=Linux -s:h os=Windows")
+
+        print(c.out)
+        assert "protobuf/0.1: Building for: Windows!!!" in c.out
+        assert "protobuf/0.1: Building for: Linux!!!" in c.out


Code review comments:
diff_hunk: @@ -282,3 +265,55 @@ def collapse_editables(self, deps_graph, profile_host, profile_build):
             result.add_edge(root, t.node, r)
 
         return result
+
+    def export(self, lockfile=None, remotes=None):
+        self._check_ws()
+        exported = []
+        for ref, info in self.editable_packages.items():
+            exported_ref = self._conan_api.export.export(info["path"], ref.name, str(ref.version),
+                                                         ref.user, ref.channel,
+                                                         lockfile=lockfile, remotes=remotes)
+            ref, _ = exported_ref
+            exported.append(ref)
+        return exported
+
+
+    def select_packages(self, packages):
+        self._check_ws()
+        editable = self.editable_packages
+        packages = packages or []
+        selected_editables = {}
+        for ref, info in editable.items():
+            if packages and not any(ref.matches(p, False) for p in packages):
+                continue
+            selected_editables[ref] = info
+        if not selected_editables:
+            raise ConanException("There are no selected packages defined in the workspace")
+
+        return selected_editables
+
+    def build_order(self, packages, profile_host, profile_build, build_mode, lockfile, remotes, args,
path: conan/api/subapi/workspace.py
text: @user1:
Maybe clearer? 

```suggestion
    def build_order(self, packages, profile_host, profile_build, build_mode, lockfile, remotes, profile_args,
```