rpr-cli 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rpr/__init__.py +1 -0
- rpr/agent/__init__.py +29 -0
- rpr/agent/approval.py +19 -0
- rpr/agent/bootstrap.py +60 -0
- rpr/agent/client.py +115 -0
- rpr/agent/control.py +16 -0
- rpr/agent/mock.py +158 -0
- rpr/agent/runtime.py +291 -0
- rpr/agent/session.py +91 -0
- rpr/agent/tools/__init__.py +10 -0
- rpr/agent/tools/base.py +62 -0
- rpr/agent/tools/mutating.py +47 -0
- rpr/agent/tools/readonly.py +147 -0
- rpr/agent/tools/registry.py +30 -0
- rpr/application/__init__.py +1 -0
- rpr/application/catalog.py +427 -0
- rpr/application/chat_service.py +413 -0
- rpr/application/checks.py +49 -0
- rpr/application/cli_adapter.py +36 -0
- rpr/application/completer.py +163 -0
- rpr/application/conversation_service.py +92 -0
- rpr/application/prompt_service.py +85 -0
- rpr/application/selector.py +240 -0
- rpr/application/shell.py +543 -0
- rpr/checks/__init__.py +0 -0
- rpr/checks/base.py +13 -0
- rpr/checks/instructions.py +89 -0
- rpr/checks/packages.py +619 -0
- rpr/checks/workspace.py +170 -0
- rpr/cli.py +78 -0
- rpr/commands/__init__.py +0 -0
- rpr/commands/add.py +166 -0
- rpr/commands/chat.py +48 -0
- rpr/commands/check.py +53 -0
- rpr/commands/generate/__init__.py +0 -0
- rpr/commands/generate/api.py +228 -0
- rpr/commands/generate/domain.py +383 -0
- rpr/commands/generate/engine.py +148 -0
- rpr/commands/generate/storybook.py +442 -0
- rpr/commands/generate/ui.py +414 -0
- rpr/commands/init.py +822 -0
- rpr/commands/map.py +113 -0
- rpr/commands/settings.py +102 -0
- rpr/commands/sync.py +97 -0
- rpr/context.py +203 -0
- rpr/generators/__init__.py +0 -0
- rpr/generators/base.py +110 -0
- rpr/generators/claude.py +33 -0
- rpr/generators/copilot.py +36 -0
- rpr/generators/cursor.py +38 -0
- rpr/generators/gemini.py +33 -0
- rpr/map/__init__.py +0 -0
- rpr/map/architecture.py +495 -0
- rpr/map/chains.py +317 -0
- rpr/map/classifier.py +170 -0
- rpr/map/coverage.py +200 -0
- rpr/map/dependencies.py +243 -0
- rpr/map/extractor.py +223 -0
- rpr/map/graph.py +318 -0
- rpr/map/output.py +1030 -0
- rpr/map/responsibility.py +345 -0
- rpr/map/topology.py +327 -0
- rpr/map/walker.py +151 -0
- rpr/scaffolds/domain/base_entity.md +30 -0
- rpr/scaffolds/domain/base_repo.md +48 -0
- rpr/scaffolds/domain/container.md +76 -0
- rpr/scaffolds/domain/settings.md +57 -0
- rpr/scaffolds/instructions/all.instructions.md +50 -0
- rpr/scaffolds/instructions/api.instructions.md +42 -0
- rpr/scaffolds/instructions/domain.instructions.md +93 -0
- rpr/scaffolds/instructions/frontend.instructions.md +97 -0
- rpr/scaffolds/instructions/rust-engine.instructions.md +40 -0
- rpr/scaffolds/instructions/setup-guide.instructions.md +86 -0
- rpr/scaffolds/instructions/tooling-setup.instructions.md +97 -0
- rpr/scaffolds/instructions/tooling.instructions.md +42 -0
- rpr/scaffolds/js_special_files/fetch.service.md +222 -0
- rpr/scaffolds/js_special_files/sticky-navigation.md +164 -0
- rpr/scaffolds/special_files/domain_container.md +76 -0
- rpr/scaffolds/special_files/domain_settings.md +57 -0
- rpr/scaffolds/special_files/dto_util.md +62 -0
- rpr/scaffolds/special_files/encrypted_column.md +98 -0
- rpr/scaffolds/special_files/mapper_util.md +159 -0
- rpr/scaffolds/special_files/partial_update.md +61 -0
- rpr/templates/__init__.py +0 -0
- rpr/templates/registry.py +81 -0
- rpr/ui/__init__.py +0 -0
- rpr/ui/console.py +32 -0
- rpr/ui/markdown.py +59 -0
- rpr/ui/prompt_session.py +430 -0
- rpr/ui/renderers.py +167 -0
- rpr/ui/theme.py +286 -0
- rpr/workspace.py +131 -0
- rpr_cli-0.1.1.dist-info/METADATA +201 -0
- rpr_cli-0.1.1.dist-info/RECORD +97 -0
- rpr_cli-0.1.1.dist-info/WHEEL +4 -0
- rpr_cli-0.1.1.dist-info/entry_points.txt +2 -0
- rpr_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
applyTo: "packages/python/*-engine/**"
|
|
3
|
+
description: "Rust + PyO3 engine build, lint, and Python bindings"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Rust Engine
|
|
7
|
+
|
|
8
|
+
Use Rust for performance-sensitive code that needs a Python-facing API. This package uses PyO3 for bindings and Maturin as the Python build backend.
|
|
9
|
+
|
|
10
|
+
## Structure
|
|
11
|
+
|
|
12
|
+
- Package root: `packages/python/{name}-engine`
|
|
13
|
+
- Rust source: `packages/python/{name}-engine/src/lib.rs`
|
|
14
|
+
- Python build metadata: `packages/python/{name}-engine/pyproject.toml`
|
|
15
|
+
- Type stubs: keep a `.pyi` file next to the package root for editor support
|
|
16
|
+
|
|
17
|
+
## Build and Tooling
|
|
18
|
+
|
|
19
|
+
- Use `pyo3 = { version = "0.23", features = ["extension-module"] }` for Python bindings.
|
|
20
|
+
- Use Maturin as the build backend in `pyproject.toml`.
|
|
21
|
+
- Keep the PyO3 module name in underscore form so it matches the generated Rust module and stub file names.
|
|
22
|
+
- Use Rayon only when the engine actually performs parallel work. Do not add concurrency primitives speculatively.
|
|
23
|
+
|
|
24
|
+
## NX Targets
|
|
25
|
+
|
|
26
|
+
- `engine:build` should run `uv run maturin develop` from the engine package directory.
|
|
27
|
+
- `engine:lint` should run `cargo clippy --all-targets --all-features -- -D warnings`.
|
|
28
|
+
- `engine:format` should run `cargo fmt --check`.
|
|
29
|
+
|
|
30
|
+
## Binding Conventions
|
|
31
|
+
|
|
32
|
+
- Keep the Python-facing API small and explicit. Expose a small set of well-typed functions rather than mirroring large internal Rust modules.
|
|
33
|
+
- Use `#[pyfunction]` for individual exported functions and register them in the `#[pymodule]` entrypoint.
|
|
34
|
+
- Favor simple Python-friendly return types unless there is a strong reason to expose a richer binding surface.
|
|
35
|
+
- Update the `.pyi` stub whenever the exported Python API changes.
|
|
36
|
+
|
|
37
|
+
## Package Boundaries
|
|
38
|
+
|
|
39
|
+
- Keep domain business rules in the Python domain layer unless the logic is genuinely performance-sensitive or requires Rust-specific capabilities.
|
|
40
|
+
- Treat the engine as an implementation detail behind a stable Python-facing contract.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
applyTo: "pyproject.toml,nx.json,package.json,.pre-commit-config.yaml,.gitignore,.env.example,apps/*/project.json,apps/*/pyproject.toml,packages/**/project.json,packages/**/pyproject.toml,packages/**/alembic.ini,packages/**/migrations/**"
|
|
3
|
+
description: "Use when checking or updating NX + UV monorepo setup, project layout, naming, workspace membership, or generated command surfaces"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# NX + UV Setup Guide
|
|
7
|
+
|
|
8
|
+
Use this guidance when validating or changing the monorepo setup against the RPR workspace contract.
|
|
9
|
+
|
|
10
|
+
## Workspace Contract
|
|
11
|
+
|
|
12
|
+
- Treat NX as the task surface for the workspace.
|
|
13
|
+
- Treat UV as the Python environment and dependency manager.
|
|
14
|
+
- Keep root workspace setup, generated project layout, and documented commands in sync.
|
|
15
|
+
- If the generated code and the guide disagree, update one so they match instead of leaving drift in place.
|
|
16
|
+
|
|
17
|
+
## Root Bootstrap
|
|
18
|
+
|
|
19
|
+
- Bootstrap NX as an empty workspace and keep Nx Cloud disabled during initialization and in `nx.json`.
|
|
20
|
+
- Initialize UV at the root and keep the root `pyproject.toml` as the source of truth for workspace members, Python dev dependencies, Ruff, and pytest discovery.
|
|
21
|
+
- Keep the root `package.json` aligned with the actual Nx targets the workspace generates.
|
|
22
|
+
- Keep `.pre-commit-config.yaml` valid YAML and make sure every documented hook command is runnable in the generated workspace.
|
|
23
|
+
|
|
24
|
+
## Naming And Layout
|
|
25
|
+
|
|
26
|
+
- Use kebab-case for project roots and Nx project names.
|
|
27
|
+
- Keep the frontend app at `apps/{name}-web`.
|
|
28
|
+
- Keep the FastAPI app at `apps/{name}-api`.
|
|
29
|
+
- Keep the reusable React library at `packages/node/{name}-ui`.
|
|
30
|
+
- Keep the dedicated Storybook host at `packages/node/sb`.
|
|
31
|
+
- Keep the Python domain package at `packages/python/domain`.
|
|
32
|
+
- Keep the domain import package at `packages/python/domain/src/{name_underscore}_domain` so imports read as `from {name_underscore}_domain ...`.
|
|
33
|
+
- Keep optional Rust engine packages at `packages/python/{name}-engine` unless the product contract changes.
|
|
34
|
+
|
|
35
|
+
## Python Naming Rule
|
|
36
|
+
|
|
37
|
+
- Use kebab-case for filesystem roots such as `apps/{name}-api`.
|
|
38
|
+
- Use snake_case for the import package inside `src/`, for example `apps/{name}-api/src/{name}_api`.
|
|
39
|
+
- Apply the same rule to the domain package: keep the root directory `packages/python/domain`, but keep the import package at `src/{name_underscore}_domain`.
|
|
40
|
+
- Keep the Python package name in `pyproject.toml` kebab-case.
|
|
41
|
+
- Add a `tests/` directory for generated Python apps and libraries.
|
|
42
|
+
|
|
43
|
+
## Node And Storybook Expectations
|
|
44
|
+
|
|
45
|
+
- Generate React app and library projects in a way that preserves the expected Nx command surface.
|
|
46
|
+
- The web app should expose at least `serve`, `build`, `lint`, `test`, and `typecheck` when those capabilities exist in the generated project.
|
|
47
|
+
- The UI library should expose at least `build`, `lint`, `test`, and `typecheck`.
|
|
48
|
+
- Keep Storybook as a dedicated host project at `packages/node/sb`; do not silently repurpose the UI library as the Storybook project.
|
|
49
|
+
|
|
50
|
+
## Python Expectations
|
|
51
|
+
|
|
52
|
+
- Generated Python projects should use `src/` and `tests/` layout.
|
|
53
|
+
- Keep Python projects registered in `[tool.uv.workspace].members`.
|
|
54
|
+
- Use Nx `project.json` files to expose Python tasks through `nx:run-commands`.
|
|
55
|
+
- Ensure generated API code only imports scaffolds that actually exist in the generated domain package.
|
|
56
|
+
|
|
57
|
+
## Alembic And Nx Migrations
|
|
58
|
+
|
|
59
|
+
- For Python services that own schema changes, add Alembic to the package that owns the domain models and expose it through an Nx target.
|
|
60
|
+
- The standard domain layout is `packages/python/domain/alembic.ini` plus `packages/python/domain/migrations/`.
|
|
61
|
+
- The standard Nx target shape is a `migrate` target on the domain project that runs `uv run alembic -c packages/python/domain/alembic.ini` from the workspace root.
|
|
62
|
+
- Run migration commands from the workspace root with the `--` separator so Alembic receives its own arguments.
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
npx nx run domain:migrate -- revision --autogenerate -m "{number}_a_descriptive_message"
|
|
66
|
+
npx nx run domain:migrate -- upgrade head
|
|
67
|
+
npx nx run domain:migrate -- downgrade -1
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
- Alembic autogeneration depends on model discovery, so make sure the domain model registry imports newly added entity modules.
|
|
71
|
+
|
|
72
|
+
## Dependency And Membership Rules
|
|
73
|
+
|
|
74
|
+
- When one workspace Python package depends on another, declare it in dependencies and mirror it in `[tool.uv.sources]` with `workspace = true`.
|
|
75
|
+
- After creating or modifying workspace packages, keep root UV workspace membership current.
|
|
76
|
+
- Do not document `uv sync --all-packages` or Nx commands unless the generated workspace can actually run them.
|
|
77
|
+
|
|
78
|
+
## Review Checklist
|
|
79
|
+
|
|
80
|
+
- Root files match the generated command surface: `pyproject.toml`, `nx.json`, `package.json`, `.pre-commit-config.yaml`.
|
|
81
|
+
- Project roots and import package names follow the kebab-case and snake_case rules.
|
|
82
|
+
- Python projects have both `src/` and `tests/`.
|
|
83
|
+
- The domain import package remains `src/{name_underscore}_domain`.
|
|
84
|
+
- Storybook remains hosted in `packages/node/sb`.
|
|
85
|
+
- Workspace members and workspace dependency sources are complete.
|
|
86
|
+
- Generated instructions, checks, and scaffolds refer to the current contract rather than stale paths.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
---
|
|
2
|
+
applyTo: "pyproject.toml,nx.json,.pre-commit-config.yaml,package.json,apps/*/project.json,packages/**/project.json,apps/*/eslint.config.mjs,packages/**/eslint.config.mjs,packages/python/*-engine/Cargo.toml"
|
|
3
|
+
description: "Use when setting up or modifying Ruff, TypeScript linting, ESLint, Prettier, cargo fmt, Clippy, or pre-commit in an RPR workspace"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Tooling Setup
|
|
7
|
+
|
|
8
|
+
Use this guidance when editing workspace tooling files or bootstrapping linting, formatting, and pre-commit automation.
|
|
9
|
+
|
|
10
|
+
## First Rule
|
|
11
|
+
|
|
12
|
+
- Match the existing workspace toolchain before adding anything new.
|
|
13
|
+
- Keep docs, Nx targets, package dependencies, and pre-commit hooks consistent with each other.
|
|
14
|
+
- Do not document commands that the workspace cannot actually run.
|
|
15
|
+
|
|
16
|
+
## Python Baseline
|
|
17
|
+
|
|
18
|
+
- Use Ruff for Python linting and formatting.
|
|
19
|
+
- Keep Ruff configuration in the root `pyproject.toml`.
|
|
20
|
+
- Prefer `uv run ruff check` for linting and `uv run ruff format` for formatting.
|
|
21
|
+
- If the workspace already has a Ruff config, extend it rather than replacing it with a different rule set.
|
|
22
|
+
- If bootstrapping from the current RPR defaults, preserve the existing generated baseline unless the task explicitly asks for different rules.
|
|
23
|
+
|
|
24
|
+
## TypeScript and React Baseline
|
|
25
|
+
|
|
26
|
+
- In the current RPR generator, the default `lint` target for Node and React projects is `npx eslint .`.
|
|
27
|
+
- Keep `typecheck` as a separate target that runs `npx tsc --noEmit`.
|
|
28
|
+
- Use ESLint flat config and the Nx plugin entry `@nx/eslint/plugin` in `nx.json`.
|
|
29
|
+
- Treat Prettier as the default formatter for Node-side code and keep it complementary to ESLint.
|
|
30
|
+
|
|
31
|
+
Valid `nx.json` plugin example:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"plugins": [
|
|
36
|
+
{
|
|
37
|
+
"plugin": "@nx/eslint/plugin",
|
|
38
|
+
"options": {
|
|
39
|
+
"targetName": "lint"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Rust Baseline
|
|
47
|
+
|
|
48
|
+
- Use `cargo fmt --check` for formatting validation.
|
|
49
|
+
- Use `cargo clippy --all-targets --all-features -- -D warnings` for linting.
|
|
50
|
+
- In RPR-generated engine packages, prefer the existing Nx targets `engine:format` and `engine:lint`.
|
|
51
|
+
|
|
52
|
+
## Pre-commit Rules
|
|
53
|
+
|
|
54
|
+
- Keep `.pre-commit-config.yaml` syntactically valid YAML.
|
|
55
|
+
- Define one hook per command. Do not reuse a single hook entry for two commands.
|
|
56
|
+
- Hook ids, documented manual run commands, and actual entries must match exactly.
|
|
57
|
+
- Prefer commands the workspace already uses in `project.json`, `package.json`, or root scripts.
|
|
58
|
+
- When bootstrapping a fresh RPR workspace, scaffold `eslint` and `prettier` hooks with the matching config files.
|
|
59
|
+
|
|
60
|
+
Valid local hook example aligned with the current RPR defaults:
|
|
61
|
+
|
|
62
|
+
```yaml
|
|
63
|
+
repos:
|
|
64
|
+
- repo: local
|
|
65
|
+
hooks:
|
|
66
|
+
- id: ruff-format
|
|
67
|
+
name: Ruff format
|
|
68
|
+
entry: uv run ruff format .
|
|
69
|
+
language: system
|
|
70
|
+
pass_filenames: false
|
|
71
|
+
|
|
72
|
+
- id: ruff-lint
|
|
73
|
+
name: Ruff lint
|
|
74
|
+
entry: uv run ruff check . --fix
|
|
75
|
+
language: system
|
|
76
|
+
pass_filenames: false
|
|
77
|
+
|
|
78
|
+
- id: prettier
|
|
79
|
+
name: Prettier
|
|
80
|
+
entry: npx prettier --write .
|
|
81
|
+
language: system
|
|
82
|
+
pass_filenames: false
|
|
83
|
+
|
|
84
|
+
- id: eslint
|
|
85
|
+
name: ESLint
|
|
86
|
+
entry: npx eslint . --ext .js,.jsx,.ts,.tsx --fix
|
|
87
|
+
language: system
|
|
88
|
+
pass_filenames: false
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Consistency Checks
|
|
92
|
+
|
|
93
|
+
- Use `package.json`, not `packages.json`.
|
|
94
|
+
- If linting uses ESLint, keep a separate `typecheck` target for `npx tsc --noEmit`.
|
|
95
|
+
- If you introduce a hook named `eslint` or `prettier`, define that hook explicitly.
|
|
96
|
+
- If you document ESLint, ensure the package install, config format, Nx integration, and manual commands all match.
|
|
97
|
+
- If a long-form guide conflicts with the generated project defaults, update the guide or the generator so they agree.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
applyTo: "**"
|
|
3
|
+
description: "Workspace tooling conventions for linting, formatting, and pre-commit"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Tooling Conventions
|
|
7
|
+
|
|
8
|
+
Use the workspace's existing linting, formatting, and pre-commit setup when it already exists. Do not introduce a second parallel toolchain.
|
|
9
|
+
|
|
10
|
+
## Python
|
|
11
|
+
|
|
12
|
+
- Use Ruff for Python linting and formatting.
|
|
13
|
+
- Keep Ruff configuration in the root `pyproject.toml` under `[tool.ruff]`.
|
|
14
|
+
- Prefer workspace commands such as `uv run ruff check` and `uv run ruff format`.
|
|
15
|
+
- If the project already pins Ruff in dependencies, keep it there. Do not rely on undocumented cache behavior as the only installation path.
|
|
16
|
+
|
|
17
|
+
## TypeScript and React
|
|
18
|
+
|
|
19
|
+
- In RPR-generated workspaces, use ESLint flat config for JavaScript and TypeScript linting, Prettier for formatting, and `npx tsc --noEmit` as a separate typecheck target.
|
|
20
|
+
- Keep the root `nx.json` ESLint plugin entry and project-local `eslint.config.mjs` files aligned.
|
|
21
|
+
- Extend existing flat-config files instead of introducing a different ESLint config format.
|
|
22
|
+
- Keep `lint`, `format`, and `typecheck` responsibilities separate. Do not use TypeScript typechecking as a substitute for ESLint.
|
|
23
|
+
|
|
24
|
+
## Rust
|
|
25
|
+
|
|
26
|
+
- Use `cargo fmt --check` for formatting validation.
|
|
27
|
+
- Use `cargo clippy --all-targets --all-features -- -D warnings` for linting.
|
|
28
|
+
- Prefer existing Nx targets such as `engine:format` and `engine:lint` when they exist.
|
|
29
|
+
|
|
30
|
+
## Pre-commit
|
|
31
|
+
|
|
32
|
+
- Pre-commit hooks must call commands that already exist in the workspace.
|
|
33
|
+
- Keep hook ids, entries, and manual run examples aligned. Do not document a hook name that is not defined.
|
|
34
|
+
- Prefer running workspace-level commands through `uv run`, `npx`, `npx nx`, or existing package-manager scripts rather than ad hoc shell fragments.
|
|
35
|
+
- When the default Node tooling is scaffolded, keep `eslint` and `prettier` hooks aligned with the generated config files.
|
|
36
|
+
- Ensure YAML examples are syntactically valid before adding them to shared docs or instructions.
|
|
37
|
+
|
|
38
|
+
## Change Discipline
|
|
39
|
+
|
|
40
|
+
- Reuse existing root config files such as `pyproject.toml`, `nx.json`, `eslint.config.mjs`, and `.pre-commit-config.yaml` instead of creating duplicates.
|
|
41
|
+
- When a task is specifically to bootstrap tooling from scratch, keep the configuration consistent across docs, generated targets, and hook examples.
|
|
42
|
+
- Use `tooling-setup.instructions.md` for file-level guidance when editing root tooling config files.
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
## Internal Template Source: API Service
|
|
2
|
+
|
|
3
|
+
Target path: `/packages/node/{name}-ui/src/services/base/api.service.ts`
|
|
4
|
+
|
|
5
|
+
This markdown file is an internal generator source for `rpr add template fetch_service`. It is not synced project instruction guidance.
|
|
6
|
+
|
|
7
|
+
Keep the first TypeScript block as the complete generated file. `src/rpr/commands/add.py` extracts the first matching fenced block for this template.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
export type ApiError = {
|
|
11
|
+
message: string;
|
|
12
|
+
status: number;
|
|
13
|
+
details?: unknown;
|
|
14
|
+
response: Response;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
declare const getToken: () => Promise<string | null>;
|
|
18
|
+
declare const globalErrorHandler: ((error: ApiError) => void) | undefined;
|
|
19
|
+
|
|
20
|
+
const buildUrl = (
|
|
21
|
+
endpoint: string,
|
|
22
|
+
params?: Record<string, unknown>,
|
|
23
|
+
): string => {
|
|
24
|
+
// 1) Resolve full URL from base config
|
|
25
|
+
// 2) Append query parameters if present
|
|
26
|
+
return endpoint;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const createRequestInit = async (
|
|
30
|
+
method: string,
|
|
31
|
+
initHeaders?: HeadersInit,
|
|
32
|
+
body?: BodyInit,
|
|
33
|
+
): Promise<RequestInit> => {
|
|
34
|
+
const headers = new Headers({
|
|
35
|
+
Accept: "application/json",
|
|
36
|
+
...initHeaders,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (body && !headers.has("Content-Type")) {
|
|
40
|
+
headers.set("Content-Type", "application/json");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!headers.has("Authorization")) {
|
|
44
|
+
const token = await getToken();
|
|
45
|
+
if (token) {
|
|
46
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const requestInit: RequestInit = {
|
|
51
|
+
method,
|
|
52
|
+
headers,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
if (body) {
|
|
56
|
+
requestInit.body = body;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return requestInit;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const onApi401 = async (
|
|
63
|
+
_response: Response,
|
|
64
|
+
error: ApiError,
|
|
65
|
+
): Promise<never> => {
|
|
66
|
+
// Handle 401 Unauthorized error
|
|
67
|
+
return Promise.reject(error);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const handleError = async (response: Response): Promise<ApiError> => {
|
|
71
|
+
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
|
72
|
+
let errorDetails = response.statusText;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const contentType = response.headers.get("content-type");
|
|
76
|
+
|
|
77
|
+
if (contentType && contentType.includes("application/json")) {
|
|
78
|
+
const errorData = (await response.json()) as {
|
|
79
|
+
message?: string;
|
|
80
|
+
detail?: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
errorMessage = errorData.message || errorData.detail || errorMessage;
|
|
84
|
+
errorDetails = errorData;
|
|
85
|
+
} else {
|
|
86
|
+
const textError = await response.text();
|
|
87
|
+
if (textError) {
|
|
88
|
+
errorMessage = textError;
|
|
89
|
+
errorDetails = textError;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch (error: unknown) {
|
|
93
|
+
console.error("Failed to parse default message", error);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
message: errorMessage,
|
|
98
|
+
status: response.status,
|
|
99
|
+
details: errorDetails,
|
|
100
|
+
response,
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const handleResponse = async <T>(response: Response): Promise<T> => {
|
|
105
|
+
if (!response.ok) {
|
|
106
|
+
const error = await handleError(response);
|
|
107
|
+
if (response.status === 401) {
|
|
108
|
+
return await onApi401(response, error);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// If you have a way to handle error, like a snackbar
|
|
112
|
+
if (globalErrorHandler) {
|
|
113
|
+
globalErrorHandler(error);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (response.status === 204) return undefined as T;
|
|
120
|
+
|
|
121
|
+
const contentType = response.headers.get("content-type");
|
|
122
|
+
if (contentType && contentType.includes("application/json")) {
|
|
123
|
+
return await response.json();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (contentType && contentType.includes("image/")) {
|
|
127
|
+
const blob = await response.blob();
|
|
128
|
+
return blob as unknown as T;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const text = await response.text();
|
|
132
|
+
return text as unknown as T;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const apiService = {
|
|
136
|
+
async get<T>(
|
|
137
|
+
endpoint: string,
|
|
138
|
+
params?: Record<string, unknown>,
|
|
139
|
+
headers?: HeadersInit,
|
|
140
|
+
): Promise<T> {
|
|
141
|
+
const url = buildUrl(endpoint, params);
|
|
142
|
+
const requestInit = await createRequestInit("GET", headers);
|
|
143
|
+
const response = await fetch(url, requestInit);
|
|
144
|
+
return handleResponse<T>(response);
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
async post<T>(
|
|
148
|
+
endpoint: string,
|
|
149
|
+
data?: unknown,
|
|
150
|
+
headers?: HeadersInit,
|
|
151
|
+
): Promise<T> {
|
|
152
|
+
const url = buildUrl(endpoint);
|
|
153
|
+
const requestInit = await createRequestInit(
|
|
154
|
+
"POST",
|
|
155
|
+
headers,
|
|
156
|
+
data ? JSON.stringify(data) : undefined,
|
|
157
|
+
);
|
|
158
|
+
const response = await fetch(url, requestInit);
|
|
159
|
+
return handleResponse<T>(response);
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
async put<T>(
|
|
163
|
+
endpoint: string,
|
|
164
|
+
data?: unknown,
|
|
165
|
+
headers?: HeadersInit,
|
|
166
|
+
): Promise<T> {
|
|
167
|
+
const url = buildUrl(endpoint);
|
|
168
|
+
const requestInit = await createRequestInit(
|
|
169
|
+
"PUT",
|
|
170
|
+
headers,
|
|
171
|
+
data ? JSON.stringify(data) : undefined,
|
|
172
|
+
);
|
|
173
|
+
const response = await fetch(url, requestInit);
|
|
174
|
+
return handleResponse<T>(response);
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
async delete<T>(endpoint: string, headers?: HeadersInit): Promise<T> {
|
|
178
|
+
const url = buildUrl(endpoint);
|
|
179
|
+
const requestInit = await createRequestInit("DELETE", headers);
|
|
180
|
+
const response = await fetch(url, requestInit);
|
|
181
|
+
return handleResponse<T>(response);
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
async patch<T>(
|
|
185
|
+
endpoint: string,
|
|
186
|
+
data?: unknown,
|
|
187
|
+
headers?: HeadersInit,
|
|
188
|
+
): Promise<T> {
|
|
189
|
+
const url = buildUrl(endpoint);
|
|
190
|
+
const requestInit = await createRequestInit(
|
|
191
|
+
"PATCH",
|
|
192
|
+
headers,
|
|
193
|
+
data ? JSON.stringify(data) : undefined,
|
|
194
|
+
);
|
|
195
|
+
const response = await fetch(url, requestInit);
|
|
196
|
+
return handleResponse<T>(response);
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## What This Template Owns
|
|
202
|
+
|
|
203
|
+
- Shared request setup, including default headers and bearer-token injection
|
|
204
|
+
- Centralized API error parsing
|
|
205
|
+
- Standard response handling for JSON, text, blobs, and `204 No Content`
|
|
206
|
+
- Thin HTTP helpers that feature-specific services can reuse
|
|
207
|
+
|
|
208
|
+
## Integration Notes
|
|
209
|
+
|
|
210
|
+
- Replace `buildUrl` with the project's actual API base-URL logic.
|
|
211
|
+
- Provide a real `getToken()` implementation or wire authentication through the app's existing auth boundary.
|
|
212
|
+
- Connect `globalErrorHandler` to the project's notification or error-reporting path if one exists.
|
|
213
|
+
- Keep domain-specific services small. They should call `apiService`, not duplicate fetch behavior.
|
|
214
|
+
- Synced target-facing guidance belongs in `frontend.instructions.md` and should reference `.github/scaffolds/frontend/api.service.ts`.
|
|
215
|
+
|
|
216
|
+
## Usage
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
export const UserService = {
|
|
220
|
+
getUsers: () => apiService.get<User[]>(`/users`),
|
|
221
|
+
};
|
|
222
|
+
```
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
## Internal Template Source: useStateNavigation
|
|
2
|
+
|
|
3
|
+
Target path: `/packages/node/{name}-ui/src/hooks/useStateNavigation.ts`
|
|
4
|
+
|
|
5
|
+
This markdown file is an internal generator source for `rpr add template sticky_navigation`. It is not synced project instruction guidance.
|
|
6
|
+
|
|
7
|
+
Keep the first TypeScript block as the complete generated file. `src/rpr/commands/add.py` extracts the first matching fenced block for this template.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { useCallback, useLayoutEffect, useState } from "react";
|
|
11
|
+
|
|
12
|
+
type NavigationStateChangeDetail = {
|
|
13
|
+
property: string;
|
|
14
|
+
newValue: string | null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type SetNavigationValueOptions = {
|
|
18
|
+
replace?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type SetNavigationValue = (
|
|
22
|
+
newValue: string | null,
|
|
23
|
+
options?: SetNavigationValueOptions,
|
|
24
|
+
) => void;
|
|
25
|
+
|
|
26
|
+
const normalizeValue = (value?: string | null): string => value?.trim() ?? "";
|
|
27
|
+
|
|
28
|
+
const getInitialValue = (property: string, defaultValue: string): string => {
|
|
29
|
+
const currentParams = new URLSearchParams(window.location.search);
|
|
30
|
+
const normalizedDefaultValue = normalizeValue(defaultValue);
|
|
31
|
+
|
|
32
|
+
return normalizeValue(currentParams.get(property)) || normalizedDefaultValue;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const buildUrlFromValue = (property: string, value?: string | null): string => {
|
|
36
|
+
const normalizedValue = normalizeValue(value);
|
|
37
|
+
const currentParams = new URLSearchParams(window.location.search);
|
|
38
|
+
|
|
39
|
+
if (!normalizedValue) {
|
|
40
|
+
currentParams.delete(property);
|
|
41
|
+
} else {
|
|
42
|
+
currentParams.set(property, normalizedValue);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const newSearch = currentParams.toString();
|
|
46
|
+
|
|
47
|
+
return newSearch
|
|
48
|
+
? `${window.location.pathname}?${newSearch}${window.location.hash}`
|
|
49
|
+
: `${window.location.pathname}${window.location.hash}`;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const useStateNavigation = (
|
|
53
|
+
property: string,
|
|
54
|
+
defaultValue = "",
|
|
55
|
+
): readonly [string, SetNavigationValue] => {
|
|
56
|
+
const normalizedDefaultValue = normalizeValue(defaultValue);
|
|
57
|
+
|
|
58
|
+
const [value, setValueState] = useState(() =>
|
|
59
|
+
getInitialValue(property, normalizedDefaultValue),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const syncValueFromUrl = useCallback(() => {
|
|
63
|
+
const nextValue = getInitialValue(property, normalizedDefaultValue);
|
|
64
|
+
|
|
65
|
+
setValueState((previousValue) =>
|
|
66
|
+
previousValue === nextValue ? previousValue : nextValue,
|
|
67
|
+
);
|
|
68
|
+
}, [property, normalizedDefaultValue]);
|
|
69
|
+
|
|
70
|
+
useLayoutEffect(() => {
|
|
71
|
+
syncValueFromUrl();
|
|
72
|
+
}, [syncValueFromUrl]);
|
|
73
|
+
|
|
74
|
+
useLayoutEffect(() => {
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
|
|
77
|
+
const handleNavigationStateChange = (event: Event) => {
|
|
78
|
+
const customEvent = event as CustomEvent<NavigationStateChangeDetail>;
|
|
79
|
+
const { property: changedProperty, newValue } = customEvent.detail;
|
|
80
|
+
|
|
81
|
+
if (changedProperty !== property) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const normalizedValue = normalizeValue(newValue);
|
|
86
|
+
|
|
87
|
+
setValueState((previousValue) =>
|
|
88
|
+
previousValue === normalizedValue ? previousValue : normalizedValue,
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
window.addEventListener(
|
|
93
|
+
"NavigationStateChange",
|
|
94
|
+
handleNavigationStateChange,
|
|
95
|
+
{ signal: controller.signal },
|
|
96
|
+
);
|
|
97
|
+
window.addEventListener("popstate", syncValueFromUrl, {
|
|
98
|
+
signal: controller.signal,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return () => {
|
|
102
|
+
controller.abort();
|
|
103
|
+
};
|
|
104
|
+
}, [property, syncValueFromUrl]);
|
|
105
|
+
|
|
106
|
+
const setValue = useCallback<SetNavigationValue>(
|
|
107
|
+
(newValue, options) => {
|
|
108
|
+
const normalizedValue = normalizeValue(newValue);
|
|
109
|
+
const newUrl = buildUrlFromValue(property, normalizedValue);
|
|
110
|
+
const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
|
111
|
+
|
|
112
|
+
if (newUrl === currentUrl) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (options?.replace) {
|
|
117
|
+
window.history.replaceState(null, "", newUrl);
|
|
118
|
+
} else {
|
|
119
|
+
window.history.pushState(null, "", newUrl);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
window.dispatchEvent(
|
|
123
|
+
new CustomEvent<NavigationStateChangeDetail>("NavigationStateChange", {
|
|
124
|
+
detail: {
|
|
125
|
+
property,
|
|
126
|
+
newValue: normalizedValue || null,
|
|
127
|
+
},
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
},
|
|
131
|
+
[property],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
useLayoutEffect(() => {
|
|
135
|
+
if (!normalizedDefaultValue) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const urlValue = normalizeValue(
|
|
140
|
+
new URLSearchParams(window.location.search).get(property),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
if (urlValue) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
setValue(normalizedDefaultValue, { replace: true });
|
|
148
|
+
}, [normalizedDefaultValue, property, setValue]);
|
|
149
|
+
|
|
150
|
+
return [value, setValue] as const;
|
|
151
|
+
};
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Why This Uses `useLayoutEffect`
|
|
155
|
+
|
|
156
|
+
Use `useLayoutEffect` here because URL-to-state synchronization and listener registration should happen before paint. That avoids stale UI during mount and reduces the chance of missing navigation events dispatched immediately after render.
|
|
157
|
+
|
|
158
|
+
## Usage Rules
|
|
159
|
+
|
|
160
|
+
- Use this hook only in UI-layer React code.
|
|
161
|
+
- Pass the default value into the hook. Do not initialize state by calling `setValue` during mount.
|
|
162
|
+
- Use one query-string property per independently controlled state value.
|
|
163
|
+
- Keep stored values string-based. If a feature needs richer state, encode and decode it explicitly at the edge.
|
|
164
|
+
- Synced target-facing guidance belongs in `frontend.instructions.md` and should reference `.github/scaffolds/frontend/useStateNavigation.ts`.
|