From c3403033140f87a91df17a090d7c99b13af0d96b Mon Sep 17 00:00:00 2001 From: Charon Date: Mon, 16 Feb 2026 14:45:06 +0000 Subject: [PATCH] refactor: flatten commands, extract php/ci to own repos (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Extract PHP/Laravel commands to `core/php` repo (42 files, standalone module) - Extract CI/release + SDK commands to `core/ci` repo (10 files) - Remove `internal/variants/` build tag system entirely - Move all 30 remaining command packages from `internal/cmd/` to top-level `cmd/` - Rewrite `main.go` with direct imports — no more variant selection - PHP and CI are now optional via commented import lines in main.go Co-authored-by: Claude Reviewed-on: https://forge.lthn.ai/core/cli/pulls/2 Co-authored-by: Charon Co-committed-by: Charon --- {internal/cmd => cmd}/ai/cmd_agent.go | 0 {internal/cmd => cmd}/ai/cmd_ai.go | 0 {internal/cmd => cmd}/ai/cmd_commands.go | 2 +- {internal/cmd => cmd}/ai/cmd_dispatch.go | 0 {internal/cmd => cmd}/ai/cmd_git.go | 0 {internal/cmd => cmd}/ai/cmd_metrics.go | 0 {internal/cmd => cmd}/ai/cmd_ratelimits.go | 0 {internal/cmd => cmd}/ai/cmd_tasks.go | 0 {internal/cmd => cmd}/ai/cmd_updates.go | 0 .../cmd => cmd}/ai/ratelimit_dispatch.go | 0 {internal/cmd => cmd}/collect/cmd.go | 0 .../cmd => cmd}/collect/cmd_bitcointalk.go | 0 {internal/cmd => cmd}/collect/cmd_dispatch.go | 0 {internal/cmd => cmd}/collect/cmd_excavate.go | 0 {internal/cmd => cmd}/collect/cmd_github.go | 0 {internal/cmd => cmd}/collect/cmd_market.go | 0 {internal/cmd => cmd}/collect/cmd_papers.go | 0 {internal/cmd => cmd}/collect/cmd_process.go | 0 {internal/cmd => cmd}/config/cmd.go | 0 {internal/cmd => cmd}/config/cmd_get.go | 0 {internal/cmd => cmd}/config/cmd_list.go | 0 {internal/cmd => cmd}/config/cmd_path.go | 0 {internal/cmd => cmd}/config/cmd_set.go | 0 {internal/cmd => cmd}/crypt/cmd.go | 0 {internal/cmd => cmd}/crypt/cmd_checksum.go | 0 {internal/cmd => cmd}/crypt/cmd_encrypt.go | 0 {internal/cmd => cmd}/crypt/cmd_hash.go | 0 {internal/cmd => cmd}/crypt/cmd_keygen.go | 0 {internal/cmd => cmd}/daemon/cmd.go | 0 {internal/cmd => cmd}/deploy/cmd_ansible.go | 0 {internal/cmd => cmd}/deploy/cmd_commands.go | 0 {internal/cmd => cmd}/deploy/cmd_deploy.go | 0 {internal/cmd => cmd}/dev/cmd_api.go | 0 {internal/cmd => cmd}/dev/cmd_apply.go | 0 {internal/cmd => cmd}/dev/cmd_bundles.go | 0 {internal/cmd => cmd}/dev/cmd_ci.go | 0 {internal/cmd => cmd}/dev/cmd_commit.go | 0 {internal/cmd => cmd}/dev/cmd_dev.go | 0 {internal/cmd => cmd}/dev/cmd_file_sync.go | 0 {internal/cmd => cmd}/dev/cmd_health.go | 0 {internal/cmd => cmd}/dev/cmd_impact.go | 0 {internal/cmd => cmd}/dev/cmd_issues.go | 0 {internal/cmd => cmd}/dev/cmd_pull.go | 0 {internal/cmd => cmd}/dev/cmd_push.go | 0 {internal/cmd => cmd}/dev/cmd_reviews.go | 0 {internal/cmd => cmd}/dev/cmd_sync.go | 0 {internal/cmd => cmd}/dev/cmd_vm.go | 0 {internal/cmd => cmd}/dev/cmd_work.go | 0 {internal/cmd => cmd}/dev/cmd_workflow.go | 0 .../cmd => cmd}/dev/cmd_workflow_test.go | 0 {internal/cmd => cmd}/dev/registry.go | 2 +- {internal/cmd => cmd}/dev/service.go | 0 {internal/cmd => cmd}/docs/cmd_commands.go | 0 {internal/cmd => cmd}/docs/cmd_docs.go | 0 {internal/cmd => cmd}/docs/cmd_list.go | 0 {internal/cmd => cmd}/docs/cmd_scan.go | 2 +- {internal/cmd => cmd}/docs/cmd_sync.go | 0 {internal/cmd => cmd}/doctor/cmd_checks.go | 0 {internal/cmd => cmd}/doctor/cmd_commands.go | 0 {internal/cmd => cmd}/doctor/cmd_doctor.go | 0 .../cmd => cmd}/doctor/cmd_environment.go | 0 {internal/cmd => cmd}/doctor/cmd_install.go | 0 {internal/cmd => cmd}/forge/cmd_auth.go | 0 {internal/cmd => cmd}/forge/cmd_config.go | 0 {internal/cmd => cmd}/forge/cmd_forge.go | 0 {internal/cmd => cmd}/forge/cmd_issues.go | 0 {internal/cmd => cmd}/forge/cmd_labels.go | 0 {internal/cmd => cmd}/forge/cmd_migrate.go | 0 {internal/cmd => cmd}/forge/cmd_orgs.go | 0 {internal/cmd => cmd}/forge/cmd_prs.go | 0 {internal/cmd => cmd}/forge/cmd_repos.go | 0 {internal/cmd => cmd}/forge/cmd_status.go | 0 {internal/cmd => cmd}/forge/cmd_sync.go | 0 {internal/cmd => cmd}/forge/helpers.go | 0 {internal/cmd => cmd}/gitcmd/cmd_git.go | 2 +- {internal/cmd => cmd}/gitea/cmd_config.go | 0 {internal/cmd => cmd}/gitea/cmd_gitea.go | 0 {internal/cmd => cmd}/gitea/cmd_issues.go | 0 {internal/cmd => cmd}/gitea/cmd_mirror.go | 0 {internal/cmd => cmd}/gitea/cmd_prs.go | 0 {internal/cmd => cmd}/gitea/cmd_repos.go | 0 {internal/cmd => cmd}/gitea/cmd_sync.go | 0 {internal/cmd => cmd}/go/cmd_commands.go | 0 {internal/cmd => cmd}/go/cmd_format.go | 0 {internal/cmd => cmd}/go/cmd_fuzz.go | 0 {internal/cmd => cmd}/go/cmd_go.go | 0 {internal/cmd => cmd}/go/cmd_gotest.go | 0 {internal/cmd => cmd}/go/cmd_qa.go | 2 +- {internal/cmd => cmd}/go/cmd_tools.go | 0 {internal/cmd => cmd}/go/coverage_test.go | 0 {internal/cmd => cmd}/help/cmd.go | 0 {internal/cmd => cmd}/lab/cmd_lab.go | 0 {internal/cmd => cmd}/mcpcmd/cmd_mcp.go | 0 {internal/cmd => cmd}/ml/cmd_agent.go | 0 {internal/cmd => cmd}/ml/cmd_approve.go | 0 {internal/cmd => cmd}/ml/cmd_consolidate.go | 0 {internal/cmd => cmd}/ml/cmd_convert.go | 0 {internal/cmd => cmd}/ml/cmd_coverage.go | 0 {internal/cmd => cmd}/ml/cmd_expand.go | 0 {internal/cmd => cmd}/ml/cmd_export.go | 0 {internal/cmd => cmd}/ml/cmd_gguf.go | 0 {internal/cmd => cmd}/ml/cmd_import.go | 0 {internal/cmd => cmd}/ml/cmd_ingest.go | 0 {internal/cmd => cmd}/ml/cmd_inventory.go | 0 {internal/cmd => cmd}/ml/cmd_metrics.go | 0 {internal/cmd => cmd}/ml/cmd_ml.go | 0 {internal/cmd => cmd}/ml/cmd_normalize.go | 0 {internal/cmd => cmd}/ml/cmd_probe.go | 0 {internal/cmd => cmd}/ml/cmd_publish.go | 0 {internal/cmd => cmd}/ml/cmd_query.go | 0 {internal/cmd => cmd}/ml/cmd_score.go | 0 {internal/cmd => cmd}/ml/cmd_seed_influx.go | 0 {internal/cmd => cmd}/ml/cmd_serve.go | 0 {internal/cmd => cmd}/ml/cmd_status.go | 0 {internal/cmd => cmd}/ml/cmd_worker.go | 0 .../cmd => cmd}/ml/serve_backend_default.go | 0 {internal/cmd => cmd}/ml/serve_backend_mlx.go | 0 {internal/cmd => cmd}/monitor/cmd_commands.go | 0 {internal/cmd => cmd}/monitor/cmd_monitor.go | 0 {internal/cmd => cmd}/pkgcmd/cmd_commands.go | 0 {internal/cmd => cmd}/pkgcmd/cmd_install.go | 0 {internal/cmd => cmd}/pkgcmd/cmd_manage.go | 0 {internal/cmd => cmd}/pkgcmd/cmd_pkg.go | 0 {internal/cmd => cmd}/pkgcmd/cmd_remove.go | 0 .../cmd => cmd}/pkgcmd/cmd_remove_test.go | 0 {internal/cmd => cmd}/pkgcmd/cmd_search.go | 0 {internal/cmd => cmd}/plugin/cmd.go | 0 {internal/cmd => cmd}/plugin/cmd_info.go | 0 {internal/cmd => cmd}/plugin/cmd_install.go | 0 {internal/cmd => cmd}/plugin/cmd_list.go | 0 {internal/cmd => cmd}/plugin/cmd_remove.go | 0 {internal/cmd => cmd}/plugin/cmd_update.go | 0 {internal/cmd => cmd}/prod/cmd_commands.go | 0 {internal/cmd => cmd}/prod/cmd_dns.go | 0 {internal/cmd => cmd}/prod/cmd_lb.go | 0 {internal/cmd => cmd}/prod/cmd_prod.go | 0 {internal/cmd => cmd}/prod/cmd_setup.go | 0 {internal/cmd => cmd}/prod/cmd_ssh.go | 0 {internal/cmd => cmd}/prod/cmd_status.go | 0 {internal/cmd => cmd}/qa/cmd_docblock.go | 0 {internal/cmd => cmd}/qa/cmd_health.go | 0 {internal/cmd => cmd}/qa/cmd_issues.go | 0 {internal/cmd => cmd}/qa/cmd_qa.go | 0 {internal/cmd => cmd}/qa/cmd_review.go | 0 {internal/cmd => cmd}/qa/cmd_watch.go | 0 {internal/cmd => cmd}/rag/cmd_collections.go | 0 {internal/cmd => cmd}/rag/cmd_commands.go | 0 {internal/cmd => cmd}/rag/cmd_ingest.go | 0 {internal/cmd => cmd}/rag/cmd_query.go | 0 {internal/cmd => cmd}/rag/cmd_rag.go | 0 {internal/cmd => cmd}/security/cmd.go | 0 {internal/cmd => cmd}/security/cmd_alerts.go | 0 {internal/cmd => cmd}/security/cmd_deps.go | 0 {internal/cmd => cmd}/security/cmd_jobs.go | 0 {internal/cmd => cmd}/security/cmd_scan.go | 0 {internal/cmd => cmd}/security/cmd_secrets.go | 0 .../cmd => cmd}/security/cmd_security.go | 0 {internal/cmd => cmd}/session/cmd_session.go | 0 {internal/cmd => cmd}/setup/cmd_bootstrap.go | 2 +- {internal/cmd => cmd}/setup/cmd_ci.go | 0 {internal/cmd => cmd}/setup/cmd_commands.go | 0 {internal/cmd => cmd}/setup/cmd_github.go | 0 {internal/cmd => cmd}/setup/cmd_registry.go | 2 +- {internal/cmd => cmd}/setup/cmd_repo.go | 0 {internal/cmd => cmd}/setup/cmd_setup.go | 0 {internal/cmd => cmd}/setup/cmd_wizard.go | 0 {internal/cmd => cmd}/setup/github_config.go | 0 {internal/cmd => cmd}/setup/github_diff.go | 0 {internal/cmd => cmd}/setup/github_labels.go | 0 .../cmd => cmd}/setup/github_protection.go | 0 .../cmd => cmd}/setup/github_security.go | 0 .../cmd => cmd}/setup/github_webhooks.go | 0 {internal/cmd => cmd}/test/cmd_commands.go | 0 {internal/cmd => cmd}/test/cmd_main.go | 0 {internal/cmd => cmd}/test/cmd_output.go | 0 {internal/cmd => cmd}/test/cmd_runner.go | 0 {internal/cmd => cmd}/test/output_test.go | 0 {internal/cmd => cmd}/unifi/cmd_clients.go | 0 {internal/cmd => cmd}/unifi/cmd_config.go | 0 {internal/cmd => cmd}/unifi/cmd_devices.go | 0 {internal/cmd => cmd}/unifi/cmd_networks.go | 0 {internal/cmd => cmd}/unifi/cmd_routes.go | 0 {internal/cmd => cmd}/unifi/cmd_sites.go | 0 {internal/cmd => cmd}/unifi/cmd_unifi.go | 0 .../updater/.github/workflows/ci.yml | 0 .../updater/.github/workflows/release.yml | 0 {internal/cmd => cmd}/updater/.gitignore | 0 {internal/cmd => cmd}/updater/LICENSE | 0 {internal/cmd => cmd}/updater/Makefile | 0 {internal/cmd => cmd}/updater/README.md | 0 {internal/cmd => cmd}/updater/build/main.go | 0 {internal/cmd => cmd}/updater/cmd.go | 0 {internal/cmd => cmd}/updater/cmd_unix.go | 0 {internal/cmd => cmd}/updater/cmd_windows.go | 0 {internal/cmd => cmd}/updater/docs/README.md | 0 .../cmd => cmd}/updater/docs/architecture.md | 0 .../cmd => cmd}/updater/docs/configuration.md | 0 .../updater/docs/getting-started.md | 0 {internal/cmd => cmd}/updater/generic_http.go | 0 .../cmd => cmd}/updater/generic_http_test.go | 0 {internal/cmd => cmd}/updater/github.go | 0 {internal/cmd => cmd}/updater/github_test.go | 0 .../updater/mock_github_client_test.go | 0 {internal/cmd => cmd}/updater/package.json | 0 {internal/cmd => cmd}/updater/service.go | 2 +- .../updater/service_examples_test.go | 2 +- {internal/cmd => cmd}/updater/service_test.go | 0 {internal/cmd => cmd}/updater/tests.patch | 0 .../cmd => cmd}/updater/ui/.editorconfig | 0 {internal/cmd => cmd}/updater/ui/.gitignore | 0 .../updater/ui/.vscode/extensions.json | 0 .../updater/ui/.vscode/launch.json | 0 .../cmd => cmd}/updater/ui/.vscode/tasks.json | 0 {internal/cmd => cmd}/updater/ui/README.md | 0 {internal/cmd => cmd}/updater/ui/angular.json | 0 .../cmd => cmd}/updater/ui/package-lock.json | 0 {internal/cmd => cmd}/updater/ui/package.json | 0 .../cmd => cmd}/updater/ui/public/favicon.ico | Bin .../updater/ui/src/app/app-module.ts | 0 .../cmd => cmd}/updater/ui/src/app/app.html | 0 .../cmd => cmd}/updater/ui/src/app/app.ts | 0 .../cmd => cmd}/updater/ui/src/index.html | 0 {internal/cmd => cmd}/updater/ui/src/main.ts | 0 .../cmd => cmd}/updater/ui/src/styles.css | 0 .../cmd => cmd}/updater/ui/tsconfig.app.json | 0 .../cmd => cmd}/updater/ui/tsconfig.json | 0 .../cmd => cmd}/updater/ui/tsconfig.spec.json | 0 {internal/cmd => cmd}/updater/updater.go | 0 {internal/cmd => cmd}/updater/updater_test.go | 0 {internal/cmd => cmd}/updater/version.go | 0 {internal/cmd => cmd}/vm/cmd_commands.go | 0 {internal/cmd => cmd}/vm/cmd_container.go | 0 {internal/cmd => cmd}/vm/cmd_templates.go | 0 {internal/cmd => cmd}/vm/cmd_vm.go | 0 {internal/cmd => cmd}/workspace/cmd.go | 0 {internal/cmd => cmd}/workspace/cmd_agent.go | 0 .../cmd => cmd}/workspace/cmd_agent_test.go | 0 {internal/cmd => cmd}/workspace/cmd_task.go | 0 .../cmd => cmd}/workspace/cmd_task_test.go | 0 .../cmd => cmd}/workspace/cmd_workspace.go | 0 {internal/cmd => cmd}/workspace/config.go | 0 internal/cmd/ci/cmd_changelog.go | 57 - internal/cmd/ci/cmd_ci.go | 84 -- internal/cmd/ci/cmd_commands.go | 23 - internal/cmd/ci/cmd_init.go | 43 - internal/cmd/ci/cmd_publish.go | 81 -- internal/cmd/ci/cmd_version.go | 25 - internal/cmd/php/cmd.go | 158 --- internal/cmd/php/cmd_build.go | 291 ----- internal/cmd/php/cmd_ci.go | 562 ---------- internal/cmd/php/cmd_commands.go | 41 - internal/cmd/php/cmd_deploy.go | 361 ------- internal/cmd/php/cmd_dev.go | 497 --------- internal/cmd/php/cmd_packages.go | 146 --- internal/cmd/php/cmd_qa_runner.go | 343 ------ internal/cmd/php/cmd_quality.go | 815 -------------- internal/cmd/php/container.go | 451 -------- internal/cmd/php/container_test.go | 383 ------- internal/cmd/php/coolify.go | 351 ------- internal/cmd/php/coolify_test.go | 502 --------- internal/cmd/php/deploy.go | 407 ------- internal/cmd/php/deploy_internal_test.go | 221 ---- internal/cmd/php/deploy_test.go | 257 ----- internal/cmd/php/detect.go | 296 ------ internal/cmd/php/detect_test.go | 663 ------------ internal/cmd/php/dockerfile.go | 398 ------- internal/cmd/php/dockerfile_test.go | 634 ----------- internal/cmd/php/i18n.go | 16 - internal/cmd/php/locales/en_GB.json | 147 --- internal/cmd/php/packages.go | 308 ------ internal/cmd/php/packages_test.go | 543 ---------- internal/cmd/php/php.go | 397 ------- internal/cmd/php/php_test.go | 644 ------------ internal/cmd/php/quality.go | 994 ------------------ internal/cmd/php/quality_extended_test.go | 304 ------ internal/cmd/php/quality_test.go | 517 --------- internal/cmd/php/services.go | 486 --------- internal/cmd/php/services_extended_test.go | 313 ------ internal/cmd/php/services_test.go | 100 -- internal/cmd/php/services_unix.go | 41 - internal/cmd/php/services_windows.go | 34 - internal/cmd/php/ssl.go | 165 --- internal/cmd/php/ssl_extended_test.go | 219 ---- internal/cmd/php/ssl_test.go | 172 --- internal/cmd/php/testing.go | 195 ---- internal/cmd/php/testing_test.go | 380 ------- internal/cmd/sdk/cmd_commands.go | 8 - internal/cmd/sdk/cmd_sdk.go | 134 --- internal/variants/ci.go | 23 - internal/variants/core_ide.go | 22 - internal/variants/full.go | 65 -- internal/variants/minimal.go | 17 - internal/variants/php.go | 19 - main.go | 39 +- 294 files changed, 45 insertions(+), 14365 deletions(-) rename {internal/cmd => cmd}/ai/cmd_agent.go (100%) rename {internal/cmd => cmd}/ai/cmd_ai.go (100%) rename {internal/cmd => cmd}/ai/cmd_commands.go (97%) rename {internal/cmd => cmd}/ai/cmd_dispatch.go (100%) rename {internal/cmd => cmd}/ai/cmd_git.go (100%) rename {internal/cmd => cmd}/ai/cmd_metrics.go (100%) rename {internal/cmd => cmd}/ai/cmd_ratelimits.go (100%) rename {internal/cmd => cmd}/ai/cmd_tasks.go (100%) rename {internal/cmd => cmd}/ai/cmd_updates.go (100%) rename {internal/cmd => cmd}/ai/ratelimit_dispatch.go (100%) rename {internal/cmd => cmd}/collect/cmd.go (100%) rename {internal/cmd => cmd}/collect/cmd_bitcointalk.go (100%) rename {internal/cmd => cmd}/collect/cmd_dispatch.go (100%) rename {internal/cmd => cmd}/collect/cmd_excavate.go (100%) rename {internal/cmd => cmd}/collect/cmd_github.go (100%) rename {internal/cmd => cmd}/collect/cmd_market.go (100%) rename {internal/cmd => cmd}/collect/cmd_papers.go (100%) rename {internal/cmd => cmd}/collect/cmd_process.go (100%) rename {internal/cmd => cmd}/config/cmd.go (100%) rename {internal/cmd => cmd}/config/cmd_get.go (100%) rename {internal/cmd => cmd}/config/cmd_list.go (100%) rename {internal/cmd => cmd}/config/cmd_path.go (100%) rename {internal/cmd => cmd}/config/cmd_set.go (100%) rename {internal/cmd => cmd}/crypt/cmd.go (100%) rename {internal/cmd => cmd}/crypt/cmd_checksum.go (100%) rename {internal/cmd => cmd}/crypt/cmd_encrypt.go (100%) rename {internal/cmd => cmd}/crypt/cmd_hash.go (100%) rename {internal/cmd => cmd}/crypt/cmd_keygen.go (100%) rename {internal/cmd => cmd}/daemon/cmd.go (100%) rename {internal/cmd => cmd}/deploy/cmd_ansible.go (100%) rename {internal/cmd => cmd}/deploy/cmd_commands.go (100%) rename {internal/cmd => cmd}/deploy/cmd_deploy.go (100%) rename {internal/cmd => cmd}/dev/cmd_api.go (100%) rename {internal/cmd => cmd}/dev/cmd_apply.go (100%) rename {internal/cmd => cmd}/dev/cmd_bundles.go (100%) rename {internal/cmd => cmd}/dev/cmd_ci.go (100%) rename {internal/cmd => cmd}/dev/cmd_commit.go (100%) rename {internal/cmd => cmd}/dev/cmd_dev.go (100%) rename {internal/cmd => cmd}/dev/cmd_file_sync.go (100%) rename {internal/cmd => cmd}/dev/cmd_health.go (100%) rename {internal/cmd => cmd}/dev/cmd_impact.go (100%) rename {internal/cmd => cmd}/dev/cmd_issues.go (100%) rename {internal/cmd => cmd}/dev/cmd_pull.go (100%) rename {internal/cmd => cmd}/dev/cmd_push.go (100%) rename {internal/cmd => cmd}/dev/cmd_reviews.go (100%) rename {internal/cmd => cmd}/dev/cmd_sync.go (100%) rename {internal/cmd => cmd}/dev/cmd_vm.go (100%) rename {internal/cmd => cmd}/dev/cmd_work.go (100%) rename {internal/cmd => cmd}/dev/cmd_workflow.go (100%) rename {internal/cmd => cmd}/dev/cmd_workflow_test.go (100%) rename {internal/cmd => cmd}/dev/registry.go (97%) rename {internal/cmd => cmd}/dev/service.go (100%) rename {internal/cmd => cmd}/docs/cmd_commands.go (100%) rename {internal/cmd => cmd}/docs/cmd_docs.go (100%) rename {internal/cmd => cmd}/docs/cmd_list.go (100%) rename {internal/cmd => cmd}/docs/cmd_scan.go (98%) rename {internal/cmd => cmd}/docs/cmd_sync.go (100%) rename {internal/cmd => cmd}/doctor/cmd_checks.go (100%) rename {internal/cmd => cmd}/doctor/cmd_commands.go (100%) rename {internal/cmd => cmd}/doctor/cmd_doctor.go (100%) rename {internal/cmd => cmd}/doctor/cmd_environment.go (100%) rename {internal/cmd => cmd}/doctor/cmd_install.go (100%) rename {internal/cmd => cmd}/forge/cmd_auth.go (100%) rename {internal/cmd => cmd}/forge/cmd_config.go (100%) rename {internal/cmd => cmd}/forge/cmd_forge.go (100%) rename {internal/cmd => cmd}/forge/cmd_issues.go (100%) rename {internal/cmd => cmd}/forge/cmd_labels.go (100%) rename {internal/cmd => cmd}/forge/cmd_migrate.go (100%) rename {internal/cmd => cmd}/forge/cmd_orgs.go (100%) rename {internal/cmd => cmd}/forge/cmd_prs.go (100%) rename {internal/cmd => cmd}/forge/cmd_repos.go (100%) rename {internal/cmd => cmd}/forge/cmd_status.go (100%) rename {internal/cmd => cmd}/forge/cmd_sync.go (100%) rename {internal/cmd => cmd}/forge/helpers.go (100%) rename {internal/cmd => cmd}/gitcmd/cmd_git.go (96%) rename {internal/cmd => cmd}/gitea/cmd_config.go (100%) rename {internal/cmd => cmd}/gitea/cmd_gitea.go (100%) rename {internal/cmd => cmd}/gitea/cmd_issues.go (100%) rename {internal/cmd => cmd}/gitea/cmd_mirror.go (100%) rename {internal/cmd => cmd}/gitea/cmd_prs.go (100%) rename {internal/cmd => cmd}/gitea/cmd_repos.go (100%) rename {internal/cmd => cmd}/gitea/cmd_sync.go (100%) rename {internal/cmd => cmd}/go/cmd_commands.go (100%) rename {internal/cmd => cmd}/go/cmd_format.go (100%) rename {internal/cmd => cmd}/go/cmd_fuzz.go (100%) rename {internal/cmd => cmd}/go/cmd_go.go (100%) rename {internal/cmd => cmd}/go/cmd_gotest.go (100%) rename {internal/cmd => cmd}/go/cmd_qa.go (99%) rename {internal/cmd => cmd}/go/cmd_tools.go (100%) rename {internal/cmd => cmd}/go/coverage_test.go (100%) rename {internal/cmd => cmd}/help/cmd.go (100%) rename {internal/cmd => cmd}/lab/cmd_lab.go (100%) rename {internal/cmd => cmd}/mcpcmd/cmd_mcp.go (100%) rename {internal/cmd => cmd}/ml/cmd_agent.go (100%) rename {internal/cmd => cmd}/ml/cmd_approve.go (100%) rename {internal/cmd => cmd}/ml/cmd_consolidate.go (100%) rename {internal/cmd => cmd}/ml/cmd_convert.go (100%) rename {internal/cmd => cmd}/ml/cmd_coverage.go (100%) rename {internal/cmd => cmd}/ml/cmd_expand.go (100%) rename {internal/cmd => cmd}/ml/cmd_export.go (100%) rename {internal/cmd => cmd}/ml/cmd_gguf.go (100%) rename {internal/cmd => cmd}/ml/cmd_import.go (100%) rename {internal/cmd => cmd}/ml/cmd_ingest.go (100%) rename {internal/cmd => cmd}/ml/cmd_inventory.go (100%) rename {internal/cmd => cmd}/ml/cmd_metrics.go (100%) rename {internal/cmd => cmd}/ml/cmd_ml.go (100%) rename {internal/cmd => cmd}/ml/cmd_normalize.go (100%) rename {internal/cmd => cmd}/ml/cmd_probe.go (100%) rename {internal/cmd => cmd}/ml/cmd_publish.go (100%) rename {internal/cmd => cmd}/ml/cmd_query.go (100%) rename {internal/cmd => cmd}/ml/cmd_score.go (100%) rename {internal/cmd => cmd}/ml/cmd_seed_influx.go (100%) rename {internal/cmd => cmd}/ml/cmd_serve.go (100%) rename {internal/cmd => cmd}/ml/cmd_status.go (100%) rename {internal/cmd => cmd}/ml/cmd_worker.go (100%) rename {internal/cmd => cmd}/ml/serve_backend_default.go (100%) rename {internal/cmd => cmd}/ml/serve_backend_mlx.go (100%) rename {internal/cmd => cmd}/monitor/cmd_commands.go (100%) rename {internal/cmd => cmd}/monitor/cmd_monitor.go (100%) rename {internal/cmd => cmd}/pkgcmd/cmd_commands.go (100%) rename {internal/cmd => cmd}/pkgcmd/cmd_install.go (100%) rename {internal/cmd => cmd}/pkgcmd/cmd_manage.go (100%) rename {internal/cmd => cmd}/pkgcmd/cmd_pkg.go (100%) rename {internal/cmd => cmd}/pkgcmd/cmd_remove.go (100%) rename {internal/cmd => cmd}/pkgcmd/cmd_remove_test.go (100%) rename {internal/cmd => cmd}/pkgcmd/cmd_search.go (100%) rename {internal/cmd => cmd}/plugin/cmd.go (100%) rename {internal/cmd => cmd}/plugin/cmd_info.go (100%) rename {internal/cmd => cmd}/plugin/cmd_install.go (100%) rename {internal/cmd => cmd}/plugin/cmd_list.go (100%) rename {internal/cmd => cmd}/plugin/cmd_remove.go (100%) rename {internal/cmd => cmd}/plugin/cmd_update.go (100%) rename {internal/cmd => cmd}/prod/cmd_commands.go (100%) rename {internal/cmd => cmd}/prod/cmd_dns.go (100%) rename {internal/cmd => cmd}/prod/cmd_lb.go (100%) rename {internal/cmd => cmd}/prod/cmd_prod.go (100%) rename {internal/cmd => cmd}/prod/cmd_setup.go (100%) rename {internal/cmd => cmd}/prod/cmd_ssh.go (100%) rename {internal/cmd => cmd}/prod/cmd_status.go (100%) rename {internal/cmd => cmd}/qa/cmd_docblock.go (100%) rename {internal/cmd => cmd}/qa/cmd_health.go (100%) rename {internal/cmd => cmd}/qa/cmd_issues.go (100%) rename {internal/cmd => cmd}/qa/cmd_qa.go (100%) rename {internal/cmd => cmd}/qa/cmd_review.go (100%) rename {internal/cmd => cmd}/qa/cmd_watch.go (100%) rename {internal/cmd => cmd}/rag/cmd_collections.go (100%) rename {internal/cmd => cmd}/rag/cmd_commands.go (100%) rename {internal/cmd => cmd}/rag/cmd_ingest.go (100%) rename {internal/cmd => cmd}/rag/cmd_query.go (100%) rename {internal/cmd => cmd}/rag/cmd_rag.go (100%) rename {internal/cmd => cmd}/security/cmd.go (100%) rename {internal/cmd => cmd}/security/cmd_alerts.go (100%) rename {internal/cmd => cmd}/security/cmd_deps.go (100%) rename {internal/cmd => cmd}/security/cmd_jobs.go (100%) rename {internal/cmd => cmd}/security/cmd_scan.go (100%) rename {internal/cmd => cmd}/security/cmd_secrets.go (100%) rename {internal/cmd => cmd}/security/cmd_security.go (100%) rename {internal/cmd => cmd}/session/cmd_session.go (100%) rename {internal/cmd => cmd}/setup/cmd_bootstrap.go (99%) rename {internal/cmd => cmd}/setup/cmd_ci.go (100%) rename {internal/cmd => cmd}/setup/cmd_commands.go (100%) rename {internal/cmd => cmd}/setup/cmd_github.go (100%) rename {internal/cmd => cmd}/setup/cmd_registry.go (99%) rename {internal/cmd => cmd}/setup/cmd_repo.go (100%) rename {internal/cmd => cmd}/setup/cmd_setup.go (100%) rename {internal/cmd => cmd}/setup/cmd_wizard.go (100%) rename {internal/cmd => cmd}/setup/github_config.go (100%) rename {internal/cmd => cmd}/setup/github_diff.go (100%) rename {internal/cmd => cmd}/setup/github_labels.go (100%) rename {internal/cmd => cmd}/setup/github_protection.go (100%) rename {internal/cmd => cmd}/setup/github_security.go (100%) rename {internal/cmd => cmd}/setup/github_webhooks.go (100%) rename {internal/cmd => cmd}/test/cmd_commands.go (100%) rename {internal/cmd => cmd}/test/cmd_main.go (100%) rename {internal/cmd => cmd}/test/cmd_output.go (100%) rename {internal/cmd => cmd}/test/cmd_runner.go (100%) rename {internal/cmd => cmd}/test/output_test.go (100%) rename {internal/cmd => cmd}/unifi/cmd_clients.go (100%) rename {internal/cmd => cmd}/unifi/cmd_config.go (100%) rename {internal/cmd => cmd}/unifi/cmd_devices.go (100%) rename {internal/cmd => cmd}/unifi/cmd_networks.go (100%) rename {internal/cmd => cmd}/unifi/cmd_routes.go (100%) rename {internal/cmd => cmd}/unifi/cmd_sites.go (100%) rename {internal/cmd => cmd}/unifi/cmd_unifi.go (100%) rename {internal/cmd => cmd}/updater/.github/workflows/ci.yml (100%) rename {internal/cmd => cmd}/updater/.github/workflows/release.yml (100%) rename {internal/cmd => cmd}/updater/.gitignore (100%) rename {internal/cmd => cmd}/updater/LICENSE (100%) rename {internal/cmd => cmd}/updater/Makefile (100%) rename {internal/cmd => cmd}/updater/README.md (100%) rename {internal/cmd => cmd}/updater/build/main.go (100%) rename {internal/cmd => cmd}/updater/cmd.go (100%) rename {internal/cmd => cmd}/updater/cmd_unix.go (100%) rename {internal/cmd => cmd}/updater/cmd_windows.go (100%) rename {internal/cmd => cmd}/updater/docs/README.md (100%) rename {internal/cmd => cmd}/updater/docs/architecture.md (100%) rename {internal/cmd => cmd}/updater/docs/configuration.md (100%) rename {internal/cmd => cmd}/updater/docs/getting-started.md (100%) rename {internal/cmd => cmd}/updater/generic_http.go (100%) rename {internal/cmd => cmd}/updater/generic_http_test.go (100%) rename {internal/cmd => cmd}/updater/github.go (100%) rename {internal/cmd => cmd}/updater/github_test.go (100%) rename {internal/cmd => cmd}/updater/mock_github_client_test.go (100%) rename {internal/cmd => cmd}/updater/package.json (100%) rename {internal/cmd => cmd}/updater/service.go (98%) rename {internal/cmd => cmd}/updater/service_examples_test.go (95%) rename {internal/cmd => cmd}/updater/service_test.go (100%) rename {internal/cmd => cmd}/updater/tests.patch (100%) rename {internal/cmd => cmd}/updater/ui/.editorconfig (100%) rename {internal/cmd => cmd}/updater/ui/.gitignore (100%) rename {internal/cmd => cmd}/updater/ui/.vscode/extensions.json (100%) rename {internal/cmd => cmd}/updater/ui/.vscode/launch.json (100%) rename {internal/cmd => cmd}/updater/ui/.vscode/tasks.json (100%) rename {internal/cmd => cmd}/updater/ui/README.md (100%) rename {internal/cmd => cmd}/updater/ui/angular.json (100%) rename {internal/cmd => cmd}/updater/ui/package-lock.json (100%) rename {internal/cmd => cmd}/updater/ui/package.json (100%) rename {internal/cmd => cmd}/updater/ui/public/favicon.ico (100%) rename {internal/cmd => cmd}/updater/ui/src/app/app-module.ts (100%) rename {internal/cmd => cmd}/updater/ui/src/app/app.html (100%) rename {internal/cmd => cmd}/updater/ui/src/app/app.ts (100%) rename {internal/cmd => cmd}/updater/ui/src/index.html (100%) rename {internal/cmd => cmd}/updater/ui/src/main.ts (100%) rename {internal/cmd => cmd}/updater/ui/src/styles.css (100%) rename {internal/cmd => cmd}/updater/ui/tsconfig.app.json (100%) rename {internal/cmd => cmd}/updater/ui/tsconfig.json (100%) rename {internal/cmd => cmd}/updater/ui/tsconfig.spec.json (100%) rename {internal/cmd => cmd}/updater/updater.go (100%) rename {internal/cmd => cmd}/updater/updater_test.go (100%) rename {internal/cmd => cmd}/updater/version.go (100%) rename {internal/cmd => cmd}/vm/cmd_commands.go (100%) rename {internal/cmd => cmd}/vm/cmd_container.go (100%) rename {internal/cmd => cmd}/vm/cmd_templates.go (100%) rename {internal/cmd => cmd}/vm/cmd_vm.go (100%) rename {internal/cmd => cmd}/workspace/cmd.go (100%) rename {internal/cmd => cmd}/workspace/cmd_agent.go (100%) rename {internal/cmd => cmd}/workspace/cmd_agent_test.go (100%) rename {internal/cmd => cmd}/workspace/cmd_task.go (100%) rename {internal/cmd => cmd}/workspace/cmd_task_test.go (100%) rename {internal/cmd => cmd}/workspace/cmd_workspace.go (100%) rename {internal/cmd => cmd}/workspace/config.go (100%) delete mode 100644 internal/cmd/ci/cmd_changelog.go delete mode 100644 internal/cmd/ci/cmd_ci.go delete mode 100644 internal/cmd/ci/cmd_commands.go delete mode 100644 internal/cmd/ci/cmd_init.go delete mode 100644 internal/cmd/ci/cmd_publish.go delete mode 100644 internal/cmd/ci/cmd_version.go delete mode 100644 internal/cmd/php/cmd.go delete mode 100644 internal/cmd/php/cmd_build.go delete mode 100644 internal/cmd/php/cmd_ci.go delete mode 100644 internal/cmd/php/cmd_commands.go delete mode 100644 internal/cmd/php/cmd_deploy.go delete mode 100644 internal/cmd/php/cmd_dev.go delete mode 100644 internal/cmd/php/cmd_packages.go delete mode 100644 internal/cmd/php/cmd_qa_runner.go delete mode 100644 internal/cmd/php/cmd_quality.go delete mode 100644 internal/cmd/php/container.go delete mode 100644 internal/cmd/php/container_test.go delete mode 100644 internal/cmd/php/coolify.go delete mode 100644 internal/cmd/php/coolify_test.go delete mode 100644 internal/cmd/php/deploy.go delete mode 100644 internal/cmd/php/deploy_internal_test.go delete mode 100644 internal/cmd/php/deploy_test.go delete mode 100644 internal/cmd/php/detect.go delete mode 100644 internal/cmd/php/detect_test.go delete mode 100644 internal/cmd/php/dockerfile.go delete mode 100644 internal/cmd/php/dockerfile_test.go delete mode 100644 internal/cmd/php/i18n.go delete mode 100644 internal/cmd/php/locales/en_GB.json delete mode 100644 internal/cmd/php/packages.go delete mode 100644 internal/cmd/php/packages_test.go delete mode 100644 internal/cmd/php/php.go delete mode 100644 internal/cmd/php/php_test.go delete mode 100644 internal/cmd/php/quality.go delete mode 100644 internal/cmd/php/quality_extended_test.go delete mode 100644 internal/cmd/php/quality_test.go delete mode 100644 internal/cmd/php/services.go delete mode 100644 internal/cmd/php/services_extended_test.go delete mode 100644 internal/cmd/php/services_test.go delete mode 100644 internal/cmd/php/services_unix.go delete mode 100644 internal/cmd/php/services_windows.go delete mode 100644 internal/cmd/php/ssl.go delete mode 100644 internal/cmd/php/ssl_extended_test.go delete mode 100644 internal/cmd/php/ssl_test.go delete mode 100644 internal/cmd/php/testing.go delete mode 100644 internal/cmd/php/testing_test.go delete mode 100644 internal/cmd/sdk/cmd_commands.go delete mode 100644 internal/cmd/sdk/cmd_sdk.go delete mode 100644 internal/variants/ci.go delete mode 100644 internal/variants/core_ide.go delete mode 100644 internal/variants/full.go delete mode 100644 internal/variants/minimal.go delete mode 100644 internal/variants/php.go diff --git a/internal/cmd/ai/cmd_agent.go b/cmd/ai/cmd_agent.go similarity index 100% rename from internal/cmd/ai/cmd_agent.go rename to cmd/ai/cmd_agent.go diff --git a/internal/cmd/ai/cmd_ai.go b/cmd/ai/cmd_ai.go similarity index 100% rename from internal/cmd/ai/cmd_ai.go rename to cmd/ai/cmd_ai.go diff --git a/internal/cmd/ai/cmd_commands.go b/cmd/ai/cmd_commands.go similarity index 97% rename from internal/cmd/ai/cmd_commands.go rename to cmd/ai/cmd_commands.go index a106e343..55e3ff65 100644 --- a/internal/cmd/ai/cmd_commands.go +++ b/cmd/ai/cmd_commands.go @@ -13,7 +13,7 @@ package ai import ( - ragcmd "forge.lthn.ai/core/cli/internal/cmd/rag" + ragcmd "forge.lthn.ai/core/cli/cmd/rag" "forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/go/pkg/i18n" ) diff --git a/internal/cmd/ai/cmd_dispatch.go b/cmd/ai/cmd_dispatch.go similarity index 100% rename from internal/cmd/ai/cmd_dispatch.go rename to cmd/ai/cmd_dispatch.go diff --git a/internal/cmd/ai/cmd_git.go b/cmd/ai/cmd_git.go similarity index 100% rename from internal/cmd/ai/cmd_git.go rename to cmd/ai/cmd_git.go diff --git a/internal/cmd/ai/cmd_metrics.go b/cmd/ai/cmd_metrics.go similarity index 100% rename from internal/cmd/ai/cmd_metrics.go rename to cmd/ai/cmd_metrics.go diff --git a/internal/cmd/ai/cmd_ratelimits.go b/cmd/ai/cmd_ratelimits.go similarity index 100% rename from internal/cmd/ai/cmd_ratelimits.go rename to cmd/ai/cmd_ratelimits.go diff --git a/internal/cmd/ai/cmd_tasks.go b/cmd/ai/cmd_tasks.go similarity index 100% rename from internal/cmd/ai/cmd_tasks.go rename to cmd/ai/cmd_tasks.go diff --git a/internal/cmd/ai/cmd_updates.go b/cmd/ai/cmd_updates.go similarity index 100% rename from internal/cmd/ai/cmd_updates.go rename to cmd/ai/cmd_updates.go diff --git a/internal/cmd/ai/ratelimit_dispatch.go b/cmd/ai/ratelimit_dispatch.go similarity index 100% rename from internal/cmd/ai/ratelimit_dispatch.go rename to cmd/ai/ratelimit_dispatch.go diff --git a/internal/cmd/collect/cmd.go b/cmd/collect/cmd.go similarity index 100% rename from internal/cmd/collect/cmd.go rename to cmd/collect/cmd.go diff --git a/internal/cmd/collect/cmd_bitcointalk.go b/cmd/collect/cmd_bitcointalk.go similarity index 100% rename from internal/cmd/collect/cmd_bitcointalk.go rename to cmd/collect/cmd_bitcointalk.go diff --git a/internal/cmd/collect/cmd_dispatch.go b/cmd/collect/cmd_dispatch.go similarity index 100% rename from internal/cmd/collect/cmd_dispatch.go rename to cmd/collect/cmd_dispatch.go diff --git a/internal/cmd/collect/cmd_excavate.go b/cmd/collect/cmd_excavate.go similarity index 100% rename from internal/cmd/collect/cmd_excavate.go rename to cmd/collect/cmd_excavate.go diff --git a/internal/cmd/collect/cmd_github.go b/cmd/collect/cmd_github.go similarity index 100% rename from internal/cmd/collect/cmd_github.go rename to cmd/collect/cmd_github.go diff --git a/internal/cmd/collect/cmd_market.go b/cmd/collect/cmd_market.go similarity index 100% rename from internal/cmd/collect/cmd_market.go rename to cmd/collect/cmd_market.go diff --git a/internal/cmd/collect/cmd_papers.go b/cmd/collect/cmd_papers.go similarity index 100% rename from internal/cmd/collect/cmd_papers.go rename to cmd/collect/cmd_papers.go diff --git a/internal/cmd/collect/cmd_process.go b/cmd/collect/cmd_process.go similarity index 100% rename from internal/cmd/collect/cmd_process.go rename to cmd/collect/cmd_process.go diff --git a/internal/cmd/config/cmd.go b/cmd/config/cmd.go similarity index 100% rename from internal/cmd/config/cmd.go rename to cmd/config/cmd.go diff --git a/internal/cmd/config/cmd_get.go b/cmd/config/cmd_get.go similarity index 100% rename from internal/cmd/config/cmd_get.go rename to cmd/config/cmd_get.go diff --git a/internal/cmd/config/cmd_list.go b/cmd/config/cmd_list.go similarity index 100% rename from internal/cmd/config/cmd_list.go rename to cmd/config/cmd_list.go diff --git a/internal/cmd/config/cmd_path.go b/cmd/config/cmd_path.go similarity index 100% rename from internal/cmd/config/cmd_path.go rename to cmd/config/cmd_path.go diff --git a/internal/cmd/config/cmd_set.go b/cmd/config/cmd_set.go similarity index 100% rename from internal/cmd/config/cmd_set.go rename to cmd/config/cmd_set.go diff --git a/internal/cmd/crypt/cmd.go b/cmd/crypt/cmd.go similarity index 100% rename from internal/cmd/crypt/cmd.go rename to cmd/crypt/cmd.go diff --git a/internal/cmd/crypt/cmd_checksum.go b/cmd/crypt/cmd_checksum.go similarity index 100% rename from internal/cmd/crypt/cmd_checksum.go rename to cmd/crypt/cmd_checksum.go diff --git a/internal/cmd/crypt/cmd_encrypt.go b/cmd/crypt/cmd_encrypt.go similarity index 100% rename from internal/cmd/crypt/cmd_encrypt.go rename to cmd/crypt/cmd_encrypt.go diff --git a/internal/cmd/crypt/cmd_hash.go b/cmd/crypt/cmd_hash.go similarity index 100% rename from internal/cmd/crypt/cmd_hash.go rename to cmd/crypt/cmd_hash.go diff --git a/internal/cmd/crypt/cmd_keygen.go b/cmd/crypt/cmd_keygen.go similarity index 100% rename from internal/cmd/crypt/cmd_keygen.go rename to cmd/crypt/cmd_keygen.go diff --git a/internal/cmd/daemon/cmd.go b/cmd/daemon/cmd.go similarity index 100% rename from internal/cmd/daemon/cmd.go rename to cmd/daemon/cmd.go diff --git a/internal/cmd/deploy/cmd_ansible.go b/cmd/deploy/cmd_ansible.go similarity index 100% rename from internal/cmd/deploy/cmd_ansible.go rename to cmd/deploy/cmd_ansible.go diff --git a/internal/cmd/deploy/cmd_commands.go b/cmd/deploy/cmd_commands.go similarity index 100% rename from internal/cmd/deploy/cmd_commands.go rename to cmd/deploy/cmd_commands.go diff --git a/internal/cmd/deploy/cmd_deploy.go b/cmd/deploy/cmd_deploy.go similarity index 100% rename from internal/cmd/deploy/cmd_deploy.go rename to cmd/deploy/cmd_deploy.go diff --git a/internal/cmd/dev/cmd_api.go b/cmd/dev/cmd_api.go similarity index 100% rename from internal/cmd/dev/cmd_api.go rename to cmd/dev/cmd_api.go diff --git a/internal/cmd/dev/cmd_apply.go b/cmd/dev/cmd_apply.go similarity index 100% rename from internal/cmd/dev/cmd_apply.go rename to cmd/dev/cmd_apply.go diff --git a/internal/cmd/dev/cmd_bundles.go b/cmd/dev/cmd_bundles.go similarity index 100% rename from internal/cmd/dev/cmd_bundles.go rename to cmd/dev/cmd_bundles.go diff --git a/internal/cmd/dev/cmd_ci.go b/cmd/dev/cmd_ci.go similarity index 100% rename from internal/cmd/dev/cmd_ci.go rename to cmd/dev/cmd_ci.go diff --git a/internal/cmd/dev/cmd_commit.go b/cmd/dev/cmd_commit.go similarity index 100% rename from internal/cmd/dev/cmd_commit.go rename to cmd/dev/cmd_commit.go diff --git a/internal/cmd/dev/cmd_dev.go b/cmd/dev/cmd_dev.go similarity index 100% rename from internal/cmd/dev/cmd_dev.go rename to cmd/dev/cmd_dev.go diff --git a/internal/cmd/dev/cmd_file_sync.go b/cmd/dev/cmd_file_sync.go similarity index 100% rename from internal/cmd/dev/cmd_file_sync.go rename to cmd/dev/cmd_file_sync.go diff --git a/internal/cmd/dev/cmd_health.go b/cmd/dev/cmd_health.go similarity index 100% rename from internal/cmd/dev/cmd_health.go rename to cmd/dev/cmd_health.go diff --git a/internal/cmd/dev/cmd_impact.go b/cmd/dev/cmd_impact.go similarity index 100% rename from internal/cmd/dev/cmd_impact.go rename to cmd/dev/cmd_impact.go diff --git a/internal/cmd/dev/cmd_issues.go b/cmd/dev/cmd_issues.go similarity index 100% rename from internal/cmd/dev/cmd_issues.go rename to cmd/dev/cmd_issues.go diff --git a/internal/cmd/dev/cmd_pull.go b/cmd/dev/cmd_pull.go similarity index 100% rename from internal/cmd/dev/cmd_pull.go rename to cmd/dev/cmd_pull.go diff --git a/internal/cmd/dev/cmd_push.go b/cmd/dev/cmd_push.go similarity index 100% rename from internal/cmd/dev/cmd_push.go rename to cmd/dev/cmd_push.go diff --git a/internal/cmd/dev/cmd_reviews.go b/cmd/dev/cmd_reviews.go similarity index 100% rename from internal/cmd/dev/cmd_reviews.go rename to cmd/dev/cmd_reviews.go diff --git a/internal/cmd/dev/cmd_sync.go b/cmd/dev/cmd_sync.go similarity index 100% rename from internal/cmd/dev/cmd_sync.go rename to cmd/dev/cmd_sync.go diff --git a/internal/cmd/dev/cmd_vm.go b/cmd/dev/cmd_vm.go similarity index 100% rename from internal/cmd/dev/cmd_vm.go rename to cmd/dev/cmd_vm.go diff --git a/internal/cmd/dev/cmd_work.go b/cmd/dev/cmd_work.go similarity index 100% rename from internal/cmd/dev/cmd_work.go rename to cmd/dev/cmd_work.go diff --git a/internal/cmd/dev/cmd_workflow.go b/cmd/dev/cmd_workflow.go similarity index 100% rename from internal/cmd/dev/cmd_workflow.go rename to cmd/dev/cmd_workflow.go diff --git a/internal/cmd/dev/cmd_workflow_test.go b/cmd/dev/cmd_workflow_test.go similarity index 100% rename from internal/cmd/dev/cmd_workflow_test.go rename to cmd/dev/cmd_workflow_test.go diff --git a/internal/cmd/dev/registry.go b/cmd/dev/registry.go similarity index 97% rename from internal/cmd/dev/registry.go rename to cmd/dev/registry.go index 3ce5ee12..119d0b73 100644 --- a/internal/cmd/dev/registry.go +++ b/cmd/dev/registry.go @@ -5,7 +5,7 @@ import ( "path/filepath" "strings" - "forge.lthn.ai/core/cli/internal/cmd/workspace" + "forge.lthn.ai/core/cli/cmd/workspace" "forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/io" diff --git a/internal/cmd/dev/service.go b/cmd/dev/service.go similarity index 100% rename from internal/cmd/dev/service.go rename to cmd/dev/service.go diff --git a/internal/cmd/docs/cmd_commands.go b/cmd/docs/cmd_commands.go similarity index 100% rename from internal/cmd/docs/cmd_commands.go rename to cmd/docs/cmd_commands.go diff --git a/internal/cmd/docs/cmd_docs.go b/cmd/docs/cmd_docs.go similarity index 100% rename from internal/cmd/docs/cmd_docs.go rename to cmd/docs/cmd_docs.go diff --git a/internal/cmd/docs/cmd_list.go b/cmd/docs/cmd_list.go similarity index 100% rename from internal/cmd/docs/cmd_list.go rename to cmd/docs/cmd_list.go diff --git a/internal/cmd/docs/cmd_scan.go b/cmd/docs/cmd_scan.go similarity index 98% rename from internal/cmd/docs/cmd_scan.go rename to cmd/docs/cmd_scan.go index a897b04d..d4f8fa73 100644 --- a/internal/cmd/docs/cmd_scan.go +++ b/cmd/docs/cmd_scan.go @@ -6,7 +6,7 @@ import ( "path/filepath" "strings" - "forge.lthn.ai/core/cli/internal/cmd/workspace" + "forge.lthn.ai/core/cli/cmd/workspace" "forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/io" diff --git a/internal/cmd/docs/cmd_sync.go b/cmd/docs/cmd_sync.go similarity index 100% rename from internal/cmd/docs/cmd_sync.go rename to cmd/docs/cmd_sync.go diff --git a/internal/cmd/doctor/cmd_checks.go b/cmd/doctor/cmd_checks.go similarity index 100% rename from internal/cmd/doctor/cmd_checks.go rename to cmd/doctor/cmd_checks.go diff --git a/internal/cmd/doctor/cmd_commands.go b/cmd/doctor/cmd_commands.go similarity index 100% rename from internal/cmd/doctor/cmd_commands.go rename to cmd/doctor/cmd_commands.go diff --git a/internal/cmd/doctor/cmd_doctor.go b/cmd/doctor/cmd_doctor.go similarity index 100% rename from internal/cmd/doctor/cmd_doctor.go rename to cmd/doctor/cmd_doctor.go diff --git a/internal/cmd/doctor/cmd_environment.go b/cmd/doctor/cmd_environment.go similarity index 100% rename from internal/cmd/doctor/cmd_environment.go rename to cmd/doctor/cmd_environment.go diff --git a/internal/cmd/doctor/cmd_install.go b/cmd/doctor/cmd_install.go similarity index 100% rename from internal/cmd/doctor/cmd_install.go rename to cmd/doctor/cmd_install.go diff --git a/internal/cmd/forge/cmd_auth.go b/cmd/forge/cmd_auth.go similarity index 100% rename from internal/cmd/forge/cmd_auth.go rename to cmd/forge/cmd_auth.go diff --git a/internal/cmd/forge/cmd_config.go b/cmd/forge/cmd_config.go similarity index 100% rename from internal/cmd/forge/cmd_config.go rename to cmd/forge/cmd_config.go diff --git a/internal/cmd/forge/cmd_forge.go b/cmd/forge/cmd_forge.go similarity index 100% rename from internal/cmd/forge/cmd_forge.go rename to cmd/forge/cmd_forge.go diff --git a/internal/cmd/forge/cmd_issues.go b/cmd/forge/cmd_issues.go similarity index 100% rename from internal/cmd/forge/cmd_issues.go rename to cmd/forge/cmd_issues.go diff --git a/internal/cmd/forge/cmd_labels.go b/cmd/forge/cmd_labels.go similarity index 100% rename from internal/cmd/forge/cmd_labels.go rename to cmd/forge/cmd_labels.go diff --git a/internal/cmd/forge/cmd_migrate.go b/cmd/forge/cmd_migrate.go similarity index 100% rename from internal/cmd/forge/cmd_migrate.go rename to cmd/forge/cmd_migrate.go diff --git a/internal/cmd/forge/cmd_orgs.go b/cmd/forge/cmd_orgs.go similarity index 100% rename from internal/cmd/forge/cmd_orgs.go rename to cmd/forge/cmd_orgs.go diff --git a/internal/cmd/forge/cmd_prs.go b/cmd/forge/cmd_prs.go similarity index 100% rename from internal/cmd/forge/cmd_prs.go rename to cmd/forge/cmd_prs.go diff --git a/internal/cmd/forge/cmd_repos.go b/cmd/forge/cmd_repos.go similarity index 100% rename from internal/cmd/forge/cmd_repos.go rename to cmd/forge/cmd_repos.go diff --git a/internal/cmd/forge/cmd_status.go b/cmd/forge/cmd_status.go similarity index 100% rename from internal/cmd/forge/cmd_status.go rename to cmd/forge/cmd_status.go diff --git a/internal/cmd/forge/cmd_sync.go b/cmd/forge/cmd_sync.go similarity index 100% rename from internal/cmd/forge/cmd_sync.go rename to cmd/forge/cmd_sync.go diff --git a/internal/cmd/forge/helpers.go b/cmd/forge/helpers.go similarity index 100% rename from internal/cmd/forge/helpers.go rename to cmd/forge/helpers.go diff --git a/internal/cmd/gitcmd/cmd_git.go b/cmd/gitcmd/cmd_git.go similarity index 96% rename from internal/cmd/gitcmd/cmd_git.go rename to cmd/gitcmd/cmd_git.go index 2326752f..0024ecda 100644 --- a/internal/cmd/gitcmd/cmd_git.go +++ b/cmd/gitcmd/cmd_git.go @@ -13,7 +13,7 @@ package gitcmd import ( - "forge.lthn.ai/core/cli/internal/cmd/dev" + "forge.lthn.ai/core/cli/cmd/dev" "forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/go/pkg/i18n" ) diff --git a/internal/cmd/gitea/cmd_config.go b/cmd/gitea/cmd_config.go similarity index 100% rename from internal/cmd/gitea/cmd_config.go rename to cmd/gitea/cmd_config.go diff --git a/internal/cmd/gitea/cmd_gitea.go b/cmd/gitea/cmd_gitea.go similarity index 100% rename from internal/cmd/gitea/cmd_gitea.go rename to cmd/gitea/cmd_gitea.go diff --git a/internal/cmd/gitea/cmd_issues.go b/cmd/gitea/cmd_issues.go similarity index 100% rename from internal/cmd/gitea/cmd_issues.go rename to cmd/gitea/cmd_issues.go diff --git a/internal/cmd/gitea/cmd_mirror.go b/cmd/gitea/cmd_mirror.go similarity index 100% rename from internal/cmd/gitea/cmd_mirror.go rename to cmd/gitea/cmd_mirror.go diff --git a/internal/cmd/gitea/cmd_prs.go b/cmd/gitea/cmd_prs.go similarity index 100% rename from internal/cmd/gitea/cmd_prs.go rename to cmd/gitea/cmd_prs.go diff --git a/internal/cmd/gitea/cmd_repos.go b/cmd/gitea/cmd_repos.go similarity index 100% rename from internal/cmd/gitea/cmd_repos.go rename to cmd/gitea/cmd_repos.go diff --git a/internal/cmd/gitea/cmd_sync.go b/cmd/gitea/cmd_sync.go similarity index 100% rename from internal/cmd/gitea/cmd_sync.go rename to cmd/gitea/cmd_sync.go diff --git a/internal/cmd/go/cmd_commands.go b/cmd/go/cmd_commands.go similarity index 100% rename from internal/cmd/go/cmd_commands.go rename to cmd/go/cmd_commands.go diff --git a/internal/cmd/go/cmd_format.go b/cmd/go/cmd_format.go similarity index 100% rename from internal/cmd/go/cmd_format.go rename to cmd/go/cmd_format.go diff --git a/internal/cmd/go/cmd_fuzz.go b/cmd/go/cmd_fuzz.go similarity index 100% rename from internal/cmd/go/cmd_fuzz.go rename to cmd/go/cmd_fuzz.go diff --git a/internal/cmd/go/cmd_go.go b/cmd/go/cmd_go.go similarity index 100% rename from internal/cmd/go/cmd_go.go rename to cmd/go/cmd_go.go diff --git a/internal/cmd/go/cmd_gotest.go b/cmd/go/cmd_gotest.go similarity index 100% rename from internal/cmd/go/cmd_gotest.go rename to cmd/go/cmd_gotest.go diff --git a/internal/cmd/go/cmd_qa.go b/cmd/go/cmd_qa.go similarity index 99% rename from internal/cmd/go/cmd_qa.go rename to cmd/go/cmd_qa.go index ed318651..62d4439b 100644 --- a/internal/cmd/go/cmd_qa.go +++ b/cmd/go/cmd_qa.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "forge.lthn.ai/core/cli/internal/cmd/qa" + "forge.lthn.ai/core/cli/cmd/qa" "forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/go/pkg/i18n" ) diff --git a/internal/cmd/go/cmd_tools.go b/cmd/go/cmd_tools.go similarity index 100% rename from internal/cmd/go/cmd_tools.go rename to cmd/go/cmd_tools.go diff --git a/internal/cmd/go/coverage_test.go b/cmd/go/coverage_test.go similarity index 100% rename from internal/cmd/go/coverage_test.go rename to cmd/go/coverage_test.go diff --git a/internal/cmd/help/cmd.go b/cmd/help/cmd.go similarity index 100% rename from internal/cmd/help/cmd.go rename to cmd/help/cmd.go diff --git a/internal/cmd/lab/cmd_lab.go b/cmd/lab/cmd_lab.go similarity index 100% rename from internal/cmd/lab/cmd_lab.go rename to cmd/lab/cmd_lab.go diff --git a/internal/cmd/mcpcmd/cmd_mcp.go b/cmd/mcpcmd/cmd_mcp.go similarity index 100% rename from internal/cmd/mcpcmd/cmd_mcp.go rename to cmd/mcpcmd/cmd_mcp.go diff --git a/internal/cmd/ml/cmd_agent.go b/cmd/ml/cmd_agent.go similarity index 100% rename from internal/cmd/ml/cmd_agent.go rename to cmd/ml/cmd_agent.go diff --git a/internal/cmd/ml/cmd_approve.go b/cmd/ml/cmd_approve.go similarity index 100% rename from internal/cmd/ml/cmd_approve.go rename to cmd/ml/cmd_approve.go diff --git a/internal/cmd/ml/cmd_consolidate.go b/cmd/ml/cmd_consolidate.go similarity index 100% rename from internal/cmd/ml/cmd_consolidate.go rename to cmd/ml/cmd_consolidate.go diff --git a/internal/cmd/ml/cmd_convert.go b/cmd/ml/cmd_convert.go similarity index 100% rename from internal/cmd/ml/cmd_convert.go rename to cmd/ml/cmd_convert.go diff --git a/internal/cmd/ml/cmd_coverage.go b/cmd/ml/cmd_coverage.go similarity index 100% rename from internal/cmd/ml/cmd_coverage.go rename to cmd/ml/cmd_coverage.go diff --git a/internal/cmd/ml/cmd_expand.go b/cmd/ml/cmd_expand.go similarity index 100% rename from internal/cmd/ml/cmd_expand.go rename to cmd/ml/cmd_expand.go diff --git a/internal/cmd/ml/cmd_export.go b/cmd/ml/cmd_export.go similarity index 100% rename from internal/cmd/ml/cmd_export.go rename to cmd/ml/cmd_export.go diff --git a/internal/cmd/ml/cmd_gguf.go b/cmd/ml/cmd_gguf.go similarity index 100% rename from internal/cmd/ml/cmd_gguf.go rename to cmd/ml/cmd_gguf.go diff --git a/internal/cmd/ml/cmd_import.go b/cmd/ml/cmd_import.go similarity index 100% rename from internal/cmd/ml/cmd_import.go rename to cmd/ml/cmd_import.go diff --git a/internal/cmd/ml/cmd_ingest.go b/cmd/ml/cmd_ingest.go similarity index 100% rename from internal/cmd/ml/cmd_ingest.go rename to cmd/ml/cmd_ingest.go diff --git a/internal/cmd/ml/cmd_inventory.go b/cmd/ml/cmd_inventory.go similarity index 100% rename from internal/cmd/ml/cmd_inventory.go rename to cmd/ml/cmd_inventory.go diff --git a/internal/cmd/ml/cmd_metrics.go b/cmd/ml/cmd_metrics.go similarity index 100% rename from internal/cmd/ml/cmd_metrics.go rename to cmd/ml/cmd_metrics.go diff --git a/internal/cmd/ml/cmd_ml.go b/cmd/ml/cmd_ml.go similarity index 100% rename from internal/cmd/ml/cmd_ml.go rename to cmd/ml/cmd_ml.go diff --git a/internal/cmd/ml/cmd_normalize.go b/cmd/ml/cmd_normalize.go similarity index 100% rename from internal/cmd/ml/cmd_normalize.go rename to cmd/ml/cmd_normalize.go diff --git a/internal/cmd/ml/cmd_probe.go b/cmd/ml/cmd_probe.go similarity index 100% rename from internal/cmd/ml/cmd_probe.go rename to cmd/ml/cmd_probe.go diff --git a/internal/cmd/ml/cmd_publish.go b/cmd/ml/cmd_publish.go similarity index 100% rename from internal/cmd/ml/cmd_publish.go rename to cmd/ml/cmd_publish.go diff --git a/internal/cmd/ml/cmd_query.go b/cmd/ml/cmd_query.go similarity index 100% rename from internal/cmd/ml/cmd_query.go rename to cmd/ml/cmd_query.go diff --git a/internal/cmd/ml/cmd_score.go b/cmd/ml/cmd_score.go similarity index 100% rename from internal/cmd/ml/cmd_score.go rename to cmd/ml/cmd_score.go diff --git a/internal/cmd/ml/cmd_seed_influx.go b/cmd/ml/cmd_seed_influx.go similarity index 100% rename from internal/cmd/ml/cmd_seed_influx.go rename to cmd/ml/cmd_seed_influx.go diff --git a/internal/cmd/ml/cmd_serve.go b/cmd/ml/cmd_serve.go similarity index 100% rename from internal/cmd/ml/cmd_serve.go rename to cmd/ml/cmd_serve.go diff --git a/internal/cmd/ml/cmd_status.go b/cmd/ml/cmd_status.go similarity index 100% rename from internal/cmd/ml/cmd_status.go rename to cmd/ml/cmd_status.go diff --git a/internal/cmd/ml/cmd_worker.go b/cmd/ml/cmd_worker.go similarity index 100% rename from internal/cmd/ml/cmd_worker.go rename to cmd/ml/cmd_worker.go diff --git a/internal/cmd/ml/serve_backend_default.go b/cmd/ml/serve_backend_default.go similarity index 100% rename from internal/cmd/ml/serve_backend_default.go rename to cmd/ml/serve_backend_default.go diff --git a/internal/cmd/ml/serve_backend_mlx.go b/cmd/ml/serve_backend_mlx.go similarity index 100% rename from internal/cmd/ml/serve_backend_mlx.go rename to cmd/ml/serve_backend_mlx.go diff --git a/internal/cmd/monitor/cmd_commands.go b/cmd/monitor/cmd_commands.go similarity index 100% rename from internal/cmd/monitor/cmd_commands.go rename to cmd/monitor/cmd_commands.go diff --git a/internal/cmd/monitor/cmd_monitor.go b/cmd/monitor/cmd_monitor.go similarity index 100% rename from internal/cmd/monitor/cmd_monitor.go rename to cmd/monitor/cmd_monitor.go diff --git a/internal/cmd/pkgcmd/cmd_commands.go b/cmd/pkgcmd/cmd_commands.go similarity index 100% rename from internal/cmd/pkgcmd/cmd_commands.go rename to cmd/pkgcmd/cmd_commands.go diff --git a/internal/cmd/pkgcmd/cmd_install.go b/cmd/pkgcmd/cmd_install.go similarity index 100% rename from internal/cmd/pkgcmd/cmd_install.go rename to cmd/pkgcmd/cmd_install.go diff --git a/internal/cmd/pkgcmd/cmd_manage.go b/cmd/pkgcmd/cmd_manage.go similarity index 100% rename from internal/cmd/pkgcmd/cmd_manage.go rename to cmd/pkgcmd/cmd_manage.go diff --git a/internal/cmd/pkgcmd/cmd_pkg.go b/cmd/pkgcmd/cmd_pkg.go similarity index 100% rename from internal/cmd/pkgcmd/cmd_pkg.go rename to cmd/pkgcmd/cmd_pkg.go diff --git a/internal/cmd/pkgcmd/cmd_remove.go b/cmd/pkgcmd/cmd_remove.go similarity index 100% rename from internal/cmd/pkgcmd/cmd_remove.go rename to cmd/pkgcmd/cmd_remove.go diff --git a/internal/cmd/pkgcmd/cmd_remove_test.go b/cmd/pkgcmd/cmd_remove_test.go similarity index 100% rename from internal/cmd/pkgcmd/cmd_remove_test.go rename to cmd/pkgcmd/cmd_remove_test.go diff --git a/internal/cmd/pkgcmd/cmd_search.go b/cmd/pkgcmd/cmd_search.go similarity index 100% rename from internal/cmd/pkgcmd/cmd_search.go rename to cmd/pkgcmd/cmd_search.go diff --git a/internal/cmd/plugin/cmd.go b/cmd/plugin/cmd.go similarity index 100% rename from internal/cmd/plugin/cmd.go rename to cmd/plugin/cmd.go diff --git a/internal/cmd/plugin/cmd_info.go b/cmd/plugin/cmd_info.go similarity index 100% rename from internal/cmd/plugin/cmd_info.go rename to cmd/plugin/cmd_info.go diff --git a/internal/cmd/plugin/cmd_install.go b/cmd/plugin/cmd_install.go similarity index 100% rename from internal/cmd/plugin/cmd_install.go rename to cmd/plugin/cmd_install.go diff --git a/internal/cmd/plugin/cmd_list.go b/cmd/plugin/cmd_list.go similarity index 100% rename from internal/cmd/plugin/cmd_list.go rename to cmd/plugin/cmd_list.go diff --git a/internal/cmd/plugin/cmd_remove.go b/cmd/plugin/cmd_remove.go similarity index 100% rename from internal/cmd/plugin/cmd_remove.go rename to cmd/plugin/cmd_remove.go diff --git a/internal/cmd/plugin/cmd_update.go b/cmd/plugin/cmd_update.go similarity index 100% rename from internal/cmd/plugin/cmd_update.go rename to cmd/plugin/cmd_update.go diff --git a/internal/cmd/prod/cmd_commands.go b/cmd/prod/cmd_commands.go similarity index 100% rename from internal/cmd/prod/cmd_commands.go rename to cmd/prod/cmd_commands.go diff --git a/internal/cmd/prod/cmd_dns.go b/cmd/prod/cmd_dns.go similarity index 100% rename from internal/cmd/prod/cmd_dns.go rename to cmd/prod/cmd_dns.go diff --git a/internal/cmd/prod/cmd_lb.go b/cmd/prod/cmd_lb.go similarity index 100% rename from internal/cmd/prod/cmd_lb.go rename to cmd/prod/cmd_lb.go diff --git a/internal/cmd/prod/cmd_prod.go b/cmd/prod/cmd_prod.go similarity index 100% rename from internal/cmd/prod/cmd_prod.go rename to cmd/prod/cmd_prod.go diff --git a/internal/cmd/prod/cmd_setup.go b/cmd/prod/cmd_setup.go similarity index 100% rename from internal/cmd/prod/cmd_setup.go rename to cmd/prod/cmd_setup.go diff --git a/internal/cmd/prod/cmd_ssh.go b/cmd/prod/cmd_ssh.go similarity index 100% rename from internal/cmd/prod/cmd_ssh.go rename to cmd/prod/cmd_ssh.go diff --git a/internal/cmd/prod/cmd_status.go b/cmd/prod/cmd_status.go similarity index 100% rename from internal/cmd/prod/cmd_status.go rename to cmd/prod/cmd_status.go diff --git a/internal/cmd/qa/cmd_docblock.go b/cmd/qa/cmd_docblock.go similarity index 100% rename from internal/cmd/qa/cmd_docblock.go rename to cmd/qa/cmd_docblock.go diff --git a/internal/cmd/qa/cmd_health.go b/cmd/qa/cmd_health.go similarity index 100% rename from internal/cmd/qa/cmd_health.go rename to cmd/qa/cmd_health.go diff --git a/internal/cmd/qa/cmd_issues.go b/cmd/qa/cmd_issues.go similarity index 100% rename from internal/cmd/qa/cmd_issues.go rename to cmd/qa/cmd_issues.go diff --git a/internal/cmd/qa/cmd_qa.go b/cmd/qa/cmd_qa.go similarity index 100% rename from internal/cmd/qa/cmd_qa.go rename to cmd/qa/cmd_qa.go diff --git a/internal/cmd/qa/cmd_review.go b/cmd/qa/cmd_review.go similarity index 100% rename from internal/cmd/qa/cmd_review.go rename to cmd/qa/cmd_review.go diff --git a/internal/cmd/qa/cmd_watch.go b/cmd/qa/cmd_watch.go similarity index 100% rename from internal/cmd/qa/cmd_watch.go rename to cmd/qa/cmd_watch.go diff --git a/internal/cmd/rag/cmd_collections.go b/cmd/rag/cmd_collections.go similarity index 100% rename from internal/cmd/rag/cmd_collections.go rename to cmd/rag/cmd_collections.go diff --git a/internal/cmd/rag/cmd_commands.go b/cmd/rag/cmd_commands.go similarity index 100% rename from internal/cmd/rag/cmd_commands.go rename to cmd/rag/cmd_commands.go diff --git a/internal/cmd/rag/cmd_ingest.go b/cmd/rag/cmd_ingest.go similarity index 100% rename from internal/cmd/rag/cmd_ingest.go rename to cmd/rag/cmd_ingest.go diff --git a/internal/cmd/rag/cmd_query.go b/cmd/rag/cmd_query.go similarity index 100% rename from internal/cmd/rag/cmd_query.go rename to cmd/rag/cmd_query.go diff --git a/internal/cmd/rag/cmd_rag.go b/cmd/rag/cmd_rag.go similarity index 100% rename from internal/cmd/rag/cmd_rag.go rename to cmd/rag/cmd_rag.go diff --git a/internal/cmd/security/cmd.go b/cmd/security/cmd.go similarity index 100% rename from internal/cmd/security/cmd.go rename to cmd/security/cmd.go diff --git a/internal/cmd/security/cmd_alerts.go b/cmd/security/cmd_alerts.go similarity index 100% rename from internal/cmd/security/cmd_alerts.go rename to cmd/security/cmd_alerts.go diff --git a/internal/cmd/security/cmd_deps.go b/cmd/security/cmd_deps.go similarity index 100% rename from internal/cmd/security/cmd_deps.go rename to cmd/security/cmd_deps.go diff --git a/internal/cmd/security/cmd_jobs.go b/cmd/security/cmd_jobs.go similarity index 100% rename from internal/cmd/security/cmd_jobs.go rename to cmd/security/cmd_jobs.go diff --git a/internal/cmd/security/cmd_scan.go b/cmd/security/cmd_scan.go similarity index 100% rename from internal/cmd/security/cmd_scan.go rename to cmd/security/cmd_scan.go diff --git a/internal/cmd/security/cmd_secrets.go b/cmd/security/cmd_secrets.go similarity index 100% rename from internal/cmd/security/cmd_secrets.go rename to cmd/security/cmd_secrets.go diff --git a/internal/cmd/security/cmd_security.go b/cmd/security/cmd_security.go similarity index 100% rename from internal/cmd/security/cmd_security.go rename to cmd/security/cmd_security.go diff --git a/internal/cmd/session/cmd_session.go b/cmd/session/cmd_session.go similarity index 100% rename from internal/cmd/session/cmd_session.go rename to cmd/session/cmd_session.go diff --git a/internal/cmd/setup/cmd_bootstrap.go b/cmd/setup/cmd_bootstrap.go similarity index 99% rename from internal/cmd/setup/cmd_bootstrap.go rename to cmd/setup/cmd_bootstrap.go index 81562ad3..3473d6f2 100644 --- a/internal/cmd/setup/cmd_bootstrap.go +++ b/cmd/setup/cmd_bootstrap.go @@ -13,7 +13,7 @@ import ( "path/filepath" "strings" - "forge.lthn.ai/core/cli/internal/cmd/workspace" + "forge.lthn.ai/core/cli/cmd/workspace" "forge.lthn.ai/core/go/pkg/i18n" coreio "forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/repos" diff --git a/internal/cmd/setup/cmd_ci.go b/cmd/setup/cmd_ci.go similarity index 100% rename from internal/cmd/setup/cmd_ci.go rename to cmd/setup/cmd_ci.go diff --git a/internal/cmd/setup/cmd_commands.go b/cmd/setup/cmd_commands.go similarity index 100% rename from internal/cmd/setup/cmd_commands.go rename to cmd/setup/cmd_commands.go diff --git a/internal/cmd/setup/cmd_github.go b/cmd/setup/cmd_github.go similarity index 100% rename from internal/cmd/setup/cmd_github.go rename to cmd/setup/cmd_github.go diff --git a/internal/cmd/setup/cmd_registry.go b/cmd/setup/cmd_registry.go similarity index 99% rename from internal/cmd/setup/cmd_registry.go rename to cmd/setup/cmd_registry.go index c1dd152d..6af20401 100644 --- a/internal/cmd/setup/cmd_registry.go +++ b/cmd/setup/cmd_registry.go @@ -13,7 +13,7 @@ import ( "path/filepath" "strings" - "forge.lthn.ai/core/cli/internal/cmd/workspace" + "forge.lthn.ai/core/cli/cmd/workspace" "forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/go/pkg/i18n" coreio "forge.lthn.ai/core/go/pkg/io" diff --git a/internal/cmd/setup/cmd_repo.go b/cmd/setup/cmd_repo.go similarity index 100% rename from internal/cmd/setup/cmd_repo.go rename to cmd/setup/cmd_repo.go diff --git a/internal/cmd/setup/cmd_setup.go b/cmd/setup/cmd_setup.go similarity index 100% rename from internal/cmd/setup/cmd_setup.go rename to cmd/setup/cmd_setup.go diff --git a/internal/cmd/setup/cmd_wizard.go b/cmd/setup/cmd_wizard.go similarity index 100% rename from internal/cmd/setup/cmd_wizard.go rename to cmd/setup/cmd_wizard.go diff --git a/internal/cmd/setup/github_config.go b/cmd/setup/github_config.go similarity index 100% rename from internal/cmd/setup/github_config.go rename to cmd/setup/github_config.go diff --git a/internal/cmd/setup/github_diff.go b/cmd/setup/github_diff.go similarity index 100% rename from internal/cmd/setup/github_diff.go rename to cmd/setup/github_diff.go diff --git a/internal/cmd/setup/github_labels.go b/cmd/setup/github_labels.go similarity index 100% rename from internal/cmd/setup/github_labels.go rename to cmd/setup/github_labels.go diff --git a/internal/cmd/setup/github_protection.go b/cmd/setup/github_protection.go similarity index 100% rename from internal/cmd/setup/github_protection.go rename to cmd/setup/github_protection.go diff --git a/internal/cmd/setup/github_security.go b/cmd/setup/github_security.go similarity index 100% rename from internal/cmd/setup/github_security.go rename to cmd/setup/github_security.go diff --git a/internal/cmd/setup/github_webhooks.go b/cmd/setup/github_webhooks.go similarity index 100% rename from internal/cmd/setup/github_webhooks.go rename to cmd/setup/github_webhooks.go diff --git a/internal/cmd/test/cmd_commands.go b/cmd/test/cmd_commands.go similarity index 100% rename from internal/cmd/test/cmd_commands.go rename to cmd/test/cmd_commands.go diff --git a/internal/cmd/test/cmd_main.go b/cmd/test/cmd_main.go similarity index 100% rename from internal/cmd/test/cmd_main.go rename to cmd/test/cmd_main.go diff --git a/internal/cmd/test/cmd_output.go b/cmd/test/cmd_output.go similarity index 100% rename from internal/cmd/test/cmd_output.go rename to cmd/test/cmd_output.go diff --git a/internal/cmd/test/cmd_runner.go b/cmd/test/cmd_runner.go similarity index 100% rename from internal/cmd/test/cmd_runner.go rename to cmd/test/cmd_runner.go diff --git a/internal/cmd/test/output_test.go b/cmd/test/output_test.go similarity index 100% rename from internal/cmd/test/output_test.go rename to cmd/test/output_test.go diff --git a/internal/cmd/unifi/cmd_clients.go b/cmd/unifi/cmd_clients.go similarity index 100% rename from internal/cmd/unifi/cmd_clients.go rename to cmd/unifi/cmd_clients.go diff --git a/internal/cmd/unifi/cmd_config.go b/cmd/unifi/cmd_config.go similarity index 100% rename from internal/cmd/unifi/cmd_config.go rename to cmd/unifi/cmd_config.go diff --git a/internal/cmd/unifi/cmd_devices.go b/cmd/unifi/cmd_devices.go similarity index 100% rename from internal/cmd/unifi/cmd_devices.go rename to cmd/unifi/cmd_devices.go diff --git a/internal/cmd/unifi/cmd_networks.go b/cmd/unifi/cmd_networks.go similarity index 100% rename from internal/cmd/unifi/cmd_networks.go rename to cmd/unifi/cmd_networks.go diff --git a/internal/cmd/unifi/cmd_routes.go b/cmd/unifi/cmd_routes.go similarity index 100% rename from internal/cmd/unifi/cmd_routes.go rename to cmd/unifi/cmd_routes.go diff --git a/internal/cmd/unifi/cmd_sites.go b/cmd/unifi/cmd_sites.go similarity index 100% rename from internal/cmd/unifi/cmd_sites.go rename to cmd/unifi/cmd_sites.go diff --git a/internal/cmd/unifi/cmd_unifi.go b/cmd/unifi/cmd_unifi.go similarity index 100% rename from internal/cmd/unifi/cmd_unifi.go rename to cmd/unifi/cmd_unifi.go diff --git a/internal/cmd/updater/.github/workflows/ci.yml b/cmd/updater/.github/workflows/ci.yml similarity index 100% rename from internal/cmd/updater/.github/workflows/ci.yml rename to cmd/updater/.github/workflows/ci.yml diff --git a/internal/cmd/updater/.github/workflows/release.yml b/cmd/updater/.github/workflows/release.yml similarity index 100% rename from internal/cmd/updater/.github/workflows/release.yml rename to cmd/updater/.github/workflows/release.yml diff --git a/internal/cmd/updater/.gitignore b/cmd/updater/.gitignore similarity index 100% rename from internal/cmd/updater/.gitignore rename to cmd/updater/.gitignore diff --git a/internal/cmd/updater/LICENSE b/cmd/updater/LICENSE similarity index 100% rename from internal/cmd/updater/LICENSE rename to cmd/updater/LICENSE diff --git a/internal/cmd/updater/Makefile b/cmd/updater/Makefile similarity index 100% rename from internal/cmd/updater/Makefile rename to cmd/updater/Makefile diff --git a/internal/cmd/updater/README.md b/cmd/updater/README.md similarity index 100% rename from internal/cmd/updater/README.md rename to cmd/updater/README.md diff --git a/internal/cmd/updater/build/main.go b/cmd/updater/build/main.go similarity index 100% rename from internal/cmd/updater/build/main.go rename to cmd/updater/build/main.go diff --git a/internal/cmd/updater/cmd.go b/cmd/updater/cmd.go similarity index 100% rename from internal/cmd/updater/cmd.go rename to cmd/updater/cmd.go diff --git a/internal/cmd/updater/cmd_unix.go b/cmd/updater/cmd_unix.go similarity index 100% rename from internal/cmd/updater/cmd_unix.go rename to cmd/updater/cmd_unix.go diff --git a/internal/cmd/updater/cmd_windows.go b/cmd/updater/cmd_windows.go similarity index 100% rename from internal/cmd/updater/cmd_windows.go rename to cmd/updater/cmd_windows.go diff --git a/internal/cmd/updater/docs/README.md b/cmd/updater/docs/README.md similarity index 100% rename from internal/cmd/updater/docs/README.md rename to cmd/updater/docs/README.md diff --git a/internal/cmd/updater/docs/architecture.md b/cmd/updater/docs/architecture.md similarity index 100% rename from internal/cmd/updater/docs/architecture.md rename to cmd/updater/docs/architecture.md diff --git a/internal/cmd/updater/docs/configuration.md b/cmd/updater/docs/configuration.md similarity index 100% rename from internal/cmd/updater/docs/configuration.md rename to cmd/updater/docs/configuration.md diff --git a/internal/cmd/updater/docs/getting-started.md b/cmd/updater/docs/getting-started.md similarity index 100% rename from internal/cmd/updater/docs/getting-started.md rename to cmd/updater/docs/getting-started.md diff --git a/internal/cmd/updater/generic_http.go b/cmd/updater/generic_http.go similarity index 100% rename from internal/cmd/updater/generic_http.go rename to cmd/updater/generic_http.go diff --git a/internal/cmd/updater/generic_http_test.go b/cmd/updater/generic_http_test.go similarity index 100% rename from internal/cmd/updater/generic_http_test.go rename to cmd/updater/generic_http_test.go diff --git a/internal/cmd/updater/github.go b/cmd/updater/github.go similarity index 100% rename from internal/cmd/updater/github.go rename to cmd/updater/github.go diff --git a/internal/cmd/updater/github_test.go b/cmd/updater/github_test.go similarity index 100% rename from internal/cmd/updater/github_test.go rename to cmd/updater/github_test.go diff --git a/internal/cmd/updater/mock_github_client_test.go b/cmd/updater/mock_github_client_test.go similarity index 100% rename from internal/cmd/updater/mock_github_client_test.go rename to cmd/updater/mock_github_client_test.go diff --git a/internal/cmd/updater/package.json b/cmd/updater/package.json similarity index 100% rename from internal/cmd/updater/package.json rename to cmd/updater/package.json diff --git a/internal/cmd/updater/service.go b/cmd/updater/service.go similarity index 98% rename from internal/cmd/updater/service.go rename to cmd/updater/service.go index bebc5d6e..213f5b2a 100644 --- a/internal/cmd/updater/service.go +++ b/cmd/updater/service.go @@ -1,4 +1,4 @@ -//go:generate go run forge.lthn.ai/core/cli/internal/cmd/updater/build +//go:generate go run forge.lthn.ai/core/cli/cmd/updater/build // Package updater provides functionality for self-updating Go applications. // It supports updates from GitHub releases and generic HTTP endpoints. diff --git a/internal/cmd/updater/service_examples_test.go b/cmd/updater/service_examples_test.go similarity index 95% rename from internal/cmd/updater/service_examples_test.go rename to cmd/updater/service_examples_test.go index 8a07910f..5d6b9366 100644 --- a/internal/cmd/updater/service_examples_test.go +++ b/cmd/updater/service_examples_test.go @@ -4,7 +4,7 @@ import ( "fmt" "log" - "forge.lthn.ai/core/cli/internal/cmd/updater" + "forge.lthn.ai/core/cli/cmd/updater" ) func ExampleNewUpdateService() { diff --git a/internal/cmd/updater/service_test.go b/cmd/updater/service_test.go similarity index 100% rename from internal/cmd/updater/service_test.go rename to cmd/updater/service_test.go diff --git a/internal/cmd/updater/tests.patch b/cmd/updater/tests.patch similarity index 100% rename from internal/cmd/updater/tests.patch rename to cmd/updater/tests.patch diff --git a/internal/cmd/updater/ui/.editorconfig b/cmd/updater/ui/.editorconfig similarity index 100% rename from internal/cmd/updater/ui/.editorconfig rename to cmd/updater/ui/.editorconfig diff --git a/internal/cmd/updater/ui/.gitignore b/cmd/updater/ui/.gitignore similarity index 100% rename from internal/cmd/updater/ui/.gitignore rename to cmd/updater/ui/.gitignore diff --git a/internal/cmd/updater/ui/.vscode/extensions.json b/cmd/updater/ui/.vscode/extensions.json similarity index 100% rename from internal/cmd/updater/ui/.vscode/extensions.json rename to cmd/updater/ui/.vscode/extensions.json diff --git a/internal/cmd/updater/ui/.vscode/launch.json b/cmd/updater/ui/.vscode/launch.json similarity index 100% rename from internal/cmd/updater/ui/.vscode/launch.json rename to cmd/updater/ui/.vscode/launch.json diff --git a/internal/cmd/updater/ui/.vscode/tasks.json b/cmd/updater/ui/.vscode/tasks.json similarity index 100% rename from internal/cmd/updater/ui/.vscode/tasks.json rename to cmd/updater/ui/.vscode/tasks.json diff --git a/internal/cmd/updater/ui/README.md b/cmd/updater/ui/README.md similarity index 100% rename from internal/cmd/updater/ui/README.md rename to cmd/updater/ui/README.md diff --git a/internal/cmd/updater/ui/angular.json b/cmd/updater/ui/angular.json similarity index 100% rename from internal/cmd/updater/ui/angular.json rename to cmd/updater/ui/angular.json diff --git a/internal/cmd/updater/ui/package-lock.json b/cmd/updater/ui/package-lock.json similarity index 100% rename from internal/cmd/updater/ui/package-lock.json rename to cmd/updater/ui/package-lock.json diff --git a/internal/cmd/updater/ui/package.json b/cmd/updater/ui/package.json similarity index 100% rename from internal/cmd/updater/ui/package.json rename to cmd/updater/ui/package.json diff --git a/internal/cmd/updater/ui/public/favicon.ico b/cmd/updater/ui/public/favicon.ico similarity index 100% rename from internal/cmd/updater/ui/public/favicon.ico rename to cmd/updater/ui/public/favicon.ico diff --git a/internal/cmd/updater/ui/src/app/app-module.ts b/cmd/updater/ui/src/app/app-module.ts similarity index 100% rename from internal/cmd/updater/ui/src/app/app-module.ts rename to cmd/updater/ui/src/app/app-module.ts diff --git a/internal/cmd/updater/ui/src/app/app.html b/cmd/updater/ui/src/app/app.html similarity index 100% rename from internal/cmd/updater/ui/src/app/app.html rename to cmd/updater/ui/src/app/app.html diff --git a/internal/cmd/updater/ui/src/app/app.ts b/cmd/updater/ui/src/app/app.ts similarity index 100% rename from internal/cmd/updater/ui/src/app/app.ts rename to cmd/updater/ui/src/app/app.ts diff --git a/internal/cmd/updater/ui/src/index.html b/cmd/updater/ui/src/index.html similarity index 100% rename from internal/cmd/updater/ui/src/index.html rename to cmd/updater/ui/src/index.html diff --git a/internal/cmd/updater/ui/src/main.ts b/cmd/updater/ui/src/main.ts similarity index 100% rename from internal/cmd/updater/ui/src/main.ts rename to cmd/updater/ui/src/main.ts diff --git a/internal/cmd/updater/ui/src/styles.css b/cmd/updater/ui/src/styles.css similarity index 100% rename from internal/cmd/updater/ui/src/styles.css rename to cmd/updater/ui/src/styles.css diff --git a/internal/cmd/updater/ui/tsconfig.app.json b/cmd/updater/ui/tsconfig.app.json similarity index 100% rename from internal/cmd/updater/ui/tsconfig.app.json rename to cmd/updater/ui/tsconfig.app.json diff --git a/internal/cmd/updater/ui/tsconfig.json b/cmd/updater/ui/tsconfig.json similarity index 100% rename from internal/cmd/updater/ui/tsconfig.json rename to cmd/updater/ui/tsconfig.json diff --git a/internal/cmd/updater/ui/tsconfig.spec.json b/cmd/updater/ui/tsconfig.spec.json similarity index 100% rename from internal/cmd/updater/ui/tsconfig.spec.json rename to cmd/updater/ui/tsconfig.spec.json diff --git a/internal/cmd/updater/updater.go b/cmd/updater/updater.go similarity index 100% rename from internal/cmd/updater/updater.go rename to cmd/updater/updater.go diff --git a/internal/cmd/updater/updater_test.go b/cmd/updater/updater_test.go similarity index 100% rename from internal/cmd/updater/updater_test.go rename to cmd/updater/updater_test.go diff --git a/internal/cmd/updater/version.go b/cmd/updater/version.go similarity index 100% rename from internal/cmd/updater/version.go rename to cmd/updater/version.go diff --git a/internal/cmd/vm/cmd_commands.go b/cmd/vm/cmd_commands.go similarity index 100% rename from internal/cmd/vm/cmd_commands.go rename to cmd/vm/cmd_commands.go diff --git a/internal/cmd/vm/cmd_container.go b/cmd/vm/cmd_container.go similarity index 100% rename from internal/cmd/vm/cmd_container.go rename to cmd/vm/cmd_container.go diff --git a/internal/cmd/vm/cmd_templates.go b/cmd/vm/cmd_templates.go similarity index 100% rename from internal/cmd/vm/cmd_templates.go rename to cmd/vm/cmd_templates.go diff --git a/internal/cmd/vm/cmd_vm.go b/cmd/vm/cmd_vm.go similarity index 100% rename from internal/cmd/vm/cmd_vm.go rename to cmd/vm/cmd_vm.go diff --git a/internal/cmd/workspace/cmd.go b/cmd/workspace/cmd.go similarity index 100% rename from internal/cmd/workspace/cmd.go rename to cmd/workspace/cmd.go diff --git a/internal/cmd/workspace/cmd_agent.go b/cmd/workspace/cmd_agent.go similarity index 100% rename from internal/cmd/workspace/cmd_agent.go rename to cmd/workspace/cmd_agent.go diff --git a/internal/cmd/workspace/cmd_agent_test.go b/cmd/workspace/cmd_agent_test.go similarity index 100% rename from internal/cmd/workspace/cmd_agent_test.go rename to cmd/workspace/cmd_agent_test.go diff --git a/internal/cmd/workspace/cmd_task.go b/cmd/workspace/cmd_task.go similarity index 100% rename from internal/cmd/workspace/cmd_task.go rename to cmd/workspace/cmd_task.go diff --git a/internal/cmd/workspace/cmd_task_test.go b/cmd/workspace/cmd_task_test.go similarity index 100% rename from internal/cmd/workspace/cmd_task_test.go rename to cmd/workspace/cmd_task_test.go diff --git a/internal/cmd/workspace/cmd_workspace.go b/cmd/workspace/cmd_workspace.go similarity index 100% rename from internal/cmd/workspace/cmd_workspace.go rename to cmd/workspace/cmd_workspace.go diff --git a/internal/cmd/workspace/config.go b/cmd/workspace/config.go similarity index 100% rename from internal/cmd/workspace/config.go rename to cmd/workspace/config.go diff --git a/internal/cmd/ci/cmd_changelog.go b/internal/cmd/ci/cmd_changelog.go deleted file mode 100644 index 8f91f955..00000000 --- a/internal/cmd/ci/cmd_changelog.go +++ /dev/null @@ -1,57 +0,0 @@ -package ci - -import ( - "os" - "os/exec" - "strings" - - "forge.lthn.ai/core/go/pkg/cli" - "forge.lthn.ai/core/go/pkg/i18n" - "forge.lthn.ai/core/go/pkg/release" -) - -func runChangelog(fromRef, toRef string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - // Auto-detect refs if not provided - if fromRef == "" || toRef == "" { - tag, err := latestTag(cwd) - if err == nil { - if fromRef == "" { - fromRef = tag - } - if toRef == "" { - toRef = "HEAD" - } - } else { - // No tags, use initial commit? Or just HEAD? - cli.Text(i18n.T("cmd.ci.changelog.no_tags")) - return nil - } - } - - cli.Print("%s %s..%s\n\n", releaseDimStyle.Render(i18n.T("cmd.ci.changelog.generating")), fromRef, toRef) - - // Generate changelog - changelog, err := release.Generate(cwd, fromRef, toRef) - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.generate", "changelog"), err) - } - - cli.Text(changelog) - - return nil -} - -func latestTag(dir string) (string, error) { - cmd := exec.Command("git", "describe", "--tags", "--abbrev=0") - cmd.Dir = dir - out, err := cmd.Output() - if err != nil { - return "", err - } - return strings.TrimSpace(string(out)), nil -} diff --git a/internal/cmd/ci/cmd_ci.go b/internal/cmd/ci/cmd_ci.go deleted file mode 100644 index 0190416c..00000000 --- a/internal/cmd/ci/cmd_ci.go +++ /dev/null @@ -1,84 +0,0 @@ -// Package ci provides release publishing commands. -package ci - -import ( - "forge.lthn.ai/core/go/pkg/cli" - "forge.lthn.ai/core/go/pkg/i18n" -) - -// Style aliases from shared -var ( - releaseHeaderStyle = cli.RepoStyle - releaseSuccessStyle = cli.SuccessStyle - releaseErrorStyle = cli.ErrorStyle - releaseDimStyle = cli.DimStyle - releaseValueStyle = cli.ValueStyle -) - -// Flag variables for ci command -var ( - ciGoForLaunch bool - ciVersion string - ciDraft bool - ciPrerelease bool -) - -// Flag variables for changelog subcommand -var ( - changelogFromRef string - changelogToRef string -) - -var ciCmd = &cli.Command{ - Use: "ci", - Short: i18n.T("cmd.ci.short"), - Long: i18n.T("cmd.ci.long"), - RunE: func(cmd *cli.Command, args []string) error { - dryRun := !ciGoForLaunch - return runCIPublish(dryRun, ciVersion, ciDraft, ciPrerelease) - }, -} - -var ciInitCmd = &cli.Command{ - Use: "init", - Short: i18n.T("cmd.ci.init.short"), - Long: i18n.T("cmd.ci.init.long"), - RunE: func(cmd *cli.Command, args []string) error { - return runCIReleaseInit() - }, -} - -var ciChangelogCmd = &cli.Command{ - Use: "changelog", - Short: i18n.T("cmd.ci.changelog.short"), - Long: i18n.T("cmd.ci.changelog.long"), - RunE: func(cmd *cli.Command, args []string) error { - return runChangelog(changelogFromRef, changelogToRef) - }, -} - -var ciVersionCmd = &cli.Command{ - Use: "version", - Short: i18n.T("cmd.ci.version.short"), - Long: i18n.T("cmd.ci.version.long"), - RunE: func(cmd *cli.Command, args []string) error { - return runCIReleaseVersion() - }, -} - -func init() { - // Main ci command flags - ciCmd.Flags().BoolVar(&ciGoForLaunch, "we-are-go-for-launch", false, i18n.T("cmd.ci.flag.go_for_launch")) - ciCmd.Flags().StringVar(&ciVersion, "version", "", i18n.T("cmd.ci.flag.version")) - ciCmd.Flags().BoolVar(&ciDraft, "draft", false, i18n.T("cmd.ci.flag.draft")) - ciCmd.Flags().BoolVar(&ciPrerelease, "prerelease", false, i18n.T("cmd.ci.flag.prerelease")) - - // Changelog subcommand flags - ciChangelogCmd.Flags().StringVar(&changelogFromRef, "from", "", i18n.T("cmd.ci.changelog.flag.from")) - ciChangelogCmd.Flags().StringVar(&changelogToRef, "to", "", i18n.T("cmd.ci.changelog.flag.to")) - - // Add subcommands - ciCmd.AddCommand(ciInitCmd) - ciCmd.AddCommand(ciChangelogCmd) - ciCmd.AddCommand(ciVersionCmd) -} diff --git a/internal/cmd/ci/cmd_commands.go b/internal/cmd/ci/cmd_commands.go deleted file mode 100644 index d1ff882a..00000000 --- a/internal/cmd/ci/cmd_commands.go +++ /dev/null @@ -1,23 +0,0 @@ -// Package ci provides release publishing commands for CI/CD pipelines. -// -// Publishes pre-built artifacts from dist/ to configured targets: -// - GitHub Releases -// - S3-compatible storage -// - Custom endpoints -// -// Safe by default: runs in dry-run mode unless --we-are-go-for-launch is specified. -// Configuration via .core/release.yaml. -package ci - -import ( - "forge.lthn.ai/core/go/pkg/cli" -) - -func init() { - cli.RegisterCommands(AddCICommands) -} - -// AddCICommands registers the 'ci' command and all subcommands. -func AddCICommands(root *cli.Command) { - root.AddCommand(ciCmd) -} diff --git a/internal/cmd/ci/cmd_init.go b/internal/cmd/ci/cmd_init.go deleted file mode 100644 index 0548ad0d..00000000 --- a/internal/cmd/ci/cmd_init.go +++ /dev/null @@ -1,43 +0,0 @@ -package ci - -import ( - "os" - - "forge.lthn.ai/core/go/pkg/cli" - "forge.lthn.ai/core/go/pkg/i18n" - "forge.lthn.ai/core/go/pkg/release" -) - -func runCIReleaseInit() error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - cli.Print("%s %s\n\n", releaseDimStyle.Render(i18n.Label("init")), i18n.T("cmd.ci.init.initializing")) - - // Check if already initialized - if release.ConfigExists(cwd) { - cli.Text(i18n.T("cmd.ci.init.already_initialized")) - return nil - } - - // Create release config - cfg := release.DefaultConfig() - if err := release.WriteConfig(cfg, cwd); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.create", "config"), err) - } - - cli.Blank() - cli.Print("%s %s\n", releaseSuccessStyle.Render("v"), i18n.T("cmd.ci.init.created_config")) - - // Templates init removed as functionality not exposed - - cli.Blank() - - cli.Text(i18n.T("cmd.ci.init.next_steps")) - cli.Print(" %s\n", i18n.T("cmd.ci.init.edit_config")) - cli.Print(" %s\n", i18n.T("cmd.ci.init.run_ci")) - - return nil -} diff --git a/internal/cmd/ci/cmd_publish.go b/internal/cmd/ci/cmd_publish.go deleted file mode 100644 index aff35fff..00000000 --- a/internal/cmd/ci/cmd_publish.go +++ /dev/null @@ -1,81 +0,0 @@ -package ci - -import ( - "context" - "errors" - "os" - - "forge.lthn.ai/core/go/pkg/cli" - "forge.lthn.ai/core/go/pkg/i18n" - "forge.lthn.ai/core/go/pkg/release" -) - -// runCIPublish publishes pre-built artifacts from dist/. -// It does NOT build - use `core build` first. -func runCIPublish(dryRun bool, version string, draft, prerelease bool) error { - ctx := context.Background() - - // Get current directory - projectDir, err := os.Getwd() - if err != nil { - return cli.WrapVerb(err, "get", "working directory") - } - - // Load configuration - cfg, err := release.LoadConfig(projectDir) - if err != nil { - return cli.WrapVerb(err, "load", "config") - } - - // Apply CLI overrides - if version != "" { - cfg.SetVersion(version) - } - - // Apply draft/prerelease overrides to all publishers - if draft || prerelease { - for i := range cfg.Publishers { - if draft { - cfg.Publishers[i].Draft = true - } - if prerelease { - cfg.Publishers[i].Prerelease = true - } - } - } - - // Print header - cli.Print("%s %s\n", releaseHeaderStyle.Render(i18n.T("cmd.ci.label.ci")), i18n.T("cmd.ci.publishing")) - if dryRun { - cli.Print(" %s\n", releaseDimStyle.Render(i18n.T("cmd.ci.dry_run_hint"))) - } else { - cli.Print(" %s\n", releaseSuccessStyle.Render(i18n.T("cmd.ci.go_for_launch"))) - } - cli.Blank() - - // Check for publishers - if len(cfg.Publishers) == 0 { - return errors.New(i18n.T("cmd.ci.error.no_publishers")) - } - - // Publish pre-built artifacts - rel, err := release.Publish(ctx, cfg, dryRun) - if err != nil { - cli.Print("%s %v\n", releaseErrorStyle.Render(i18n.Label("error")), err) - return err - } - - // Print summary - cli.Blank() - cli.Print("%s %s\n", releaseSuccessStyle.Render(i18n.T("i18n.done.pass")), i18n.T("cmd.ci.publish_completed")) - cli.Print(" %s %s\n", i18n.Label("version"), releaseValueStyle.Render(rel.Version)) - cli.Print(" %s %d\n", i18n.T("cmd.ci.label.artifacts"), len(rel.Artifacts)) - - if !dryRun { - for _, pub := range cfg.Publishers { - cli.Print(" %s %s\n", i18n.T("cmd.ci.label.published"), releaseValueStyle.Render(pub.Type)) - } - } - - return nil -} diff --git a/internal/cmd/ci/cmd_version.go b/internal/cmd/ci/cmd_version.go deleted file mode 100644 index 5afb237d..00000000 --- a/internal/cmd/ci/cmd_version.go +++ /dev/null @@ -1,25 +0,0 @@ -package ci - -import ( - "os" - - "forge.lthn.ai/core/go/pkg/cli" - "forge.lthn.ai/core/go/pkg/i18n" - "forge.lthn.ai/core/go/pkg/release" -) - -// runCIReleaseVersion shows the determined version. -func runCIReleaseVersion() error { - projectDir, err := os.Getwd() - if err != nil { - return cli.WrapVerb(err, "get", "working directory") - } - - version, err := release.DetermineVersion(projectDir) - if err != nil { - return cli.WrapVerb(err, "determine", "version") - } - - cli.Print("%s %s\n", i18n.Label("version"), releaseValueStyle.Render(version)) - return nil -} diff --git a/internal/cmd/php/cmd.go b/internal/cmd/php/cmd.go deleted file mode 100644 index 810414c8..00000000 --- a/internal/cmd/php/cmd.go +++ /dev/null @@ -1,158 +0,0 @@ -package php - -import ( - "os" - "path/filepath" - - "forge.lthn.ai/core/cli/internal/cmd/workspace" - "forge.lthn.ai/core/go/pkg/cli" - "forge.lthn.ai/core/go/pkg/i18n" - "forge.lthn.ai/core/go/pkg/io" - "github.com/spf13/cobra" -) - -// DefaultMedium is the default filesystem medium used by the php package. -// It defaults to io.Local (unsandboxed filesystem access). -// Use SetMedium to change this for testing or sandboxed operation. -var DefaultMedium io.Medium = io.Local - -// SetMedium sets the default medium for filesystem operations. -// This is primarily useful for testing with mock mediums. -func SetMedium(m io.Medium) { - DefaultMedium = m -} - -// getMedium returns the default medium for filesystem operations. -func getMedium() io.Medium { - return DefaultMedium -} - -func init() { - cli.RegisterCommands(AddPHPCommands) -} - -// Style aliases from shared -var ( - successStyle = cli.SuccessStyle - errorStyle = cli.ErrorStyle - dimStyle = cli.DimStyle - linkStyle = cli.LinkStyle -) - -// Service colors for log output (domain-specific, keep local) -var ( - phpFrankenPHPStyle = cli.NewStyle().Foreground(cli.ColourIndigo500) - phpViteStyle = cli.NewStyle().Foreground(cli.ColourYellow500) - phpHorizonStyle = cli.NewStyle().Foreground(cli.ColourOrange500) - phpReverbStyle = cli.NewStyle().Foreground(cli.ColourViolet500) - phpRedisStyle = cli.NewStyle().Foreground(cli.ColourRed500) -) - -// Status styles (from shared) -var ( - phpStatusRunning = cli.SuccessStyle - phpStatusStopped = cli.DimStyle - phpStatusError = cli.ErrorStyle -) - -// QA command styles (from shared) -var ( - phpQAPassedStyle = cli.SuccessStyle - phpQAFailedStyle = cli.ErrorStyle - phpQAWarningStyle = cli.WarningStyle - phpQAStageStyle = cli.HeaderStyle -) - -// Security severity styles (from shared) -var ( - phpSecurityCriticalStyle = cli.NewStyle().Bold().Foreground(cli.ColourRed500) - phpSecurityHighStyle = cli.NewStyle().Bold().Foreground(cli.ColourOrange500) - phpSecurityMediumStyle = cli.NewStyle().Foreground(cli.ColourAmber500) - phpSecurityLowStyle = cli.NewStyle().Foreground(cli.ColourGray500) -) - -// AddPHPCommands adds PHP/Laravel development commands. -func AddPHPCommands(root *cobra.Command) { - phpCmd := &cobra.Command{ - Use: "php", - Short: i18n.T("cmd.php.short"), - Long: i18n.T("cmd.php.long"), - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - // Check if we are in a workspace root - wsRoot, err := workspace.FindWorkspaceRoot() - if err != nil { - return nil // Not in a workspace, regular behavior - } - - // Load workspace config - config, err := workspace.LoadConfig(wsRoot) - if err != nil || config == nil { - return nil // Failed to load or no config, ignore - } - - if config.Active == "" { - return nil // No active package - } - - // Calculate package path - pkgDir := config.PackagesDir - if pkgDir == "" { - pkgDir = "./packages" - } - if !filepath.IsAbs(pkgDir) { - pkgDir = filepath.Join(wsRoot, pkgDir) - } - - targetDir := filepath.Join(pkgDir, config.Active) - - // Check if target directory exists - if !getMedium().IsDir(targetDir) { - cli.Warnf("Active package directory not found: %s", targetDir) - return nil - } - - // Change working directory - if err := os.Chdir(targetDir); err != nil { - return cli.Err("failed to change directory to active package: %w", err) - } - - cli.Print("%s %s\n", dimStyle.Render("Workspace:"), config.Active) - return nil - }, - } - root.AddCommand(phpCmd) - - // Development - addPHPDevCommand(phpCmd) - addPHPLogsCommand(phpCmd) - addPHPStopCommand(phpCmd) - addPHPStatusCommand(phpCmd) - addPHPSSLCommand(phpCmd) - - // Build & Deploy - addPHPBuildCommand(phpCmd) - addPHPServeCommand(phpCmd) - addPHPShellCommand(phpCmd) - - // Quality (existing) - addPHPTestCommand(phpCmd) - addPHPFmtCommand(phpCmd) - addPHPStanCommand(phpCmd) - - // Quality (new) - addPHPPsalmCommand(phpCmd) - addPHPAuditCommand(phpCmd) - addPHPSecurityCommand(phpCmd) - addPHPQACommand(phpCmd) - addPHPRectorCommand(phpCmd) - addPHPInfectionCommand(phpCmd) - - // CI/CD Integration - addPHPCICommand(phpCmd) - - // Package Management - addPHPPackagesCommands(phpCmd) - - // Deployment - addPHPDeployCommands(phpCmd) -} diff --git a/internal/cmd/php/cmd_build.go b/internal/cmd/php/cmd_build.go deleted file mode 100644 index b8b75836..00000000 --- a/internal/cmd/php/cmd_build.go +++ /dev/null @@ -1,291 +0,0 @@ -package php - -import ( - "context" - "errors" - "os" - "strings" - - "forge.lthn.ai/core/go/pkg/cli" - "forge.lthn.ai/core/go/pkg/i18n" - "github.com/spf13/cobra" -) - -var ( - buildType string - buildImageName string - buildTag string - buildPlatform string - buildDockerfile string - buildOutputPath string - buildFormat string - buildTemplate string - buildNoCache bool -) - -func addPHPBuildCommand(parent *cobra.Command) { - buildCmd := &cobra.Command{ - Use: "build", - Short: i18n.T("cmd.php.build.short"), - Long: i18n.T("cmd.php.build.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - ctx := context.Background() - - switch strings.ToLower(buildType) { - case "linuxkit": - return runPHPBuildLinuxKit(ctx, cwd, linuxKitBuildOptions{ - OutputPath: buildOutputPath, - Format: buildFormat, - Template: buildTemplate, - }) - default: - return runPHPBuildDocker(ctx, cwd, dockerBuildOptions{ - ImageName: buildImageName, - Tag: buildTag, - Platform: buildPlatform, - Dockerfile: buildDockerfile, - NoCache: buildNoCache, - }) - } - }, - } - - buildCmd.Flags().StringVar(&buildType, "type", "", i18n.T("cmd.php.build.flag.type")) - buildCmd.Flags().StringVar(&buildImageName, "name", "", i18n.T("cmd.php.build.flag.name")) - buildCmd.Flags().StringVar(&buildTag, "tag", "", i18n.T("common.flag.tag")) - buildCmd.Flags().StringVar(&buildPlatform, "platform", "", i18n.T("cmd.php.build.flag.platform")) - buildCmd.Flags().StringVar(&buildDockerfile, "dockerfile", "", i18n.T("cmd.php.build.flag.dockerfile")) - buildCmd.Flags().StringVar(&buildOutputPath, "output", "", i18n.T("cmd.php.build.flag.output")) - buildCmd.Flags().StringVar(&buildFormat, "format", "", i18n.T("cmd.php.build.flag.format")) - buildCmd.Flags().StringVar(&buildTemplate, "template", "", i18n.T("cmd.php.build.flag.template")) - buildCmd.Flags().BoolVar(&buildNoCache, "no-cache", false, i18n.T("cmd.php.build.flag.no_cache")) - - parent.AddCommand(buildCmd) -} - -type dockerBuildOptions struct { - ImageName string - Tag string - Platform string - Dockerfile string - NoCache bool -} - -type linuxKitBuildOptions struct { - OutputPath string - Format string - Template string -} - -func runPHPBuildDocker(ctx context.Context, projectDir string, opts dockerBuildOptions) error { - if !IsPHPProject(projectDir) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.build.building_docker")) - - // Show detected configuration - config, err := DetectDockerfileConfig(projectDir) - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.detect", "project configuration"), err) - } - - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.build.php_version")), config.PHPVersion) - cli.Print("%s %v\n", dimStyle.Render(i18n.T("cmd.php.build.laravel")), config.IsLaravel) - cli.Print("%s %v\n", dimStyle.Render(i18n.T("cmd.php.build.octane")), config.HasOctane) - cli.Print("%s %v\n", dimStyle.Render(i18n.T("cmd.php.build.frontend")), config.HasAssets) - if len(config.PHPExtensions) > 0 { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.build.extensions")), strings.Join(config.PHPExtensions, ", ")) - } - cli.Blank() - - // Build options - buildOpts := DockerBuildOptions{ - ProjectDir: projectDir, - ImageName: opts.ImageName, - Tag: opts.Tag, - Platform: opts.Platform, - Dockerfile: opts.Dockerfile, - NoBuildCache: opts.NoCache, - Output: os.Stdout, - } - - if buildOpts.ImageName == "" { - buildOpts.ImageName = GetLaravelAppName(projectDir) - if buildOpts.ImageName == "" { - buildOpts.ImageName = "php-app" - } - // Sanitize for Docker - buildOpts.ImageName = strings.ToLower(strings.ReplaceAll(buildOpts.ImageName, " ", "-")) - } - - if buildOpts.Tag == "" { - buildOpts.Tag = "latest" - } - - cli.Print("%s %s:%s\n", dimStyle.Render(i18n.Label("image")), buildOpts.ImageName, buildOpts.Tag) - if opts.Platform != "" { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.build.platform")), opts.Platform) - } - cli.Blank() - - if err := BuildDocker(ctx, buildOpts); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.build"), err) - } - - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Docker image built"})) - cli.Print("%s docker run -p 80:80 -p 443:443 %s:%s\n", - dimStyle.Render(i18n.T("cmd.php.build.docker_run_with")), - buildOpts.ImageName, buildOpts.Tag) - - return nil -} - -func runPHPBuildLinuxKit(ctx context.Context, projectDir string, opts linuxKitBuildOptions) error { - if !IsPHPProject(projectDir) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.build.building_linuxkit")) - - buildOpts := LinuxKitBuildOptions{ - ProjectDir: projectDir, - OutputPath: opts.OutputPath, - Format: opts.Format, - Template: opts.Template, - Output: os.Stdout, - } - - if buildOpts.Format == "" { - buildOpts.Format = "qcow2" - } - if buildOpts.Template == "" { - buildOpts.Template = "server-php" - } - - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("template")), buildOpts.Template) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.build.format")), buildOpts.Format) - cli.Blank() - - if err := BuildLinuxKit(ctx, buildOpts); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.build"), err) - } - - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "LinuxKit image built"})) - return nil -} - -var ( - serveImageName string - serveTag string - serveContainerName string - servePort int - serveHTTPSPort int - serveDetach bool - serveEnvFile string -) - -func addPHPServeCommand(parent *cobra.Command) { - serveCmd := &cobra.Command{ - Use: "serve", - Short: i18n.T("cmd.php.serve.short"), - Long: i18n.T("cmd.php.serve.long"), - RunE: func(cmd *cobra.Command, args []string) error { - imageName := serveImageName - if imageName == "" { - // Try to detect from current directory - cwd, err := os.Getwd() - if err == nil { - imageName = GetLaravelAppName(cwd) - if imageName != "" { - imageName = strings.ToLower(strings.ReplaceAll(imageName, " ", "-")) - } - } - if imageName == "" { - return errors.New(i18n.T("cmd.php.serve.name_required")) - } - } - - ctx := context.Background() - - opts := ServeOptions{ - ImageName: imageName, - Tag: serveTag, - ContainerName: serveContainerName, - Port: servePort, - HTTPSPort: serveHTTPSPort, - Detach: serveDetach, - EnvFile: serveEnvFile, - Output: os.Stdout, - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.ProgressSubject("run", "production container")) - cli.Print("%s %s:%s\n", dimStyle.Render(i18n.Label("image")), imageName, func() string { - if serveTag == "" { - return "latest" - } - return serveTag - }()) - - effectivePort := servePort - if effectivePort == 0 { - effectivePort = 80 - } - effectiveHTTPSPort := serveHTTPSPort - if effectiveHTTPSPort == 0 { - effectiveHTTPSPort = 443 - } - - cli.Print("%s http://localhost:%d, https://localhost:%d\n", - dimStyle.Render("Ports:"), effectivePort, effectiveHTTPSPort) - cli.Blank() - - if err := ServeProduction(ctx, opts); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.start", "container"), err) - } - - if !serveDetach { - cli.Print("\n%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.serve.stopped")) - } - - return nil - }, - } - - serveCmd.Flags().StringVar(&serveImageName, "name", "", i18n.T("cmd.php.serve.flag.name")) - serveCmd.Flags().StringVar(&serveTag, "tag", "", i18n.T("common.flag.tag")) - serveCmd.Flags().StringVar(&serveContainerName, "container", "", i18n.T("cmd.php.serve.flag.container")) - serveCmd.Flags().IntVar(&servePort, "port", 0, i18n.T("cmd.php.serve.flag.port")) - serveCmd.Flags().IntVar(&serveHTTPSPort, "https-port", 0, i18n.T("cmd.php.serve.flag.https_port")) - serveCmd.Flags().BoolVarP(&serveDetach, "detach", "d", false, i18n.T("cmd.php.serve.flag.detach")) - serveCmd.Flags().StringVar(&serveEnvFile, "env-file", "", i18n.T("cmd.php.serve.flag.env_file")) - - parent.AddCommand(serveCmd) -} - -func addPHPShellCommand(parent *cobra.Command) { - shellCmd := &cobra.Command{ - Use: "shell [container]", - Short: i18n.T("cmd.php.shell.short"), - Long: i18n.T("cmd.php.shell.long"), - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := context.Background() - - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.shell.opening", map[string]interface{}{"Container": args[0]})) - - if err := Shell(ctx, args[0]); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.open", "shell"), err) - } - - return nil - }, - } - - parent.AddCommand(shellCmd) -} diff --git a/internal/cmd/php/cmd_ci.go b/internal/cmd/php/cmd_ci.go deleted file mode 100644 index 1c4344f3..00000000 --- a/internal/cmd/php/cmd_ci.go +++ /dev/null @@ -1,562 +0,0 @@ -// cmd_ci.go implements the 'php ci' command for CI/CD pipeline integration. -// -// Usage: -// core php ci # Run full CI pipeline -// core php ci --json # Output combined JSON report -// core php ci --summary # Output markdown summary -// core php ci --sarif # Generate SARIF files -// core php ci --upload-sarif # Upload SARIF to GitHub Security -// core php ci --fail-on=high # Only fail on high+ severity - -package php - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - "forge.lthn.ai/core/go/pkg/cli" - "forge.lthn.ai/core/go/pkg/i18n" - "github.com/spf13/cobra" -) - -// CI command flags -var ( - ciJSON bool - ciSummary bool - ciSARIF bool - ciUploadSARIF bool - ciFailOn string -) - -// CIResult represents the overall CI pipeline result -type CIResult struct { - Passed bool `json:"passed"` - ExitCode int `json:"exit_code"` - Duration string `json:"duration"` - StartedAt time.Time `json:"started_at"` - Checks []CICheckResult `json:"checks"` - Summary CISummary `json:"summary"` - Artifacts []string `json:"artifacts,omitempty"` -} - -// CICheckResult represents an individual check result -type CICheckResult struct { - Name string `json:"name"` - Status string `json:"status"` // passed, failed, warning, skipped - Duration string `json:"duration"` - Details string `json:"details,omitempty"` - Issues int `json:"issues,omitempty"` - Errors int `json:"errors,omitempty"` - Warnings int `json:"warnings,omitempty"` -} - -// CISummary contains aggregate statistics -type CISummary struct { - Total int `json:"total"` - Passed int `json:"passed"` - Failed int `json:"failed"` - Warnings int `json:"warnings"` - Skipped int `json:"skipped"` -} - -func addPHPCICommand(parent *cobra.Command) { - ciCmd := &cobra.Command{ - Use: "ci", - Short: i18n.T("cmd.php.ci.short"), - Long: i18n.T("cmd.php.ci.long"), - RunE: func(cmd *cobra.Command, args []string) error { - return runPHPCI() - }, - } - - ciCmd.Flags().BoolVar(&ciJSON, "json", false, i18n.T("cmd.php.ci.flag.json")) - ciCmd.Flags().BoolVar(&ciSummary, "summary", false, i18n.T("cmd.php.ci.flag.summary")) - ciCmd.Flags().BoolVar(&ciSARIF, "sarif", false, i18n.T("cmd.php.ci.flag.sarif")) - ciCmd.Flags().BoolVar(&ciUploadSARIF, "upload-sarif", false, i18n.T("cmd.php.ci.flag.upload_sarif")) - ciCmd.Flags().StringVar(&ciFailOn, "fail-on", "error", i18n.T("cmd.php.ci.flag.fail_on")) - - parent.AddCommand(ciCmd) -} - -func runPHPCI() error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - startTime := time.Now() - ctx := context.Background() - - // Define checks to run in order - checks := []struct { - name string - run func(context.Context, string) (CICheckResult, error) - sarif bool // Whether this check can generate SARIF - }{ - {"test", runCITest, false}, - {"stan", runCIStan, true}, - {"psalm", runCIPsalm, true}, - {"fmt", runCIFmt, false}, - {"audit", runCIAudit, false}, - {"security", runCISecurity, false}, - } - - result := CIResult{ - StartedAt: startTime, - Passed: true, - Checks: make([]CICheckResult, 0, len(checks)), - } - - var artifacts []string - - // Print header unless JSON output - if !ciJSON { - cli.Print("\n%s\n", cli.BoldStyle.Render("core php ci - QA Pipeline")) - cli.Print("%s\n\n", strings.Repeat("─", 40)) - } - - // Run each check - for _, check := range checks { - if !ciJSON { - cli.Print(" %s %s...", dimStyle.Render("→"), check.name) - } - - checkResult, err := check.run(ctx, cwd) - if err != nil { - checkResult = CICheckResult{ - Name: check.name, - Status: "failed", - Details: err.Error(), - } - } - - result.Checks = append(result.Checks, checkResult) - - // Update summary - result.Summary.Total++ - switch checkResult.Status { - case "passed": - result.Summary.Passed++ - case "failed": - result.Summary.Failed++ - if shouldFailOn(checkResult, ciFailOn) { - result.Passed = false - } - case "warning": - result.Summary.Warnings++ - case "skipped": - result.Summary.Skipped++ - } - - // Print result - if !ciJSON { - cli.Print("\r %s %s %s\n", getStatusIcon(checkResult.Status), check.name, dimStyle.Render(checkResult.Details)) - } - - // Generate SARIF if requested - if (ciSARIF || ciUploadSARIF) && check.sarif { - sarifFile := filepath.Join(cwd, check.name+".sarif") - if generateSARIF(ctx, cwd, check.name, sarifFile) == nil { - artifacts = append(artifacts, sarifFile) - } - } - } - - result.Duration = time.Since(startTime).Round(time.Millisecond).String() - result.Artifacts = artifacts - - // Set exit code - if result.Passed { - result.ExitCode = 0 - } else { - result.ExitCode = 1 - } - - // Output based on flags - if ciJSON { - if err := outputCIJSON(result); err != nil { - return err - } - if !result.Passed { - return cli.Exit(result.ExitCode, cli.Err("CI pipeline failed")) - } - return nil - } - - if ciSummary { - if err := outputCISummary(result); err != nil { - return err - } - if !result.Passed { - return cli.Err("CI pipeline failed") - } - return nil - } - - // Default table output - cli.Print("\n%s\n", strings.Repeat("─", 40)) - - if result.Passed { - cli.Print("%s %s\n", successStyle.Render("✓ CI PASSED"), dimStyle.Render(result.Duration)) - } else { - cli.Print("%s %s\n", errorStyle.Render("✗ CI FAILED"), dimStyle.Render(result.Duration)) - } - - if len(artifacts) > 0 { - cli.Print("\n%s\n", dimStyle.Render("Artifacts:")) - for _, a := range artifacts { - cli.Print(" → %s\n", filepath.Base(a)) - } - } - - // Upload SARIF if requested - if ciUploadSARIF && len(artifacts) > 0 { - cli.Blank() - for _, sarifFile := range artifacts { - if err := uploadSARIFToGitHub(ctx, sarifFile); err != nil { - cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), filepath.Base(sarifFile), err) - } else { - cli.Print(" %s %s uploaded\n", successStyle.Render("✓"), filepath.Base(sarifFile)) - } - } - } - - if !result.Passed { - return cli.Err("CI pipeline failed") - } - return nil -} - -// runCITest runs Pest/PHPUnit tests -func runCITest(ctx context.Context, dir string) (CICheckResult, error) { - start := time.Now() - result := CICheckResult{Name: "test", Status: "passed"} - - opts := TestOptions{ - Dir: dir, - Output: nil, // Suppress output - } - - if err := RunTests(ctx, opts); err != nil { - result.Status = "failed" - result.Details = err.Error() - } else { - result.Details = "all tests passed" - } - - result.Duration = time.Since(start).Round(time.Millisecond).String() - return result, nil -} - -// runCIStan runs PHPStan -func runCIStan(ctx context.Context, dir string) (CICheckResult, error) { - start := time.Now() - result := CICheckResult{Name: "stan", Status: "passed"} - - _, found := DetectAnalyser(dir) - if !found { - result.Status = "skipped" - result.Details = "PHPStan not configured" - return result, nil - } - - opts := AnalyseOptions{ - Dir: dir, - Output: nil, - } - - if err := Analyse(ctx, opts); err != nil { - result.Status = "failed" - result.Details = "errors found" - } else { - result.Details = "0 errors" - } - - result.Duration = time.Since(start).Round(time.Millisecond).String() - return result, nil -} - -// runCIPsalm runs Psalm -func runCIPsalm(ctx context.Context, dir string) (CICheckResult, error) { - start := time.Now() - result := CICheckResult{Name: "psalm", Status: "passed"} - - _, found := DetectPsalm(dir) - if !found { - result.Status = "skipped" - result.Details = "Psalm not configured" - return result, nil - } - - opts := PsalmOptions{ - Dir: dir, - Output: nil, - } - - if err := RunPsalm(ctx, opts); err != nil { - result.Status = "failed" - result.Details = "errors found" - } else { - result.Details = "0 errors" - } - - result.Duration = time.Since(start).Round(time.Millisecond).String() - return result, nil -} - -// runCIFmt checks code formatting -func runCIFmt(ctx context.Context, dir string) (CICheckResult, error) { - start := time.Now() - result := CICheckResult{Name: "fmt", Status: "passed"} - - _, found := DetectFormatter(dir) - if !found { - result.Status = "skipped" - result.Details = "no formatter configured" - return result, nil - } - - opts := FormatOptions{ - Dir: dir, - Fix: false, // Check only - Output: nil, - } - - if err := Format(ctx, opts); err != nil { - result.Status = "warning" - result.Details = "formatting issues" - } else { - result.Details = "code style OK" - } - - result.Duration = time.Since(start).Round(time.Millisecond).String() - return result, nil -} - -// runCIAudit runs composer audit -func runCIAudit(ctx context.Context, dir string) (CICheckResult, error) { - start := time.Now() - result := CICheckResult{Name: "audit", Status: "passed"} - - results, err := RunAudit(ctx, AuditOptions{ - Dir: dir, - Output: nil, - }) - if err != nil { - result.Status = "failed" - result.Details = err.Error() - result.Duration = time.Since(start).Round(time.Millisecond).String() - return result, nil - } - - totalVulns := 0 - for _, r := range results { - totalVulns += r.Vulnerabilities - } - - if totalVulns > 0 { - result.Status = "failed" - result.Details = fmt.Sprintf("%d vulnerabilities", totalVulns) - result.Issues = totalVulns - } else { - result.Details = "no vulnerabilities" - } - - result.Duration = time.Since(start).Round(time.Millisecond).String() - return result, nil -} - -// runCISecurity runs security checks -func runCISecurity(ctx context.Context, dir string) (CICheckResult, error) { - start := time.Now() - result := CICheckResult{Name: "security", Status: "passed"} - - secResult, err := RunSecurityChecks(ctx, SecurityOptions{ - Dir: dir, - Output: nil, - }) - if err != nil { - result.Status = "failed" - result.Details = err.Error() - result.Duration = time.Since(start).Round(time.Millisecond).String() - return result, nil - } - - if secResult.Summary.Critical > 0 || secResult.Summary.High > 0 { - result.Status = "failed" - result.Details = fmt.Sprintf("%d critical, %d high", secResult.Summary.Critical, secResult.Summary.High) - result.Issues = secResult.Summary.Critical + secResult.Summary.High - } else if secResult.Summary.Medium > 0 { - result.Status = "warning" - result.Details = fmt.Sprintf("%d medium issues", secResult.Summary.Medium) - result.Warnings = secResult.Summary.Medium - } else { - result.Details = "no issues" - } - - result.Duration = time.Since(start).Round(time.Millisecond).String() - return result, nil -} - -// shouldFailOn determines if a check should cause CI failure based on --fail-on -func shouldFailOn(check CICheckResult, level string) bool { - switch level { - case "critical": - return check.Status == "failed" && check.Issues > 0 - case "high", "error": - return check.Status == "failed" - case "warning": - return check.Status == "failed" || check.Status == "warning" - default: - return check.Status == "failed" - } -} - -// getStatusIcon returns the icon for a check status -func getStatusIcon(status string) string { - switch status { - case "passed": - return successStyle.Render("✓") - case "failed": - return errorStyle.Render("✗") - case "warning": - return phpQAWarningStyle.Render("⚠") - case "skipped": - return dimStyle.Render("-") - default: - return dimStyle.Render("?") - } -} - -// outputCIJSON outputs the result as JSON -func outputCIJSON(result CIResult) error { - data, err := json.MarshalIndent(result, "", " ") - if err != nil { - return err - } - fmt.Println(string(data)) - return nil -} - -// outputCISummary outputs a markdown summary -func outputCISummary(result CIResult) error { - var sb strings.Builder - - sb.WriteString("## CI Pipeline Results\n\n") - - if result.Passed { - sb.WriteString("**Status:** ✅ Passed\n\n") - } else { - sb.WriteString("**Status:** ❌ Failed\n\n") - } - - sb.WriteString("| Check | Status | Details |\n") - sb.WriteString("|-------|--------|----------|\n") - - for _, check := range result.Checks { - icon := "✅" - switch check.Status { - case "failed": - icon = "❌" - case "warning": - icon = "⚠️" - case "skipped": - icon = "⏭️" - } - sb.WriteString(fmt.Sprintf("| %s | %s | %s |\n", check.Name, icon, check.Details)) - } - - sb.WriteString(fmt.Sprintf("\n**Duration:** %s\n", result.Duration)) - - fmt.Print(sb.String()) - return nil -} - -// generateSARIF generates a SARIF file for a specific check -func generateSARIF(ctx context.Context, dir, checkName, outputFile string) error { - var args []string - - switch checkName { - case "stan": - args = []string{"vendor/bin/phpstan", "analyse", "--error-format=sarif", "--no-progress"} - case "psalm": - args = []string{"vendor/bin/psalm", "--output-format=sarif"} - default: - return fmt.Errorf("SARIF not supported for %s", checkName) - } - - cmd := exec.CommandContext(ctx, "php", args...) - cmd.Dir = dir - - // Capture output - command may exit non-zero when issues are found - // but still produce valid SARIF output - output, err := cmd.CombinedOutput() - if len(output) == 0 { - if err != nil { - return fmt.Errorf("failed to generate SARIF: %w", err) - } - return fmt.Errorf("no SARIF output generated") - } - - // Validate output is valid JSON - var js json.RawMessage - if err := json.Unmarshal(output, &js); err != nil { - return fmt.Errorf("invalid SARIF output: %w", err) - } - - return getMedium().Write(outputFile, string(output)) -} - -// uploadSARIFToGitHub uploads a SARIF file to GitHub Security tab -func uploadSARIFToGitHub(ctx context.Context, sarifFile string) error { - // Validate commit SHA before calling API - sha := getGitSHA() - if sha == "" { - return errors.New("cannot upload SARIF: git commit SHA not available (ensure you're in a git repository)") - } - - // Use gh CLI to upload - cmd := exec.CommandContext(ctx, "gh", "api", - "repos/{owner}/{repo}/code-scanning/sarifs", - "-X", "POST", - "-F", "sarif=@"+sarifFile, - "-F", "ref="+getGitRef(), - "-F", "commit_sha="+sha, - ) - - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("%s: %s", err, string(output)) - } - return nil -} - -// getGitRef returns the current git ref -func getGitRef() string { - cmd := exec.Command("git", "symbolic-ref", "HEAD") - output, err := cmd.Output() - if err != nil { - return "refs/heads/main" - } - return strings.TrimSpace(string(output)) -} - -// getGitSHA returns the current git commit SHA -func getGitSHA() string { - cmd := exec.Command("git", "rev-parse", "HEAD") - output, err := cmd.Output() - if err != nil { - return "" - } - return strings.TrimSpace(string(output)) -} diff --git a/internal/cmd/php/cmd_commands.go b/internal/cmd/php/cmd_commands.go deleted file mode 100644 index c0a2444e..00000000 --- a/internal/cmd/php/cmd_commands.go +++ /dev/null @@ -1,41 +0,0 @@ -// Package php provides Laravel/PHP development and deployment commands. -// -// Development Commands: -// - dev: Start Laravel environment (FrankenPHP, Vite, Horizon, Reverb, Redis) -// - logs: Stream unified service logs -// - stop: Stop all running services -// - status: Show service status -// - ssl: Setup SSL certificates with mkcert -// -// Build Commands: -// - build: Build Docker or LinuxKit image -// - serve: Run production container -// - shell: Open shell in running container -// -// Code Quality: -// - test: Run PHPUnit/Pest tests -// - fmt: Format code with Laravel Pint -// - stan: Run PHPStan/Larastan static analysis -// - psalm: Run Psalm static analysis -// - audit: Security audit for dependencies -// - security: Security vulnerability scanning -// - qa: Run full QA pipeline -// - rector: Automated code refactoring -// - infection: Mutation testing for test quality -// -// Package Management: -// - packages link/unlink/update/list: Manage local Composer packages -// -// Deployment (Coolify): -// - deploy: Deploy to Coolify -// - deploy:status: Check deployment status -// - deploy:rollback: Rollback deployment -// - deploy:list: List recent deployments -package php - -import "github.com/spf13/cobra" - -// AddCommands registers the 'php' command and all subcommands. -func AddCommands(root *cobra.Command) { - AddPHPCommands(root) -} diff --git a/internal/cmd/php/cmd_deploy.go b/internal/cmd/php/cmd_deploy.go deleted file mode 100644 index 2298a43b..00000000 --- a/internal/cmd/php/cmd_deploy.go +++ /dev/null @@ -1,361 +0,0 @@ -package php - -import ( - "context" - "os" - "time" - - "forge.lthn.ai/core/go/pkg/cli" - "forge.lthn.ai/core/go/pkg/i18n" - "github.com/spf13/cobra" -) - -// Deploy command styles (aliases to shared) -var ( - phpDeployStyle = cli.SuccessStyle - phpDeployPendingStyle = cli.WarningStyle - phpDeployFailedStyle = cli.ErrorStyle -) - -func addPHPDeployCommands(parent *cobra.Command) { - // Main deploy command - addPHPDeployCommand(parent) - - // Deploy status subcommand (using colon notation: deploy:status) - addPHPDeployStatusCommand(parent) - - // Deploy rollback subcommand - addPHPDeployRollbackCommand(parent) - - // Deploy list subcommand - addPHPDeployListCommand(parent) -} - -var ( - deployStaging bool - deployForce bool - deployWait bool -) - -func addPHPDeployCommand(parent *cobra.Command) { - deployCmd := &cobra.Command{ - Use: "deploy", - Short: i18n.T("cmd.php.deploy.short"), - Long: i18n.T("cmd.php.deploy.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - env := EnvProduction - if deployStaging { - env = EnvStaging - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.deploy")), i18n.T("cmd.php.deploy.deploying", map[string]interface{}{"Environment": env})) - - ctx := context.Background() - - opts := DeployOptions{ - Dir: cwd, - Environment: env, - Force: deployForce, - Wait: deployWait, - } - - status, err := Deploy(ctx, opts) - if err != nil { - return cli.Err("%s: %w", i18n.T("cmd.php.error.deploy_failed"), err) - } - - printDeploymentStatus(status) - - if deployWait { - if IsDeploymentSuccessful(status.Status) { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Deployment completed"})) - } else { - cli.Print("\n%s %s\n", errorStyle.Render(i18n.Label("warning")), i18n.T("cmd.php.deploy.warning_status", map[string]interface{}{"Status": status.Status})) - } - } else { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.deploy.triggered")) - } - - return nil - }, - } - - deployCmd.Flags().BoolVar(&deployStaging, "staging", false, i18n.T("cmd.php.deploy.flag.staging")) - deployCmd.Flags().BoolVar(&deployForce, "force", false, i18n.T("cmd.php.deploy.flag.force")) - deployCmd.Flags().BoolVar(&deployWait, "wait", false, i18n.T("cmd.php.deploy.flag.wait")) - - parent.AddCommand(deployCmd) -} - -var ( - deployStatusStaging bool - deployStatusDeploymentID string -) - -func addPHPDeployStatusCommand(parent *cobra.Command) { - statusCmd := &cobra.Command{ - Use: "deploy:status", - Short: i18n.T("cmd.php.deploy_status.short"), - Long: i18n.T("cmd.php.deploy_status.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - env := EnvProduction - if deployStatusStaging { - env = EnvStaging - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.deploy")), i18n.ProgressSubject("check", "deployment status")) - - ctx := context.Background() - - opts := StatusOptions{ - Dir: cwd, - Environment: env, - DeploymentID: deployStatusDeploymentID, - } - - status, err := DeployStatus(ctx, opts) - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "status"), err) - } - - printDeploymentStatus(status) - - return nil - }, - } - - statusCmd.Flags().BoolVar(&deployStatusStaging, "staging", false, i18n.T("cmd.php.deploy_status.flag.staging")) - statusCmd.Flags().StringVar(&deployStatusDeploymentID, "id", "", i18n.T("cmd.php.deploy_status.flag.id")) - - parent.AddCommand(statusCmd) -} - -var ( - rollbackStaging bool - rollbackDeploymentID string - rollbackWait bool -) - -func addPHPDeployRollbackCommand(parent *cobra.Command) { - rollbackCmd := &cobra.Command{ - Use: "deploy:rollback", - Short: i18n.T("cmd.php.deploy_rollback.short"), - Long: i18n.T("cmd.php.deploy_rollback.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - env := EnvProduction - if rollbackStaging { - env = EnvStaging - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.deploy")), i18n.T("cmd.php.deploy_rollback.rolling_back", map[string]interface{}{"Environment": env})) - - ctx := context.Background() - - opts := RollbackOptions{ - Dir: cwd, - Environment: env, - DeploymentID: rollbackDeploymentID, - Wait: rollbackWait, - } - - status, err := Rollback(ctx, opts) - if err != nil { - return cli.Err("%s: %w", i18n.T("cmd.php.error.rollback_failed"), err) - } - - printDeploymentStatus(status) - - if rollbackWait { - if IsDeploymentSuccessful(status.Status) { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Rollback completed"})) - } else { - cli.Print("\n%s %s\n", errorStyle.Render(i18n.Label("warning")), i18n.T("cmd.php.deploy_rollback.warning_status", map[string]interface{}{"Status": status.Status})) - } - } else { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.deploy_rollback.triggered")) - } - - return nil - }, - } - - rollbackCmd.Flags().BoolVar(&rollbackStaging, "staging", false, i18n.T("cmd.php.deploy_rollback.flag.staging")) - rollbackCmd.Flags().StringVar(&rollbackDeploymentID, "id", "", i18n.T("cmd.php.deploy_rollback.flag.id")) - rollbackCmd.Flags().BoolVar(&rollbackWait, "wait", false, i18n.T("cmd.php.deploy_rollback.flag.wait")) - - parent.AddCommand(rollbackCmd) -} - -var ( - deployListStaging bool - deployListLimit int -) - -func addPHPDeployListCommand(parent *cobra.Command) { - listCmd := &cobra.Command{ - Use: "deploy:list", - Short: i18n.T("cmd.php.deploy_list.short"), - Long: i18n.T("cmd.php.deploy_list.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - env := EnvProduction - if deployListStaging { - env = EnvStaging - } - - limit := deployListLimit - if limit == 0 { - limit = 10 - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.deploy")), i18n.T("cmd.php.deploy_list.recent", map[string]interface{}{"Environment": env})) - - ctx := context.Background() - - deployments, err := ListDeployments(ctx, cwd, env, limit) - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.list", "deployments"), err) - } - - if len(deployments) == 0 { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.info")), i18n.T("cmd.php.deploy_list.none_found")) - return nil - } - - for i, d := range deployments { - printDeploymentSummary(i+1, &d) - } - - return nil - }, - } - - listCmd.Flags().BoolVar(&deployListStaging, "staging", false, i18n.T("cmd.php.deploy_list.flag.staging")) - listCmd.Flags().IntVar(&deployListLimit, "limit", 0, i18n.T("cmd.php.deploy_list.flag.limit")) - - parent.AddCommand(listCmd) -} - -func printDeploymentStatus(status *DeploymentStatus) { - // Status with color - statusStyle := phpDeployStyle - switch status.Status { - case "queued", "building", "deploying", "pending", "rolling_back": - statusStyle = phpDeployPendingStyle - case "failed", "error", "cancelled": - statusStyle = phpDeployFailedStyle - } - - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("status")), statusStyle.Render(status.Status)) - - if status.ID != "" { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.id")), status.ID) - } - - if status.URL != "" { - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("url")), linkStyle.Render(status.URL)) - } - - if status.Branch != "" { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.branch")), status.Branch) - } - - if status.Commit != "" { - commit := status.Commit - if len(commit) > 7 { - commit = commit[:7] - } - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.commit")), commit) - if status.CommitMessage != "" { - // Truncate long messages - msg := status.CommitMessage - if len(msg) > 60 { - msg = msg[:57] + "..." - } - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.message")), msg) - } - } - - if !status.StartedAt.IsZero() { - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("started")), status.StartedAt.Format(time.RFC3339)) - } - - if !status.CompletedAt.IsZero() { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.completed")), status.CompletedAt.Format(time.RFC3339)) - if !status.StartedAt.IsZero() { - duration := status.CompletedAt.Sub(status.StartedAt) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.duration")), duration.Round(time.Second)) - } - } -} - -func printDeploymentSummary(index int, status *DeploymentStatus) { - // Status with color - statusStyle := phpDeployStyle - switch status.Status { - case "queued", "building", "deploying", "pending", "rolling_back": - statusStyle = phpDeployPendingStyle - case "failed", "error", "cancelled": - statusStyle = phpDeployFailedStyle - } - - // Format: #1 [finished] abc1234 - commit message (2 hours ago) - id := status.ID - if len(id) > 8 { - id = id[:8] - } - - commit := status.Commit - if len(commit) > 7 { - commit = commit[:7] - } - - msg := status.CommitMessage - if len(msg) > 40 { - msg = msg[:37] + "..." - } - - age := "" - if !status.StartedAt.IsZero() { - age = i18n.TimeAgo(status.StartedAt) - } - - cli.Print(" %s %s %s", - dimStyle.Render(cli.Sprintf("#%d", index)), - statusStyle.Render(cli.Sprintf("[%s]", status.Status)), - id, - ) - - if commit != "" { - cli.Print(" %s", commit) - } - - if msg != "" { - cli.Print(" - %s", msg) - } - - if age != "" { - cli.Print(" %s", dimStyle.Render(cli.Sprintf("(%s)", age))) - } - - cli.Blank() -} diff --git a/internal/cmd/php/cmd_dev.go b/internal/cmd/php/cmd_dev.go deleted file mode 100644 index d2d8de04..00000000 --- a/internal/cmd/php/cmd_dev.go +++ /dev/null @@ -1,497 +0,0 @@ -package php - -import ( - "bufio" - "context" - "errors" - "os" - "os/signal" - "strings" - "syscall" - "time" - - "forge.lthn.ai/core/go/pkg/cli" - "forge.lthn.ai/core/go/pkg/i18n" - "github.com/spf13/cobra" -) - -var ( - devNoVite bool - devNoHorizon bool - devNoReverb bool - devNoRedis bool - devHTTPS bool - devDomain string - devPort int -) - -func addPHPDevCommand(parent *cobra.Command) { - devCmd := &cobra.Command{ - Use: "dev", - Short: i18n.T("cmd.php.dev.short"), - Long: i18n.T("cmd.php.dev.long"), - RunE: func(cmd *cobra.Command, args []string) error { - return runPHPDev(phpDevOptions{ - NoVite: devNoVite, - NoHorizon: devNoHorizon, - NoReverb: devNoReverb, - NoRedis: devNoRedis, - HTTPS: devHTTPS, - Domain: devDomain, - Port: devPort, - }) - }, - } - - devCmd.Flags().BoolVar(&devNoVite, "no-vite", false, i18n.T("cmd.php.dev.flag.no_vite")) - devCmd.Flags().BoolVar(&devNoHorizon, "no-horizon", false, i18n.T("cmd.php.dev.flag.no_horizon")) - devCmd.Flags().BoolVar(&devNoReverb, "no-reverb", false, i18n.T("cmd.php.dev.flag.no_reverb")) - devCmd.Flags().BoolVar(&devNoRedis, "no-redis", false, i18n.T("cmd.php.dev.flag.no_redis")) - devCmd.Flags().BoolVar(&devHTTPS, "https", false, i18n.T("cmd.php.dev.flag.https")) - devCmd.Flags().StringVar(&devDomain, "domain", "", i18n.T("cmd.php.dev.flag.domain")) - devCmd.Flags().IntVar(&devPort, "port", 0, i18n.T("cmd.php.dev.flag.port")) - - parent.AddCommand(devCmd) -} - -type phpDevOptions struct { - NoVite bool - NoHorizon bool - NoReverb bool - NoRedis bool - HTTPS bool - Domain string - Port int -} - -func runPHPDev(opts phpDevOptions) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("failed to get working directory: %w", err) - } - - // Check if this is a Laravel project - if !IsLaravelProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_laravel")) - } - - // Get app name for display - appName := GetLaravelAppName(cwd) - if appName == "" { - appName = "Laravel" - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.dev.starting", map[string]interface{}{"AppName": appName})) - - // Detect services - services := DetectServices(cwd) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.services")), i18n.T("cmd.php.dev.detected_services")) - for _, svc := range services { - cli.Print(" %s %s\n", successStyle.Render("*"), svc) - } - cli.Blank() - - // Setup options - port := opts.Port - if port == 0 { - port = 8000 - } - - devOpts := Options{ - Dir: cwd, - NoVite: opts.NoVite, - NoHorizon: opts.NoHorizon, - NoReverb: opts.NoReverb, - NoRedis: opts.NoRedis, - HTTPS: opts.HTTPS, - Domain: opts.Domain, - FrankenPHPPort: port, - } - - // Create and start dev server - server := NewDevServer(devOpts) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Handle shutdown signals - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - - go func() { - <-sigCh - cli.Print("\n%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.dev.shutting_down")) - cancel() - }() - - if err := server.Start(ctx, devOpts); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.start", "services"), err) - } - - // Print status - cli.Print("%s %s\n", successStyle.Render(i18n.T("cmd.php.label.running")), i18n.T("cmd.php.dev.services_started")) - printServiceStatuses(server.Status()) - cli.Blank() - - // Print URLs - appURL := GetLaravelAppURL(cwd) - if appURL == "" { - if opts.HTTPS { - appURL = cli.Sprintf("https://localhost:%d", port) - } else { - appURL = cli.Sprintf("http://localhost:%d", port) - } - } - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.app_url")), linkStyle.Render(appURL)) - - // Check for Vite - if !opts.NoVite && containsService(services, ServiceVite) { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.vite")), linkStyle.Render("http://localhost:5173")) - } - - cli.Print("\n%s\n\n", dimStyle.Render(i18n.T("cmd.php.dev.press_ctrl_c"))) - - // Stream unified logs - logsReader, err := server.Logs("", true) - if err != nil { - cli.Print("%s %s\n", errorStyle.Render(i18n.Label("warning")), i18n.T("i18n.fail.get", "logs")) - } else { - defer func() { _ = logsReader.Close() }() - - scanner := bufio.NewScanner(logsReader) - for scanner.Scan() { - select { - case <-ctx.Done(): - goto shutdown - default: - line := scanner.Text() - printColoredLog(line) - } - } - } - -shutdown: - // Stop services - if err := server.Stop(); err != nil { - cli.Print("%s %s\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.dev.stop_error", map[string]interface{}{"Error": err})) - } - - cli.Print("%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.dev.all_stopped")) - return nil -} - -var ( - logsFollow bool - logsService string -) - -func addPHPLogsCommand(parent *cobra.Command) { - logsCmd := &cobra.Command{ - Use: "logs", - Short: i18n.T("cmd.php.logs.short"), - Long: i18n.T("cmd.php.logs.long"), - RunE: func(cmd *cobra.Command, args []string) error { - return runPHPLogs(logsService, logsFollow) - }, - } - - logsCmd.Flags().BoolVar(&logsFollow, "follow", false, i18n.T("common.flag.follow")) - logsCmd.Flags().StringVar(&logsService, "service", "", i18n.T("cmd.php.logs.flag.service")) - - parent.AddCommand(logsCmd) -} - -func runPHPLogs(service string, follow bool) error { - cwd, err := os.Getwd() - if err != nil { - return err - } - - if !IsLaravelProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_laravel_short")) - } - - // Create a minimal server just to access logs - server := NewDevServer(Options{Dir: cwd}) - - logsReader, err := server.Logs(service, follow) - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "logs"), err) - } - defer func() { _ = logsReader.Close() }() - - // Handle interrupt - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - - go func() { - <-sigCh - cancel() - }() - - scanner := bufio.NewScanner(logsReader) - for scanner.Scan() { - select { - case <-ctx.Done(): - return nil - default: - printColoredLog(scanner.Text()) - } - } - - return scanner.Err() -} - -func addPHPStopCommand(parent *cobra.Command) { - stopCmd := &cobra.Command{ - Use: "stop", - Short: i18n.T("cmd.php.stop.short"), - RunE: func(cmd *cobra.Command, args []string) error { - return runPHPStop() - }, - } - - parent.AddCommand(stopCmd) -} - -func runPHPStop() error { - cwd, err := os.Getwd() - if err != nil { - return err - } - - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.stop.stopping")) - - // We need to find running processes - // This is a simplified version - in practice you'd want to track PIDs - server := NewDevServer(Options{Dir: cwd}) - if err := server.Stop(); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.stop", "services"), err) - } - - cli.Print("%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.dev.all_stopped")) - return nil -} - -func addPHPStatusCommand(parent *cobra.Command) { - statusCmd := &cobra.Command{ - Use: "status", - Short: i18n.T("cmd.php.status.short"), - RunE: func(cmd *cobra.Command, args []string) error { - return runPHPStatus() - }, - } - - parent.AddCommand(statusCmd) -} - -func runPHPStatus() error { - cwd, err := os.Getwd() - if err != nil { - return err - } - - if !IsLaravelProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_laravel_short")) - } - - appName := GetLaravelAppName(cwd) - if appName == "" { - appName = "Laravel" - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.Label("project")), appName) - - // Detect available services - services := DetectServices(cwd) - cli.Print("%s\n", dimStyle.Render(i18n.T("cmd.php.status.detected_services"))) - for _, svc := range services { - style := getServiceStyle(string(svc)) - cli.Print(" %s %s\n", style.Render("*"), svc) - } - cli.Blank() - - // Package manager - pm := DetectPackageManager(cwd) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.status.package_manager")), pm) - - // FrankenPHP status - if IsFrankenPHPProject(cwd) { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.status.octane_server")), "FrankenPHP") - } - - // SSL status - appURL := GetLaravelAppURL(cwd) - if appURL != "" { - domain := ExtractDomainFromURL(appURL) - if CertsExist(domain, SSLOptions{}) { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.status.ssl_certs")), successStyle.Render(i18n.T("cmd.php.status.ssl_installed"))) - } else { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.status.ssl_certs")), dimStyle.Render(i18n.T("cmd.php.status.ssl_not_setup"))) - } - } - - return nil -} - -var sslDomain string - -func addPHPSSLCommand(parent *cobra.Command) { - sslCmd := &cobra.Command{ - Use: "ssl", - Short: i18n.T("cmd.php.ssl.short"), - RunE: func(cmd *cobra.Command, args []string) error { - return runPHPSSL(sslDomain) - }, - } - - sslCmd.Flags().StringVar(&sslDomain, "domain", "", i18n.T("cmd.php.ssl.flag.domain")) - - parent.AddCommand(sslCmd) -} - -func runPHPSSL(domain string) error { - cwd, err := os.Getwd() - if err != nil { - return err - } - - // Get domain from APP_URL if not specified - if domain == "" { - appURL := GetLaravelAppURL(cwd) - if appURL != "" { - domain = ExtractDomainFromURL(appURL) - } - } - if domain == "" { - domain = "localhost" - } - - // Check if mkcert is installed - if !IsMkcertInstalled() { - cli.Print("%s %s\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.ssl.mkcert_not_installed")) - cli.Print("\n%s\n", i18n.T("common.hint.install_with")) - cli.Print(" %s\n", i18n.T("cmd.php.ssl.install_macos")) - cli.Print(" %s\n", i18n.T("cmd.php.ssl.install_linux")) - return errors.New(i18n.T("cmd.php.error.mkcert_not_installed")) - } - - cli.Print("%s %s\n", dimStyle.Render("SSL:"), i18n.T("cmd.php.ssl.setting_up", map[string]interface{}{"Domain": domain})) - - // Check if certs already exist - if CertsExist(domain, SSLOptions{}) { - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("skip")), i18n.T("cmd.php.ssl.certs_exist")) - - certFile, keyFile, _ := CertPaths(domain, SSLOptions{}) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.ssl.cert_label")), certFile) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.ssl.key_label")), keyFile) - return nil - } - - // Setup SSL - if err := SetupSSL(domain, SSLOptions{}); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.setup", "SSL"), err) - } - - certFile, keyFile, _ := CertPaths(domain, SSLOptions{}) - - cli.Print("%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.ssl.certs_created")) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.ssl.cert_label")), certFile) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.ssl.key_label")), keyFile) - - return nil -} - -// Helper functions for dev commands - -func printServiceStatuses(statuses []ServiceStatus) { - for _, s := range statuses { - style := getServiceStyle(s.Name) - var statusText string - - if s.Error != nil { - statusText = phpStatusError.Render(i18n.T("cmd.php.status.error", map[string]interface{}{"Error": s.Error})) - } else if s.Running { - statusText = phpStatusRunning.Render(i18n.T("cmd.php.status.running")) - if s.Port > 0 { - statusText += dimStyle.Render(cli.Sprintf(" (%s)", i18n.T("cmd.php.status.port", map[string]interface{}{"Port": s.Port}))) - } - if s.PID > 0 { - statusText += dimStyle.Render(cli.Sprintf(" [%s]", i18n.T("cmd.php.status.pid", map[string]interface{}{"PID": s.PID}))) - } - } else { - statusText = phpStatusStopped.Render(i18n.T("cmd.php.status.stopped")) - } - - cli.Print(" %s %s\n", style.Render(s.Name+":"), statusText) - } -} - -func printColoredLog(line string) { - // Parse service prefix from log line - timestamp := time.Now().Format("15:04:05") - - var style *cli.AnsiStyle - serviceName := "" - - if strings.HasPrefix(line, "[FrankenPHP]") { - style = phpFrankenPHPStyle - serviceName = "FrankenPHP" - line = strings.TrimPrefix(line, "[FrankenPHP] ") - } else if strings.HasPrefix(line, "[Vite]") { - style = phpViteStyle - serviceName = "Vite" - line = strings.TrimPrefix(line, "[Vite] ") - } else if strings.HasPrefix(line, "[Horizon]") { - style = phpHorizonStyle - serviceName = "Horizon" - line = strings.TrimPrefix(line, "[Horizon] ") - } else if strings.HasPrefix(line, "[Reverb]") { - style = phpReverbStyle - serviceName = "Reverb" - line = strings.TrimPrefix(line, "[Reverb] ") - } else if strings.HasPrefix(line, "[Redis]") { - style = phpRedisStyle - serviceName = "Redis" - line = strings.TrimPrefix(line, "[Redis] ") - } else { - // Unknown service, print as-is - cli.Print("%s %s\n", dimStyle.Render(timestamp), line) - return - } - - cli.Print("%s %s %s\n", - dimStyle.Render(timestamp), - style.Render(cli.Sprintf("[%s]", serviceName)), - line, - ) -} - -func getServiceStyle(name string) *cli.AnsiStyle { - switch strings.ToLower(name) { - case "frankenphp": - return phpFrankenPHPStyle - case "vite": - return phpViteStyle - case "horizon": - return phpHorizonStyle - case "reverb": - return phpReverbStyle - case "redis": - return phpRedisStyle - default: - return dimStyle - } -} - -func containsService(services []DetectedService, target DetectedService) bool { - for _, s := range services { - if s == target { - return true - } - } - return false -} diff --git a/internal/cmd/php/cmd_packages.go b/internal/cmd/php/cmd_packages.go deleted file mode 100644 index fa1172be..00000000 --- a/internal/cmd/php/cmd_packages.go +++ /dev/null @@ -1,146 +0,0 @@ -package php - -import ( - "os" - - "forge.lthn.ai/core/go/pkg/cli" - "forge.lthn.ai/core/go/pkg/i18n" - "github.com/spf13/cobra" -) - -func addPHPPackagesCommands(parent *cobra.Command) { - packagesCmd := &cobra.Command{ - Use: "packages", - Short: i18n.T("cmd.php.packages.short"), - Long: i18n.T("cmd.php.packages.long"), - } - parent.AddCommand(packagesCmd) - - addPHPPackagesLinkCommand(packagesCmd) - addPHPPackagesUnlinkCommand(packagesCmd) - addPHPPackagesUpdateCommand(packagesCmd) - addPHPPackagesListCommand(packagesCmd) -} - -func addPHPPackagesLinkCommand(parent *cobra.Command) { - linkCmd := &cobra.Command{ - Use: "link [paths...]", - Short: i18n.T("cmd.php.packages.link.short"), - Long: i18n.T("cmd.php.packages.link.long"), - Args: cobra.MinimumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.link.linking")) - - if err := LinkPackages(cwd, args); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.link", "packages"), err) - } - - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.packages.link.done")) - return nil - }, - } - - parent.AddCommand(linkCmd) -} - -func addPHPPackagesUnlinkCommand(parent *cobra.Command) { - unlinkCmd := &cobra.Command{ - Use: "unlink [packages...]", - Short: i18n.T("cmd.php.packages.unlink.short"), - Long: i18n.T("cmd.php.packages.unlink.long"), - Args: cobra.MinimumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.unlink.unlinking")) - - if err := UnlinkPackages(cwd, args); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.unlink", "packages"), err) - } - - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.packages.unlink.done")) - return nil - }, - } - - parent.AddCommand(unlinkCmd) -} - -func addPHPPackagesUpdateCommand(parent *cobra.Command) { - updateCmd := &cobra.Command{ - Use: "update [packages...]", - Short: i18n.T("cmd.php.packages.update.short"), - Long: i18n.T("cmd.php.packages.update.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.update.updating")) - - if err := UpdatePackages(cwd, args); err != nil { - return cli.Err("%s: %w", i18n.T("cmd.php.error.update_packages"), err) - } - - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.packages.update.done")) - return nil - }, - } - - parent.AddCommand(updateCmd) -} - -func addPHPPackagesListCommand(parent *cobra.Command) { - listCmd := &cobra.Command{ - Use: "list", - Short: i18n.T("cmd.php.packages.list.short"), - Long: i18n.T("cmd.php.packages.list.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - packages, err := ListLinkedPackages(cwd) - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.list", "packages"), err) - } - - if len(packages) == 0 { - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.list.none_found")) - return nil - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.T("cmd.php.packages.list.linked")) - - for _, pkg := range packages { - name := pkg.Name - if name == "" { - name = i18n.T("cmd.php.packages.list.unknown") - } - version := pkg.Version - if version == "" { - version = "dev" - } - - cli.Print(" %s %s\n", successStyle.Render("*"), name) - cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("path")), pkg.Path) - cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("version")), version) - cli.Blank() - } - - return nil - }, - } - - parent.AddCommand(listCmd) -} diff --git a/internal/cmd/php/cmd_qa_runner.go b/internal/cmd/php/cmd_qa_runner.go deleted file mode 100644 index 7e9d7ae8..00000000 --- a/internal/cmd/php/cmd_qa_runner.go +++ /dev/null @@ -1,343 +0,0 @@ -package php - -import ( - "context" - "path/filepath" - "strings" - "sync" - - "forge.lthn.ai/core/go/pkg/cli" - "forge.lthn.ai/core/go/pkg/framework" - "forge.lthn.ai/core/go/pkg/i18n" - "forge.lthn.ai/core/go/pkg/process" -) - -// QARunner orchestrates PHP QA checks using pkg/process. -type QARunner struct { - dir string - fix bool - service *process.Service - core *framework.Core - - // Output tracking - outputMu sync.Mutex - checkOutputs map[string][]string -} - -// NewQARunner creates a QA runner for the given directory. -func NewQARunner(dir string, fix bool) (*QARunner, error) { - // Create a Core with process service for the QA session - core, err := framework.New( - framework.WithName("process", process.NewService(process.Options{})), - ) - if err != nil { - return nil, cli.WrapVerb(err, "create", "process service") - } - - svc, err := framework.ServiceFor[*process.Service](core, "process") - if err != nil { - return nil, cli.WrapVerb(err, "get", "process service") - } - - runner := &QARunner{ - dir: dir, - fix: fix, - service: svc, - core: core, - checkOutputs: make(map[string][]string), - } - - return runner, nil -} - -// BuildSpecs creates RunSpecs for the given QA checks. -func (r *QARunner) BuildSpecs(checks []string) []process.RunSpec { - specs := make([]process.RunSpec, 0, len(checks)) - - for _, check := range checks { - spec := r.buildSpec(check) - if spec != nil { - specs = append(specs, *spec) - } - } - - return specs -} - -// buildSpec creates a RunSpec for a single check. -func (r *QARunner) buildSpec(check string) *process.RunSpec { - switch check { - case "audit": - return &process.RunSpec{ - Name: "audit", - Command: "composer", - Args: []string{"audit", "--format=summary"}, - Dir: r.dir, - } - - case "fmt": - m := getMedium() - formatter, found := DetectFormatter(r.dir) - if !found { - return nil - } - if formatter == FormatterPint { - vendorBin := filepath.Join(r.dir, "vendor", "bin", "pint") - cmd := "pint" - if m.IsFile(vendorBin) { - cmd = vendorBin - } - args := []string{} - if !r.fix { - args = append(args, "--test") - } - return &process.RunSpec{ - Name: "fmt", - Command: cmd, - Args: args, - Dir: r.dir, - After: []string{"audit"}, - } - } - return nil - - case "stan": - m := getMedium() - _, found := DetectAnalyser(r.dir) - if !found { - return nil - } - vendorBin := filepath.Join(r.dir, "vendor", "bin", "phpstan") - cmd := "phpstan" - if m.IsFile(vendorBin) { - cmd = vendorBin - } - return &process.RunSpec{ - Name: "stan", - Command: cmd, - Args: []string{"analyse", "--no-progress"}, - Dir: r.dir, - After: []string{"fmt"}, - } - - case "psalm": - m := getMedium() - _, found := DetectPsalm(r.dir) - if !found { - return nil - } - vendorBin := filepath.Join(r.dir, "vendor", "bin", "psalm") - cmd := "psalm" - if m.IsFile(vendorBin) { - cmd = vendorBin - } - args := []string{"--no-progress"} - if r.fix { - args = append(args, "--alter", "--issues=all") - } - return &process.RunSpec{ - Name: "psalm", - Command: cmd, - Args: args, - Dir: r.dir, - After: []string{"stan"}, - } - - case "test": - m := getMedium() - // Check for Pest first, fall back to PHPUnit - pestBin := filepath.Join(r.dir, "vendor", "bin", "pest") - phpunitBin := filepath.Join(r.dir, "vendor", "bin", "phpunit") - - var cmd string - if m.IsFile(pestBin) { - cmd = pestBin - } else if m.IsFile(phpunitBin) { - cmd = phpunitBin - } else { - return nil - } - - // Tests depend on stan (or psalm if available) - after := []string{"stan"} - if _, found := DetectPsalm(r.dir); found { - after = []string{"psalm"} - } - - return &process.RunSpec{ - Name: "test", - Command: cmd, - Args: []string{}, - Dir: r.dir, - After: after, - } - - case "rector": - m := getMedium() - if !DetectRector(r.dir) { - return nil - } - vendorBin := filepath.Join(r.dir, "vendor", "bin", "rector") - cmd := "rector" - if m.IsFile(vendorBin) { - cmd = vendorBin - } - args := []string{"process"} - if !r.fix { - args = append(args, "--dry-run") - } - return &process.RunSpec{ - Name: "rector", - Command: cmd, - Args: args, - Dir: r.dir, - After: []string{"test"}, - AllowFailure: true, // Dry-run returns non-zero if changes would be made - } - - case "infection": - m := getMedium() - if !DetectInfection(r.dir) { - return nil - } - vendorBin := filepath.Join(r.dir, "vendor", "bin", "infection") - cmd := "infection" - if m.IsFile(vendorBin) { - cmd = vendorBin - } - return &process.RunSpec{ - Name: "infection", - Command: cmd, - Args: []string{"--min-msi=50", "--min-covered-msi=70", "--threads=4"}, - Dir: r.dir, - After: []string{"test"}, - AllowFailure: true, - } - } - - return nil -} - -// Run executes all QA checks and returns the results. -func (r *QARunner) Run(ctx context.Context, stages []QAStage) (*QARunResult, error) { - // Collect all checks from all stages - var allChecks []string - for _, stage := range stages { - checks := GetQAChecks(r.dir, stage) - allChecks = append(allChecks, checks...) - } - - if len(allChecks) == 0 { - return &QARunResult{Passed: true}, nil - } - - // Build specs - specs := r.BuildSpecs(allChecks) - if len(specs) == 0 { - return &QARunResult{Passed: true}, nil - } - - // Register output handler - r.core.RegisterAction(func(c *framework.Core, msg framework.Message) error { - switch m := msg.(type) { - case process.ActionProcessOutput: - r.outputMu.Lock() - // Extract check name from process ID mapping - for _, spec := range specs { - if strings.Contains(m.ID, spec.Name) || m.ID != "" { - // Store output for later display if needed - r.checkOutputs[spec.Name] = append(r.checkOutputs[spec.Name], m.Line) - break - } - } - r.outputMu.Unlock() - } - return nil - }) - - // Create runner and execute - runner := process.NewRunner(r.service) - result, err := runner.RunAll(ctx, specs) - if err != nil { - return nil, err - } - - // Convert to QA result - qaResult := &QARunResult{ - Passed: result.Success(), - Duration: result.Duration.String(), - Results: make([]QACheckRunResult, 0, len(result.Results)), - } - - for _, res := range result.Results { - qaResult.Results = append(qaResult.Results, QACheckRunResult{ - Name: res.Name, - Passed: res.Passed(), - Skipped: res.Skipped, - ExitCode: res.ExitCode, - Duration: res.Duration.String(), - Output: res.Output, - }) - if res.Passed() { - qaResult.PassedCount++ - } else if res.Skipped { - qaResult.SkippedCount++ - } else { - qaResult.FailedCount++ - } - } - - return qaResult, nil -} - -// GetCheckOutput returns captured output for a check. -func (r *QARunner) GetCheckOutput(check string) []string { - r.outputMu.Lock() - defer r.outputMu.Unlock() - return r.checkOutputs[check] -} - -// QARunResult holds the results of running QA checks. -type QARunResult struct { - Passed bool `json:"passed"` - Duration string `json:"duration"` - Results []QACheckRunResult `json:"results"` - PassedCount int `json:"passed_count"` - FailedCount int `json:"failed_count"` - SkippedCount int `json:"skipped_count"` -} - -// QACheckRunResult holds the result of a single QA check. -type QACheckRunResult struct { - Name string `json:"name"` - Passed bool `json:"passed"` - Skipped bool `json:"skipped"` - ExitCode int `json:"exit_code"` - Duration string `json:"duration"` - Output string `json:"output,omitempty"` -} - -// GetIssueMessage returns an issue message for a check. -func (r QACheckRunResult) GetIssueMessage() string { - if r.Passed || r.Skipped { - return "" - } - switch r.Name { - case "audit": - return i18n.T("i18n.done.find", "vulnerabilities") - case "fmt": - return i18n.T("i18n.done.find", "style issues") - case "stan": - return i18n.T("i18n.done.find", "analysis errors") - case "psalm": - return i18n.T("i18n.done.find", "type errors") - case "test": - return i18n.T("i18n.done.fail", "tests") - case "rector": - return i18n.T("i18n.done.find", "refactoring suggestions") - case "infection": - return i18n.T("i18n.fail.pass", "mutation testing") - default: - return i18n.T("i18n.done.find", "issues") - } -} diff --git a/internal/cmd/php/cmd_quality.go b/internal/cmd/php/cmd_quality.go deleted file mode 100644 index e76363ee..00000000 --- a/internal/cmd/php/cmd_quality.go +++ /dev/null @@ -1,815 +0,0 @@ -package php - -import ( - "context" - "encoding/json" - "errors" - "os" - "strings" - - "forge.lthn.ai/core/go/pkg/cli" - "forge.lthn.ai/core/go/pkg/i18n" - "github.com/spf13/cobra" -) - -var ( - testParallel bool - testCoverage bool - testFilter string - testGroup string - testJSON bool -) - -func addPHPTestCommand(parent *cobra.Command) { - testCmd := &cobra.Command{ - Use: "test", - Short: i18n.T("cmd.php.test.short"), - Long: i18n.T("cmd.php.test.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - if !testJSON { - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.ProgressSubject("run", "tests")) - } - - ctx := context.Background() - - opts := TestOptions{ - Dir: cwd, - Filter: testFilter, - Parallel: testParallel, - Coverage: testCoverage, - JUnit: testJSON, - Output: os.Stdout, - } - - if testGroup != "" { - opts.Groups = []string{testGroup} - } - - if err := RunTests(ctx, opts); err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.run", "tests"), err) - } - - return nil - }, - } - - testCmd.Flags().BoolVar(&testParallel, "parallel", false, i18n.T("cmd.php.test.flag.parallel")) - testCmd.Flags().BoolVar(&testCoverage, "coverage", false, i18n.T("cmd.php.test.flag.coverage")) - testCmd.Flags().StringVar(&testFilter, "filter", "", i18n.T("cmd.php.test.flag.filter")) - testCmd.Flags().StringVar(&testGroup, "group", "", i18n.T("cmd.php.test.flag.group")) - testCmd.Flags().BoolVar(&testJSON, "junit", false, i18n.T("cmd.php.test.flag.junit")) - - parent.AddCommand(testCmd) -} - -var ( - fmtFix bool - fmtDiff bool - fmtJSON bool -) - -func addPHPFmtCommand(parent *cobra.Command) { - fmtCmd := &cobra.Command{ - Use: "fmt [paths...]", - Short: i18n.T("cmd.php.fmt.short"), - Long: i18n.T("cmd.php.fmt.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - // Detect formatter - formatter, found := DetectFormatter(cwd) - if !found { - return errors.New(i18n.T("cmd.php.fmt.no_formatter")) - } - - if !fmtJSON { - var msg string - if fmtFix { - msg = i18n.T("cmd.php.fmt.formatting", map[string]interface{}{"Formatter": formatter}) - } else { - msg = i18n.ProgressSubject("check", "code style") - } - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), msg) - } - - ctx := context.Background() - - opts := FormatOptions{ - Dir: cwd, - Fix: fmtFix, - Diff: fmtDiff, - JSON: fmtJSON, - Output: os.Stdout, - } - - // Get any additional paths from args - if len(args) > 0 { - opts.Paths = args - } - - if err := Format(ctx, opts); err != nil { - if fmtFix { - return cli.Err("%s: %w", i18n.T("cmd.php.error.fmt_failed"), err) - } - return cli.Err("%s: %w", i18n.T("cmd.php.error.fmt_issues"), err) - } - - if !fmtJSON { - if fmtFix { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Code formatted"})) - } else { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.fmt.no_issues")) - } - } - - return nil - }, - } - - fmtCmd.Flags().BoolVar(&fmtFix, "fix", false, i18n.T("cmd.php.fmt.flag.fix")) - fmtCmd.Flags().BoolVar(&fmtDiff, "diff", false, i18n.T("common.flag.diff")) - fmtCmd.Flags().BoolVar(&fmtJSON, "json", false, i18n.T("common.flag.json")) - - parent.AddCommand(fmtCmd) -} - -var ( - stanLevel int - stanMemory string - stanJSON bool - stanSARIF bool -) - -func addPHPStanCommand(parent *cobra.Command) { - stanCmd := &cobra.Command{ - Use: "stan [paths...]", - Short: i18n.T("cmd.php.analyse.short"), - Long: i18n.T("cmd.php.analyse.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - // Detect analyser - _, found := DetectAnalyser(cwd) - if !found { - return errors.New(i18n.T("cmd.php.analyse.no_analyser")) - } - - if stanJSON && stanSARIF { - return errors.New(i18n.T("common.error.json_sarif_exclusive")) - } - - if !stanJSON && !stanSARIF { - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.php")), i18n.ProgressSubject("run", "static analysis")) - } - - ctx := context.Background() - - opts := AnalyseOptions{ - Dir: cwd, - Level: stanLevel, - Memory: stanMemory, - JSON: stanJSON, - SARIF: stanSARIF, - Output: os.Stdout, - } - - // Get any additional paths from args - if len(args) > 0 { - opts.Paths = args - } - - if err := Analyse(ctx, opts); err != nil { - return cli.Err("%s: %w", i18n.T("cmd.php.error.analysis_issues"), err) - } - - if !stanJSON && !stanSARIF { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.result.no_issues")) - } - return nil - }, - } - - stanCmd.Flags().IntVar(&stanLevel, "level", 0, i18n.T("cmd.php.analyse.flag.level")) - stanCmd.Flags().StringVar(&stanMemory, "memory", "", i18n.T("cmd.php.analyse.flag.memory")) - stanCmd.Flags().BoolVar(&stanJSON, "json", false, i18n.T("common.flag.json")) - stanCmd.Flags().BoolVar(&stanSARIF, "sarif", false, i18n.T("common.flag.sarif")) - - parent.AddCommand(stanCmd) -} - -// ============================================================================= -// New QA Commands -// ============================================================================= - -var ( - psalmLevel int - psalmFix bool - psalmBaseline bool - psalmShowInfo bool - psalmJSON bool - psalmSARIF bool -) - -func addPHPPsalmCommand(parent *cobra.Command) { - psalmCmd := &cobra.Command{ - Use: "psalm", - Short: i18n.T("cmd.php.psalm.short"), - Long: i18n.T("cmd.php.psalm.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - // Check if Psalm is available - _, found := DetectPsalm(cwd) - if !found { - cli.Print("%s %s\n\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.psalm.not_found")) - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("install")), i18n.T("cmd.php.psalm.install")) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.setup")), i18n.T("cmd.php.psalm.setup")) - return errors.New(i18n.T("cmd.php.error.psalm_not_installed")) - } - - if psalmJSON && psalmSARIF { - return errors.New(i18n.T("common.error.json_sarif_exclusive")) - } - - if !psalmJSON && !psalmSARIF { - var msg string - if psalmFix { - msg = i18n.T("cmd.php.psalm.analysing_fixing") - } else { - msg = i18n.T("cmd.php.psalm.analysing") - } - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.psalm")), msg) - } - - ctx := context.Background() - - opts := PsalmOptions{ - Dir: cwd, - Level: psalmLevel, - Fix: psalmFix, - Baseline: psalmBaseline, - ShowInfo: psalmShowInfo, - JSON: psalmJSON, - SARIF: psalmSARIF, - Output: os.Stdout, - } - - if err := RunPsalm(ctx, opts); err != nil { - return cli.Err("%s: %w", i18n.T("cmd.php.error.psalm_issues"), err) - } - - if !psalmJSON && !psalmSARIF { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.result.no_issues")) - } - return nil - }, - } - - psalmCmd.Flags().IntVar(&psalmLevel, "level", 0, i18n.T("cmd.php.psalm.flag.level")) - psalmCmd.Flags().BoolVar(&psalmFix, "fix", false, i18n.T("common.flag.fix")) - psalmCmd.Flags().BoolVar(&psalmBaseline, "baseline", false, i18n.T("cmd.php.psalm.flag.baseline")) - psalmCmd.Flags().BoolVar(&psalmShowInfo, "show-info", false, i18n.T("cmd.php.psalm.flag.show_info")) - psalmCmd.Flags().BoolVar(&psalmJSON, "json", false, i18n.T("common.flag.json")) - psalmCmd.Flags().BoolVar(&psalmSARIF, "sarif", false, i18n.T("common.flag.sarif")) - - parent.AddCommand(psalmCmd) -} - -var ( - auditJSONOutput bool - auditFix bool -) - -func addPHPAuditCommand(parent *cobra.Command) { - auditCmd := &cobra.Command{ - Use: "audit", - Short: i18n.T("cmd.php.audit.short"), - Long: i18n.T("cmd.php.audit.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.audit")), i18n.T("cmd.php.audit.scanning")) - - ctx := context.Background() - - results, err := RunAudit(ctx, AuditOptions{ - Dir: cwd, - JSON: auditJSONOutput, - Fix: auditFix, - Output: os.Stdout, - }) - if err != nil { - return cli.Err("%s: %w", i18n.T("cmd.php.error.audit_failed"), err) - } - - // Print results - totalVulns := 0 - hasErrors := false - - for _, result := range results { - icon := successStyle.Render("✓") - status := successStyle.Render(i18n.T("cmd.php.audit.secure")) - - if result.Error != nil { - icon = errorStyle.Render("✗") - status = errorStyle.Render(i18n.T("cmd.php.audit.error")) - hasErrors = true - } else if result.Vulnerabilities > 0 { - icon = errorStyle.Render("✗") - status = errorStyle.Render(i18n.T("cmd.php.audit.vulnerabilities", map[string]interface{}{"Count": result.Vulnerabilities})) - totalVulns += result.Vulnerabilities - } - - cli.Print(" %s %s %s\n", icon, dimStyle.Render(result.Tool+":"), status) - - // Show advisories - for _, adv := range result.Advisories { - severity := adv.Severity - if severity == "" { - severity = "unknown" - } - sevStyle := getSeverityStyle(severity) - cli.Print(" %s %s\n", sevStyle.Render("["+severity+"]"), adv.Package) - if adv.Title != "" { - cli.Print(" %s\n", dimStyle.Render(adv.Title)) - } - } - } - - cli.Blank() - - if totalVulns > 0 { - cli.Print("%s %s\n", errorStyle.Render(i18n.Label("warning")), i18n.T("cmd.php.audit.found_vulns", map[string]interface{}{"Count": totalVulns})) - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("fix")), i18n.T("common.hint.fix_deps")) - return errors.New(i18n.T("cmd.php.error.vulns_found")) - } - - if hasErrors { - return errors.New(i18n.T("cmd.php.audit.completed_errors")) - } - - cli.Print("%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.audit.all_secure")) - return nil - }, - } - - auditCmd.Flags().BoolVar(&auditJSONOutput, "json", false, i18n.T("common.flag.json")) - auditCmd.Flags().BoolVar(&auditFix, "fix", false, i18n.T("cmd.php.audit.flag.fix")) - - parent.AddCommand(auditCmd) -} - -var ( - securitySeverity string - securityJSONOutput bool - securitySarif bool - securityURL string -) - -func addPHPSecurityCommand(parent *cobra.Command) { - securityCmd := &cobra.Command{ - Use: "security", - Short: i18n.T("cmd.php.security.short"), - Long: i18n.T("cmd.php.security.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.security")), i18n.ProgressSubject("run", "security checks")) - - ctx := context.Background() - - result, err := RunSecurityChecks(ctx, SecurityOptions{ - Dir: cwd, - Severity: securitySeverity, - JSON: securityJSONOutput, - SARIF: securitySarif, - URL: securityURL, - Output: os.Stdout, - }) - if err != nil { - return cli.Err("%s: %w", i18n.T("cmd.php.error.security_failed"), err) - } - - // Print results by category - currentCategory := "" - for _, check := range result.Checks { - category := strings.Split(check.ID, "_")[0] - if category != currentCategory { - if currentCategory != "" { - cli.Blank() - } - currentCategory = category - cli.Print(" %s\n", dimStyle.Render(strings.ToUpper(category)+i18n.T("cmd.php.security.checks_suffix"))) - } - - icon := successStyle.Render("✓") - if !check.Passed { - icon = getSeverityStyle(check.Severity).Render("✗") - } - - cli.Print(" %s %s\n", icon, check.Name) - if !check.Passed && check.Message != "" { - cli.Print(" %s\n", dimStyle.Render(check.Message)) - if check.Fix != "" { - cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("fix")), check.Fix) - } - } - } - - cli.Blank() - - // Print summary - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("summary")), i18n.T("cmd.php.security.summary")) - cli.Print(" %s %d/%d\n", dimStyle.Render(i18n.T("cmd.php.security.passed")), result.Summary.Passed, result.Summary.Total) - - if result.Summary.Critical > 0 { - cli.Print(" %s %d\n", phpSecurityCriticalStyle.Render(i18n.T("cmd.php.security.critical")), result.Summary.Critical) - } - if result.Summary.High > 0 { - cli.Print(" %s %d\n", phpSecurityHighStyle.Render(i18n.T("cmd.php.security.high")), result.Summary.High) - } - if result.Summary.Medium > 0 { - cli.Print(" %s %d\n", phpSecurityMediumStyle.Render(i18n.T("cmd.php.security.medium")), result.Summary.Medium) - } - if result.Summary.Low > 0 { - cli.Print(" %s %d\n", phpSecurityLowStyle.Render(i18n.T("cmd.php.security.low")), result.Summary.Low) - } - - if result.Summary.Critical > 0 || result.Summary.High > 0 { - return errors.New(i18n.T("cmd.php.error.critical_high_issues")) - } - - return nil - }, - } - - securityCmd.Flags().StringVar(&securitySeverity, "severity", "", i18n.T("cmd.php.security.flag.severity")) - securityCmd.Flags().BoolVar(&securityJSONOutput, "json", false, i18n.T("common.flag.json")) - securityCmd.Flags().BoolVar(&securitySarif, "sarif", false, i18n.T("cmd.php.security.flag.sarif")) - securityCmd.Flags().StringVar(&securityURL, "url", "", i18n.T("cmd.php.security.flag.url")) - - parent.AddCommand(securityCmd) -} - -var ( - qaQuick bool - qaFull bool - qaFix bool - qaJSON bool -) - -func addPHPQACommand(parent *cobra.Command) { - qaCmd := &cobra.Command{ - Use: "qa", - Short: i18n.T("cmd.php.qa.short"), - Long: i18n.T("cmd.php.qa.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - // Determine stages - opts := QAOptions{ - Dir: cwd, - Quick: qaQuick, - Full: qaFull, - Fix: qaFix, - JSON: qaJSON, - } - stages := GetQAStages(opts) - - // Print header - if !qaJSON { - cli.Print("%s %s\n\n", dimStyle.Render(i18n.Label("qa")), i18n.ProgressSubject("run", "QA pipeline")) - } - - ctx := context.Background() - - // Create QA runner using pkg/process - runner, err := NewQARunner(cwd, qaFix) - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.create", "QA runner"), err) - } - - // Run all checks with dependency ordering - result, err := runner.Run(ctx, stages) - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.run", "QA checks"), err) - } - - // Display results by stage (skip when JSON output is enabled) - if !qaJSON { - currentStage := "" - for _, checkResult := range result.Results { - // Determine stage for this check - stage := getCheckStage(checkResult.Name, stages, cwd) - if stage != currentStage { - if currentStage != "" { - cli.Blank() - } - currentStage = stage - cli.Print("%s\n", phpQAStageStyle.Render("── "+strings.ToUpper(stage)+" ──")) - } - - icon := phpQAPassedStyle.Render("✓") - status := phpQAPassedStyle.Render(i18n.T("i18n.done.pass")) - if checkResult.Skipped { - icon = dimStyle.Render("-") - status = dimStyle.Render(i18n.T("i18n.done.skip")) - } else if !checkResult.Passed { - icon = phpQAFailedStyle.Render("✗") - status = phpQAFailedStyle.Render(i18n.T("i18n.done.fail")) - } - - cli.Print(" %s %s %s %s\n", icon, checkResult.Name, status, dimStyle.Render(checkResult.Duration)) - } - cli.Blank() - - // Print summary - if result.Passed { - cli.Print("%s %s\n", phpQAPassedStyle.Render("QA PASSED:"), i18n.T("i18n.count.check", result.PassedCount)+" "+i18n.T("i18n.done.pass")) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("i18n.label.duration")), result.Duration) - return nil - } - - cli.Print("%s %s\n\n", phpQAFailedStyle.Render("QA FAILED:"), i18n.T("i18n.count.check", result.PassedCount)+"/"+cli.Sprint(len(result.Results))+" "+i18n.T("i18n.done.pass")) - - // Show what needs fixing - cli.Print("%s\n", dimStyle.Render(i18n.T("i18n.label.fix"))) - for _, checkResult := range result.Results { - if checkResult.Passed || checkResult.Skipped { - continue - } - fixCmd := getQAFixCommand(checkResult.Name, qaFix) - issue := checkResult.GetIssueMessage() - if issue == "" { - issue = "issues found" - } - cli.Print(" %s %s\n", phpQAFailedStyle.Render("*"), checkResult.Name+": "+issue) - if fixCmd != "" { - cli.Print(" %s %s\n", dimStyle.Render("->"), fixCmd) - } - } - - return cli.Err("%s", i18n.T("i18n.fail.run", "QA pipeline")) - } - - // JSON mode: output results as JSON - output, err := json.MarshalIndent(result, "", " ") - if err != nil { - return cli.Wrap(err, "marshal JSON output") - } - cli.Text(string(output)) - - if !result.Passed { - return cli.Err("%s", i18n.T("i18n.fail.run", "QA pipeline")) - } - return nil - }, - } - - qaCmd.Flags().BoolVar(&qaQuick, "quick", false, i18n.T("cmd.php.qa.flag.quick")) - qaCmd.Flags().BoolVar(&qaFull, "full", false, i18n.T("cmd.php.qa.flag.full")) - qaCmd.Flags().BoolVar(&qaFix, "fix", false, i18n.T("common.flag.fix")) - qaCmd.Flags().BoolVar(&qaJSON, "json", false, i18n.T("common.flag.json")) - - parent.AddCommand(qaCmd) -} - -// getCheckStage determines which stage a check belongs to. -func getCheckStage(checkName string, stages []QAStage, dir string) string { - for _, stage := range stages { - checks := GetQAChecks(dir, stage) - for _, c := range checks { - if c == checkName { - return string(stage) - } - } - } - return "unknown" -} - -func getQAFixCommand(checkName string, fixEnabled bool) string { - switch checkName { - case "audit": - return i18n.T("i18n.progress.update", "dependencies") - case "fmt": - if fixEnabled { - return "" - } - return "core php fmt --fix" - case "stan": - return i18n.T("i18n.progress.fix", "PHPStan errors") - case "psalm": - return i18n.T("i18n.progress.fix", "Psalm errors") - case "test": - return i18n.T("i18n.progress.fix", i18n.T("i18n.done.fail")+" tests") - case "rector": - if fixEnabled { - return "" - } - return "core php rector --fix" - case "infection": - return i18n.T("i18n.progress.improve", "test coverage") - } - return "" -} - -var ( - rectorFix bool - rectorDiff bool - rectorClearCache bool -) - -func addPHPRectorCommand(parent *cobra.Command) { - rectorCmd := &cobra.Command{ - Use: "rector", - Short: i18n.T("cmd.php.rector.short"), - Long: i18n.T("cmd.php.rector.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - // Check if Rector is available - if !DetectRector(cwd) { - cli.Print("%s %s\n\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.rector.not_found")) - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("install")), i18n.T("cmd.php.rector.install")) - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.setup")), i18n.T("cmd.php.rector.setup")) - return errors.New(i18n.T("cmd.php.error.rector_not_installed")) - } - - var msg string - if rectorFix { - msg = i18n.T("cmd.php.rector.refactoring") - } else { - msg = i18n.T("cmd.php.rector.analysing") - } - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.rector")), msg) - - ctx := context.Background() - - opts := RectorOptions{ - Dir: cwd, - Fix: rectorFix, - Diff: rectorDiff, - ClearCache: rectorClearCache, - Output: os.Stdout, - } - - if err := RunRector(ctx, opts); err != nil { - if rectorFix { - return cli.Err("%s: %w", i18n.T("cmd.php.error.rector_failed"), err) - } - // Dry-run returns non-zero if changes would be made - cli.Print("\n%s %s\n", phpQAWarningStyle.Render(i18n.T("cmd.php.label.info")), i18n.T("cmd.php.rector.changes_suggested")) - return nil - } - - if rectorFix { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("common.success.completed", map[string]any{"Action": "Code refactored"})) - } else { - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.rector.no_changes")) - } - return nil - }, - } - - rectorCmd.Flags().BoolVar(&rectorFix, "fix", false, i18n.T("cmd.php.rector.flag.fix")) - rectorCmd.Flags().BoolVar(&rectorDiff, "diff", false, i18n.T("cmd.php.rector.flag.diff")) - rectorCmd.Flags().BoolVar(&rectorClearCache, "clear-cache", false, i18n.T("cmd.php.rector.flag.clear_cache")) - - parent.AddCommand(rectorCmd) -} - -var ( - infectionMinMSI int - infectionMinCoveredMSI int - infectionThreads int - infectionFilter string - infectionOnlyCovered bool -) - -func addPHPInfectionCommand(parent *cobra.Command) { - infectionCmd := &cobra.Command{ - Use: "infection", - Short: i18n.T("cmd.php.infection.short"), - Long: i18n.T("cmd.php.infection.long"), - RunE: func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) - } - - if !IsPHPProject(cwd) { - return errors.New(i18n.T("cmd.php.error.not_php")) - } - - // Check if Infection is available - if !DetectInfection(cwd) { - cli.Print("%s %s\n\n", errorStyle.Render(i18n.Label("error")), i18n.T("cmd.php.infection.not_found")) - cli.Print("%s %s\n", dimStyle.Render(i18n.Label("install")), i18n.T("cmd.php.infection.install")) - return errors.New(i18n.T("cmd.php.error.infection_not_installed")) - } - - cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.php.label.infection")), i18n.ProgressSubject("run", "mutation testing")) - cli.Print("%s %s\n\n", dimStyle.Render(i18n.T("cmd.php.label.info")), i18n.T("cmd.php.infection.note")) - - ctx := context.Background() - - opts := InfectionOptions{ - Dir: cwd, - MinMSI: infectionMinMSI, - MinCoveredMSI: infectionMinCoveredMSI, - Threads: infectionThreads, - Filter: infectionFilter, - OnlyCovered: infectionOnlyCovered, - Output: os.Stdout, - } - - if err := RunInfection(ctx, opts); err != nil { - return cli.Err("%s: %w", i18n.T("cmd.php.error.infection_failed"), err) - } - - cli.Print("\n%s %s\n", successStyle.Render(i18n.Label("done")), i18n.T("cmd.php.infection.complete")) - return nil - }, - } - - infectionCmd.Flags().IntVar(&infectionMinMSI, "min-msi", 0, i18n.T("cmd.php.infection.flag.min_msi")) - infectionCmd.Flags().IntVar(&infectionMinCoveredMSI, "min-covered-msi", 0, i18n.T("cmd.php.infection.flag.min_covered_msi")) - infectionCmd.Flags().IntVar(&infectionThreads, "threads", 0, i18n.T("cmd.php.infection.flag.threads")) - infectionCmd.Flags().StringVar(&infectionFilter, "filter", "", i18n.T("cmd.php.infection.flag.filter")) - infectionCmd.Flags().BoolVar(&infectionOnlyCovered, "only-covered", false, i18n.T("cmd.php.infection.flag.only_covered")) - - parent.AddCommand(infectionCmd) -} - -func getSeverityStyle(severity string) *cli.AnsiStyle { - switch strings.ToLower(severity) { - case "critical": - return phpSecurityCriticalStyle - case "high": - return phpSecurityHighStyle - case "medium": - return phpSecurityMediumStyle - case "low": - return phpSecurityLowStyle - default: - return dimStyle - } -} diff --git a/internal/cmd/php/container.go b/internal/cmd/php/container.go deleted file mode 100644 index 1df5deae..00000000 --- a/internal/cmd/php/container.go +++ /dev/null @@ -1,451 +0,0 @@ -package php - -import ( - "context" - "io" - "os" - "os/exec" - "path/filepath" - "strings" - - "forge.lthn.ai/core/go/pkg/cli" -) - -// DockerBuildOptions configures Docker image building for PHP projects. -type DockerBuildOptions struct { - // ProjectDir is the path to the PHP/Laravel project. - ProjectDir string - - // ImageName is the name for the Docker image. - ImageName string - - // Tag is the image tag (default: "latest"). - Tag string - - // Platform specifies the target platform (e.g., "linux/amd64", "linux/arm64"). - Platform string - - // Dockerfile is the path to a custom Dockerfile. - // If empty, one will be auto-generated for FrankenPHP. - Dockerfile string - - // NoBuildCache disables Docker build cache. - NoBuildCache bool - - // BuildArgs are additional build arguments. - BuildArgs map[string]string - - // Output is the writer for build output (default: os.Stdout). - Output io.Writer -} - -// LinuxKitBuildOptions configures LinuxKit image building for PHP projects. -type LinuxKitBuildOptions struct { - // ProjectDir is the path to the PHP/Laravel project. - ProjectDir string - - // OutputPath is the path for the output image. - OutputPath string - - // Format is the output format: "iso", "qcow2", "raw", "vmdk". - Format string - - // Template is the LinuxKit template name (default: "server-php"). - Template string - - // Variables are template variables to apply. - Variables map[string]string - - // Output is the writer for build output (default: os.Stdout). - Output io.Writer -} - -// ServeOptions configures running a production PHP container. -type ServeOptions struct { - // ImageName is the Docker image to run. - ImageName string - - // Tag is the image tag (default: "latest"). - Tag string - - // ContainerName is the name for the container. - ContainerName string - - // Port is the host port to bind (default: 80). - Port int - - // HTTPSPort is the host HTTPS port to bind (default: 443). - HTTPSPort int - - // Detach runs the container in detached mode. - Detach bool - - // EnvFile is the path to an environment file. - EnvFile string - - // Volumes maps host paths to container paths. - Volumes map[string]string - - // Output is the writer for output (default: os.Stdout). - Output io.Writer -} - -// BuildDocker builds a Docker image for the PHP project. -func BuildDocker(ctx context.Context, opts DockerBuildOptions) error { - if opts.ProjectDir == "" { - cwd, err := os.Getwd() - if err != nil { - return cli.WrapVerb(err, "get", "working directory") - } - opts.ProjectDir = cwd - } - - // Validate project directory - if !IsPHPProject(opts.ProjectDir) { - return cli.Err("not a PHP project: %s (missing composer.json)", opts.ProjectDir) - } - - // Set defaults - if opts.ImageName == "" { - opts.ImageName = filepath.Base(opts.ProjectDir) - } - if opts.Tag == "" { - opts.Tag = "latest" - } - if opts.Output == nil { - opts.Output = os.Stdout - } - - // Determine Dockerfile path - dockerfilePath := opts.Dockerfile - var tempDockerfile string - - if dockerfilePath == "" { - // Generate Dockerfile - content, err := GenerateDockerfile(opts.ProjectDir) - if err != nil { - return cli.WrapVerb(err, "generate", "Dockerfile") - } - - // Write to temporary file - m := getMedium() - tempDockerfile = filepath.Join(opts.ProjectDir, "Dockerfile.core-generated") - if err := m.Write(tempDockerfile, content); err != nil { - return cli.WrapVerb(err, "write", "Dockerfile") - } - defer func() { _ = m.Delete(tempDockerfile) }() - - dockerfilePath = tempDockerfile - } - - // Build Docker image - imageRef := cli.Sprintf("%s:%s", opts.ImageName, opts.Tag) - - args := []string{"build", "-t", imageRef, "-f", dockerfilePath} - - if opts.Platform != "" { - args = append(args, "--platform", opts.Platform) - } - - if opts.NoBuildCache { - args = append(args, "--no-cache") - } - - for key, value := range opts.BuildArgs { - args = append(args, "--build-arg", cli.Sprintf("%s=%s", key, value)) - } - - args = append(args, opts.ProjectDir) - - cmd := exec.CommandContext(ctx, "docker", args...) - cmd.Dir = opts.ProjectDir - cmd.Stdout = opts.Output - cmd.Stderr = opts.Output - - if err := cmd.Run(); err != nil { - return cli.Wrap(err, "docker build failed") - } - - return nil -} - -// BuildLinuxKit builds a LinuxKit image for the PHP project. -func BuildLinuxKit(ctx context.Context, opts LinuxKitBuildOptions) error { - if opts.ProjectDir == "" { - cwd, err := os.Getwd() - if err != nil { - return cli.WrapVerb(err, "get", "working directory") - } - opts.ProjectDir = cwd - } - - // Validate project directory - if !IsPHPProject(opts.ProjectDir) { - return cli.Err("not a PHP project: %s (missing composer.json)", opts.ProjectDir) - } - - // Set defaults - if opts.Template == "" { - opts.Template = "server-php" - } - if opts.Format == "" { - opts.Format = "qcow2" - } - if opts.OutputPath == "" { - opts.OutputPath = filepath.Join(opts.ProjectDir, "dist", filepath.Base(opts.ProjectDir)) - } - if opts.Output == nil { - opts.Output = os.Stdout - } - - // Ensure output directory exists - m := getMedium() - outputDir := filepath.Dir(opts.OutputPath) - if err := m.EnsureDir(outputDir); err != nil { - return cli.WrapVerb(err, "create", "output directory") - } - - // Find linuxkit binary - linuxkitPath, err := lookupLinuxKit() - if err != nil { - return err - } - - // Get template content - templateContent, err := getLinuxKitTemplate(opts.Template) - if err != nil { - return cli.WrapVerb(err, "get", "template") - } - - // Apply variables - if opts.Variables == nil { - opts.Variables = make(map[string]string) - } - // Add project-specific variables - opts.Variables["PROJECT_DIR"] = opts.ProjectDir - opts.Variables["PROJECT_NAME"] = filepath.Base(opts.ProjectDir) - - content, err := applyTemplateVariables(templateContent, opts.Variables) - if err != nil { - return cli.WrapVerb(err, "apply", "template variables") - } - - // Write template to temp file - tempYAML := filepath.Join(opts.ProjectDir, ".core-linuxkit.yml") - if err := m.Write(tempYAML, content); err != nil { - return cli.WrapVerb(err, "write", "template") - } - defer func() { _ = m.Delete(tempYAML) }() - - // Build LinuxKit image - args := []string{ - "build", - "--format", opts.Format, - "--name", opts.OutputPath, - tempYAML, - } - - cmd := exec.CommandContext(ctx, linuxkitPath, args...) - cmd.Dir = opts.ProjectDir - cmd.Stdout = opts.Output - cmd.Stderr = opts.Output - - if err := cmd.Run(); err != nil { - return cli.Wrap(err, "linuxkit build failed") - } - - return nil -} - -// ServeProduction runs a production PHP container. -func ServeProduction(ctx context.Context, opts ServeOptions) error { - if opts.ImageName == "" { - return cli.Err("image name is required") - } - - // Set defaults - if opts.Tag == "" { - opts.Tag = "latest" - } - if opts.Port == 0 { - opts.Port = 80 - } - if opts.HTTPSPort == 0 { - opts.HTTPSPort = 443 - } - if opts.Output == nil { - opts.Output = os.Stdout - } - - imageRef := cli.Sprintf("%s:%s", opts.ImageName, opts.Tag) - - args := []string{"run"} - - if opts.Detach { - args = append(args, "-d") - } else { - args = append(args, "--rm") - } - - if opts.ContainerName != "" { - args = append(args, "--name", opts.ContainerName) - } - - // Port mappings - args = append(args, "-p", cli.Sprintf("%d:80", opts.Port)) - args = append(args, "-p", cli.Sprintf("%d:443", opts.HTTPSPort)) - - // Environment file - if opts.EnvFile != "" { - args = append(args, "--env-file", opts.EnvFile) - } - - // Volume mounts - for hostPath, containerPath := range opts.Volumes { - args = append(args, "-v", cli.Sprintf("%s:%s", hostPath, containerPath)) - } - - args = append(args, imageRef) - - cmd := exec.CommandContext(ctx, "docker", args...) - cmd.Stdout = opts.Output - cmd.Stderr = opts.Output - - if opts.Detach { - output, err := cmd.Output() - if err != nil { - return cli.WrapVerb(err, "start", "container") - } - containerID := strings.TrimSpace(string(output)) - cli.Print("Container started: %s\n", containerID[:12]) - return nil - } - - return cmd.Run() -} - -// Shell opens a shell in a running container. -func Shell(ctx context.Context, containerID string) error { - if containerID == "" { - return cli.Err("container ID is required") - } - - // Resolve partial container ID - fullID, err := resolveDockerContainerID(ctx, containerID) - if err != nil { - return err - } - - cmd := exec.CommandContext(ctx, "docker", "exec", "-it", fullID, "/bin/sh") - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - return cmd.Run() -} - -// IsPHPProject checks if the given directory is a PHP project. -func IsPHPProject(dir string) bool { - composerPath := filepath.Join(dir, "composer.json") - return getMedium().IsFile(composerPath) -} - -// commonLinuxKitPaths defines default search locations for linuxkit. -var commonLinuxKitPaths = []string{ - "/usr/local/bin/linuxkit", - "/opt/homebrew/bin/linuxkit", -} - -// lookupLinuxKit finds the linuxkit binary. -func lookupLinuxKit() (string, error) { - // Check PATH first - if path, err := exec.LookPath("linuxkit"); err == nil { - return path, nil - } - - m := getMedium() - for _, p := range commonLinuxKitPaths { - if m.IsFile(p) { - return p, nil - } - } - - return "", cli.Err("linuxkit not found. Install with: brew install linuxkit (macOS) or see https://github.com/linuxkit/linuxkit") -} - -// getLinuxKitTemplate retrieves a LinuxKit template by name. -func getLinuxKitTemplate(name string) (string, error) { - // Default server-php template for PHP projects - if name == "server-php" { - return defaultServerPHPTemplate, nil - } - - // Try to load from container package templates - // This would integrate with forge.lthn.ai/core/go/pkg/container - return "", cli.Err("template not found: %s", name) -} - -// applyTemplateVariables applies variable substitution to template content. -func applyTemplateVariables(content string, vars map[string]string) (string, error) { - result := content - for key, value := range vars { - placeholder := "${" + key + "}" - result = strings.ReplaceAll(result, placeholder, value) - } - return result, nil -} - -// resolveDockerContainerID resolves a partial container ID to a full ID. -func resolveDockerContainerID(ctx context.Context, partialID string) (string, error) { - cmd := exec.CommandContext(ctx, "docker", "ps", "-a", "--no-trunc", "--format", "{{.ID}}") - output, err := cmd.Output() - if err != nil { - return "", cli.WrapVerb(err, "list", "containers") - } - - lines := strings.Split(strings.TrimSpace(string(output)), "\n") - var matches []string - - for _, line := range lines { - if strings.HasPrefix(line, partialID) { - matches = append(matches, line) - } - } - - switch len(matches) { - case 0: - return "", cli.Err("no container found matching: %s", partialID) - case 1: - return matches[0], nil - default: - return "", cli.Err("multiple containers match '%s', be more specific", partialID) - } -} - -// defaultServerPHPTemplate is the default LinuxKit template for PHP servers. -const defaultServerPHPTemplate = `# LinuxKit configuration for PHP/FrankenPHP server -kernel: - image: linuxkit/kernel:6.6.13 - cmdline: "console=tty0 console=ttyS0" -init: - - linuxkit/init:v1.0.1 - - linuxkit/runc:v1.0.1 - - linuxkit/containerd:v1.0.1 -onboot: - - name: sysctl - image: linuxkit/sysctl:v1.0.1 - - name: dhcpcd - image: linuxkit/dhcpcd:v1.0.1 - command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf"] -services: - - name: getty - image: linuxkit/getty:v1.0.1 - env: - - INSECURE=true - - name: sshd - image: linuxkit/sshd:v1.0.1 -files: - - path: etc/ssh/authorized_keys - contents: | - ${SSH_KEY:-} -` diff --git a/internal/cmd/php/container_test.go b/internal/cmd/php/container_test.go deleted file mode 100644 index c0d0e196..00000000 --- a/internal/cmd/php/container_test.go +++ /dev/null @@ -1,383 +0,0 @@ -package php - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestDockerBuildOptions_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - opts := DockerBuildOptions{ - ProjectDir: "/project", - ImageName: "myapp", - Tag: "v1.0.0", - Platform: "linux/amd64", - Dockerfile: "/path/to/Dockerfile", - NoBuildCache: true, - BuildArgs: map[string]string{"ARG1": "value1"}, - Output: os.Stdout, - } - - assert.Equal(t, "/project", opts.ProjectDir) - assert.Equal(t, "myapp", opts.ImageName) - assert.Equal(t, "v1.0.0", opts.Tag) - assert.Equal(t, "linux/amd64", opts.Platform) - assert.Equal(t, "/path/to/Dockerfile", opts.Dockerfile) - assert.True(t, opts.NoBuildCache) - assert.Equal(t, "value1", opts.BuildArgs["ARG1"]) - assert.NotNil(t, opts.Output) - }) -} - -func TestLinuxKitBuildOptions_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - opts := LinuxKitBuildOptions{ - ProjectDir: "/project", - OutputPath: "/output/image.qcow2", - Format: "qcow2", - Template: "server-php", - Variables: map[string]string{"VAR1": "value1"}, - Output: os.Stdout, - } - - assert.Equal(t, "/project", opts.ProjectDir) - assert.Equal(t, "/output/image.qcow2", opts.OutputPath) - assert.Equal(t, "qcow2", opts.Format) - assert.Equal(t, "server-php", opts.Template) - assert.Equal(t, "value1", opts.Variables["VAR1"]) - assert.NotNil(t, opts.Output) - }) -} - -func TestServeOptions_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - opts := ServeOptions{ - ImageName: "myapp", - Tag: "latest", - ContainerName: "myapp-container", - Port: 8080, - HTTPSPort: 8443, - Detach: true, - EnvFile: "/path/to/.env", - Volumes: map[string]string{"/host": "/container"}, - Output: os.Stdout, - } - - assert.Equal(t, "myapp", opts.ImageName) - assert.Equal(t, "latest", opts.Tag) - assert.Equal(t, "myapp-container", opts.ContainerName) - assert.Equal(t, 8080, opts.Port) - assert.Equal(t, 8443, opts.HTTPSPort) - assert.True(t, opts.Detach) - assert.Equal(t, "/path/to/.env", opts.EnvFile) - assert.Equal(t, "/container", opts.Volumes["/host"]) - assert.NotNil(t, opts.Output) - }) -} - -func TestIsPHPProject_Container_Good(t *testing.T) { - t.Run("returns true with composer.json", func(t *testing.T) { - dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(`{}`), 0644) - require.NoError(t, err) - - assert.True(t, IsPHPProject(dir)) - }) -} - -func TestIsPHPProject_Container_Bad(t *testing.T) { - t.Run("returns false without composer.json", func(t *testing.T) { - dir := t.TempDir() - assert.False(t, IsPHPProject(dir)) - }) - - t.Run("returns false for non-existent directory", func(t *testing.T) { - assert.False(t, IsPHPProject("/non/existent/path")) - }) -} - -func TestLookupLinuxKit_Bad(t *testing.T) { - t.Run("returns error when linuxkit not found", func(t *testing.T) { - // Save original PATH and paths - origPath := os.Getenv("PATH") - origCommonPaths := commonLinuxKitPaths - defer func() { - _ = os.Setenv("PATH", origPath) - commonLinuxKitPaths = origCommonPaths - }() - - // Set PATH to empty and clear common paths - _ = os.Setenv("PATH", "") - commonLinuxKitPaths = []string{} - - _, err := lookupLinuxKit() - if assert.Error(t, err) { - assert.Contains(t, err.Error(), "linuxkit not found") - } - }) -} - -func TestGetLinuxKitTemplate_Good(t *testing.T) { - t.Run("returns server-php template", func(t *testing.T) { - content, err := getLinuxKitTemplate("server-php") - assert.NoError(t, err) - assert.Contains(t, content, "kernel:") - assert.Contains(t, content, "linuxkit/kernel") - }) -} - -func TestGetLinuxKitTemplate_Bad(t *testing.T) { - t.Run("returns error for unknown template", func(t *testing.T) { - _, err := getLinuxKitTemplate("unknown-template") - assert.Error(t, err) - assert.Contains(t, err.Error(), "template not found") - }) -} - -func TestApplyTemplateVariables_Good(t *testing.T) { - t.Run("replaces variables", func(t *testing.T) { - content := "Hello ${NAME}, welcome to ${PLACE}!" - vars := map[string]string{ - "NAME": "World", - "PLACE": "Earth", - } - - result, err := applyTemplateVariables(content, vars) - assert.NoError(t, err) - assert.Equal(t, "Hello World, welcome to Earth!", result) - }) - - t.Run("handles empty variables", func(t *testing.T) { - content := "No variables here" - vars := map[string]string{} - - result, err := applyTemplateVariables(content, vars) - assert.NoError(t, err) - assert.Equal(t, "No variables here", result) - }) - - t.Run("leaves unmatched placeholders", func(t *testing.T) { - content := "Hello ${NAME}, ${UNKNOWN} is unknown" - vars := map[string]string{ - "NAME": "World", - } - - result, err := applyTemplateVariables(content, vars) - assert.NoError(t, err) - assert.Contains(t, result, "Hello World") - assert.Contains(t, result, "${UNKNOWN}") - }) - - t.Run("handles multiple occurrences", func(t *testing.T) { - content := "${VAR} and ${VAR} again" - vars := map[string]string{ - "VAR": "value", - } - - result, err := applyTemplateVariables(content, vars) - assert.NoError(t, err) - assert.Equal(t, "value and value again", result) - }) -} - -func TestDefaultServerPHPTemplate_Good(t *testing.T) { - t.Run("template has required sections", func(t *testing.T) { - assert.Contains(t, defaultServerPHPTemplate, "kernel:") - assert.Contains(t, defaultServerPHPTemplate, "init:") - assert.Contains(t, defaultServerPHPTemplate, "services:") - assert.Contains(t, defaultServerPHPTemplate, "onboot:") - }) - - t.Run("template contains placeholders", func(t *testing.T) { - assert.Contains(t, defaultServerPHPTemplate, "${SSH_KEY:-}") - }) -} - -func TestBuildDocker_Bad(t *testing.T) { - t.Skip("requires Docker installed") - - t.Run("fails for non-PHP project", func(t *testing.T) { - dir := t.TempDir() - err := BuildDocker(context.TODO(), DockerBuildOptions{ProjectDir: dir}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a PHP project") - }) -} - -func TestBuildLinuxKit_Bad(t *testing.T) { - t.Skip("requires linuxkit installed") - - t.Run("fails for non-PHP project", func(t *testing.T) { - dir := t.TempDir() - err := BuildLinuxKit(context.TODO(), LinuxKitBuildOptions{ProjectDir: dir}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a PHP project") - }) -} - -func TestServeProduction_Bad(t *testing.T) { - t.Run("fails without image name", func(t *testing.T) { - err := ServeProduction(context.TODO(), ServeOptions{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "image name is required") - }) -} - -func TestShell_Bad(t *testing.T) { - t.Run("fails without container ID", func(t *testing.T) { - err := Shell(context.TODO(), "") - assert.Error(t, err) - assert.Contains(t, err.Error(), "container ID is required") - }) -} - -func TestResolveDockerContainerID_Bad(t *testing.T) { - t.Skip("requires Docker installed") -} - -func TestBuildDocker_DefaultOptions(t *testing.T) { - t.Run("sets defaults correctly", func(t *testing.T) { - // This tests the default logic without actually running Docker - opts := DockerBuildOptions{} - - // Verify default values would be set in BuildDocker - if opts.Tag == "" { - opts.Tag = "latest" - } - assert.Equal(t, "latest", opts.Tag) - - if opts.ImageName == "" { - opts.ImageName = filepath.Base("/project/myapp") - } - assert.Equal(t, "myapp", opts.ImageName) - }) -} - -func TestBuildLinuxKit_DefaultOptions(t *testing.T) { - t.Run("sets defaults correctly", func(t *testing.T) { - opts := LinuxKitBuildOptions{} - - // Verify default values would be set - if opts.Template == "" { - opts.Template = "server-php" - } - assert.Equal(t, "server-php", opts.Template) - - if opts.Format == "" { - opts.Format = "qcow2" - } - assert.Equal(t, "qcow2", opts.Format) - }) -} - -func TestServeProduction_DefaultOptions(t *testing.T) { - t.Run("sets defaults correctly", func(t *testing.T) { - opts := ServeOptions{ImageName: "myapp"} - - // Verify default values would be set - if opts.Tag == "" { - opts.Tag = "latest" - } - assert.Equal(t, "latest", opts.Tag) - - if opts.Port == 0 { - opts.Port = 80 - } - assert.Equal(t, 80, opts.Port) - - if opts.HTTPSPort == 0 { - opts.HTTPSPort = 443 - } - assert.Equal(t, 443, opts.HTTPSPort) - }) -} - -func TestLookupLinuxKit_Good(t *testing.T) { - t.Skip("requires linuxkit installed") - - t.Run("finds linuxkit in PATH", func(t *testing.T) { - path, err := lookupLinuxKit() - assert.NoError(t, err) - assert.NotEmpty(t, path) - }) -} - -func TestBuildDocker_WithCustomDockerfile(t *testing.T) { - t.Skip("requires Docker installed") - - t.Run("uses custom Dockerfile when provided", func(t *testing.T) { - dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(`{"name":"test"}`), 0644) - require.NoError(t, err) - - dockerfilePath := filepath.Join(dir, "Dockerfile.custom") - err = os.WriteFile(dockerfilePath, []byte("FROM alpine"), 0644) - require.NoError(t, err) - - opts := DockerBuildOptions{ - ProjectDir: dir, - Dockerfile: dockerfilePath, - } - - // The function would use the custom Dockerfile - assert.Equal(t, dockerfilePath, opts.Dockerfile) - }) -} - -func TestBuildDocker_GeneratesDockerfile(t *testing.T) { - t.Skip("requires Docker installed") - - t.Run("generates Dockerfile when not provided", func(t *testing.T) { - dir := t.TempDir() - - // Create valid PHP project - composerJSON := `{"name":"test","require":{"php":"^8.2","laravel/framework":"^11.0"}}` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - opts := DockerBuildOptions{ - ProjectDir: dir, - // Dockerfile not specified - should be generated - } - - assert.Empty(t, opts.Dockerfile) - }) -} - -func TestServeProduction_BuildsCorrectArgs(t *testing.T) { - t.Run("builds correct docker run arguments", func(t *testing.T) { - opts := ServeOptions{ - ImageName: "myapp", - Tag: "v1.0.0", - ContainerName: "myapp-prod", - Port: 8080, - HTTPSPort: 8443, - Detach: true, - EnvFile: "/path/.env", - Volumes: map[string]string{ - "/host/storage": "/app/storage", - }, - } - - // Verify the expected image reference format - imageRef := opts.ImageName + ":" + opts.Tag - assert.Equal(t, "myapp:v1.0.0", imageRef) - - // Verify port format - portMapping := opts.Port - assert.Equal(t, 8080, portMapping) - }) -} - -func TestShell_Integration(t *testing.T) { - t.Skip("requires Docker with running container") -} - -func TestResolveDockerContainerID_Integration(t *testing.T) { - t.Skip("requires Docker with running containers") -} diff --git a/internal/cmd/php/coolify.go b/internal/cmd/php/coolify.go deleted file mode 100644 index fd08a06c..00000000 --- a/internal/cmd/php/coolify.go +++ /dev/null @@ -1,351 +0,0 @@ -package php - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "os" - "path/filepath" - "strings" - "time" - - "forge.lthn.ai/core/go/pkg/cli" -) - -// CoolifyClient is an HTTP client for the Coolify API. -type CoolifyClient struct { - BaseURL string - Token string - HTTPClient *http.Client -} - -// CoolifyConfig holds configuration loaded from environment. -type CoolifyConfig struct { - URL string - Token string - AppID string - StagingAppID string -} - -// CoolifyDeployment represents a deployment from the Coolify API. -type CoolifyDeployment struct { - ID string `json:"id"` - Status string `json:"status"` - CommitSHA string `json:"commit_sha,omitempty"` - CommitMsg string `json:"commit_message,omitempty"` - Branch string `json:"branch,omitempty"` - CreatedAt time.Time `json:"created_at"` - FinishedAt time.Time `json:"finished_at,omitempty"` - Log string `json:"log,omitempty"` - DeployedURL string `json:"deployed_url,omitempty"` -} - -// CoolifyApp represents an application from the Coolify API. -type CoolifyApp struct { - ID string `json:"id"` - Name string `json:"name"` - FQDN string `json:"fqdn,omitempty"` - Status string `json:"status,omitempty"` - Repository string `json:"repository,omitempty"` - Branch string `json:"branch,omitempty"` - Environment string `json:"environment,omitempty"` -} - -// NewCoolifyClient creates a new Coolify API client. -func NewCoolifyClient(baseURL, token string) *CoolifyClient { - // Ensure baseURL doesn't have trailing slash - baseURL = strings.TrimSuffix(baseURL, "/") - - return &CoolifyClient{ - BaseURL: baseURL, - Token: token, - HTTPClient: &http.Client{ - Timeout: 30 * time.Second, - }, - } -} - -// LoadCoolifyConfig loads Coolify configuration from .env file in the given directory. -func LoadCoolifyConfig(dir string) (*CoolifyConfig, error) { - envPath := filepath.Join(dir, ".env") - return LoadCoolifyConfigFromFile(envPath) -} - -// LoadCoolifyConfigFromFile loads Coolify configuration from a specific .env file. -func LoadCoolifyConfigFromFile(path string) (*CoolifyConfig, error) { - m := getMedium() - config := &CoolifyConfig{} - - // First try environment variables - config.URL = os.Getenv("COOLIFY_URL") - config.Token = os.Getenv("COOLIFY_TOKEN") - config.AppID = os.Getenv("COOLIFY_APP_ID") - config.StagingAppID = os.Getenv("COOLIFY_STAGING_APP_ID") - - // Then try .env file - if !m.Exists(path) { - // No .env file, just use env vars - return validateCoolifyConfig(config) - } - - content, err := m.Read(path) - if err != nil { - return nil, cli.WrapVerb(err, "read", ".env file") - } - - // Parse .env file - lines := strings.Split(content, "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - parts := strings.SplitN(line, "=", 2) - if len(parts) != 2 { - continue - } - - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - // Remove quotes if present - value = strings.Trim(value, `"'`) - - // Only override if not already set from env - switch key { - case "COOLIFY_URL": - if config.URL == "" { - config.URL = value - } - case "COOLIFY_TOKEN": - if config.Token == "" { - config.Token = value - } - case "COOLIFY_APP_ID": - if config.AppID == "" { - config.AppID = value - } - case "COOLIFY_STAGING_APP_ID": - if config.StagingAppID == "" { - config.StagingAppID = value - } - } - } - - return validateCoolifyConfig(config) -} - -// validateCoolifyConfig checks that required fields are set. -func validateCoolifyConfig(config *CoolifyConfig) (*CoolifyConfig, error) { - if config.URL == "" { - return nil, cli.Err("COOLIFY_URL is not set") - } - if config.Token == "" { - return nil, cli.Err("COOLIFY_TOKEN is not set") - } - return config, nil -} - -// TriggerDeploy triggers a deployment for the specified application. -func (c *CoolifyClient) TriggerDeploy(ctx context.Context, appID string, force bool) (*CoolifyDeployment, error) { - endpoint := cli.Sprintf("%s/api/v1/applications/%s/deploy", c.BaseURL, appID) - - payload := map[string]interface{}{} - if force { - payload["force"] = true - } - - body, err := json.Marshal(payload) - if err != nil { - return nil, cli.WrapVerb(err, "marshal", "request") - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return nil, cli.WrapVerb(err, "create", "request") - } - - c.setHeaders(req) - - resp, err := c.HTTPClient.Do(req) - if err != nil { - return nil, cli.Wrap(err, "request failed") - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted { - return nil, c.parseError(resp) - } - - var deployment CoolifyDeployment - if err := json.NewDecoder(resp.Body).Decode(&deployment); err != nil { - // Some Coolify versions return minimal response - return &CoolifyDeployment{ - Status: "queued", - CreatedAt: time.Now(), - }, nil - } - - return &deployment, nil -} - -// GetDeployment retrieves a specific deployment by ID. -func (c *CoolifyClient) GetDeployment(ctx context.Context, appID, deploymentID string) (*CoolifyDeployment, error) { - endpoint := cli.Sprintf("%s/api/v1/applications/%s/deployments/%s", c.BaseURL, appID, deploymentID) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, cli.WrapVerb(err, "create", "request") - } - - c.setHeaders(req) - - resp, err := c.HTTPClient.Do(req) - if err != nil { - return nil, cli.Wrap(err, "request failed") - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, c.parseError(resp) - } - - var deployment CoolifyDeployment - if err := json.NewDecoder(resp.Body).Decode(&deployment); err != nil { - return nil, cli.WrapVerb(err, "decode", "response") - } - - return &deployment, nil -} - -// ListDeployments retrieves deployments for an application. -func (c *CoolifyClient) ListDeployments(ctx context.Context, appID string, limit int) ([]CoolifyDeployment, error) { - endpoint := cli.Sprintf("%s/api/v1/applications/%s/deployments", c.BaseURL, appID) - if limit > 0 { - endpoint = cli.Sprintf("%s?limit=%d", endpoint, limit) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, cli.WrapVerb(err, "create", "request") - } - - c.setHeaders(req) - - resp, err := c.HTTPClient.Do(req) - if err != nil { - return nil, cli.Wrap(err, "request failed") - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, c.parseError(resp) - } - - var deployments []CoolifyDeployment - if err := json.NewDecoder(resp.Body).Decode(&deployments); err != nil { - return nil, cli.WrapVerb(err, "decode", "response") - } - - return deployments, nil -} - -// Rollback triggers a rollback to a previous deployment. -func (c *CoolifyClient) Rollback(ctx context.Context, appID, deploymentID string) (*CoolifyDeployment, error) { - endpoint := cli.Sprintf("%s/api/v1/applications/%s/rollback", c.BaseURL, appID) - - payload := map[string]interface{}{ - "deployment_id": deploymentID, - } - - body, err := json.Marshal(payload) - if err != nil { - return nil, cli.WrapVerb(err, "marshal", "request") - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return nil, cli.WrapVerb(err, "create", "request") - } - - c.setHeaders(req) - - resp, err := c.HTTPClient.Do(req) - if err != nil { - return nil, cli.Wrap(err, "request failed") - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted { - return nil, c.parseError(resp) - } - - var deployment CoolifyDeployment - if err := json.NewDecoder(resp.Body).Decode(&deployment); err != nil { - return &CoolifyDeployment{ - Status: "rolling_back", - CreatedAt: time.Now(), - }, nil - } - - return &deployment, nil -} - -// GetApp retrieves application details. -func (c *CoolifyClient) GetApp(ctx context.Context, appID string) (*CoolifyApp, error) { - endpoint := cli.Sprintf("%s/api/v1/applications/%s", c.BaseURL, appID) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, cli.WrapVerb(err, "create", "request") - } - - c.setHeaders(req) - - resp, err := c.HTTPClient.Do(req) - if err != nil { - return nil, cli.Wrap(err, "request failed") - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, c.parseError(resp) - } - - var app CoolifyApp - if err := json.NewDecoder(resp.Body).Decode(&app); err != nil { - return nil, cli.WrapVerb(err, "decode", "response") - } - - return &app, nil -} - -// setHeaders sets common headers for API requests. -func (c *CoolifyClient) setHeaders(req *http.Request) { - req.Header.Set("Authorization", "Bearer "+c.Token) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") -} - -// parseError extracts error information from an API response. -func (c *CoolifyClient) parseError(resp *http.Response) error { - body, _ := io.ReadAll(resp.Body) - - var errResp struct { - Message string `json:"message"` - Error string `json:"error"` - } - - if err := json.Unmarshal(body, &errResp); err == nil { - if errResp.Message != "" { - return cli.Err("API error (%d): %s", resp.StatusCode, errResp.Message) - } - if errResp.Error != "" { - return cli.Err("API error (%d): %s", resp.StatusCode, errResp.Error) - } - } - - return cli.Err("API error (%d): %s", resp.StatusCode, string(body)) -} diff --git a/internal/cmd/php/coolify_test.go b/internal/cmd/php/coolify_test.go deleted file mode 100644 index 8176c88e..00000000 --- a/internal/cmd/php/coolify_test.go +++ /dev/null @@ -1,502 +0,0 @@ -package php - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestCoolifyClient_Good(t *testing.T) { - t.Run("creates client with correct base URL", func(t *testing.T) { - client := NewCoolifyClient("https://coolify.example.com", "token") - - assert.Equal(t, "https://coolify.example.com", client.BaseURL) - assert.Equal(t, "token", client.Token) - assert.NotNil(t, client.HTTPClient) - }) - - t.Run("strips trailing slash from base URL", func(t *testing.T) { - client := NewCoolifyClient("https://coolify.example.com/", "token") - assert.Equal(t, "https://coolify.example.com", client.BaseURL) - }) - - t.Run("http client has timeout", func(t *testing.T) { - client := NewCoolifyClient("https://coolify.example.com", "token") - assert.Equal(t, 30*time.Second, client.HTTPClient.Timeout) - }) -} - -func TestCoolifyConfig_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - config := CoolifyConfig{ - URL: "https://coolify.example.com", - Token: "secret-token", - AppID: "app-123", - StagingAppID: "staging-456", - } - - assert.Equal(t, "https://coolify.example.com", config.URL) - assert.Equal(t, "secret-token", config.Token) - assert.Equal(t, "app-123", config.AppID) - assert.Equal(t, "staging-456", config.StagingAppID) - }) -} - -func TestCoolifyDeployment_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - now := time.Now() - deployment := CoolifyDeployment{ - ID: "dep-123", - Status: "finished", - CommitSHA: "abc123", - CommitMsg: "Test commit", - Branch: "main", - CreatedAt: now, - FinishedAt: now.Add(5 * time.Minute), - Log: "Build successful", - DeployedURL: "https://app.example.com", - } - - assert.Equal(t, "dep-123", deployment.ID) - assert.Equal(t, "finished", deployment.Status) - assert.Equal(t, "abc123", deployment.CommitSHA) - assert.Equal(t, "Test commit", deployment.CommitMsg) - assert.Equal(t, "main", deployment.Branch) - }) -} - -func TestCoolifyApp_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - app := CoolifyApp{ - ID: "app-123", - Name: "MyApp", - FQDN: "https://myapp.example.com", - Status: "running", - Repository: "https://github.com/user/repo", - Branch: "main", - Environment: "production", - } - - assert.Equal(t, "app-123", app.ID) - assert.Equal(t, "MyApp", app.Name) - assert.Equal(t, "https://myapp.example.com", app.FQDN) - assert.Equal(t, "running", app.Status) - }) -} - -func TestLoadCoolifyConfigFromFile_Good(t *testing.T) { - t.Run("loads config from .env file", func(t *testing.T) { - dir := t.TempDir() - envContent := `COOLIFY_URL=https://coolify.example.com -COOLIFY_TOKEN=secret-token -COOLIFY_APP_ID=app-123 -COOLIFY_STAGING_APP_ID=staging-456` - - err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) - - config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) - assert.NoError(t, err) - assert.Equal(t, "https://coolify.example.com", config.URL) - assert.Equal(t, "secret-token", config.Token) - assert.Equal(t, "app-123", config.AppID) - assert.Equal(t, "staging-456", config.StagingAppID) - }) - - t.Run("handles quoted values", func(t *testing.T) { - dir := t.TempDir() - envContent := `COOLIFY_URL="https://coolify.example.com" -COOLIFY_TOKEN='secret-token'` - - err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) - - config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) - assert.NoError(t, err) - assert.Equal(t, "https://coolify.example.com", config.URL) - assert.Equal(t, "secret-token", config.Token) - }) - - t.Run("ignores comments", func(t *testing.T) { - dir := t.TempDir() - envContent := `# This is a comment -COOLIFY_URL=https://coolify.example.com -# COOLIFY_TOKEN=wrong-token -COOLIFY_TOKEN=correct-token` - - err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) - - config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) - assert.NoError(t, err) - assert.Equal(t, "correct-token", config.Token) - }) - - t.Run("ignores blank lines", func(t *testing.T) { - dir := t.TempDir() - envContent := `COOLIFY_URL=https://coolify.example.com - -COOLIFY_TOKEN=secret-token` - - err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) - - config, err := LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) - assert.NoError(t, err) - assert.Equal(t, "https://coolify.example.com", config.URL) - }) -} - -func TestLoadCoolifyConfigFromFile_Bad(t *testing.T) { - t.Run("fails when COOLIFY_URL missing", func(t *testing.T) { - dir := t.TempDir() - envContent := `COOLIFY_TOKEN=secret-token` - - err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) - - _, err = LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) - assert.Error(t, err) - assert.Contains(t, err.Error(), "COOLIFY_URL is not set") - }) - - t.Run("fails when COOLIFY_TOKEN missing", func(t *testing.T) { - dir := t.TempDir() - envContent := `COOLIFY_URL=https://coolify.example.com` - - err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) - - _, err = LoadCoolifyConfigFromFile(filepath.Join(dir, ".env")) - assert.Error(t, err) - assert.Contains(t, err.Error(), "COOLIFY_TOKEN is not set") - }) -} - -func TestLoadCoolifyConfig_FromDirectory_Good(t *testing.T) { - t.Run("loads from directory", func(t *testing.T) { - dir := t.TempDir() - envContent := `COOLIFY_URL=https://coolify.example.com -COOLIFY_TOKEN=secret-token` - - err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) - - config, err := LoadCoolifyConfig(dir) - assert.NoError(t, err) - assert.Equal(t, "https://coolify.example.com", config.URL) - }) -} - -func TestValidateCoolifyConfig_Bad(t *testing.T) { - t.Run("returns error for empty URL", func(t *testing.T) { - config := &CoolifyConfig{Token: "token"} - _, err := validateCoolifyConfig(config) - assert.Error(t, err) - assert.Contains(t, err.Error(), "COOLIFY_URL is not set") - }) - - t.Run("returns error for empty token", func(t *testing.T) { - config := &CoolifyConfig{URL: "https://coolify.example.com"} - _, err := validateCoolifyConfig(config) - assert.Error(t, err) - assert.Contains(t, err.Error(), "COOLIFY_TOKEN is not set") - }) -} - -func TestCoolifyClient_TriggerDeploy_Good(t *testing.T) { - t.Run("triggers deployment successfully", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v1/applications/app-123/deploy", r.URL.Path) - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "Bearer secret-token", r.Header.Get("Authorization")) - assert.Equal(t, "application/json", r.Header.Get("Content-Type")) - - resp := CoolifyDeployment{ - ID: "dep-456", - Status: "queued", - CreatedAt: time.Now(), - } - _ = json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "secret-token") - deployment, err := client.TriggerDeploy(context.Background(), "app-123", false) - - assert.NoError(t, err) - assert.Equal(t, "dep-456", deployment.ID) - assert.Equal(t, "queued", deployment.Status) - }) - - t.Run("triggers deployment with force", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var body map[string]interface{} - _ = json.NewDecoder(r.Body).Decode(&body) - assert.Equal(t, true, body["force"]) - - resp := CoolifyDeployment{ID: "dep-456", Status: "queued"} - _ = json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "secret-token") - _, err := client.TriggerDeploy(context.Background(), "app-123", true) - assert.NoError(t, err) - }) - - t.Run("handles minimal response", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Return an invalid JSON response to trigger the fallback - _, _ = w.Write([]byte("not json")) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "secret-token") - deployment, err := client.TriggerDeploy(context.Background(), "app-123", false) - - assert.NoError(t, err) - // The fallback response should be returned - assert.Equal(t, "queued", deployment.Status) - }) -} - -func TestCoolifyClient_TriggerDeploy_Bad(t *testing.T) { - t.Run("fails on HTTP error", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - _ = json.NewEncoder(w).Encode(map[string]string{"message": "Internal error"}) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "secret-token") - _, err := client.TriggerDeploy(context.Background(), "app-123", false) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "API error") - }) -} - -func TestCoolifyClient_GetDeployment_Good(t *testing.T) { - t.Run("gets deployment details", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v1/applications/app-123/deployments/dep-456", r.URL.Path) - assert.Equal(t, "GET", r.Method) - - resp := CoolifyDeployment{ - ID: "dep-456", - Status: "finished", - CommitSHA: "abc123", - Branch: "main", - } - _ = json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "secret-token") - deployment, err := client.GetDeployment(context.Background(), "app-123", "dep-456") - - assert.NoError(t, err) - assert.Equal(t, "dep-456", deployment.ID) - assert.Equal(t, "finished", deployment.Status) - assert.Equal(t, "abc123", deployment.CommitSHA) - }) -} - -func TestCoolifyClient_GetDeployment_Bad(t *testing.T) { - t.Run("fails on 404", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - _ = json.NewEncoder(w).Encode(map[string]string{"error": "Not found"}) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "secret-token") - _, err := client.GetDeployment(context.Background(), "app-123", "dep-456") - - assert.Error(t, err) - assert.Contains(t, err.Error(), "Not found") - }) -} - -func TestCoolifyClient_ListDeployments_Good(t *testing.T) { - t.Run("lists deployments", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v1/applications/app-123/deployments", r.URL.Path) - assert.Equal(t, "10", r.URL.Query().Get("limit")) - - resp := []CoolifyDeployment{ - {ID: "dep-1", Status: "finished"}, - {ID: "dep-2", Status: "failed"}, - } - _ = json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "secret-token") - deployments, err := client.ListDeployments(context.Background(), "app-123", 10) - - assert.NoError(t, err) - assert.Len(t, deployments, 2) - assert.Equal(t, "dep-1", deployments[0].ID) - assert.Equal(t, "dep-2", deployments[1].ID) - }) - - t.Run("lists without limit", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "", r.URL.Query().Get("limit")) - _ = json.NewEncoder(w).Encode([]CoolifyDeployment{}) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "secret-token") - _, err := client.ListDeployments(context.Background(), "app-123", 0) - assert.NoError(t, err) - }) -} - -func TestCoolifyClient_Rollback_Good(t *testing.T) { - t.Run("triggers rollback", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v1/applications/app-123/rollback", r.URL.Path) - assert.Equal(t, "POST", r.Method) - - var body map[string]string - _ = json.NewDecoder(r.Body).Decode(&body) - assert.Equal(t, "dep-old", body["deployment_id"]) - - resp := CoolifyDeployment{ - ID: "dep-new", - Status: "rolling_back", - } - _ = json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "secret-token") - deployment, err := client.Rollback(context.Background(), "app-123", "dep-old") - - assert.NoError(t, err) - assert.Equal(t, "dep-new", deployment.ID) - assert.Equal(t, "rolling_back", deployment.Status) - }) -} - -func TestCoolifyClient_GetApp_Good(t *testing.T) { - t.Run("gets app details", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/api/v1/applications/app-123", r.URL.Path) - assert.Equal(t, "GET", r.Method) - - resp := CoolifyApp{ - ID: "app-123", - Name: "MyApp", - FQDN: "https://myapp.example.com", - Status: "running", - } - _ = json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "secret-token") - app, err := client.GetApp(context.Background(), "app-123") - - assert.NoError(t, err) - assert.Equal(t, "app-123", app.ID) - assert.Equal(t, "MyApp", app.Name) - assert.Equal(t, "https://myapp.example.com", app.FQDN) - }) -} - -func TestCoolifyClient_SetHeaders(t *testing.T) { - t.Run("sets all required headers", func(t *testing.T) { - client := NewCoolifyClient("https://coolify.example.com", "my-token") - req, _ := http.NewRequest("GET", "https://coolify.example.com", nil) - - client.setHeaders(req) - - assert.Equal(t, "Bearer my-token", req.Header.Get("Authorization")) - assert.Equal(t, "application/json", req.Header.Get("Content-Type")) - assert.Equal(t, "application/json", req.Header.Get("Accept")) - }) -} - -func TestCoolifyClient_ParseError(t *testing.T) { - t.Run("parses message field", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]string{"message": "Bad request message"}) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "token") - _, err := client.GetApp(context.Background(), "app-123") - - assert.Error(t, err) - assert.Contains(t, err.Error(), "Bad request message") - }) - - t.Run("parses error field", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]string{"error": "Error message"}) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "token") - _, err := client.GetApp(context.Background(), "app-123") - - assert.Error(t, err) - assert.Contains(t, err.Error(), "Error message") - }) - - t.Run("returns raw body when no JSON fields", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte("Raw error message")) - })) - defer server.Close() - - client := NewCoolifyClient(server.URL, "token") - _, err := client.GetApp(context.Background(), "app-123") - - assert.Error(t, err) - assert.Contains(t, err.Error(), "Raw error message") - }) -} - -func TestEnvironmentVariablePriority(t *testing.T) { - t.Run("env vars take precedence over .env file", func(t *testing.T) { - dir := t.TempDir() - envContent := `COOLIFY_URL=https://from-file.com -COOLIFY_TOKEN=file-token` - - err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) - - // Set environment variables - origURL := os.Getenv("COOLIFY_URL") - origToken := os.Getenv("COOLIFY_TOKEN") - defer func() { - _ = os.Setenv("COOLIFY_URL", origURL) - _ = os.Setenv("COOLIFY_TOKEN", origToken) - }() - - _ = os.Setenv("COOLIFY_URL", "https://from-env.com") - _ = os.Setenv("COOLIFY_TOKEN", "env-token") - - config, err := LoadCoolifyConfig(dir) - assert.NoError(t, err) - // Environment variables should take precedence - assert.Equal(t, "https://from-env.com", config.URL) - assert.Equal(t, "env-token", config.Token) - }) -} diff --git a/internal/cmd/php/deploy.go b/internal/cmd/php/deploy.go deleted file mode 100644 index 9717ae70..00000000 --- a/internal/cmd/php/deploy.go +++ /dev/null @@ -1,407 +0,0 @@ -package php - -import ( - "context" - "time" - - "forge.lthn.ai/core/go/pkg/cli" -) - -// Environment represents a deployment environment. -type Environment string - -const ( - // EnvProduction is the production environment. - EnvProduction Environment = "production" - // EnvStaging is the staging environment. - EnvStaging Environment = "staging" -) - -// DeployOptions configures a deployment. -type DeployOptions struct { - // Dir is the project directory containing .env config. - Dir string - - // Environment is the target environment (production or staging). - Environment Environment - - // Force triggers a deployment even if no changes are detected. - Force bool - - // Wait blocks until deployment completes. - Wait bool - - // WaitTimeout is the maximum time to wait for deployment. - // Defaults to 10 minutes. - WaitTimeout time.Duration - - // PollInterval is how often to check deployment status when waiting. - // Defaults to 5 seconds. - PollInterval time.Duration -} - -// StatusOptions configures a status check. -type StatusOptions struct { - // Dir is the project directory containing .env config. - Dir string - - // Environment is the target environment (production or staging). - Environment Environment - - // DeploymentID is a specific deployment to check. - // If empty, returns the latest deployment. - DeploymentID string -} - -// RollbackOptions configures a rollback. -type RollbackOptions struct { - // Dir is the project directory containing .env config. - Dir string - - // Environment is the target environment (production or staging). - Environment Environment - - // DeploymentID is the deployment to rollback to. - // If empty, rolls back to the previous successful deployment. - DeploymentID string - - // Wait blocks until rollback completes. - Wait bool - - // WaitTimeout is the maximum time to wait for rollback. - WaitTimeout time.Duration -} - -// DeploymentStatus represents the status of a deployment. -type DeploymentStatus struct { - // ID is the deployment identifier. - ID string - - // Status is the current deployment status. - // Values: queued, building, deploying, finished, failed, cancelled - Status string - - // URL is the deployed application URL. - URL string - - // Commit is the git commit SHA. - Commit string - - // CommitMessage is the git commit message. - CommitMessage string - - // Branch is the git branch. - Branch string - - // StartedAt is when the deployment started. - StartedAt time.Time - - // CompletedAt is when the deployment completed. - CompletedAt time.Time - - // Log contains deployment logs. - Log string -} - -// Deploy triggers a deployment to Coolify. -func Deploy(ctx context.Context, opts DeployOptions) (*DeploymentStatus, error) { - if opts.Dir == "" { - opts.Dir = "." - } - if opts.Environment == "" { - opts.Environment = EnvProduction - } - if opts.WaitTimeout == 0 { - opts.WaitTimeout = 10 * time.Minute - } - if opts.PollInterval == 0 { - opts.PollInterval = 5 * time.Second - } - - // Load config - config, err := LoadCoolifyConfig(opts.Dir) - if err != nil { - return nil, cli.WrapVerb(err, "load", "Coolify config") - } - - // Get app ID for environment - appID := getAppIDForEnvironment(config, opts.Environment) - if appID == "" { - return nil, cli.Err("no app ID configured for %s environment", opts.Environment) - } - - // Create client - client := NewCoolifyClient(config.URL, config.Token) - - // Trigger deployment - deployment, err := client.TriggerDeploy(ctx, appID, opts.Force) - if err != nil { - return nil, cli.WrapVerb(err, "trigger", "deployment") - } - - status := convertDeployment(deployment) - - // Wait for completion if requested - if opts.Wait && deployment.ID != "" { - status, err = waitForDeployment(ctx, client, appID, deployment.ID, opts.WaitTimeout, opts.PollInterval) - if err != nil { - return status, err - } - } - - // Get app info for URL - app, err := client.GetApp(ctx, appID) - if err == nil && app.FQDN != "" { - status.URL = app.FQDN - } - - return status, nil -} - -// DeployStatus retrieves the status of a deployment. -func DeployStatus(ctx context.Context, opts StatusOptions) (*DeploymentStatus, error) { - if opts.Dir == "" { - opts.Dir = "." - } - if opts.Environment == "" { - opts.Environment = EnvProduction - } - - // Load config - config, err := LoadCoolifyConfig(opts.Dir) - if err != nil { - return nil, cli.WrapVerb(err, "load", "Coolify config") - } - - // Get app ID for environment - appID := getAppIDForEnvironment(config, opts.Environment) - if appID == "" { - return nil, cli.Err("no app ID configured for %s environment", opts.Environment) - } - - // Create client - client := NewCoolifyClient(config.URL, config.Token) - - var deployment *CoolifyDeployment - - if opts.DeploymentID != "" { - // Get specific deployment - deployment, err = client.GetDeployment(ctx, appID, opts.DeploymentID) - if err != nil { - return nil, cli.WrapVerb(err, "get", "deployment") - } - } else { - // Get latest deployment - deployments, err := client.ListDeployments(ctx, appID, 1) - if err != nil { - return nil, cli.WrapVerb(err, "list", "deployments") - } - if len(deployments) == 0 { - return nil, cli.Err("no deployments found") - } - deployment = &deployments[0] - } - - status := convertDeployment(deployment) - - // Get app info for URL - app, err := client.GetApp(ctx, appID) - if err == nil && app.FQDN != "" { - status.URL = app.FQDN - } - - return status, nil -} - -// Rollback triggers a rollback to a previous deployment. -func Rollback(ctx context.Context, opts RollbackOptions) (*DeploymentStatus, error) { - if opts.Dir == "" { - opts.Dir = "." - } - if opts.Environment == "" { - opts.Environment = EnvProduction - } - if opts.WaitTimeout == 0 { - opts.WaitTimeout = 10 * time.Minute - } - - // Load config - config, err := LoadCoolifyConfig(opts.Dir) - if err != nil { - return nil, cli.WrapVerb(err, "load", "Coolify config") - } - - // Get app ID for environment - appID := getAppIDForEnvironment(config, opts.Environment) - if appID == "" { - return nil, cli.Err("no app ID configured for %s environment", opts.Environment) - } - - // Create client - client := NewCoolifyClient(config.URL, config.Token) - - // Find deployment to rollback to - deploymentID := opts.DeploymentID - if deploymentID == "" { - // Find previous successful deployment - deployments, err := client.ListDeployments(ctx, appID, 10) - if err != nil { - return nil, cli.WrapVerb(err, "list", "deployments") - } - - // Skip the first (current) deployment, find the last successful one - for i, d := range deployments { - if i == 0 { - continue // Skip current deployment - } - if d.Status == "finished" || d.Status == "success" { - deploymentID = d.ID - break - } - } - - if deploymentID == "" { - return nil, cli.Err("no previous successful deployment found to rollback to") - } - } - - // Trigger rollback - deployment, err := client.Rollback(ctx, appID, deploymentID) - if err != nil { - return nil, cli.WrapVerb(err, "trigger", "rollback") - } - - status := convertDeployment(deployment) - - // Wait for completion if requested - if opts.Wait && deployment.ID != "" { - status, err = waitForDeployment(ctx, client, appID, deployment.ID, opts.WaitTimeout, 5*time.Second) - if err != nil { - return status, err - } - } - - return status, nil -} - -// ListDeployments retrieves recent deployments. -func ListDeployments(ctx context.Context, dir string, env Environment, limit int) ([]DeploymentStatus, error) { - if dir == "" { - dir = "." - } - if env == "" { - env = EnvProduction - } - if limit == 0 { - limit = 10 - } - - // Load config - config, err := LoadCoolifyConfig(dir) - if err != nil { - return nil, cli.WrapVerb(err, "load", "Coolify config") - } - - // Get app ID for environment - appID := getAppIDForEnvironment(config, env) - if appID == "" { - return nil, cli.Err("no app ID configured for %s environment", env) - } - - // Create client - client := NewCoolifyClient(config.URL, config.Token) - - deployments, err := client.ListDeployments(ctx, appID, limit) - if err != nil { - return nil, cli.WrapVerb(err, "list", "deployments") - } - - result := make([]DeploymentStatus, len(deployments)) - for i, d := range deployments { - result[i] = *convertDeployment(&d) - } - - return result, nil -} - -// getAppIDForEnvironment returns the app ID for the given environment. -func getAppIDForEnvironment(config *CoolifyConfig, env Environment) string { - switch env { - case EnvStaging: - if config.StagingAppID != "" { - return config.StagingAppID - } - return config.AppID // Fallback to production - default: - return config.AppID - } -} - -// convertDeployment converts a CoolifyDeployment to DeploymentStatus. -func convertDeployment(d *CoolifyDeployment) *DeploymentStatus { - return &DeploymentStatus{ - ID: d.ID, - Status: d.Status, - URL: d.DeployedURL, - Commit: d.CommitSHA, - CommitMessage: d.CommitMsg, - Branch: d.Branch, - StartedAt: d.CreatedAt, - CompletedAt: d.FinishedAt, - Log: d.Log, - } -} - -// waitForDeployment polls for deployment completion. -func waitForDeployment(ctx context.Context, client *CoolifyClient, appID, deploymentID string, timeout, interval time.Duration) (*DeploymentStatus, error) { - deadline := time.Now().Add(timeout) - - for time.Now().Before(deadline) { - select { - case <-ctx.Done(): - return nil, ctx.Err() - default: - } - - deployment, err := client.GetDeployment(ctx, appID, deploymentID) - if err != nil { - return nil, cli.WrapVerb(err, "get", "deployment status") - } - - status := convertDeployment(deployment) - - // Check if deployment is complete - switch deployment.Status { - case "finished", "success": - return status, nil - case "failed", "error": - return status, cli.Err("deployment failed: %s", deployment.Status) - case "cancelled": - return status, cli.Err("deployment was cancelled") - } - - // Still in progress, wait and retry - select { - case <-ctx.Done(): - return status, ctx.Err() - case <-time.After(interval): - } - } - - return nil, cli.Err("deployment timed out after %v", timeout) -} - -// IsDeploymentComplete returns true if the status indicates completion. -func IsDeploymentComplete(status string) bool { - switch status { - case "finished", "success", "failed", "error", "cancelled": - return true - default: - return false - } -} - -// IsDeploymentSuccessful returns true if the status indicates success. -func IsDeploymentSuccessful(status string) bool { - return status == "finished" || status == "success" -} diff --git a/internal/cmd/php/deploy_internal_test.go b/internal/cmd/php/deploy_internal_test.go deleted file mode 100644 index 9362aaf5..00000000 --- a/internal/cmd/php/deploy_internal_test.go +++ /dev/null @@ -1,221 +0,0 @@ -package php - -import ( - "testing" - "time" - - "github.com/stretchr/testify/assert" -) - -func TestConvertDeployment_Good(t *testing.T) { - t.Run("converts all fields", func(t *testing.T) { - now := time.Now() - coolify := &CoolifyDeployment{ - ID: "dep-123", - Status: "finished", - CommitSHA: "abc123", - CommitMsg: "Test commit", - Branch: "main", - CreatedAt: now, - FinishedAt: now.Add(5 * time.Minute), - Log: "Build successful", - DeployedURL: "https://app.example.com", - } - - status := convertDeployment(coolify) - - assert.Equal(t, "dep-123", status.ID) - assert.Equal(t, "finished", status.Status) - assert.Equal(t, "https://app.example.com", status.URL) - assert.Equal(t, "abc123", status.Commit) - assert.Equal(t, "Test commit", status.CommitMessage) - assert.Equal(t, "main", status.Branch) - assert.Equal(t, now, status.StartedAt) - assert.Equal(t, now.Add(5*time.Minute), status.CompletedAt) - assert.Equal(t, "Build successful", status.Log) - }) - - t.Run("handles empty deployment", func(t *testing.T) { - coolify := &CoolifyDeployment{} - status := convertDeployment(coolify) - - assert.Empty(t, status.ID) - assert.Empty(t, status.Status) - }) -} - -func TestDeploymentStatus_Struct_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - now := time.Now() - status := DeploymentStatus{ - ID: "dep-123", - Status: "finished", - URL: "https://app.example.com", - Commit: "abc123", - CommitMessage: "Test commit", - Branch: "main", - StartedAt: now, - CompletedAt: now.Add(5 * time.Minute), - Log: "Build log", - } - - assert.Equal(t, "dep-123", status.ID) - assert.Equal(t, "finished", status.Status) - assert.Equal(t, "https://app.example.com", status.URL) - assert.Equal(t, "abc123", status.Commit) - assert.Equal(t, "Test commit", status.CommitMessage) - assert.Equal(t, "main", status.Branch) - assert.Equal(t, "Build log", status.Log) - }) -} - -func TestDeployOptions_Struct_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - opts := DeployOptions{ - Dir: "/project", - Environment: EnvProduction, - Force: true, - Wait: true, - WaitTimeout: 10 * time.Minute, - PollInterval: 5 * time.Second, - } - - assert.Equal(t, "/project", opts.Dir) - assert.Equal(t, EnvProduction, opts.Environment) - assert.True(t, opts.Force) - assert.True(t, opts.Wait) - assert.Equal(t, 10*time.Minute, opts.WaitTimeout) - assert.Equal(t, 5*time.Second, opts.PollInterval) - }) -} - -func TestStatusOptions_Struct_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - opts := StatusOptions{ - Dir: "/project", - Environment: EnvStaging, - DeploymentID: "dep-123", - } - - assert.Equal(t, "/project", opts.Dir) - assert.Equal(t, EnvStaging, opts.Environment) - assert.Equal(t, "dep-123", opts.DeploymentID) - }) -} - -func TestRollbackOptions_Struct_Good(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - opts := RollbackOptions{ - Dir: "/project", - Environment: EnvProduction, - DeploymentID: "dep-old", - Wait: true, - WaitTimeout: 5 * time.Minute, - } - - assert.Equal(t, "/project", opts.Dir) - assert.Equal(t, EnvProduction, opts.Environment) - assert.Equal(t, "dep-old", opts.DeploymentID) - assert.True(t, opts.Wait) - assert.Equal(t, 5*time.Minute, opts.WaitTimeout) - }) -} - -func TestEnvironment_Constants(t *testing.T) { - t.Run("constants are defined", func(t *testing.T) { - assert.Equal(t, Environment("production"), EnvProduction) - assert.Equal(t, Environment("staging"), EnvStaging) - }) -} - -func TestGetAppIDForEnvironment_Edge(t *testing.T) { - t.Run("staging without staging ID falls back to production", func(t *testing.T) { - config := &CoolifyConfig{ - AppID: "prod-123", - // No StagingAppID set - } - - id := getAppIDForEnvironment(config, EnvStaging) - assert.Equal(t, "prod-123", id) - }) - - t.Run("staging with staging ID uses staging", func(t *testing.T) { - config := &CoolifyConfig{ - AppID: "prod-123", - StagingAppID: "staging-456", - } - - id := getAppIDForEnvironment(config, EnvStaging) - assert.Equal(t, "staging-456", id) - }) - - t.Run("production uses production ID", func(t *testing.T) { - config := &CoolifyConfig{ - AppID: "prod-123", - StagingAppID: "staging-456", - } - - id := getAppIDForEnvironment(config, EnvProduction) - assert.Equal(t, "prod-123", id) - }) - - t.Run("unknown environment uses production", func(t *testing.T) { - config := &CoolifyConfig{ - AppID: "prod-123", - } - - id := getAppIDForEnvironment(config, "unknown") - assert.Equal(t, "prod-123", id) - }) -} - -func TestIsDeploymentComplete_Edge(t *testing.T) { - tests := []struct { - status string - expected bool - }{ - {"finished", true}, - {"success", true}, - {"failed", true}, - {"error", true}, - {"cancelled", true}, - {"queued", false}, - {"building", false}, - {"deploying", false}, - {"pending", false}, - {"rolling_back", false}, - {"", false}, - {"unknown", false}, - } - - for _, tt := range tests { - t.Run(tt.status, func(t *testing.T) { - result := IsDeploymentComplete(tt.status) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestIsDeploymentSuccessful_Edge(t *testing.T) { - tests := []struct { - status string - expected bool - }{ - {"finished", true}, - {"success", true}, - {"failed", false}, - {"error", false}, - {"cancelled", false}, - {"queued", false}, - {"building", false}, - {"deploying", false}, - {"", false}, - } - - for _, tt := range tests { - t.Run(tt.status, func(t *testing.T) { - result := IsDeploymentSuccessful(tt.status) - assert.Equal(t, tt.expected, result) - }) - } -} diff --git a/internal/cmd/php/deploy_test.go b/internal/cmd/php/deploy_test.go deleted file mode 100644 index 228de7d2..00000000 --- a/internal/cmd/php/deploy_test.go +++ /dev/null @@ -1,257 +0,0 @@ -package php - -import ( - "os" - "path/filepath" - "testing" -) - -func TestLoadCoolifyConfig_Good(t *testing.T) { - tests := []struct { - name string - envContent string - wantURL string - wantToken string - wantAppID string - wantStaging string - }{ - { - name: "all values set", - envContent: `COOLIFY_URL=https://coolify.example.com -COOLIFY_TOKEN=secret-token -COOLIFY_APP_ID=app-123 -COOLIFY_STAGING_APP_ID=staging-456`, - wantURL: "https://coolify.example.com", - wantToken: "secret-token", - wantAppID: "app-123", - wantStaging: "staging-456", - }, - { - name: "quoted values", - envContent: `COOLIFY_URL="https://coolify.example.com" -COOLIFY_TOKEN='secret-token' -COOLIFY_APP_ID="app-123"`, - wantURL: "https://coolify.example.com", - wantToken: "secret-token", - wantAppID: "app-123", - }, - { - name: "with comments and blank lines", - envContent: `# Coolify configuration -COOLIFY_URL=https://coolify.example.com - -# API token -COOLIFY_TOKEN=secret-token -COOLIFY_APP_ID=app-123 -# COOLIFY_STAGING_APP_ID=not-this`, - wantURL: "https://coolify.example.com", - wantToken: "secret-token", - wantAppID: "app-123", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create temp directory - dir := t.TempDir() - envPath := filepath.Join(dir, ".env") - - // Write .env file - if err := os.WriteFile(envPath, []byte(tt.envContent), 0644); err != nil { - t.Fatalf("failed to write .env: %v", err) - } - - // Load config - config, err := LoadCoolifyConfig(dir) - if err != nil { - t.Fatalf("LoadCoolifyConfig() error = %v", err) - } - - if config.URL != tt.wantURL { - t.Errorf("URL = %q, want %q", config.URL, tt.wantURL) - } - if config.Token != tt.wantToken { - t.Errorf("Token = %q, want %q", config.Token, tt.wantToken) - } - if config.AppID != tt.wantAppID { - t.Errorf("AppID = %q, want %q", config.AppID, tt.wantAppID) - } - if tt.wantStaging != "" && config.StagingAppID != tt.wantStaging { - t.Errorf("StagingAppID = %q, want %q", config.StagingAppID, tt.wantStaging) - } - }) - } -} - -func TestLoadCoolifyConfig_Bad(t *testing.T) { - tests := []struct { - name string - envContent string - wantErr string - }{ - { - name: "missing URL", - envContent: "COOLIFY_TOKEN=secret", - wantErr: "COOLIFY_URL is not set", - }, - { - name: "missing token", - envContent: "COOLIFY_URL=https://coolify.example.com", - wantErr: "COOLIFY_TOKEN is not set", - }, - { - name: "empty values", - envContent: "COOLIFY_URL=\nCOOLIFY_TOKEN=", - wantErr: "COOLIFY_URL is not set", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create temp directory - dir := t.TempDir() - envPath := filepath.Join(dir, ".env") - - // Write .env file - if err := os.WriteFile(envPath, []byte(tt.envContent), 0644); err != nil { - t.Fatalf("failed to write .env: %v", err) - } - - // Load config - _, err := LoadCoolifyConfig(dir) - if err == nil { - t.Fatal("LoadCoolifyConfig() expected error, got nil") - } - - if err.Error() != tt.wantErr { - t.Errorf("error = %q, want %q", err.Error(), tt.wantErr) - } - }) - } -} - -func TestGetAppIDForEnvironment_Good(t *testing.T) { - config := &CoolifyConfig{ - URL: "https://coolify.example.com", - Token: "token", - AppID: "prod-123", - StagingAppID: "staging-456", - } - - tests := []struct { - name string - env Environment - wantID string - }{ - { - name: "production environment", - env: EnvProduction, - wantID: "prod-123", - }, - { - name: "staging environment", - env: EnvStaging, - wantID: "staging-456", - }, - { - name: "empty defaults to production", - env: "", - wantID: "prod-123", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - id := getAppIDForEnvironment(config, tt.env) - if id != tt.wantID { - t.Errorf("getAppIDForEnvironment() = %q, want %q", id, tt.wantID) - } - }) - } -} - -func TestGetAppIDForEnvironment_FallbackToProduction(t *testing.T) { - config := &CoolifyConfig{ - URL: "https://coolify.example.com", - Token: "token", - AppID: "prod-123", - // No staging app ID - } - - // Staging should fall back to production - id := getAppIDForEnvironment(config, EnvStaging) - if id != "prod-123" { - t.Errorf("getAppIDForEnvironment(EnvStaging) = %q, want %q (should fallback)", id, "prod-123") - } -} - -func TestIsDeploymentComplete_Good(t *testing.T) { - completeStatuses := []string{"finished", "success", "failed", "error", "cancelled"} - for _, status := range completeStatuses { - if !IsDeploymentComplete(status) { - t.Errorf("IsDeploymentComplete(%q) = false, want true", status) - } - } - - incompleteStatuses := []string{"queued", "building", "deploying", "pending", "rolling_back"} - for _, status := range incompleteStatuses { - if IsDeploymentComplete(status) { - t.Errorf("IsDeploymentComplete(%q) = true, want false", status) - } - } -} - -func TestIsDeploymentSuccessful_Good(t *testing.T) { - successStatuses := []string{"finished", "success"} - for _, status := range successStatuses { - if !IsDeploymentSuccessful(status) { - t.Errorf("IsDeploymentSuccessful(%q) = false, want true", status) - } - } - - failedStatuses := []string{"failed", "error", "cancelled", "queued", "building"} - for _, status := range failedStatuses { - if IsDeploymentSuccessful(status) { - t.Errorf("IsDeploymentSuccessful(%q) = true, want false", status) - } - } -} - -func TestNewCoolifyClient_Good(t *testing.T) { - tests := []struct { - name string - baseURL string - wantBaseURL string - }{ - { - name: "URL without trailing slash", - baseURL: "https://coolify.example.com", - wantBaseURL: "https://coolify.example.com", - }, - { - name: "URL with trailing slash", - baseURL: "https://coolify.example.com/", - wantBaseURL: "https://coolify.example.com", - }, - { - name: "URL with api path", - baseURL: "https://coolify.example.com/api/", - wantBaseURL: "https://coolify.example.com/api", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client := NewCoolifyClient(tt.baseURL, "token") - if client.BaseURL != tt.wantBaseURL { - t.Errorf("BaseURL = %q, want %q", client.BaseURL, tt.wantBaseURL) - } - if client.Token != "token" { - t.Errorf("Token = %q, want %q", client.Token, "token") - } - if client.HTTPClient == nil { - t.Error("HTTPClient is nil") - } - }) - } -} diff --git a/internal/cmd/php/detect.go b/internal/cmd/php/detect.go deleted file mode 100644 index c13da9d7..00000000 --- a/internal/cmd/php/detect.go +++ /dev/null @@ -1,296 +0,0 @@ -package php - -import ( - "encoding/json" - "path/filepath" - "strings" -) - -// DetectedService represents a service that was detected in a Laravel project. -type DetectedService string - -// Detected service constants for Laravel projects. -const ( - // ServiceFrankenPHP indicates FrankenPHP server is detected. - ServiceFrankenPHP DetectedService = "frankenphp" - // ServiceVite indicates Vite frontend bundler is detected. - ServiceVite DetectedService = "vite" - // ServiceHorizon indicates Laravel Horizon queue dashboard is detected. - ServiceHorizon DetectedService = "horizon" - // ServiceReverb indicates Laravel Reverb WebSocket server is detected. - ServiceReverb DetectedService = "reverb" - // ServiceRedis indicates Redis cache/queue backend is detected. - ServiceRedis DetectedService = "redis" -) - -// IsLaravelProject checks if the given directory is a Laravel project. -// It looks for the presence of artisan file and laravel in composer.json. -func IsLaravelProject(dir string) bool { - m := getMedium() - - // Check for artisan file - artisanPath := filepath.Join(dir, "artisan") - if !m.Exists(artisanPath) { - return false - } - - // Check composer.json for laravel/framework - composerPath := filepath.Join(dir, "composer.json") - data, err := m.Read(composerPath) - if err != nil { - return false - } - - var composer struct { - Require map[string]string `json:"require"` - RequireDev map[string]string `json:"require-dev"` - } - - if err := json.Unmarshal([]byte(data), &composer); err != nil { - return false - } - - // Check for laravel/framework in require - if _, ok := composer.Require["laravel/framework"]; ok { - return true - } - - // Also check require-dev (less common but possible) - if _, ok := composer.RequireDev["laravel/framework"]; ok { - return true - } - - return false -} - -// IsFrankenPHPProject checks if the project is configured for FrankenPHP. -// It looks for laravel/octane with frankenphp driver. -func IsFrankenPHPProject(dir string) bool { - m := getMedium() - - // Check composer.json for laravel/octane - composerPath := filepath.Join(dir, "composer.json") - data, err := m.Read(composerPath) - if err != nil { - return false - } - - var composer struct { - Require map[string]string `json:"require"` - } - - if err := json.Unmarshal([]byte(data), &composer); err != nil { - return false - } - - if _, ok := composer.Require["laravel/octane"]; !ok { - return false - } - - // Check octane config for frankenphp - configPath := filepath.Join(dir, "config", "octane.php") - if !m.Exists(configPath) { - // If no config exists but octane is installed, assume frankenphp - return true - } - - configData, err := m.Read(configPath) - if err != nil { - return true // Assume frankenphp if we can't read config - } - - // Look for frankenphp in the config - return strings.Contains(configData, "frankenphp") -} - -// DetectServices detects which services are needed based on project files. -func DetectServices(dir string) []DetectedService { - services := []DetectedService{} - - // FrankenPHP/Octane is always needed for a Laravel dev environment - if IsFrankenPHPProject(dir) || IsLaravelProject(dir) { - services = append(services, ServiceFrankenPHP) - } - - // Check for Vite - if hasVite(dir) { - services = append(services, ServiceVite) - } - - // Check for Horizon - if hasHorizon(dir) { - services = append(services, ServiceHorizon) - } - - // Check for Reverb - if hasReverb(dir) { - services = append(services, ServiceReverb) - } - - // Check for Redis - if needsRedis(dir) { - services = append(services, ServiceRedis) - } - - return services -} - -// hasVite checks if the project uses Vite. -func hasVite(dir string) bool { - m := getMedium() - viteConfigs := []string{ - "vite.config.js", - "vite.config.ts", - "vite.config.mjs", - "vite.config.mts", - } - - for _, config := range viteConfigs { - if m.Exists(filepath.Join(dir, config)) { - return true - } - } - - return false -} - -// hasHorizon checks if Laravel Horizon is configured. -func hasHorizon(dir string) bool { - horizonConfig := filepath.Join(dir, "config", "horizon.php") - return getMedium().Exists(horizonConfig) -} - -// hasReverb checks if Laravel Reverb is configured. -func hasReverb(dir string) bool { - reverbConfig := filepath.Join(dir, "config", "reverb.php") - return getMedium().Exists(reverbConfig) -} - -// needsRedis checks if the project uses Redis based on .env configuration. -func needsRedis(dir string) bool { - m := getMedium() - envPath := filepath.Join(dir, ".env") - content, err := m.Read(envPath) - if err != nil { - return false - } - - lines := strings.Split(content, "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "#") { - continue - } - - // Check for Redis-related environment variables - redisIndicators := []string{ - "REDIS_HOST=", - "CACHE_DRIVER=redis", - "QUEUE_CONNECTION=redis", - "SESSION_DRIVER=redis", - "BROADCAST_DRIVER=redis", - } - - for _, indicator := range redisIndicators { - if strings.HasPrefix(line, indicator) { - // Check if it's set to localhost or 127.0.0.1 - if strings.Contains(line, "127.0.0.1") || strings.Contains(line, "localhost") || - indicator != "REDIS_HOST=" { - return true - } - } - } - } - - return false -} - -// DetectPackageManager detects which package manager is used in the project. -// Returns "npm", "pnpm", "yarn", or "bun". -func DetectPackageManager(dir string) string { - m := getMedium() - // Check for lock files in order of preference - lockFiles := []struct { - file string - manager string - }{ - {"bun.lockb", "bun"}, - {"pnpm-lock.yaml", "pnpm"}, - {"yarn.lock", "yarn"}, - {"package-lock.json", "npm"}, - } - - for _, lf := range lockFiles { - if m.Exists(filepath.Join(dir, lf.file)) { - return lf.manager - } - } - - // Default to npm if no lock file found - return "npm" -} - -// GetLaravelAppName extracts the application name from Laravel's .env file. -func GetLaravelAppName(dir string) string { - m := getMedium() - envPath := filepath.Join(dir, ".env") - content, err := m.Read(envPath) - if err != nil { - return "" - } - - lines := strings.Split(content, "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "APP_NAME=") { - value := strings.TrimPrefix(line, "APP_NAME=") - // Remove quotes if present - value = strings.Trim(value, `"'`) - return value - } - } - - return "" -} - -// GetLaravelAppURL extracts the application URL from Laravel's .env file. -func GetLaravelAppURL(dir string) string { - m := getMedium() - envPath := filepath.Join(dir, ".env") - content, err := m.Read(envPath) - if err != nil { - return "" - } - - lines := strings.Split(content, "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "APP_URL=") { - value := strings.TrimPrefix(line, "APP_URL=") - // Remove quotes if present - value = strings.Trim(value, `"'`) - return value - } - } - - return "" -} - -// ExtractDomainFromURL extracts the domain from a URL string. -func ExtractDomainFromURL(url string) string { - // Remove protocol - domain := strings.TrimPrefix(url, "https://") - domain = strings.TrimPrefix(domain, "http://") - - // Remove port if present - if idx := strings.Index(domain, ":"); idx != -1 { - domain = domain[:idx] - } - - // Remove path if present - if idx := strings.Index(domain, "/"); idx != -1 { - domain = domain[:idx] - } - - return domain -} diff --git a/internal/cmd/php/detect_test.go b/internal/cmd/php/detect_test.go deleted file mode 100644 index 9b72f843..00000000 --- a/internal/cmd/php/detect_test.go +++ /dev/null @@ -1,663 +0,0 @@ -package php - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestIsLaravelProject_Good(t *testing.T) { - t.Run("valid Laravel project with artisan and composer.json", func(t *testing.T) { - dir := t.TempDir() - - // Create artisan file - artisanPath := filepath.Join(dir, "artisan") - err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) - require.NoError(t, err) - - // Create composer.json with laravel/framework - composerJSON := `{ - "name": "test/laravel-project", - "require": { - "php": "^8.2", - "laravel/framework": "^11.0" - } - }` - composerPath := filepath.Join(dir, "composer.json") - err = os.WriteFile(composerPath, []byte(composerJSON), 0644) - require.NoError(t, err) - - assert.True(t, IsLaravelProject(dir)) - }) - - t.Run("Laravel in require-dev", func(t *testing.T) { - dir := t.TempDir() - - // Create artisan file - artisanPath := filepath.Join(dir, "artisan") - err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) - require.NoError(t, err) - - // Create composer.json with laravel/framework in require-dev - composerJSON := `{ - "name": "test/laravel-project", - "require-dev": { - "laravel/framework": "^11.0" - } - }` - composerPath := filepath.Join(dir, "composer.json") - err = os.WriteFile(composerPath, []byte(composerJSON), 0644) - require.NoError(t, err) - - assert.True(t, IsLaravelProject(dir)) - }) -} - -func TestIsLaravelProject_Bad(t *testing.T) { - t.Run("missing artisan file", func(t *testing.T) { - dir := t.TempDir() - - // Create composer.json but no artisan - composerJSON := `{ - "name": "test/laravel-project", - "require": { - "laravel/framework": "^11.0" - } - }` - composerPath := filepath.Join(dir, "composer.json") - err := os.WriteFile(composerPath, []byte(composerJSON), 0644) - require.NoError(t, err) - - assert.False(t, IsLaravelProject(dir)) - }) - - t.Run("missing composer.json", func(t *testing.T) { - dir := t.TempDir() - - // Create artisan but no composer.json - artisanPath := filepath.Join(dir, "artisan") - err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) - require.NoError(t, err) - - assert.False(t, IsLaravelProject(dir)) - }) - - t.Run("composer.json without Laravel", func(t *testing.T) { - dir := t.TempDir() - - // Create artisan file - artisanPath := filepath.Join(dir, "artisan") - err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) - require.NoError(t, err) - - // Create composer.json without laravel/framework - composerJSON := `{ - "name": "test/symfony-project", - "require": { - "symfony/framework-bundle": "^7.0" - } - }` - composerPath := filepath.Join(dir, "composer.json") - err = os.WriteFile(composerPath, []byte(composerJSON), 0644) - require.NoError(t, err) - - assert.False(t, IsLaravelProject(dir)) - }) - - t.Run("invalid composer.json", func(t *testing.T) { - dir := t.TempDir() - - // Create artisan file - artisanPath := filepath.Join(dir, "artisan") - err := os.WriteFile(artisanPath, []byte("#!/usr/bin/env php\n"), 0755) - require.NoError(t, err) - - // Create invalid composer.json - composerPath := filepath.Join(dir, "composer.json") - err = os.WriteFile(composerPath, []byte("not valid json{"), 0644) - require.NoError(t, err) - - assert.False(t, IsLaravelProject(dir)) - }) - - t.Run("empty directory", func(t *testing.T) { - dir := t.TempDir() - assert.False(t, IsLaravelProject(dir)) - }) - - t.Run("non-existent directory", func(t *testing.T) { - assert.False(t, IsLaravelProject("/non/existent/path")) - }) -} - -func TestIsFrankenPHPProject_Good(t *testing.T) { - t.Run("project with octane and frankenphp config", func(t *testing.T) { - dir := t.TempDir() - - // Create composer.json with laravel/octane - composerJSON := `{ - "require": { - "laravel/octane": "^2.0" - } - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - // Create config directory and octane.php - configDir := filepath.Join(dir, "config") - err = os.MkdirAll(configDir, 0755) - require.NoError(t, err) - - octaneConfig := ` 'frankenphp', -];` - err = os.WriteFile(filepath.Join(configDir, "octane.php"), []byte(octaneConfig), 0644) - require.NoError(t, err) - - assert.True(t, IsFrankenPHPProject(dir)) - }) - - t.Run("project with octane but no config file", func(t *testing.T) { - dir := t.TempDir() - - // Create composer.json with laravel/octane - composerJSON := `{ - "require": { - "laravel/octane": "^2.0" - } - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - // No config file - should still return true (assume frankenphp) - assert.True(t, IsFrankenPHPProject(dir)) - }) - - t.Run("project with octane but unreadable config file", func(t *testing.T) { - if os.Geteuid() == 0 { - t.Skip("root can read any file") - } - dir := t.TempDir() - - // Create composer.json with laravel/octane - composerJSON := `{ - "require": { - "laravel/octane": "^2.0" - } - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - // Create config directory and octane.php with no read permissions - configDir := filepath.Join(dir, "config") - err = os.MkdirAll(configDir, 0755) - require.NoError(t, err) - - octanePath := filepath.Join(configDir, "octane.php") - err = os.WriteFile(octanePath, []byte(" 'swoole', -];` - err = os.WriteFile(filepath.Join(configDir, "octane.php"), []byte(octaneConfig), 0644) - require.NoError(t, err) - - assert.False(t, IsFrankenPHPProject(dir)) - }) -} diff --git a/internal/cmd/php/dockerfile.go b/internal/cmd/php/dockerfile.go deleted file mode 100644 index be7afd1a..00000000 --- a/internal/cmd/php/dockerfile.go +++ /dev/null @@ -1,398 +0,0 @@ -package php - -import ( - "encoding/json" - "path/filepath" - "sort" - "strings" - - "forge.lthn.ai/core/go/pkg/cli" -) - -// DockerfileConfig holds configuration for generating a Dockerfile. -type DockerfileConfig struct { - // PHPVersion is the PHP version to use (default: "8.3"). - PHPVersion string - - // BaseImage is the base Docker image (default: "dunglas/frankenphp"). - BaseImage string - - // PHPExtensions is the list of PHP extensions to install. - PHPExtensions []string - - // HasAssets indicates if the project has frontend assets to build. - HasAssets bool - - // PackageManager is the Node.js package manager (npm, pnpm, yarn, bun). - PackageManager string - - // IsLaravel indicates if this is a Laravel project. - IsLaravel bool - - // HasOctane indicates if Laravel Octane is installed. - HasOctane bool - - // UseAlpine uses the Alpine-based image (smaller). - UseAlpine bool -} - -// GenerateDockerfile generates a Dockerfile for a PHP/Laravel project. -// It auto-detects dependencies from composer.json and project structure. -func GenerateDockerfile(dir string) (string, error) { - config, err := DetectDockerfileConfig(dir) - if err != nil { - return "", err - } - - return GenerateDockerfileFromConfig(config), nil -} - -// DetectDockerfileConfig detects configuration from project files. -func DetectDockerfileConfig(dir string) (*DockerfileConfig, error) { - m := getMedium() - config := &DockerfileConfig{ - PHPVersion: "8.3", - BaseImage: "dunglas/frankenphp", - UseAlpine: true, - } - - // Read composer.json - composerPath := filepath.Join(dir, "composer.json") - composerContent, err := m.Read(composerPath) - if err != nil { - return nil, cli.WrapVerb(err, "read", "composer.json") - } - - var composer ComposerJSON - if err := json.Unmarshal([]byte(composerContent), &composer); err != nil { - return nil, cli.WrapVerb(err, "parse", "composer.json") - } - - // Detect PHP version from composer.json - if phpVersion, ok := composer.Require["php"]; ok { - config.PHPVersion = extractPHPVersion(phpVersion) - } - - // Detect if Laravel - if _, ok := composer.Require["laravel/framework"]; ok { - config.IsLaravel = true - } - - // Detect if Octane - if _, ok := composer.Require["laravel/octane"]; ok { - config.HasOctane = true - } - - // Detect required PHP extensions - config.PHPExtensions = detectPHPExtensions(composer) - - // Detect frontend assets - config.HasAssets = hasNodeAssets(dir) - if config.HasAssets { - config.PackageManager = DetectPackageManager(dir) - } - - return config, nil -} - -// GenerateDockerfileFromConfig generates a Dockerfile from the given configuration. -func GenerateDockerfileFromConfig(config *DockerfileConfig) string { - var sb strings.Builder - - // Base image - baseTag := cli.Sprintf("latest-php%s", config.PHPVersion) - if config.UseAlpine { - baseTag += "-alpine" - } - - sb.WriteString("# Auto-generated Dockerfile for FrankenPHP\n") - sb.WriteString("# Generated by Core Framework\n\n") - - // Multi-stage build for smaller images - if config.HasAssets { - // Frontend build stage - sb.WriteString("# Stage 1: Build frontend assets\n") - sb.WriteString("FROM node:20-alpine AS frontend\n\n") - sb.WriteString("WORKDIR /app\n\n") - - // Copy package files based on package manager - switch config.PackageManager { - case "pnpm": - sb.WriteString("RUN corepack enable && corepack prepare pnpm@latest --activate\n\n") - sb.WriteString("COPY package.json pnpm-lock.yaml ./\n") - sb.WriteString("RUN pnpm install --frozen-lockfile\n\n") - case "yarn": - sb.WriteString("COPY package.json yarn.lock ./\n") - sb.WriteString("RUN yarn install --frozen-lockfile\n\n") - case "bun": - sb.WriteString("RUN npm install -g bun\n\n") - sb.WriteString("COPY package.json bun.lockb ./\n") - sb.WriteString("RUN bun install --frozen-lockfile\n\n") - default: // npm - sb.WriteString("COPY package.json package-lock.json ./\n") - sb.WriteString("RUN npm ci\n\n") - } - - sb.WriteString("COPY . .\n\n") - - // Build command - switch config.PackageManager { - case "pnpm": - sb.WriteString("RUN pnpm run build\n\n") - case "yarn": - sb.WriteString("RUN yarn build\n\n") - case "bun": - sb.WriteString("RUN bun run build\n\n") - default: - sb.WriteString("RUN npm run build\n\n") - } - } - - // PHP build stage - stageNum := 2 - if config.HasAssets { - sb.WriteString(cli.Sprintf("# Stage %d: PHP application\n", stageNum)) - } - sb.WriteString(cli.Sprintf("FROM %s:%s AS app\n\n", config.BaseImage, baseTag)) - - sb.WriteString("WORKDIR /app\n\n") - - // Install PHP extensions if needed - if len(config.PHPExtensions) > 0 { - sb.WriteString("# Install PHP extensions\n") - sb.WriteString(cli.Sprintf("RUN install-php-extensions %s\n\n", strings.Join(config.PHPExtensions, " "))) - } - - // Copy composer files first for better caching - sb.WriteString("# Copy composer files\n") - sb.WriteString("COPY composer.json composer.lock ./\n\n") - - // Install composer dependencies - sb.WriteString("# Install PHP dependencies\n") - sb.WriteString("RUN composer install --no-dev --no-scripts --optimize-autoloader --no-interaction\n\n") - - // Copy application code - sb.WriteString("# Copy application code\n") - sb.WriteString("COPY . .\n\n") - - // Run post-install scripts - sb.WriteString("# Run composer scripts\n") - sb.WriteString("RUN composer dump-autoload --optimize\n\n") - - // Copy frontend assets if built - if config.HasAssets { - sb.WriteString("# Copy built frontend assets\n") - sb.WriteString("COPY --from=frontend /app/public/build public/build\n\n") - } - - // Laravel-specific setup - if config.IsLaravel { - sb.WriteString("# Laravel setup\n") - sb.WriteString("RUN php artisan config:cache \\\n") - sb.WriteString(" && php artisan route:cache \\\n") - sb.WriteString(" && php artisan view:cache\n\n") - - // Set permissions - sb.WriteString("# Set permissions for Laravel\n") - sb.WriteString("RUN chown -R www-data:www-data storage bootstrap/cache \\\n") - sb.WriteString(" && chmod -R 775 storage bootstrap/cache\n\n") - } - - // Expose ports - sb.WriteString("# Expose ports\n") - sb.WriteString("EXPOSE 80 443\n\n") - - // Health check - sb.WriteString("# Health check\n") - sb.WriteString("HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \\\n") - sb.WriteString(" CMD curl -f http://localhost/up || exit 1\n\n") - - // Start command - sb.WriteString("# Start FrankenPHP\n") - if config.HasOctane { - sb.WriteString("CMD [\"php\", \"artisan\", \"octane:start\", \"--server=frankenphp\", \"--host=0.0.0.0\", \"--port=80\"]\n") - } else { - sb.WriteString("CMD [\"frankenphp\", \"run\", \"--config\", \"/etc/caddy/Caddyfile\"]\n") - } - - return sb.String() -} - -// ComposerJSON represents the structure of composer.json. -type ComposerJSON struct { - Name string `json:"name"` - Require map[string]string `json:"require"` - RequireDev map[string]string `json:"require-dev"` -} - -// detectPHPExtensions detects required PHP extensions from composer.json. -func detectPHPExtensions(composer ComposerJSON) []string { - extensionMap := make(map[string]bool) - - // Check for common packages and their required extensions - packageExtensions := map[string][]string{ - // Database - "doctrine/dbal": {"pdo_mysql", "pdo_pgsql"}, - "illuminate/database": {"pdo_mysql"}, - "laravel/framework": {"pdo_mysql", "bcmath", "ctype", "fileinfo", "mbstring", "openssl", "tokenizer", "xml"}, - "mongodb/mongodb": {"mongodb"}, - "predis/predis": {"redis"}, - "phpredis/phpredis": {"redis"}, - "laravel/horizon": {"redis", "pcntl"}, - "aws/aws-sdk-php": {"curl"}, - "intervention/image": {"gd"}, - "intervention/image-laravel": {"gd"}, - "spatie/image": {"gd"}, - "league/flysystem-aws-s3-v3": {"curl"}, - "guzzlehttp/guzzle": {"curl"}, - "nelmio/cors-bundle": {}, - // Queues - "laravel/reverb": {"pcntl"}, - "php-amqplib/php-amqplib": {"sockets"}, - // Misc - "moneyphp/money": {"bcmath", "intl"}, - "symfony/intl": {"intl"}, - "nesbot/carbon": {"intl"}, - "spatie/laravel-medialibrary": {"exif", "gd"}, - } - - // Check all require and require-dev dependencies - allDeps := make(map[string]string) - for pkg, ver := range composer.Require { - allDeps[pkg] = ver - } - for pkg, ver := range composer.RequireDev { - allDeps[pkg] = ver - } - - // Find required extensions - for pkg := range allDeps { - if exts, ok := packageExtensions[pkg]; ok { - for _, ext := range exts { - extensionMap[ext] = true - } - } - - // Check for direct ext- requirements - if strings.HasPrefix(pkg, "ext-") { - ext := strings.TrimPrefix(pkg, "ext-") - // Skip extensions that are built into PHP - builtIn := map[string]bool{ - "json": true, "ctype": true, "iconv": true, - "session": true, "simplexml": true, "pdo": true, - "xml": true, "tokenizer": true, - } - if !builtIn[ext] { - extensionMap[ext] = true - } - } - } - - // Convert to sorted slice - extensions := make([]string, 0, len(extensionMap)) - for ext := range extensionMap { - extensions = append(extensions, ext) - } - sort.Strings(extensions) - - return extensions -} - -// extractPHPVersion extracts a clean PHP version from a composer constraint. -func extractPHPVersion(constraint string) string { - // Handle common formats: ^8.2, >=8.2, 8.2.*, ~8.2 - constraint = strings.TrimLeft(constraint, "^>=~") - constraint = strings.TrimRight(constraint, ".*") - - // Extract major.minor - parts := strings.Split(constraint, ".") - if len(parts) >= 2 { - return parts[0] + "." + parts[1] - } - if len(parts) == 1 { - return parts[0] + ".0" - } - - return "8.3" // default -} - -// hasNodeAssets checks if the project has frontend assets. -func hasNodeAssets(dir string) bool { - m := getMedium() - packageJSON := filepath.Join(dir, "package.json") - if !m.IsFile(packageJSON) { - return false - } - - // Check for build script in package.json - content, err := m.Read(packageJSON) - if err != nil { - return false - } - - var pkg struct { - Scripts map[string]string `json:"scripts"` - } - - if err := json.Unmarshal([]byte(content), &pkg); err != nil { - return false - } - - // Check if there's a build script - _, hasBuild := pkg.Scripts["build"] - return hasBuild -} - -// GenerateDockerignore generates a .dockerignore file content for PHP projects. -func GenerateDockerignore(dir string) string { - var sb strings.Builder - - sb.WriteString("# Git\n") - sb.WriteString(".git\n") - sb.WriteString(".gitignore\n") - sb.WriteString(".gitattributes\n\n") - - sb.WriteString("# Node\n") - sb.WriteString("node_modules\n\n") - - sb.WriteString("# Development\n") - sb.WriteString(".env\n") - sb.WriteString(".env.local\n") - sb.WriteString(".env.*.local\n") - sb.WriteString("*.log\n") - sb.WriteString(".phpunit.result.cache\n") - sb.WriteString("phpunit.xml\n") - sb.WriteString(".php-cs-fixer.cache\n") - sb.WriteString("phpstan.neon\n\n") - - sb.WriteString("# IDE\n") - sb.WriteString(".idea\n") - sb.WriteString(".vscode\n") - sb.WriteString("*.swp\n") - sb.WriteString("*.swo\n\n") - - sb.WriteString("# Laravel specific\n") - sb.WriteString("storage/app/*\n") - sb.WriteString("storage/logs/*\n") - sb.WriteString("storage/framework/cache/*\n") - sb.WriteString("storage/framework/sessions/*\n") - sb.WriteString("storage/framework/views/*\n") - sb.WriteString("bootstrap/cache/*\n\n") - - sb.WriteString("# Build artifacts\n") - sb.WriteString("public/hot\n") - sb.WriteString("public/storage\n") - sb.WriteString("vendor\n\n") - - sb.WriteString("# Docker\n") - sb.WriteString("Dockerfile*\n") - sb.WriteString("docker-compose*.yml\n") - sb.WriteString(".dockerignore\n\n") - - sb.WriteString("# Documentation\n") - sb.WriteString("README.md\n") - sb.WriteString("CHANGELOG.md\n") - sb.WriteString("docs\n") - - return sb.String() -} diff --git a/internal/cmd/php/dockerfile_test.go b/internal/cmd/php/dockerfile_test.go deleted file mode 100644 index 5c3b1ce1..00000000 --- a/internal/cmd/php/dockerfile_test.go +++ /dev/null @@ -1,634 +0,0 @@ -package php - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGenerateDockerfile_Good(t *testing.T) { - t.Run("basic Laravel project", func(t *testing.T) { - dir := t.TempDir() - - // Create composer.json - composerJSON := `{ - "name": "test/laravel-project", - "require": { - "php": "^8.2", - "laravel/framework": "^11.0" - } - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - // Create composer.lock - err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) - - content, err := GenerateDockerfile(dir) - require.NoError(t, err) - - // Check content - assert.Contains(t, content, "FROM dunglas/frankenphp") - assert.Contains(t, content, "php8.2") - assert.Contains(t, content, "COPY composer.json composer.lock") - assert.Contains(t, content, "composer install") - assert.Contains(t, content, "EXPOSE 80 443") - }) - - t.Run("Laravel project with Octane", func(t *testing.T) { - dir := t.TempDir() - - composerJSON := `{ - "name": "test/laravel-octane", - "require": { - "php": "^8.3", - "laravel/framework": "^11.0", - "laravel/octane": "^2.0" - } - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) - - content, err := GenerateDockerfile(dir) - require.NoError(t, err) - - assert.Contains(t, content, "php8.3") - assert.Contains(t, content, "octane:start") - }) - - t.Run("project with frontend assets", func(t *testing.T) { - dir := t.TempDir() - - composerJSON := `{ - "name": "test/laravel-vite", - "require": { - "php": "^8.3", - "laravel/framework": "^11.0" - } - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) - - packageJSON := `{ - "name": "test-app", - "scripts": { - "dev": "vite", - "build": "vite build" - } - }` - err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0644) - require.NoError(t, err) - - content, err := GenerateDockerfile(dir) - require.NoError(t, err) - - // Should have multi-stage build - assert.Contains(t, content, "FROM node:20-alpine AS frontend") - assert.Contains(t, content, "npm ci") - assert.Contains(t, content, "npm run build") - assert.Contains(t, content, "COPY --from=frontend") - }) - - t.Run("project with pnpm", func(t *testing.T) { - dir := t.TempDir() - - composerJSON := `{ - "name": "test/laravel-pnpm", - "require": { - "php": "^8.3", - "laravel/framework": "^11.0" - } - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) - - packageJSON := `{ - "name": "test-app", - "scripts": { - "build": "vite build" - } - }` - err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) - - // Create pnpm-lock.yaml - err = os.WriteFile(filepath.Join(dir, "pnpm-lock.yaml"), []byte("lockfileVersion: 6.0"), 0644) - require.NoError(t, err) - - content, err := GenerateDockerfile(dir) - require.NoError(t, err) - - assert.Contains(t, content, "pnpm install") - assert.Contains(t, content, "pnpm run build") - }) - - t.Run("project with Redis dependency", func(t *testing.T) { - dir := t.TempDir() - - composerJSON := `{ - "name": "test/laravel-redis", - "require": { - "php": "^8.3", - "laravel/framework": "^11.0", - "predis/predis": "^2.0" - } - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) - - content, err := GenerateDockerfile(dir) - require.NoError(t, err) - - assert.Contains(t, content, "install-php-extensions") - assert.Contains(t, content, "redis") - }) - - t.Run("project with explicit ext- requirements", func(t *testing.T) { - dir := t.TempDir() - - composerJSON := `{ - "name": "test/with-extensions", - "require": { - "php": "^8.3", - "ext-gd": "*", - "ext-imagick": "*", - "ext-intl": "*" - } - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) - - content, err := GenerateDockerfile(dir) - require.NoError(t, err) - - assert.Contains(t, content, "install-php-extensions") - assert.Contains(t, content, "gd") - assert.Contains(t, content, "imagick") - assert.Contains(t, content, "intl") - }) -} - -func TestGenerateDockerfile_Bad(t *testing.T) { - t.Run("missing composer.json", func(t *testing.T) { - dir := t.TempDir() - - _, err := GenerateDockerfile(dir) - assert.Error(t, err) - assert.Contains(t, err.Error(), "composer.json") - }) - - t.Run("invalid composer.json", func(t *testing.T) { - dir := t.TempDir() - - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644) - require.NoError(t, err) - - _, err = GenerateDockerfile(dir) - assert.Error(t, err) - }) -} - -func TestDetectDockerfileConfig_Good(t *testing.T) { - t.Run("full Laravel project", func(t *testing.T) { - dir := t.TempDir() - - composerJSON := `{ - "name": "test/full-laravel", - "require": { - "php": "^8.3", - "laravel/framework": "^11.0", - "laravel/octane": "^2.0", - "predis/predis": "^2.0", - "intervention/image": "^3.0" - } - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - packageJSON := `{"scripts": {"build": "vite build"}}` - err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte(""), 0644) - require.NoError(t, err) - - config, err := DetectDockerfileConfig(dir) - require.NoError(t, err) - - assert.Equal(t, "8.3", config.PHPVersion) - assert.True(t, config.IsLaravel) - assert.True(t, config.HasOctane) - assert.True(t, config.HasAssets) - assert.Equal(t, "yarn", config.PackageManager) - assert.Contains(t, config.PHPExtensions, "redis") - assert.Contains(t, config.PHPExtensions, "gd") - }) -} - -func TestDetectDockerfileConfig_Bad(t *testing.T) { - t.Run("non-existent directory", func(t *testing.T) { - _, err := DetectDockerfileConfig("/non/existent/path") - assert.Error(t, err) - }) -} - -func TestExtractPHPVersion_Good(t *testing.T) { - tests := []struct { - constraint string - expected string - }{ - {"^8.2", "8.2"}, - {"^8.3", "8.3"}, - {">=8.2", "8.2"}, - {"~8.2", "8.2"}, - {"8.2.*", "8.2"}, - {"8.2.0", "8.2"}, - {"8", "8.0"}, - } - - for _, tt := range tests { - t.Run(tt.constraint, func(t *testing.T) { - result := extractPHPVersion(tt.constraint) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestDetectPHPExtensions_Good(t *testing.T) { - t.Run("detects Redis from predis", func(t *testing.T) { - composer := ComposerJSON{ - Require: map[string]string{ - "predis/predis": "^2.0", - }, - } - - extensions := detectPHPExtensions(composer) - assert.Contains(t, extensions, "redis") - }) - - t.Run("detects GD from intervention/image", func(t *testing.T) { - composer := ComposerJSON{ - Require: map[string]string{ - "intervention/image": "^3.0", - }, - } - - extensions := detectPHPExtensions(composer) - assert.Contains(t, extensions, "gd") - }) - - t.Run("detects multiple extensions from Laravel", func(t *testing.T) { - composer := ComposerJSON{ - Require: map[string]string{ - "laravel/framework": "^11.0", - }, - } - - extensions := detectPHPExtensions(composer) - assert.Contains(t, extensions, "pdo_mysql") - assert.Contains(t, extensions, "bcmath") - }) - - t.Run("detects explicit ext- requirements", func(t *testing.T) { - composer := ComposerJSON{ - Require: map[string]string{ - "ext-gd": "*", - "ext-imagick": "*", - }, - } - - extensions := detectPHPExtensions(composer) - assert.Contains(t, extensions, "gd") - assert.Contains(t, extensions, "imagick") - }) - - t.Run("skips built-in extensions", func(t *testing.T) { - composer := ComposerJSON{ - Require: map[string]string{ - "ext-json": "*", - "ext-session": "*", - "ext-pdo": "*", - }, - } - - extensions := detectPHPExtensions(composer) - assert.NotContains(t, extensions, "json") - assert.NotContains(t, extensions, "session") - assert.NotContains(t, extensions, "pdo") - }) - - t.Run("sorts extensions alphabetically", func(t *testing.T) { - composer := ComposerJSON{ - Require: map[string]string{ - "ext-zip": "*", - "ext-gd": "*", - "ext-intl": "*", - }, - } - - extensions := detectPHPExtensions(composer) - - // Check they are sorted - for i := 1; i < len(extensions); i++ { - assert.True(t, extensions[i-1] < extensions[i], - "extensions should be sorted: %v", extensions) - } - }) -} - -func TestHasNodeAssets_Good(t *testing.T) { - t.Run("with build script", func(t *testing.T) { - dir := t.TempDir() - - packageJSON := `{ - "name": "test", - "scripts": { - "dev": "vite", - "build": "vite build" - } - }` - err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) - - assert.True(t, hasNodeAssets(dir)) - }) -} - -func TestHasNodeAssets_Bad(t *testing.T) { - t.Run("no package.json", func(t *testing.T) { - dir := t.TempDir() - assert.False(t, hasNodeAssets(dir)) - }) - - t.Run("no build script", func(t *testing.T) { - dir := t.TempDir() - - packageJSON := `{ - "name": "test", - "scripts": { - "dev": "vite" - } - }` - err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) - - assert.False(t, hasNodeAssets(dir)) - }) - - t.Run("invalid package.json", func(t *testing.T) { - dir := t.TempDir() - - err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("invalid{"), 0644) - require.NoError(t, err) - - assert.False(t, hasNodeAssets(dir)) - }) -} - -func TestGenerateDockerignore_Good(t *testing.T) { - t.Run("generates complete dockerignore", func(t *testing.T) { - dir := t.TempDir() - content := GenerateDockerignore(dir) - - // Check key entries - assert.Contains(t, content, ".git") - assert.Contains(t, content, "node_modules") - assert.Contains(t, content, ".env") - assert.Contains(t, content, "vendor") - assert.Contains(t, content, "storage/logs/*") - assert.Contains(t, content, ".idea") - assert.Contains(t, content, ".vscode") - }) -} - -func TestGenerateDockerfileFromConfig_Good(t *testing.T) { - t.Run("minimal config", func(t *testing.T) { - config := &DockerfileConfig{ - PHPVersion: "8.3", - BaseImage: "dunglas/frankenphp", - UseAlpine: true, - } - - content := GenerateDockerfileFromConfig(config) - - assert.Contains(t, content, "FROM dunglas/frankenphp:latest-php8.3-alpine") - assert.Contains(t, content, "WORKDIR /app") - assert.Contains(t, content, "COPY composer.json composer.lock") - assert.Contains(t, content, "EXPOSE 80 443") - }) - - t.Run("with extensions", func(t *testing.T) { - config := &DockerfileConfig{ - PHPVersion: "8.3", - BaseImage: "dunglas/frankenphp", - UseAlpine: true, - PHPExtensions: []string{"redis", "gd", "intl"}, - } - - content := GenerateDockerfileFromConfig(config) - - assert.Contains(t, content, "install-php-extensions redis gd intl") - }) - - t.Run("Laravel with Octane", func(t *testing.T) { - config := &DockerfileConfig{ - PHPVersion: "8.3", - BaseImage: "dunglas/frankenphp", - UseAlpine: true, - IsLaravel: true, - HasOctane: true, - } - - content := GenerateDockerfileFromConfig(config) - - assert.Contains(t, content, "php artisan config:cache") - assert.Contains(t, content, "php artisan route:cache") - assert.Contains(t, content, "php artisan view:cache") - assert.Contains(t, content, "chown -R www-data:www-data storage") - assert.Contains(t, content, "octane:start") - }) - - t.Run("with frontend assets", func(t *testing.T) { - config := &DockerfileConfig{ - PHPVersion: "8.3", - BaseImage: "dunglas/frankenphp", - UseAlpine: true, - HasAssets: true, - PackageManager: "npm", - } - - content := GenerateDockerfileFromConfig(config) - - // Multi-stage build - assert.Contains(t, content, "FROM node:20-alpine AS frontend") - assert.Contains(t, content, "COPY package.json package-lock.json") - assert.Contains(t, content, "RUN npm ci") - assert.Contains(t, content, "RUN npm run build") - assert.Contains(t, content, "COPY --from=frontend /app/public/build public/build") - }) - - t.Run("with yarn", func(t *testing.T) { - config := &DockerfileConfig{ - PHPVersion: "8.3", - BaseImage: "dunglas/frankenphp", - UseAlpine: true, - HasAssets: true, - PackageManager: "yarn", - } - - content := GenerateDockerfileFromConfig(config) - - assert.Contains(t, content, "COPY package.json yarn.lock") - assert.Contains(t, content, "yarn install --frozen-lockfile") - assert.Contains(t, content, "yarn build") - }) - - t.Run("with bun", func(t *testing.T) { - config := &DockerfileConfig{ - PHPVersion: "8.3", - BaseImage: "dunglas/frankenphp", - UseAlpine: true, - HasAssets: true, - PackageManager: "bun", - } - - content := GenerateDockerfileFromConfig(config) - - assert.Contains(t, content, "npm install -g bun") - assert.Contains(t, content, "COPY package.json bun.lockb") - assert.Contains(t, content, "bun install --frozen-lockfile") - assert.Contains(t, content, "bun run build") - }) - - t.Run("non-alpine image", func(t *testing.T) { - config := &DockerfileConfig{ - PHPVersion: "8.3", - BaseImage: "dunglas/frankenphp", - UseAlpine: false, - } - - content := GenerateDockerfileFromConfig(config) - - assert.Contains(t, content, "FROM dunglas/frankenphp:latest-php8.3 AS app") - assert.NotContains(t, content, "alpine") - }) -} - -func TestIsPHPProject_Good(t *testing.T) { - t.Run("project with composer.json", func(t *testing.T) { - dir := t.TempDir() - - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("{}"), 0644) - require.NoError(t, err) - - assert.True(t, IsPHPProject(dir)) - }) -} - -func TestIsPHPProject_Bad(t *testing.T) { - t.Run("project without composer.json", func(t *testing.T) { - dir := t.TempDir() - assert.False(t, IsPHPProject(dir)) - }) - - t.Run("non-existent directory", func(t *testing.T) { - assert.False(t, IsPHPProject("/non/existent/path")) - }) -} - -func TestExtractPHPVersion_Edge(t *testing.T) { - t.Run("handles single major version", func(t *testing.T) { - result := extractPHPVersion("8") - assert.Equal(t, "8.0", result) - }) -} - -func TestDetectPHPExtensions_RequireDev(t *testing.T) { - t.Run("detects extensions from require-dev", func(t *testing.T) { - composer := ComposerJSON{ - RequireDev: map[string]string{ - "predis/predis": "^2.0", - }, - } - - extensions := detectPHPExtensions(composer) - assert.Contains(t, extensions, "redis") - }) -} - -func TestDockerfileStructure_Good(t *testing.T) { - t.Run("Dockerfile has proper structure", func(t *testing.T) { - dir := t.TempDir() - - composerJSON := `{ - "name": "test/app", - "require": { - "php": "^8.3", - "laravel/framework": "^11.0", - "laravel/octane": "^2.0", - "predis/predis": "^2.0" - } - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644) - require.NoError(t, err) - - packageJSON := `{"scripts": {"build": "vite build"}}` - err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0644) - require.NoError(t, err) - - content, err := GenerateDockerfile(dir) - require.NoError(t, err) - - lines := strings.Split(content, "\n") - var fromCount, workdirCount, copyCount, runCount, exposeCount, cmdCount int - - for _, line := range lines { - trimmed := strings.TrimSpace(line) - switch { - case strings.HasPrefix(trimmed, "FROM "): - fromCount++ - case strings.HasPrefix(trimmed, "WORKDIR "): - workdirCount++ - case strings.HasPrefix(trimmed, "COPY "): - copyCount++ - case strings.HasPrefix(trimmed, "RUN "): - runCount++ - case strings.HasPrefix(trimmed, "EXPOSE "): - exposeCount++ - case strings.HasPrefix(trimmed, "CMD ["): - // Only count actual CMD instructions, not HEALTHCHECK CMD - cmdCount++ - } - } - - // Multi-stage build should have 2 FROM statements - assert.Equal(t, 2, fromCount, "should have 2 FROM statements for multi-stage build") - - // Should have proper structure - assert.GreaterOrEqual(t, workdirCount, 1, "should have WORKDIR") - assert.GreaterOrEqual(t, copyCount, 3, "should have multiple COPY statements") - assert.GreaterOrEqual(t, runCount, 2, "should have multiple RUN statements") - assert.Equal(t, 1, exposeCount, "should have exactly one EXPOSE") - assert.Equal(t, 1, cmdCount, "should have exactly one CMD") - }) -} diff --git a/internal/cmd/php/i18n.go b/internal/cmd/php/i18n.go deleted file mode 100644 index 96a60a94..00000000 --- a/internal/cmd/php/i18n.go +++ /dev/null @@ -1,16 +0,0 @@ -// Package php provides PHP/Laravel development tools. -package php - -import ( - "embed" - - "forge.lthn.ai/core/go/pkg/i18n" -) - -//go:embed locales/*.json -var localeFS embed.FS - -func init() { - // Register PHP translations with the i18n system - i18n.RegisterLocales(localeFS, "locales") -} diff --git a/internal/cmd/php/locales/en_GB.json b/internal/cmd/php/locales/en_GB.json deleted file mode 100644 index 4f74cd89..00000000 --- a/internal/cmd/php/locales/en_GB.json +++ /dev/null @@ -1,147 +0,0 @@ -{ - "cmd": { - "php": { - "short": "Laravel/PHP development tools", - "long": "Laravel and PHP development tools including testing, formatting, static analysis, and deployment", - "label": { - "php": "PHP:", - "audit": "Audit:", - "psalm": "Psalm:", - "rector": "Rector:", - "security": "Security:", - "infection": "Infection:", - "info": "Info:", - "setup": "Setup:" - }, - "error": { - "not_php": "Not a PHP project (no composer.json found)", - "fmt_failed": "Formatting failed", - "fmt_issues": "Style issues found", - "analysis_issues": "Analysis errors found", - "audit_failed": "Audit failed", - "vulns_found": "Vulnerabilities found", - "psalm_not_installed": "Psalm not installed", - "psalm_issues": "Psalm found type errors", - "rector_not_installed": "Rector not installed", - "rector_failed": "Rector failed", - "infection_not_installed": "Infection not installed", - "infection_failed": "Mutation testing failed", - "security_failed": "Security check failed", - "critical_high_issues": "Critical or high severity issues found" - }, - "test": { - "short": "Run PHPUnit/Pest tests", - "long": "Run PHPUnit or Pest tests with optional filtering, parallel execution, and coverage", - "flag": { - "parallel": "Run tests in parallel", - "coverage": "Generate code coverage report", - "filter": "Filter tests by name", - "group": "Run only tests in this group" - } - }, - "fmt": { - "short": "Format PHP code with Laravel Pint", - "long": "Format PHP code using Laravel Pint code style fixer", - "no_formatter": "No code formatter found (install laravel/pint)", - "no_issues": "No style issues found", - "formatting": "Formatting with {{.Formatter}}...", - "flag": { - "fix": "Fix style issues (default: check only)" - } - }, - "analyse": { - "short": "Run PHPStan static analysis", - "long": "Run PHPStan/Larastan for static code analysis", - "no_analyser": "No static analyser found (install phpstan/phpstan or nunomaduro/larastan)", - "flag": { - "level": "Analysis level (0-9, default: from config)", - "memory": "Memory limit (e.g., 2G)" - } - }, - "audit": { - "short": "Security audit for dependencies", - "long": "Audit Composer and NPM dependencies for known vulnerabilities", - "scanning": "Scanning dependencies for vulnerabilities...", - "secure": "No vulnerabilities", - "error": "Audit error", - "vulnerabilities": "{{.Count}} vulnerabilities found", - "found_vulns": "Found {{.Count}} vulnerabilities", - "all_secure": "All dependencies secure", - "completed_errors": "Audit completed with errors", - "flag": { - "fix": "Attempt to fix vulnerabilities" - } - }, - "psalm": { - "short": "Run Psalm static analysis", - "long": "Run Psalm for deep static analysis and type checking", - "not_found": "Psalm not found", - "install": "composer require --dev vimeo/psalm", - "setup": "vendor/bin/psalm --init", - "analysing": "Analysing with Psalm...", - "analysing_fixing": "Analysing and fixing with Psalm...", - "flag": { - "level": "Analysis level (1-8)", - "baseline": "Generate or update baseline", - "show_info": "Show informational issues" - } - }, - "rector": { - "short": "Automated code refactoring", - "long": "Run Rector for automated code upgrades and refactoring", - "not_found": "Rector not found", - "install": "composer require --dev rector/rector", - "setup": "vendor/bin/rector init", - "analysing": "Analysing code for refactoring opportunities...", - "refactoring": "Refactoring code...", - "no_changes": "No refactoring changes needed", - "changes_suggested": "Rector suggests changes (run with --fix to apply)", - "flag": { - "fix": "Apply refactoring changes", - "diff": "Show diff of changes", - "clear_cache": "Clear Rector cache before running" - } - }, - "infection": { - "short": "Mutation testing for test quality", - "long": "Run Infection mutation testing to measure test suite quality", - "not_found": "Infection not found", - "install": "composer require --dev infection/infection", - "note": "This may take a while depending on test suite size", - "complete": "Mutation testing complete", - "flag": { - "min_msi": "Minimum Mutation Score Indicator (0-100)", - "min_covered_msi": "Minimum covered code MSI (0-100)", - "threads": "Number of parallel threads", - "filter": "Filter mutants by file path", - "only_covered": "Only mutate covered code" - } - }, - "security": { - "short": "Security vulnerability scanning", - "long": "Run comprehensive security checks on PHP codebase", - "checks_suffix": " CHECKS", - "summary": "Security scan complete", - "passed": "Passed:", - "critical": "Critical:", - "high": "High:", - "medium": "Medium:", - "low": "Low:", - "flag": { - "severity": "Minimum severity to report (low, medium, high, critical)", - "sarif": "Output in SARIF format", - "url": "Application URL for runtime checks" - } - }, - "qa": { - "short": "Run full QA pipeline", - "long": "Run comprehensive quality assurance: audit, format, analyse, test, and more", - "flag": { - "quick": "Run quick checks only (audit, fmt, stan)", - "full": "Run all stages including slow checks", - "fix": "Auto-fix issues where possible" - } - } - } - } -} diff --git a/internal/cmd/php/packages.go b/internal/cmd/php/packages.go deleted file mode 100644 index 03645d66..00000000 --- a/internal/cmd/php/packages.go +++ /dev/null @@ -1,308 +0,0 @@ -package php - -import ( - "encoding/json" - "os" - "os/exec" - "path/filepath" - - "forge.lthn.ai/core/go/pkg/cli" -) - -// LinkedPackage represents a linked local package. -type LinkedPackage struct { - Name string `json:"name"` - Path string `json:"path"` - Version string `json:"version"` -} - -// composerRepository represents a composer repository entry. -type composerRepository struct { - Type string `json:"type"` - URL string `json:"url,omitempty"` - Options map[string]any `json:"options,omitempty"` -} - -// readComposerJSON reads and parses composer.json from the given directory. -func readComposerJSON(dir string) (map[string]json.RawMessage, error) { - m := getMedium() - composerPath := filepath.Join(dir, "composer.json") - content, err := m.Read(composerPath) - if err != nil { - return nil, cli.WrapVerb(err, "read", "composer.json") - } - - var raw map[string]json.RawMessage - if err := json.Unmarshal([]byte(content), &raw); err != nil { - return nil, cli.WrapVerb(err, "parse", "composer.json") - } - - return raw, nil -} - -// writeComposerJSON writes the composer.json to the given directory. -func writeComposerJSON(dir string, raw map[string]json.RawMessage) error { - m := getMedium() - composerPath := filepath.Join(dir, "composer.json") - - data, err := json.MarshalIndent(raw, "", " ") - if err != nil { - return cli.WrapVerb(err, "marshal", "composer.json") - } - - // Add trailing newline - content := string(data) + "\n" - - if err := m.Write(composerPath, content); err != nil { - return cli.WrapVerb(err, "write", "composer.json") - } - - return nil -} - -// getRepositories extracts repositories from raw composer.json. -func getRepositories(raw map[string]json.RawMessage) ([]composerRepository, error) { - reposRaw, ok := raw["repositories"] - if !ok { - return []composerRepository{}, nil - } - - var repos []composerRepository - if err := json.Unmarshal(reposRaw, &repos); err != nil { - return nil, cli.WrapVerb(err, "parse", "repositories") - } - - return repos, nil -} - -// setRepositories sets repositories in raw composer.json. -func setRepositories(raw map[string]json.RawMessage, repos []composerRepository) error { - if len(repos) == 0 { - delete(raw, "repositories") - return nil - } - - reposData, err := json.Marshal(repos) - if err != nil { - return cli.WrapVerb(err, "marshal", "repositories") - } - - raw["repositories"] = reposData - return nil -} - -// getPackageInfo reads package name and version from a composer.json in the given path. -func getPackageInfo(packagePath string) (name, version string, err error) { - m := getMedium() - composerPath := filepath.Join(packagePath, "composer.json") - content, err := m.Read(composerPath) - if err != nil { - return "", "", cli.WrapVerb(err, "read", "package composer.json") - } - - var pkg struct { - Name string `json:"name"` - Version string `json:"version"` - } - - if err := json.Unmarshal([]byte(content), &pkg); err != nil { - return "", "", cli.WrapVerb(err, "parse", "package composer.json") - } - - if pkg.Name == "" { - return "", "", cli.Err("package name not found in composer.json") - } - - return pkg.Name, pkg.Version, nil -} - -// LinkPackages adds path repositories to composer.json for local package development. -func LinkPackages(dir string, packages []string) error { - if !IsPHPProject(dir) { - return cli.Err("not a PHP project (missing composer.json)") - } - - raw, err := readComposerJSON(dir) - if err != nil { - return err - } - - repos, err := getRepositories(raw) - if err != nil { - return err - } - - for _, packagePath := range packages { - // Resolve absolute path - absPath, err := filepath.Abs(packagePath) - if err != nil { - return cli.Err("failed to resolve path %s: %w", packagePath, err) - } - - // Verify the path exists and has a composer.json - if !IsPHPProject(absPath) { - return cli.Err("not a PHP package (missing composer.json): %s", absPath) - } - - // Get package name for validation - pkgName, _, err := getPackageInfo(absPath) - if err != nil { - return cli.Err("failed to get package info from %s: %w", absPath, err) - } - - // Check if already linked - alreadyLinked := false - for _, repo := range repos { - if repo.Type == "path" && repo.URL == absPath { - alreadyLinked = true - break - } - } - - if alreadyLinked { - continue - } - - // Add path repository - repos = append(repos, composerRepository{ - Type: "path", - URL: absPath, - Options: map[string]any{ - "symlink": true, - }, - }) - - cli.Print("Linked: %s -> %s\n", pkgName, absPath) - } - - if err := setRepositories(raw, repos); err != nil { - return err - } - - return writeComposerJSON(dir, raw) -} - -// UnlinkPackages removes path repositories from composer.json. -func UnlinkPackages(dir string, packages []string) error { - if !IsPHPProject(dir) { - return cli.Err("not a PHP project (missing composer.json)") - } - - raw, err := readComposerJSON(dir) - if err != nil { - return err - } - - repos, err := getRepositories(raw) - if err != nil { - return err - } - - // Build set of packages to unlink - toUnlink := make(map[string]bool) - for _, pkg := range packages { - toUnlink[pkg] = true - } - - // Filter out unlinked packages - filtered := make([]composerRepository, 0, len(repos)) - for _, repo := range repos { - if repo.Type != "path" { - filtered = append(filtered, repo) - continue - } - - // Check if this repo should be unlinked - shouldUnlink := false - - // Try to get package name from the path - if IsPHPProject(repo.URL) { - pkgName, _, err := getPackageInfo(repo.URL) - if err == nil && toUnlink[pkgName] { - shouldUnlink = true - cli.Print("Unlinked: %s\n", pkgName) - } - } - - // Also check if path matches any of the provided names - for pkg := range toUnlink { - if repo.URL == pkg || filepath.Base(repo.URL) == pkg { - shouldUnlink = true - cli.Print("Unlinked: %s\n", repo.URL) - break - } - } - - if !shouldUnlink { - filtered = append(filtered, repo) - } - } - - if err := setRepositories(raw, filtered); err != nil { - return err - } - - return writeComposerJSON(dir, raw) -} - -// UpdatePackages runs composer update for specific packages. -func UpdatePackages(dir string, packages []string) error { - if !IsPHPProject(dir) { - return cli.Err("not a PHP project (missing composer.json)") - } - - args := []string{"update"} - args = append(args, packages...) - - cmd := exec.Command("composer", args...) - cmd.Dir = dir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - return cmd.Run() -} - -// ListLinkedPackages returns all path repositories from composer.json. -func ListLinkedPackages(dir string) ([]LinkedPackage, error) { - if !IsPHPProject(dir) { - return nil, cli.Err("not a PHP project (missing composer.json)") - } - - raw, err := readComposerJSON(dir) - if err != nil { - return nil, err - } - - repos, err := getRepositories(raw) - if err != nil { - return nil, err - } - - linked := make([]LinkedPackage, 0) - for _, repo := range repos { - if repo.Type != "path" { - continue - } - - pkg := LinkedPackage{ - Path: repo.URL, - } - - // Try to get package info - if IsPHPProject(repo.URL) { - name, version, err := getPackageInfo(repo.URL) - if err == nil { - pkg.Name = name - pkg.Version = version - } - } - - if pkg.Name == "" { - pkg.Name = filepath.Base(repo.URL) - } - - linked = append(linked, pkg) - } - - return linked, nil -} diff --git a/internal/cmd/php/packages_test.go b/internal/cmd/php/packages_test.go deleted file mode 100644 index a340a9b0..00000000 --- a/internal/cmd/php/packages_test.go +++ /dev/null @@ -1,543 +0,0 @@ -package php - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestReadComposerJSON_Good(t *testing.T) { - t.Run("reads valid composer.json", func(t *testing.T) { - dir := t.TempDir() - composerJSON := `{ - "name": "test/project", - "require": { - "php": "^8.2" - } - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - raw, err := readComposerJSON(dir) - assert.NoError(t, err) - assert.NotNil(t, raw) - assert.Contains(t, string(raw["name"]), "test/project") - }) - - t.Run("preserves all fields", func(t *testing.T) { - dir := t.TempDir() - composerJSON := `{ - "name": "test/project", - "description": "Test project", - "require": {"php": "^8.2"}, - "autoload": {"psr-4": {"App\\": "src/"}} - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - raw, err := readComposerJSON(dir) - assert.NoError(t, err) - assert.Contains(t, string(raw["autoload"]), "psr-4") - }) -} - -func TestReadComposerJSON_Bad(t *testing.T) { - t.Run("missing composer.json", func(t *testing.T) { - dir := t.TempDir() - _, err := readComposerJSON(dir) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Failed to read composer.json") - }) - - t.Run("invalid JSON", func(t *testing.T) { - dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644) - require.NoError(t, err) - - _, err = readComposerJSON(dir) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Failed to parse composer.json") - }) -} - -func TestWriteComposerJSON_Good(t *testing.T) { - t.Run("writes valid composer.json", func(t *testing.T) { - dir := t.TempDir() - raw := make(map[string]json.RawMessage) - raw["name"] = json.RawMessage(`"test/project"`) - - err := writeComposerJSON(dir, raw) - assert.NoError(t, err) - - // Verify file was written - content, err := os.ReadFile(filepath.Join(dir, "composer.json")) - assert.NoError(t, err) - assert.Contains(t, string(content), "test/project") - // Verify trailing newline - assert.True(t, content[len(content)-1] == '\n') - }) - - t.Run("pretty prints with indentation", func(t *testing.T) { - dir := t.TempDir() - raw := make(map[string]json.RawMessage) - raw["name"] = json.RawMessage(`"test/project"`) - raw["require"] = json.RawMessage(`{"php":"^8.2"}`) - - err := writeComposerJSON(dir, raw) - assert.NoError(t, err) - - content, err := os.ReadFile(filepath.Join(dir, "composer.json")) - assert.NoError(t, err) - // Should be indented - assert.Contains(t, string(content), " ") - }) -} - -func TestWriteComposerJSON_Bad(t *testing.T) { - t.Run("fails for non-existent directory", func(t *testing.T) { - raw := make(map[string]json.RawMessage) - raw["name"] = json.RawMessage(`"test/project"`) - - err := writeComposerJSON("/non/existent/path", raw) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Failed to write composer.json") - }) -} -func TestGetRepositories_Good(t *testing.T) { - t.Run("returns empty slice when no repositories", func(t *testing.T) { - raw := make(map[string]json.RawMessage) - raw["name"] = json.RawMessage(`"test/project"`) - - repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Empty(t, repos) - }) - - t.Run("parses existing repositories", func(t *testing.T) { - raw := make(map[string]json.RawMessage) - raw["name"] = json.RawMessage(`"test/project"`) - raw["repositories"] = json.RawMessage(`[{"type":"path","url":"/path/to/package"}]`) - - repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Len(t, repos, 1) - assert.Equal(t, "path", repos[0].Type) - assert.Equal(t, "/path/to/package", repos[0].URL) - }) - - t.Run("parses repositories with options", func(t *testing.T) { - raw := make(map[string]json.RawMessage) - raw["repositories"] = json.RawMessage(`[{"type":"path","url":"/path","options":{"symlink":true}}]`) - - repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Len(t, repos, 1) - assert.NotNil(t, repos[0].Options) - assert.Equal(t, true, repos[0].Options["symlink"]) - }) -} - -func TestGetRepositories_Bad(t *testing.T) { - t.Run("fails for invalid repositories JSON", func(t *testing.T) { - raw := make(map[string]json.RawMessage) - raw["repositories"] = json.RawMessage(`not valid json`) - - _, err := getRepositories(raw) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Failed to parse repositories") - }) -} - -func TestSetRepositories_Good(t *testing.T) { - t.Run("sets repositories", func(t *testing.T) { - raw := make(map[string]json.RawMessage) - repos := []composerRepository{ - {Type: "path", URL: "/path/to/package"}, - } - - err := setRepositories(raw, repos) - assert.NoError(t, err) - assert.Contains(t, string(raw["repositories"]), "/path/to/package") - }) - - t.Run("removes repositories key when empty", func(t *testing.T) { - raw := make(map[string]json.RawMessage) - raw["repositories"] = json.RawMessage(`[{"type":"path"}]`) - - err := setRepositories(raw, []composerRepository{}) - assert.NoError(t, err) - _, exists := raw["repositories"] - assert.False(t, exists) - }) -} - -func TestGetPackageInfo_Good(t *testing.T) { - t.Run("extracts package name and version", func(t *testing.T) { - dir := t.TempDir() - composerJSON := `{ - "name": "vendor/package", - "version": "1.0.0" - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - name, version, err := getPackageInfo(dir) - assert.NoError(t, err) - assert.Equal(t, "vendor/package", name) - assert.Equal(t, "1.0.0", version) - }) - - t.Run("works without version", func(t *testing.T) { - dir := t.TempDir() - composerJSON := `{ - "name": "vendor/package" - }` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - name, version, err := getPackageInfo(dir) - assert.NoError(t, err) - assert.Equal(t, "vendor/package", name) - assert.Equal(t, "", version) - }) -} - -func TestGetPackageInfo_Bad(t *testing.T) { - t.Run("missing composer.json", func(t *testing.T) { - dir := t.TempDir() - _, _, err := getPackageInfo(dir) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Failed to read package composer.json") - }) - - t.Run("invalid JSON", func(t *testing.T) { - dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644) - require.NoError(t, err) - - _, _, err = getPackageInfo(dir) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Failed to parse package composer.json") - }) - - t.Run("missing name", func(t *testing.T) { - dir := t.TempDir() - composerJSON := `{"version": "1.0.0"}` - err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - _, _, err = getPackageInfo(dir) - assert.Error(t, err) - assert.Contains(t, err.Error(), "package name not found") - }) -} - -func TestLinkPackages_Good(t *testing.T) { - t.Run("links a package", func(t *testing.T) { - // Create project directory - projectDir := t.TempDir() - err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644) - require.NoError(t, err) - - // Create package directory - packageDir := t.TempDir() - err = os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package"}`), 0644) - require.NoError(t, err) - - err = LinkPackages(projectDir, []string{packageDir}) - assert.NoError(t, err) - - // Verify repository was added - raw, err := readComposerJSON(projectDir) - assert.NoError(t, err) - repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Len(t, repos, 1) - assert.Equal(t, "path", repos[0].Type) - }) - - t.Run("skips already linked package", func(t *testing.T) { - // Create project with existing repository - projectDir := t.TempDir() - packageDir := t.TempDir() - - err := os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package"}`), 0644) - require.NoError(t, err) - - absPackagePath, _ := filepath.Abs(packageDir) - composerJSON := `{ - "name": "test/project", - "repositories": [{"type":"path","url":"` + absPackagePath + `"}] - }` - err = os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - // Link again - should not add duplicate - err = LinkPackages(projectDir, []string{packageDir}) - assert.NoError(t, err) - - raw, err := readComposerJSON(projectDir) - assert.NoError(t, err) - repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Len(t, repos, 1) // Still only one - }) - - t.Run("links multiple packages", func(t *testing.T) { - projectDir := t.TempDir() - err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644) - require.NoError(t, err) - - pkg1Dir := t.TempDir() - err = os.WriteFile(filepath.Join(pkg1Dir, "composer.json"), []byte(`{"name":"vendor/pkg1"}`), 0644) - require.NoError(t, err) - - pkg2Dir := t.TempDir() - err = os.WriteFile(filepath.Join(pkg2Dir, "composer.json"), []byte(`{"name":"vendor/pkg2"}`), 0644) - require.NoError(t, err) - - err = LinkPackages(projectDir, []string{pkg1Dir, pkg2Dir}) - assert.NoError(t, err) - - raw, err := readComposerJSON(projectDir) - assert.NoError(t, err) - repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Len(t, repos, 2) - }) -} - -func TestLinkPackages_Bad(t *testing.T) { - t.Run("fails for non-PHP project", func(t *testing.T) { - dir := t.TempDir() - err := LinkPackages(dir, []string{"/path/to/package"}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a PHP project") - }) - - t.Run("fails for non-PHP package", func(t *testing.T) { - projectDir := t.TempDir() - err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644) - require.NoError(t, err) - - packageDir := t.TempDir() - // No composer.json in package - - err = LinkPackages(projectDir, []string{packageDir}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a PHP package") - }) -} - -func TestUnlinkPackages_Good(t *testing.T) { - t.Run("unlinks package by name", func(t *testing.T) { - projectDir := t.TempDir() - packageDir := t.TempDir() - - err := os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package"}`), 0644) - require.NoError(t, err) - - absPackagePath, _ := filepath.Abs(packageDir) - composerJSON := `{ - "name": "test/project", - "repositories": [{"type":"path","url":"` + absPackagePath + `"}] - }` - err = os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - err = UnlinkPackages(projectDir, []string{"vendor/package"}) - assert.NoError(t, err) - - raw, err := readComposerJSON(projectDir) - assert.NoError(t, err) - repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Len(t, repos, 0) - }) - - t.Run("unlinks package by path", func(t *testing.T) { - projectDir := t.TempDir() - packageDir := t.TempDir() - - absPackagePath, _ := filepath.Abs(packageDir) - composerJSON := `{ - "name": "test/project", - "repositories": [{"type":"path","url":"` + absPackagePath + `"}] - }` - err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - err = UnlinkPackages(projectDir, []string{absPackagePath}) - assert.NoError(t, err) - - raw, err := readComposerJSON(projectDir) - assert.NoError(t, err) - repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Len(t, repos, 0) - }) - - t.Run("keeps non-path repositories", func(t *testing.T) { - projectDir := t.TempDir() - composerJSON := `{ - "name": "test/project", - "repositories": [ - {"type":"vcs","url":"https://github.com/vendor/package"}, - {"type":"path","url":"/local/path"} - ] - }` - err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - err = UnlinkPackages(projectDir, []string{"/local/path"}) - assert.NoError(t, err) - - raw, err := readComposerJSON(projectDir) - assert.NoError(t, err) - repos, err := getRepositories(raw) - assert.NoError(t, err) - assert.Len(t, repos, 1) - assert.Equal(t, "vcs", repos[0].Type) - }) -} - -func TestUnlinkPackages_Bad(t *testing.T) { - t.Run("fails for non-PHP project", func(t *testing.T) { - dir := t.TempDir() - err := UnlinkPackages(dir, []string{"vendor/package"}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a PHP project") - }) -} - -func TestListLinkedPackages_Good(t *testing.T) { - t.Run("lists linked packages", func(t *testing.T) { - projectDir := t.TempDir() - packageDir := t.TempDir() - - err := os.WriteFile(filepath.Join(packageDir, "composer.json"), []byte(`{"name":"vendor/package","version":"1.0.0"}`), 0644) - require.NoError(t, err) - - absPackagePath, _ := filepath.Abs(packageDir) - composerJSON := `{ - "name": "test/project", - "repositories": [{"type":"path","url":"` + absPackagePath + `"}] - }` - err = os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - linked, err := ListLinkedPackages(projectDir) - assert.NoError(t, err) - assert.Len(t, linked, 1) - assert.Equal(t, "vendor/package", linked[0].Name) - assert.Equal(t, "1.0.0", linked[0].Version) - assert.Equal(t, absPackagePath, linked[0].Path) - }) - - t.Run("returns empty list when no linked packages", func(t *testing.T) { - projectDir := t.TempDir() - err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644) - require.NoError(t, err) - - linked, err := ListLinkedPackages(projectDir) - assert.NoError(t, err) - assert.Empty(t, linked) - }) - - t.Run("uses basename when package info unavailable", func(t *testing.T) { - projectDir := t.TempDir() - composerJSON := `{ - "name": "test/project", - "repositories": [{"type":"path","url":"/nonexistent/package-name"}] - }` - err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - linked, err := ListLinkedPackages(projectDir) - assert.NoError(t, err) - assert.Len(t, linked, 1) - assert.Equal(t, "package-name", linked[0].Name) - }) - - t.Run("ignores non-path repositories", func(t *testing.T) { - projectDir := t.TempDir() - composerJSON := `{ - "name": "test/project", - "repositories": [ - {"type":"vcs","url":"https://github.com/vendor/package"} - ] - }` - err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - linked, err := ListLinkedPackages(projectDir) - assert.NoError(t, err) - assert.Empty(t, linked) - }) -} - -func TestListLinkedPackages_Bad(t *testing.T) { - t.Run("fails for non-PHP project", func(t *testing.T) { - dir := t.TempDir() - _, err := ListLinkedPackages(dir) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a PHP project") - }) -} - -func TestUpdatePackages_Bad(t *testing.T) { - t.Run("fails for non-PHP project", func(t *testing.T) { - dir := t.TempDir() - err := UpdatePackages(dir, []string{"vendor/package"}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a PHP project") - }) -} - -func TestUpdatePackages_Good(t *testing.T) { - t.Skip("requires Composer installed") - - t.Run("runs composer update", func(t *testing.T) { - projectDir := t.TempDir() - err := os.WriteFile(filepath.Join(projectDir, "composer.json"), []byte(`{"name":"test/project"}`), 0644) - require.NoError(t, err) - - _ = UpdatePackages(projectDir, []string{"vendor/package"}) - // This will fail because composer update needs real dependencies - // but it validates the command runs - }) -} - -func TestLinkedPackage_Struct(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - pkg := LinkedPackage{ - Name: "vendor/package", - Path: "/path/to/package", - Version: "1.0.0", - } - - assert.Equal(t, "vendor/package", pkg.Name) - assert.Equal(t, "/path/to/package", pkg.Path) - assert.Equal(t, "1.0.0", pkg.Version) - }) -} - -func TestComposerRepository_Struct(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - repo := composerRepository{ - Type: "path", - URL: "/path/to/package", - Options: map[string]any{ - "symlink": true, - }, - } - - assert.Equal(t, "path", repo.Type) - assert.Equal(t, "/path/to/package", repo.URL) - assert.Equal(t, true, repo.Options["symlink"]) - }) -} diff --git a/internal/cmd/php/php.go b/internal/cmd/php/php.go deleted file mode 100644 index 96393eb5..00000000 --- a/internal/cmd/php/php.go +++ /dev/null @@ -1,397 +0,0 @@ -package php - -import ( - "context" - "io" - "os" - "sync" - "time" - - "forge.lthn.ai/core/go/pkg/cli" -) - -// Options configures the development server. -type Options struct { - // Dir is the Laravel project directory. - Dir string - - // Services specifies which services to start. - // If empty, services are auto-detected. - Services []DetectedService - - // NoVite disables the Vite dev server. - NoVite bool - - // NoHorizon disables Laravel Horizon. - NoHorizon bool - - // NoReverb disables Laravel Reverb. - NoReverb bool - - // NoRedis disables the Redis server. - NoRedis bool - - // HTTPS enables HTTPS with mkcert certificates. - HTTPS bool - - // Domain is the domain for SSL certificates. - // Defaults to APP_URL from .env or "localhost". - Domain string - - // Ports for each service - FrankenPHPPort int - HTTPSPort int - VitePort int - ReverbPort int - RedisPort int -} - -// DevServer manages all development services. -type DevServer struct { - opts Options - services []Service - ctx context.Context - cancel context.CancelFunc - mu sync.RWMutex - running bool -} - -// NewDevServer creates a new development server manager. -func NewDevServer(opts Options) *DevServer { - return &DevServer{ - opts: opts, - services: make([]Service, 0), - } -} - -// Start starts all detected/configured services. -func (d *DevServer) Start(ctx context.Context, opts Options) error { - d.mu.Lock() - defer d.mu.Unlock() - - if d.running { - return cli.Err("dev server is already running") - } - - // Merge options - if opts.Dir != "" { - d.opts.Dir = opts.Dir - } - if d.opts.Dir == "" { - cwd, err := os.Getwd() - if err != nil { - return cli.WrapVerb(err, "get", "working directory") - } - d.opts.Dir = cwd - } - - // Verify this is a Laravel project - if !IsLaravelProject(d.opts.Dir) { - return cli.Err("not a Laravel project: %s", d.opts.Dir) - } - - // Create cancellable context - d.ctx, d.cancel = context.WithCancel(ctx) - - // Detect or use provided services - services := opts.Services - if len(services) == 0 { - services = DetectServices(d.opts.Dir) - } - - // Filter out disabled services - services = d.filterServices(services, opts) - - // Setup SSL if HTTPS is enabled - var certFile, keyFile string - if opts.HTTPS { - domain := opts.Domain - if domain == "" { - // Try to get domain from APP_URL - appURL := GetLaravelAppURL(d.opts.Dir) - if appURL != "" { - domain = ExtractDomainFromURL(appURL) - } - } - if domain == "" { - domain = "localhost" - } - - var err error - certFile, keyFile, err = SetupSSLIfNeeded(domain, SSLOptions{}) - if err != nil { - return cli.WrapVerb(err, "setup", "SSL") - } - } - - // Create services - d.services = make([]Service, 0) - - for _, svc := range services { - var service Service - - switch svc { - case ServiceFrankenPHP: - port := opts.FrankenPHPPort - if port == 0 { - port = 8000 - } - httpsPort := opts.HTTPSPort - if httpsPort == 0 { - httpsPort = 443 - } - service = NewFrankenPHPService(d.opts.Dir, FrankenPHPOptions{ - Port: port, - HTTPSPort: httpsPort, - HTTPS: opts.HTTPS, - CertFile: certFile, - KeyFile: keyFile, - }) - - case ServiceVite: - port := opts.VitePort - if port == 0 { - port = 5173 - } - service = NewViteService(d.opts.Dir, ViteOptions{ - Port: port, - }) - - case ServiceHorizon: - service = NewHorizonService(d.opts.Dir) - - case ServiceReverb: - port := opts.ReverbPort - if port == 0 { - port = 8080 - } - service = NewReverbService(d.opts.Dir, ReverbOptions{ - Port: port, - }) - - case ServiceRedis: - port := opts.RedisPort - if port == 0 { - port = 6379 - } - service = NewRedisService(d.opts.Dir, RedisOptions{ - Port: port, - }) - } - - if service != nil { - d.services = append(d.services, service) - } - } - - // Start all services - var startErrors []error - for _, svc := range d.services { - if err := svc.Start(d.ctx); err != nil { - startErrors = append(startErrors, cli.Err("%s: %v", svc.Name(), err)) - } - } - - if len(startErrors) > 0 { - // Stop any services that did start - for _, svc := range d.services { - _ = svc.Stop() - } - return cli.Err("failed to start services: %v", startErrors) - } - - d.running = true - return nil -} - -// filterServices removes disabled services from the list. -func (d *DevServer) filterServices(services []DetectedService, opts Options) []DetectedService { - filtered := make([]DetectedService, 0) - - for _, svc := range services { - switch svc { - case ServiceVite: - if !opts.NoVite { - filtered = append(filtered, svc) - } - case ServiceHorizon: - if !opts.NoHorizon { - filtered = append(filtered, svc) - } - case ServiceReverb: - if !opts.NoReverb { - filtered = append(filtered, svc) - } - case ServiceRedis: - if !opts.NoRedis { - filtered = append(filtered, svc) - } - default: - filtered = append(filtered, svc) - } - } - - return filtered -} - -// Stop stops all services gracefully. -func (d *DevServer) Stop() error { - d.mu.Lock() - defer d.mu.Unlock() - - if !d.running { - return nil - } - - // Cancel context first - if d.cancel != nil { - d.cancel() - } - - // Stop all services in reverse order - var stopErrors []error - for i := len(d.services) - 1; i >= 0; i-- { - svc := d.services[i] - if err := svc.Stop(); err != nil { - stopErrors = append(stopErrors, cli.Err("%s: %v", svc.Name(), err)) - } - } - - d.running = false - - if len(stopErrors) > 0 { - return cli.Err("errors stopping services: %v", stopErrors) - } - - return nil -} - -// Logs returns a reader for the specified service's logs. -// If service is empty, returns unified logs from all services. -func (d *DevServer) Logs(service string, follow bool) (io.ReadCloser, error) { - d.mu.RLock() - defer d.mu.RUnlock() - - if service == "" { - // Return unified logs - return d.unifiedLogs(follow) - } - - // Find specific service - for _, svc := range d.services { - if svc.Name() == service { - return svc.Logs(follow) - } - } - - return nil, cli.Err("service not found: %s", service) -} - -// unifiedLogs creates a reader that combines logs from all services. -func (d *DevServer) unifiedLogs(follow bool) (io.ReadCloser, error) { - readers := make([]io.ReadCloser, 0) - - for _, svc := range d.services { - reader, err := svc.Logs(follow) - if err != nil { - // Close any readers we already opened - for _, r := range readers { - _ = r.Close() - } - return nil, cli.Err("failed to get logs for %s: %v", svc.Name(), err) - } - readers = append(readers, reader) - } - - return newMultiServiceReader(d.services, readers, follow), nil -} - -// Status returns the status of all services. -func (d *DevServer) Status() []ServiceStatus { - d.mu.RLock() - defer d.mu.RUnlock() - - statuses := make([]ServiceStatus, 0, len(d.services)) - for _, svc := range d.services { - statuses = append(statuses, svc.Status()) - } - - return statuses -} - -// IsRunning returns true if the dev server is running. -func (d *DevServer) IsRunning() bool { - d.mu.RLock() - defer d.mu.RUnlock() - return d.running -} - -// Services returns the list of managed services. -func (d *DevServer) Services() []Service { - d.mu.RLock() - defer d.mu.RUnlock() - return d.services -} - -// multiServiceReader combines multiple service log readers. -type multiServiceReader struct { - services []Service - readers []io.ReadCloser - follow bool - closed bool - mu sync.RWMutex -} - -func newMultiServiceReader(services []Service, readers []io.ReadCloser, follow bool) *multiServiceReader { - return &multiServiceReader{ - services: services, - readers: readers, - follow: follow, - } -} - -func (m *multiServiceReader) Read(p []byte) (n int, err error) { - m.mu.RLock() - if m.closed { - m.mu.RUnlock() - return 0, io.EOF - } - m.mu.RUnlock() - - // Round-robin read from all readers - for i, reader := range m.readers { - buf := make([]byte, len(p)) - n, err := reader.Read(buf) - if n > 0 { - // Prefix with service name - prefix := cli.Sprintf("[%s] ", m.services[i].Name()) - copy(p, prefix) - copy(p[len(prefix):], buf[:n]) - return n + len(prefix), nil - } - if err != nil && err != io.EOF { - return 0, err - } - } - - if m.follow { - time.Sleep(100 * time.Millisecond) - return 0, nil - } - - return 0, io.EOF -} - -func (m *multiServiceReader) Close() error { - m.mu.Lock() - m.closed = true - m.mu.Unlock() - - var closeErr error - for _, reader := range m.readers { - if err := reader.Close(); err != nil && closeErr == nil { - closeErr = err - } - } - return closeErr -} diff --git a/internal/cmd/php/php_test.go b/internal/cmd/php/php_test.go deleted file mode 100644 index e295d73e..00000000 --- a/internal/cmd/php/php_test.go +++ /dev/null @@ -1,644 +0,0 @@ -package php - -import ( - "context" - "io" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNewDevServer_Good(t *testing.T) { - t.Run("creates dev server with default options", func(t *testing.T) { - opts := Options{} - server := NewDevServer(opts) - - assert.NotNil(t, server) - assert.Empty(t, server.services) - assert.False(t, server.running) - }) - - t.Run("creates dev server with custom options", func(t *testing.T) { - opts := Options{ - Dir: "/tmp/test", - NoVite: true, - NoHorizon: true, - FrankenPHPPort: 9000, - } - server := NewDevServer(opts) - - assert.NotNil(t, server) - assert.Equal(t, "/tmp/test", server.opts.Dir) - assert.True(t, server.opts.NoVite) - }) -} - -func TestDevServer_IsRunning_Good(t *testing.T) { - t.Run("returns false when not running", func(t *testing.T) { - server := NewDevServer(Options{}) - assert.False(t, server.IsRunning()) - }) -} - -func TestDevServer_Status_Good(t *testing.T) { - t.Run("returns empty status when no services", func(t *testing.T) { - server := NewDevServer(Options{}) - statuses := server.Status() - assert.Empty(t, statuses) - }) -} - -func TestDevServer_Services_Good(t *testing.T) { - t.Run("returns empty services list initially", func(t *testing.T) { - server := NewDevServer(Options{}) - services := server.Services() - assert.Empty(t, services) - }) -} - -func TestDevServer_Stop_Good(t *testing.T) { - t.Run("returns nil when not running", func(t *testing.T) { - server := NewDevServer(Options{}) - err := server.Stop() - assert.NoError(t, err) - }) -} - -func TestDevServer_Start_Bad(t *testing.T) { - t.Run("fails when already running", func(t *testing.T) { - server := NewDevServer(Options{}) - server.running = true - - err := server.Start(context.Background(), Options{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "already running") - }) - - t.Run("fails for non-Laravel project", func(t *testing.T) { - dir := t.TempDir() - server := NewDevServer(Options{Dir: dir}) - - err := server.Start(context.Background(), Options{Dir: dir}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not a Laravel project") - }) -} - -func TestDevServer_Logs_Bad(t *testing.T) { - t.Run("fails for non-existent service", func(t *testing.T) { - server := NewDevServer(Options{}) - - _, err := server.Logs("nonexistent", false) - assert.Error(t, err) - assert.Contains(t, err.Error(), "service not found") - }) -} - -func TestDevServer_filterServices_Good(t *testing.T) { - tests := []struct { - name string - services []DetectedService - opts Options - expected []DetectedService - }{ - { - name: "no filtering with default options", - services: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon}, - opts: Options{}, - expected: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon}, - }, - { - name: "filters Vite when NoVite is true", - services: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon}, - opts: Options{NoVite: true}, - expected: []DetectedService{ServiceFrankenPHP, ServiceHorizon}, - }, - { - name: "filters Horizon when NoHorizon is true", - services: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon}, - opts: Options{NoHorizon: true}, - expected: []DetectedService{ServiceFrankenPHP, ServiceVite}, - }, - { - name: "filters Reverb when NoReverb is true", - services: []DetectedService{ServiceFrankenPHP, ServiceReverb}, - opts: Options{NoReverb: true}, - expected: []DetectedService{ServiceFrankenPHP}, - }, - { - name: "filters Redis when NoRedis is true", - services: []DetectedService{ServiceFrankenPHP, ServiceRedis}, - opts: Options{NoRedis: true}, - expected: []DetectedService{ServiceFrankenPHP}, - }, - { - name: "filters multiple services", - services: []DetectedService{ServiceFrankenPHP, ServiceVite, ServiceHorizon, ServiceReverb, ServiceRedis}, - opts: Options{NoVite: true, NoHorizon: true, NoReverb: true, NoRedis: true}, - expected: []DetectedService{ServiceFrankenPHP}, - }, - { - name: "keeps unknown services", - services: []DetectedService{ServiceFrankenPHP}, - opts: Options{NoVite: true}, - expected: []DetectedService{ServiceFrankenPHP}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - server := NewDevServer(Options{}) - result := server.filterServices(tt.services, tt.opts) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestMultiServiceReader_Good(t *testing.T) { - t.Run("closes all readers on Close", func(t *testing.T) { - // Create mock readers using files - dir := t.TempDir() - file1, err := os.CreateTemp(dir, "log1-*.log") - require.NoError(t, err) - _, _ = file1.WriteString("test1") - _, _ = file1.Seek(0, 0) - - file2, err := os.CreateTemp(dir, "log2-*.log") - require.NoError(t, err) - _, _ = file2.WriteString("test2") - _, _ = file2.Seek(0, 0) - - // Create mock services - services := []Service{ - &FrankenPHPService{baseService: baseService{name: "svc1"}}, - &ViteService{baseService: baseService{name: "svc2"}}, - } - readers := []io.ReadCloser{file1, file2} - - reader := newMultiServiceReader(services, readers, false) - assert.NotNil(t, reader) - - err = reader.Close() - assert.NoError(t, err) - assert.True(t, reader.closed) - }) - - t.Run("returns EOF when closed", func(t *testing.T) { - reader := &multiServiceReader{closed: true} - buf := make([]byte, 10) - n, err := reader.Read(buf) - assert.Equal(t, 0, n) - assert.Equal(t, io.EOF, err) - }) -} - -func TestMultiServiceReader_Read_Good(t *testing.T) { - t.Run("reads from readers with service prefix", func(t *testing.T) { - dir := t.TempDir() - file1, err := os.CreateTemp(dir, "log-*.log") - require.NoError(t, err) - _, _ = file1.WriteString("log content") - _, _ = file1.Seek(0, 0) - - services := []Service{ - &FrankenPHPService{baseService: baseService{name: "TestService"}}, - } - readers := []io.ReadCloser{file1} - - reader := newMultiServiceReader(services, readers, false) - buf := make([]byte, 100) - n, err := reader.Read(buf) - - assert.NoError(t, err) - assert.Greater(t, n, 0) - result := string(buf[:n]) - assert.Contains(t, result, "[TestService]") - }) - - t.Run("returns EOF when all readers are exhausted in non-follow mode", func(t *testing.T) { - dir := t.TempDir() - file1, err := os.CreateTemp(dir, "log-*.log") - require.NoError(t, err) - _ = file1.Close() // Empty file - - file1, err = os.Open(file1.Name()) - require.NoError(t, err) - - services := []Service{ - &FrankenPHPService{baseService: baseService{name: "TestService"}}, - } - readers := []io.ReadCloser{file1} - - reader := newMultiServiceReader(services, readers, false) - buf := make([]byte, 100) - n, err := reader.Read(buf) - - assert.Equal(t, 0, n) - assert.Equal(t, io.EOF, err) - }) -} - -func TestOptions_Good(t *testing.T) { - t.Run("all fields are accessible", func(t *testing.T) { - opts := Options{ - Dir: "/test", - Services: []DetectedService{ServiceFrankenPHP}, - NoVite: true, - NoHorizon: true, - NoReverb: true, - NoRedis: true, - HTTPS: true, - Domain: "test.local", - FrankenPHPPort: 8000, - HTTPSPort: 443, - VitePort: 5173, - ReverbPort: 8080, - RedisPort: 6379, - } - - assert.Equal(t, "/test", opts.Dir) - assert.Equal(t, []DetectedService{ServiceFrankenPHP}, opts.Services) - assert.True(t, opts.NoVite) - assert.True(t, opts.NoHorizon) - assert.True(t, opts.NoReverb) - assert.True(t, opts.NoRedis) - assert.True(t, opts.HTTPS) - assert.Equal(t, "test.local", opts.Domain) - assert.Equal(t, 8000, opts.FrankenPHPPort) - assert.Equal(t, 443, opts.HTTPSPort) - assert.Equal(t, 5173, opts.VitePort) - assert.Equal(t, 8080, opts.ReverbPort) - assert.Equal(t, 6379, opts.RedisPort) - }) -} - -func TestDevServer_StartStop_Integration(t *testing.T) { - t.Skip("requires PHP/FrankenPHP installed") - - dir := t.TempDir() - setupLaravelProject(t, dir) - - server := NewDevServer(Options{Dir: dir}) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - err := server.Start(ctx, Options{Dir: dir}) - require.NoError(t, err) - assert.True(t, server.IsRunning()) - - err = server.Stop() - require.NoError(t, err) - assert.False(t, server.IsRunning()) -} - -// setupLaravelProject creates a minimal Laravel project structure for testing. -func setupLaravelProject(t *testing.T, dir string) { - t.Helper() - - // Create artisan file - err := os.WriteFile(filepath.Join(dir, "artisan"), []byte("#!/usr/bin/env php\n"), 0755) - require.NoError(t, err) - - // Create composer.json with Laravel - composerJSON := `{ - "name": "test/laravel-project", - "require": { - "php": "^8.2", - "laravel/framework": "^11.0", - "laravel/octane": "^2.0" - } - }` - err = os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) -} - -func TestDevServer_UnifiedLogs_Bad(t *testing.T) { - t.Run("returns error when service logs fail", func(t *testing.T) { - server := NewDevServer(Options{}) - - // Create a mock service that will fail to provide logs - mockService := &FrankenPHPService{ - baseService: baseService{ - name: "FailingService", - logPath: "", // No log path set will cause error - }, - } - server.services = []Service{mockService} - - _, err := server.Logs("", false) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to get logs") - }) -} - -func TestDevServer_Logs_Good(t *testing.T) { - t.Run("finds specific service logs", func(t *testing.T) { - dir := t.TempDir() - logFile := filepath.Join(dir, "test.log") - err := os.WriteFile(logFile, []byte("test log content"), 0644) - require.NoError(t, err) - - server := NewDevServer(Options{}) - mockService := &FrankenPHPService{ - baseService: baseService{ - name: "TestService", - logPath: logFile, - }, - } - server.services = []Service{mockService} - - reader, err := server.Logs("TestService", false) - assert.NoError(t, err) - assert.NotNil(t, reader) - _ = reader.Close() - }) -} - -func TestDevServer_MergeOptions_Good(t *testing.T) { - t.Run("start merges options correctly", func(t *testing.T) { - dir := t.TempDir() - server := NewDevServer(Options{Dir: "/original"}) - - // Setup a minimal non-Laravel project to trigger an error - // but still test the options merge happens first - err := server.Start(context.Background(), Options{Dir: dir}) - assert.Error(t, err) // Will fail because not Laravel project - // But the directory should have been merged - assert.Equal(t, dir, server.opts.Dir) - }) -} - -func TestDetectedService_Constants(t *testing.T) { - t.Run("all service constants are defined", func(t *testing.T) { - assert.Equal(t, DetectedService("frankenphp"), ServiceFrankenPHP) - assert.Equal(t, DetectedService("vite"), ServiceVite) - assert.Equal(t, DetectedService("horizon"), ServiceHorizon) - assert.Equal(t, DetectedService("reverb"), ServiceReverb) - assert.Equal(t, DetectedService("redis"), ServiceRedis) - }) -} - -func TestDevServer_HTTPSSetup(t *testing.T) { - t.Run("extracts domain from APP_URL when HTTPS enabled", func(t *testing.T) { - dir := t.TempDir() - - // Create Laravel project - err := os.WriteFile(filepath.Join(dir, "artisan"), []byte("#!/usr/bin/env php\n"), 0755) - require.NoError(t, err) - - composerJSON := `{ - "require": { - "laravel/framework": "^11.0", - "laravel/octane": "^2.0" - } - }` - err = os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644) - require.NoError(t, err) - - // Create .env with APP_URL - envContent := "APP_URL=https://myapp.test" - err = os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) - - // Verify we can extract the domain - url := GetLaravelAppURL(dir) - domain := ExtractDomainFromURL(url) - assert.Equal(t, "myapp.test", domain) - }) -} - -func TestDevServer_PortDefaults(t *testing.T) { - t.Run("uses default ports when not specified", func(t *testing.T) { - // This tests the logic in Start() for default port assignment - // We verify the constants/defaults by checking what would be created - - // FrankenPHP default port is 8000 - svc := NewFrankenPHPService("/tmp", FrankenPHPOptions{}) - assert.Equal(t, 8000, svc.port) - - // Vite default port is 5173 - vite := NewViteService("/tmp", ViteOptions{}) - assert.Equal(t, 5173, vite.port) - - // Reverb default port is 8080 - reverb := NewReverbService("/tmp", ReverbOptions{}) - assert.Equal(t, 8080, reverb.port) - - // Redis default port is 6379 - redis := NewRedisService("/tmp", RedisOptions{}) - assert.Equal(t, 6379, redis.port) - }) -} - -func TestDevServer_ServiceCreation(t *testing.T) { - t.Run("creates correct services based on detected services", func(t *testing.T) { - // Test that the switch statement in Start() creates the right service types - services := []DetectedService{ - ServiceFrankenPHP, - ServiceVite, - ServiceHorizon, - ServiceReverb, - ServiceRedis, - } - - // Verify each service type string - expected := []string{"frankenphp", "vite", "horizon", "reverb", "redis"} - for i, svc := range services { - assert.Equal(t, expected[i], string(svc)) - } - }) -} - -func TestMultiServiceReader_CloseError(t *testing.T) { - t.Run("returns first close error", func(t *testing.T) { - dir := t.TempDir() - - // Create a real file that we can close - file1, err := os.CreateTemp(dir, "log-*.log") - require.NoError(t, err) - file1Name := file1.Name() - _ = file1.Close() - - // Reopen for reading - file1, err = os.Open(file1Name) - require.NoError(t, err) - - services := []Service{ - &FrankenPHPService{baseService: baseService{name: "svc1"}}, - } - readers := []io.ReadCloser{file1} - - reader := newMultiServiceReader(services, readers, false) - err = reader.Close() - assert.NoError(t, err) - - // Second close should still work (files already closed) - // The closed flag prevents double-processing - assert.True(t, reader.closed) - }) -} - -func TestMultiServiceReader_FollowMode(t *testing.T) { - t.Run("returns 0 bytes without error in follow mode when no data", func(t *testing.T) { - dir := t.TempDir() - file1, err := os.CreateTemp(dir, "log-*.log") - require.NoError(t, err) - file1Name := file1.Name() - _ = file1.Close() - - // Reopen for reading (empty file) - file1, err = os.Open(file1Name) - require.NoError(t, err) - - services := []Service{ - &FrankenPHPService{baseService: baseService{name: "svc1"}}, - } - readers := []io.ReadCloser{file1} - - reader := newMultiServiceReader(services, readers, true) // follow=true - - // Use a channel to timeout the read since follow mode waits - done := make(chan bool) - go func() { - buf := make([]byte, 100) - n, err := reader.Read(buf) - // In follow mode, should return 0 bytes and nil error (waiting for more data) - assert.Equal(t, 0, n) - assert.NoError(t, err) - done <- true - }() - - select { - case <-done: - // Good, read completed - case <-time.After(500 * time.Millisecond): - // Also acceptable - follow mode is waiting - } - - _ = reader.Close() - }) -} - -func TestGetLaravelAppURL_Bad(t *testing.T) { - t.Run("no .env file", func(t *testing.T) { - dir := t.TempDir() - assert.Equal(t, "", GetLaravelAppURL(dir)) - }) - - t.Run("no APP_URL in .env", func(t *testing.T) { - dir := t.TempDir() - envContent := "APP_NAME=Test\nAPP_ENV=local" - err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) - - assert.Equal(t, "", GetLaravelAppURL(dir)) - }) -} - -func TestExtractDomainFromURL_Edge(t *testing.T) { - tests := []struct { - name string - url string - expected string - }{ - {"empty string", "", ""}, - {"just domain", "example.com", "example.com"}, - {"http only", "http://", ""}, - {"https only", "https://", ""}, - {"domain with trailing slash", "https://example.com/", "example.com"}, - {"complex path", "https://example.com:8080/path/to/page?query=1", "example.com"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Strip protocol - result := ExtractDomainFromURL(tt.url) - if tt.url != "" && !strings.HasPrefix(tt.url, "http://") && !strings.HasPrefix(tt.url, "https://") && !strings.Contains(tt.url, ":") && !strings.Contains(tt.url, "/") { - assert.Equal(t, tt.expected, result) - } - }) - } -} - -func TestDevServer_StatusWithServices(t *testing.T) { - t.Run("returns statuses for all services", func(t *testing.T) { - server := NewDevServer(Options{}) - - // Add mock services - server.services = []Service{ - &FrankenPHPService{baseService: baseService{name: "svc1", running: true, port: 8000}}, - &ViteService{baseService: baseService{name: "svc2", running: false, port: 5173}}, - } - - statuses := server.Status() - assert.Len(t, statuses, 2) - assert.Equal(t, "svc1", statuses[0].Name) - assert.True(t, statuses[0].Running) - assert.Equal(t, "svc2", statuses[1].Name) - assert.False(t, statuses[1].Running) - }) -} - -func TestDevServer_ServicesReturnsAll(t *testing.T) { - t.Run("returns all services", func(t *testing.T) { - server := NewDevServer(Options{}) - - // Add mock services - server.services = []Service{ - &FrankenPHPService{baseService: baseService{name: "svc1"}}, - &ViteService{baseService: baseService{name: "svc2"}}, - &HorizonService{baseService: baseService{name: "svc3"}}, - } - - services := server.Services() - assert.Len(t, services, 3) - }) -} - -func TestDevServer_StopWithCancel(t *testing.T) { - t.Run("calls cancel when running", func(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - server := NewDevServer(Options{}) - server.running = true - server.cancel = cancel - server.ctx = ctx - - // Add a mock service that won't error - server.services = []Service{ - &FrankenPHPService{baseService: baseService{name: "svc1", running: false}}, - } - - err := server.Stop() - assert.NoError(t, err) - assert.False(t, server.running) - }) -} - -func TestMultiServiceReader_CloseWithErrors(t *testing.T) { - t.Run("handles multiple close errors", func(t *testing.T) { - dir := t.TempDir() - - // Create files - file1, err := os.CreateTemp(dir, "log1-*.log") - require.NoError(t, err) - file2, err := os.CreateTemp(dir, "log2-*.log") - require.NoError(t, err) - - services := []Service{ - &FrankenPHPService{baseService: baseService{name: "svc1"}}, - &ViteService{baseService: baseService{name: "svc2"}}, - } - readers := []io.ReadCloser{file1, file2} - - reader := newMultiServiceReader(services, readers, false) - - // Close successfully - err = reader.Close() - assert.NoError(t, err) - }) -} diff --git a/internal/cmd/php/quality.go b/internal/cmd/php/quality.go deleted file mode 100644 index a7f96388..00000000 --- a/internal/cmd/php/quality.go +++ /dev/null @@ -1,994 +0,0 @@ -package php - -import ( - "context" - "encoding/json" - goio "io" - "os" - "os/exec" - "path/filepath" - "strings" - - "forge.lthn.ai/core/go/pkg/cli" - "forge.lthn.ai/core/go/pkg/i18n" -) - -// FormatOptions configures PHP code formatting. -type FormatOptions struct { - // Dir is the project directory (defaults to current working directory). - Dir string - - // Fix automatically fixes formatting issues. - Fix bool - - // Diff shows a diff of changes instead of modifying files. - Diff bool - - // JSON outputs results in JSON format. - JSON bool - - // Paths limits formatting to specific paths. - Paths []string - - // Output is the writer for output (defaults to os.Stdout). - Output goio.Writer -} - -// AnalyseOptions configures PHP static analysis. -type AnalyseOptions struct { - // Dir is the project directory (defaults to current working directory). - Dir string - - // Level is the PHPStan analysis level (0-9). - Level int - - // Paths limits analysis to specific paths. - Paths []string - - // Memory is the memory limit for analysis (e.g., "2G"). - Memory string - - // JSON outputs results in JSON format. - JSON bool - - // SARIF outputs results in SARIF format for GitHub Security tab. - SARIF bool - - // Output is the writer for output (defaults to os.Stdout). - Output goio.Writer -} - -// FormatterType represents the detected formatter. -type FormatterType string - -// Formatter type constants. -const ( - // FormatterPint indicates Laravel Pint code formatter. - FormatterPint FormatterType = "pint" -) - -// AnalyserType represents the detected static analyser. -type AnalyserType string - -// Static analyser type constants. -const ( - // AnalyserPHPStan indicates standard PHPStan analyser. - AnalyserPHPStan AnalyserType = "phpstan" - // AnalyserLarastan indicates Laravel-specific Larastan analyser. - AnalyserLarastan AnalyserType = "larastan" -) - -// DetectFormatter detects which formatter is available in the project. -func DetectFormatter(dir string) (FormatterType, bool) { - m := getMedium() - - // Check for Pint config - pintConfig := filepath.Join(dir, "pint.json") - if m.Exists(pintConfig) { - return FormatterPint, true - } - - // Check for vendor binary - pintBin := filepath.Join(dir, "vendor", "bin", "pint") - if m.Exists(pintBin) { - return FormatterPint, true - } - - return "", false -} - -// DetectAnalyser detects which static analyser is available in the project. -func DetectAnalyser(dir string) (AnalyserType, bool) { - m := getMedium() - - // Check for PHPStan config - phpstanConfig := filepath.Join(dir, "phpstan.neon") - phpstanDistConfig := filepath.Join(dir, "phpstan.neon.dist") - - hasConfig := m.Exists(phpstanConfig) || m.Exists(phpstanDistConfig) - - // Check for vendor binary - phpstanBin := filepath.Join(dir, "vendor", "bin", "phpstan") - hasBin := m.Exists(phpstanBin) - - if hasConfig || hasBin { - // Check if it's Larastan (Laravel-specific PHPStan) - larastanPath := filepath.Join(dir, "vendor", "larastan", "larastan") - if m.Exists(larastanPath) { - return AnalyserLarastan, true - } - // Also check nunomaduro/larastan - larastanPath2 := filepath.Join(dir, "vendor", "nunomaduro", "larastan") - if m.Exists(larastanPath2) { - return AnalyserLarastan, true - } - return AnalyserPHPStan, true - } - - return "", false -} - -// Format runs Laravel Pint to format PHP code. -func Format(ctx context.Context, opts FormatOptions) error { - if opts.Dir == "" { - cwd, err := os.Getwd() - if err != nil { - return cli.WrapVerb(err, "get", "working directory") - } - opts.Dir = cwd - } - - if opts.Output == nil { - opts.Output = os.Stdout - } - - // Check if formatter is available - formatter, found := DetectFormatter(opts.Dir) - if !found { - return cli.Err("no formatter found (install Laravel Pint: composer require laravel/pint --dev)") - } - - var cmdName string - var args []string - - switch formatter { - case FormatterPint: - cmdName, args = buildPintCommand(opts) - } - - cmd := exec.CommandContext(ctx, cmdName, args...) - cmd.Dir = opts.Dir - cmd.Stdout = opts.Output - cmd.Stderr = opts.Output - - return cmd.Run() -} - -// Analyse runs PHPStan or Larastan for static analysis. -func Analyse(ctx context.Context, opts AnalyseOptions) error { - if opts.Dir == "" { - cwd, err := os.Getwd() - if err != nil { - return cli.WrapVerb(err, "get", "working directory") - } - opts.Dir = cwd - } - - if opts.Output == nil { - opts.Output = os.Stdout - } - - // Check if analyser is available - analyser, found := DetectAnalyser(opts.Dir) - if !found { - return cli.Err("no static analyser found (install PHPStan: composer require phpstan/phpstan --dev)") - } - - var cmdName string - var args []string - - switch analyser { - case AnalyserPHPStan, AnalyserLarastan: - cmdName, args = buildPHPStanCommand(opts) - } - - cmd := exec.CommandContext(ctx, cmdName, args...) - cmd.Dir = opts.Dir - cmd.Stdout = opts.Output - cmd.Stderr = opts.Output - - return cmd.Run() -} - -// buildPintCommand builds the command for running Laravel Pint. -func buildPintCommand(opts FormatOptions) (string, []string) { - m := getMedium() - - // Check for vendor binary first - vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "pint") - cmdName := "pint" - if m.Exists(vendorBin) { - cmdName = vendorBin - } - - var args []string - - if !opts.Fix { - args = append(args, "--test") - } - - if opts.Diff { - args = append(args, "--diff") - } - - if opts.JSON { - args = append(args, "--format=json") - } - - // Add specific paths if provided - args = append(args, opts.Paths...) - - return cmdName, args -} - -// buildPHPStanCommand builds the command for running PHPStan. -func buildPHPStanCommand(opts AnalyseOptions) (string, []string) { - m := getMedium() - - // Check for vendor binary first - vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "phpstan") - cmdName := "phpstan" - if m.Exists(vendorBin) { - cmdName = vendorBin - } - - args := []string{"analyse"} - - if opts.Level > 0 { - args = append(args, "--level", cli.Sprintf("%d", opts.Level)) - } - - if opts.Memory != "" { - args = append(args, "--memory-limit", opts.Memory) - } - - // Output format - SARIF takes precedence over JSON - if opts.SARIF { - args = append(args, "--error-format=sarif") - } else if opts.JSON { - args = append(args, "--error-format=json") - } - - // Add specific paths if provided - args = append(args, opts.Paths...) - - return cmdName, args -} - -// ============================================================================= -// Psalm Static Analysis -// ============================================================================= - -// PsalmOptions configures Psalm static analysis. -type PsalmOptions struct { - Dir string - Level int // Error level (1=strictest, 8=most lenient) - Fix bool // Auto-fix issues where possible - Baseline bool // Generate/update baseline file - ShowInfo bool // Show info-level issues - JSON bool // Output in JSON format - SARIF bool // Output in SARIF format for GitHub Security tab - Output goio.Writer -} - -// PsalmType represents the detected Psalm configuration. -type PsalmType string - -// Psalm configuration type constants. -const ( - // PsalmStandard indicates standard Psalm configuration. - PsalmStandard PsalmType = "psalm" -) - -// DetectPsalm checks if Psalm is available in the project. -func DetectPsalm(dir string) (PsalmType, bool) { - m := getMedium() - - // Check for psalm.xml config - psalmConfig := filepath.Join(dir, "psalm.xml") - psalmDistConfig := filepath.Join(dir, "psalm.xml.dist") - - hasConfig := m.Exists(psalmConfig) || m.Exists(psalmDistConfig) - - // Check for vendor binary - psalmBin := filepath.Join(dir, "vendor", "bin", "psalm") - if m.Exists(psalmBin) { - return PsalmStandard, true - } - - if hasConfig { - return PsalmStandard, true - } - - return "", false -} - -// RunPsalm runs Psalm static analysis. -func RunPsalm(ctx context.Context, opts PsalmOptions) error { - if opts.Dir == "" { - cwd, err := os.Getwd() - if err != nil { - return cli.WrapVerb(err, "get", "working directory") - } - opts.Dir = cwd - } - - if opts.Output == nil { - opts.Output = os.Stdout - } - - m := getMedium() - - // Build command - vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "psalm") - cmdName := "psalm" - if m.Exists(vendorBin) { - cmdName = vendorBin - } - - args := []string{"--no-progress"} - - if opts.Level > 0 && opts.Level <= 8 { - args = append(args, cli.Sprintf("--error-level=%d", opts.Level)) - } - - if opts.Fix { - args = append(args, "--alter", "--issues=all") - } - - if opts.Baseline { - args = append(args, "--set-baseline=psalm-baseline.xml") - } - - if opts.ShowInfo { - args = append(args, "--show-info=true") - } - - // Output format - SARIF takes precedence over JSON - if opts.SARIF { - args = append(args, "--output-format=sarif") - } else if opts.JSON { - args = append(args, "--output-format=json") - } - - cmd := exec.CommandContext(ctx, cmdName, args...) - cmd.Dir = opts.Dir - cmd.Stdout = opts.Output - cmd.Stderr = opts.Output - - return cmd.Run() -} - -// ============================================================================= -// Security Audit -// ============================================================================= - -// AuditOptions configures dependency security auditing. -type AuditOptions struct { - Dir string - JSON bool // Output in JSON format - Fix bool // Auto-fix vulnerabilities (npm only) - Output goio.Writer -} - -// AuditResult holds the results of a security audit. -type AuditResult struct { - Tool string - Vulnerabilities int - Advisories []AuditAdvisory - Error error -} - -// AuditAdvisory represents a single security advisory. -type AuditAdvisory struct { - Package string - Severity string - Title string - URL string - Identifiers []string -} - -// RunAudit runs security audits on dependencies. -func RunAudit(ctx context.Context, opts AuditOptions) ([]AuditResult, error) { - if opts.Dir == "" { - cwd, err := os.Getwd() - if err != nil { - return nil, cli.WrapVerb(err, "get", "working directory") - } - opts.Dir = cwd - } - - if opts.Output == nil { - opts.Output = os.Stdout - } - - var results []AuditResult - - // Run composer audit - composerResult := runComposerAudit(ctx, opts) - results = append(results, composerResult) - - // Run npm audit if package.json exists - if getMedium().Exists(filepath.Join(opts.Dir, "package.json")) { - npmResult := runNpmAudit(ctx, opts) - results = append(results, npmResult) - } - - return results, nil -} - -func runComposerAudit(ctx context.Context, opts AuditOptions) AuditResult { - result := AuditResult{Tool: "composer"} - - args := []string{"audit", "--format=json"} - - cmd := exec.CommandContext(ctx, "composer", args...) - cmd.Dir = opts.Dir - - output, err := cmd.Output() - if err != nil { - // composer audit returns non-zero if vulnerabilities found - if exitErr, ok := err.(*exec.ExitError); ok { - output = append(output, exitErr.Stderr...) - } - } - - // Parse JSON output - var auditData struct { - Advisories map[string][]struct { - Title string `json:"title"` - Link string `json:"link"` - CVE string `json:"cve"` - AffectedRanges string `json:"affectedVersions"` - } `json:"advisories"` - } - - if jsonErr := json.Unmarshal(output, &auditData); jsonErr == nil { - for pkg, advisories := range auditData.Advisories { - for _, adv := range advisories { - result.Advisories = append(result.Advisories, AuditAdvisory{ - Package: pkg, - Title: adv.Title, - URL: adv.Link, - Identifiers: []string{adv.CVE}, - }) - } - } - result.Vulnerabilities = len(result.Advisories) - } else if err != nil { - result.Error = err - } - - return result -} - -func runNpmAudit(ctx context.Context, opts AuditOptions) AuditResult { - result := AuditResult{Tool: "npm"} - - args := []string{"audit", "--json"} - if opts.Fix { - args = []string{"audit", "fix"} - } - - cmd := exec.CommandContext(ctx, "npm", args...) - cmd.Dir = opts.Dir - - output, err := cmd.Output() - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - output = append(output, exitErr.Stderr...) - } - } - - if !opts.Fix { - // Parse JSON output - var auditData struct { - Metadata struct { - Vulnerabilities struct { - Total int `json:"total"` - } `json:"vulnerabilities"` - } `json:"metadata"` - Vulnerabilities map[string]struct { - Severity string `json:"severity"` - Via []any `json:"via"` - } `json:"vulnerabilities"` - } - - if jsonErr := json.Unmarshal(output, &auditData); jsonErr == nil { - result.Vulnerabilities = auditData.Metadata.Vulnerabilities.Total - for pkg, vuln := range auditData.Vulnerabilities { - result.Advisories = append(result.Advisories, AuditAdvisory{ - Package: pkg, - Severity: vuln.Severity, - }) - } - } else if err != nil { - result.Error = err - } - } - - return result -} - -// ============================================================================= -// Rector Automated Refactoring -// ============================================================================= - -// RectorOptions configures Rector code refactoring. -type RectorOptions struct { - Dir string - Fix bool // Apply changes (default is dry-run) - Diff bool // Show detailed diff - ClearCache bool // Clear cache before running - Output goio.Writer -} - -// DetectRector checks if Rector is available in the project. -func DetectRector(dir string) bool { - m := getMedium() - - // Check for rector.php config - rectorConfig := filepath.Join(dir, "rector.php") - if m.Exists(rectorConfig) { - return true - } - - // Check for vendor binary - rectorBin := filepath.Join(dir, "vendor", "bin", "rector") - if m.Exists(rectorBin) { - return true - } - - return false -} - -// RunRector runs Rector for automated code refactoring. -func RunRector(ctx context.Context, opts RectorOptions) error { - if opts.Dir == "" { - cwd, err := os.Getwd() - if err != nil { - return cli.WrapVerb(err, "get", "working directory") - } - opts.Dir = cwd - } - - if opts.Output == nil { - opts.Output = os.Stdout - } - - m := getMedium() - - // Build command - vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "rector") - cmdName := "rector" - if m.Exists(vendorBin) { - cmdName = vendorBin - } - - args := []string{"process"} - - if !opts.Fix { - args = append(args, "--dry-run") - } - - if opts.Diff { - args = append(args, "--output-format", "diff") - } - - if opts.ClearCache { - args = append(args, "--clear-cache") - } - - cmd := exec.CommandContext(ctx, cmdName, args...) - cmd.Dir = opts.Dir - cmd.Stdout = opts.Output - cmd.Stderr = opts.Output - - return cmd.Run() -} - -// ============================================================================= -// Infection Mutation Testing -// ============================================================================= - -// InfectionOptions configures Infection mutation testing. -type InfectionOptions struct { - Dir string - MinMSI int // Minimum mutation score indicator (0-100) - MinCoveredMSI int // Minimum covered mutation score (0-100) - Threads int // Number of parallel threads - Filter string // Filter files by pattern - OnlyCovered bool // Only mutate covered code - Output goio.Writer -} - -// DetectInfection checks if Infection is available in the project. -func DetectInfection(dir string) bool { - m := getMedium() - - // Check for infection config files - configs := []string{"infection.json", "infection.json5", "infection.json.dist"} - for _, config := range configs { - if m.Exists(filepath.Join(dir, config)) { - return true - } - } - - // Check for vendor binary - infectionBin := filepath.Join(dir, "vendor", "bin", "infection") - if m.Exists(infectionBin) { - return true - } - - return false -} - -// RunInfection runs Infection mutation testing. -func RunInfection(ctx context.Context, opts InfectionOptions) error { - if opts.Dir == "" { - cwd, err := os.Getwd() - if err != nil { - return cli.WrapVerb(err, "get", "working directory") - } - opts.Dir = cwd - } - - if opts.Output == nil { - opts.Output = os.Stdout - } - - m := getMedium() - - // Build command - vendorBin := filepath.Join(opts.Dir, "vendor", "bin", "infection") - cmdName := "infection" - if m.Exists(vendorBin) { - cmdName = vendorBin - } - - var args []string - - // Set defaults - minMSI := opts.MinMSI - if minMSI == 0 { - minMSI = 50 - } - minCoveredMSI := opts.MinCoveredMSI - if minCoveredMSI == 0 { - minCoveredMSI = 70 - } - threads := opts.Threads - if threads == 0 { - threads = 4 - } - - args = append(args, cli.Sprintf("--min-msi=%d", minMSI)) - args = append(args, cli.Sprintf("--min-covered-msi=%d", minCoveredMSI)) - args = append(args, cli.Sprintf("--threads=%d", threads)) - - if opts.Filter != "" { - args = append(args, "--filter="+opts.Filter) - } - - if opts.OnlyCovered { - args = append(args, "--only-covered") - } - - cmd := exec.CommandContext(ctx, cmdName, args...) - cmd.Dir = opts.Dir - cmd.Stdout = opts.Output - cmd.Stderr = opts.Output - - return cmd.Run() -} - -// ============================================================================= -// QA Pipeline -// ============================================================================= - -// QAOptions configures the full QA pipeline. -type QAOptions struct { - Dir string - Quick bool // Only run quick checks - Full bool // Run all stages including slow checks - Fix bool // Auto-fix issues where possible - JSON bool // Output results as JSON -} - -// QAStage represents a stage in the QA pipeline. -type QAStage string - -// QA pipeline stage constants. -const ( - // QAStageQuick runs fast checks only (audit, fmt, stan). - QAStageQuick QAStage = "quick" - // QAStageStandard runs standard checks including tests. - QAStageStandard QAStage = "standard" - // QAStageFull runs all checks including slow security scans. - QAStageFull QAStage = "full" -) - -// QACheckResult holds the result of a single QA check. -type QACheckResult struct { - Name string - Stage QAStage - Passed bool - Duration string - Error error - Output string -} - -// QAResult holds the results of the full QA pipeline. -type QAResult struct { - Stages []QAStage - Checks []QACheckResult - Passed bool - Summary string -} - -// GetQAStages returns the stages to run based on options. -func GetQAStages(opts QAOptions) []QAStage { - if opts.Quick { - return []QAStage{QAStageQuick} - } - if opts.Full { - return []QAStage{QAStageQuick, QAStageStandard, QAStageFull} - } - // Default: quick + standard - return []QAStage{QAStageQuick, QAStageStandard} -} - -// GetQAChecks returns the checks for a given stage. -func GetQAChecks(dir string, stage QAStage) []string { - switch stage { - case QAStageQuick: - checks := []string{"audit", "fmt", "stan"} - return checks - case QAStageStandard: - checks := []string{} - if _, found := DetectPsalm(dir); found { - checks = append(checks, "psalm") - } - checks = append(checks, "test") - return checks - case QAStageFull: - checks := []string{} - if DetectRector(dir) { - checks = append(checks, "rector") - } - if DetectInfection(dir) { - checks = append(checks, "infection") - } - return checks - } - return nil -} - -// ============================================================================= -// Security Checks -// ============================================================================= - -// SecurityOptions configures security scanning. -type SecurityOptions struct { - Dir string - Severity string // Minimum severity (critical, high, medium, low) - JSON bool // Output in JSON format - SARIF bool // Output in SARIF format - URL string // URL to check HTTP headers (optional) - Output goio.Writer -} - -// SecurityResult holds the results of security scanning. -type SecurityResult struct { - Checks []SecurityCheck - Summary SecuritySummary -} - -// SecurityCheck represents a single security check result. -type SecurityCheck struct { - ID string - Name string - Description string - Severity string - Passed bool - Message string - Fix string - CWE string -} - -// SecuritySummary summarizes security check results. -type SecuritySummary struct { - Total int - Passed int - Critical int - High int - Medium int - Low int -} - -// RunSecurityChecks runs security checks on the project. -func RunSecurityChecks(ctx context.Context, opts SecurityOptions) (*SecurityResult, error) { - if opts.Dir == "" { - cwd, err := os.Getwd() - if err != nil { - return nil, cli.WrapVerb(err, "get", "working directory") - } - opts.Dir = cwd - } - - result := &SecurityResult{} - - // Run composer audit - auditResults, _ := RunAudit(ctx, AuditOptions{Dir: opts.Dir}) - for _, audit := range auditResults { - check := SecurityCheck{ - ID: audit.Tool + "_audit", - Name: i18n.Title(audit.Tool) + " Security Audit", - Description: "Check " + audit.Tool + " dependencies for vulnerabilities", - Severity: "critical", - Passed: audit.Vulnerabilities == 0 && audit.Error == nil, - CWE: "CWE-1395", - } - if !check.Passed { - check.Message = cli.Sprintf("Found %d vulnerabilities", audit.Vulnerabilities) - } - result.Checks = append(result.Checks, check) - } - - // Check .env file for security issues - envChecks := runEnvSecurityChecks(opts.Dir) - result.Checks = append(result.Checks, envChecks...) - - // Check filesystem security - fsChecks := runFilesystemSecurityChecks(opts.Dir) - result.Checks = append(result.Checks, fsChecks...) - - // Calculate summary - for _, check := range result.Checks { - result.Summary.Total++ - if check.Passed { - result.Summary.Passed++ - } else { - switch check.Severity { - case "critical": - result.Summary.Critical++ - case "high": - result.Summary.High++ - case "medium": - result.Summary.Medium++ - case "low": - result.Summary.Low++ - } - } - } - - return result, nil -} - -func runEnvSecurityChecks(dir string) []SecurityCheck { - var checks []SecurityCheck - - m := getMedium() - envPath := filepath.Join(dir, ".env") - envContent, err := m.Read(envPath) - if err != nil { - return checks - } - - envLines := strings.Split(envContent, "\n") - envMap := make(map[string]string) - for _, line := range envLines { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - parts := strings.SplitN(line, "=", 2) - if len(parts) == 2 { - envMap[parts[0]] = parts[1] - } - } - - // Check APP_DEBUG - if debug, ok := envMap["APP_DEBUG"]; ok { - check := SecurityCheck{ - ID: "debug_mode", - Name: "Debug Mode Disabled", - Description: "APP_DEBUG should be false in production", - Severity: "critical", - Passed: strings.ToLower(debug) != "true", - CWE: "CWE-215", - } - if !check.Passed { - check.Message = "Debug mode exposes sensitive information" - check.Fix = "Set APP_DEBUG=false in .env" - } - checks = append(checks, check) - } - - // Check APP_KEY - if key, ok := envMap["APP_KEY"]; ok { - check := SecurityCheck{ - ID: "app_key_set", - Name: "Application Key Set", - Description: "APP_KEY must be set and valid", - Severity: "critical", - Passed: len(key) >= 32, - CWE: "CWE-321", - } - if !check.Passed { - check.Message = "Missing or weak encryption key" - check.Fix = "Run: php artisan key:generate" - } - checks = append(checks, check) - } - - // Check APP_URL for HTTPS - if url, ok := envMap["APP_URL"]; ok { - check := SecurityCheck{ - ID: "https_enforced", - Name: "HTTPS Enforced", - Description: "APP_URL should use HTTPS in production", - Severity: "high", - Passed: strings.HasPrefix(url, "https://"), - CWE: "CWE-319", - } - if !check.Passed { - check.Message = "Application not using HTTPS" - check.Fix = "Update APP_URL to use https://" - } - checks = append(checks, check) - } - - return checks -} - -func runFilesystemSecurityChecks(dir string) []SecurityCheck { - var checks []SecurityCheck - m := getMedium() - - // Check .env not in public - publicEnvPaths := []string{"public/.env", "public_html/.env"} - for _, path := range publicEnvPaths { - fullPath := filepath.Join(dir, path) - if m.Exists(fullPath) { - checks = append(checks, SecurityCheck{ - ID: "env_not_public", - Name: ".env Not Publicly Accessible", - Description: ".env file should not be in public directory", - Severity: "critical", - Passed: false, - Message: "Environment file exposed to web at " + path, - CWE: "CWE-538", - }) - } - } - - // Check .git not in public - publicGitPaths := []string{"public/.git", "public_html/.git"} - for _, path := range publicGitPaths { - fullPath := filepath.Join(dir, path) - if m.Exists(fullPath) { - checks = append(checks, SecurityCheck{ - ID: "git_not_public", - Name: ".git Not Publicly Accessible", - Description: ".git directory should not be in public", - Severity: "critical", - Passed: false, - Message: "Git repository exposed to web (source code leak)", - CWE: "CWE-538", - }) - } - } - - return checks -} diff --git a/internal/cmd/php/quality_extended_test.go b/internal/cmd/php/quality_extended_test.go deleted file mode 100644 index 8c1c00e3..00000000 --- a/internal/cmd/php/quality_extended_test.go +++ /dev/null @@ -1,304 +0,0 @@ -package php - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestFormatOptions_Struct(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - opts := FormatOptions{ - Dir: "/project", - Fix: true, - Diff: true, - Paths: []string{"app", "tests"}, - Output: os.Stdout, - } - - assert.Equal(t, "/project", opts.Dir) - assert.True(t, opts.Fix) - assert.True(t, opts.Diff) - assert.Equal(t, []string{"app", "tests"}, opts.Paths) - assert.NotNil(t, opts.Output) - }) -} - -func TestAnalyseOptions_Struct(t *testing.T) { - t.Run("all fields accessible", func(t *testing.T) { - opts := AnalyseOptions{ - Dir: "/project", - Level: 5, - Paths: []string{"src"}, - Memory: "2G", - Output: os.Stdout, - } - - assert.Equal(t, "/project", opts.Dir) - assert.Equal(t, 5, opts.Level) - assert.Equal(t, []string{"src"}, opts.Paths) - assert.Equal(t, "2G", opts.Memory) - assert.NotNil(t, opts.Output) - }) -} - -func TestFormatterType_Constants(t *testing.T) { - t.Run("constants are defined", func(t *testing.T) { - assert.Equal(t, FormatterType("pint"), FormatterPint) - }) -} - -func TestAnalyserType_Constants(t *testing.T) { - t.Run("constants are defined", func(t *testing.T) { - assert.Equal(t, AnalyserType("phpstan"), AnalyserPHPStan) - assert.Equal(t, AnalyserType("larastan"), AnalyserLarastan) - }) -} - -func TestDetectFormatter_Extended(t *testing.T) { - t.Run("returns not found for empty directory", func(t *testing.T) { - dir := t.TempDir() - _, found := DetectFormatter(dir) - assert.False(t, found) - }) - - t.Run("prefers pint.json over vendor binary", func(t *testing.T) { - dir := t.TempDir() - - // Create pint.json - err := os.WriteFile(filepath.Join(dir, "pint.json"), []byte("{}"), 0644) - require.NoError(t, err) - - formatter, found := DetectFormatter(dir) - assert.True(t, found) - assert.Equal(t, FormatterPint, formatter) - }) -} - -func TestDetectAnalyser_Extended(t *testing.T) { - t.Run("returns not found for empty directory", func(t *testing.T) { - dir := t.TempDir() - _, found := DetectAnalyser(dir) - assert.False(t, found) - }) - - t.Run("detects phpstan from vendor binary alone", func(t *testing.T) { - dir := t.TempDir() - - // Create vendor binary - binDir := filepath.Join(dir, "vendor", "bin") - err := os.MkdirAll(binDir, 0755) - require.NoError(t, err) - - err = os.WriteFile(filepath.Join(binDir, "phpstan"), []byte(""), 0755) - require.NoError(t, err) - - analyser, found := DetectAnalyser(dir) - assert.True(t, found) - assert.Equal(t, AnalyserPHPStan, analyser) - }) - - t.Run("detects larastan from larastan/larastan vendor path", func(t *testing.T) { - dir := t.TempDir() - - // Create phpstan.neon - err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644) - require.NoError(t, err) - - // Create larastan/larastan path - larastanPath := filepath.Join(dir, "vendor", "larastan", "larastan") - err = os.MkdirAll(larastanPath, 0755) - require.NoError(t, err) - - analyser, found := DetectAnalyser(dir) - assert.True(t, found) - assert.Equal(t, AnalyserLarastan, analyser) - }) - - t.Run("detects larastan from nunomaduro/larastan vendor path", func(t *testing.T) { - dir := t.TempDir() - - // Create phpstan.neon - err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644) - require.NoError(t, err) - - // Create nunomaduro/larastan path - larastanPath := filepath.Join(dir, "vendor", "nunomaduro", "larastan") - err = os.MkdirAll(larastanPath, 0755) - require.NoError(t, err) - - analyser, found := DetectAnalyser(dir) - assert.True(t, found) - assert.Equal(t, AnalyserLarastan, analyser) - }) -} - -func TestBuildPintCommand_Extended(t *testing.T) { - t.Run("uses global pint when no vendor binary", func(t *testing.T) { - dir := t.TempDir() - opts := FormatOptions{Dir: dir} - - cmd, _ := buildPintCommand(opts) - assert.Equal(t, "pint", cmd) - }) - - t.Run("adds test flag when Fix is false", func(t *testing.T) { - dir := t.TempDir() - opts := FormatOptions{Dir: dir, Fix: false} - - _, args := buildPintCommand(opts) - assert.Contains(t, args, "--test") - }) - - t.Run("does not add test flag when Fix is true", func(t *testing.T) { - dir := t.TempDir() - opts := FormatOptions{Dir: dir, Fix: true} - - _, args := buildPintCommand(opts) - assert.NotContains(t, args, "--test") - }) - - t.Run("adds diff flag", func(t *testing.T) { - dir := t.TempDir() - opts := FormatOptions{Dir: dir, Diff: true} - - _, args := buildPintCommand(opts) - assert.Contains(t, args, "--diff") - }) - - t.Run("adds paths", func(t *testing.T) { - dir := t.TempDir() - opts := FormatOptions{Dir: dir, Paths: []string{"app", "tests"}} - - _, args := buildPintCommand(opts) - assert.Contains(t, args, "app") - assert.Contains(t, args, "tests") - }) -} - -func TestBuildPHPStanCommand_Extended(t *testing.T) { - t.Run("uses global phpstan when no vendor binary", func(t *testing.T) { - dir := t.TempDir() - opts := AnalyseOptions{Dir: dir} - - cmd, _ := buildPHPStanCommand(opts) - assert.Equal(t, "phpstan", cmd) - }) - - t.Run("adds level flag", func(t *testing.T) { - dir := t.TempDir() - opts := AnalyseOptions{Dir: dir, Level: 8} - - _, args := buildPHPStanCommand(opts) - assert.Contains(t, args, "--level") - assert.Contains(t, args, "8") - }) - - t.Run("does not add level flag when zero", func(t *testing.T) { - dir := t.TempDir() - opts := AnalyseOptions{Dir: dir, Level: 0} - - _, args := buildPHPStanCommand(opts) - assert.NotContains(t, args, "--level") - }) - - t.Run("adds memory limit", func(t *testing.T) { - dir := t.TempDir() - opts := AnalyseOptions{Dir: dir, Memory: "4G"} - - _, args := buildPHPStanCommand(opts) - assert.Contains(t, args, "--memory-limit") - assert.Contains(t, args, "4G") - }) - - t.Run("does not add memory flag when empty", func(t *testing.T) { - dir := t.TempDir() - opts := AnalyseOptions{Dir: dir, Memory: ""} - - _, args := buildPHPStanCommand(opts) - assert.NotContains(t, args, "--memory-limit") - }) - - t.Run("adds paths", func(t *testing.T) { - dir := t.TempDir() - opts := AnalyseOptions{Dir: dir, Paths: []string{"src", "app"}} - - _, args := buildPHPStanCommand(opts) - assert.Contains(t, args, "src") - assert.Contains(t, args, "app") - }) -} - -func TestFormat_Bad(t *testing.T) { - t.Run("fails when no formatter found", func(t *testing.T) { - dir := t.TempDir() - opts := FormatOptions{Dir: dir} - - err := Format(context.TODO(), opts) - assert.Error(t, err) - assert.Contains(t, err.Error(), "no formatter found") - }) - - t.Run("uses cwd when dir not specified", func(t *testing.T) { - // When no formatter found in cwd, should still fail with "no formatter found" - opts := FormatOptions{Dir: ""} - - err := Format(context.TODO(), opts) - // May or may not find a formatter depending on cwd, but function should not panic - if err != nil { - // Expected - no formatter in cwd - assert.Contains(t, err.Error(), "no formatter") - } - }) - - t.Run("uses stdout when output not specified", func(t *testing.T) { - dir := t.TempDir() - // Create pint.json to enable formatter detection - err := os.WriteFile(filepath.Join(dir, "pint.json"), []byte("{}"), 0644) - require.NoError(t, err) - - opts := FormatOptions{Dir: dir, Output: nil} - - // Will fail because pint isn't actually installed, but tests the code path - err = Format(context.Background(), opts) - assert.Error(t, err) // Pint not installed - }) -} - -func TestAnalyse_Bad(t *testing.T) { - t.Run("fails when no analyser found", func(t *testing.T) { - dir := t.TempDir() - opts := AnalyseOptions{Dir: dir} - - err := Analyse(context.TODO(), opts) - assert.Error(t, err) - assert.Contains(t, err.Error(), "no static analyser found") - }) - - t.Run("uses cwd when dir not specified", func(t *testing.T) { - opts := AnalyseOptions{Dir: ""} - - err := Analyse(context.TODO(), opts) - // May or may not find an analyser depending on cwd - if err != nil { - assert.Contains(t, err.Error(), "no static analyser") - } - }) - - t.Run("uses stdout when output not specified", func(t *testing.T) { - dir := t.TempDir() - // Create phpstan.neon to enable analyser detection - err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644) - require.NoError(t, err) - - opts := AnalyseOptions{Dir: dir, Output: nil} - - // Will fail because phpstan isn't actually installed, but tests the code path - err = Analyse(context.Background(), opts) - assert.Error(t, err) // PHPStan not installed - }) -} diff --git a/internal/cmd/php/quality_test.go b/internal/cmd/php/quality_test.go deleted file mode 100644 index 710e3fad..00000000 --- a/internal/cmd/php/quality_test.go +++ /dev/null @@ -1,517 +0,0 @@ -package php - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestDetectFormatter_Good(t *testing.T) { - t.Run("detects pint.json", func(t *testing.T) { - dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "pint.json"), []byte("{}"), 0644) - require.NoError(t, err) - - formatter, found := DetectFormatter(dir) - assert.True(t, found) - assert.Equal(t, FormatterPint, formatter) - }) - - t.Run("detects vendor binary", func(t *testing.T) { - dir := t.TempDir() - binDir := filepath.Join(dir, "vendor", "bin") - err := os.MkdirAll(binDir, 0755) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(binDir, "pint"), []byte(""), 0755) - require.NoError(t, err) - - formatter, found := DetectFormatter(dir) - assert.True(t, found) - assert.Equal(t, FormatterPint, formatter) - }) -} - -func TestDetectFormatter_Bad(t *testing.T) { - t.Run("no formatter", func(t *testing.T) { - dir := t.TempDir() - _, found := DetectFormatter(dir) - assert.False(t, found) - }) -} - -func TestDetectAnalyser_Good(t *testing.T) { - t.Run("detects phpstan.neon", func(t *testing.T) { - dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644) - require.NoError(t, err) - - analyser, found := DetectAnalyser(dir) - assert.True(t, found) - assert.Equal(t, AnalyserPHPStan, analyser) - }) - - t.Run("detects phpstan.neon.dist", func(t *testing.T) { - dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "phpstan.neon.dist"), []byte(""), 0644) - require.NoError(t, err) - - analyser, found := DetectAnalyser(dir) - assert.True(t, found) - assert.Equal(t, AnalyserPHPStan, analyser) - }) - - t.Run("detects larastan", func(t *testing.T) { - dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644) - require.NoError(t, err) - - larastanDir := filepath.Join(dir, "vendor", "larastan", "larastan") - err = os.MkdirAll(larastanDir, 0755) - require.NoError(t, err) - - analyser, found := DetectAnalyser(dir) - assert.True(t, found) - assert.Equal(t, AnalyserLarastan, analyser) - }) - - t.Run("detects nunomaduro/larastan", func(t *testing.T) { - dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "phpstan.neon"), []byte(""), 0644) - require.NoError(t, err) - - larastanDir := filepath.Join(dir, "vendor", "nunomaduro", "larastan") - err = os.MkdirAll(larastanDir, 0755) - require.NoError(t, err) - - analyser, found := DetectAnalyser(dir) - assert.True(t, found) - assert.Equal(t, AnalyserLarastan, analyser) - }) -} - -func TestBuildPintCommand_Good(t *testing.T) { - t.Run("basic command", func(t *testing.T) { - dir := t.TempDir() - opts := FormatOptions{Dir: dir} - cmd, args := buildPintCommand(opts) - assert.Equal(t, "pint", cmd) - assert.Contains(t, args, "--test") - }) - - t.Run("fix enabled", func(t *testing.T) { - dir := t.TempDir() - opts := FormatOptions{Dir: dir, Fix: true} - _, args := buildPintCommand(opts) - assert.NotContains(t, args, "--test") - }) - - t.Run("diff enabled", func(t *testing.T) { - dir := t.TempDir() - opts := FormatOptions{Dir: dir, Diff: true} - _, args := buildPintCommand(opts) - assert.Contains(t, args, "--diff") - }) - - t.Run("with specific paths", func(t *testing.T) { - dir := t.TempDir() - paths := []string{"app", "tests"} - opts := FormatOptions{Dir: dir, Paths: paths} - _, args := buildPintCommand(opts) - assert.Equal(t, paths, args[len(args)-2:]) - }) - - t.Run("uses vendor binary if exists", func(t *testing.T) { - dir := t.TempDir() - binDir := filepath.Join(dir, "vendor", "bin") - err := os.MkdirAll(binDir, 0755) - require.NoError(t, err) - pintPath := filepath.Join(binDir, "pint") - err = os.WriteFile(pintPath, []byte(""), 0755) - require.NoError(t, err) - - opts := FormatOptions{Dir: dir} - cmd, _ := buildPintCommand(opts) - assert.Equal(t, pintPath, cmd) - }) -} - -func TestBuildPHPStanCommand_Good(t *testing.T) { - t.Run("basic command", func(t *testing.T) { - dir := t.TempDir() - opts := AnalyseOptions{Dir: dir} - cmd, args := buildPHPStanCommand(opts) - assert.Equal(t, "phpstan", cmd) - assert.Equal(t, []string{"analyse"}, args) - }) - - t.Run("with level", func(t *testing.T) { - dir := t.TempDir() - opts := AnalyseOptions{Dir: dir, Level: 5} - _, args := buildPHPStanCommand(opts) - assert.Contains(t, args, "--level") - assert.Contains(t, args, "5") - }) - - t.Run("with memory limit", func(t *testing.T) { - dir := t.TempDir() - opts := AnalyseOptions{Dir: dir, Memory: "2G"} - _, args := buildPHPStanCommand(opts) - assert.Contains(t, args, "--memory-limit") - assert.Contains(t, args, "2G") - }) - - t.Run("uses vendor binary if exists", func(t *testing.T) { - dir := t.TempDir() - binDir := filepath.Join(dir, "vendor", "bin") - err := os.MkdirAll(binDir, 0755) - require.NoError(t, err) - phpstanPath := filepath.Join(binDir, "phpstan") - err = os.WriteFile(phpstanPath, []byte(""), 0755) - require.NoError(t, err) - - opts := AnalyseOptions{Dir: dir} - cmd, _ := buildPHPStanCommand(opts) - assert.Equal(t, phpstanPath, cmd) - }) -} - -// ============================================================================= -// Psalm Detection Tests -// ============================================================================= - -func TestDetectPsalm_Good(t *testing.T) { - t.Run("detects psalm.xml", func(t *testing.T) { - dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "psalm.xml"), []byte(""), 0644) - require.NoError(t, err) - - // Also need vendor binary for it to return true - binDir := filepath.Join(dir, "vendor", "bin") - err = os.MkdirAll(binDir, 0755) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(binDir, "psalm"), []byte(""), 0755) - require.NoError(t, err) - - psalmType, found := DetectPsalm(dir) - assert.True(t, found) - assert.Equal(t, PsalmStandard, psalmType) - }) - - t.Run("detects psalm.xml.dist", func(t *testing.T) { - dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "psalm.xml.dist"), []byte(""), 0644) - require.NoError(t, err) - - binDir := filepath.Join(dir, "vendor", "bin") - err = os.MkdirAll(binDir, 0755) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(binDir, "psalm"), []byte(""), 0755) - require.NoError(t, err) - - _, found := DetectPsalm(dir) - assert.True(t, found) - }) - - t.Run("detects vendor binary only", func(t *testing.T) { - dir := t.TempDir() - binDir := filepath.Join(dir, "vendor", "bin") - err := os.MkdirAll(binDir, 0755) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(binDir, "psalm"), []byte(""), 0755) - require.NoError(t, err) - - _, found := DetectPsalm(dir) - assert.True(t, found) - }) -} - -func TestDetectPsalm_Bad(t *testing.T) { - t.Run("no psalm", func(t *testing.T) { - dir := t.TempDir() - _, found := DetectPsalm(dir) - assert.False(t, found) - }) -} - -// ============================================================================= -// Rector Detection Tests -// ============================================================================= - -func TestDetectRector_Good(t *testing.T) { - t.Run("detects rector.php", func(t *testing.T) { - dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "rector.php"), []byte("