@pennyfarthing/core 10.2.0 → 10.3.0
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.
- package/README.md +11 -8
- package/package.json +1 -1
- package/packages/core/dist/cli/commands/e2e-fresh-install.test.js +1 -1
- package/packages/core/dist/cli/commands/e2e-fresh-install.test.js.map +1 -1
- package/packages/core/dist/cli/commands/e2e-upgrade.test.js +1 -1
- package/packages/core/dist/cli/commands/e2e-upgrade.test.js.map +1 -1
- package/packages/core/dist/cli/commands/theme.js +1 -1
- package/packages/core/dist/cli/commands/theme.js.map +1 -1
- package/packages/core/dist/cli/utils/themes.d.ts.map +1 -1
- package/packages/core/dist/cli/utils/themes.js +3 -2
- package/packages/core/dist/cli/utils/themes.js.map +1 -1
- package/packages/core/dist/scripts/add-ocean-profiles.js +1 -1
- package/packages/core/dist/scripts/add-ocean-profiles.js.map +1 -1
- package/packages/core/dist/scripts/generate-all-spiders.js +2 -0
- package/packages/core/dist/scripts/generate-all-spiders.js.map +1 -1
- package/packages/core/dist/scripts/generate-report.d.ts.map +1 -1
- package/packages/core/dist/scripts/generate-report.js +2 -0
- package/packages/core/dist/scripts/generate-report.js.map +1 -1
- package/packages/core/dist/scripts/generate-spider.d.ts.map +1 -1
- package/packages/core/dist/scripts/generate-spider.js +2 -0
- package/packages/core/dist/scripts/generate-spider.js.map +1 -1
- package/packages/core/dist/scripts/validate-ocean-profiles.js +1 -1
- package/packages/core/dist/scripts/validate-ocean-profiles.js.map +1 -1
- package/packages/core/dist/workflow/file-watch.test.js.map +1 -1
- package/packages/core/dist/workflow/output-path-normalizer.d.ts +47 -0
- package/packages/core/dist/workflow/output-path-normalizer.d.ts.map +1 -0
- package/packages/core/dist/workflow/output-path-normalizer.js +79 -0
- package/packages/core/dist/workflow/output-path-normalizer.js.map +1 -0
- package/packages/core/dist/workflow/output-path-normalizer.test.d.ts +16 -0
- package/packages/core/dist/workflow/output-path-normalizer.test.d.ts.map +1 -0
- package/packages/core/dist/workflow/output-path-normalizer.test.js +157 -0
- package/packages/core/dist/workflow/output-path-normalizer.test.js.map +1 -0
- package/packages/core/dist/workflow/tool-watch.test.js +1 -2
- package/packages/core/dist/workflow/tool-watch.test.js.map +1 -1
- package/packages/core/dist/workflow/variable-resolver.js +1 -1
- package/packages/core/dist/workflow/variable-resolver.js.map +1 -1
- package/pennyfarthing-dist/agents/README.md +3 -1
- package/pennyfarthing-dist/agents/ba.md +165 -0
- package/pennyfarthing-dist/commands/ba.md +17 -0
- package/pennyfarthing-dist/guides/workflow-schema.md +1 -1
- package/pennyfarthing-dist/personas/themes/a-team.yaml +30 -0
- package/pennyfarthing-dist/personas/themes/alice-in-wonderland.yaml +30 -0
- package/pennyfarthing-dist/personas/themes/battlestar-galactica.yaml +30 -0
- package/pennyfarthing-dist/personas/themes/blade-runner.yaml +30 -0
- package/pennyfarthing-dist/personas/themes/catch-22.yaml +30 -0
- package/pennyfarthing-dist/personas/themes/control.yaml +30 -0
- package/pennyfarthing-dist/personas/themes/cowboy-bebop.yaml +31 -0
- package/pennyfarthing-dist/personas/themes/discworld.yaml +31 -0
- package/pennyfarthing-dist/personas/themes/doctor-who.yaml +31 -0
- package/pennyfarthing-dist/personas/themes/dune.yaml +32 -0
- package/pennyfarthing-dist/personas/themes/fifth-element.yaml +32 -0
- package/pennyfarthing-dist/personas/themes/firefly.yaml +31 -0
- package/pennyfarthing-dist/personas/themes/game-of-thrones.yaml +30 -0
- package/pennyfarthing-dist/personas/themes/harry-potter.yaml +30 -0
- package/pennyfarthing-dist/personas/themes/hitchhikers-guide.yaml +30 -0
- package/pennyfarthing-dist/personas/themes/lord-of-the-rings.yaml +30 -0
- package/pennyfarthing-dist/personas/themes/mad-max.yaml +30 -0
- package/pennyfarthing-dist/personas/themes/mash.yaml +33 -0
- package/pennyfarthing-dist/personas/themes/princess-bride.yaml +34 -0
- package/pennyfarthing-dist/personas/themes/sandman.yaml +33 -0
- package/pennyfarthing-dist/personas/themes/star-trek-tng.yaml +34 -0
- package/pennyfarthing-dist/personas/themes/star-wars.yaml +33 -0
- package/pennyfarthing-dist/personas/themes/the-expanse.yaml +30 -0
- package/pennyfarthing-dist/personas/themes/the-matrix.yaml +30 -0
- package/pennyfarthing-dist/personas/themes/watchmen.yaml +30 -0
- package/pennyfarthing-dist/personas/themes/west-wing.yaml +30 -0
- package/pennyfarthing-dist/personas/themes/x-files.yaml +30 -0
- package/pennyfarthing-dist/scripts/core/agent-session.sh +1 -1
- package/pennyfarthing-dist/scripts/hooks/__pycache__/question_reflector_check.cpython-314.pyc +0 -0
- package/pennyfarthing-dist/scripts/portraits/generate-portraits.py +2 -2
- package/pennyfarthing-dist/scripts/validation/validate-agent-schema.sh +1 -0
- package/pennyfarthing-dist/skills/theme/skill.md +1 -1
- package/pennyfarthing-dist/workflows/architecture/workflow.yaml +2 -2
- package/pennyfarthing-dist/workflows/architecture.yaml +2 -2
- package/pennyfarthing-dist/workflows/epics-and-stories/workflow.yaml +2 -2
- package/pennyfarthing-dist/workflows/implementation-readiness/workflow.yaml +2 -2
- package/pennyfarthing-dist/workflows/prd/workflow.yaml +2 -2
- package/pennyfarthing-dist/workflows/product-brief/workflow.yaml +2 -2
- package/pennyfarthing-dist/workflows/project-context/workflow.yaml +2 -2
- package/pennyfarthing-dist/workflows/quick-dev/workflow.yaml +2 -2
- package/pennyfarthing-dist/workflows/research/workflow.yaml +2 -2
- package/pennyfarthing-dist/workflows/retrospective/workflow.yaml +1 -1
- package/pennyfarthing-dist/workflows/sprint-planning/workflow.yaml +3 -3
- package/pennyfarthing-dist/workflows/ux-design/workflow.yaml +2 -2
- package/pennyfarthing_scripts/__pycache__/__init__.cpython-311.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/config.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/hooks.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira_bidirectional_sync.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira_epic_creation.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira_sync.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira_sync_story.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/output.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/patch_mode.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/pretooluse_hook.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/schema_validation_hook.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/sprint.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/workflow.cpython-311.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/workflow.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bellmode_hook.py +1 -1
- package/pennyfarthing_scripts/bikerack/__init__.py +36 -0
- package/pennyfarthing_scripts/bikerack/__main__.py +5 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/launcher.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/cli.py +148 -0
- package/pennyfarthing_scripts/bikerack/launcher.py +181 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/discover.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/cli.py +5 -0
- package/pennyfarthing_scripts/codemarkers/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/codemarkers/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/codemarkers/__pycache__/analyze.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/codemarkers/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/codemarkers/__pycache__/formatters.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/codemarkers/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/common/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/common/__pycache__/output.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/common/__pycache__/themes.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/complexity/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/complexity/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/complexity/__pycache__/analyze.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/complexity/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/complexity/__pycache__/formatters.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/complexity/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/deadcode/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/deadcode/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/deadcode/__pycache__/analyze.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/deadcode/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/deadcode/__pycache__/formatters.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/deadcode/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/dependencies/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/dependencies/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/dependencies/__pycache__/analyze.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/dependencies/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/dependencies/__pycache__/formatters.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/dependencies/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/create_branches.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/status_all.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/healthscore/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/healthscore/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/healthscore/__pycache__/analyze.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/healthscore/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/healthscore/__pycache__/formatters.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/healthscore/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/healthscore/analyze.py +2 -1
- package/pennyfarthing_scripts/hotspots/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hotspots/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hotspots/__pycache__/analyze.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hotspots/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hotspots/__pycache__/formatters.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hotspots/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/bidirectional.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/claim.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/client.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/compat.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/create.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/epic.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/mappings.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/operations.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/reconcile.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/story.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/sync.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/migration/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/migration/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/migration/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/migration/__pycache__/session.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/migration/__pycache__/skill.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/migration/__pycache__/step.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/migration/__pycache__/validate.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/__pycache__/finish.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__init__.py +2 -0
- package/pennyfarthing_scripts/prime/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/loader.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/persona.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/session.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/tiers.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/cli.py +13 -0
- package/pennyfarthing_scripts/prime/loader.py +70 -0
- package/pennyfarthing_scripts/prime/persona.py +2 -1
- package/pennyfarthing_scripts/prime/tiers.py +13 -0
- package/pennyfarthing_scripts/sprint/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/archive.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/archive_epic.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/epic_add.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/import_epic.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/status.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/story_add.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/story_finish.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/story_update.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/validate_cmd.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/validator.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/work.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/yaml_io.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/story/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/story/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/story/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/story/__pycache__/create.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/story/__pycache__/size.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/story/__pycache__/template.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_bikerack.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_brownfield.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_cli_modules.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_codemarkers.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_common.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_epic_shard_validation.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_git_utils.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_healthscore.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_jira_package.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_package_structure.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_patch_mode.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_prime.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_sprint_package.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_sprint_validator.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_story_add.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_story_package.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_story_update.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_tiers.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_token_counting.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_validate_cmd.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_workflow_check.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_workflow_cli.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_yaml_io.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/test_bikerack.py +785 -0
- package/pennyfarthing_scripts/tests/test_topology_loader.py +620 -0
- package/pennyfarthing_scripts/theme/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/theme/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/validate/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/validate/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/validate/adapters/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/validate/adapters/__pycache__/agent.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/validate/adapters/__pycache__/schema.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/validate/adapters/__pycache__/skill_command.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/validate/adapters/__pycache__/sprint.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/validate/adapters/__pycache__/workflow.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/validate/adapters/skill_command.py +0 -1
- package/packages/core/dist/workflow/context-watch.d.ts +0 -80
- package/packages/core/dist/workflow/context-watch.d.ts.map +0 -1
- package/packages/core/dist/workflow/context-watch.js +0 -235
- package/packages/core/dist/workflow/context-watch.js.map +0 -1
- package/packages/core/dist/workflow/context-watch.test.d.ts +0 -1
- package/packages/core/dist/workflow/context-watch.test.d.ts.map +0 -1
- package/packages/core/dist/workflow/context-watch.test.js +0 -746
- package/packages/core/dist/workflow/context-watch.test.js.map +0 -1
- package/pennyfarthing_scripts/__pycache__/bellmode_hook.cpython-314.pyc +0 -0
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
"""Tests for BikeRack launcher CLI.
|
|
2
|
+
|
|
3
|
+
Story 101-5: BikeRack launcher CLI (pf bikerack start/stop/status)
|
|
4
|
+
Epic: 101 — BikeRack Mode (ADR-0024)
|
|
5
|
+
|
|
6
|
+
Acceptance Criteria:
|
|
7
|
+
- [AC1] `pf bikerack start` starts WheelHub background with IS_BIKERACK=1
|
|
8
|
+
- [AC2] Polls for .bikerack-port file (100ms interval, 5s timeout)
|
|
9
|
+
- [AC3] Sets exactly 5 OTEL env vars from discovered port (Rule 5)
|
|
10
|
+
- [AC4] Uses exec (not spawn) for Claude CLI (CE-4)
|
|
11
|
+
- [AC5] trap EXIT registered before exec to kill WheelHub PID (Rule 8)
|
|
12
|
+
- [AC6] Writes .bikerack-pid after spawning WheelHub
|
|
13
|
+
- [AC7] `pf bikerack stop` reads PID, sends SIGTERM, deletes files
|
|
14
|
+
- [AC8] `pf bikerack status` shows running state (PID, port, uptime)
|
|
15
|
+
- [AC9] Error if already running (.bikerack-port exists with live PID)
|
|
16
|
+
- [AC10] Exit code 1 if WheelHub fails to start, 2 if already running
|
|
17
|
+
- [AC11] Prints dashboard URL on startup
|
|
18
|
+
- [AC12] `just bikerack` works as alias
|
|
19
|
+
|
|
20
|
+
Tests should FAIL until launcher.py is implemented.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import signal
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from unittest.mock import MagicMock, patch
|
|
29
|
+
|
|
30
|
+
import pytest
|
|
31
|
+
|
|
32
|
+
from pennyfarthing_scripts.bikerack.launcher import (
|
|
33
|
+
build_otel_env,
|
|
34
|
+
cleanup_files,
|
|
35
|
+
exec_claude,
|
|
36
|
+
get_status,
|
|
37
|
+
is_already_running,
|
|
38
|
+
is_process_alive,
|
|
39
|
+
poll_for_port_file,
|
|
40
|
+
read_pid_file,
|
|
41
|
+
read_port_file,
|
|
42
|
+
register_cleanup,
|
|
43
|
+
start_wheelhub,
|
|
44
|
+
stop_bikerack,
|
|
45
|
+
write_pid_file,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# AC1: `pf bikerack start` starts WheelHub background with IS_BIKERACK=1
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestStartWheelHub:
|
|
54
|
+
"""AC1: start_wheelhub starts WheelHub in background with IS_BIKERACK=1."""
|
|
55
|
+
|
|
56
|
+
def test_starts_subprocess(self, tmp_path: Path) -> None:
|
|
57
|
+
"""start_wheelhub should return a Popen object (background process)."""
|
|
58
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.subprocess.Popen") as mock_popen:
|
|
59
|
+
mock_proc = MagicMock()
|
|
60
|
+
mock_proc.pid = 12345
|
|
61
|
+
mock_popen.return_value = mock_proc
|
|
62
|
+
|
|
63
|
+
result = start_wheelhub(tmp_path)
|
|
64
|
+
|
|
65
|
+
assert result.pid == 12345
|
|
66
|
+
mock_popen.assert_called_once()
|
|
67
|
+
|
|
68
|
+
def test_sets_is_bikerack_env(self, tmp_path: Path) -> None:
|
|
69
|
+
"""start_wheelhub should set IS_BIKERACK=1 in subprocess env."""
|
|
70
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.subprocess.Popen") as mock_popen:
|
|
71
|
+
mock_popen.return_value = MagicMock(pid=12345)
|
|
72
|
+
|
|
73
|
+
start_wheelhub(tmp_path)
|
|
74
|
+
|
|
75
|
+
# Inspect the env passed to Popen
|
|
76
|
+
popen_kwargs = mock_popen.call_args
|
|
77
|
+
env = popen_kwargs.kwargs.get("env") or popen_kwargs[1].get("env")
|
|
78
|
+
assert env is not None, "Popen should be called with env parameter"
|
|
79
|
+
assert env.get("IS_BIKERACK") == "1"
|
|
80
|
+
|
|
81
|
+
def test_sets_project_dir_env(self, tmp_path: Path) -> None:
|
|
82
|
+
"""start_wheelhub should set CYCLIST_PROJECT_DIR in subprocess env."""
|
|
83
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.subprocess.Popen") as mock_popen:
|
|
84
|
+
mock_popen.return_value = MagicMock(pid=12345)
|
|
85
|
+
|
|
86
|
+
start_wheelhub(tmp_path)
|
|
87
|
+
|
|
88
|
+
popen_kwargs = mock_popen.call_args
|
|
89
|
+
env = popen_kwargs.kwargs.get("env") or popen_kwargs[1].get("env")
|
|
90
|
+
assert env.get("CYCLIST_PROJECT_DIR") == str(tmp_path)
|
|
91
|
+
|
|
92
|
+
def test_process_is_background(self, tmp_path: Path) -> None:
|
|
93
|
+
"""start_wheelhub should not block (background process)."""
|
|
94
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.subprocess.Popen") as mock_popen:
|
|
95
|
+
mock_popen.return_value = MagicMock(pid=12345)
|
|
96
|
+
|
|
97
|
+
result = start_wheelhub(tmp_path)
|
|
98
|
+
|
|
99
|
+
# Should return immediately (Popen, not run)
|
|
100
|
+
result.wait.assert_not_called()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# AC2: Polls for .bikerack-port file (100ms interval, 5s timeout)
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TestPortFilePolling:
|
|
109
|
+
"""AC2: poll_for_port_file polls with correct interval and timeout."""
|
|
110
|
+
|
|
111
|
+
def test_returns_port_when_file_exists(self, tmp_path: Path) -> None:
|
|
112
|
+
"""poll_for_port_file should return port number from file."""
|
|
113
|
+
port_file = tmp_path / ".bikerack-port"
|
|
114
|
+
port_file.write_text("2898")
|
|
115
|
+
|
|
116
|
+
result = poll_for_port_file(tmp_path)
|
|
117
|
+
|
|
118
|
+
assert result == 2898
|
|
119
|
+
|
|
120
|
+
def test_raises_on_timeout(self, tmp_path: Path) -> None:
|
|
121
|
+
"""poll_for_port_file should raise TimeoutError when file never appears."""
|
|
122
|
+
# No port file exists — should timeout
|
|
123
|
+
with pytest.raises(TimeoutError):
|
|
124
|
+
poll_for_port_file(tmp_path, timeout=0.3, interval=0.1)
|
|
125
|
+
|
|
126
|
+
def test_waits_for_file_to_appear(self, tmp_path: Path) -> None:
|
|
127
|
+
"""poll_for_port_file should poll until file appears."""
|
|
128
|
+
port_file = tmp_path / ".bikerack-port"
|
|
129
|
+
|
|
130
|
+
# Simulate file appearing after short delay
|
|
131
|
+
call_count = [0]
|
|
132
|
+
original_exists = Path.exists
|
|
133
|
+
|
|
134
|
+
def delayed_exists(self_path):
|
|
135
|
+
if str(self_path) == str(port_file):
|
|
136
|
+
call_count[0] += 1
|
|
137
|
+
if call_count[0] >= 3:
|
|
138
|
+
port_file.write_text("2898")
|
|
139
|
+
return True
|
|
140
|
+
return False
|
|
141
|
+
return original_exists(self_path)
|
|
142
|
+
|
|
143
|
+
with patch.object(Path, "exists", delayed_exists):
|
|
144
|
+
result = poll_for_port_file(tmp_path, timeout=5.0, interval=0.05)
|
|
145
|
+
assert result == 2898
|
|
146
|
+
|
|
147
|
+
def test_default_timeout_is_5_seconds(self, tmp_path: Path) -> None:
|
|
148
|
+
"""poll_for_port_file default timeout should be 5 seconds."""
|
|
149
|
+
import inspect
|
|
150
|
+
|
|
151
|
+
sig = inspect.signature(poll_for_port_file)
|
|
152
|
+
assert sig.parameters["timeout"].default == 5.0
|
|
153
|
+
|
|
154
|
+
def test_default_interval_is_100ms(self, tmp_path: Path) -> None:
|
|
155
|
+
"""poll_for_port_file default interval should be 0.1 seconds (100ms)."""
|
|
156
|
+
import inspect
|
|
157
|
+
|
|
158
|
+
sig = inspect.signature(poll_for_port_file)
|
|
159
|
+
assert sig.parameters["interval"].default == 0.1
|
|
160
|
+
|
|
161
|
+
def test_reads_integer_port(self, tmp_path: Path) -> None:
|
|
162
|
+
"""poll_for_port_file should parse port as integer."""
|
|
163
|
+
port_file = tmp_path / ".bikerack-port"
|
|
164
|
+
port_file.write_text("3000\n") # Trailing newline should be handled
|
|
165
|
+
|
|
166
|
+
result = poll_for_port_file(tmp_path)
|
|
167
|
+
|
|
168
|
+
assert isinstance(result, int)
|
|
169
|
+
assert result == 3000
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# AC3: Sets exactly 5 OTEL env vars from discovered port (Rule 5)
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class TestOtelEnvVars:
|
|
178
|
+
"""AC3: build_otel_env sets exactly 5 OTEL env vars (Rule 5 from ADR-0024)."""
|
|
179
|
+
|
|
180
|
+
def test_returns_exactly_five_vars(self) -> None:
|
|
181
|
+
"""build_otel_env should return exactly 5 environment variables."""
|
|
182
|
+
result = build_otel_env(2898)
|
|
183
|
+
assert len(result) == 5, f"Expected 5 OTEL vars, got {len(result)}: {list(result.keys())}"
|
|
184
|
+
|
|
185
|
+
def test_includes_telemetry_enable(self) -> None:
|
|
186
|
+
"""build_otel_env should include CLAUDE_CODE_ENABLE_TELEMETRY=1."""
|
|
187
|
+
result = build_otel_env(2898)
|
|
188
|
+
assert result.get("CLAUDE_CODE_ENABLE_TELEMETRY") == "1"
|
|
189
|
+
|
|
190
|
+
def test_includes_logs_exporter(self) -> None:
|
|
191
|
+
"""build_otel_env should include OTEL_LOGS_EXPORTER=otlp."""
|
|
192
|
+
result = build_otel_env(2898)
|
|
193
|
+
assert result.get("OTEL_LOGS_EXPORTER") == "otlp"
|
|
194
|
+
|
|
195
|
+
def test_includes_metrics_exporter(self) -> None:
|
|
196
|
+
"""build_otel_env should include OTEL_METRICS_EXPORTER=otlp."""
|
|
197
|
+
result = build_otel_env(2898)
|
|
198
|
+
assert result.get("OTEL_METRICS_EXPORTER") == "otlp"
|
|
199
|
+
|
|
200
|
+
def test_includes_otlp_protocol(self) -> None:
|
|
201
|
+
"""build_otel_env should include OTEL_EXPORTER_OTLP_PROTOCOL=http/json."""
|
|
202
|
+
result = build_otel_env(2898)
|
|
203
|
+
assert result.get("OTEL_EXPORTER_OTLP_PROTOCOL") == "http/json"
|
|
204
|
+
|
|
205
|
+
def test_includes_otlp_endpoint_with_port(self) -> None:
|
|
206
|
+
"""build_otel_env should include OTEL_EXPORTER_OTLP_ENDPOINT with correct port."""
|
|
207
|
+
result = build_otel_env(2898)
|
|
208
|
+
assert result.get("OTEL_EXPORTER_OTLP_ENDPOINT") == "http://localhost:2898"
|
|
209
|
+
|
|
210
|
+
def test_endpoint_uses_provided_port(self) -> None:
|
|
211
|
+
"""build_otel_env endpoint should use the port argument."""
|
|
212
|
+
result = build_otel_env(3456)
|
|
213
|
+
assert result["OTEL_EXPORTER_OTLP_ENDPOINT"] == "http://localhost:3456"
|
|
214
|
+
|
|
215
|
+
def test_no_traces_exporter(self) -> None:
|
|
216
|
+
"""build_otel_env should NOT include OTEL_TRACES_EXPORTER (Claude doesn't emit traces)."""
|
|
217
|
+
result = build_otel_env(2898)
|
|
218
|
+
assert "OTEL_TRACES_EXPORTER" not in result
|
|
219
|
+
|
|
220
|
+
def test_returns_dict_of_strings(self) -> None:
|
|
221
|
+
"""build_otel_env should return dict[str, str]."""
|
|
222
|
+
result = build_otel_env(2898)
|
|
223
|
+
for key, val in result.items():
|
|
224
|
+
assert isinstance(key, str), f"Key {key!r} is not str"
|
|
225
|
+
assert isinstance(val, str), f"Value {val!r} for {key} is not str"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# AC4: Uses exec (not spawn) for Claude CLI (CE-4)
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class TestExecClaude:
|
|
234
|
+
"""AC4: exec_claude replaces the process with Claude CLI via os.execvpe."""
|
|
235
|
+
|
|
236
|
+
def test_calls_os_execvpe(self) -> None:
|
|
237
|
+
"""exec_claude should call os.execvpe (not subprocess.Popen)."""
|
|
238
|
+
otel_env = build_otel_env(2898)
|
|
239
|
+
|
|
240
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.os.execvpe") as mock_exec:
|
|
241
|
+
# execvpe never returns, so mock it
|
|
242
|
+
mock_exec.side_effect = SystemExit(0)
|
|
243
|
+
|
|
244
|
+
with pytest.raises(SystemExit):
|
|
245
|
+
exec_claude(otel_env)
|
|
246
|
+
|
|
247
|
+
mock_exec.assert_called_once()
|
|
248
|
+
|
|
249
|
+
def test_execs_claude_binary(self) -> None:
|
|
250
|
+
"""exec_claude should exec the 'claude' binary."""
|
|
251
|
+
otel_env = build_otel_env(2898)
|
|
252
|
+
|
|
253
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.os.execvpe") as mock_exec:
|
|
254
|
+
mock_exec.side_effect = SystemExit(0)
|
|
255
|
+
|
|
256
|
+
with pytest.raises(SystemExit):
|
|
257
|
+
exec_claude(otel_env)
|
|
258
|
+
|
|
259
|
+
args = mock_exec.call_args
|
|
260
|
+
# First arg to execvpe is the program name
|
|
261
|
+
assert args[0][0] == "claude"
|
|
262
|
+
|
|
263
|
+
def test_merges_otel_env_with_current_env(self) -> None:
|
|
264
|
+
"""exec_claude should merge OTEL vars into current environment."""
|
|
265
|
+
otel_env = {"CLAUDE_CODE_ENABLE_TELEMETRY": "1", "OTEL_LOGS_EXPORTER": "otlp"}
|
|
266
|
+
|
|
267
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.os.execvpe") as mock_exec:
|
|
268
|
+
mock_exec.side_effect = SystemExit(0)
|
|
269
|
+
|
|
270
|
+
with pytest.raises(SystemExit):
|
|
271
|
+
exec_claude(otel_env)
|
|
272
|
+
|
|
273
|
+
args = mock_exec.call_args
|
|
274
|
+
# Third arg is the env dict
|
|
275
|
+
exec_env = args[0][2] if len(args[0]) > 2 else args.kwargs.get("env")
|
|
276
|
+
assert exec_env is not None
|
|
277
|
+
# Should contain OTEL vars
|
|
278
|
+
assert exec_env.get("CLAUDE_CODE_ENABLE_TELEMETRY") == "1"
|
|
279
|
+
# Should also contain existing env vars (e.g., PATH)
|
|
280
|
+
assert "PATH" in exec_env
|
|
281
|
+
|
|
282
|
+
def test_does_not_use_subprocess(self) -> None:
|
|
283
|
+
"""exec_claude must NOT use subprocess (CE-4: exec, not spawn)."""
|
|
284
|
+
otel_env = build_otel_env(2898)
|
|
285
|
+
|
|
286
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.os.execvpe") as mock_exec:
|
|
287
|
+
mock_exec.side_effect = SystemExit(0)
|
|
288
|
+
|
|
289
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.subprocess.Popen") as mock_popen:
|
|
290
|
+
with pytest.raises(SystemExit):
|
|
291
|
+
exec_claude(otel_env)
|
|
292
|
+
|
|
293
|
+
mock_popen.assert_not_called()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
# AC5: trap EXIT registered before exec to kill WheelHub PID (Rule 8)
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class TestCleanupRegistration:
|
|
302
|
+
"""AC5: register_cleanup sets up atexit handler to kill WheelHub."""
|
|
303
|
+
|
|
304
|
+
def test_registers_atexit_handler(self, tmp_path: Path) -> None:
|
|
305
|
+
"""register_cleanup should register an atexit handler."""
|
|
306
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.atexit.register") as mock_register:
|
|
307
|
+
register_cleanup(tmp_path, pid=12345)
|
|
308
|
+
|
|
309
|
+
mock_register.assert_called_once()
|
|
310
|
+
|
|
311
|
+
def test_cleanup_kills_wheelhub_pid(self, tmp_path: Path) -> None:
|
|
312
|
+
"""Registered cleanup should send SIGTERM to WheelHub PID."""
|
|
313
|
+
cleanup_func = None
|
|
314
|
+
|
|
315
|
+
def capture_handler(func, *args, **kwargs):
|
|
316
|
+
nonlocal cleanup_func
|
|
317
|
+
|
|
318
|
+
def _call_cleanup():
|
|
319
|
+
return func(*args, **kwargs)
|
|
320
|
+
|
|
321
|
+
cleanup_func = _call_cleanup
|
|
322
|
+
|
|
323
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.atexit.register", side_effect=capture_handler):
|
|
324
|
+
register_cleanup(tmp_path, pid=12345)
|
|
325
|
+
|
|
326
|
+
assert cleanup_func is not None, "atexit handler not registered"
|
|
327
|
+
|
|
328
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.os.kill") as mock_kill:
|
|
329
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.os.path.exists", return_value=True):
|
|
330
|
+
# Simulate cleanup
|
|
331
|
+
try:
|
|
332
|
+
cleanup_func()
|
|
333
|
+
except (ProcessLookupError, OSError):
|
|
334
|
+
pass # Expected if mocking doesn't cover all paths
|
|
335
|
+
|
|
336
|
+
# Should attempt to kill the PID
|
|
337
|
+
mock_kill.assert_called()
|
|
338
|
+
kill_args = mock_kill.call_args[0]
|
|
339
|
+
assert kill_args[0] == 12345
|
|
340
|
+
assert kill_args[1] == signal.SIGTERM
|
|
341
|
+
|
|
342
|
+
def test_cleanup_removes_port_file(self, tmp_path: Path) -> None:
|
|
343
|
+
"""Registered cleanup should delete .bikerack-port file."""
|
|
344
|
+
port_file = tmp_path / ".bikerack-port"
|
|
345
|
+
port_file.write_text("2898")
|
|
346
|
+
|
|
347
|
+
cleanup_func = None
|
|
348
|
+
|
|
349
|
+
def capture_handler(func, *args, **kwargs):
|
|
350
|
+
nonlocal cleanup_func
|
|
351
|
+
|
|
352
|
+
def _call_cleanup():
|
|
353
|
+
return func(*args, **kwargs)
|
|
354
|
+
|
|
355
|
+
cleanup_func = _call_cleanup
|
|
356
|
+
|
|
357
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.atexit.register", side_effect=capture_handler):
|
|
358
|
+
register_cleanup(tmp_path, pid=12345)
|
|
359
|
+
|
|
360
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.os.kill"):
|
|
361
|
+
try:
|
|
362
|
+
cleanup_func()
|
|
363
|
+
except (ProcessLookupError, OSError):
|
|
364
|
+
pass
|
|
365
|
+
|
|
366
|
+
assert not port_file.exists(), ".bikerack-port should be deleted by cleanup"
|
|
367
|
+
|
|
368
|
+
def test_cleanup_removes_pid_file(self, tmp_path: Path) -> None:
|
|
369
|
+
"""Registered cleanup should delete .bikerack-pid file."""
|
|
370
|
+
pid_file = tmp_path / ".bikerack-pid"
|
|
371
|
+
pid_file.write_text("12345")
|
|
372
|
+
|
|
373
|
+
cleanup_func = None
|
|
374
|
+
|
|
375
|
+
def capture_handler(func, *args, **kwargs):
|
|
376
|
+
nonlocal cleanup_func
|
|
377
|
+
|
|
378
|
+
def _call_cleanup():
|
|
379
|
+
return func(*args, **kwargs)
|
|
380
|
+
|
|
381
|
+
cleanup_func = _call_cleanup
|
|
382
|
+
|
|
383
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.atexit.register", side_effect=capture_handler):
|
|
384
|
+
register_cleanup(tmp_path, pid=12345)
|
|
385
|
+
|
|
386
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.os.kill"):
|
|
387
|
+
try:
|
|
388
|
+
cleanup_func()
|
|
389
|
+
except (ProcessLookupError, OSError):
|
|
390
|
+
pass
|
|
391
|
+
|
|
392
|
+
assert not pid_file.exists(), ".bikerack-pid should be deleted by cleanup"
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# ---------------------------------------------------------------------------
|
|
396
|
+
# AC6: Writes .bikerack-pid after spawning WheelHub
|
|
397
|
+
# ---------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
class TestPidFile:
|
|
401
|
+
"""AC6: write_pid_file writes .bikerack-pid."""
|
|
402
|
+
|
|
403
|
+
def test_writes_pid_to_file(self, tmp_path: Path) -> None:
|
|
404
|
+
"""write_pid_file should write PID as ASCII string."""
|
|
405
|
+
write_pid_file(tmp_path, pid=48291)
|
|
406
|
+
|
|
407
|
+
pid_file = tmp_path / ".bikerack-pid"
|
|
408
|
+
assert pid_file.exists()
|
|
409
|
+
assert pid_file.read_text().strip() == "48291"
|
|
410
|
+
|
|
411
|
+
def test_read_pid_file_returns_pid(self, tmp_path: Path) -> None:
|
|
412
|
+
"""read_pid_file should return PID as integer."""
|
|
413
|
+
pid_file = tmp_path / ".bikerack-pid"
|
|
414
|
+
pid_file.write_text("48291")
|
|
415
|
+
|
|
416
|
+
result = read_pid_file(tmp_path)
|
|
417
|
+
|
|
418
|
+
assert result == 48291
|
|
419
|
+
|
|
420
|
+
def test_read_pid_file_returns_none_when_missing(self, tmp_path: Path) -> None:
|
|
421
|
+
"""read_pid_file should return None when file doesn't exist."""
|
|
422
|
+
result = read_pid_file(tmp_path)
|
|
423
|
+
|
|
424
|
+
assert result is None
|
|
425
|
+
|
|
426
|
+
def test_write_pid_creates_file_in_project_dir(self, tmp_path: Path) -> None:
|
|
427
|
+
"""write_pid_file should create .bikerack-pid in project directory."""
|
|
428
|
+
write_pid_file(tmp_path, pid=99999)
|
|
429
|
+
|
|
430
|
+
expected = tmp_path / ".bikerack-pid"
|
|
431
|
+
assert expected.exists()
|
|
432
|
+
assert expected.name == ".bikerack-pid"
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
# ---------------------------------------------------------------------------
|
|
436
|
+
# AC7: `pf bikerack stop` reads PID, sends SIGTERM, deletes files
|
|
437
|
+
# ---------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class TestStopBikeRack:
|
|
441
|
+
"""AC7: stop_bikerack sends SIGTERM and cleans up files."""
|
|
442
|
+
|
|
443
|
+
def test_sends_sigterm_to_pid(self, tmp_path: Path) -> None:
|
|
444
|
+
"""stop_bikerack should send SIGTERM to the WheelHub PID."""
|
|
445
|
+
# Setup: create port and pid files
|
|
446
|
+
(tmp_path / ".bikerack-port").write_text("2898")
|
|
447
|
+
(tmp_path / ".bikerack-pid").write_text("12345")
|
|
448
|
+
|
|
449
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.os.kill") as mock_kill:
|
|
450
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.is_process_alive", return_value=True):
|
|
451
|
+
stop_bikerack(tmp_path)
|
|
452
|
+
|
|
453
|
+
mock_kill.assert_called_with(12345, signal.SIGTERM)
|
|
454
|
+
|
|
455
|
+
def test_deletes_port_file(self, tmp_path: Path) -> None:
|
|
456
|
+
"""stop_bikerack should delete .bikerack-port."""
|
|
457
|
+
port_file = tmp_path / ".bikerack-port"
|
|
458
|
+
port_file.write_text("2898")
|
|
459
|
+
(tmp_path / ".bikerack-pid").write_text("12345")
|
|
460
|
+
|
|
461
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.os.kill"):
|
|
462
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.is_process_alive", return_value=True):
|
|
463
|
+
stop_bikerack(tmp_path)
|
|
464
|
+
|
|
465
|
+
assert not port_file.exists()
|
|
466
|
+
|
|
467
|
+
def test_deletes_pid_file(self, tmp_path: Path) -> None:
|
|
468
|
+
"""stop_bikerack should delete .bikerack-pid."""
|
|
469
|
+
(tmp_path / ".bikerack-port").write_text("2898")
|
|
470
|
+
pid_file = tmp_path / ".bikerack-pid"
|
|
471
|
+
pid_file.write_text("12345")
|
|
472
|
+
|
|
473
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.os.kill"):
|
|
474
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.is_process_alive", return_value=True):
|
|
475
|
+
stop_bikerack(tmp_path)
|
|
476
|
+
|
|
477
|
+
assert not pid_file.exists()
|
|
478
|
+
|
|
479
|
+
def test_returns_success_dict(self, tmp_path: Path) -> None:
|
|
480
|
+
"""stop_bikerack should return {success: True, pid: N, message: str}."""
|
|
481
|
+
(tmp_path / ".bikerack-port").write_text("2898")
|
|
482
|
+
(tmp_path / ".bikerack-pid").write_text("12345")
|
|
483
|
+
|
|
484
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.os.kill"):
|
|
485
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.is_process_alive", return_value=True):
|
|
486
|
+
result = stop_bikerack(tmp_path)
|
|
487
|
+
|
|
488
|
+
assert result["success"] is True
|
|
489
|
+
assert result["pid"] == 12345
|
|
490
|
+
|
|
491
|
+
def test_returns_error_when_not_running(self, tmp_path: Path) -> None:
|
|
492
|
+
"""stop_bikerack should return error when no instance is running."""
|
|
493
|
+
# No port or pid files
|
|
494
|
+
result = stop_bikerack(tmp_path)
|
|
495
|
+
|
|
496
|
+
assert result["success"] is False
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# ---------------------------------------------------------------------------
|
|
500
|
+
# AC8: `pf bikerack status` shows running state (PID, port, uptime)
|
|
501
|
+
# ---------------------------------------------------------------------------
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
class TestStatus:
|
|
505
|
+
"""AC8: get_status returns running state info."""
|
|
506
|
+
|
|
507
|
+
def test_returns_running_state(self, tmp_path: Path) -> None:
|
|
508
|
+
"""get_status should detect running BikeRack."""
|
|
509
|
+
(tmp_path / ".bikerack-port").write_text("2898")
|
|
510
|
+
(tmp_path / ".bikerack-pid").write_text("12345")
|
|
511
|
+
|
|
512
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.is_process_alive", return_value=True):
|
|
513
|
+
result = get_status(tmp_path)
|
|
514
|
+
|
|
515
|
+
assert result["running"] is True
|
|
516
|
+
assert result["pid"] == 12345
|
|
517
|
+
assert result["port"] == 2898
|
|
518
|
+
|
|
519
|
+
def test_returns_not_running(self, tmp_path: Path) -> None:
|
|
520
|
+
"""get_status should detect no running BikeRack."""
|
|
521
|
+
result = get_status(tmp_path)
|
|
522
|
+
|
|
523
|
+
assert result["running"] is False
|
|
524
|
+
|
|
525
|
+
def test_includes_dashboard_url(self, tmp_path: Path) -> None:
|
|
526
|
+
"""get_status should include dashboard URL when running."""
|
|
527
|
+
(tmp_path / ".bikerack-port").write_text("2898")
|
|
528
|
+
(tmp_path / ".bikerack-pid").write_text("12345")
|
|
529
|
+
|
|
530
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.is_process_alive", return_value=True):
|
|
531
|
+
result = get_status(tmp_path)
|
|
532
|
+
|
|
533
|
+
assert "http://localhost:2898/bikerack" in result.get("dashboard", "")
|
|
534
|
+
|
|
535
|
+
def test_detects_stale_pid(self, tmp_path: Path) -> None:
|
|
536
|
+
"""get_status should detect stale PID (file exists, process dead)."""
|
|
537
|
+
(tmp_path / ".bikerack-port").write_text("2898")
|
|
538
|
+
(tmp_path / ".bikerack-pid").write_text("99999")
|
|
539
|
+
|
|
540
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.is_process_alive", return_value=False):
|
|
541
|
+
result = get_status(tmp_path)
|
|
542
|
+
|
|
543
|
+
assert result["running"] is False
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# ---------------------------------------------------------------------------
|
|
547
|
+
# AC9: Error if already running (.bikerack-port exists with live PID)
|
|
548
|
+
# ---------------------------------------------------------------------------
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
class TestAlreadyRunning:
|
|
552
|
+
"""AC9: is_already_running detects existing BikeRack instance."""
|
|
553
|
+
|
|
554
|
+
def test_detects_running_instance(self, tmp_path: Path) -> None:
|
|
555
|
+
"""is_already_running should return True when port file + live PID."""
|
|
556
|
+
(tmp_path / ".bikerack-port").write_text("2898")
|
|
557
|
+
(tmp_path / ".bikerack-pid").write_text("12345")
|
|
558
|
+
|
|
559
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.is_process_alive", return_value=True):
|
|
560
|
+
running, pid, port = is_already_running(tmp_path)
|
|
561
|
+
|
|
562
|
+
assert running is True
|
|
563
|
+
assert pid == 12345
|
|
564
|
+
assert port == 2898
|
|
565
|
+
|
|
566
|
+
def test_not_running_when_no_files(self, tmp_path: Path) -> None:
|
|
567
|
+
"""is_already_running should return False when no files exist."""
|
|
568
|
+
running, pid, port = is_already_running(tmp_path)
|
|
569
|
+
|
|
570
|
+
assert running is False
|
|
571
|
+
assert pid is None
|
|
572
|
+
assert port is None
|
|
573
|
+
|
|
574
|
+
def test_not_running_when_stale_pid(self, tmp_path: Path) -> None:
|
|
575
|
+
"""is_already_running should return False when PID is dead (stale)."""
|
|
576
|
+
(tmp_path / ".bikerack-port").write_text("2898")
|
|
577
|
+
(tmp_path / ".bikerack-pid").write_text("99999")
|
|
578
|
+
|
|
579
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.is_process_alive", return_value=False):
|
|
580
|
+
running, pid, port = is_already_running(tmp_path)
|
|
581
|
+
|
|
582
|
+
assert running is False
|
|
583
|
+
|
|
584
|
+
def test_cleans_stale_files(self, tmp_path: Path) -> None:
|
|
585
|
+
"""is_already_running should clean up stale files when PID is dead."""
|
|
586
|
+
port_file = tmp_path / ".bikerack-port"
|
|
587
|
+
pid_file = tmp_path / ".bikerack-pid"
|
|
588
|
+
port_file.write_text("2898")
|
|
589
|
+
pid_file.write_text("99999")
|
|
590
|
+
|
|
591
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.is_process_alive", return_value=False):
|
|
592
|
+
is_already_running(tmp_path)
|
|
593
|
+
|
|
594
|
+
assert not port_file.exists(), "Stale port file should be cleaned up"
|
|
595
|
+
assert not pid_file.exists(), "Stale PID file should be cleaned up"
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
# ---------------------------------------------------------------------------
|
|
599
|
+
# AC10: Exit codes — 1 if WheelHub fails to start, 2 if already running
|
|
600
|
+
# ---------------------------------------------------------------------------
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
class TestExitCodes:
|
|
604
|
+
"""AC10: Correct exit codes for error conditions."""
|
|
605
|
+
|
|
606
|
+
def test_exit_code_2_when_already_running(self, tmp_path: Path) -> None:
|
|
607
|
+
"""Start should raise SystemExit(2) when already running."""
|
|
608
|
+
(tmp_path / ".bikerack-port").write_text("2898")
|
|
609
|
+
(tmp_path / ".bikerack-pid").write_text("12345")
|
|
610
|
+
|
|
611
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.is_process_alive", return_value=True):
|
|
612
|
+
with patch("pennyfarthing_scripts.bikerack.launcher.is_already_running",
|
|
613
|
+
return_value=(True, 12345, 2898)):
|
|
614
|
+
# The start flow should detect already-running and exit 2
|
|
615
|
+
# This tests the logic, not the full CLI flow
|
|
616
|
+
running, pid, port = is_already_running(tmp_path)
|
|
617
|
+
assert running is True
|
|
618
|
+
# The CLI layer should convert this to exit code 2
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
# ---------------------------------------------------------------------------
|
|
622
|
+
# AC11: Prints dashboard URL on startup
|
|
623
|
+
# ---------------------------------------------------------------------------
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
class TestDashboardUrl:
|
|
627
|
+
"""AC11: Dashboard URL is displayed on startup."""
|
|
628
|
+
|
|
629
|
+
def test_dashboard_url_format(self) -> None:
|
|
630
|
+
"""Dashboard URL should be http://localhost:{port}/bikerack."""
|
|
631
|
+
# The URL format is deterministic from the port
|
|
632
|
+
port = 2898
|
|
633
|
+
expected = f"http://localhost:{port}/bikerack"
|
|
634
|
+
assert expected == "http://localhost:2898/bikerack"
|
|
635
|
+
|
|
636
|
+
def test_dashboard_url_uses_custom_port(self) -> None:
|
|
637
|
+
"""Dashboard URL should use the actual discovered port."""
|
|
638
|
+
port = 3456
|
|
639
|
+
expected = f"http://localhost:{port}/bikerack"
|
|
640
|
+
assert expected == "http://localhost:3456/bikerack"
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
# ---------------------------------------------------------------------------
|
|
644
|
+
# AC12: `just bikerack` works as alias (tested at integration level)
|
|
645
|
+
# ---------------------------------------------------------------------------
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
class TestBikeRackCLI:
|
|
649
|
+
"""AC12: CLI module entry point works."""
|
|
650
|
+
|
|
651
|
+
def test_bikerack_cli_help(self) -> None:
|
|
652
|
+
"""bikerack CLI should show help with --help."""
|
|
653
|
+
result = subprocess.run(
|
|
654
|
+
[sys.executable, "-m", "pennyfarthing_scripts.bikerack", "--help"],
|
|
655
|
+
capture_output=True,
|
|
656
|
+
text=True,
|
|
657
|
+
timeout=30,
|
|
658
|
+
cwd=str(Path(__file__).parent.parent.parent),
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
assert result.returncode == 0
|
|
662
|
+
assert "bikerack" in result.stdout.lower() or "BikeRack" in result.stdout
|
|
663
|
+
|
|
664
|
+
def test_bikerack_has_start_subcommand(self) -> None:
|
|
665
|
+
"""bikerack CLI should have start subcommand."""
|
|
666
|
+
result = subprocess.run(
|
|
667
|
+
[sys.executable, "-m", "pennyfarthing_scripts.bikerack", "start", "--help"],
|
|
668
|
+
capture_output=True,
|
|
669
|
+
text=True,
|
|
670
|
+
timeout=30,
|
|
671
|
+
cwd=str(Path(__file__).parent.parent.parent),
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
assert result.returncode == 0
|
|
675
|
+
|
|
676
|
+
def test_bikerack_has_stop_subcommand(self) -> None:
|
|
677
|
+
"""bikerack CLI should have stop subcommand."""
|
|
678
|
+
result = subprocess.run(
|
|
679
|
+
[sys.executable, "-m", "pennyfarthing_scripts.bikerack", "stop", "--help"],
|
|
680
|
+
capture_output=True,
|
|
681
|
+
text=True,
|
|
682
|
+
timeout=30,
|
|
683
|
+
cwd=str(Path(__file__).parent.parent.parent),
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
assert result.returncode == 0
|
|
687
|
+
|
|
688
|
+
def test_bikerack_has_status_subcommand(self) -> None:
|
|
689
|
+
"""bikerack CLI should have status subcommand."""
|
|
690
|
+
result = subprocess.run(
|
|
691
|
+
[sys.executable, "-m", "pennyfarthing_scripts.bikerack", "status", "--help"],
|
|
692
|
+
capture_output=True,
|
|
693
|
+
text=True,
|
|
694
|
+
timeout=30,
|
|
695
|
+
cwd=str(Path(__file__).parent.parent.parent),
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
assert result.returncode == 0
|
|
699
|
+
|
|
700
|
+
def test_bikerack_default_invokes_start(self) -> None:
|
|
701
|
+
"""bikerack with no subcommand should invoke start."""
|
|
702
|
+
# Running without subcommand should behave like 'start'
|
|
703
|
+
# Since start is not implemented, it should error
|
|
704
|
+
result = subprocess.run(
|
|
705
|
+
[sys.executable, "-m", "pennyfarthing_scripts.bikerack"],
|
|
706
|
+
capture_output=True,
|
|
707
|
+
text=True,
|
|
708
|
+
timeout=30,
|
|
709
|
+
cwd=str(Path(__file__).parent.parent.parent),
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
# Should fail (NotImplementedError in start) but not with usage error
|
|
713
|
+
assert result.returncode != 0
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
# ---------------------------------------------------------------------------
|
|
717
|
+
# Utility function tests
|
|
718
|
+
# ---------------------------------------------------------------------------
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
class TestProcessAlive:
|
|
722
|
+
"""Utility: is_process_alive checks if PID is running."""
|
|
723
|
+
|
|
724
|
+
def test_detects_own_process(self) -> None:
|
|
725
|
+
"""is_process_alive should return True for current process."""
|
|
726
|
+
result = is_process_alive(os.getpid())
|
|
727
|
+
assert result is True
|
|
728
|
+
|
|
729
|
+
def test_returns_false_for_invalid_pid(self) -> None:
|
|
730
|
+
"""is_process_alive should return False for non-existent PID."""
|
|
731
|
+
result = is_process_alive(999999999)
|
|
732
|
+
assert result is False
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
class TestCleanupFiles:
|
|
736
|
+
"""Utility: cleanup_files removes port and PID files."""
|
|
737
|
+
|
|
738
|
+
def test_removes_port_file(self, tmp_path: Path) -> None:
|
|
739
|
+
"""cleanup_files should remove .bikerack-port."""
|
|
740
|
+
port_file = tmp_path / ".bikerack-port"
|
|
741
|
+
port_file.write_text("2898")
|
|
742
|
+
|
|
743
|
+
cleanup_files(tmp_path)
|
|
744
|
+
|
|
745
|
+
assert not port_file.exists()
|
|
746
|
+
|
|
747
|
+
def test_removes_pid_file(self, tmp_path: Path) -> None:
|
|
748
|
+
"""cleanup_files should remove .bikerack-pid."""
|
|
749
|
+
pid_file = tmp_path / ".bikerack-pid"
|
|
750
|
+
pid_file.write_text("12345")
|
|
751
|
+
|
|
752
|
+
cleanup_files(tmp_path)
|
|
753
|
+
|
|
754
|
+
assert not pid_file.exists()
|
|
755
|
+
|
|
756
|
+
def test_no_error_when_files_missing(self, tmp_path: Path) -> None:
|
|
757
|
+
"""cleanup_files should not error when files don't exist."""
|
|
758
|
+
# Should not raise
|
|
759
|
+
cleanup_files(tmp_path)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
class TestReadPortFile:
|
|
763
|
+
"""Utility: read_port_file reads port from file."""
|
|
764
|
+
|
|
765
|
+
def test_reads_port(self, tmp_path: Path) -> None:
|
|
766
|
+
"""read_port_file should return port as integer."""
|
|
767
|
+
(tmp_path / ".bikerack-port").write_text("2898")
|
|
768
|
+
|
|
769
|
+
result = read_port_file(tmp_path)
|
|
770
|
+
|
|
771
|
+
assert result == 2898
|
|
772
|
+
|
|
773
|
+
def test_returns_none_when_missing(self, tmp_path: Path) -> None:
|
|
774
|
+
"""read_port_file should return None when file doesn't exist."""
|
|
775
|
+
result = read_port_file(tmp_path)
|
|
776
|
+
|
|
777
|
+
assert result is None
|
|
778
|
+
|
|
779
|
+
def test_handles_trailing_whitespace(self, tmp_path: Path) -> None:
|
|
780
|
+
"""read_port_file should handle trailing newlines/spaces."""
|
|
781
|
+
(tmp_path / ".bikerack-port").write_text("2898\n")
|
|
782
|
+
|
|
783
|
+
result = read_port_file(tmp_path)
|
|
784
|
+
|
|
785
|
+
assert result == 2898
|