Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.git
.github
__pycache__
*.pyc
tests/
doc/
vault/
config/
.fns_state.json
*.md
!fns_cli/*.md
18 changes: 18 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
FROM python:3.13-slim

WORKDIR /app

# Install dependencies first for better layer caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy application code
COPY fns_cli/ ./fns_cli/
Comment on lines +1 to +10
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 issue (security): The container runs as root; consider adding a non-root user for improved security.

Running the container as root amplifies the impact of any compromise of the CLI or its dependencies. Please add a dedicated non-root user (e.g., fns), chown /app, /app/config, and /app/vault to that user, and switch to it with USER. This usually needs no code changes and significantly reduces risk.


# Default config mount point
VOLUME ["/app/config", "/app/vault"]

ENV PYTHONUNBUFFERED=1

ENTRYPOINT ["python", "-m", "fns_cli.main"]
CMD ["run", "-c", "/app/config/config.yaml"]
25 changes: 21 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Edit `config.yaml`:
```yaml
server:
api: "https://your-server-address" # Fast Note Sync Service base URL
token: "your_api_token" # API token from the admin panel
token: "your_api_token" # JWT token from the Token Management panel
vault: "notes" # Vault name; must match the Obsidian plugin

sync:
Expand All @@ -85,6 +85,7 @@ client:
reconnect_max_retries: 15
reconnect_base_delay: 3
heartbeat_interval: 30
client_type: "fns-cli" # Client identifier for 3D-RBAC auth

logging:
level: "INFO"
Expand All @@ -93,16 +94,32 @@ logging:

**How to obtain a token**

> **Note:** Since service version with 3D-RBAC, tokens are now JWT-based and managed via the web UI.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (typo): Consider adding an article before "service version" for grammatical correctness.

The wording "Since service version with 3D-RBAC" is ungrammatical. Consider revising to "Since the service version with 3D-RBAC" or "Since the 3D-RBAC-enabled service version" to improve clarity.

Suggested change
> **Note:** Since service version with 3D-RBAC, tokens are now JWT-based and managed via the web UI.
> **Note:** Since the 3D-RBAC-enabled service version, tokens are now JWT-based and managed via the web UI.


1. Open the Fast Note Sync Service web UI (e.g. `https://your-server-address`)
2. Sign in
3. Click **"Copy API Config"**
4. Copy `api`, `apiToken`, and `vault` from the JSON into `config.yaml`
3. Navigate to **Settings → Token Management**
4. Click **"Create Token"** with these settings:
- **Client Type**: `fns-cli` (or a descriptive name like `my-server-cli`)
- **Scope**: `p:ws c:fns-cli* f:note_rw,file_rw,config_rw` (full read/write access via WebSocket)
- **Expired Days**: set as needed (e.g. `365`)
5. Copy the generated JWT token into `config.yaml`'s `token` field

**Scope examples:**

| Use case | Scope |
|----------|-------|
| Full sync (read + write) | `p:ws c:fns-cli* f:note_rw,file_rw,config_rw` |
| Read-only (pull only) | `p:ws c:fns-cli* f:note_r,file_r,config_r` |
| Notes only | `p:ws c:fns-cli* f:note_rw` |

> **Fallback:** If your server version still supports legacy tokens, the old "Copy API Config" method also works — the CLI sends the token as-is.

Optional environment variables (override when not set in the file):

```bash
export FNS_API="https://your-server-address"
export FNS_TOKEN="your_api_token"
export FNS_TOKEN="your_jwt_token"
```

### 4. Run
Expand Down
1 change: 1 addition & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ client:
reconnect_max_retries: 15
reconnect_base_delay: 3
heartbeat_interval: 30
client_type: "fns-cli"

logging:
level: "INFO"
Expand Down
13 changes: 13 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
services:
fns-cli:
build: .
container_name: fns-cli
restart: unless-stopped
volumes:
- ./config:/app/config # Mount config directory
- ./vault:/app/vault # Mount vault directory for sync
environment:
- PYTHONUNBUFFERED=1
# Optional: override config values via env vars
# - FNS_API=https://your-server-address
# - FNS_TOKEN=your_jwt_token
2 changes: 1 addition & 1 deletion fns_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""FastNodeSync CLI - A command-line client for Fast Note Sync Service."""

__version__ = "0.1.0"
__version__ = "0.2.0"
17 changes: 15 additions & 2 deletions fns_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import asyncio
import logging
import sys
from typing import Any, Callable, Coroutine

import websockets
Expand All @@ -18,6 +19,7 @@
decode_message,
parse_binary_chunk,
)
from . import __version__

log = logging.getLogger("fns_cli.client")

Expand Down Expand Up @@ -128,9 +130,13 @@ async def _connect(self) -> None:
self._ensure_ready_event().clear()
self._connect_count += 1

client_type = self.config.client.client_type
url = (
f"{self.config.ws_api}/api/user/sync"
f"?lang=zh-cn&count={self._connect_count}"
f"&client={client_type}"
f"&clientName=FastNodeSync-CLI"
f"&clientVersion={__version__}"
)
log.info("Connecting to %s", url)

Expand Down Expand Up @@ -197,8 +203,15 @@ async def _on_auth_response(self, msg: WSMessage) -> None:

client_info = WSMessage(ACTION_CLIENT_INFO, {
"name": "FastNodeSync-CLI",
"version": "0.1.0",
"type": "cli",
"version": __version__,
"type": self.config.client.client_type,
"isDesktop": True,
"isMobile": False,
"isPhone": False,
"isTablet": False,
"isMacOS": sys.platform == "darwin",
"isWin": sys.platform == "win32",
"isLinux": sys.platform.startswith("linux"),
})
await self._raw_send(client_info.encode())
await self._flush_queue()
Expand Down
2 changes: 2 additions & 0 deletions fns_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class ClientConfig:
reconnect_max_retries: int = 15
reconnect_base_delay: int = 3
heartbeat_interval: int = 30
client_type: str = "fns-cli"


@dataclass
Expand Down Expand Up @@ -108,6 +109,7 @@ def load_config(path: str) -> AppConfig:
reconnect_max_retries=c.get("reconnect_max_retries", 15),
reconnect_base_delay=c.get("reconnect_base_delay", 3),
heartbeat_interval=c.get("heartbeat_interval", 30),
client_type=c.get("client_type", "fns-cli"),
)

if "logging" in raw:
Expand Down
1 change: 1 addition & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def _make_config() -> MagicMock:
config = MagicMock()
config.client.reconnect_base_delay = 1
config.client.reconnect_max_retries = 3
config.client.client_type = "fns-cli"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add tests to verify the new WebSocket URL query parameters (client, clientName, clientVersion).

Right now the tests only set config.client.client_type but never verify that _connect (or its public entrypoint) actually uses these values. Please add/extend a test that drives a real connection attempt and asserts that the final WebSocket URL includes:

  • client=fns-cli (or the configured value)
  • clientName=FastNodeSync-CLI
  • clientVersion equal to fns_cli.__version__.

This will guard against regressions in URL construction for the new query parameters.

Suggested implementation:

    config = MagicMock()
    config.client.reconnect_base_delay = 1
    config.client.reconnect_max_retries = 3
    config.client.client_type = "fns-cli"
    config.server.token = "token"
    config.ws_api = "wss://example.com"
    return config


def test_websocket_url_includes_client_query_params(mocker):
    from urllib.parse import urlparse, parse_qs
    import fns_cli

    # Arrange
    config = _make_config()  # adjust to the actual helper/factory name if different
    client = FastNodeSyncClient(config)  # adjust to the actual client class under test

    # Patch the websocket connect function used by the client to initiate connections
    mock_connect = mocker.patch(
        "fns_cli.client.websocket.connect", autospec=True
    )  # adjust import path to match implementation

    # Act: trigger a connection attempt (use the public entrypoint if available)
    client._connect()  # or client.connect(), depending on what is exposed

    # Assert: inspect the URL used for the WebSocket connection
    called_url = mock_connect.call_args[0][0]
    parsed = urlparse(called_url)
    qs = parse_qs(parsed.query)

    assert qs["client"] == [config.client.client_type]
    assert qs["clientName"] == ["FastNodeSync-CLI"]
    assert qs["clientVersion"] == [fns_cli.__version__]

To integrate this test with the existing codebase, you will likely need to:

  1. Replace _make_config() with the actual helper/fixture used to construct the config MagicMock. If this is a pytest fixture (e.g. config), change the test signature to accept it instead of calling a function.
  2. Replace FastNodeSyncClient with the actual client class name being tested (for example, WebsocketClient or similar).
  3. Adjust the mocker.patch target "fns_cli.client.websocket.connect" to point to the exact function used by your _connect implementation (e.g. "fns_cli.client._websocket_connect" or "fns_cli.client.websocket.WebSocketApp"), and adapt how you extract the URL from call_args if the signature differs.
  4. Prefer calling the public entrypoint used to start a connection (e.g. client.connect() or client.start()) instead of client._connect() if such a method exists, so the test remains resilient to internal refactors.
  5. If your code constructs the WebSocket URL in a separate helper, ensure this test exercises that same path (not a duplicated or test-only code path).

config.server.token = "token"
config.ws_api = "wss://example.com"
return config
Expand Down