From 3d08ef7f11c65039764ddb548a63e03e955292cb Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sat, 3 Oct 2026 03:17:18 +0300 Subject: [PATCH] chore: configure Papyrus development tools, skills, and agents --- .agents/skills/papyrus-client/SKILL.md | 38 +++ .../skills/papyrus-client/agents/openai.yaml | 4 + .agents/skills/papyrus-server/SKILL.md | 39 +++ .../skills/papyrus-server/agents/openai.yaml | 4 + .agents/skills/papyrus-sync-contract/SKILL.md | 38 +++ .../papyrus-sync-contract/agents/openai.yaml | 4 + .../references/contract-map.md | 52 +++ .codex/agents/papyrus_client.toml | 11 + .codex/agents/papyrus_contract_reviewer.toml | 13 + .codex/agents/papyrus_server.toml | 11 + .codex/config.toml | 8 + .fvmrc | 6 + .github/workflows/tooling.yml | 34 ++ .gitignore | 3 + .vscode/extensions.json | 9 + .vscode/settings.json | 24 ++ .vscode/setup.sh | 9 +- .vscode/tasks.json | 105 +++++- AGENTS.md | 63 ++++ DEVELOPMENT.md | 163 +++++++++ README.md | 23 +- client | 2 +- server | 2 +- tools/dart | 9 + tools/dart-mcp | 4 + tools/flutter | 9 + tools/papyrus | 315 ++++++++++++++++++ tools/tests/test_papyrus.py | 147 ++++++++ 28 files changed, 1140 insertions(+), 9 deletions(-) create mode 100644 .agents/skills/papyrus-client/SKILL.md create mode 100644 .agents/skills/papyrus-client/agents/openai.yaml create mode 100644 .agents/skills/papyrus-server/SKILL.md create mode 100644 .agents/skills/papyrus-server/agents/openai.yaml create mode 100644 .agents/skills/papyrus-sync-contract/SKILL.md create mode 100644 .agents/skills/papyrus-sync-contract/agents/openai.yaml create mode 100644 .agents/skills/papyrus-sync-contract/references/contract-map.md create mode 100644 .codex/agents/papyrus_client.toml create mode 100644 .codex/agents/papyrus_contract_reviewer.toml create mode 100644 .codex/agents/papyrus_server.toml create mode 100644 .codex/config.toml create mode 100644 .fvmrc create mode 100644 .github/workflows/tooling.yml create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 AGENTS.md create mode 100644 DEVELOPMENT.md create mode 100755 tools/dart create mode 100755 tools/dart-mcp create mode 100755 tools/flutter create mode 100755 tools/papyrus create mode 100644 tools/tests/test_papyrus.py diff --git a/.agents/skills/papyrus-client/SKILL.md b/.agents/skills/papyrus-client/SKILL.md new file mode 100644 index 0000000..a7dd341 --- /dev/null +++ b/.agents/skills/papyrus-client/SKILL.md @@ -0,0 +1,38 @@ +--- +name: papyrus-client +description: Implement or debug Papyrus Flutter UI, reader integration, local storage, and platform behavior in client/app. +--- + +Read `client/AGENTS.md` from the Papyrus workspace. Work in `client/app`; use the +workspace's `tools/flutter`, `tools/dart`, and `tools/papyrus` for the pinned SDK. +Locate the workspace by ascending from the active directory to `.fvmrc` and `tools/papyrus`. + +Trace a feature from its page/widget through the Provider state to its repository +or service. `lib/main.dart` owns composition; `lib/config/app_router.dart` owns +navigation. Inspect existing tests in the corresponding `test/` domain before +choosing a regression. For purely visual edits, inspect shared theme tokens and +responsive widgets rather than adding a second style system. + +For persisted changes, follow writes and subscriptions in `lib/data` and +`lib/powersync`. A successful UI update is not evidence that an offline edit was +saved. Account for guest/user/server profile switches and scoped media caches. +For e-ink UI, inspect `lib/themes/app_motion.dart` and e-ink tests. For platform +changes, preserve conditional imports and validate the relevant target. + +For reading changes, inspect `lib/reader` and the actual `papyrus_reader` revision +in `pubspec.yaml`/`pubspec.lock`. The sibling reader checkout is independent. EPUB +and PDF locators have different semantics; preserve restart/resume and progress. + +Use Dart MCP with `client/app` as a root when available for diagnostics, tests and +running-app inspection. CLI checks remain available when MCP is not attached. +Consult Context7 or official docs for framework APIs specific to the installed SDK. + +Run focused tests and `tools/papyrus check client`; for platform adapter changes, +also compile web or the affected available native target. Run the full suite when +the change spans shared composition/persistence. Distinguish analyzer warnings, +formatting debt, actual failures, and unrun targets. The OPDS network smoke test +needs `OPDS_SMOKE_URL`; it is not part of an ordinary offline test run. + +If wire payloads, auth flows, or sync fields change, also read the workspace +`papyrus-sync-contract` skill. Do not broaden a client-only task into a backend +refactor unless the behavior requires it. diff --git a/.agents/skills/papyrus-client/agents/openai.yaml b/.agents/skills/papyrus-client/agents/openai.yaml new file mode 100644 index 0000000..cb5ad40 --- /dev/null +++ b/.agents/skills/papyrus-client/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Papyrus Client" + short_description: "Flutter UI, persistence, and platform checks" + default_prompt: "Use $papyrus-client to implement and verify this Papyrus client change." diff --git a/.agents/skills/papyrus-server/SKILL.md b/.agents/skills/papyrus-server/SKILL.md new file mode 100644 index 0000000..2e76028 --- /dev/null +++ b/.agents/skills/papyrus-server/SKILL.md @@ -0,0 +1,39 @@ +--- +name: papyrus-server +description: Implement or debug Papyrus FastAPI services, PostgreSQL persistence, auth, media, and Alembic migrations in server. +--- + +Read `server/AGENTS.md` from the Papyrus workspace and work from `server/`. Locate +the workspace by ascending to `.fvmrc` and `tools/papyrus`. Use locked `uv` tools; +`tools/papyrus deps server` installs existing dev dependencies without upgrading. + +Trace an endpoint from `papyrus/api/routes` through `papyrus/services` into schemas +and SQLAlchemy models. Keep domain rules and transaction orchestration in services. +New router modules need registration in `papyrus/api/routes/__init__.py`; new models +need exports through `papyrus.models` for Alembic discovery. + +For ownership-sensitive changes, trace the authenticated user through every query, +referenced entity and media path. For sync changes, preserve atomic batches, +owner-level serialization, deletion tombstones and physical deletion after commit. +Inspect `papyrus/services/library_sync.py` and `sync.py` for the existing semantics. + +For persisted schema changes, update models and add/review an Alembic revision. +Inspect both the existing database migration tests and affected domain tests. +Autogeneration needs a running database at the expected current revision; a generated +file alone does not verify an upgrade or data preservation. Apply migrations to a +disposable database when validation needs it. Honor existing approval requirements +for destructive production migrations. + +Use `tools/papyrus test server -- tests/.py`. The test fixtures create +roles/databases and drop/recreate tables. They need local PostgreSQL even without an +`integration` marker. Do not run overlapping suites on the same test database. +CLI runs exclude externally backed `auth_smoke` tests; provider smoke testing is a +separate opt-in workflow. Do not print environment files or rotated auth tokens. + +Run `tools/papyrus check server` for Ruff lint, non-mutating formatting and the +repo's configured strict Mypy. Mypy is installed and used by server CI; adding a +different type checker is not required. If sandbox TS changes, install with +`npm --prefix frontend/dev-pages ci`, then run its `typecheck` and `build` scripts. + +For auth, upload or PowerSync wire changes, also read the workspace +`papyrus-sync-contract` skill and inspect the corresponding client tests. diff --git a/.agents/skills/papyrus-server/agents/openai.yaml b/.agents/skills/papyrus-server/agents/openai.yaml new file mode 100644 index 0000000..2e86887 --- /dev/null +++ b/.agents/skills/papyrus-server/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Papyrus Server" + short_description: "FastAPI services, migrations, and database tests" + default_prompt: "Use $papyrus-server to implement and verify this Papyrus backend change." diff --git a/.agents/skills/papyrus-sync-contract/SKILL.md b/.agents/skills/papyrus-sync-contract/SKILL.md new file mode 100644 index 0000000..e35bfc4 --- /dev/null +++ b/.agents/skills/papyrus-sync-contract/SKILL.md @@ -0,0 +1,38 @@ +--- +name: papyrus-sync-contract +description: Coordinate or review Papyrus client/server changes to auth, PowerSync schemas, offline uploads, ownership, and media contracts. +--- + +Use for changes that cross the client/server boundary. Find the Papyrus workspace +by ascending to `.fvmrc` and `tools/papyrus`; read the affected component AGENTS.md. +Read [contract map](references/contract-map.md) for exact code and test locations. +Check only the contract involved in the request. + +For a synced field, follow the full round trip: Dart model and mapper -> SQLite +column -> queued CRUD serialization -> server Pydantic validation -> owned SQLAlchemy +row -> PowerSync SELECT/alias -> client decoding and repository subscription. +Check ID aliases, omitted versus null fields, booleans, UTC timestamps, JSON text, +foreign-key ownership and local-only guest behavior. Update all required layers +together; a route test alone cannot prove the replicated field round trip. + +Preserve upload transaction acknowledgment after successful server persistence. +Failed batches must remain retryable. Server batches serialize per owner and commit +atomically; deleting physical media occurs after commit. Tombstones stop delayed +offline writes from reviving deleted entities. Tests should prove the affected +failure mode, including account/server profile isolation when relevant. + +For auth, trace refresh, token expiry, logout and profile transition as well as the +successful sign-in. Distinguish opaque refresh tokens, short-lived API access JWTs +and PowerSync credentials. Keep browser callbacks/deep links and API prefixes +compatible with the client's configured server URI. + +Choose focused client and server regressions from the reference map. Run the relevant +component checks. A live sync test needs the API, PowerSync, publication/replication, +keys and a supported native test target; skipped live tests must be reported as +unrun. Do not modify user libraries or purge local state to make a test pass. + +For coordinated implementation, settle payload/schema decisions before assigning +disjoint client/server edits. If delegation is requested, use client/server roles +and the contract reviewer. Serialize database tests on each shared test database. +Report the contract change, tests on both sides, migration needs and unverified +round-trip behavior. diff --git a/.agents/skills/papyrus-sync-contract/agents/openai.yaml b/.agents/skills/papyrus-sync-contract/agents/openai.yaml new file mode 100644 index 0000000..aaf7b17 --- /dev/null +++ b/.agents/skills/papyrus-sync-contract/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Papyrus Sync Contract" + short_description: "Auth and offline sync across client and server" + default_prompt: "Use $papyrus-sync-contract to trace and verify this Papyrus contract change." diff --git a/.agents/skills/papyrus-sync-contract/references/contract-map.md b/.agents/skills/papyrus-sync-contract/references/contract-map.md new file mode 100644 index 0000000..ce84ce7 --- /dev/null +++ b/.agents/skills/papyrus-sync-contract/references/contract-map.md @@ -0,0 +1,52 @@ +# Contract entry points + +Paths below are relative to the Papyrus workspace. Verify the current source before +changing a contract; this map identifies owners rather than freezing field lists. + +| Contract | Client | Server | +| --- | --- | --- | +| API base/prefix | `client/app/lib/auth/papyrus_api_config.dart` | `server/papyrus/config.py`, `server/papyrus/main.py` | +| Auth/token lifecycle | `client/app/lib/auth/{auth_api_client,auth_repository,token_store}.dart`, `lib/providers/auth_provider.dart` | `server/papyrus/api/routes/auth.py`, `schemas/auth.py`, `services/auth/` | +| PowerSync credentials and upload queue | `client/app/lib/powersync/papyrus_powersync_connector.dart` | `server/papyrus/api/routes/sync.py`, `schemas/sync.py`, `services/sync.py` | +| Library schema and row mapping | `client/app/lib/powersync/{papyrus_schema,powersync_book_mapper,library_row_mapper,library_database}.dart` | `server/papyrus/models/sync.py`, `schemas/book.py`, `services/library_validation.py`, `services/library_sync.py` | +| Replication projection | `client/app/lib/powersync/papyrus_schema.dart` | `server/powersync/sync-config.yaml`, `scripts/setup_local_powersync.sh` | +| Profiles and repository watches | `client/app/lib/powersync/{powersync_service,sync_profile_switch_queue}.dart`, `lib/data/data_store.dart` | JWT user identity and row ownership predicates | +| Media upload/cache | `client/app/lib/media/`, `lib/services/book_download_service*` | `server/papyrus/api/routes/media.py`, `services/media.py`, `schemas/media.py` | +| OPDS relay | `client/app/lib/opds/opds_http_client.dart` | `server/papyrus/api/routes/opds.py`, `services/opds.py`, `schemas/opds.py`, `docs/opds-relay.md` | +| Managed acquisition | `client/app/lib/acquisition/`, `lib/providers/acquisition_downloads_provider.dart` | `server/papyrus/api/routes/acquisition.py`, `services/acquisition.py`, `services/acquisition_monitor.py` | + +## Useful existing regressions + +- Client sync: `client/app/test/powersync/`, especially mapper, persistence, connector, + live-library, schema-mode and profile-switch tests. +- Client auth: `client/app/test/auth/`, `test/providers/auth_provider_test.dart`. +- Client media isolation: `client/app/test/media/media_profile_switch_contract_test.dart`, + `media_storage_scope_test.dart`, and upload queue tests. +- Server batches/ownership/deletion: `server/tests/api/routes/test_sync.py`, + `test_library_sync.py`, `test_bookmark_sync.py`, `tests/services/test_sync.py`. +- Server schema/projection: `server/tests/test_powersync_sync_config.py`, + `test_library_migration.py`, `test_bookmark_migration.py`. +- Server auth: `server/tests/api/routes/test_auth.py`, `tests/services/test_auth.py`. +- End-to-end: `client/app/integration_test/powersync_books_integration_test.dart` + is gated by `RUN_POWERSYNC_INTEGRATION=true`. It registers temporary users and + validates two clients, offline reconnect, deletion and isolation against live + services. It imports `dart:io`; do not describe it as a browser integration test. + +Example focused checks, from the workspace: + +```bash +tools/papyrus test client -- test/powersync/papyrus_powersync_connector_test.dart +tools/papyrus test server -- tests/api/routes/test_sync.py tests/services/test_sync.py +``` + +For a deliberately requested live run on an available native device: + +```bash +tools/papyrus test client -- integration_test/powersync_books_integration_test.dart \ + -d macos --dart-define=RUN_POWERSYNC_INTEGRATION=true \ + --dart-define-from-file=.dart_defines +``` + +Check `tools/flutter devices` first. Configure server auth/email requirements for +the disposable test users before running. Never equate the test's default skip +with a successful live sync run. diff --git a/.codex/agents/papyrus_client.toml b/.codex/agents/papyrus_client.toml new file mode 100644 index 0000000..eefdbf1 --- /dev/null +++ b/.codex/agents/papyrus_client.toml @@ -0,0 +1,11 @@ +name = "papyrus_client" +description = "Flutter client implementation and focused widget, persistence, and platform tests." +developer_instructions = """ +Work in the assigned client files. Read client/AGENTS.md and use the papyrus-client +skill when relevant. Trace UI state through providers into repositories. Preserve +offline writes, profile isolation, theme tokens, responsive layouts and e-ink motion. +Use the pinned SDK and run focused analysis/tests. Editing reader/ does not change +the Git-pinned client dependency. Share HTTP or sync contract changes with the parent. +Report changed files, verification, and remaining uncertainty. Do not change another +agent's files or update submodule revisions without the parent assigning that work. +""" diff --git a/.codex/agents/papyrus_contract_reviewer.toml b/.codex/agents/papyrus_contract_reviewer.toml new file mode 100644 index 0000000..4c1e1f1 --- /dev/null +++ b/.codex/agents/papyrus_contract_reviewer.toml @@ -0,0 +1,13 @@ +name = "papyrus_contract_reviewer" +description = "Read-only review of client/server auth, offline sync, ownership, and reader compatibility." +sandbox_mode = "read-only" +developer_instructions = """ +Review the assigned diff and trace affected callers across the client/server boundary. +Use the papyrus-sync-contract skill. Check wire names, IDs, null clearing, serialized +JSON, booleans/timestamps, profile isolation, auth refresh, queue acknowledgement, +ownership, atomicity and tombstones where relevant. For reader changes check the +client's actual pinned dependency. Prioritize concrete regressions with file/line +evidence and a reproduction or missing regression test. Do not edit files, install +packages, start services or run database tests. Separate confirmed bugs from unknowns. +Return concise actionable findings and any material verification gaps. +""" diff --git a/.codex/agents/papyrus_server.toml b/.codex/agents/papyrus_server.toml new file mode 100644 index 0000000..42389dc --- /dev/null +++ b/.codex/agents/papyrus_server.toml @@ -0,0 +1,11 @@ +name = "papyrus_server" +description = "FastAPI services, schemas, async persistence, migrations, and focused backend tests." +developer_instructions = """ +Work in the assigned server files. Read server/AGENTS.md and use the papyrus-server +skill when relevant. Keep routes thin and business rules in async services. Preserve +ownership checks, atomic sync batches, tombstones and post-commit media cleanup. +Include reviewed migrations when persisted schemas change. Use locked uv tools and +coordinate database tests with the parent; fixtures recreate tables and cannot share +a database concurrently. Run relevant tests, Ruff and Mypy. Share contract changes +with the parent. Report changed files, verification, and remaining uncertainty. +""" diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..4d27381 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,8 @@ +[agents] +max_concurrent_threads_per_session = 2 + +[mcp_servers.dart] +command = "bash" +args = ["-c", 'while [ ! -x tools/dart-mcp ]; do if [ "$PWD" = / ]; then echo "Papyrus workspace not found" >&2; exit 1; fi; cd ..; done; exec ./tools/dart-mcp'] +startup_timeout_sec = 120 +tool_timeout_sec = 120 diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 0000000..61dcca7 --- /dev/null +++ b/.fvmrc @@ -0,0 +1,6 @@ +{ + "flutter": "3.41.2", + "runPubGetOnSdkChanges": false, + "updateVscodeSettings": false, + "updateGitIgnore": false +} \ No newline at end of file diff --git a/.github/workflows/tooling.yml b/.github/workflows/tooling.yml new file mode 100644 index 0000000..0fb1a3a --- /dev/null +++ b/.github/workflows/tooling.yml @@ -0,0 +1,34 @@ +name: Workspace tooling + +on: + push: + paths: + - 'tools/**' + - '.vscode/setup.sh' + - '.github/workflows/tooling.yml' + pull_request: + paths: + - 'tools/**' + - '.vscode/setup.sh' + - '.github/workflows/tooling.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + tooling: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install ShellCheck + run: | + sudo apt-get update + sudo apt-get install -y shellcheck + - name: Check workspace commands + run: tools/papyrus check tooling + - name: Check setup script + run: shellcheck .vscode/setup.sh diff --git a/.gitignore b/.gitignore index e43b0f9..999cbb2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ .DS_Store +.fvm/ +.local/ +__pycache__/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..8675953 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "Dart-Code.flutter", + "Dart-Code.dart-code", + "ms-python.python", + "charliermarsh.ruff", + "openai.chatgpt" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5b45580 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,24 @@ +{ + "dart.flutterSdkPath": ".fvm/flutter_sdk", + "dart.projectSearchDepth": 6, + "python.defaultInterpreterPath": "${workspaceFolder}/server/.venv/bin/python", + "python.analysis.extraPaths": ["${workspaceFolder}/server"], + "ruff.path": ["${workspaceFolder}/server/.venv/bin/ruff"], + "git.detectSubmodules": true, + "git.autoRepositoryDetection": "subFolders", + "[dart]": { + "editor.defaultFormatter": "Dart-Code.dart-code", + "editor.formatOnSave": true + }, + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true + }, + "files.watcherExclude": { + "**/.fvm/**": true, + "**/.local/**": true, + "**/.venv/**": true, + "**/.dart_tool/**": true, + "**/build/**": true + } +} diff --git a/.vscode/setup.sh b/.vscode/setup.sh index f9e9f40..3d813d0 100755 --- a/.vscode/setup.sh +++ b/.vscode/setup.sh @@ -6,7 +6,7 @@ workspace_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" client_dir="${workspace_root}/client/app" server_dir="${workspace_root}/server" -required_commands=(git flutter docker uv openssl) +required_commands=(git docker uv openssl) for command_name in "${required_commands[@]}"; do if ! command -v "${command_name}" >/dev/null 2>&1; then @@ -24,6 +24,9 @@ echo "Initializing workspace projects..." git -C "${workspace_root}" submodule sync --recursive git -C "${workspace_root}" submodule update --init --recursive +echo "Preparing project Flutter SDK..." +"${workspace_root}/tools/papyrus" sdk + echo "Preparing client..." if [[ ! -f "${client_dir}/.dart_defines" ]]; then cp "${client_dir}/.dart_defines.example" "${client_dir}/.dart_defines" @@ -32,7 +35,7 @@ fi ( cd "${client_dir}" - flutter pub get + "${workspace_root}/tools/flutter" pub get --enforce-lockfile ) echo "Preparing server..." @@ -43,7 +46,7 @@ fi ( cd "${server_dir}" - uv sync --extra dev + uv sync --locked --extra dev set -a # shellcheck disable=SC1091 diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 531879d..1988185 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -18,7 +18,7 @@ { "label": "Client", "type": "shell", - "command": "flutter run -d chrome --web-hostname papyrus.localhost --web-port 3000 --dart-define-from-file=.dart_defines", + "command": "${workspaceFolder}/tools/papyrus run client", "options": { "cwd": "${workspaceFolder}/client/app" }, @@ -31,7 +31,7 @@ { "label": "Back-end", "type": "shell", - "command": "uv run uvicorn papyrus.main:app --reload --host 0.0.0.0 --port 8080", + "command": "${workspaceFolder}/tools/papyrus run server", "options": { "cwd": "${workspaceFolder}/server" }, @@ -84,6 +84,107 @@ "clear": true, "reveal": "always" } + }, + { + "label": "Diagnose workspace", + "type": "process", + "command": "${workspaceFolder}/tools/papyrus", + "args": [ + "doctor" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "panel": "dedicated", + "reveal": "always" + } + }, + { + "label": "Check client", + "type": "process", + "command": "${workspaceFolder}/tools/papyrus", + "args": [ + "check", + "client" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "panel": "dedicated", + "reveal": "always" + } + }, + { + "label": "Check server", + "type": "process", + "command": "${workspaceFolder}/tools/papyrus", + "args": [ + "check", + "server" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "panel": "dedicated", + "reveal": "always" + } + }, + { + "label": "Check tooling", + "type": "process", + "command": "${workspaceFolder}/tools/papyrus", + "args": [ + "check", + "tooling" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "panel": "dedicated", + "reveal": "always" + } + }, + { + "label": "Test client", + "type": "process", + "command": "${workspaceFolder}/tools/papyrus", + "args": [ + "test", + "client" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "panel": "dedicated", + "reveal": "always" + } + }, + { + "label": "Test server", + "type": "process", + "command": "${workspaceFolder}/tools/papyrus", + "args": [ + "test", + "server" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "panel": "dedicated", + "reveal": "always" + } } ], "inputs": [ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5446d16 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,63 @@ +# Papyrus workspace + +Papyrus is an offline-first, cross-platform book library and reader. This repo +coordinates independent Git submodules; each component has its own history and CI. + +## Find the owner + +| Component | Location | Responsibility | +| --- | --- | --- | +| Client | `client/app` | Flutter UI, Provider state, local persistence, auth, PowerSync uploads, OPDS | +| Server | `server/papyrus` | FastAPI routes, async services, PostgreSQL models, auth, media, sync validation | +| Reader | `reader/lib` | EPUB/PDF reading engine and public reader API | +| Docs | `docs` | Sphinx documentation | +| Website | `website` | Public landing page | + +Read `client/AGENTS.md` or `server/AGENTS.md` for component changes. Start with the +owning component and follow a boundary into another repo when the behavior needs it. +Use `rg` and targeted reads. Never run the workspace Pull/Setup tasks to refresh a +checkout while investigating: setup updates submodules to recorded revisions. +Preserve existing changes and detached submodule revisions. Before implementation, +check the relevant repo's status and choose a branch if commits are requested. + +## Tools and checks + +`tools/papyrus` resolves paths from its own location and runs from the correct repo. +Flutter is pinned in `.fvmrc` to the client CI version; use `tools/flutter` and +`tools/dart` rather than the machine's potentially newer SDK. + +- `tools/papyrus doctor`: tools, SDK, local configuration, submodules, GitHub auth. +- `tools/papyrus deps client|server`: locked dependency setup. +- `tools/papyrus check client|server|tooling`: non-mutating quality checks. +- `tools/papyrus test client -- test/path_test.dart`: focused Flutter test. +- `tools/papyrus test server -- tests/services/test_sync.py`: focused pytest run. +- `tools/papyrus run client|server`: development processes. + +Server fixtures drop and recreate test tables. The CLI checks for a separate local +test database and prevents overlapping CLI server test runs. Direct pytest runs +must also use a distinct test database and must not overlap on that database. +Most route tests need PostgreSQL even if they lack the `integration` marker. +Provider-backed auth smoke tests are excluded by the CLI; run them explicitly only +when that external provider test is requested and configured. + +For Flutter runtime inspection, use the project Dart MCP server with `client/app` +as a root, or the CLI when MCP is unavailable. Consult Context7 or official +documentation for version-specific library behavior. No extra browser MCP is +needed when the session already provides browser/computer-use tools. + +## Reusable workflows + +Project skills live in `.agents/skills`: + +- `papyrus-client`: client implementation and platform verification. +- `papyrus-server`: backend services, migrations and database tests. +- `papyrus-sync-contract`: changes crossing the Dart/HTTP/PostgreSQL/PowerSync boundary. + +Custom roles in `.codex/agents` cover client work, server work and contract review. +Use them when delegation is requested. Give editing agents disjoint files. Share +contract decisions with both implementers and run database tests serially. + +Follow the existing design tokens and e-ink motion preferences. Library operations +must work offline and remain isolated across guest, user, and server profiles. +Treat stored reading positions and user media as durable data. See +`DEVELOPMENT.md` for setup, measured baseline and known gaps. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..17cb304 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,163 @@ +# Development tooling + +Papyrus is a workspace of independent Git submodules. The client is Flutter with +Provider, SQLite/PowerSync, platform adapters and a Git-pinned EPUB/PDF reader. +The server is FastAPI with async SQLAlchemy, Pydantic, Alembic, PostgreSQL and a +self-hosted PowerSync service. Inspect the source for implemented capabilities; +some README feature lists and setup links are ahead of the current checkout. + +## Setup + +The workspace uses Flutter **3.41.2 / Dart 3.11.0**, matching client CI and release +workflows. FVM installs that SDK separately from the machine's global Flutter. +Keep `.fvmrc` and the workflow version pins aligned when upgrading deliberately. + +```bash +dart pub global activate fvm +tools/papyrus sdk +tools/papyrus deps all +tools/papyrus doctor +``` + +Put `~/.pub-cache/bin` on PATH for the `fvm` command. The workspace CLI also finds +FVM there if it is not on PATH. If local config is missing, follow the root README +setup workflow; dependency commands do not create config or start services. + +Useful optional CLIs on macOS: + +```bash +brew install gh jq shellcheck +gh auth login --web +``` + +GitHub sign-in is interactive and enables `gh` issue/PR/CI operations. No token is +stored in this repository. Use `gh pr view`, `gh run list`, and `gh run view --log-failed` +from the component repo. `jq` inspects JSON responses; ShellCheck checks shell +wrappers. Existing `uv`, Docker Compose and Codex provide the other core tools. + +VS Code recommendations and settings configure Dart/Flutter, Python and Ruff, +the FVM SDK, `server/.venv`, and submodule discovery. Format on save is enabled for +Dart/Python. The installed `code` CLI can open this workspace with `code .`. + +## Everyday commands + +Run these from the workspace root, or invoke the CLI by its path from another +directory. All subprocesses use the appropriate component directory. + +| Command | Purpose | +| --- | --- | +| `tools/papyrus doctor` | Inspect tools, config, SDK, repos, Docker and GitHub authentication | +| `tools/papyrus sdk` | Install/link the FVM version from `.fvmrc` | +| `tools/papyrus deps client` | Install client dependencies with its committed lock | +| `tools/papyrus deps server` | Install server/dev dependencies with the uv lock | +| `tools/papyrus check client` | Non-writing Dart format check, Flutter analysis, four web-bootstrap tests | +| `tools/papyrus check server` | Ruff lint, Ruff format check, strict Mypy | +| `tools/papyrus check tooling` | Eight CLI regression tests and ShellCheck | +| `tools/papyrus check all` | All three check groups above; reports independent failures | +| `tools/papyrus test client -- test/auth/token_store_test.dart` | Focused Flutter tests; pass options after `--` | +| `tools/papyrus test client -- --coverage` | Full client suite and coverage | +| `tools/papyrus test server -- tests/services/test_sync.py` | Focused backend tests | +| `tools/papyrus test server` | Full backend suite excluding provider auth smoke tests | +| `tools/papyrus run client` | Chrome app with local Dart defines on port 3000 | +| `tools/papyrus run server` | Reloading API on port 8080 | + +Use `tools/flutter` and `tools/dart` when a command is not covered by the CLI. These +wrappers fail if the pinned SDK is missing. They do not silently use global Flutter. +Reader `deps`, `check`, and `test` commands are also supported; its library lockfile +is ignored, so reader dependency setup resolves its declared constraints normally. +Website and Sphinx checks remain in their own repos. + +Server pytest fixtures drop and recreate test tables. The CLI checks for a separate +local database named `*_test` or `test_*` and locks overlapping CLI server test runs. +Direct pytest runs must not overlap on that database. If a killed process leaves +`.local/server-test.lock`, remove it only after confirming no server test is active. +Most endpoint tests need PostgreSQL even without an `integration` marker. The +Storage task or `docker compose up -d database` from `server/` provides it. + +For TS sandbox changes, use the existing lock and scripts: + +```bash +npm --prefix server/frontend/dev-pages ci +npm --prefix server/frontend/dev-pages run typecheck +npm --prefix server/frontend/dev-pages run build +``` + +## Skills and agents + +`AGENTS.md` maps ownership and tools. `client/AGENTS.md` covers Flutter conventions, +while `server/AGENTS.md` retains its service-layer, testing and migration rules. + +Repo skills are in `.agents/skills` and can be selected automatically or invoked +explicitly as `$papyrus-client`, `$papyrus-server`, and `$papyrus-sync-contract`. +The contract skill includes paths for the Dart/HTTP/database/replication round trip. +Launch Codex from this workspace root to discover the workspace skills and roles. + +Project roles in `.codex/agents` are `papyrus_client`, `papyrus_server`, and +`papyrus_contract_reviewer`. They inherit the chosen model and reasoning. The +reviewer is configured read-only. The project caps delegated agents at two; +ordinary work remains a single-agent workflow unless delegation is requested. + +Example: “Use the client and server agents to implement this agreed payload change +in disjoint files, then use the contract reviewer. Run database tests serially.” + +## Dart MCP and documentation + +`.codex/config.toml` starts the official Dart tooling MCP through `tools/dart-mcp`, +which uses the pinned SDK. The launcher finds the workspace from the root or a +nested component directory. Protocol initialization and enumeration of **25 tools** +were verified from both the root and `client/app`. These include analysis, tests, +hot reload, widget inspection and runtime-error inspection. + +Restart the Codex session to load new project MCP/agent configuration if needed. +Use `client/app` as the MCP project root. Use CLI checks if the active session +does not expose the new server yet. Runtime tools need a running instrumented app. +Context7 is already configured for framework/library documentation; existing +computer-use tools cover browser inspection when available. + +The layout follows [Codex skills](https://learn.chatgpt.com/docs/build-skills), +[custom agents](https://learn.chatgpt.com/docs/agent-configuration/subagents), +and [Dart MCP setup](https://docs.flutter.dev/ai/get-started). FVM's +[project configuration](https://fvm.app/documentation/getting-started/configuration) +keeps SDK selection separate from the global toolchain. + +## Verified baseline — 2026-10-03 + +| Scope | Result | +| --- | --- | +| Client lockfile | Five transitive versions normalized to the CI SDK; locked installation succeeds | +| Client formatting | 449 Dart files checked, no changes required | +| Client analysis | No issues | +| Client tests | 1,387 passed; 19 skipped | +| Web bootstrap | Four tests passed | +| Web build | Release compilation and Wasm dry run succeeded | +| Reader tests | 93 passed; 21 skipped in the sibling checkout | +| Server lint/types | Ruff lint and Mypy pass (138 source files) | +| Server tests | 340 passed; two provider smoke tests excluded | +| Server formatting | Five pre-existing files need formatting; check correctly fails | +| Tooling | Eight regression tests, ShellCheck and skill frontmatter validation pass | +| Dart MCP | Initialized successfully; 25 tools enumerated from root and nested cwd | +| Local platforms | Flutter doctor finds Android SDK, Xcode, Chrome and macOS target | +| GitHub CLI | Installed; account sign-in remains pending | + +The five server formatting files are `papyrus/models/powersync_demo.py`, +`tests/api/routes/test_auth_sandbox.py`, `test_powersync_sandbox.py`, +`tests/integration/test_auth_smoke.py`, and `tests/services/test_auth.py`. +They were not reformatted as part of tooling setup. Local verification logs are +ignored under `.local/tooling/`. + +Live cross-device PowerSync and external OAuth/SMTP smoke tests were not run. +Native release builds and Windows/Linux builds were not run. The global Flutter +SDK remains newer; Flutter doctor may report that PATH mismatch while project +commands and VS Code use FVM correctly. + +The client CI now enforces its lockfile and checks formatting without writing. +The workspace tooling workflow runs the CLI regressions and ShellCheck on relevant +pushes and pull requests; it does not require the application submodules or services. + +The server README links to auth, acquisition and PowerSync guides absent from +this checkout. Its existing `.env.example`, tests, services, and +`docs/opds-relay.md` are usable sources. The client reader dependency is pinned to +`08a5161b9d00eb73581f74ce087b9ad6c1568ca7`, while the sibling reader checkout is at a +different revision. Editing it alone does not change client behavior. For a joint +reader change, use an ignored `client/app/pubspec_overrides.yaml` with a local path +override during validation, then coordinate a deliberate revision update. diff --git a/README.md b/README.md index 6ec8eb8..6df3bd0 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ This repository is the development entry point for Papyrus. - [Flutter](https://flutter.dev/) - [Docker](https://docs.docker.com/) - [Python](https://www.python.org/) and [uv](https://docs.astral.sh/uv/) package manager +- [FVM](https://fvm.app/) to use the project Flutter version (`dart pub global activate fvm`) - [VS Code](https://code.visualstudio.com/) is recommended to run the included tasks ## Clone @@ -57,12 +58,12 @@ docker compose up database mailpit powersync-storage powersync ```bash cd server -uv run uvicorn papyrus.main:app --reload --host 0.0.0.0 --port 8080 +uv run --locked uvicorn papyrus.main:app --reload --host 0.0.0.0 --port 8080 ``` ```bash cd client/app -flutter run -d chrome \ +../../tools/flutter run -d chrome \ --web-hostname papyrus.localhost \ --web-port 3000 \ --dart-define-from-file=.dart_defines @@ -72,3 +73,21 @@ flutter run -d chrome \ The `Purge` task deletes local Papyrus databases, uploaded media, PowerSync state, and browser storage before recreating clean service state. + +## Development tools + +Use the workspace CLI from the repository root: + +```bash +tools/papyrus doctor +tools/papyrus check client +tools/papyrus check server +tools/papyrus test client -- test/auth/token_store_test.dart +tools/papyrus test server -- tests/services/test_sync.py +``` + +It uses the Flutter version pinned in `.fvmrc` and the server's uv lockfile. +The same checks and test suites are available as VS Code tasks. + +See [development tooling](DEVELOPMENT.md) for the project skills, agent roles, +Dart MCP integration, setup instructions, and verified baseline. diff --git a/client b/client index 1bf115e..513d177 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit 1bf115e280ebb29fc6bb7cf6486721c461d53d3b +Subproject commit 513d1774670be1a617affa5249fc06a65d98b1ce diff --git a/server b/server index bfc4fbc..71b21c6 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit bfc4fbc7406b1fd225fadb0c250e65b116de2425 +Subproject commit 71b21c65833891826427a568a6d990db964b5962 diff --git a/tools/dart b/tools/dart new file mode 100755 index 0000000..b681cdb --- /dev/null +++ b/tools/dart @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +workspace_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +sdk_command="${workspace_root}/.fvm/flutter_sdk/bin/dart" +if [[ ! -x "${sdk_command}" ]]; then + echo "Project Dart SDK is missing. Run: tools/papyrus sdk" >&2 + exit 1 +fi +exec "${sdk_command}" "$@" diff --git a/tools/dart-mcp b/tools/dart-mcp new file mode 100755 index 0000000..0bf7a61 --- /dev/null +++ b/tools/dart-mcp @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +workspace_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec "${workspace_root}/tools/dart" mcp-server "$@" diff --git a/tools/flutter b/tools/flutter new file mode 100755 index 0000000..6ab8c1a --- /dev/null +++ b/tools/flutter @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +workspace_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +sdk_command="${workspace_root}/.fvm/flutter_sdk/bin/flutter" +if [[ ! -x "${sdk_command}" ]]; then + echo "Project Flutter SDK is missing. Run: tools/papyrus sdk" >&2 + exit 1 +fi +exec "${sdk_command}" "$@" diff --git a/tools/papyrus b/tools/papyrus new file mode 100755 index 0000000..608e8e3 --- /dev/null +++ b/tools/papyrus @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""Workspace commands using the project's pinned Flutter SDK and uv lock.""" + +import argparse +import json +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +CLIENT = ROOT / "client/app" +SERVER = ROOT / "server" + + +def run(args, cwd=ROOT, env=None): + print(f"[{cwd.relative_to(ROOT) or '.'}] {shlex.join(map(str, args))}", flush=True) + try: + return subprocess.run( + list(map(str, args)), cwd=cwd, env=env, check=False + ).returncode + except FileNotFoundError as error: + print(f"Missing command: {error.filename}", file=sys.stderr) + return 127 + + +def sdk_command(name): + command = ROOT / ".fvm/flutter_sdk/bin" / name + if not command.is_file(): + raise RuntimeError("Project Flutter SDK is missing. Run tools/papyrus sdk.") + return str(command) + + +def uv(*args): + return ["uv", "run", "--locked", *args] + + +def checks(component): + if component == "client": + return [ + ( + [ + sdk_command("dart"), + "format", + "--output=none", + "--set-exit-if-changed", + ".", + ], + CLIENT, + ), + ( + [ + sdk_command("flutter"), + "analyze", + "--no-pub", + "--no-fatal-warnings", + "--no-fatal-infos", + ], + CLIENT, + ), + (["node", "--test", "test/web/flutter_bootstrap_test.cjs"], CLIENT), + ] + if component == "server": + return [ + (uv("ruff", "check", "."), SERVER), + (uv("ruff", "format", "--check", "."), SERVER), + (uv("mypy", "."), SERVER), + ] + if component == "reader": + return [ + ( + [ + sdk_command("dart"), + "format", + "--output=none", + "--set-exit-if-changed", + "lib", + "test", + ], + ROOT / "reader", + ), + ([sdk_command("flutter"), "analyze", "--no-pub"], ROOT / "reader"), + ] + return [ + ( + [sys.executable, "-m", "unittest", "discover", "-s", "tools/tests", "-v"], + ROOT, + ), + (["shellcheck", "tools/flutter", "tools/dart", "tools/dart-mcp"], ROOT), + ] + + +def check(component): + components = ["tooling", "client", "server"] if component == "all" else [component] + failed = [] + for item in components: + for args, cwd in checks(item): + if run(args, cwd): + failed.append(shlex.join(args)) + if failed: + print( + "Failed checks:\n" + "\n".join(f" {item}" for item in failed), + file=sys.stderr, + ) + return int(bool(failed)) + + +def test_server(args): + probe = """ +import json, os +from sqlalchemy.engine import make_url +from papyrus.config import get_settings +s = get_settings() +raw = os.environ.get('TEST_DATABASE_URL') +url = make_url(raw) if raw else None +host = url.host if url else os.environ.get('TEST_POSTGRES_HOST', s.postgres_host) +db = url.database if url else os.environ.get( + 'TEST_POSTGRES_DB', s.postgres_db + '_test' +) +print(json.dumps({'host': host, 'database': db, 'app_database': s.postgres_db})) +""" + result = subprocess.run( + uv("python", "-c", probe), cwd=SERVER, capture_output=True, text=True + ) + if result.returncode: + raise RuntimeError( + "Cannot load server test configuration. " + "Run tools/papyrus deps server and check server/.env." + ) + target = json.loads(result.stdout) + database = target["database"] or "" + if ( + target["host"] not in {"localhost", "127.0.0.1", "::1"} + or database == target["app_database"] + or not (database.endswith("_test") or database.startswith("test_")) + ): + raise RuntimeError( + "Tests drop tables. " + "Configure a distinct local database named test_* or *_test." + ) + print(f"Test database: {database} on {target['host']}", flush=True) + lock = ROOT / ".local/server-test.lock" + lock.parent.mkdir(exist_ok=True) + try: + lock.mkdir() + except FileExistsError: + raise RuntimeError( + "Another server test command holds .local/server-test.lock. " + "Do not share its database." + ) from None + try: + return run(uv("pytest", "-m", "not auth_smoke", *args), SERVER) + finally: + lock.rmdir() + + +def test(component, args): + if component == "server": + return test_server(args) + directory = CLIENT if component == "client" else ROOT / "reader" + return run([sdk_command("flutter"), "test", "--no-pub", *args], directory) + + +def doctor(): + failed = False + pin = json.loads((ROOT / ".fvmrc").read_text())["flutter"] + print(f"Workspace: {ROOT}\nFlutter pin: {pin}") + for name in ("git", "uv", "docker", "node", "gh", "jq", "shellcheck"): + executable = shutil.which(name) + print( + f"{'OK' if executable else 'MISSING'} {name}: {executable or 'not on PATH'}" + ) + failed |= executable is None + for filename in ( + "client/app/.dart_defines", + "client/app/.dart_tool/package_config.json", + "server/.env", + "server/.venv/bin/python", + ): + present = (ROOT / filename).is_file() + print(f"{'OK' if present else 'MISSING'} {filename}") + failed |= not present + try: + result = subprocess.run( + [sdk_command("flutter"), "--version", "--machine"], + capture_output=True, + text=True, + ) + actual = ( + json.loads(result.stdout)["frameworkVersion"] + if result.returncode == 0 + else "unknown" + ) + print(f"{'OK' if actual == pin else 'MISMATCH'} project Flutter: {actual}") + failed |= actual != pin + except (RuntimeError, ValueError): + print("MISSING project SDK: run tools/papyrus sdk") + failed = True + for name in ("client", "server", "reader", "docs", "website"): + if (ROOT / name / ".git").exists(): + run(["git", "status", "--short", "--branch"], ROOT / name) + else: + print(f"MISSING submodule {name}") + failed = True + if shutil.which("docker"): + failed |= bool(run(["docker", "compose", "version"])) + failed |= bool(run(["docker", "info", "--format", "{{.ServerVersion}}"])) + if shutil.which("gh") and run(["gh", "auth", "status"]): + print( + "GitHub sign-in pending: gh auth login --web " + "(local development works without it)" + ) + return int(failed) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("doctor", help="Inspect tools and repos without changing them") + commands.add_parser("sdk", help="Install/link the Flutter version in .fvmrc") + deps = commands.add_parser("deps", help="Install dependencies from existing locks") + deps.add_argument("component", choices=["client", "server", "reader", "all"]) + quality = commands.add_parser( + "check", help="Check formatting, analysis and types without fixing files" + ) + quality.add_argument( + "component", + choices=["client", "server", "reader", "tooling", "all"], + default="all", + nargs="?", + ) + tests = commands.add_parser("test", help="Run tests; forward options after --") + tests.add_argument("component", choices=["client", "server", "reader"]) + tests.add_argument("args", nargs=argparse.REMAINDER) + start = commands.add_parser( + "run", help="Run client or API with the workspace configuration" + ) + start.add_argument("component", choices=["client", "server"]) + options = parser.parse_args(argv) + try: + if options.command == "doctor": + return doctor() + if options.command == "sdk": + fvm = shutil.which("fvm") or str(Path.home() / ".pub-cache/bin/fvm") + pin = json.loads((ROOT / ".fvmrc").read_text())["flutter"] + return run([fvm, "use", pin, "--force", "--skip-pub-get"]) + if options.command == "check": + return check(options.component) + if options.command == "test": + args = options.args[1:] if options.args[:1] == ["--"] else options.args + return test(options.component, args) + if options.command == "deps": + selected = ( + ["client", "server"] + if options.component == "all" + else [options.component] + ) + codes = [] + for item in selected: + if item == "server": + codes.append( + run(["uv", "sync", "--locked", "--extra", "dev"], SERVER) + ) + else: + directory = CLIENT if item == "client" else ROOT / "reader" + codes.append( + run( + [ + sdk_command("flutter"), + "pub", + "get", + *(["--enforce-lockfile"] if item == "client" else []), + ], + directory, + ) + ) + return int(any(codes)) + if options.component == "server": + return run( + uv( + "uvicorn", + "papyrus.main:app", + "--reload", + "--host", + "0.0.0.0", + "--port", + "8080", + ), + SERVER, + ) + return run( + [ + sdk_command("flutter"), + "run", + "-d", + "chrome", + "--web-hostname", + "papyrus.localhost", + "--web-port", + "3000", + "--dart-define-from-file=.dart_defines", + ], + CLIENT, + ) + except (RuntimeError, OSError) as error: + print(str(error), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + sys.exit(130) diff --git a/tools/tests/test_papyrus.py b/tools/tests/test_papyrus.py new file mode 100644 index 0000000..ffb779a --- /dev/null +++ b/tools/tests/test_papyrus.py @@ -0,0 +1,147 @@ +"""Exercise command dispatch, check outcomes, and destructive test-db boundaries.""" + +import contextlib +import importlib.machinery +import importlib.util +import io +import json +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +loader = importlib.machinery.SourceFileLoader( + "papyrus_cli", str(Path(__file__).resolve().parents[1] / "papyrus") +) +spec = importlib.util.spec_from_loader(loader.name, loader) +cli = importlib.util.module_from_spec(spec) +loader.exec_module(cli) + + +class WorkspaceCommandsTest(unittest.TestCase): + def setUp(self): + self.output = contextlib.redirect_stdout(io.StringIO()) + self.errors = contextlib.redirect_stderr(io.StringIO()) + self.output.__enter__() + self.errors.__enter__() + self.addCleanup(self.output.__exit__, None, None, None) + self.addCleanup(self.errors.__exit__, None, None, None) + + def test_test_selector_and_options_reach_correct_component(self): + with patch.object(cli, "test", return_value=3) as test: + result = cli.main( + [ + "test", + "client", + "--", + "test/auth/token_store_test.dart", + "--plain-name", + "refresh token", + ] + ) + self.assertEqual(result, 3) + test.assert_called_once_with( + "client", + ["test/auth/token_store_test.dart", "--plain-name", "refresh token"], + ) + + def test_commands_preserve_arguments_without_shell_interpretation(self): + args = ["node", "a file.js", "$(touch unexpected)"] + with patch.object( + cli.subprocess, "run", return_value=subprocess.CompletedProcess(args, 0) + ) as run: + self.assertEqual(cli.run(args, cli.CLIENT), 0) + run.assert_called_once_with(args, cwd=cli.CLIENT, env=None, check=False) + + def test_missing_command_returns_failure(self): + with patch.object( + cli.subprocess, + "run", + side_effect=FileNotFoundError(2, "not found", "missing"), + ): + self.assertEqual(cli.run(["missing"]), 127) + + def test_checks_continue_and_propagate_any_failure(self): + commands = [(["first"], cli.ROOT), (["second"], cli.ROOT)] + with ( + patch.object(cli, "checks", return_value=commands), + patch.object(cli, "run", side_effect=[1, 0]) as run, + ): + self.assertEqual(cli.check("server"), 1) + self.assertEqual(run.call_count, 2) + + def test_client_format_check_does_not_write_files(self): + with patch.object(cli, "sdk_command", side_effect=lambda name: name): + args, cwd = cli.checks("client")[0] + self.assertIn("--output=none", args) + self.assertIn("--set-exit-if-changed", args) + self.assertEqual(cwd, cli.CLIENT) + + def test_sdk_absent_does_not_fall_back_to_global_flutter(self): + with ( + tempfile.TemporaryDirectory() as directory, + patch.object(cli, "ROOT", Path(directory)), + ): + with self.assertRaisesRegex(RuntimeError, "SDK is missing"): + cli.sdk_command("flutter") + + def test_server_rejects_application_remote_or_unmarked_database(self): + for host, database in [ + ("127.0.0.1", "papyrus"), + ("remote.example.com", "papyrus_test"), + ("127.0.0.1", "library"), + ]: + with self.subTest(host=host, database=database): + result = subprocess.CompletedProcess( + [], + 0, + json.dumps( + {"host": host, "database": database, "app_database": "papyrus"} + ), + ) + with ( + patch.object(cli.subprocess, "run", return_value=result), + patch.object(cli, "run") as run, + ): + with self.assertRaisesRegex( + RuntimeError, "distinct local database" + ): + cli.test_server([]) + run.assert_not_called() + + def test_server_lock_blocks_overlap_and_is_released_after_failure(self): + result = subprocess.CompletedProcess( + [], + 0, + json.dumps( + { + "host": "localhost", + "database": "papyrus_test", + "app_database": "papyrus", + } + ), + ) + with ( + tempfile.TemporaryDirectory() as directory, + patch.object(cli, "ROOT", Path(directory)), + ): + lock = Path(directory) / ".local/server-test.lock" + lock.mkdir(parents=True) + with ( + patch.object(cli.subprocess, "run", return_value=result), + patch.object(cli, "run") as run, + ): + with self.assertRaisesRegex(RuntimeError, "Another server test"): + cli.test_server([]) + run.assert_not_called() + lock.rmdir() + run.return_value = 4 + self.assertEqual(cli.test_server(["tests/test_models.py"]), 4) + self.assertFalse(lock.exists()) + self.assertIn("--locked", run.call_args.args[0]) + self.assertIn("tests/test_models.py", run.call_args.args[0]) + + +if __name__ == "__main__": + unittest.main()