Repository: canonical/snapcraft

Issue:
snapcraft 8.2.1: `snapcraft pack` not respecting `<directory>` arg to find `snapcraft.yaml`
### Bug Description

When using snapcraft 8.2.1, I find that repacking an existing snap by using the directory arg has regressed.  Reverting snapcraft (to 8.0.5 in my case) allows this to work again.

### To Reproduce

```
snap download hello
unsquashfs hello_42.snap
snapcraft pack squashfs-root --output hello.snap
```

workaround:
```
snap download hello
unsquashfs hello_42.snap
cd squashfs-root
snapcraft pack . --output ../hello.snap
```

### Environment

Ubuntu 24.04

### snapcraft.yaml

```shell
# n/a
```


### Relevant log output

```shell
2024-04-29 15:05:47.128 Starting snapcraft, version 8.2.1
2024-04-29 15:05:47.128 Log verbosity level set to BRIEF
2024-04-29 15:05:47.128 Configuring application...
2024-04-29 15:05:47.128 Preparing application...
2024-04-29 15:05:47.129 Build plan: platform=None, build_for=None
2024-04-29 15:05:47.129 Project file 'snapcraft.yaml' not found in '/tmp/tmp.2ItMSEP6Oi'.
2024-04-29 15:05:47.129 Detailed information: The project file could not be found.
2024-04-29 15:05:47.131 Traceback (most recent call last):
2024-04-29 15:05:47.131   File "/snap/snapcraft/11471/lib/python3.10/site-packages/snapcraft/application.py", line 136, in _resolve_project_path
2024-04-29 15:05:47.131     return super()._resolve_project_path(project_dir / "snap")
2024-04-29 15:05:47.131   File "/snap/snapcraft/11471/lib/python3.10/site-packages/craft_application/application.py", line 242, in _resolve_project_path
2024-04-29 15:05:47.131     return (project_dir / f"{self.app.name}.yaml").resolve(strict=True)
2024-04-29 15:05:47.131   File "/snap/snapcraft/11471/usr/lib/python3.10/pathlib.py", line 1077, in resolve
2024-04-29 15:05:47.131     s = self._accessor.realpath(self, strict=strict)
2024-04-29 15:05:47.131   File "/snap/snapcraft/11471/bin/../usr/lib/python3.10/posixpath.py", line 396, in realpath
2024-04-29 15:05:47.131     path, ok = _joinrealpath(filename[:0], filename, strict, {})
2024-04-29 15:05:47.131   File "/snap/snapcraft/11471/bin/../usr/lib/python3.10/posixpath.py", line 431, in _joinrealpath
2024-04-29 15:05:47.131     st = os.lstat(newpath)
2024-04-29 15:05:47.131 FileNotFoundError: [Errno 2] No such file or directory: '/tmp/tmp.2ItMSEP6Oi/snap'
2024-04-29 15:05:47.131 
2024-04-29 15:05:47.131 During handling of the above exception, another exception occurred:
2024-04-29 15:05:47.131 Traceback (most recent call last):
2024-04-29 15:05:47.131   File "/snap/snapcraft/11471/lib/python3.10/site-packages/snapcraft/application.py", line 139, in _resolve_project_path
2024-04-29 15:05:47.131     return super()._resolve_project_path(project_dir)
2024-04-29 15:05:47.131   File "/snap/snapcraft/11471/lib/python3.10/site-packages/craft_application/application.py", line 242, in _resolve_project_path
2024-04-29 15:05:47.131     return (project_dir / f"{self.app.name}.yaml").resolve(strict=True)
2024-04-29 15:05:47.131   File "/snap/snapcraft/11471/usr/lib/python3.10/pathlib.py", line 1077, in resolve
2024-04-29 15:05:47.131     s = self._accessor.realpath(self, strict=strict)
2024-04-29 15:05:47.131   File "/snap/snapcraft/11471/bin/../usr/lib/python3.10/posixpath.py", line 396, in realpath
2024-04-29 15:05:47.131     path, ok = _joinrealpath(filename[:0], filename, strict, {})
2024-04-29 15:05:47.131   File "/snap/snapcraft/11471/bin/../usr/lib/python3.10/posixpath.py", line 431, in _joinrealpath
2024-04-29 15:05:47.131     st = os.lstat(newpath)
2024-04-29 15:05:47.131 FileNotFoundError: [Errno 2] No such file or directory: '/tmp/tmp.2ItMSEP6Oi/snapcraft.yaml'
2024-04-29 15:05:47.131 
2024-04-29 15:05:47.131 During handling of the above exception, another exception occurred:
2024-04-29 15:05:47.131 Traceback (most recent call last):
2024-04-29 15:05:47.131   File "/snap/snapcraft/11471/lib/python3.10/site-packages/craft_application/application.py", line 263, in get_project
2024-04-29 15:05:47.131     project_path = self._resolve_project_path(self.project_dir)
2024-04-29 15:05:47.131   File "/snap/snapcraft/11471/lib/python3.10/site-packages/snapcraft/application.py", line 141, in _resolve_project_path
2024-04-29 15:05:47.131     return super()._resolve_project_path(project_dir / "build-aux" / "snap")
2024-04-29 15:05:47.131   File "/snap/snapcraft/11471/lib/python3.10/site-packages/craft_application/application.py", line 242, in _resolve_project_path
2024-04-29 15:05:47.131     return (project_dir / f"{self.app.name}.yaml").resolve(strict=True)
2024-04-29 15:05:47.131   File "/snap/snapcraft/11471/usr/lib/python3.10/pathlib.py", line 1077, in resolve
2024-04-29 15:05:47.131     s = self._accessor.realpath(self, strict=strict)
2024-04-29 15:05:47.131   File "/snap/snapcraft/11471/bin/../usr/lib/python3.10/posixpath.py", line 396, in realpath
2024-04-29 15:05:47.131     path, ok = _joinrealpath(filename[:0], filename, strict, {})
2024-04-29 15:05:47.131   File "/snap/snapcraft/11471/bin/../usr/lib/python3.10/posixpath.py", line 431, in _joinrealpath
2024-04-29 15:05:47.131     st = os.lstat(newpath)
2024-04-29 15:05:47.131 FileNotFoundError: [Errno 2] No such file or directory: '/tmp/tmp.2ItMSEP6Oi/build-aux'
2024-04-29 15:05:47.131 Recommended resolution: Ensure the project file exists.
```


### Additional context

_No response_


Discussion:

Thank you for reporting us your feedback!

The internal ticket has been created: https://warthogs.atlassian.net/browse/CRAFT-2861.

> This message was autogenerated

Thanks for the report.  I can confirm this is a regression.  We had a similar report in the Snapcraft channel on Matrix [here](https://matrix.to/#/!GGqzbFAUQprdPgYYCM:ubuntu.com/$VWb-JQ7q698LFecjX708h97c-MjRPx-iCoJKPjpeEB0?via=ubuntu.com&via=matrix.org&via=kde.org).

I'll take a look and see what we can do to fix this.
got an issue about this on goreleaser too

https://github.com/goreleaser/goreleaser/issues/4816
also worth noting that before this version the `snapcraft.yaml` file wasn't even needed.

Before, we created an structure like this:

```
❯ lt /tmp/nix-shell.NTGJBy/TestRunPipe348068566/001/dist/foo_amd64/prime
 prime
├──  foo
└──  meta
    └──  snap.yaml
```

and it was enough for it to work.

is this behavior being removed? if so, this should probably have been a major release 🤔 
> is this behavior being removed? if so, this should probably have been a major release 🤔

No, sorry about this. This was an unintentional regression and we're working on a fix.

As a temporary workaround, you can revert to the previous release with the revisions listed here: https://forum.snapcraft.io/t/call-for-testing-snapcraft-8-2-1/39928/2
Thank you for the speedy response @mr-cal -- much appreciated!

Resolving patch:
diff --git a/snapcraft/application.py b/snapcraft/application.py
--- a/snapcraft/application.py
+++ b/snapcraft/application.py
@@ -256,7 +256,7 @@ def create_app() -> Snapcraft:
             craft_app_commands.lifecycle.BuildCommand,
             craft_app_commands.lifecycle.StageCommand,
             craft_app_commands.lifecycle.PrimeCommand,
-            craft_app_commands.lifecycle.PackCommand,
+            commands.PackCommand,
             commands.SnapCommand,  # Hidden (legacy compatibility)
             commands.RemoteBuildCommand,
             unimplemented.Plugins,
diff --git a/snapcraft/commands/__init__.py b/snapcraft/commands/__init__.py
--- a/snapcraft/commands/__init__.py
+++ b/snapcraft/commands/__init__.py
@@ -37,7 +37,7 @@
     StoreLegacyUploadMetadataCommand,
     StoreLegacyValidateCommand,
 )
-from .lifecycle import SnapCommand
+from .lifecycle import PackCommand, SnapCommand
 from .manage import StoreCloseCommand, StoreReleaseCommand
 from .names import (
     StoreLegacyListCommand,
@@ -60,6 +60,7 @@
     "ExpandExtensions",
     "ListExtensions",
     "RemoteBuildCommand",
+    "PackCommand",
     "SnapCommand",
     "StoreCloseCommand",
     "StoreEditValidationSetsCommand",
diff --git a/snapcraft/commands/lifecycle.py b/snapcraft/commands/lifecycle.py
--- a/snapcraft/commands/lifecycle.py
+++ b/snapcraft/commands/lifecycle.py
@@ -17,16 +17,107 @@
 """Snapcraft lifecycle commands."""
 
 import argparse
+import pathlib
+import textwrap
 from typing import Any
 
 import craft_application.commands
 from craft_cli import emit
 from typing_extensions import override
 
-# pylint: disable=too-many-ancestors
+import snapcraft.pack
 
 
-class SnapCommand(craft_application.commands.lifecycle.PackCommand):
+class PackCommand(craft_application.commands.LifecycleCommand):
+    """Snapcraft pack command."""
+
+    name = "pack"
+    help_msg = "Create the final artifact"
+    overview = textwrap.dedent(
+        """
+        Process parts and create a snap file containing the project payload
+        with the provided metadata. If a directory is specified, pack its
+        contents instead.
+        """
+    )
+
+    @override
+    def _fill_parser(self, parser: argparse.ArgumentParser) -> None:
+        """Add arguments specific to the pack command."""
+        super()._fill_parser(parser)
+
+        parser.add_argument(
+            "directory",
+            metavar="directory",
+            type=str,
+            nargs="?",
+            default=None,
+            help="Directory to pack",
+        )
+        parser.add_argument(
+            "--output",
+            "-o",
+            type=pathlib.Path,
+            default=pathlib.Path(),
+            help="Output directory for created packages",
+        )
+
+    @override
+    def _run(
+        self,
+        parsed_args: argparse.Namespace,
+        step_name: str | None = None,
+        **kwargs: Any,
+    ) -> None:
+        """Pack a directory or run the lifecycle and pack all artifacts."""
+        if parsed_args.directory:
+            emit.progress("Packing...")
+            snap_filename = snapcraft.pack.pack_snap(
+                parsed_args.directory, output=str(parsed_args.output)
+            )
+            emit.message(f"Packed {snap_filename}")
+        else:
+            super()._run(parsed_args, step_name="prime")
+            self._services.package.update_project()
+            self._services.package.write_metadata(self._services.lifecycle.prime_dir)
+
+            emit.progress("Packing...")
+            packages = self._services.package.pack(
+                self._services.lifecycle.prime_dir, parsed_args.output
+            )
+
+            if not packages:
+                emit.progress("No packages created.", permanent=True)
+            elif len(packages) == 1:
+                emit.progress(f"Packed {packages[0].name}", permanent=True)
+            else:
+                package_names = ", ".join(pkg.name for pkg in packages)
+                emit.progress(f"Packed: {package_names}", permanent=True)
+
+    @override
+    def needs_project(self, parsed_args: argparse.Namespace) -> bool:
+        """Project is not required to pack a directory."""
+        if parsed_args.directory:
+            emit.debug("Not loading project because a directory was provided.")
+            return False
+
+        emit.debug("Loading project because a directory was not provided.")
+        return True
+
+    @override
+    def run_managed(self, parsed_args: argparse.Namespace) -> bool:
+        """Return whether the command should run in managed mode or not.
+
+        Packing a directory always runs locally.
+        """
+        if parsed_args.directory:
+            emit.debug("Not running managed mode because a directory was provided.")
+            return False
+
+        return super().run_managed(parsed_args)
+
+
+class SnapCommand(PackCommand):
     """Deprecated legacy command to pack the final snap payload."""
 
     name = "snap"


Test patch:
diff --git a/tests/spread/core22/packing/task.yaml b/tests/spread/core22/packing/task.yaml
--- a/tests/spread/core22/packing/task.yaml
+++ b/tests/spread/core22/packing/task.yaml
@@ -4,6 +4,9 @@ environment:
   CMD/pack: pack
   CMD/pack_output: pack -o output.snap
   CMD/pack_output_subdir: pack --output subdir/output.snap
+  CMD/pack_directory: pack prime
+  CMD/pack_directory_output: pack prime --output output.snap
+  CMD/pack_directory_output_subdir: pack prime --output subdir/output.snap
   CMD/snap: snap
   CMD/snap_output: snap --output output.snap
   CMD/snap_output_subdir: snap -o subdir/output.snap
@@ -25,6 +28,11 @@ restore: |
   restore_yaml "snap/snapcraft.yaml"
 
 execute: |
+  # create a local directory to pack
+  if [[ "$SPREAD_VARIANT" =~ "pack_directory" ]]; then
+    snapcraft prime --destructive-mode
+  fi
+  
   # shellcheck disable=SC2086
   snapcraft $CMD --destructive-mode
 
diff --git a/tests/spread/core24/packing/task.yaml b/tests/spread/core24/packing/task.yaml
--- a/tests/spread/core24/packing/task.yaml
+++ b/tests/spread/core24/packing/task.yaml
@@ -4,6 +4,9 @@ environment:
   CMD/pack: pack
   CMD/pack_output: pack -o output.snap
   CMD/pack_output_subdir: pack --output subdir/output.snap
+  CMD/pack_directory: pack prime
+  CMD/pack_directory_output: pack prime --output output.snap
+  CMD/pack_directory_output_subdir: pack prime --output subdir/output.snap
   CMD/snap: snap
   CMD/snap_output: snap --output output.snap
   CMD/snap_output_subdir: snap -o subdir/output.snap
@@ -17,14 +20,25 @@ prepare: |
   # set_base "$SNAP_DIR/snap/snapcraft.yaml"
 
 restore: |
-  snapcraft clean
   rm -Rf subdir ./*.snap
+  
+  # 'pack_directory' tests are run in destructive-mode
+  if [[ "$SPREAD_VARIANT" =~ "pack_directory" ]]; then
+    snapcraft clean --destructive-mode
+  else
+    snapcraft clean
+  fi
 
   #shellcheck source=tests/spread/tools/snapcraft-yaml.sh
   . "$TOOLS_DIR/snapcraft-yaml.sh"
   restore_yaml "snap/snapcraft.yaml"
 
 execute: |
+  # create a local directory to pack
+  if [[ "$SPREAD_VARIANT" =~ "pack_directory" ]]; then
+    snapcraft prime --destructive-mode
+  fi
+  
   # shellcheck disable=SC2086
   snapcraft $CMD
 
diff --git a/tests/unit/commands/test_lifecycle.py b/tests/unit/commands/test_lifecycle.py
--- a/tests/unit/commands/test_lifecycle.py
+++ b/tests/unit/commands/test_lifecycle.py
@@ -95,7 +95,7 @@ def test_core22_pack_command_with_directory(mocker):
 def test_snap_command_fallback(tmp_path, emitter, mocker, fake_services):
     """Test that the snap command is falling back to the pack command."""
     parsed_args = argparse.Namespace(parts=[], output=tmp_path)
-    mock_pack = mocker.patch("craft_application.commands.lifecycle.PackCommand._run")
+    mock_pack = mocker.patch("snapcraft.commands.lifecycle.PackCommand._run")
     cmd = lifecycle.SnapCommand({"app": APP_METADATA, "services": fake_services})
     cmd.run(parsed_args=parsed_args)
     mock_pack.assert_called_once()
diff --git a/tests/unit/test_application.py b/tests/unit/test_application.py
--- a/tests/unit/test_application.py
+++ b/tests/unit/test_application.py
@@ -23,11 +23,11 @@
 import pytest
 import yaml
 from craft_application import util
-from craft_application.commands.lifecycle import PackCommand
 from craft_parts.packages import snaps
 from craft_providers import bases
 
 from snapcraft import application, services
+from snapcraft.commands import PackCommand
 from snapcraft.errors import ClassicFallback, SnapcraftError
 from snapcraft.models.project import Architecture
 from snapcraft.parts.yaml_utils import CURRENT_BASES, ESM_BASES, LEGACY_BASES