feat(cli): add private NanoShare sync client
Build and Push Docker Container / build-and-push (push) Successful in 1m3s

- Add a separate installable NanoShare CLI package under cli/.
- Implement browser login with local callback and refresh-token config.
- Add upload, list, download, sync, and watch CLI commands.
- Add private owner-only file download endpoint for CLI downloads.
- Add CLI auth endpoints for browser login and token refresh.
- Return file_id from ServiceLink uploads for reliable sync state.
- Exclude the CLI package from NanoShare container builds.
- Include tests for private downloads and sync state updates.
This commit is contained in:
2026-07-27 15:52:34 +02:00
parent 4ee4d6a0a4
commit 42064de633
21 changed files with 1118 additions and 3 deletions
+170
View File
@@ -0,0 +1,170 @@
from __future__ import annotations
import hashlib
import json
import os
import shutil
import sys
import time
from pathlib import Path
from .client import delete_remote, download, list_remote, upload
SYNC_DIR_NAME = '.nanoshare-sync'
STATE_FILE_NAME = 'state.json'
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open('rb') as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b''):
digest.update(chunk)
return digest.hexdigest()
def state_path(root: Path) -> Path:
return root / SYNC_DIR_NAME / STATE_FILE_NAME
def load_state(root: Path) -> dict:
path = state_path(root)
if not path.is_file():
return {'version': 1, 'files': {}}
try:
data = json.loads(path.read_text())
except (OSError, ValueError):
return {'version': 1, 'files': {}}
if not isinstance(data, dict):
return {'version': 1, 'files': {}}
data.setdefault('version', 1)
data.setdefault('files', {})
return data
def save_state(root: Path, state: dict) -> None:
path = state_path(root)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(f'{path.name}.{os.getpid()}.tmp')
tmp.write_text(json.dumps(state, indent=2, sort_keys=True))
os.replace(tmp, path)
def iter_local_files(root: Path):
sync_dir = root / SYNC_DIR_NAME
for path in sorted(root.rglob('*')):
if not path.is_file():
continue
if path == state_path(root) or sync_dir in path.parents:
continue
yield path
def relative(root: Path, path: Path) -> str:
return path.relative_to(root).as_posix()
def safe_remote_name(name: str, file_id: str) -> str:
candidate = Path(name or file_id).name
return candidate or file_id
def unique_path(path: Path) -> Path:
if not path.exists():
return path
stamp = time.strftime('%Y%m%d-%H%M%S')
suffix = ''.join(path.suffixes)
stem = path.name[:-len(suffix)] if suffix else path.name
candidate = path.with_name(f'{stem}.conflict-{stamp}{suffix}')
counter = 2
while candidate.exists():
candidate = path.with_name(f'{stem}.conflict-{stamp}-{counter}{suffix}')
counter += 1
return candidate
def remote_by_id(remote_files: list[dict]) -> dict[str, dict]:
return {item['file_id']: item for item in remote_files if isinstance(item, dict) and item.get('file_id')}
def remote_by_name(remote_files: list[dict]) -> dict[str, dict]:
result = {}
for item in remote_files:
if isinstance(item, dict) and item.get('file_name') and item.get('file_id'):
result.setdefault(item['file_name'], item)
return result
def sync_once(args, client) -> int:
root = Path(args.folder).expanduser().resolve()
root.mkdir(parents=True, exist_ok=True)
state = load_state(root)
known = state.setdefault('files', {})
try:
remote_files = list_remote(client, args.node)
remote_ids = remote_by_id(remote_files)
remote_names = remote_by_name(remote_files)
local_paths = {relative(root, path): path for path in iter_local_files(root)}
for rel, path in local_paths.items():
current_hash = sha256(path)
entry = known.get(rel)
remote_id = entry.get('file_id') if isinstance(entry, dict) else None
old_hash = entry.get('sha256') if isinstance(entry, dict) else None
if old_hash == current_hash and remote_id in remote_ids:
continue
if remote_id and remote_id not in remote_ids and old_hash != current_hash:
conflict_path = unique_path(path)
shutil.copy2(path, conflict_path)
print(f'conflict: kept changed local copy at {conflict_path}')
print(f'upload: {rel}')
result = upload(client, args.node, path, rel, args.note or '', args.expires or '')
new_file_id = result.get('file_id') or result.get('id')
if not new_file_id:
matching = remote_names.get(rel)
refreshed = list_remote(client, args.node)
new_file_id = (remote_by_name(refreshed).get(rel) or matching or {}).get('file_id')
remote_files = refreshed
remote_ids = remote_by_id(remote_files)
remote_names = remote_by_name(remote_files)
if not new_file_id:
print(f'warning: upload succeeded but file_id is unknown for {rel}', file=sys.stderr)
continue
if remote_id and remote_id in remote_ids and remote_id != new_file_id:
try:
delete_remote(client, args.node, remote_id)
print(f'delete old remote: {rel}')
remote_files = [item for item in remote_files if item.get('file_id') != remote_id]
remote_ids = remote_by_id(remote_files)
remote_names = remote_by_name(remote_files)
except Exception as exc:
print(f'warning: could not delete old remote {remote_id}: {exc}', file=sys.stderr)
known[rel] = {
'file_id': new_file_id,
'sha256': current_hash,
'local_mtime': path.stat().st_mtime,
'file_name': rel,
}
known_ids = {entry.get('file_id') for entry in known.values() if isinstance(entry, dict)}
for remote in remote_files:
file_id = remote.get('file_id')
if not file_id or file_id in known_ids:
continue
name = safe_remote_name(remote.get('file_name') or '', file_id)
path = unique_path(root / name)
print(f'download: {file_id} -> {relative(root, path)}')
download(client, args.node, file_id, path)
known[relative(root, path)] = {
'file_id': file_id,
'sha256': sha256(path),
'local_mtime': path.stat().st_mtime,
'file_name': relative(root, path),
}
if args.delete:
for rel, entry in list(known.items()):
if rel in local_paths:
continue
file_id = entry.get('file_id') if isinstance(entry, dict) else None
if file_id and file_id in remote_ids:
print(f'delete remote missing locally: {rel}')
delete_remote(client, args.node, file_id)
known.pop(rel, None)
save_state(root, state)
except Exception as exc:
print(f'error: {exc}', file=sys.stderr)
return 1
return 0