From 7311bbaa18609b0e8600ffa2c722cbd1682a3f82 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sat, 20 Jun 2026 01:27:05 -0400 Subject: [PATCH 01/15] wip --- .agents/skills/release-app-version/SKILL.md | 250 +++ .../release-app-version/agents/openai.yaml | 4 + .../release-resource-artifacts/SKILL.md | 191 ++ .../agents/openai.yaml | 4 + .../SKILL.md | 240 +++ .../agents/openai.yaml | 4 + .../skills/revoke-resource-artifact/SKILL.md | 218 ++ .../agents/openai.yaml | 4 + .../skills/update-resource-checksums/SKILL.md | 198 ++ .../agents/openai.yaml | 4 + CONTEXT.md | 8 + IDEAS.md | 1832 +++++++++++++++++ 12 files changed, 2957 insertions(+) create mode 100644 .agents/skills/release-app-version/SKILL.md create mode 100644 .agents/skills/release-app-version/agents/openai.yaml create mode 100644 .agents/skills/release-resource-artifacts/SKILL.md create mode 100644 .agents/skills/release-resource-artifacts/agents/openai.yaml create mode 100644 .agents/skills/release-resource-upstream-version/SKILL.md create mode 100644 .agents/skills/release-resource-upstream-version/agents/openai.yaml create mode 100644 .agents/skills/revoke-resource-artifact/SKILL.md create mode 100644 .agents/skills/revoke-resource-artifact/agents/openai.yaml create mode 100644 .agents/skills/update-resource-checksums/SKILL.md create mode 100644 .agents/skills/update-resource-checksums/agents/openai.yaml create mode 100644 IDEAS.md diff --git a/.agents/skills/release-app-version/SKILL.md b/.agents/skills/release-app-version/SKILL.md new file mode 100644 index 00000000..293e8bdf --- /dev/null +++ b/.agents/skills/release-app-version/SKILL.md @@ -0,0 +1,250 @@ +--- +name: release-app-version +description: Build, publish, and verify PV application releases. Use when the user asks to bump the PV app version, run the PV App Release workflow, publish app binaries, update pv-app-manifest.json or install.sh, inspect app release workflow inputs, or release a new stable PV CLI version. +--- + +# Release App Version + +Requested operation: $ARGUMENTS + +Use this workflow for PV application releases. This is separate from Managed Resource artifact releases: app releases publish native `pv` binaries, `pv-app-manifest.json`, and `install.sh`. + +## Core Rules + +- The app release version comes from root `Cargo.toml`. +- Keep workspace package versions in sync unless the user explicitly asks for a narrower change. +- App publication must use a successful `PV App Release` workflow run from the same commit as the `PV App Publication` run. +- Do not dispatch release or publication workflows until you inspect the current workflow inputs and confirm exact values with the user. + +## Before Editing Or Dispatching + +1. Read the repo rules and release design: + + ```sh + sed -n '1,180p' CONTRIBUTING.md + sed -n '72,84p' DESIGN.md + sed -n '409,580p' DESIGN.md + git status --short --branch + ``` + +2. Inspect app version fields: + + ```sh + rg -n '^version = ' Cargo.toml crates/*/Cargo.toml + rg -n 'env!\("CARGO_PKG_VERSION"\)|PV_DEFAULT_APP_UPDATE_MANIFEST_URL|pv-app-manifest' crates .github Cargo.toml + ``` + +3. Inspect available workflow inputs before choosing values: + + ```sh + sed -n '1,300p' .github/workflows/app-release.yml + sed -n '1,340p' .github/workflows/app-publication.yml + ``` + + If local files may not match the dispatch ref, inspect the remote workflow with `gh workflow view`. + +4. Confirm exact workflow inputs with the user before dispatching: + - `PV App Release`: git ref, `minimum_pv_version`, `app_platforms` + - `PV App Publication`: git ref, `source_run_id` + - whether this is build-only or build-and-publish + +Do not silently rely on defaults. Current checked-in defaults are `minimum_pv_version=0.1.0` and `app_platforms=darwin-arm64`, but always re-read the workflow first. + +## Bump The App Version + +Update the root package and workspace package versions together unless the release is intentionally different: + +```txt +Cargo.toml +crates/*/Cargo.toml +Cargo.lock +``` + +Use precise lockfile updates for every changed workspace package. Do not run a broad dependency update. + +```sh +cargo update -p pv --precise +cargo update -p cli --precise +cargo update -p config --precise +cargo update -p daemon --precise +cargo update -p platform --precise +cargo update -p protocol --precise +cargo update -p pv-release --precise +cargo update -p resources --precise +cargo update -p self-update --precise +cargo update -p state --precise +``` + +If the app release does not require code changes, keep the commit limited to version files and expected snapshots. + +## Local Verification + +Focused release checks: + +```sh +cargo nextest run -p pv-release --test app_release_records +cargo nextest run -p pv-release --test app_publication +cargo nextest run -p pv-release --test workflow_defaults +cargo nextest run -p self-update --test app_update_manifest +cargo nextest run -p cli --test update +``` + +If snapshots change, inspect them first and accept only expected changes: + +```sh +cargo insta accept --all +``` + +Final verification before commit or dispatch: + +```sh +cargo fmt --all -- --check +cargo insta pending-snapshots --workspace +git diff --check +cargo nextest run -p pv-release -p self-update -p cli +cargo clippy --workspace --all-targets --all-features --locked -- -D warnings +``` + +## Commit And Push + +Stage only release-related files. Leave unrelated local work untouched. + +Use a Conventional Commit, for example: + +```sh +git commit -m "chore(release): bump PV app to 0.1.4" +git push origin main +``` + +Use `fix` or `feat` instead when the release commit includes the actual bug fix or feature being released. + +## Build App Artifacts + +After confirming inputs with the user, dispatch the app release workflow: + +```sh +gh workflow run app-release.yml \ + --ref \ + -f minimum_pv_version= \ + -f app_platforms= +``` + +Watch the run: + +```sh +gh run watch --exit-status --interval 30 +``` + +If a job fails, inspect logs before rerunning: + +```sh +gh run view --job --log +``` + +Rerun failed jobs only when the evidence shows an environmental failure: + +```sh +gh run rerun --failed +``` + +After success, confirm handoff artifacts exist: + +```sh +gh api repos///actions/runs//artifacts \ + --jq '.artifacts[] | {name, size_in_bytes, expired}' +``` + +Expected handoff contents include: + +```txt +pv//pv- +pv/records//pv-.json +pv-app-manifest.json +install.sh +``` + +## Publish App Artifacts + +Publication uploads immutable app binaries and versioned app metadata, then updates stable `install.sh` and `pv-app-manifest.json`. + +Confirm `source_run_id` and git ref with the user, then dispatch from the same commit used by the successful app release run: + +```sh +gh workflow run app-publication.yml \ + --ref \ + -f source_run_id= +``` + +Watch: + +```sh +gh run watch --exit-status --interval 15 +``` + +Inspect the uploaded publication plan when useful: + +```sh +gh run download \ + --name pv-app-publication-plan-- \ + --dir /tmp/pv-app-publication-plan +jq . /tmp/pv-app-publication-plan/publication-plan.json +``` + +## Verify Stable App Release + +After publication succeeds, verify both stable entrypoints: + +```sh +curl -fsSL /pv-app-manifest.json | jq . +curl -fsSL /install.sh | sed -n '1,80p' +``` + +Check the manifest version, assets, checksums, and provenance: + +```sh +curl -fsSL /pv-app-manifest.json | jq -r ' + .version as $version + | .minimum_pv_version as $minimum + | .assets[] + | [$version, $minimum, .platform, .url, .sha256, (.size|tostring)] | @tsv +' +``` + +The published object layout should include: + +```txt +pv//pv-darwin-arm64 +pv/records//pv-darwin-arm64.json +pv/manifests/runs//pv-app-manifest.json +pv/manifests/runs//install.sh +pv-app-manifest.json +install.sh +``` + +Run an update check against the published release if a test machine is available: + +```sh +pv update --check +``` + +## Failure Meanings + +- `source PV App Release run must use this commit`: build and publication were dispatched from different commits. Rebuild on the publication ref or publish from the source run commit. +- `candidate app version must not be older than current stable`: the stable app manifest or installer already advertises a newer version. Confirm the intended version before proceeding. +- missing `pv-app-manifest.json` or `install.sh`: the app release handoff artifact is incomplete; inspect the release run before publishing. +- missing required `pv-darwin-arm64`: current publication requires the Apple Silicon app binary. Rebuild with `app_platforms` including `darwin-arm64`. +- immutable app object already exists with different content: do not overwrite. Confirm whether this is a version/object-key collision or an unintended rebuild with changed bytes. +- checksum or size mismatch: the binary does not match its release record. Treat the source run as invalid until the cause is understood. + +## Final Report + +Report: + +- version released +- commit SHA and ref +- local verification commands and outcomes +- `PV App Release` run URL and conclusion +- app handoff artifacts confirmed +- `PV App Publication` run URL and conclusion, if published +- manifest and installer verification result +- unrelated local changes left untouched diff --git a/.agents/skills/release-app-version/agents/openai.yaml b/.agents/skills/release-app-version/agents/openai.yaml new file mode 100644 index 00000000..04bc9235 --- /dev/null +++ b/.agents/skills/release-app-version/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Release App Version" + short_description: "Build and publish PV app releases" + default_prompt: "Use $release-app-version to build and publish a new PV application version." diff --git a/.agents/skills/release-resource-artifacts/SKILL.md b/.agents/skills/release-resource-artifacts/SKILL.md new file mode 100644 index 00000000..45ce9572 --- /dev/null +++ b/.agents/skills/release-resource-artifacts/SKILL.md @@ -0,0 +1,191 @@ +--- +name: release-resource-artifacts +description: Build, update, revise, and publish PV Managed Resource artifacts through GitHub Actions. Use when the user asks to build resource artifacts, release a new resource revision, bump pv_build_revision, publish artifacts, rerun artifact recipes, inspect artifact workflow inputs, or update the public/staging artifact manifest for PV resources such as php, frankenphp, composer, redis, mysql, postgres, mailpit, or rustfs. +--- + +# Release Resource Artifacts + +Requested operation: $ARGUMENTS + +Use this workflow for PV Managed Resource artifact releases. Treat GitHub Actions dispatches as release operations: inspect inputs first, confirm them with the user, then run and verify. + +## Before Dispatch + +1. Read repository instructions that apply to the workspace, especially `CONTRIBUTING.md` and `DESIGN.md`. +2. Inspect current git state: + + ```sh + git status --short --branch + git log --oneline -5 + ``` + +3. Inspect available workflow inputs before choosing values: + + ```sh + sed -n '1,180p' .github/workflows/artifact-recipes.yml + sed -n '1,260p' .github/workflows/artifact-publication.yml + ``` + + Prefer the checked-in workflow on the ref that will be dispatched. If local files may be stale, also inspect the remote workflow with `gh workflow view`. + +4. Confirm exact workflow inputs with the user before dispatching any build or publication workflow. Include: + - workflow file + - git ref + - resource + - track + - platform + - source run ID, for publication + - required native platforms, for publication + +Do not silently rely on workflow defaults. Do not reuse old run IDs or old inputs without re-confirming them. + +## Choose Operation + +- **Build only**: User wants candidate artifacts but not public manifest publication. +- **Publish existing build**: User already has a successful `Artifact Recipes` run ID. +- **New build revision**: Artifact contents, packaging, patches, validation, or build flags changed for the same upstream version. +- **Full release**: Merge or push the release commit, build on the publish ref, then publish. + +For public/stable publication, the publication workflow validates that the source recipe run used the same commit as the publication run. Build and publish from the same ref and commit, normally `main`. + +## New Build Revision + +If artifact contents changed while upstream versions stayed the same, bump the recipe's `pv_build_revision`. A new GitHub run ID is provenance, not an artifact revision. + +Common recipe locations: + +```txt +release/artifacts/recipes/php/tracks.toml +release/artifacts/recipes/composer/composer.toml +release/artifacts/recipes//recipe.toml +``` + +Change only the relevant resource from, for example: + +```toml +pv_build_revision = "pv1" +``` + +to: + +```toml +pv_build_revision = "pv2" +``` + +Update nearby tests and snapshots that encode artifact versions, object keys, release records, archive roots, or manifests. Prefer existing `pv-release` test patterns. + +Useful verification for recipe metadata changes: + +```sh +cargo nextest run -p pv-release --test recipe_metadata +cargo nextest run -p pv-release --test recipe_fixtures +cargo fmt --all -- --check +cargo insta pending-snapshots --workspace +git diff --check +cargo nextest run -p pv-release +cargo clippy -p pv-release --all-targets --all-features --locked -- -D warnings +``` + +Commit with a Conventional Commit message, for example: + +```sh +git commit -m "fix(release): bump PHP artifact revision" +``` + +## Build Artifacts + +After confirming inputs with the user, dispatch: + +```sh +gh workflow run artifact-recipes.yml \ + --ref \ + -f resource= \ + -f track= \ + -f platform= +``` + +Watch the run: + +```sh +gh run watch --exit-status --interval 30 +``` + +If validation fails due a transient registry/network error, inspect logs before rerunning: + +```sh +gh run view --job --log +``` + +Rerun failed jobs only after confirming the failure is environmental: + +```sh +gh run rerun --failed +``` + +After success, confirm artifacts exist: + +```sh +gh api repos///actions/runs//artifacts \ + --jq '.artifacts[] | {name, size_in_bytes, expired}' +``` + +## Publish Artifacts + +Confirm publication inputs with the user before dispatch: + +- `source_run_id` +- `versioned_manifest_prefix` +- `required_native_platforms` +- git ref + +Dispatch: + +```sh +gh workflow run artifact-publication.yml \ + --ref \ + -f source_run_id= \ + -f versioned_manifest_prefix=manifests/runs \ + -f required_native_platforms= +``` + +Watch: + +```sh +gh run watch --exit-status --interval 15 +``` + +After success, verify the manifest points at the intended build revision and run ID: + +```sh +curl -fsSL | jq -r ' + .resources[] + | select(.name == "") + | .name as $resource + | .tracks[] + | .name as $track + | .artifacts[] + | select(.platform == "") + | [$resource, $track, .artifact_version, .pv_build_revision, .provenance.build_run_id] | @tsv +' +``` + +For PHP releases, check both `php` and `frankenphp`. + +## Failure Meanings + +- `duplicate artifact identity`: the source run produced an identity that is already published. If contents changed for the same upstream version, bump `pv_build_revision`, rebuild, and publish the new run. +- `source Artifact Recipes run must use this commit`: build and publication were dispatched from different commits. Rebuild on the publication ref or publish from the source run's commit. +- missing required native platform: either build the missing platform or confirm an explicit preview gate such as `required_native_platforms=darwin-arm64`. +- immutable object already exists: do not overwrite. Inspect whether this is a retry with matching records or a real identity/object-key collision. + +## Final Report + +Report: + +- commit SHA pushed, if any +- local verification commands and outcomes +- Artifact Recipes run URL and conclusion +- artifact bundle names or count +- Artifact Publication run URL and conclusion, if published +- manifest verification result +- unrelated local changes left untouched diff --git a/.agents/skills/release-resource-artifacts/agents/openai.yaml b/.agents/skills/release-resource-artifacts/agents/openai.yaml new file mode 100644 index 00000000..691be03c --- /dev/null +++ b/.agents/skills/release-resource-artifacts/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Release Resource Artifacts" + short_description: "Build and publish PV resource artifacts" + default_prompt: "Use $release-resource-artifacts to build or publish PV managed resource artifacts." diff --git a/.agents/skills/release-resource-upstream-version/SKILL.md b/.agents/skills/release-resource-upstream-version/SKILL.md new file mode 100644 index 00000000..7fe06ce1 --- /dev/null +++ b/.agents/skills/release-resource-upstream-version/SKILL.md @@ -0,0 +1,240 @@ +--- +name: release-resource-upstream-version +description: Update and publish a PV Managed Resource artifact for a new upstream version. Use when the user asks to move a resource track to a newer upstream release, update source_url/source_sha256/upstream_version, build artifacts for a new upstream resource version, or publish a new artifact such as rustfs 1.1.0-beta-pv1, mysql 8.4.x-pv1, postgres 18.x-pv1, redis 8.8.x-pv1, composer 2.x-pv1, mailpit 1.x-pv1, or PHP patch versions. +--- + +# Release Resource Upstream Version + +Requested operation: $ARGUMENTS + +Use this workflow when the upstream resource version changes. For the same upstream version with changed PV packaging, use the revision-bump workflow instead. + +## Core Rule + +- New upstream release: change `upstream_version` and source metadata; use `pv_build_revision = "pv1"` for that upstream version. +- Same upstream release, new PV packaging/build flags/validation: keep `upstream_version`; bump `pv_build_revision` to `pv2`, `pv3`, etc. + +Example: + +```txt +rustfs 1.0.0-beta.7-pv1 -> rustfs 1.1.0-beta-pv1 +``` + +is a new upstream version, not a new PV revision. + +## Before Editing Or Dispatching + +1. Read repository instructions and design: + + ```sh + sed -n '1,160p' CONTRIBUTING.md + sed -n '780,930p' DESIGN.md + git status --short --branch + ``` + +2. Inspect available GitHub workflow inputs before choosing values: + + ```sh + sed -n '1,180p' .github/workflows/artifact-recipes.yml + sed -n '1,260p' .github/workflows/artifact-publication.yml + ``` + + If local workflows may not match the dispatch ref, inspect the remote workflow with `gh workflow view`. + +3. Confirm exact workflow inputs with the user before dispatching any build or publication workflow: + - workflow file + - git ref + - resource + - track + - platform + - whether to publish after build + - source run ID, for publication + - required native platforms, for publication + +Do not silently rely on defaults or reuse old run IDs without confirmation. + +## Update Recipe + +Find the resource recipe: + +```txt +release/artifacts/recipes/php/tracks.toml +release/artifacts/recipes/composer/composer.toml +release/artifacts/recipes//recipe.toml +``` + +Update the resource track's upstream version and source metadata. For backing resources, the shape is usually: + +```toml +[[tracks]] +name = "1" +upstream_version = "1.1.0-beta" + +[[tracks.sources]] +platform = "darwin-arm64" +source_url = "https://..." +source_sha256 = "..." + +[[tracks.sources]] +platform = "darwin-amd64" +source_url = "https://..." +source_sha256 = "..." +``` + +Usually keep or reset: + +```toml +pv_build_revision = "pv1" +``` + +For PHP, standalone PHP and FrankenPHP tracks must stay paired on the same PHP patch version. Update PHP source URLs/checksums and any FrankenPHP source metadata only when that upstream also changes. + +## Update Tests And Snapshots + +Update tests and snapshots that encode the old upstream version: + +- recipe metadata expectations +- fixture archive roots +- generated release record or manifest snapshots +- smoke tests when source filenames, version output, or archive shape changed +- docs that list current recipe versions, when present + +Prefer existing `pv-release` test patterns and `insta` snapshots. + +Focused checks: + +```sh +cargo nextest run -p pv-release --test recipe_metadata +cargo nextest run -p pv-release --test recipe_fixtures +``` + +If snapshots change, inspect them first, then accept only expected changes: + +```sh +cargo insta accept --all +``` + +Final local verification before committing: + +```sh +cargo fmt --all -- --check +cargo insta pending-snapshots --workspace +git diff --check +cargo nextest run -p pv-release +cargo clippy -p pv-release --all-targets --all-features --locked -- -D warnings +``` + +## Commit And Push + +Stage only files related to the upstream version update. Leave unrelated local work untouched. + +Use a Conventional Commit, for example: + +```sh +git commit -m "feat(release): update RustFS to 1.1.0-beta" +git push origin main +``` + +Use `feat(release)` because a new installable artifact version becomes available. + +## Build Artifacts + +After confirming the exact inputs with the user, dispatch: + +```sh +gh workflow run artifact-recipes.yml \ + --ref \ + -f resource= \ + -f track= \ + -f platform= +``` + +Watch: + +```sh +gh run watch --exit-status --interval 30 +``` + +If a job fails, inspect logs before deciding whether to rerun: + +```sh +gh run view --job --log +``` + +Rerun only when the evidence shows an environmental failure: + +```sh +gh run rerun --failed +``` + +Confirm uploaded artifacts: + +```sh +gh api repos///actions/runs//artifacts \ + --jq '.artifacts[] | {name, size_in_bytes, expired}' +``` + +## Publish Artifacts + +Publication must use the same commit as the successful recipe run. + +After confirming publication inputs with the user, dispatch: + +```sh +gh workflow run artifact-publication.yml \ + --ref \ + -f source_run_id= \ + -f versioned_manifest_prefix=manifests/runs \ + -f required_native_platforms= +``` + +Watch: + +```sh +gh run watch --exit-status --interval 15 +``` + +Verify the stable manifest includes the new upstream artifact: + +```sh +curl -fsSL | jq -r ' + .resources[] + | select(.name == "") + | .tracks[] + | select(.name == "") + | .artifacts[] + | [.artifact_version, .upstream_version, .pv_build_revision, .platform, .provenance.build_run_id] + | @tsv +' +``` + +Expected for a new upstream release: + +```txt +artifact_version: -pv1 +upstream_version: +pv_build_revision: pv1 +``` + +For PHP, verify both `php` and `frankenphp` resources. + +## Failure Meanings + +- `duplicate artifact identity`: the candidate used an already-published upstream version, PV revision, and platform. Confirm whether this is really a new upstream release or should be a PV revision bump. +- `source Artifact Recipes run must use this commit`: build and publication were dispatched from different commits. Rebuild on the publication ref or publish from the source run's commit. +- missing required native platform: build the missing platform or confirm an explicit preview gate such as `required_native_platforms=darwin-arm64`. +- source checksum mismatch: update the source SHA only after independently verifying the upstream asset and URL are correct. + +## Final Report + +Report: + +- old and new upstream versions +- `pv_build_revision` used +- commit SHA pushed, if any +- local verification commands and outcomes +- Artifact Recipes run URL and conclusion +- artifact bundle names or count +- Artifact Publication run URL and conclusion, if published +- manifest verification result +- unrelated local changes left untouched diff --git a/.agents/skills/release-resource-upstream-version/agents/openai.yaml b/.agents/skills/release-resource-upstream-version/agents/openai.yaml new file mode 100644 index 00000000..87b12e13 --- /dev/null +++ b/.agents/skills/release-resource-upstream-version/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Release Resource Upstream Version" + short_description: "Publish new upstream resource versions" + default_prompt: "Use $release-resource-upstream-version to update and publish a PV resource for a new upstream version." diff --git a/.agents/skills/revoke-resource-artifact/SKILL.md b/.agents/skills/revoke-resource-artifact/SKILL.md new file mode 100644 index 00000000..f9c65a95 --- /dev/null +++ b/.agents/skills/revoke-resource-artifact/SKILL.md @@ -0,0 +1,218 @@ +--- +name: revoke-resource-artifact +description: Revoke PV Managed Resource artifacts. Use when a published resource artifact must be marked revoked, a revocation_reason or replacement_artifact_version is needed, update/check should warn about a bad artifact, or release metadata needs emergency artifact revocation. +--- + +# Revoke Resource Artifact + +Requested operation: $ARGUMENTS + +Use this workflow when a published Managed Resource artifact must be marked revoked. Revocations are append-only metadata records; never mutate or delete the original release record or archive. + +## Current Repo Capability + +The code supports revocation records and manifest generation: + +- revocation records are parsed by `pv-release` +- generated manifests include `revoked`, `revocation_reason`, `revoked_at`, and optional `replacement_artifact_version` +- clients report revoked installed artifacts and can fall back to a non-revoked latest artifact + +The current `Artifact Publication` workflow does not upload new candidate revocation records from the repo and does not support revocation-only publication. It only downloads already-published R2 revocation records and merges them into the generated manifest. Do not claim revocation is publishable through `artifact-publication.yml` alone unless the workflow has changed. + +## Before Acting + +1. Read repo rules and revocation design: + + ```sh + sed -n '1,180p' CONTRIBUTING.md + sed -n '909,919p' DESIGN.md + git status --short --branch + ``` + +2. Inspect current implementation and workflows: + + ```sh + rg -n 'RevocationRecord|revocation|replacement_artifact_version|published_revocations' crates/pv-release crates/resources crates/cli .github release + sed -n '1,300p' .github/workflows/artifact-publication.yml + ``` + +3. Confirm the exact revocation decision with the user: + - resource + - track + - artifact version + - platform + - revocation reason + - replacement artifact version, if any + - whether this is emergency manual publication or a code/workflow change first + +## Identify The Artifact + +Read the stable manifest and confirm the exact target: + +```sh +curl -fsSL | jq -r ' + .resources[] + | select(.name == "") + | .tracks[] + | select(.name == "") + | .artifacts[] + | [.artifact_version, .platform, .published_at, .revoked, (.revocation_reason // ""), .url] + | @tsv +' +``` + +The revocation identity is: + +```txt +::: +``` + +If a replacement is provided, it must exist for the same resource, track, and platform, must not point to the revoked artifact itself, and must not also be revoked. + +## Create A Revocation Record + +Preferred local path: + +```txt +release/artifacts/revocations/resources/////--.json +``` + +Record shape: + +```json +{ + "resource": "redis", + "track": "8.8", + "artifact_version": "8.8.0-pv1", + "platform": "darwin-arm64", + "reason": "broken archive", + "revoked_at": "2026-06-20T12:00:00Z", + "replacement_artifact_version": "8.8.0-pv2" +} +``` + +Omit `replacement_artifact_version` only when no non-revoked replacement exists yet. Use an RFC 3339 UTC timestamp ending in `Z`. + +## Local Validation + +If R2 credentials are available, validate against the published record set: + +```sh +published=/tmp/pv-published-revocation-check +rm -rf "$published" +mkdir -p "$published/records" "$published/revocations" +aws s3 sync "s3://$R2_BUCKET/records/" "$published/records" --endpoint-url "$R2_ENDPOINT" +aws s3 sync "s3://$R2_BUCKET/revocations/" "$published/revocations" --endpoint-url "$R2_ENDPOINT" +cp "$published/revocations/.json" +cargo run -p pv-release -- generate-manifest \ + --records "$published/records" \ + --revocations "$published/revocations" \ + --defaults release/artifacts/default-tracks.toml \ + --output "$published/manifest.json" \ + --base-url +``` + +Then inspect the generated target: + +```sh +jq -r ' + .resources[] + | select(.name == "") + | .tracks[] + | select(.name == "") + | .artifacts[] + | select(.artifact_version == "" and .platform == "") + | {artifact_version, platform, revoked, revocation_reason, revoked_at, replacement_artifact_version} +' "$published/manifest.json" +``` + +Focused test checks: + +```sh +cargo nextest run -p pv-release --test release_records +cargo nextest run -p pv-release --test manifest_generation +cargo nextest run -p pv-release --test publication +cargo nextest run -p resources --test manifest_foundation +cargo nextest run -p cli --test update +``` + +Final local verification for code or checked-in revocation changes: + +```sh +cargo fmt --all -- --check +cargo insta pending-snapshots --workspace +git diff --check +cargo nextest run -p pv-release -p resources -p cli +cargo clippy --workspace --all-targets --all-features --locked -- -D warnings +``` + +## Publication Options + +Because the current workflow cannot publish a new revocation record by itself, choose one of these paths with the user: + +- Add a proper revocation publication workflow or extend `Artifact Publication` to accept candidate revocations. +- If a replacement artifact is also being published, ensure the revocation record is already present in R2 before the artifact publication run generates the stable manifest. +- For emergency manual publication, get explicit user approval before mutating R2 outside the workflow. + +For an emergency manual path, confirm all object keys before running commands: + +```txt +revocations/resources/////--.json +manifests/runs//manifest.json +manifest.json +``` + +The safe order is: + +1. Upload the revocation JSON as an immutable object, failing if it already exists. +2. Generate and validate a complete manifest from published records plus all revocations. +3. Upload a versioned manifest. +4. Update stable `manifest.json` last. +5. Verify clients see the revoked state. + +Do not hand-edit the stable manifest JSON directly. + +## Verify Client Behavior + +After publication, verify the stable manifest: + +```sh +curl -fsSL | jq -r ' + .resources[] + | select(.name == "") + | .tracks[] + | select(.name == "") + | .artifacts[] + | select(.artifact_version == "" and .platform == "") +' +``` + +If a machine has the revoked artifact installed, run: + +```sh +pv update --check +``` + +Expected status is `revoked` for the installed artifact, with the reason and replacement when present. + +## Failure Meanings + +- revocation target missing: no release record exists for the target resource, track, artifact version, and platform. +- replacement release must exist: the replacement artifact version is not published for the same resource, track, and platform. +- replacement must not point at the revoked artifact itself: choose a different replacement or omit the field. +- duplicate or conflicting revocation: a revocation for this identity already exists; do not overwrite it. +- publication workflow has no immutable uploads: current `artifact-publication.yml` is not a revocation-only publication workflow. + +## Final Report + +Report: + +- target artifact identity +- revocation reason and timestamp +- replacement artifact version, if any +- local manifest validation result +- tests run and outcomes +- publication path used or current workflow gap +- manifest verification result +- client `pv update --check` result, when available +- unrelated local changes left untouched diff --git a/.agents/skills/revoke-resource-artifact/agents/openai.yaml b/.agents/skills/revoke-resource-artifact/agents/openai.yaml new file mode 100644 index 00000000..c3c7ee2e --- /dev/null +++ b/.agents/skills/revoke-resource-artifact/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Revoke Resource Artifact" + short_description: "Validate and publish artifact revocations" + default_prompt: "Use $revoke-resource-artifact to revoke a published PV Managed Resource artifact." diff --git a/.agents/skills/update-resource-checksums/SKILL.md b/.agents/skills/update-resource-checksums/SKILL.md new file mode 100644 index 00000000..01721231 --- /dev/null +++ b/.agents/skills/update-resource-checksums/SKILL.md @@ -0,0 +1,198 @@ +--- +name: update-resource-checksums +description: Verify and update PV Managed Resource source checksums. Use when a resource recipe source_sha256, php_source_sha256, source_url, upstream archive checksum, checksum mismatch, or source provenance needs investigation without necessarily moving to a new upstream version. +--- + +# Update Resource Checksums + +Requested operation: $ARGUMENTS + +Use this workflow to investigate and update source checksums for PV Managed Resource recipes. Do not blindly replace a checksum: first decide whether the source identity changed, the upstream asset was reissued, or the existing checksum was simply wrong. + +## Choose The Correct Path + +- New upstream version: use the upstream-version release workflow instead. +- Same upstream version, artifact already published, source bytes changed: bump `pv_build_revision` before rebuilding because the artifact contents changed for the same upstream identity. +- Same upstream version, artifact not published yet, checksum was wrong: update only the checksum and rebuild the unpublished candidate. +- Build log says source checksum mismatch: verify the URL and bytes independently before editing. + +## Before Editing + +1. Read repo rules and artifact design: + + ```sh + sed -n '1,180p' CONTRIBUTING.md + sed -n '819,917p' DESIGN.md + git status --short --branch + ``` + +2. Inspect current recipe and workflow inputs: + + ```sh + sed -n '1,180p' .github/workflows/artifact-recipes.yml + sed -n '1,260p' .github/workflows/artifact-publication.yml + rg -n 'source_url|source_sha256|php_source_url|php_source_sha256|pv_build_revision|upstream_version' release/artifacts/recipes + ``` + +3. Confirm with the user before dispatching any GitHub workflow: + - resource + - track + - platform + - git ref + - whether the artifact identity is already published + - whether to bump `pv_build_revision` + - whether to publish after a successful build + +## Locate The Source Fields + +Common recipe files: + +```txt +release/artifacts/recipes/php/tracks.toml +release/artifacts/recipes/composer/composer.toml +release/artifacts/recipes//recipe.toml +``` + +Common checksum shapes: + +```toml +source_url = "https://..." +source_sha256 = "..." +``` + +```toml +php_source_url = "https://..." +php_source_sha256 = "..." +``` + +```toml +[[tracks.sources]] +platform = "darwin-arm64" +source_url = "https://..." +source_sha256 = "..." +``` + +For PHP, verify both the per-track PHP source and the shared `[frankenphp]` source when relevant. For platform-specific binary recipes such as RustFS or Mailpit, verify each platform source independently. + +## Verify The Upstream Bytes + +Download to a temporary location and compute SHA-256: + +```sh +tmpdir=$(mktemp -d) +curl -L --fail --show-error --silent \ + --retry 3 --retry-delay 2 --retry-all-errors \ + '' \ + -o "$tmpdir/source" +shasum -a 256 "$tmpdir/source" +``` + +Compare against: + +- the recipe checksum +- the checksum reported in the failed build log, if any +- the checksum in the published manifest provenance, when the artifact is already public +- upstream release notes or checksums, when upstream publishes them + +If the upstream asset changed for the same version and a matching artifact identity is already public, do not keep the old `pv_build_revision`. Publish a new PV revision such as `pv2`. + +## Edit The Recipe + +Change the smallest relevant fields: + +- update `source_sha256` or `php_source_sha256` when the URL is still correct +- update `source_url` only when the source location moved +- keep `upstream_version` unchanged for same-version checksum repair +- bump `pv_build_revision` only when the rebuilt artifact should become a new PV revision + +Do not update unrelated tracks, platforms, or resource recipes. + +## Local Verification + +Focused checks: + +```sh +cargo nextest run -p pv-release --test recipe_metadata +cargo nextest run -p pv-release --test recipe_fixtures +cargo nextest run -p pv-release --test release_records +``` + +If snapshots change, inspect them first and accept only expected checksum/provenance changes: + +```sh +cargo insta accept --all +``` + +Final local verification: + +```sh +cargo fmt --all -- --check +cargo insta pending-snapshots --workspace +git diff --check +cargo nextest run -p pv-release +cargo clippy -p pv-release --all-targets --all-features --locked -- -D warnings +``` + +## Build And Publish + +After confirming exact inputs with the user, dispatch the existing artifact build workflow: + +```sh +gh workflow run artifact-recipes.yml \ + --ref \ + -f resource= \ + -f track= \ + -f platform= +``` + +Watch and inspect logs: + +```sh +gh run watch --exit-status --interval 30 +gh run view --job --log +``` + +If publishing, confirm publication inputs and use the artifact publication workflow: + +```sh +gh workflow run artifact-publication.yml \ + --ref \ + -f source_run_id= \ + -f versioned_manifest_prefix=manifests/runs \ + -f required_native_platforms= +``` + +Verify the stable manifest provenance after publication: + +```sh +curl -fsSL | jq -r ' + .resources[] + | select(.name == "") + | .tracks[] + | select(.name == "") + | .artifacts[] + | select(.platform == "") + | [.artifact_version, .provenance.source_url, .provenance.source_sha256, .provenance.build_run_id] + | @tsv +' +``` + +## Failure Meanings + +- source checksum mismatch: the downloaded source bytes do not match recipe metadata. Verify the URL and upstream source before editing. +- duplicate artifact identity: the rebuilt candidate matches an already-published resource, track, upstream version, PV revision, and platform. Bump `pv_build_revision` if the artifact contents changed. +- source Artifact Recipes run must use this commit: build and publication were dispatched from different commits. +- checksum changed for a same-version upstream asset: treat as a release decision, not a mechanical checksum replacement. + +## Final Report + +Report: + +- resource, track, and platform +- old and new checksum values +- whether `source_url`, `upstream_version`, or `pv_build_revision` changed +- how the source bytes were verified +- local verification commands and outcomes +- build run URL and conclusion, if dispatched +- publication run URL and manifest verification, if published +- unrelated local changes left untouched diff --git a/.agents/skills/update-resource-checksums/agents/openai.yaml b/.agents/skills/update-resource-checksums/agents/openai.yaml new file mode 100644 index 00000000..c370ebcc --- /dev/null +++ b/.agents/skills/update-resource-checksums/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Update Resource Checksums" + short_description: "Verify and refresh resource source SHAs" + default_prompt: "Use $update-resource-checksums to verify and update PV resource source checksums." diff --git a/CONTEXT.md b/CONTEXT.md index 95a2cebb..f24148a8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -28,6 +28,10 @@ _Avoid_: Tool, service when referring to the whole category A PV-owned packaged installable for a **Managed Resource**. _Avoid_: Upstream binary, local build recipe +**PHP track defaults**: +PV-owned PHP configuration associated with a PHP version track. +_Avoid_: php.ini support, custom Project ini + **Artifact manifest**: The PV-owned catalog of available **Managed Resource artifacts**. _Avoid_: Project config, per-archive manifest @@ -45,6 +49,7 @@ _Avoid_: Logical resource, project resource - A **Project** may have many **Resource allocations**. - A **Resource allocation** belongs to one **Project** and one **Managed Resource**. - A **Managed Resource** is installed from one or more **Managed Resource artifacts**. +- **PHP track defaults** belong to a PHP version track and apply consistently to PHP execution for that track. - An **Artifact manifest** lists **Managed Resource artifacts**. - The **Gateway** routes **Project hostnames** to **Projects**. @@ -62,6 +67,8 @@ _Avoid_: Logical resource, project resource > **Domain expert:** "No — the **Managed Resource artifact** is the PV-owned package users install." > **Dev:** "Does each **Managed Resource artifact** contain its own manifest?" > **Domain expert:** "No — the **Artifact manifest** is the PV-owned catalog outside the archive." +> **Dev:** "If PHP needs default ini settings, are those **PHP track defaults**?" +> **Domain expert:** "Yes — they belong to the PHP version track, not to an individual **Project config**." ## Flagged ambiguities @@ -71,3 +78,4 @@ _Avoid_: Logical resource, project resource - "logical resource" and "project resource" were considered for per-Project objects inside shared resources — resolved: use **Resource allocation**. - "artifact" could mean an upstream binary, source archive, or local build recipe — resolved: use **Managed Resource artifact** for the PV-owned packaged installable. - "manifest" could mean Project config, a per-archive metadata file, or the artifact catalog — resolved: use **Artifact manifest** only for the PV-owned catalog of available Managed Resource artifacts. +- "php.ini support" could mean Project-specific overrides, user-editable ini files, or PV-owned defaults — resolved: use **PHP track defaults** for PV-owned PHP configuration associated with a PHP version track. diff --git a/IDEAS.md b/IDEAS.md new file mode 100644 index 00000000..14c8145c --- /dev/null +++ b/IDEAS.md @@ -0,0 +1,1832 @@ +# Ideas + +## Maybes / Parking Lot + +These are ideas that seem useful, but are not yet strong enough to treat as a +product direction. Promote them only if user feedback, support pain, or PV's own +development experience proves the need. + +### Component-Specific Doctor Commands + +PV already has `pv doctor` in the v1 design as a deeper read-only diagnostic for +setup, DNS, ports, CA, daemon, Gateway, manifest cache, and common conflicts. + +The maybe is a more focused diagnostic family for specific components: + +```sh +pv dns:doctor +pv daemon:doctor +pv php:doctor +pv gateway:doctor +pv resource:doctor mysql +``` + +Competitor research shows users often struggle when a broad "something is +broken" status does not identify the exact layer that failed. DDEV's focused +diagnostic commands are the strongest example here. + +This should stay parked for now. Add it only if the broad `pv doctor` output +gets too noisy, users repeatedly ask for narrower checks, or PV development +itself needs component-level debugging commands. + +If added later, these commands should stay read-only and actionable. They should +suggest repair commands instead of mutating privileged system state or restarting +processes automatically. + +### Project Command Sandboxing + +PV could eventually offer a guarded command runner for risky Project commands, +but this is a far-future maybe rather than a near-term product direction. + +The motivating problem is real: AI agents, package manager scripts, and +compromised dependencies can be destructive when they run with normal user +access. A command such as `pnpm install`, `composer install`, or an AI agent +session can read dotfiles, cloud credentials, SSH keys, and unrelated Projects +unless something constrains it. + +The PV-shaped version would be generic, not AI-specific: + +```sh +pv sandbox run -- pnpm install +pv sandbox run -- composer install +pv sandbox run -- codex +pv sandbox shell +``` + +Potentially, hooks could opt into the same execution mode later: + +```yaml +sandbox: true + +hooks: + setup: + - pnpm install +``` + +The honest first version would only promise damage reduction, not perfect +supply-chain security. It might restrict filesystem access to the Project, PV +shims, required runtime artifacts, and explicitly allowed cache directories. +Broad claims like "safe npm install" would be wrong unless PV also controls +network access, credentials, and exfiltration paths. + +For macOS, a lightweight native implementation might use Apple's Seatbelt +sandboxing through `sandbox-exec`, but that tool is deprecated and the profile +surface is not a great product foundation. A stronger version would require a +VM or microVM/container boundary, which is much closer to a separate product +than a small PV feature. + +Keep this parked unless user demand is strong or PV's hook runner creates enough +risk that a first-party guardrail becomes necessary. + +### Worktree Environment Commands + +PV does not need first-class Git worktree commands right now. + +The existing product answer should be: + +```sh +pv link +``` + +Each Git worktree is just another directory. Since PV identifies Projects by +canonical absolute path and requires unique Project hostnames, linking a +worktree naturally creates a separate Project with its own hostname and Resource +allocations. + +This keeps PV out of Git-specific workflows and avoids adding command surface +such as: + +```sh +pv worktree:link +pv project:clone +``` + +Those would mostly duplicate `pv link`. + +The one useful bit of polish to keep in mind is collision UX. If a hostname was +auto-derived from the directory name and already exists, PV could eventually +suggest or assign the next available name. If the user explicitly passed +`--hostname`, collisions should keep failing hard. + +Do not promote this into a feature unless users repeatedly struggle to link +multiple checkouts of the same app. + +### `.localhost` Project Hostnames + +PV should stay `.test`-first. + +The current design intentionally rejects non-`.test` Project hostnames and +wildcards. That keeps DNS, Gateway routing, certificates, docs, and user mental +models much simpler. + +Revisit `.localhost` only if real OAuth/provider workflows complain about +`.test` callback URLs. Google or similar providers may be more willing to accept +`localhost`-style redirect URIs than arbitrary local TLDs. If that becomes a +support issue, PV could consider allowing explicit `.localhost` Project +hostnames as a narrow compatibility escape hatch. + +This should not become broad custom domain support: + +- no arbitrary local TLDs +- no wildcard Project routing +- no routing real user-owned domains +- no per-team domain policy system +- no extra resolver suffixes unless there is a concrete provider problem + +If added later, `.localhost` should follow the same explicit hostname collision, +certificate, Gateway, and `hostnames:` rules as `.test`. + +### Targeted Runtime Restart + +PV already has the broad product shape of `pv restart`: restart PV-managed +runtime processes and reconcile desired state. + +The maybe is allowing `pv restart` to target one PV-owned runtime instead of +restarting the whole local stack. + +Possible shape: + +```sh +pv restart +pv restart mysql +pv restart mysql 8.0 +pv restart postgres 18 +pv restart pg 18 +pv restart redis +pv restart mailpit +pv restart rustfs +pv restart php 8.4 +pv restart gateway +``` + +This should stay under `pv restart` rather than adding separate commands such +as `pv mysql:restart` or `pv postgres:restart`. The resource name remains +explicit, but the verb stays in one obvious place. + +Useful when a single local runtime is degraded or weird and the user wants the +equivalent of "restart Redis" without bouncing every Project and Resource. + +Boundaries: + +- restart only PV-owned runtime processes after ownership verification +- stream daemon job progress and fail if readiness fails +- no Project hooks +- no Project config mutation +- no privileged system repair +- no generic `db` target +- if the daemon is down, suggest `pv daemon:restart` or `pv setup` + +Avoid `pv restart ` until the semantics are clear. A Project can share +a PHP worker with other Projects, so "restart this Project" may not map cleanly +to one runtime process. + +## Guided Project Init + +PV should probably include a guided Project config initializer in v1. + +This contradicts the current `DESIGN.md`, which says: + +> PV v1 does not include `pv init`, does not create sample Project config files +> during setup, and does not create Project config during `pv link`. + +That design decision should be revisited. Herd already has `herd init`, DDEV has +`ddev config`, Laragon has Quick App, and FlyEnv users are asking for richer +Laravel project customization. If `pv.yml` is supposed to be the elegant, +team-shareable entrypoint, PV should help users create the first one. + +### Product Shape + +The command should be focused on existing directories: + +```sh +pv init +pv init path/to/project +``` + +It should generate or update a `pv.yml` for a Project. It should not create a new +Laravel application, run `composer install`, run `pnpm install`, migrate +databases, or execute framework setup commands. Those actions belong to hooks or +user-owned scripts. + +In other words: + +- `pv init` helps write Project config. +- `pv link` registers and reconciles a Project. +- hooks run user-owned commands when the user opts into that idea. + +This keeps PV from becoming a project scaffolder too early while still avoiding +the bad first-run UX of making users hand-write config from documentation. + +### Interaction + +The default experience should be interactive and conservative. + +PV can inspect the directory and suggest values, but the user should confirm +what gets written. Useful detection could include: + +- Laravel or generic PHP Project shape +- likely document root, such as `public` +- PHP track, defaulting to PV's default when uncertain +- whether `.env.example` or `.env` exists +- likely database/cache/mail/object-storage needs from existing env keys +- whether the Project should be served by the Gateway or marked `serve: false` + +PV should show the proposed `pv.yml` before writing it, or at least print a clear +summary and the output path. Existing files should not be overwritten without +confirmation. + +### Detection Scope + +Framework detection belongs in `pv init`, not `pv link`. + +`pv init` can inspect common Project files to make better suggestions: + +- `composer.json` for Laravel, generic PHP shape, and package hints +- `artisan`, `bootstrap/app.php`, `config/app.php`, and `public/index.php` for + Laravel confidence +- `package.json` for frontend tooling signals such as Vite, Next.js, or build + scripts +- `.env.example` and `.env` for likely resource needs such as database, Redis, + mail, object storage, and app URL mappings +- directory layout for document root and resource-only hints + +Detection should produce suggestions, not hidden behavior. PV should explain the +detected shape, show the proposed config, and let the user confirm or edit. + +### Migration-Assisted Init + +PV should provide a migration path for Herd users through `pv init`, not through +a separate migration command namespace. + +Possible command shape: + +```sh +pv init --migrate herd +pv init path/to/project --migrate herd +``` + +This mode should still behave like guided init: inspect the existing Project, +translate what can be translated safely, show the proposed `pv.yml`, and ask +before writing. + +The first supported migration target should be Herd. That is the clearest +competitive path and avoids designing a generic migration framework too early. +Other sources such as Valet or DDEV can wait until users ask. + +Useful Herd migration inputs might include: + +- existing hostname / site name +- PHP version when detectable +- document root +- common Laravel shape +- `.env` / `.env.example` resource hints +- future Herd team config if it becomes common enough to translate + +This should not: + +- import databases +- copy certificates +- run Composer, pnpm, Artisan, or framework commands +- rewrite application `.env` values outside PV's managed block +- attempt to fully emulate Herd behavior + +Database movement belongs to the later Resource Data Commands idea. A good +future migration flow can tell users the next command to run, such as +`pv mysql:import`, but `pv init --migrate herd` should focus on generating the +PV Project config. + +`pv init` should not: + +- infer a PHP version from complex Composer constraints as a hard fact +- run framework commands +- install packages +- generate app secrets +- run migrations +- diagnose application correctness +- deeply customize framework-specific starter kits + +`pv link` should stay simple. It may eventually print a hint when no `pv.yml` +exists, such as: + +```text +No pv.yml found. Run `pv init` to generate one. +``` + +But `pv link` should not perform framework detection or write Project config. + +### Non-Goals + +This should not be a Laragon-style Quick App in v1. + +Avoid: + +- `laravel new` +- starter kit selection +- Breeze/Jetstream installation +- package manager execution +- migrations +- application secrets +- long-running project processes +- framework-specific deep customization + +Those are useful later, but they cross into scaffolding and command execution. +For v1, the win is simply making `pv.yml` creation feel obvious and polished. + +### Open Questions + +- Should the command be `pv init`, `pv project:init`, or both? +- Should `pv link` suggest `pv init` when no Project config exists? +- Should `pv init` support non-interactive output for templates or docs, such as + `pv init --print`? +- Should `pv init` be allowed to add hooks, or only resource/env declarations? +- Should `pv init` ever touch `.env`, or should it only write `pv.yml`? + +## Project Hooks / Command Runner + +PV could support a simple Project hook runner in `pv.yml` so a Project can declare +the commands needed to become usable after linking. + +This is intentionally a product idea, not a spec. The current `DESIGN.md` says PV +v1 does not automatically run package manager or Laravel application commands +during `pv link`. This idea would deliberately change that behavior. + +Example: + +```yaml +php: "8.4" + +env: + APP_URL: "${project_url}" + +postgres: + version: "8.0" + allocations: + laravel-db: + env: + DB_DATABASE: "${database}" + DB_USERNAME: "${username}" + DB_PASSWORD: "${password}" + DB_PORT: "${port}" + +hooks: + prepare: + - test -f .env || cp .env.example .env + + setup: + - composer install + - pnpm install + - php artisan key:generate + - x-migrate +``` + +### Product Shape + +`hooks.prepare` runs before PV mutates Project state. + +PV still has to read and parse `pv.yml` first so it knows the hook exists. After +that, `prepare` should run before PV records the Project in `pv.db`, provisions +Managed Resources, creates Resource allocations, or writes the PV-managed `.env` +block. + +This gives users a clean place for local Project file prep, such as copying +`.env.example` to `.env`. If `prepare` fails, `pv link` stops before PV has +anything meaningful to roll back. + +`hooks.setup` runs immediately after PV renders the PV-managed `.env` block. + +This is the place for dependency installation, app keys, migrations, local asset +build steps, and custom framework setup commands. PV should not classify these +commands or infer risk from their names. `php artisan migrate`, `x-migrate`, +`just setup`, and `./bin/bootstrap` are all just raw user-owned commands. + +### Lifecycle + +The intended `pv link` flow: + +1. Read and parse Project config. +2. Run `hooks.prepare`. +3. Record/link the Project as desired state. +4. Reconcile Managed Resources and Resource allocations. +5. Render the PV-managed `.env` block. +6. Run `hooks.setup`. +7. Finish Project serving reconciliation. + +If `pv link` runs before `pv setup`, `prepare` can still run immediately because +it does not need PV infrastructure. `setup` should be deferred until `pv setup` +starts the daemon, reconciles the Project, and renders `.env`. + +When `pv setup` later handles Projects that were linked before setup, it should +run deferred `setup` hooks per Project. A failed Project hook should not stop +other Projects from reconciling or running their own hooks. + +The Project config watcher should not run hooks. Editing `pv.yml` should trigger +normal reconciliation only, not arbitrary shell commands. + +### Execution Contract + +Hooks are deliberately dumb: + +- Commands run sequentially. +- Commands fail fast within a Project. +- PV does not diff hook definitions. +- PV does not track whether command text changed. +- PV does not decide whether a command is necessary. +- The user owns idempotency. +- PV owns ordering, environment, output, logging, and failure reporting. + +Re-running `pv link` should run the hooks again. If users do not want that, they +can skip hooks for that invocation. + +Likely escape hatch: + +```sh +pv link --skip-hooks +``` + +### Failure Behavior + +If `prepare` fails: + +- Stop immediately. +- Do not record/link the Project. +- Do not reconcile resources. +- Do not write `.env`. +- Exit non-zero. + +If `setup` fails: + +- Stop at the first failed command. +- Keep the Project linked. +- Keep reconciled Managed Resources and Resource allocations. +- Keep the rendered `.env` block. +- Mark the Project degraded or failed for hook execution. +- Exit non-zero for the foreground command that ran the hook. +- Log stdout/stderr and show the failed command clearly. + +For `pv setup` with multiple previously linked Projects, hook failures are scoped +per Project. PV should continue processing other Projects and exit non-zero at +the end if any deferred hook failed. + +### Shell Choice + +Run each hook command through: + +```sh +/bin/sh -c '' +``` + +Do not use the user's `$SHELL`, do not use a login shell, and do not source shell +profiles by default. This keeps behavior deterministic and avoids aliases, +functions, shell plugins, and unrelated startup side effects. + +Each command should run with: + +- working directory set to the Project root +- PV shims prepended to `PATH` +- PV-managed Composer environment values set explicitly +- the normal inherited environment otherwise + +Users who need another shell can opt in inside their own command: + +```yaml +hooks: + setup: + - zsh -lc 'source ~/.zshrc && custom-command' +``` + +For anything complex, Projects should prefer a script: + +```yaml +hooks: + setup: + - ./bin/pv-setup +``` + +### Names Considered + +Preferred names: + +- `prepare`: before PV mutates Project state +- `setup`: after PV renders `.env` + +Names that felt too implementation-focused: + +- `before_env` +- `after_env` +- `before_resources` +- `after_resources` +- `after_gateway` + +Names that may be useful later but should not be part of the first version: + +- `ready`: after the Project hostname is expected to be reachable +- `cleanup`: around unlink or explicit cleanup flows + +### Open Questions + +- Does PV need a small deferred-hook marker so repeated `pv setup` does not run + deferred `setup` hooks forever? +- Should there be a separate command to rerun only hooks, such as + `pv project:setup`, or is `pv link` enough? +- Should hook status appear in `pv list`, `pv status`, both, or only logs? +- Should hook command output be stored in the normal daemon job logs, a + Project-specific hook log, or both? + +## Curated Global Tools + +PV could offer a curated local development tool installer for global CLI tools +that are useful in Laravel/PHP workflows. The first candidate is the Laravel +Installer. + +This should not be modeled as a normal Managed Resource artifact. Composer is +already a PV-managed artifact, but Composer global packages live in the +user-facing Composer home: + +```text +~/.pv/composer/ + composer.json + composer.lock + vendor/bin/ +``` + +PV already includes `~/.pv/composer/vendor/bin` in `pv env`, so Composer global +tool binaries are already exposed on `PATH` when users opt into PV shell +integration. For example, installing `laravel/installer` globally should expose +the `laravel` binary without PV creating an extra shim. + +### Product Shape + +This is a quality-of-life feature, not core infrastructure. + +The most useful interface is an interactive picker: + +```sh +pv tools +``` + +Possible UI shape: + +```text +PV tools + +[ ] Laravel Installer laravel/installer +[ ] Laravel Pint laravel/pint +[ ] PHPStan phpstan/phpstan +[ ] Pest pestphp/pest + +Install selected tools? [Y/n] +``` + +Scriptable commands can still exist for documentation, automation, and tests: + +```sh +pv tools:list +pv tools:install laravel +``` + +The picker is important because the feature is mostly about discovery and +curation. If PV only exposes `pv tools:install `, it is not much better +than telling users to run `composer global require ` themselves. + +### Registry + +The registry should be structured, not a set of arbitrary shell commands. + +Preferred shape: + +```yaml +laravel: + manager: composer + package: laravel/installer + binaries: + - laravel +``` + +Avoid this shape: + +```yaml +laravel: + manager: composer + package: laravel/installer + install: composer global require laravel/installer + update: composer global require laravel/installer +``` + +Raw command strings look flexible, but they turn the registry into a command +execution system. If the registry ever becomes remote, that gets especially +dangerous. A structured registry lets PV derive safe manager-specific behavior +for install, update, remove, status, and diagnostics. + +For Composer-managed tools, PV can derive commands such as: + +```sh +composer global require laravel/installer +composer global update laravel/installer --with-dependencies +composer global remove laravel/installer +``` + +The `binaries` field is still useful even though Composer handles installation. +PV can use it to show users what command they will get and to verify that the +expected binary exists after installation. + +### Tool Metadata + +The curated tools registry can expose helpful metadata without becoming a +plugin or add-on command system. + +The interactive picker and `tools:list` output should eventually be able to +show: + +- package name +- manager +- installed binaries +- install location +- update path +- trust boundary + +For example: + +```text +Laravel Installer +Package: laravel/installer +Manager: Composer +Binary: laravel +Installs into: ~/.pv/composer/vendor/bin +Updates with: pv update +Trust: third-party Composer package +``` + +Do not use this as a way to register arbitrary commands. PV should show what it +is managing and how, but it should not become an add-on/plugin command runner. + +### Updates + +PV should not silently update tools in the background. + +Composer/npm-style packages can change behavior, run scripts/plugins, or become +compromised upstream. Background updates would make executable code change +without an explicit user action, which is not a good default. + +Instead, installed curated tools should update during: + +```sh +pv update +``` + +That keeps the maintenance story simple: + +> Anything PV installed can be updated by running `pv update`. + +Tool-specific update commands may be added later if useful, but they are not the +core value of the feature. + +### Boundaries + +Curated tools are different from Managed Resources. + +Managed Resources are installed artifacts that PV provisions, starts, +reconciles, supervises, or uses as local runtime infrastructure. Curated tools +are global CLI packages installed through an existing package manager such as +Composer. + +For a first version, Composer is the only manager worth supporting. Future +managers such as pnpm could reuse the same concept if PV later supports them. + +### Open Questions + +- Should PV track selected curated tools in `pv.db`, or infer installed tools + from Composer global state? +- Should `pv tools:list` show all curated tools, installed tools only, or both? +- Should `pv tools` support uninstalling from the same picker, or only + installing? +- Should installed curated tools participate in `pv uninstall --prune`, or is + preserving `~/.pv/composer` already enough? + +## Resource Shell Commands + +PV should strongly consider shell commands for SQL Managed Resources in v1. + +Competitor research showed that users value fast access to local databases and +resource tooling. PV already has `pv project:env`, `pv mailpit:open` / +`pv mail:open`, and `pv rustfs:open` / `pv s3:open`, so this idea should not +duplicate those commands. + +The missing ergonomic piece is opening the correct database client with the +correct PV-managed connection details. + +### Namespace + +Do not introduce a generic `db:*` namespace. + +PV already has explicit Managed Resource namespaces, and `db` is ambiguous. It +could mean MySQL, Postgres, Redis, SQLite, or some future database-like Managed +Resource. Commands should stay under the resource they operate on: + +```sh +pv mysql:shell +pv mysql:shell 8.4 + +pv postgres:shell +pv postgres:shell 18 + +pv pg:shell +pv pg:shell 18 +``` + +The optional positional track can resolve the same way other resource commands +resolve tracks. If omitted, PV can use the manifest default track or the only +installed/running track, depending on the final command design. + +The exact resolution rules need a real design pass. The core product idea is the +command, not the track-resolution contract. + +### Product Shape + +`mysql:shell` should launch the PV-managed MySQL client for the selected MySQL +track with the correct host, port, username, and password. + +`postgres:shell` / `pg:shell` should launch the PV-managed `psql` client for the +selected Postgres track with the correct host, port, username, and password. + +The first version can be resource-track oriented rather than Project-allocation +oriented. It is enough to connect users to the running local database server. +Later, PV can consider Project-aware selection for a specific Resource +allocation database. + +### Boundaries + +These commands are interactive convenience commands. They should not: + +- print secrets in broad status output +- rotate credentials +- create or delete databases +- run migrations +- inspect application schemas +- create a generic `db:*` namespace + +`pv project:env` remains the explicit command that prints generated Project env +values, including secrets. + +### Open Questions + +- Should `mysql:shell` / `postgres:shell` connect to the server default database, + or try to infer the current Project's first SQL Resource allocation? +- If the current Project has multiple SQL allocations, should PV show a picker + or require an explicit allocation selector? +- Should there be a non-interactive flag to print the equivalent native client + command without executing it? +- Should PV expose SQL shell commands before or after Project-aware allocation + selection exists? + +## Resource Data Commands + +PV should keep database import/export/snapshot/restore/backups as a compelling +post-v1 or v1.1 idea. + +DDEV's database tooling is a major product advantage, and Laragon/FlyEnv users +also ask for backup and admin workflows. This is worth copying eventually, but +it is bigger than a small shell command because restore/import operations are +destructive and each resource has different semantics. + +Possible future commands: + +```sh +pv mysql:export +pv mysql:import +pv mysql:snapshot +pv mysql:restore +pv mysql:backups +pv mysql:clone + +pv postgres:export +pv postgres:import +pv postgres:snapshot +pv postgres:restore +pv postgres:backups +pv postgres:clone + +pv pg:export +pv pg:import +pv pg:snapshot +pv pg:restore +pv pg:backups +pv pg:clone +``` + +These should stay under explicit resource namespaces. Do not use `db:*`. + +### Resource Cloning + +Resource cloning is not confirmed yet, but it is stronger than a random maybe. + +The compelling use case is worktree and agent workflows. A developer or agent +could have several linked worktrees based on `main`, each with its own Project +allocation. PV could clone the main Project's database into each worktree's +database so every checkout starts with useful local data without hand-written +dump/import commands. + +This should not become `pv project:clone`. Worktrees already use normal +`pv link`; cloning is about copying backing Resource data between explicit +allocations. + +Possible command shape: + +```sh +pv postgres:clone +pv mysql:clone +``` + +The hard part is architecture, not naming. Cloning may be slow depending on +database size, disk speed, engine behavior, and whether PV can use an efficient +local snapshot path or has to fall back to dump/restore. A real design should +think through progress output, cancellation, confirmation before overwriting +target data, whether to snapshot the target first, and how source/target +selectors resolve to Project Resource allocations. + +### Why This Is Later + +The design needs to answer: + +- whether operations target a whole Managed Resource track or a Project Resource + allocation +- how clone source/target selectors resolve across linked Projects and + allocations +- whether PV snapshots before destructive restore/import +- file formats and compression +- whether clone is implemented as dump/restore, engine-native copy, or a + resource-specific fast path +- MySQL vs Postgres differences +- backup retention and naming +- whether Redis and RustFS need equivalent backup concepts +- how to avoid surprising users who expect PV to preserve local data by default + +This idea is captivating, but it needs a deliberate design before it becomes a +command surface. + +## Post-v1 Managed Resource Candidates + +PV should have room to grow beyond the initial Laravel-first v1 Managed Resource +set. + +The current v1 set is already broad enough: PHP/FrankenPHP, MySQL, Postgres, +Redis, Composer, Mailpit, and RustFS. Future resources should be added +deliberately, one at a time, after the resource adapter/artifact pipeline is +boring. + +Strong future candidates: + +- Meilisearch +- Typesense +- Valkey +- ClickHouse + +Other possible candidates: + +- MariaDB +- MongoDB +- OpenSearch / Elasticsearch +- Redis Stack or Redis module-aware variants, only if Redis 8 does not cover the + practical local-dev need + +### Product Shape + +Do not create a generic module marketplace. + +Each supported resource should be a first-class PV resource with: + +- a PV-owned artifact recipe or wrapped upstream artifact +- manifest tracks and update behavior +- install/update/uninstall/list commands +- daemon runtime adapter +- readiness checks +- logs +- status/doctor coverage +- Project config support +- env placeholder contract +- clear data retention and prune behavior + +Command namespaces should stay explicit: + +```sh +pv meilisearch:install +pv meilisearch:list + +pv typesense:install +pv typesense:list + +pv valkey:install +pv valkey:list + +pv clickhouse:install +pv clickhouse:list +``` + +Avoid a vague `service:*`, `module:*`, or `resource:*` public namespace for +normal user workflows. + +### Likely Order + +Meilisearch and Typesense are probably the best early additions. They are common +in Laravel apps through Scout/search workflows and have clear local-development +value. + +Valkey is useful if users want a Redis-compatible alternative or if Redis +licensing/community pressure keeps mattering. Since PV already targets Redis 8, +Valkey should be demand-driven rather than automatic. + +ClickHouse is interesting for analytics-heavy apps, but it is a larger product +surface than search services. Add it only after the common web-app resources are +solid. + +MariaDB and MongoDB can wait for explicit demand. OpenSearch/Elasticsearch are +heavy enough that PV should be careful before taking them on. + +### Allocation Questions + +Not every resource needs Project allocations in the first version. + +Search services may only need resource-level env values at first: + +```yaml +meilisearch: + version: "latest" + env: + MEILISEARCH_HOST: "${url}" + MEILISEARCH_KEY: "${key}" +``` + +Later, PV can decide whether a resource needs Project-specific objects such as +indexes, databases, users, tokens, or namespaces. + +The important rule is the same as the v1 resources: PV should not pretend it +manages application data semantics. It can create local infrastructure and basic +access credentials, but application schemas, indexes, migrations, and data +seeding remain user-owned. + +## TUI / `pv.test` Dashboard + +PV should consider a dashboard experience after the CLI foundation is stable. + +Competitor research points in this direction: Herd has a GUI Site Manager, +Laragon's tray/menu workflow is a major part of its appeal, FlyEnv is heavily +GUI-driven, and DDEV's community pushed toward a TUI before a full GUI. + +The likely order should be: + +1. TUI first. +2. `pv.test` internal web dashboard later. +3. Native GUI/menu bar much later, if ever. + +### TUI First + +A terminal dashboard fits PV's CLI-first product shape better than a native GUI. +It could become a fast way to inspect and act on local state without adding a +large desktop application surface. + +Possible command shapes: + +```sh +pv +pv dashboard +``` + +The exact command name needs a later design pass. + +Useful TUI content: + +- linked Projects +- Project hostnames +- PHP tracks +- Managed Resource health and ports +- recent jobs +- config/env/hook failures +- quick actions for opening Projects, Mailpit, RustFS, and logs + +The TUI should be a view over existing daemon/state APIs. It should not require +special polling hacks or a separate state model. + +### TUI Log Viewer + +PV could also offer a focused terminal log viewer before a full dashboard. + +This is different from making normal `pv logs` richer. The plain command should +stay simple, pipeable, and script-friendly. A TUI log viewer is for the human +case where someone wants to watch several PV-owned streams at once without +opening multiple terminal tabs. + +Possible command shape: + +```sh +pv logs --tui +pv logs --tui --all +pv logs --tui --gateway +pv logs --tui --resource mysql --track 8.0 +``` + +The command should reuse the existing `pv logs` source selection model instead +of inventing a separate logs product. + +Useful behavior: + +- split panes or tabs for daemon, LaunchAgent, Gateway, workers, and Managed + Resources +- source labels and severity coloring +- pause/resume following +- search/filter within visible logs +- quick source toggles +- clear empty-state messages when a selected log does not exist yet + +Boundaries: + +- read-only +- no daemon control from the log viewer +- no mutation or auto-reconcile +- no secret scraping or Project `.env` display +- normal `pv logs`, `pv logs --follow`, and `pv logs --all` remain the stable + non-TUI interface + +### `pv.test` Later + +`DESIGN.md` already reserves `pv.test` for PV diagnostics or a future internal +UI. That makes it a natural place for a local web dashboard later. + +The first version should probably be read-only or mostly read-only: + +- Projects +- Managed Resources +- Mailpit/RustFS links +- logs +- diagnostics +- generated env preview +- recent jobs + +Mutating actions from a browser UI are a much bigger product and security +surface, even locally. They should wait until the CLI behavior is already +boring and well understood. + +### Native GUI + +A native desktop GUI or menu bar app should be deferred hard. + +It may become useful later, but it is expensive and risks pulling PV toward the +same broad surface area as Herd/FlyEnv before the CLI control plane is excellent. + +## Richer CLI Presentation + +PV should have a cleaner, richer, more minimal CLI presentation across important +commands. + +This is separate from a TUI or dashboard. It applies to normal command output: + +```sh +pv setup +pv status +pv doctor +pv list +pv jobs +pv mysql:list +pv postgres:list +``` + +Current command output is functional, but it can be noisy and flat. For example, +`pv setup` prints every step as plain lines, `pv doctor` prints every passing +check, and `pv status` reads like a raw state dump. That is good for tests and +debugging, but not necessarily a polished product experience. + +### Product Feel + +PV should feel calm, minimal, and precise. + +The default human output should emphasize: + +- what happened +- what needs attention +- the next command to run +- paths and details only when useful + +It should avoid making successful flows feel like logs. + +Possible presentation direction: + +- group related lines into compact sections +- hide successful low-level details by default +- show warnings/failures before verbose successful checks +- use color and symbols in TTY output, while preserving clear words +- keep `NO_COLOR` and `--no-color` deterministic +- provide detail flags where needed, such as `--verbose` +- keep JSON output stable and boring for scripts + +### Command-Specific Notes + +`pv setup` should feel like a guided installer, not a log dump. It can show a few +major phases and then a concise success summary. + +`pv doctor` should probably default to failed/warning checks plus a compact +summary. Passing checks can be collapsed unless `--verbose` is used. + +`pv status` should read like a dashboard summary: overall health first, then the +few things the user should care about. It should not duplicate every detail that +belongs in `pv doctor`, `pv list`, or resource-specific list commands. + +`pv list` and resource list commands should be easy to scan. Tables should be +compact, aligned, and avoid noisy placeholder values when possible. + +Project selectors should also feel polished. + +This mainly affects `pv open` when run outside a linked Project, and later the +TUI/dashboard command surfaces. Once users have many linked Projects, a plain +numbered list becomes tedious even if it is deterministic. + +Useful selector polish: + +- searchable / fuzzy filtering by primary hostname and path +- compact health indicators for failed or degraded Projects +- additional hostname count or hint without making each hostname a separate row +- recent Projects can influence initial ordering if PV can derive that without + adding user-managed metadata +- keyboard navigation in TUI contexts + +Avoid persistent Project favorites, folders, or arbitrary groups for now. They +add metadata and product surface without solving a core PV problem. + +Terminal hyperlinks are another worthwhile polish pass. + +When output is going to an interactive terminal that supports OSC 8 links, PV +can make obvious URLs and paths clickable while keeping the visible text normal +and copyable. + +Good candidates: + +- Project URLs +- Mailpit/RustFS dashboard URLs +- log directories and log files +- Project config paths +- generated Gateway/worker config paths when shown for diagnostics + +Boundaries: + +- never in JSON +- never for secrets, DSNs, or generated env values +- disabled for non-TTY output +- disabled by `NO_COLOR` / `--no-color`, or by a later plain-output flag +- implemented in the shared output layer rather than one command at a time + +### Boundaries + +This is presentation polish, not a behavior change. + +The underlying command contracts should stay intact: + +- no hidden mutations +- JSON output remains scriptable +- secrets remain redacted outside explicit commands such as `pv project:env` +- plain output remains usable without color +- tests should continue to snapshot deterministic output + +## ZDOTDIR-Aware Shell Integration + +PV should respect `$ZDOTDIR` for zsh shell profile integration. + +This is small v1 setup polish, not a new product surface. Today the design says +PV edits `~/.zprofile` for zsh, and the current implementation follows that. +That works for the default macOS zsh setup, but zsh users can move their startup +files by setting `$ZDOTDIR`. For those users, writing `~/.zprofile` can appear to +succeed while new terminals never load PV's shell integration. + +### Product Shape + +For zsh only: + +- if `$ZDOTDIR` is set to a sane absolute directory, use + `$ZDOTDIR/.zprofile` +- otherwise keep using `~/.zprofile` +- still edit only one detected shell profile file +- keep the same confirmation, backup, reporting, `--yes`, `--non-interactive`, + and `--no-path` behavior +- keep the same manual shell integration fallback when the target cannot be + determined or edited + +This should apply to both the generated installer and `pv setup`, because both +can create or repair the `PV ENV` shell block. + +### Boundaries + +Do not turn this into broad shell-profile discovery. + +PV should not scan and mutate several files such as `.zshrc`, `.zlogin`, and +`.profile`. The existing design rule still matters: PV edits one detected shell +profile file and keeps the mutation clearly delimited inside the `PV ENV` block. + +Do not add completion installation as part of this. Completions remain explicit +through `pv completions `. + +## Agent-Friendly CLI Contracts + +PV should stay friendly to AI agents and automation by improving its existing +CLI contracts, not by becoming an AI tool manager. + +The direction for now is: + +- keep expanding reliable `--json` output where it is useful +- make JSON stdout quiet and machine-readable +- keep warnings, repair hints, and human presentation out of JSON stdout +- improve existing commands instead of adding a dedicated agent context command +- do not add an MCP server yet +- do not manage Codex, Claude, OpenCode, API keys, model selection, or agent + installs + +Current PV already has useful machine-readable surfaces: + +```sh +pv project:env --json +pv list --json +pv status --json +pv jobs --json +pv mysql:list --json +pv postgres:list --json +pv redis:list --json +pv rustfs:list --json +``` + +That is enough of a foundation for now. If agents need more context later, the +first move should be to improve the existing command shapes and schemas before +adding new AI-specific product surface. + +### Non-Goals + +Avoid an agent-specific tool registry. + +In this context, "agent-specific tool registry" means PV keeping a catalog of +known AI tools such as Codex, Claude, OpenCode, Cursor agents, or MCP servers, +then exposing install commands, capabilities, binary paths, API key setup, model +configuration, or tool manifests for those agents. + +That would pull PV away from its local development control-plane job. PV should +make project/resource state easy for any tool to read, but it should not become +the owner of the agent ecosystem. + +## Project TLS Placeholders + +PV should expose stable Project TLS file paths as env placeholders instead of +building framework-specific integrations for every frontend tool. + +Possible placeholders: + +```yaml +env: + APP_URL: "${project_url}" + VITE_DEV_SERVER_KEY: "${tls_key}" + VITE_DEV_SERVER_CERT: "${tls_cert}" + PV_TLS_CA: "${tls_ca}" +``` + +The concrete placeholders should be: + +- `${tls_key}`: path to the Project TLS private key +- `${tls_cert}`: path to the Project TLS certificate +- `${tls_ca}`: path to PV's local CA certificate, if exposed + +`${tls_ca}` should point only to the CA certificate. PV must never expose the CA +private key through Project env placeholders. + +### Product Shape + +This keeps PV's side simple: + +- PV owns creating or exporting stable certificate files for the Project's + primary hostname. +- env rendering exposes those file paths. +- users map the placeholders into whatever their frontend or dev server + expects. + +PV should not create first-party plugins for Vite, Rspack, Webpack, Laravel Mix, +or every other JavaScript dev server. Laravel's Vite integration can already +read env-style key/cert paths when users map them. Other tools can use small +config snippets in their own config files. + +This is the right level of abstraction: PV provides trusted local TLS material; +the application decides how its dev tooling consumes it. + +### Important Caveat + +Do not make Caddy/FrankenPHP's internal certificate storage part of PV's public +contract. + +The current design says the Gateway uses PV's local CA and Caddy/FrankenPHP +generates Project certificates as needed. For placeholders, PV probably needs a +deliberate export or generation path with stable filenames under PV-owned +storage, such as a future `~/.pv/certificates/projects/...` layout. + +The exact storage path can wait for implementation design, but the env contract +should be stable from the user's point of view. + +### Boundaries + +This should not turn into: + +- automatic edits to `vite.config.*` +- automatic edits to `webpack.mix.js` +- frontend build tool detection +- JS dev server process management +- a PV plugin ecosystem for frontend TLS + +If a Project wants custom behavior, it can use normal env mappings and its own +config. + +## PHP Extension Profiles + +PV should not support arbitrary user-loaded PHP `.so` files, but PV-managed +optional shared extensions look promising. + +The current v1 design deliberately avoids PHP extension management. PHP and +FrankenPHP artifacts use a fixed compiled-in extension set, no `phpize`, no +PECL, no dynamic extension installation, and one build flavor per PHP track. +This idea would be a later design pass that reopens that decision carefully. + +### Product Direction + +PV owns the extension modules. + +Users should not download random `.so` files, point PV at them, or compile +extensions locally. If PV supports Xdebug, Imagick, or another optional +extension, PV should build, publish, validate, and smoke-test that module. + +PV then enables extensions by generating ini overlays. + +For CLI PHP, the PV `php` shim already controls ini discovery with `PHPRC` and +`PHP_INI_SCAN_DIR`. A future extension system could append a PV-generated +profile `conf.d` directory to that scan path. + +For FrankenPHP, PV would start a worker process for the required PHP track plus +extension profile. Since PHP extensions load at process startup, changing the +enabled extension set means reloading or restarting the affected worker group. + +Example generated Xdebug ini: + +```ini +zend_extension=/Users/me/.pv/resources/php/8.4/releases/8.4.22-pv1/modules/xdebug.so +xdebug.mode=debug,develop +xdebug.client_host=127.0.0.1 +``` + +Normal extensions would use `extension=...`; Zend extensions such as Xdebug use +`zend_extension=...`. + +### Activation Models + +Two user-facing activation models seem useful. + +Track-global toggle: + +```sh +pv php:extension enable xdebug --track 8.4 +pv php:extension disable xdebug --track 8.4 +``` + +This is simple and probably good enough for some users, but it affects every +CLI command and every Project worker using that PHP track. + +Project/runtime profile: + +```yaml +php: "8.4" +php_extensions: + - xdebug +``` + +or: + +```yaml +php: "8.4" +php_profile: debug +``` + +Internally, PV can turn that into separate runtime groups: + +```text +php-8.4 +php-8.4+xdebug +``` + +That gives Project-level process isolation without a VM or container. A Project +using Xdebug routes to the Xdebug-enabled FrankenPHP worker, and the `php` shim +inside that Project uses the same ini overlay for CLI commands. + +### Packaging Options + +The packaging choice can stay open for now. + +Bundled optional modules: + +```text +~/.pv/resources/php/8.4/releases/8.4.22-pv1/ + bin/php + modules/xdebug.so + modules/imagick.so +``` + +This is the simplest model for a small curated set, especially Xdebug. + +Separate extension artifacts: + +```text +php-extension:xdebug:8.4 +php-extension:imagick:8.4 +``` + +This may be better for larger extensions, extensions with native dependency +surfaces, or extensions that update on a different cadence from PHP itself. The +hard requirement is that each extension artifact is tied to the exact PHP patch +version, platform, ZTS mode, and PV build revision it was built against. + +Compiled-in optional extensions are less attractive for toggles. They are fine +for the default fixed extension set, but not for Xdebug-style opt-in behavior. + +### Boundaries + +Avoid: + +- arbitrary user-provided `.so` loading +- local extension compilation +- `phpize` and `php-config` as product features +- PECL installation as product behavior +- per-Project custom ini as a broad general feature +- CLI and browser extension drift for the same Project/runtime profile + +PV should keep the default PHP track clean and boring. Optional extensions +should be opt-in and visible in status output. + +### Open Questions + +- Is Xdebug important enough for v1, or should this stay post-v1? +- Should the Project config key be `php_extensions`, `php_profile`, or both? +- Should track-global extension toggles exist, or should PV only expose + Project/runtime profiles? +- Should optional modules be bundled into the PHP artifact first, or published + as separate extension artifacts from day one? +- How should `pv status`, `pv list`, and `pv php:list` display active extension + profiles? + +## Auto-Reconcile Action Commands + +PV should consider auto-reconciling before action commands after v1. + +Current design keeps dashboard/open commands read-only. That is a good v1 +boundary, because it keeps command behavior obvious while the daemon and +resource lifecycle are still settling. + +Post-v1, the UX can get smoother. If a user asks PV to do an action that needs a +Project or Resource runtime, PV can reconcile the relevant desired state first, +wait for readiness, then perform the action. + +Possible commands: + +```sh +pv open +pv mailpit:open +pv mail:open +pv rustfs:open +pv s3:open +pv mysql:shell +pv postgres:shell +pv pg:shell +``` + +This means a stopped-but-linked Project could come back up when the user runs +`pv open`, and a demanded Mailpit/RustFS runtime could start before opening the +dashboard. + +### Product Shape + +This should apply to action commands only. + +Good candidates: + +- open a Project URL +- open a Resource dashboard +- enter an interactive Resource shell +- maybe tail logs for a specific Project or Resource if the runtime is demanded + +Bad candidates: + +- `pv status` +- `pv list` +- `pv doctor` +- `pv jobs` +- `pv *:list` +- JSON/status commands in general + +Read/status commands should stay observational. They should report current +state, not mutate it. + +If the daemon is running, the action command can request the narrowest useful +reconciliation scope, wait for completion, then continue. If the daemon is not +running, it should fail clearly or suggest `pv setup` / `pv daemon:restart` +rather than silently starting system integrations. + +### Boundaries + +Avoid making every command magical. + +Auto-reconcile should not: + +- run Project hooks +- install unrelated default resources +- start resources that no linked Project demands +- mutate Project config +- hide reconciliation failures +- make `--json` commands perform surprise mutations + +If a runtime cannot become ready, the command should show the failed +reconciliation result and point to logs/doctor output. + +## Project Details Command + +PV should probably add a focused Project details command. + +`pv list` is broad and compact. `pv status` is whole-system and explicitly not +scoped to the current Project. `pv project:env` prints generated env values, +including secrets, and should stay focused on that job. + +There is still a useful gap: + +```sh +pv project:status +pv project:status acme.test +pv project:status --json +``` + +The command should show what PV believes about one Project, resolving from the +current directory by default and accepting a hostname argument when provided. + +Possible output shape: + +```text +Project: acme.test +Path: /Users/me/Code/acme +Config: pv.yml +PHP: 8.4 +Document root: public +URLs: + https://acme.test + https://api.acme.test +Serving: running via worker php-8.4 +Env: current +Resources: + postgres 18 app ready + redis 8.8 cache ready + mailpit 1 mail ready +Logs: + pv logs --worker 8.4 + pv logs --resource postgres --track 18 +``` + +This should be read-only. It should not reconcile, start resources, rewrite +env, install artifacts, or run hooks. + +### Boundaries + +Do not print secrets. + +If users need actual generated env values, the explicit command remains: + +```sh +pv project:env +``` + +`project:status --json` should be stable and agent-friendly, but still avoid +secrets. It can include Project identity, path, config status, resolved PHP +track, document root, hostnames, env observed state, resource demand/allocation +status, relevant runtime subjects, and useful log command hints. + +This is likely v1-worthy because it makes PV's Project model visible without +adding new lifecycle behavior. + +## Gateway Unknown Hostname Page + +PV should make the Gateway 404 page nicer before or soon after v1. + +The current design already says unknown `.test` hostnames should return a simple +self-contained HTML response explaining that no PV Project is linked for the +hostname. This is worth treating as product polish rather than leaving the +Gateway fallback as a generic "running" response. + +When a user visits an unlinked hostname such as `https://whatever.test`, PV can +use the page to reinforce the routing model: + +- DNS catches `.test` +- the Gateway is running +- no linked Project owns this hostname yet + +The page should stay small and static. It should not become a dashboard, daemon +control UI, or secret-bearing status page. + +Useful content: + +- the requested hostname +- `pv link --hostname ` +- `pv list` +- `pv open` +- maybe known linked Projects if the generated Gateway config can include them + safely +- a more specific missing-Project message when a hostname belongs to a linked + Project whose path no longer exists + +This belongs to Gateway UX, not Project config. It should not add new hostname +rules, wildcard routing, dashboard routes, or arbitrary local domains. + +## LAN Project Access + +PV should support local network access to selected Projects after v1, likely in +v1.1. + +This is separate from public tunneling. LAN access is for phones, tablets, and +other devices on the same trusted local network. Tunneling through Cloudflare, +frp, ngrok-style services, or PV-hosted sharing can wait until later, maybe v2. + +The current v1 design intentionally binds Gateway, Project workers, DNS, and +backing Managed Resources to loopback. That should remain the safe default. +LAN access should be explicit and scoped. + +Possible command shape: + +```sh +pv lan acme.test +pv lan +pv lan:stop acme.test +pv lan:list +``` + +The command should expose one selected Project, not every linked Project and not +the whole PV stack. + +### Product Shape + +The simplest useful version is probably a per-Project LAN listener: + +```text +http://192.168.1.25:48123 +``` + +PV can allocate a high port, bind that listener on the chosen LAN interface, and +route all traffic for that listener to the selected Project internally. The +phone does not need to resolve `.test`, install PV's CA, or send a special Host +header. + +Internally, the LAN listener can still proxy to the normal Project route using +the Project's primary hostname as the upstream Host. From the user's point of +view, they get a simple LAN URL. + +This avoids requiring users to: + +- configure router DNS +- point a phone at PV's DNS resolver +- install the PV local CA on the phone +- use a public tunnel for same-network testing + +HTTPS can come later. Plain HTTP on a private LAN is probably acceptable for the +first version, as long as the command is explicit about what is being exposed. + +### Boundaries + +LAN access should not expose: + +- backing Managed Resource ports +- Mailpit or RustFS dashboards unless explicitly requested later +- every linked Project by default +- the PV daemon socket or control plane +- public internet tunnels + +PV should show the active LAN URL clearly and make stopping it obvious. + +The feature should also handle common rough edges: + +- multiple network interfaces +- changing Wi-Fi IP addresses +- port conflicts +- macOS firewall prompts +- sleeping/waking laptops +- clear status output for active LAN shares + +Possible later polish: + +- QR code output for mobile testing +- interface selection +- temporary expiry +- optional allowlist or one-time token +- HTTPS after the certificate/trust story is designed + +Public tunneling should be a separate idea. It needs provider accounts, public +exposure warnings, auth, rate limits, and abuse considerations. + +## Resource-Only Projects + +PV could support Projects that use PV-managed resources without being served by +the Gateway. + +This would let non-PHP and non-Laravel Projects use PV for local infrastructure +such as Postgres, MySQL, Redis, Mailpit, or RustFS while running their own app +server. For example, a Next.js Project could use PV for Postgres and still run: + +```sh +pnpm dev +``` + +PV would own the backing resources and env rendering. The framework's own dev +server would remain user-owned. + +Example: + +```yaml +serve: false + +postgres: + version: "18" + allocations: + app: + env: + DATABASE_URL: "postgresql://${username}:${password}@${host}:${port}/${database}" + +redis: + allocations: + cache: + env: + REDIS_URL: "redis://${host}:${port}" +``` + +### Product Shape + +`serve: false` means the Project is linked and reconciled, but PV does not create +a Gateway route or try to serve the Project at a `.test` hostname. + +`pv link` for a resource-only Project should: + +- register the directory as a Project +- install and start requested Managed Resource tracks +- create Resource allocations +- render configured env values +- skip Gateway route generation for that Project +- show the Project as resource-only in status/list output + +PV should not try to run `next dev`, `pnpm dev`, Rails, Go, Python, or other app +servers in the first version of this idea. That is a separate process +orchestration problem. + +### Project Slug + +Resource-only Projects should not need fake `.test` hostnames just to get stable +Resource allocation names. + +Instead, PV should assign a stored Project slug when the Project is first linked. +The default slug is derived from the Project directory basename: + +```text +/Users/me/Code/appointment -> appointment +``` + +If the slug already exists in PV state, PV assigns the next available suffix: + +```text +appointment +appointment-1 +appointment-2 +``` + +PV should store the assigned slug in `pv.db` and never change it automatically, +even if the directory is later renamed. This keeps generated resource names +stable. + +The assigned slug is used as the readable prefix for generated Resource +allocation names: + +```text +appointment_app # SQL database name +appointment_1_app # SQL database name when slug is appointment-1 +appointment-cache- # Redis prefix +appointment-uploads # RustFS bucket +``` + +SQL resource names convert hyphens to underscores. Redis prefixes and RustFS +buckets keep DNS-style hyphens. + +Collision checks should happen against PV state, not by scanning the underlying +database, Redis, or object storage directly. Underlying resource objects may +include old or orphaned data that PV deliberately preserves. + +### Served Projects + +Served Projects can keep using their Project hostname as the user-facing label +and resource-name prefix for now. + +The Project slug idea may later be unified across all Projects, but the first +reason to introduce it is resource-only Projects where a hostname would be fake. + +### Open Questions + +- Should served Projects also get a stored Project slug, or only resource-only + Projects? +- Should `pv list` show both Project hostname and Project slug when both exist? +- Should resource-only Projects support `pv open`, or should `pv open` clearly + say that the Project is not served by PV? +- Should env rendering still default to `.env`, or should resource-only Projects + support an `env_file` key such as `.env.local`? From 5569eb6b30679cf32c5071c29dfe7b1fc9aa75e4 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sun, 21 Jun 2026 00:13:59 -0400 Subject: [PATCH 02/15] docs: add PHP track defaults design --- docs/2026-06-20-php-track-defaults-design.md | 207 +++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 docs/2026-06-20-php-track-defaults-design.md diff --git a/docs/2026-06-20-php-track-defaults-design.md b/docs/2026-06-20-php-track-defaults-design.md new file mode 100644 index 00000000..ce935350 --- /dev/null +++ b/docs/2026-06-20-php-track-defaults-design.md @@ -0,0 +1,207 @@ +# PHP Track Defaults Design + +## Summary + +PV needs PHP track defaults that are stable across Managed Resource artifact revisions and apply consistently to standalone PHP, Composer, and Project-serving FrankenPHP workers. + +The current published PHP/FrankenPHP artifacts fall back to compiled ini paths under `/usr/local/etc/php`. That is risky because a user machine may already have files there, and PV should not accidentally load host PHP configuration. A runtime probe against a linked Project also showed that current FrankenPHP workers inherit PV's XDG environment but do not receive `PHPRC` or `PHP_INI_SCAN_DIR`, so browser execution currently reports no loaded `php.ini` and falls back to `/usr/local/etc/php`. + +The approved direction is intentionally narrow: PV seeds default PHP configuration once per PHP track, keeps that configuration outside artifact release directories, and points CLI PHP, Composer, and Project-serving FrankenPHP workers at the same track-level defaults through process-level `PHPRC` and `PHP_INI_SCAN_DIR`. + +## Goals + +- Add PV-owned PHP track defaults for each installed PHP track. +- Store track defaults outside immutable artifact release directories so artifact updates and old-release pruning do not remove them. +- Seed `php.ini` and `conf.d/` only when missing. +- Do not overwrite an existing track default file during install or update. +- Use the same default PHP profile for supported PHP tracks `8.3`, `8.4`, and `8.5`. +- Keep standalone PHP, Composer, and FrankenPHP worker execution on the same track defaults through process-level `PHPRC` and `PHP_INI_SCAN_DIR`. +- Change PHP/FrankenPHP artifact build fallback ini paths away from `/usr/local/etc/php`. +- Add tests that prove CLI and browser execution no longer fall back to `/usr/local/etc/php`. + +## Non-Goals + +- Do not add Project-specific PHP ini support. +- Do not add a public command for editing PHP defaults. +- Do not add extension management, dynamic extension loading, `phpize`, PECL, Xdebug, or extra PHP artifact flavors. +- Do not render the seeded defaults into Caddyfile `php_ini` directives. +- Do not pass PHP ini discovery paths through Caddyfile `env` directives. +- Do not overwrite existing seeded files to apply new defaults. +- Do not create or depend on files under the compiled fallback ini path. + +## Filesystem Layout + +Track defaults live under the mutable PHP track directory: + +```text +~/.pv/resources/php// + current -> releases/ + releases/ + etc/ + php.ini + conf.d/ +``` + +`etc/` belongs to the PHP track, not to a specific artifact version. It is preserved across `php:` artifact updates because update cleanup only prunes old directories under `releases/`. + +Default `pv uninstall` preserves `resources/`, so track defaults are preserved with other Managed Resource data. `pv uninstall --prune` removes all PV state, including track defaults. + +## Seed Behavior + +PV creates the track default paths when installing or updating a PHP track: + +- create `~/.pv/resources/php//etc/` +- create `~/.pv/resources/php//etc/conf.d/` +- create `~/.pv/resources/php//etc/php.ini` only if it is missing + +If `php.ini` already exists, PV leaves it unchanged. If `conf.d/` already exists, PV leaves its contents unchanged. + +This is seed-only behavior. It gives PV a stable default file without introducing a managed merge or migration system for future default changes. + +If an existing `php.ini` is a regular readable file, PV uses it. If an existing `php.ini` is a directory, symlink, unreadable file, or other non-regular file, PV fails clearly rather than overwriting it. If `conf.d/` already exists and is a directory, PV leaves its contents unchanged. If `conf.d/` exists but is not a directory, PV fails clearly. + +## Default Source + +The seeded `php.ini` content should come from PV's approved sample `php.ini`. + +PV should generate the seeded file by: + +- removing comments +- preserving section headers, such as `[PHP]` and `[Date]` +- preserving active assignments in their original order, including intentionally empty values + +The same generated default profile applies to PHP tracks `8.3`, `8.4`, and `8.5`. PV seeds it only when `php.ini` is missing. User edits to the seeded file are preserved and become effective for CLI PHP, Composer, and FrankenPHP workers because all three execution paths use `PHPRC` and `PHP_INI_SCAN_DIR`. + +PV should not parse an already-seeded, user-edited `php.ini` to produce any other runtime configuration. The file itself is the runtime configuration surface. + +## PHP Track Defaults Component + +PV should keep PHP default path and environment logic in one small shared component rather than scattering it through CLI and daemon code. + +The component should: + +- compute `resources/php//etc` +- compute `resources/php//etc/conf.d` +- seed `php.ini` and `conf.d/` when missing +- expose the process environment overlay for a resolved PHP track +- validate that existing default paths are usable + +## CLI PHP And Composer + +Standalone PHP and Composer continue to use process environment because the Caddyfile is not involved in CLI execution. + +For an installed PHP track, the `php` shim sets: + +```text +PHPRC=~/.pv/resources/php//etc +PHP_INI_SCAN_DIR=~/.pv/resources/php//etc/conf.d +``` + +The Composer shim invokes Composer through PV's PHP shim, so it inherits the same PHP track defaults. + +The shim should not point to `resources/php//releases//etc` because release directories are immutable artifact payloads and may be pruned after updates. + +## FrankenPHP Workers + +Project-serving FrankenPHP workers should receive PHP defaults through the worker process environment: + +```text +PHPRC=~/.pv/resources/php//etc +PHP_INI_SCAN_DIR=~/.pv/resources/php//etc/conf.d +``` + +Each worker process serves one PHP track, so one track-level process environment overlay per worker process matches the runtime model. + +PV should pass the same environment overlay to FrankenPHP config validation that it will pass to the worker process. Validation must validate the same generated Caddyfile under the same PHP ini discovery environment that will be used to start or reload the worker. + +The Gateway process does not serve Project PHP directly. It should keep only the environment/config it needs for routing, TLS, and storage unless a future feature makes PHP execution in the Gateway process intentional. + +PV should not use Caddyfile `env` to set `PHPRC` or `PHP_INI_SCAN_DIR`. FrankenPHP's Caddyfile `env` directives are request/worker environment values made available to PHP as CGI-like variables after the embedded PHP runtime has already started. PHP ini file discovery must be configured through the OS process environment before FrankenPHP initializes PHP. + +## Artifact Build Fallback + +The PHP/FrankenPHP artifact recipe should stop compiling PHP with `/usr/local/etc/php` as the fallback ini location. + +Use a deterministic path that PV will not create and users are extremely unlikely to populate: + +```text +/var/empty/com.prvious.pv/php +/var/empty/com.prvious.pv/php/conf.d +``` + +These paths are only defensive fallbacks. Normal PV execution must provide explicit process environment configuration. Tests should fail if `phpinfo()` or `php --ini` reports `/usr/local/etc/php` for PV-managed artifacts after the fix. + +## Data Flow + +PHP track install or update: + +1. Resolve and install the selected `php:` artifact. +2. Ensure `resources/php//etc/` and `resources/php//etc/conf.d/` exist. +3. Seed `resources/php//etc/php.ini` if missing. +4. Install or update the paired `frankenphp:` artifact. +5. Record both installed tracks in `pv.db`. +6. Reconcile affected Project-serving workers. + +CLI `php` execution: + +1. Resolve the concrete PHP track from Project state or global default. +2. Verify the installed PHP artifact. +3. Ensure track defaults exist. +4. Execute the selected PHP binary with `PHPRC` and `PHP_INI_SCAN_DIR` pointing at the track defaults. + +FrankenPHP worker reconciliation: + +1. Group Projects by PHP track. +2. Ensure track defaults exist for each demanded PHP track. +3. Render the worker root Caddyfile without expanding PHP defaults into `php_ini` directives. +4. Validate the Caddyfile with the managed FrankenPHP binary and the track-level `PHPRC` / `PHP_INI_SCAN_DIR` process environment. +5. Start, reload, or restart the worker with the same track-level process environment. +6. Promote runtime state through the existing Gateway/worker reconciliation flow. + +## Error Handling + +Failure to create the track defaults is an install/reconciliation failure for that PHP track. PV should report the failing path and preserve the previous working artifact/runtime state where existing rollback behavior allows it. + +If the track default file already exists but is unreadable or is not a regular file, PV should fail clearly rather than overwrite it. If the `conf.d` path exists but is not a directory, PV should fail clearly rather than replace it. + +If the generated FrankenPHP config fails validation under the track-level PHP ini environment, PV should keep the previous active worker config/process and record a worker-scoped runtime error. + +## Testing + +Prefer integration tests and snapshots following nearby PHP, Composer, and Gateway tests. + +Always-run tests should cover: + +- PHP pair install seeds `resources/php//etc/php.ini` and `etc/conf.d/`. +- PHP pair update preserves an existing `php.ini`. +- Seeded `php.ini` removes comments while preserving section headers and active assignments from the approved sample. +- The PHP shim passes `PHPRC` and `PHP_INI_SCAN_DIR` pointing at the track-level `etc` paths. +- Composer shim execution inherits the PHP shim's track-level ini environment. +- Worker process specs include track-level `PHPRC` and `PHP_INI_SCAN_DIR`. +- Worker config validation receives the same track-level `PHPRC` and `PHP_INI_SCAN_DIR` as the worker process. +- Worker config snapshots do not include a generated `frankenphp { php_ini ... }` defaults block. +- Gateway process specs and config snapshots do not add PHP track defaults to the pure routing Gateway unless explicitly needed. + +Artifact recipe and smoke tests should cover: + +- PHP build metadata no longer contains `/usr/local/etc/php` fallback paths. +- `php --ini` under the PV shim reports the track-level `php.ini` path. +- A real FrankenPHP worker serving `phpinfo()` reports the track-level loaded config file and does not report `/usr/local/etc/php`. +- The real-artifact browser smoke remains opt-in and does not run during ordinary local or branch CI unless explicitly enabled. + +Focused verification should prefer: + +```shell +cargo nextest run -E 'test()' +cargo insta test --accept --test-runner nextest -- +cargo fmt --all -- --check +git diff --check +``` + +Before publication of revised PHP/FrankenPHP artifacts, run the native artifact workflow smoke for every supported PHP track and platform. + +## Documentation Impact + +`DESIGN.md` should be updated to describe PHP track defaults as PV-owned track-level configuration seeded under `~/.pv/resources/php//etc`. + +The artifact recipe documentation should describe the safe compiled fallback ini path and state that PV-managed runtime execution must not depend on it. From 82a9da5c7472482b309050834ba517eb099fd7b8 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sun, 21 Jun 2026 00:53:09 -0400 Subject: [PATCH 03/15] feat(resources): add shared PHP track defaults --- .superpowers/sdd/task-1-report.md | 84 ++++++++++++++++++ crates/resources/src/lib.rs | 5 ++ crates/resources/src/php-defaults.ini | 118 +++++++++++++++++++++++++ crates/resources/src/php_defaults.rs | 87 ++++++++++++++++++ crates/resources/tests/php_defaults.rs | 107 ++++++++++++++++++++++ 5 files changed, 401 insertions(+) create mode 100644 .superpowers/sdd/task-1-report.md create mode 100644 crates/resources/src/php-defaults.ini create mode 100644 crates/resources/src/php_defaults.rs create mode 100644 crates/resources/tests/php_defaults.rs diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md new file mode 100644 index 00000000..35eb1811 --- /dev/null +++ b/.superpowers/sdd/task-1-report.md @@ -0,0 +1,84 @@ +What you implemented + +- Added the shared bundled PHP defaults asset at `crates/resources/src/php-defaults.ini` using the exact stripped values from the task brief. +- Added `crates/resources/src/php_defaults.rs` with: + - `PHP_TRACK_DEFAULT_INI` + - `PhpTrackDefaults { etc_dir, php_ini, conf_dir }` + - `php_track_defaults(&PvPaths, &str) -> PhpTrackDefaults` + - `ensure_php_track_defaults(&PvPaths, &str) -> Result` + - `php_track_environment(&PvPaths, &str) -> BTreeMap` + - `php_track_exec_environment(&PvPaths, &str) -> Vec<(OsString, OsString)>` +- Exported the new API from `crates/resources/src/lib.rs`. +- Added focused integration tests in `crates/resources/tests/php_defaults.rs` for one-time seeding, blocking-path rejection, and environment helper output. + +What you tested and results + +- Ran `cargo nextest run -p resources -E 'test(php_track_defaults_)'`. +- Result: 3 tests passed, 0 failed. + +TDD Evidence: RED command/output and GREEN command/output + +RED command: + +```shell +cargo nextest run -p resources -E 'test(php_track_defaults_)' +``` + +RED output: + +```text +error[E0432]: unresolved imports `resources::PHP_TRACK_DEFAULT_INI`, `resources::ensure_php_track_defaults`, `resources::php_track_defaults`, `resources::php_track_environment`, `resources::php_track_exec_environment` + --> crates/resources/tests/php_defaults.rs:6:5 + | +6 | PHP_TRACK_DEFAULT_INI, ensure_php_track_defaults, php_track_defaults, + | ^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^ no `php_track_defaults` in the root + | | | + | | no `ensure_php_track_defaults` in the root + | no `PHP_TRACK_DEFAULT_INI` in the root +7 | php_track_environment, php_track_exec_environment, + | ^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^ no `php_track_exec_environment` in the root + | | + | no `php_track_environment` in the root + +For more information about this error, try `rustc --explain E0432`. +error: could not compile `resources` (test "php_defaults") due to 1 previous error +error: command `/Users/clovismuneza/.rustup/toolchains/stable-aarch64-apple-darwin/bin/cargo test --no-run --message-format json-render-diagnostics --package resources` exited with code 101 +``` + +GREEN command: + +```shell +cargo nextest run -p resources -E 'test(php_track_defaults_)' +``` + +GREEN output: + +```text +Finished `test` profile [unoptimized + debuginfo] target(s) in 5.04s +──────────── + Nextest run ID 658696da-43e8-486a-8b87-c0c55ca1d59a with nextest profile: default + Starting 3 tests across 8 binaries (111 tests skipped) + PASS [ 0.013s] (1/3) resources::php_defaults php_track_defaults_env_helpers_point_at_track_etc + PASS [ 0.014s] (2/3) resources::php_defaults php_track_defaults_reject_blocking_paths + PASS [ 0.015s] (3/3) resources::php_defaults php_track_defaults_seed_stripped_sample_once +──────────── + Summary [ 0.016s] 3 tests run: 3 passed, 111 skipped +``` + +Files changed + +- `crates/resources/src/php-defaults.ini` +- `crates/resources/src/php_defaults.rs` +- `crates/resources/src/lib.rs` +- `crates/resources/tests/php_defaults.rs` +- `.superpowers/sdd/task-1-report.md` + +Self-review findings + +- The implementation is intentionally narrow: it seeds the per-track `etc/php.ini` once, creates `conf.d`, and exposes env helpers without adding validation or cross-crate behavior not required by this task. +- Blocking `conf.d` or `etc` paths return a `StateError::Filesystem` with a task-specific message so later callers can surface a clear failure. +- The root sample file `php.ini` was not modified or tracked. + +Any concerns + +- None for Task 1 scope. diff --git a/crates/resources/src/lib.rs b/crates/resources/src/lib.rs index d7dc425a..3da24816 100644 --- a/crates/resources/src/lib.rs +++ b/crates/resources/src/lib.rs @@ -9,6 +9,7 @@ pub mod http; pub mod identity; pub mod install; pub mod manifest; +mod php_defaults; pub mod platform; pub mod registry; pub mod runtime; @@ -38,6 +39,10 @@ pub use identity::{ }; pub use install::{ArtifactInstall, ArtifactInstaller, ResourceAdapter}; pub use manifest::{ArtifactManifest, ManifestArtifact, ManifestSelection, RevocationState}; +pub use php_defaults::{ + PHP_TRACK_DEFAULT_INI, PhpTrackDefaults, ensure_php_track_defaults, php_track_defaults, + php_track_environment, php_track_exec_environment, +}; pub use platform::{ArtifactPlatform, TargetPlatform}; pub use registry::{ResourceCapability, ResourceDescriptor, ResourceKind}; pub use runtime::{ diff --git a/crates/resources/src/php-defaults.ini b/crates/resources/src/php-defaults.ini new file mode 100644 index 00000000..d8d64ea4 --- /dev/null +++ b/crates/resources/src/php-defaults.ini @@ -0,0 +1,118 @@ +[PHP] +engine = On +short_open_tag = Off +precision = 14 +output_buffering = 4096 +zlib.output_compression = Off +implicit_flush = Off +unserialize_callback_func = +serialize_precision = -1 +disable_functions = +zend.enable_gc = On +zend.exception_ignore_args = Off +zend.exception_string_param_max_len = 15 +expose_php = On +max_execution_time = 30 +max_input_time = 60 +memory_limit = 1024M +max_memory_limit = -1 +error_reporting = E_ALL +display_errors = On +display_startup_errors = On +log_errors = On +ignore_repeated_errors = Off +ignore_repeated_source = Off +variables_order = "GPCS" +request_order = "GP" +auto_globals_jit = On +post_max_size = 128M +auto_prepend_file = +auto_append_file = +default_mimetype = "text/html" +default_charset = "UTF-8" +doc_root = +user_dir = +enable_dl = Off +file_uploads = On +upload_max_filesize = 128M +max_file_uploads = 20 +allow_url_fopen = On +allow_url_include = Off +default_socket_timeout = 60 +[CLI Server] +cli_server.color = On +[Date] +[filter] +[iconv] +[intl] +[sqlite3] +[Pcre] +[Pdo] +[Pdo_mysql] +pdo_mysql.default_socket= +[Phar] +[mail function] +SMTP = localhost +smtp_port = 25 +mail.add_x_header = Off +mail.mixed_lf_and_crlf = Off +mail.cr_lf_mode = crlf +[ODBC] +odbc.allow_persistent = On +odbc.check_persistent = On +odbc.max_persistent = -1 +odbc.max_links = -1 +odbc.defaultlrl = 4096 +odbc.defaultbinmode = 1 +[MySQLi] +mysqli.max_persistent = -1 +mysqli.allow_persistent = On +mysqli.max_links = -1 +mysqli.default_port = 3306 +mysqli.default_socket = +mysqli.default_host = +mysqli.default_user = +mysqli.default_pw = +[mysqlnd] +mysqlnd.collect_statistics = On +mysqlnd.collect_memory_statistics = On +[PostgreSQL] +pgsql.allow_persistent = On +pgsql.auto_reset_persistent = Off +pgsql.max_persistent = -1 +pgsql.max_links = -1 +pgsql.ignore_notice = 0 +pgsql.log_notice = 0 +[bcmath] +bcmath.scale = 0 +[browscap] +[Session] +session.save_handler = files +session.use_strict_mode = 0 +session.use_cookies = 1 +session.use_only_cookies = 1 +session.name = PHPSESSID +session.auto_start = 0 +session.cookie_lifetime = 0 +session.cookie_path = / +session.cookie_domain = +session.cookie_httponly = +session.cookie_samesite = +session.serialize_handler = php +session.gc_probability = 1 +session.gc_divisor = 1000 +session.gc_maxlifetime = 1440 +session.referer_check = +session.cache_limiter = nocache +session.cache_expire = 180 +session.use_trans_sid = 0 +session.trans_sid_tags = "a=href,area=href,frame=src,form=" +[Assertion] +zend.assertions = 1 +[COM] +[mbstring] +[gd] +[exif] +[Tidy] +tidy.clean_output = Off +[soap] diff --git a/crates/resources/src/php_defaults.rs b/crates/resources/src/php_defaults.rs new file mode 100644 index 00000000..3001d4ef --- /dev/null +++ b/crates/resources/src/php_defaults.rs @@ -0,0 +1,87 @@ +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::io; + +use camino::{Utf8Path, Utf8PathBuf}; +use state::{PvPaths, StateError, fs}; + +pub const PHP_TRACK_DEFAULT_INI: &str = include_str!("php-defaults.ini"); + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PhpTrackDefaults { + etc_dir: Utf8PathBuf, + php_ini: Utf8PathBuf, + conf_dir: Utf8PathBuf, +} + +impl PhpTrackDefaults { + pub fn etc_dir(&self) -> &Utf8Path { + &self.etc_dir + } + + pub fn php_ini(&self) -> &Utf8Path { + &self.php_ini + } + + pub fn conf_dir(&self) -> &Utf8Path { + &self.conf_dir + } +} + +pub fn php_track_defaults(paths: &PvPaths, track: &str) -> PhpTrackDefaults { + let etc_dir = paths.resources().join(format!("php/{track}/etc")); + let php_ini = etc_dir.join("php.ini"); + let conf_dir = etc_dir.join("conf.d"); + + PhpTrackDefaults { + etc_dir, + php_ini, + conf_dir, + } +} + +pub fn ensure_php_track_defaults( + paths: &PvPaths, + track: &str, +) -> Result { + let defaults = php_track_defaults(paths, track); + + ensure_directory_path(defaults.etc_dir(), "etc")?; + ensure_directory_path(defaults.conf_dir(), "conf.d")?; + + if !defaults.php_ini().exists() { + fs::write_sensitive_file(defaults.php_ini(), PHP_TRACK_DEFAULT_INI)?; + } + + Ok(defaults) +} + +pub fn php_track_environment(paths: &PvPaths, track: &str) -> BTreeMap { + let defaults = php_track_defaults(paths, track); + + BTreeMap::from([ + ("PHPRC".to_owned(), defaults.etc_dir().to_string()), + ( + "PHP_INI_SCAN_DIR".to_owned(), + defaults.conf_dir().to_string(), + ), + ]) +} + +pub fn php_track_exec_environment(paths: &PvPaths, track: &str) -> Vec<(OsString, OsString)> { + php_track_environment(paths, track) + .into_iter() + .map(|(key, value)| (OsString::from(key), OsString::from(value))) + .collect() +} + +fn ensure_directory_path(path: &Utf8Path, name: &'static str) -> Result<(), StateError> { + if path.exists() && !path.is_dir() { + return Err(StateError::Filesystem { + path: path.to_path_buf(), + source: io::Error::other(format!("PHP track defaults {name} path is not a directory")), + }); + } + + fs::ensure_user_dir(path) +} diff --git a/crates/resources/tests/php_defaults.rs b/crates/resources/tests/php_defaults.rs new file mode 100644 index 00000000..2a542199 --- /dev/null +++ b/crates/resources/tests/php_defaults.rs @@ -0,0 +1,107 @@ +use std::collections::BTreeMap; + +use anyhow::Result; +use camino_tempfile::tempdir; +use resources::{ + PHP_TRACK_DEFAULT_INI, ensure_php_track_defaults, php_track_defaults, php_track_environment, + php_track_exec_environment, +}; +use state::{PvPaths, fs}; + +#[test] +fn php_track_defaults_seed_stripped_sample_once() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let defaults = ensure_php_track_defaults(&paths, "8.4")?; + let first_content = fs::read_to_string(defaults.php_ini())?; + + assert_eq!(defaults.etc_dir(), paths.resources().join("php/8.4/etc")); + assert_eq!( + defaults.conf_dir(), + paths.resources().join("php/8.4/etc/conf.d") + ); + assert_eq!(first_content, PHP_TRACK_DEFAULT_INI); + assert!(first_content.starts_with("[PHP]\nengine = On\n")); + assert!(first_content.contains("\n[Date]\n")); + assert!(first_content.contains("\nunserialize_callback_func =\n")); + assert!(!first_content.contains("; About php.ini")); + + fs::write_sensitive_file(defaults.php_ini(), "memory_limit = 768M\n")?; + let seeded_again = ensure_php_track_defaults(&paths, "8.4")?; + + assert_eq!(seeded_again, defaults); + assert_eq!( + fs::read_to_string(defaults.php_ini())?, + "memory_limit = 768M\n" + ); + + Ok(()) +} + +#[test] +fn php_track_defaults_reject_blocking_paths() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let defaults = php_track_defaults(&paths, "8.5"); + fs::ensure_user_dir(defaults.etc_dir())?; + fs::write_sensitive_file(defaults.conf_dir(), "not a directory\n")?; + + let error = match ensure_php_track_defaults(&paths, "8.5") { + Ok(_) => anyhow::bail!("expected blocking conf.d path to fail"), + Err(error) => error, + }; + + assert!( + error + .to_string() + .contains("PHP track defaults conf.d path is not a directory") + ); + + Ok(()) +} + +#[test] +fn php_track_defaults_env_helpers_point_at_track_etc() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + + assert_eq!( + php_track_environment(&paths, "8.3"), + BTreeMap::from([ + ( + "PHPRC".to_owned(), + paths.resources().join("php/8.3/etc").to_string(), + ), + ( + "PHP_INI_SCAN_DIR".to_owned(), + paths.resources().join("php/8.3/etc/conf.d").to_string(), + ), + ]) + ); + + let exec_env = php_track_exec_environment(&paths, "8.3") + .into_iter() + .map(|(key, value)| { + ( + key.to_string_lossy().into_owned(), + value.to_string_lossy().into_owned(), + ) + }) + .collect::>(); + + assert_eq!( + exec_env, + vec![ + ( + "PHPRC".to_owned(), + paths.resources().join("php/8.3/etc").to_string(), + ), + ( + "PHP_INI_SCAN_DIR".to_owned(), + paths.resources().join("php/8.3/etc/conf.d").to_string(), + ), + ] + ); + + Ok(()) +} From c4f7cf7a4e5cadf992ee2168e953e3a17b91ab23 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sun, 21 Jun 2026 01:00:16 -0400 Subject: [PATCH 04/15] fix(resources): validate PHP track defaults seeding --- .superpowers/sdd/task-1-report.md | 77 ++++++++++++++++++++++++++ crates/resources/src/lib.rs | 2 +- crates/resources/src/php-defaults.ini | 12 ++++ crates/resources/src/php_defaults.rs | 33 +++++++++-- crates/resources/tests/php_defaults.rs | 55 ++++++++++++++++++ 5 files changed, 174 insertions(+), 5 deletions(-) diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md index 35eb1811..6000216c 100644 --- a/.superpowers/sdd/task-1-report.md +++ b/.superpowers/sdd/task-1-report.md @@ -82,3 +82,80 @@ Self-review findings Any concerns - None for Task 1 scope. + +Fix follow-up from review + +What changed + +- Completed the bundled defaults asset tail after `[soap]` with the required active SOAP cache settings plus `[sysvshm]`, `[ldap]`, `[dba]`, `[opcache]`, `[curl]`, `[openssl]`, and `[ffi]` in the required order. +- Tightened `ensure_php_track_defaults` to: + - reject unsupported tracks outside `8.3`, `8.4`, and `8.5` + - validate an existing `php.ini` is a regular file + - validate an existing `php.ini` is readable by attempting to read it +- Changed `crates/resources/src/lib.rs` to `pub mod php_defaults;` to match the brief. +- Expanded the focused integration tests to cover: + - exact required asset tail content/order + - unsupported-track rejection + - blocking `php.ini` directory rejection + +Review-fix TDD evidence + +RED command: + +```shell +cargo nextest run -p resources -E 'test(php_track_defaults_)' +``` + +RED output: + +```text +──────────── + Nextest run ID 89d9113d-b0f1-4f1c-8bab-ae38f26eeb38 with nextest profile: default + Starting 5 tests across 8 binaries (111 tests skipped) + PASS [ 0.013s] (1/5) resources::php_defaults php_track_defaults_env_helpers_point_at_track_etc + FAIL [ 0.014s] (2/5) resources::php_defaults php_track_defaults_reject_blocking_php_ini_paths +Error: expected blocking php.ini path to fail + FAIL [ 0.015s] (3/5) resources::php_defaults php_track_defaults_reject_unsupported_tracks +Error: expected unsupported PHP track to fail + FAIL [ 0.015s] (4/5) resources::php_defaults php_track_defaults_seed_stripped_sample_once +assertion failed: PHP_TRACK_DEFAULT_INI.ends_with(...) + PASS [ 0.016s] (5/5) resources::php_defaults php_track_defaults_reject_blocking_paths +──────────── + Summary [ 0.016s] 5 tests run: 2 passed, 3 failed, 111 skipped +``` + +GREEN command: + +```shell +cargo nextest run -p resources -E 'test(php_track_defaults_)' +``` + +GREEN output: + +```text +Finished `test` profile [unoptimized + debuginfo] target(s) in 0.85s +──────────── + Nextest run ID 5c7f6d68-cd55-4a9e-8578-d160b28c8bb1 with nextest profile: default + Starting 5 tests across 8 binaries (111 tests skipped) + PASS [ 0.008s] (1/5) resources::php_defaults php_track_defaults_reject_unsupported_tracks + PASS [ 0.008s] (2/5) resources::php_defaults php_track_defaults_env_helpers_point_at_track_etc + PASS [ 0.009s] (3/5) resources::php_defaults php_track_defaults_reject_blocking_paths + PASS [ 0.009s] (4/5) resources::php_defaults php_track_defaults_reject_blocking_php_ini_paths + PASS [ 0.009s] (5/5) resources::php_defaults php_track_defaults_seed_stripped_sample_once +──────────── + Summary [ 0.010s] 5 tests run: 5 passed, 111 skipped +``` + +Files changed for review fixes + +- `crates/resources/src/php-defaults.ini` +- `crates/resources/src/php_defaults.rs` +- `crates/resources/src/lib.rs` +- `crates/resources/tests/php_defaults.rs` +- `.superpowers/sdd/task-1-report.md` + +Self-review for fixes + +- The new track gate is enforced at the seeding entrypoint, which is where arbitrary-track mutation could occur. +- Existing `php.ini` validation now fails fast for non-files and unreadable files, while preserving the existing file content when valid. +- The root `/Users/clovismuneza/Apps/pv/php.ini` remained unchanged and untracked. diff --git a/crates/resources/src/lib.rs b/crates/resources/src/lib.rs index 3da24816..9ff4f740 100644 --- a/crates/resources/src/lib.rs +++ b/crates/resources/src/lib.rs @@ -9,7 +9,7 @@ pub mod http; pub mod identity; pub mod install; pub mod manifest; -mod php_defaults; +pub mod php_defaults; pub mod platform; pub mod registry; pub mod runtime; diff --git a/crates/resources/src/php-defaults.ini b/crates/resources/src/php-defaults.ini index d8d64ea4..a26fc7bb 100644 --- a/crates/resources/src/php-defaults.ini +++ b/crates/resources/src/php-defaults.ini @@ -116,3 +116,15 @@ zend.assertions = 1 [Tidy] tidy.clean_output = Off [soap] +soap.wsdl_cache_enabled=1 +soap.wsdl_cache_dir="/tmp" +soap.wsdl_cache_ttl=86400 +soap.wsdl_cache_limit = 5 +[sysvshm] +[ldap] +ldap.max_links = -1 +[dba] +[opcache] +[curl] +[openssl] +[ffi] diff --git a/crates/resources/src/php_defaults.rs b/crates/resources/src/php_defaults.rs index 3001d4ef..fc057af3 100644 --- a/crates/resources/src/php_defaults.rs +++ b/crates/resources/src/php_defaults.rs @@ -6,6 +6,7 @@ use camino::{Utf8Path, Utf8PathBuf}; use state::{PvPaths, StateError, fs}; pub const PHP_TRACK_DEFAULT_INI: &str = include_str!("php-defaults.ini"); +const SUPPORTED_PHP_TRACKS: [&str; 3] = ["8.3", "8.4", "8.5"]; #[derive(Clone, Debug, Eq, PartialEq)] pub struct PhpTrackDefaults { @@ -44,14 +45,12 @@ pub fn ensure_php_track_defaults( paths: &PvPaths, track: &str, ) -> Result { + ensure_supported_track(track)?; let defaults = php_track_defaults(paths, track); ensure_directory_path(defaults.etc_dir(), "etc")?; ensure_directory_path(defaults.conf_dir(), "conf.d")?; - - if !defaults.php_ini().exists() { - fs::write_sensitive_file(defaults.php_ini(), PHP_TRACK_DEFAULT_INI)?; - } + ensure_php_ini_path(defaults.php_ini())?; Ok(defaults) } @@ -85,3 +84,29 @@ fn ensure_directory_path(path: &Utf8Path, name: &'static str) -> Result<(), Stat fs::ensure_user_dir(path) } + +fn ensure_php_ini_path(path: &Utf8Path) -> Result<(), StateError> { + if path.exists() && !path.is_file() { + return Err(StateError::Filesystem { + path: path.to_path_buf(), + source: io::Error::other("PHP track defaults php.ini path is not a file"), + }); + } + + if path.exists() { + let _content = fs::read_to_string(path)?; + return Ok(()); + } + + fs::write_sensitive_file(path, PHP_TRACK_DEFAULT_INI) +} + +fn ensure_supported_track(track: &str) -> Result<(), StateError> { + if SUPPORTED_PHP_TRACKS.contains(&track) { + return Ok(()); + } + + Err(StateError::InvalidProjectTrack { + track: track.to_owned(), + }) +} diff --git a/crates/resources/tests/php_defaults.rs b/crates/resources/tests/php_defaults.rs index 2a542199..9fb1c73c 100644 --- a/crates/resources/tests/php_defaults.rs +++ b/crates/resources/tests/php_defaults.rs @@ -25,6 +25,21 @@ fn php_track_defaults_seed_stripped_sample_once() -> Result<()> { assert!(first_content.contains("\n[Date]\n")); assert!(first_content.contains("\nunserialize_callback_func =\n")); assert!(!first_content.contains("; About php.ini")); + assert!(PHP_TRACK_DEFAULT_INI.ends_with( + "[soap]\n\ +soap.wsdl_cache_enabled=1\n\ +soap.wsdl_cache_dir=\"/tmp\"\n\ +soap.wsdl_cache_ttl=86400\n\ +soap.wsdl_cache_limit = 5\n\ +[sysvshm]\n\ +[ldap]\n\ +ldap.max_links = -1\n\ +[dba]\n\ +[opcache]\n\ +[curl]\n\ +[openssl]\n\ +[ffi]\n" + )); fs::write_sensitive_file(defaults.php_ini(), "memory_limit = 768M\n")?; let seeded_again = ensure_php_track_defaults(&paths, "8.4")?; @@ -38,6 +53,24 @@ fn php_track_defaults_seed_stripped_sample_once() -> Result<()> { Ok(()) } +#[test] +fn php_track_defaults_reject_unsupported_tracks() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + + let error = match ensure_php_track_defaults(&paths, "8.2") { + Ok(_) => anyhow::bail!("expected unsupported PHP track to fail"), + Err(error) => error, + }; + + assert!(matches!( + error, + state::StateError::InvalidProjectTrack { track } if track == "8.2" + )); + + Ok(()) +} + #[test] fn php_track_defaults_reject_blocking_paths() -> Result<()> { let tempdir = tempdir()?; @@ -60,6 +93,28 @@ fn php_track_defaults_reject_blocking_paths() -> Result<()> { Ok(()) } +#[test] +fn php_track_defaults_reject_blocking_php_ini_paths() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let defaults = php_track_defaults(&paths, "8.5"); + fs::ensure_user_dir(defaults.etc_dir())?; + fs::ensure_user_dir(defaults.php_ini())?; + + let error = match ensure_php_track_defaults(&paths, "8.5") { + Ok(_) => anyhow::bail!("expected blocking php.ini path to fail"), + Err(error) => error, + }; + + assert!( + error + .to_string() + .contains("PHP track defaults php.ini path is not a file") + ); + + Ok(()) +} + #[test] fn php_track_defaults_env_helpers_point_at_track_etc() -> Result<()> { let tempdir = tempdir()?; From 294fa51d3fc3e12e0bdc65bfe61d1065ebf1bcd0 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sun, 21 Jun 2026 11:59:23 -0400 Subject: [PATCH 05/15] fix(resources): validate PHP track default helpers --- .superpowers/sdd/task-1-report.md | 87 ++++++++++++++++++++++++++ crates/resources/src/php_defaults.rs | 46 +++++++++----- crates/resources/tests/php_defaults.rs | 59 +++++++++++++++-- 3 files changed, 169 insertions(+), 23 deletions(-) diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md index 6000216c..0ec4c98b 100644 --- a/.superpowers/sdd/task-1-report.md +++ b/.superpowers/sdd/task-1-report.md @@ -159,3 +159,90 @@ Self-review for fixes - The new track gate is enforced at the seeding entrypoint, which is where arbitrary-track mutation could occur. - Existing `php.ini` validation now fails fast for non-files and unreadable files, while preserving the existing file content when valid. - The root `/Users/clovismuneza/Apps/pv/php.ini` remained unchanged and untracked. + +Review-fix second pass: strict public helpers and symlink rejection + +What changed + +- Changed `php_track_defaults`, `php_track_environment`, and `php_track_exec_environment` to return `Result<..., StateError>` and validate the PHP track before constructing paths or env overlays. +- Kept default path construction behind a private helper that is called only after supported-track validation. +- Changed existing `php.ini` validation to use `state::fs::path_entry_exists` and `state::fs::path_is_file`, which are based on `symlink_metadata`, so symlinked `php.ini` paths are rejected instead of followed. +- Added integration coverage for unsupported public helper APIs and symlinked `php.ini` rejection. + +TDD RED command: + +```shell +cargo nextest run -p resources -E 'test(php_track_defaults_)' +``` + +TDD RED output: + +```text +Compiling resources v0.1.3 (/Users/clovismuneza/Apps/pv/crates/resources) +error[E0277]: the `?` operator can only be applied to values that implement `Try` + --> crates/resources/tests/php_defaults.rs:79:20 + | +79 | let defaults = php_track_defaults(&paths, "8.5")?; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the `?` operator cannot be applied to type `PhpTrackDefaults` + +error[E0308]: mismatched types + --> crates/resources/tests/php_defaults.rs:148:26 + | +148 | assert_invalid_track(php_track_defaults(&paths, "8.2"), "8.2")?; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Result<_, StateError>`, found `PhpTrackDefaults` + +error[E0308]: mismatched types + --> crates/resources/tests/php_defaults.rs:149:26 + | +149 | assert_invalid_track(php_track_environment(&paths, "8.2"), "8.2")?; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Result<_, StateError>`, found `BTreeMap` + +error[E0308]: mismatched types + --> crates/resources/tests/php_defaults.rs:150:26 + | +150 | assert_invalid_track(php_track_exec_environment(&paths, "8.2"), "8.2")?; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Result<_, StateError>`, found `Vec<(OsString, OsString)>` + +error: could not compile `resources` (test "php_defaults") due to 8 previous errors +``` + +TDD GREEN command: + +```shell +cargo fmt --all && cargo nextest run -p resources -E 'test(php_track_defaults_)' +``` + +TDD GREEN output: + +```text +Finished `test` profile [unoptimized + debuginfo] target(s) in 0.75s +──────────── + Nextest run ID a7022687-cad4-4125-bd8e-48188c296f20 with nextest profile: default + Starting 7 tests across 8 binaries (111 tests skipped) + PASS [ 0.015s] (1/7) resources::php_defaults php_track_defaults_reject_unsupported_tracks + PASS [ 0.015s] (2/7) resources::php_defaults php_track_defaults_helpers_reject_unsupported_tracks + PASS [ 0.015s] (3/7) resources::php_defaults php_track_defaults_env_helpers_point_at_track_etc + PASS [ 0.017s] (4/7) resources::php_defaults php_track_defaults_reject_blocking_paths + PASS [ 0.018s] (5/7) resources::php_defaults php_track_defaults_reject_blocking_php_ini_paths + PASS [ 0.018s] (6/7) resources::php_defaults php_track_defaults_seed_stripped_sample_once + PASS [ 0.018s] (7/7) resources::php_defaults php_track_defaults_reject_symlinked_php_ini_paths +──────────── + Summary [ 0.019s] 7 tests run: 7 passed, 111 skipped +``` + +Files changed + +- `crates/resources/src/php_defaults.rs` +- `crates/resources/tests/php_defaults.rs` +- `.superpowers/sdd/task-1-report.md` + +Self-review findings + +- Unsupported PHP tracks now fail before any public helper can synthesize defaults paths or env overlays. +- `ensure_php_track_defaults` still preserves a valid existing seeded `php.ini`, but rejects directories and symlinks before checking readability. +- The implementation uses existing `state::fs` helpers for symlink-aware filesystem checks and does not add panic, assert, unwrap, unsafe code, or clippy ignores. +- The root `/Users/clovismuneza/Apps/pv/php.ini` sample remained untouched and untracked. + +Any concerns + +- No functional concerns. I did not add a chmod-based unreadable-file fixture because local macOS permission behavior can make that unreliable; readable-file validation is still exercised by the implementation through `state::fs::read_to_string`. diff --git a/crates/resources/src/php_defaults.rs b/crates/resources/src/php_defaults.rs index fc057af3..5487119d 100644 --- a/crates/resources/src/php_defaults.rs +++ b/crates/resources/src/php_defaults.rs @@ -29,7 +29,13 @@ impl PhpTrackDefaults { } } -pub fn php_track_defaults(paths: &PvPaths, track: &str) -> PhpTrackDefaults { +pub fn php_track_defaults(paths: &PvPaths, track: &str) -> Result { + ensure_supported_track(track)?; + + Ok(php_track_defaults_for_supported_track(paths, track)) +} + +fn php_track_defaults_for_supported_track(paths: &PvPaths, track: &str) -> PhpTrackDefaults { let etc_dir = paths.resources().join(format!("php/{track}/etc")); let php_ini = etc_dir.join("php.ini"); let conf_dir = etc_dir.join("conf.d"); @@ -45,8 +51,7 @@ pub fn ensure_php_track_defaults( paths: &PvPaths, track: &str, ) -> Result { - ensure_supported_track(track)?; - let defaults = php_track_defaults(paths, track); + let defaults = php_track_defaults(paths, track)?; ensure_directory_path(defaults.etc_dir(), "etc")?; ensure_directory_path(defaults.conf_dir(), "conf.d")?; @@ -55,27 +60,33 @@ pub fn ensure_php_track_defaults( Ok(defaults) } -pub fn php_track_environment(paths: &PvPaths, track: &str) -> BTreeMap { - let defaults = php_track_defaults(paths, track); +pub fn php_track_environment( + paths: &PvPaths, + track: &str, +) -> Result, StateError> { + let defaults = php_track_defaults(paths, track)?; - BTreeMap::from([ + Ok(BTreeMap::from([ ("PHPRC".to_owned(), defaults.etc_dir().to_string()), ( "PHP_INI_SCAN_DIR".to_owned(), defaults.conf_dir().to_string(), ), - ]) + ])) } -pub fn php_track_exec_environment(paths: &PvPaths, track: &str) -> Vec<(OsString, OsString)> { - php_track_environment(paths, track) +pub fn php_track_exec_environment( + paths: &PvPaths, + track: &str, +) -> Result, StateError> { + Ok(php_track_environment(paths, track)? .into_iter() .map(|(key, value)| (OsString::from(key), OsString::from(value))) - .collect() + .collect()) } fn ensure_directory_path(path: &Utf8Path, name: &'static str) -> Result<(), StateError> { - if path.exists() && !path.is_dir() { + if fs::path_entry_exists(path)? && !fs::path_is_directory(path)? { return Err(StateError::Filesystem { path: path.to_path_buf(), source: io::Error::other(format!("PHP track defaults {name} path is not a directory")), @@ -86,19 +97,20 @@ fn ensure_directory_path(path: &Utf8Path, name: &'static str) -> Result<(), Stat } fn ensure_php_ini_path(path: &Utf8Path) -> Result<(), StateError> { - if path.exists() && !path.is_file() { + if !fs::path_entry_exists(path)? { + return fs::write_sensitive_file(path, PHP_TRACK_DEFAULT_INI); + } + + if !fs::path_is_file(path)? { return Err(StateError::Filesystem { path: path.to_path_buf(), source: io::Error::other("PHP track defaults php.ini path is not a file"), }); } - if path.exists() { - let _content = fs::read_to_string(path)?; - return Ok(()); - } + let _content = fs::read_to_string(path)?; - fs::write_sensitive_file(path, PHP_TRACK_DEFAULT_INI) + Ok(()) } fn ensure_supported_track(track: &str) -> Result<(), StateError> { diff --git a/crates/resources/tests/php_defaults.rs b/crates/resources/tests/php_defaults.rs index 9fb1c73c..94b2cc23 100644 --- a/crates/resources/tests/php_defaults.rs +++ b/crates/resources/tests/php_defaults.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::result::Result as StdResult; use anyhow::Result; use camino_tempfile::tempdir; @@ -6,7 +7,7 @@ use resources::{ PHP_TRACK_DEFAULT_INI, ensure_php_track_defaults, php_track_defaults, php_track_environment, php_track_exec_environment, }; -use state::{PvPaths, fs}; +use state::{PvPaths, StateError, fs}; #[test] fn php_track_defaults_seed_stripped_sample_once() -> Result<()> { @@ -65,7 +66,7 @@ fn php_track_defaults_reject_unsupported_tracks() -> Result<()> { assert!(matches!( error, - state::StateError::InvalidProjectTrack { track } if track == "8.2" + StateError::InvalidProjectTrack { track } if track == "8.2" )); Ok(()) @@ -75,7 +76,7 @@ fn php_track_defaults_reject_unsupported_tracks() -> Result<()> { fn php_track_defaults_reject_blocking_paths() -> Result<()> { let tempdir = tempdir()?; let paths = PvPaths::for_home(tempdir.path().join("home")); - let defaults = php_track_defaults(&paths, "8.5"); + let defaults = php_track_defaults(&paths, "8.5")?; fs::ensure_user_dir(defaults.etc_dir())?; fs::write_sensitive_file(defaults.conf_dir(), "not a directory\n")?; @@ -97,7 +98,7 @@ fn php_track_defaults_reject_blocking_paths() -> Result<()> { fn php_track_defaults_reject_blocking_php_ini_paths() -> Result<()> { let tempdir = tempdir()?; let paths = PvPaths::for_home(tempdir.path().join("home")); - let defaults = php_track_defaults(&paths, "8.5"); + let defaults = php_track_defaults(&paths, "8.5")?; fs::ensure_user_dir(defaults.etc_dir())?; fs::ensure_user_dir(defaults.php_ini())?; @@ -115,13 +116,49 @@ fn php_track_defaults_reject_blocking_php_ini_paths() -> Result<()> { Ok(()) } +#[test] +fn php_track_defaults_reject_symlinked_php_ini_paths() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let defaults = php_track_defaults(&paths, "8.5")?; + let target = tempdir.path().join("linked-php.ini"); + fs::ensure_user_dir(defaults.etc_dir())?; + fs::write_sensitive_file(&target, "memory_limit = 768M\n")?; + fs::symlink_file(&target, defaults.php_ini())?; + + let error = match ensure_php_track_defaults(&paths, "8.5") { + Ok(_) => anyhow::bail!("expected symlinked php.ini path to fail"), + Err(error) => error, + }; + + assert!( + error + .to_string() + .contains("PHP track defaults php.ini path is not a file") + ); + + Ok(()) +} + +#[test] +fn php_track_defaults_helpers_reject_unsupported_tracks() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + + assert_invalid_track(php_track_defaults(&paths, "8.2"), "8.2")?; + assert_invalid_track(php_track_environment(&paths, "8.2"), "8.2")?; + assert_invalid_track(php_track_exec_environment(&paths, "8.2"), "8.2")?; + + Ok(()) +} + #[test] fn php_track_defaults_env_helpers_point_at_track_etc() -> Result<()> { let tempdir = tempdir()?; let paths = PvPaths::for_home(tempdir.path().join("home")); assert_eq!( - php_track_environment(&paths, "8.3"), + php_track_environment(&paths, "8.3")?, BTreeMap::from([ ( "PHPRC".to_owned(), @@ -134,7 +171,7 @@ fn php_track_defaults_env_helpers_point_at_track_etc() -> Result<()> { ]) ); - let exec_env = php_track_exec_environment(&paths, "8.3") + let exec_env = php_track_exec_environment(&paths, "8.3")? .into_iter() .map(|(key, value)| { ( @@ -160,3 +197,13 @@ fn php_track_defaults_env_helpers_point_at_track_etc() -> Result<()> { Ok(()) } + +fn assert_invalid_track(result: StdResult, track: &str) -> Result<()> { + match result { + Err(StateError::InvalidProjectTrack { + track: invalid_track, + }) if invalid_track == track => Ok(()), + Err(error) => anyhow::bail!("expected invalid track error, got {error}"), + Ok(_) => anyhow::bail!("expected unsupported PHP track to fail"), + } +} From 1c098eaf72659c99a6fa3d10ffc05d5b32b470ea Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sun, 21 Jun 2026 12:10:44 -0400 Subject: [PATCH 06/15] feat(resources): seed PHP track defaults on install --- .superpowers/sdd/task-2-report.md | 102 ++++++++++ crates/resources/src/command.rs | 13 ++ .../tests/managed_resource_commands.rs | 176 +++++++++++++++++- ...er_with_php_pair_seeds_track_defaults.snap | 35 ++++ ...install_php_pair_seeds_track_defaults.snap | 26 +++ ..._php_pairs_preserves_existing_php_ini.snap | 24 +++ 6 files changed, 372 insertions(+), 4 deletions(-) create mode 100644 .superpowers/sdd/task-2-report.md create mode 100644 crates/resources/tests/snapshots/managed_resource_commands__managed_resource_commands_install_composer_with_php_pair_seeds_track_defaults.snap create mode 100644 crates/resources/tests/snapshots/managed_resource_commands__managed_resource_commands_install_php_pair_seeds_track_defaults.snap create mode 100644 crates/resources/tests/snapshots/managed_resource_commands__managed_resource_commands_update_php_pairs_preserves_existing_php_ini.snap diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md new file mode 100644 index 00000000..b2137dbc --- /dev/null +++ b/.superpowers/sdd/task-2-report.md @@ -0,0 +1,102 @@ +# Task 2 Report: Seed Defaults During PHP Pair Install And Update + +## What I Implemented + +- Added `ManagedResourceCommands::ensure_php_pair_defaults`. +- Call PHP default seeding before recording PHP pair install/update state. +- Call PHP default seeding before recording Composer-with-PHP-pair state. +- Added integration coverage for: + - PHP pair install seeds `resources/php//etc/php.ini` and `conf.d`. + - Composer-with-PHP-pair install seeds the PHP track defaults. + - PHP pair update preserves an existing customized `php.ini`. + +## What I Tested And Results + +- `cargo insta test --accept --test-runner nextest -p resources -- managed_resource_commands_install_php_pair_seeds_track_defaults` + - PASS: 1 test passed; snapshot accepted. +- `cargo insta test --accept --test-runner nextest -p resources -- managed_resource_commands_install_composer_with_php_pair_seeds_track_defaults` + - PASS: 1 test passed; snapshot accepted. +- `cargo insta test --accept --test-runner nextest -p resources -- managed_resource_commands_update_php_pairs_preserves_existing_php_ini` + - PASS: 1 test passed. +- `cargo fmt --all` + - PASS: no output. +- `cargo nextest run -p resources -E 'test(managed_resource_commands_install_php_pair_seeds_track_defaults) | test(managed_resource_commands_update_php_pairs_preserves_existing_php_ini) | test(managed_resource_commands_install_composer_with_php_pair_seeds_track_defaults)'` + - PASS: 3 tests passed; 118 skipped. +- `cargo nextest run -p resources --test managed_resource_commands` + - PASS: 35 tests passed. +- `git diff --check` + - PASS: no whitespace errors. + +## TDD Evidence + +### RED + +Command: + +```shell +cargo nextest run -p resources -E 'test(managed_resource_commands_install_php_pair_seeds_track_defaults) | test(managed_resource_commands_update_php_pairs_preserves_existing_php_ini) | test(managed_resource_commands_install_composer_with_php_pair_seeds_track_defaults)' +``` + +Output summary: + +```text +Starting 3 tests across 8 binaries +FAIL resources::managed_resource_commands managed_resource_commands_install_php_pair_seeds_track_defaults +Error: filesystem error at .../home/.pv/resources/php/8.4/etc/php.ini: No such file or directory (os error 2) + +FAIL resources::managed_resource_commands managed_resource_commands_install_composer_with_php_pair_seeds_track_defaults +Error: filesystem error at .../home/.pv/resources/php/8.4/etc/php.ini: No such file or directory (os error 2) + +FAIL resources::managed_resource_commands managed_resource_commands_update_php_pairs_preserves_existing_php_ini +stored new snapshot ...managed_resource_commands_update_php_pairs_preserves_existing_php_ini.snap.new + +Summary: 3 tests run: 0 passed, 3 failed, 118 skipped +error: test run failed +``` + +### GREEN + +Command: + +```shell +cargo nextest run -p resources -E 'test(managed_resource_commands_install_php_pair_seeds_track_defaults) | test(managed_resource_commands_update_php_pairs_preserves_existing_php_ini) | test(managed_resource_commands_install_composer_with_php_pair_seeds_track_defaults)' +``` + +Output summary: + +```text +Starting 3 tests across 8 binaries +PASS resources::managed_resource_commands managed_resource_commands_install_php_pair_seeds_track_defaults +PASS resources::managed_resource_commands managed_resource_commands_install_composer_with_php_pair_seeds_track_defaults +PASS resources::managed_resource_commands managed_resource_commands_update_php_pairs_preserves_existing_php_ini +Summary: 3 tests run: 3 passed, 118 skipped +``` + +Broader focused verification: + +```text +cargo nextest run -p resources --test managed_resource_commands +Summary: 35 tests run: 35 passed, 0 skipped +``` + +## Files Changed + +- `crates/resources/src/command.rs` +- `crates/resources/tests/managed_resource_commands.rs` +- `crates/resources/tests/snapshots/managed_resource_commands__managed_resource_commands_install_php_pair_seeds_track_defaults.snap` +- `crates/resources/tests/snapshots/managed_resource_commands__managed_resource_commands_install_composer_with_php_pair_seeds_track_defaults.snap` +- `crates/resources/tests/snapshots/managed_resource_commands__managed_resource_commands_update_php_pairs_preserves_existing_php_ini.snap` +- `.superpowers/sdd/task-2-report.md` + +## Self-Review Findings + +- The default seeding happens before `Database::open` and before managed resource state is recorded. +- The same helper is used by install, update, and Composer-with-PHP-pair recording paths. +- Existing seeded `php.ini` content is preserved by the `ensure_php_track_defaults` helper. +- Tests use fallible `php_track_defaults(...)?` per the Task 1 API shape. +- No root `php.ini` sample changes were made. +- No unrelated untracked files were modified or staged. + +## Concerns + +- None. diff --git a/crates/resources/src/command.rs b/crates/resources/src/command.rs index c9f49a41..c26ea93f 100644 --- a/crates/resources/src/command.rs +++ b/crates/resources/src/command.rs @@ -428,10 +428,21 @@ impl ManagedResourceCommands { } } + fn ensure_php_pair_defaults( + &self, + install: &PhpPairInstall, + ) -> ManagedResourceCommandResult<()> { + crate::php_defaults::ensure_php_track_defaults(&self.paths, install.php.track.as_str())?; + + Ok(()) + } + fn record_php_pair_install( &self, install: &PhpPairInstall, ) -> ManagedResourceCommandResult<()> { + self.ensure_php_pair_defaults(install)?; + let mut database = Database::open(&self.paths)?; database.record_managed_resource_tracks_desired_and_installed(&[ ManagedResourceTrackInstallInput { @@ -456,6 +467,8 @@ impl ManagedResourceCommands { php_pair: &PhpPairInstall, composer: &ManagedResourceInstall, ) -> ManagedResourceCommandResult<()> { + self.ensure_php_pair_defaults(php_pair)?; + let mut database = Database::open(&self.paths)?; database.record_managed_resource_tracks_desired_and_installed(&[ ManagedResourceTrackInstallInput { diff --git a/crates/resources/tests/managed_resource_commands.rs b/crates/resources/tests/managed_resource_commands.rs index 5564fc30..07fa1140 100644 --- a/crates/resources/tests/managed_resource_commands.rs +++ b/crates/resources/tests/managed_resource_commands.rs @@ -12,12 +12,12 @@ use resources::{ ArtifactManifestSource, ManagedResourceCommandError, ManagedResourceCommands, ManagedResourceInstall, ManagedResourceRemovalIntent, ManagedResourceTrack, ManagedResourceUninstallOptions, ManagedResourceUpdate, ManagedResourceUpdateCheck, - ManagedResourceUpdateCheckTrack, ResourceAdapter, ResourceHttpClient, ResourceName, - ResourcesError, TargetPlatform, TrackName, TrackSelector, composer_adapter, frankenphp_adapter, - mailpit_adapter, php_adapter, redis_adapter, + ManagedResourceUpdateCheckTrack, PHP_TRACK_DEFAULT_INI, ResourceAdapter, ResourceHttpClient, + ResourceName, ResourcesError, TargetPlatform, TrackName, TrackSelector, composer_adapter, + frankenphp_adapter, mailpit_adapter, php_adapter, php_track_defaults, redis_adapter, }; use sha2::{Digest, Sha256}; -use state::{Database, ManagedResourceTrackRecord, PvPaths}; +use state::{Database, ManagedResourceTrackRecord, PvPaths, fs}; use tar::{Builder, Header}; #[test] @@ -295,6 +295,57 @@ fn managed_resource_commands_install_php_pair_resolves_latest_once_for_both_reso Ok(()) } +#[test] +fn managed_resource_commands_install_php_pair_seeds_track_defaults() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let commands = + ManagedResourceCommands::new(paths.clone(), MANIFEST_URL, TargetPlatform::DarwinArm64); + let php_artifact = runtime_fixture_artifact("php", "8.4.8-pv1", "bin/php", "php 8.4")?; + let frankenphp_artifact = runtime_fixture_artifact( + "frankenphp", + "8.4.8-pv1", + "bin/frankenphp", + "frankenphp 8.4", + )?; + let manifest = manifest_with_resources(&[ + manifest_resource( + "php", + "8.4", + vec![manifest_track("8.4", vec![&php_artifact])], + ), + manifest_resource( + "frankenphp", + "8.4", + vec![manifest_track("8.4", vec![&frankenphp_artifact])], + ), + ]); + let client = ScriptedClient::new() + .with_text(&manifest) + .with_bytes(php_artifact.bytes()) + .with_bytes(frankenphp_artifact.bytes()); + + let installed = commands.install_php_pair(TrackSelector::Latest, &client)?; + let defaults = php_track_defaults(&paths, installed.php().track().as_str())?; + + assert_eq!( + fs::read_to_string(defaults.php_ini())?, + PHP_TRACK_DEFAULT_INI + ); + assert!(fs::path_is_directory(defaults.conf_dir())?); + assert_debug_snapshot!(( + install_summary(installed.php(), tempdir.path())?, + install_summary(installed.frankenphp(), tempdir.path())?, + defaults.php_ini().strip_prefix(tempdir.path())?.to_string(), + defaults + .conf_dir() + .strip_prefix(tempdir.path())? + .to_string(), + )); + + Ok(()) +} + #[test] fn managed_resource_commands_install_php_pair_preflights_frankenphp_track_before_mutation() -> Result<()> { @@ -517,6 +568,65 @@ fn managed_resource_commands_install_composer_failure_removes_prepared_php_pair( Ok(()) } +#[test] +fn managed_resource_commands_install_composer_with_php_pair_seeds_track_defaults() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let commands = + ManagedResourceCommands::new(paths.clone(), MANIFEST_URL, TargetPlatform::DarwinArm64); + let php_artifact = runtime_fixture_artifact("php", "8.4.8-pv1", "bin/php", "php 8.4")?; + let frankenphp_artifact = runtime_fixture_artifact( + "frankenphp", + "8.4.8-pv1", + "bin/frankenphp", + "frankenphp 8.4", + )?; + let composer_artifact = composer_fixture_artifact("2.8.1-pv1", "v2")?; + let manifest = manifest_with_resources(&[ + manifest_resource( + "php", + "8.4", + vec![manifest_track("8.4", vec![&php_artifact])], + ), + manifest_resource( + "frankenphp", + "8.4", + vec![manifest_track("8.4", vec![&frankenphp_artifact])], + ), + manifest_resource( + "composer", + "2", + vec![manifest_track("2", vec![&composer_artifact])], + ), + ]); + let client = ScriptedClient::new() + .with_text(&manifest) + .with_bytes(php_artifact.bytes()) + .with_bytes(frankenphp_artifact.bytes()) + .with_bytes(composer_artifact.bytes()); + + let installed = commands.install_composer_with_php_pair(TrackSelector::Latest, &client)?; + let defaults = php_track_defaults(&paths, installed.php_pair().php().track().as_str())?; + + assert_eq!( + fs::read_to_string(defaults.php_ini())?, + PHP_TRACK_DEFAULT_INI + ); + assert!(fs::path_is_directory(defaults.conf_dir())?); + assert_debug_snapshot!(( + install_summary(installed.php_pair().php(), tempdir.path())?, + install_summary(installed.php_pair().frankenphp(), tempdir.path())?, + install_summary(installed.composer(), tempdir.path())?, + defaults.php_ini().strip_prefix(tempdir.path())?.to_string(), + defaults + .conf_dir() + .strip_prefix(tempdir.path())? + .to_string(), + )); + + Ok(()) +} + #[test] fn managed_resource_commands_update_php_pairs_uses_installed_track_union_and_one_manifest_refresh() -> Result<()> { @@ -630,6 +740,64 @@ fn managed_resource_commands_update_php_pairs_uses_installed_track_union_and_one Ok(()) } +#[test] +fn managed_resource_commands_update_php_pairs_preserves_existing_php_ini() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let commands = + ManagedResourceCommands::new(paths.clone(), MANIFEST_URL, TargetPlatform::DarwinArm64); + let old_php = runtime_fixture_artifact("php", "8.4.8-pv1", "bin/php", "old php")?; + let old_frankenphp = + runtime_fixture_artifact("frankenphp", "8.4.8-pv1", "bin/frankenphp", "old fpm")?; + let new_php = runtime_fixture_artifact("php", "8.4.9-pv1", "bin/php", "new php")?; + let new_frankenphp = + runtime_fixture_artifact("frankenphp", "8.4.9-pv1", "bin/frankenphp", "new fpm")?; + let initial_manifest = manifest_with_resources(&[ + manifest_resource("php", "8.4", vec![manifest_track("8.4", vec![&old_php])]), + manifest_resource( + "frankenphp", + "8.4", + vec![manifest_track("8.4", vec![&old_frankenphp])], + ), + ]); + let updated_manifest = manifest_with_resources(&[ + manifest_resource( + "php", + "8.4", + vec![manifest_track("8.4", vec![&old_php, &new_php])], + ), + manifest_resource( + "frankenphp", + "8.4", + vec![manifest_track( + "8.4", + vec![&old_frankenphp, &new_frankenphp], + )], + ), + ]); + let client = ScriptedClient::new() + .with_text(&initial_manifest) + .with_bytes(old_php.bytes()) + .with_bytes(old_frankenphp.bytes()) + .with_text(&updated_manifest) + .with_bytes(new_php.bytes()) + .with_bytes(new_frankenphp.bytes()); + + commands.install_php_pair(TrackSelector::Latest, &client)?; + let defaults = php_track_defaults(&paths, "8.4")?; + fs::write_sensitive_file(defaults.php_ini(), "memory_limit = 768M\n")?; + + let updated = commands.update_php_pairs(&client)?; + + assert_eq!( + fs::read_to_string(defaults.php_ini())?, + "memory_limit = 768M\n" + ); + assert_debug_snapshot!(install_summaries(updated.installs(), tempdir.path())?); + + Ok(()) +} + #[test] fn managed_resource_commands_update_php_pairs_preflights_all_pairs_before_mutation() -> Result<()> { let tempdir = tempdir()?; diff --git a/crates/resources/tests/snapshots/managed_resource_commands__managed_resource_commands_install_composer_with_php_pair_seeds_track_defaults.snap b/crates/resources/tests/snapshots/managed_resource_commands__managed_resource_commands_install_composer_with_php_pair_seeds_track_defaults.snap new file mode 100644 index 00000000..a0ba4710 --- /dev/null +++ b/crates/resources/tests/snapshots/managed_resource_commands__managed_resource_commands_install_composer_with_php_pair_seeds_track_defaults.snap @@ -0,0 +1,35 @@ +--- +source: crates/resources/tests/managed_resource_commands.rs +expression: "(install_summary(installed.php_pair().php(), tempdir.path())?,\ninstall_summary(installed.php_pair().frankenphp(), tempdir.path())?,\ninstall_summary(installed.composer(), tempdir.path())?,\ndefaults.php_ini().strip_prefix(tempdir.path())?.to_string(),\ndefaults.conf_dir().strip_prefix(tempdir.path())?.to_string(),)" +--- +( + InstallSnapshot { + resource_name: "php", + track: "8.4", + artifact_version: "8.4.8-pv1", + current_artifact_path: "home/.pv/resources/php/8.4/releases/8.4.8-pv1", + manifest_source: "Latest", + revoked_latest: None, + downloaded_from_cache: false, + }, + InstallSnapshot { + resource_name: "frankenphp", + track: "8.4", + artifact_version: "8.4.8-pv1", + current_artifact_path: "home/.pv/resources/frankenphp/8.4/releases/8.4.8-pv1", + manifest_source: "Latest", + revoked_latest: None, + downloaded_from_cache: false, + }, + InstallSnapshot { + resource_name: "composer", + track: "2", + artifact_version: "2.8.1-pv1", + current_artifact_path: "home/.pv/resources/composer/2/releases/2.8.1-pv1", + manifest_source: "Latest", + revoked_latest: None, + downloaded_from_cache: false, + }, + "home/.pv/resources/php/8.4/etc/php.ini", + "home/.pv/resources/php/8.4/etc/conf.d", +) diff --git a/crates/resources/tests/snapshots/managed_resource_commands__managed_resource_commands_install_php_pair_seeds_track_defaults.snap b/crates/resources/tests/snapshots/managed_resource_commands__managed_resource_commands_install_php_pair_seeds_track_defaults.snap new file mode 100644 index 00000000..3206fcf9 --- /dev/null +++ b/crates/resources/tests/snapshots/managed_resource_commands__managed_resource_commands_install_php_pair_seeds_track_defaults.snap @@ -0,0 +1,26 @@ +--- +source: crates/resources/tests/managed_resource_commands.rs +expression: "(install_summary(installed.php(), tempdir.path())?,\ninstall_summary(installed.frankenphp(), tempdir.path())?,\ndefaults.php_ini().strip_prefix(tempdir.path())?.to_string(),\ndefaults.conf_dir().strip_prefix(tempdir.path())?.to_string(),)" +--- +( + InstallSnapshot { + resource_name: "php", + track: "8.4", + artifact_version: "8.4.8-pv1", + current_artifact_path: "home/.pv/resources/php/8.4/releases/8.4.8-pv1", + manifest_source: "Latest", + revoked_latest: None, + downloaded_from_cache: false, + }, + InstallSnapshot { + resource_name: "frankenphp", + track: "8.4", + artifact_version: "8.4.8-pv1", + current_artifact_path: "home/.pv/resources/frankenphp/8.4/releases/8.4.8-pv1", + manifest_source: "Latest", + revoked_latest: None, + downloaded_from_cache: false, + }, + "home/.pv/resources/php/8.4/etc/php.ini", + "home/.pv/resources/php/8.4/etc/conf.d", +) diff --git a/crates/resources/tests/snapshots/managed_resource_commands__managed_resource_commands_update_php_pairs_preserves_existing_php_ini.snap b/crates/resources/tests/snapshots/managed_resource_commands__managed_resource_commands_update_php_pairs_preserves_existing_php_ini.snap new file mode 100644 index 00000000..e2b0b65a --- /dev/null +++ b/crates/resources/tests/snapshots/managed_resource_commands__managed_resource_commands_update_php_pairs_preserves_existing_php_ini.snap @@ -0,0 +1,24 @@ +--- +source: crates/resources/tests/managed_resource_commands.rs +expression: "install_summaries(updated.installs(), tempdir.path())?" +--- +[ + InstallSnapshot { + resource_name: "php", + track: "8.4", + artifact_version: "8.4.9-pv1", + current_artifact_path: "home/.pv/resources/php/8.4/releases/8.4.9-pv1", + manifest_source: "Latest", + revoked_latest: None, + downloaded_from_cache: false, + }, + InstallSnapshot { + resource_name: "frankenphp", + track: "8.4", + artifact_version: "8.4.9-pv1", + current_artifact_path: "home/.pv/resources/frankenphp/8.4/releases/8.4.9-pv1", + manifest_source: "Latest", + revoked_latest: None, + downloaded_from_cache: false, + }, +] From 6d3b2e5e449930af833522be64508657308f0827 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sun, 21 Jun 2026 12:22:08 -0400 Subject: [PATCH 07/15] fix(cli): use PHP track defaults in shims --- .superpowers/sdd/task-3-report.md | 110 ++++++++++++++++++ crates/cli/src/commands/php.rs | 40 +++---- crates/cli/tests/composer.rs | 23 ++-- crates/cli/tests/php.rs | 30 +++-- ...execs_installed_phar_through_php_shim.snap | 4 +- ..._shim_forwards_help_and_version_flags.snap | 16 +-- ...ched_manifest_default_without_network.snap | 4 +- ..._global_default_track_outside_project.snap | 4 +- ...php_shim_execs_resolved_project_track.snap | 4 +- ..._shim_forwards_help_and_version_flags.snap | 16 +-- ...ched_manifest_default_without_network.snap | 4 +- 11 files changed, 182 insertions(+), 73 deletions(-) create mode 100644 .superpowers/sdd/task-3-report.md diff --git a/.superpowers/sdd/task-3-report.md b/.superpowers/sdd/task-3-report.md new file mode 100644 index 00000000..20f1c214 --- /dev/null +++ b/.superpowers/sdd/task-3-report.md @@ -0,0 +1,110 @@ +# Task 3 Report: Point CLI PHP And Composer At Track Defaults + +## What I implemented + +- Updated the PHP shim so it ensures PHP track defaults before exec and appends `PHPRC` / `PHP_INI_SCAN_DIR` from `resources::php_track_exec_environment(&paths, &track)?`. +- Removed the old PHP shim release-path env overlay that pointed ini discovery at `~/.pv/resources/php//releases//etc`. +- Kept `InstalledPhp.release` and derive the executable path from the installed artifact release path. +- Updated PHP shim test helpers and assertions to expect track defaults under `~/.pv/resources/php//etc`. +- Updated Composer shim test helpers and all affected expected env calls to expect track defaults. Composer still execs through the PHP artifact binary via the PHP shim path. +- Added a PHP shim assertion that running the shim seeds the track default `php.ini` with `resources::PHP_TRACK_DEFAULT_INI`. +- Accepted affected insta snapshots for PHP and Composer shim env output. + +## What I tested and results + +- `cargo nextest run -p cli -E 'test(php_shim_sets_only_php_ini_env_overlay) | test(composer_shim_execs_installed_phar_through_php_shim) | test(composer_shim_sets_pv_owned_env_overlay)'` + - Result: PASS, 3 passed. +- `cargo nextest run -p cli -E 'test(php_shim) | test(composer_shim)'` + - Result: PASS, 12 passed. +- `cargo insta test --accept --test-runner nextest -p cli -- php_shim` + - Result: PASS, 7 passed; accepted affected PHP shim snapshots and `composer_shim_execs_installed_phar_through_php_shim`. +- `cargo insta test --accept --test-runner nextest -p cli -- composer_shim` + - Result: PASS, 6 passed; accepted remaining Composer shim snapshots. + +## TDD Evidence + +### RED + +Command: + +```shell +cargo nextest run -p cli -E 'test(php_shim_sets_only_php_ini_env_overlay) | test(composer_shim_execs_installed_phar_through_php_shim) | test(composer_shim_sets_pv_owned_env_overlay)' +``` + +Key output: + +```text +Summary [0.038s] 3 tests run: 0 passed, 3 failed, 194 skipped +FAIL cli::php php_shim_sets_only_php_ini_env_overlay +FAIL cli::composer composer_shim_sets_pv_owned_env_overlay +FAIL cli::composer composer_shim_execs_installed_phar_through_php_shim +``` + +The expected failure showed `PHPRC` and `PHP_INI_SCAN_DIR` still coming from: + +```text +~/.pv/resources/php/8.4/releases/8.4.8-pv1/etc +~/.pv/resources/php/8.4/releases/8.4.8-pv1/etc/conf.d +``` + +instead of: + +```text +~/.pv/resources/php/8.4/etc +~/.pv/resources/php/8.4/etc/conf.d +``` + +### GREEN + +Command: + +```shell +cargo nextest run -p cli -E 'test(php_shim_sets_only_php_ini_env_overlay) | test(composer_shim_execs_installed_phar_through_php_shim) | test(composer_shim_sets_pv_owned_env_overlay)' +``` + +Output: + +```text +Summary [0.039s] 3 tests run: 3 passed, 194 skipped +PASS cli::php php_shim_sets_only_php_ini_env_overlay +PASS cli::composer composer_shim_sets_pv_owned_env_overlay +PASS cli::composer composer_shim_execs_installed_phar_through_php_shim +``` + +Broader affected shim verification: + +```shell +cargo nextest run -p cli -E 'test(php_shim) | test(composer_shim)' +``` + +Output: + +```text +Summary [0.110s] 12 tests run: 12 passed, 185 skipped +``` + +## Files changed + +- `crates/cli/src/commands/php.rs` +- `crates/cli/tests/php.rs` +- `crates/cli/tests/composer.rs` +- `crates/cli/tests/snapshots/composer__composer_shim_execs_installed_phar_through_php_shim.snap` +- `crates/cli/tests/snapshots/composer__composer_shim_forwards_help_and_version_flags.snap` +- `crates/cli/tests/snapshots/composer__composer_shim_uses_cached_manifest_default_without_network.snap` +- `crates/cli/tests/snapshots/php__php_shim_execs_global_default_track_outside_project.snap` +- `crates/cli/tests/snapshots/php__php_shim_execs_resolved_project_track.snap` +- `crates/cli/tests/snapshots/php__php_shim_forwards_help_and_version_flags.snap` +- `crates/cli/tests/snapshots/php__php_shim_uses_cached_manifest_default_without_network.snap` +- `.superpowers/sdd/task-3-report.md` + +## Self-review findings + +- Shim ini discovery now uses process-level `PHPRC` and `PHP_INI_SCAN_DIR`; no Caddyfile env directives were introduced. +- Composer expected env calls using the shared helper were updated, including help/version and cached-manifest cases beyond the three tests named in the brief. +- The PHP executable still comes from the installed artifact release path. +- Existing seeded `php.ini` preservation remains owned by `resources::ensure_php_track_defaults`; this task calls that API rather than reimplementing seeding. +- No unrelated files were reverted or staged. + +## Concerns + +None. diff --git a/crates/cli/src/commands/php.rs b/crates/cli/src/commands/php.rs index 90217459..7c05aff1 100644 --- a/crates/cli/src/commands/php.rs +++ b/crates/cli/src/commands/php.rs @@ -4,7 +4,7 @@ use std::io; use std::io::Write; use std::process::ExitCode; -use camino::{Utf8Path, Utf8PathBuf}; +use camino::Utf8PathBuf; use resources::{ ArtifactManifestCache, ManagedResourceCommands, ManagedResourceUninstallOptions, ResourceAdapter, ResourceHttpClient, ResourceName, TargetPlatform, TrackName, TrackSelector, @@ -261,10 +261,12 @@ pub(crate) fn shim_with_args_and_env( let database = Database::open(&paths)?; let track = resolve_php_track_for_shim(&paths, &database, environment)?; let installed = installed_php(&database, &track)?; - env.extend(php_env_overlay(&installed.release)); + resources::ensure_php_track_defaults(&paths, &track)?; + env.extend(resources::php_track_exec_environment(&paths, &track)?); + let executable = installed.executable()?; environment - .exec_with_env(installed.executable.as_std_path(), &args, &env) + .exec_with_env(executable.as_std_path(), &args, &env) .map_err(ExecuteError::from) } @@ -313,7 +315,14 @@ fn effective_global_php_default_track( struct InstalledPhp { release: Utf8PathBuf, - executable: Utf8PathBuf, +} + +impl InstalledPhp { + fn executable(&self) -> Result { + let adapter = resources::php_adapter()?; + + Ok(adapter.executable_path(&self.release)) + } } fn installed_php(database: &Database, track: &str) -> Result { @@ -340,29 +349,8 @@ fn installed_php(database: &Database, track: &str) -> Result Vec<(OsString, OsString)> { - vec![ - ( - OsString::from("PHPRC"), - release.join("etc").as_std_path().as_os_str().to_os_string(), - ), - ( - OsString::from("PHP_INI_SCAN_DIR"), - release - .join("etc/conf.d") - .as_std_path() - .as_os_str() - .to_os_string(), - ), - ] + Ok(InstalledPhp { release }) } fn write_install_lines( diff --git a/crates/cli/tests/composer.rs b/crates/cli/tests/composer.rs index 35a63f51..082045d6 100644 --- a/crates/cli/tests/composer.rs +++ b/crates/cli/tests/composer.rs @@ -72,10 +72,11 @@ fn exec_env_snapshot(env: &[(OsString, OsString)]) -> Vec<(String, String)> { .collect() } -fn composer_exec_env(home: &Utf8Path, php_release: &Utf8Path) -> Vec<(String, String)> { +fn composer_exec_env(home: &Utf8Path, php_track: &str) -> anyhow::Result> { let paths = pv_paths(home); + let defaults = resources::php_track_defaults(&paths, php_track)?; - vec![ + Ok(vec![ ("COMPOSER_HOME".to_string(), paths.composer().to_string()), ( "COMPOSER_CACHE_DIR".to_string(), @@ -85,12 +86,12 @@ fn composer_exec_env(home: &Utf8Path, php_release: &Utf8Path) -> Vec<(String, St "PATH".to_string(), format!("{}:{}", paths.bin(), paths.composer().join("vendor/bin")), ), - ("PHPRC".to_string(), php_release.join("etc").to_string()), + ("PHPRC".to_string(), defaults.etc_dir().to_string()), ( "PHP_INI_SCAN_DIR".to_string(), - php_release.join("etc/conf.d").to_string(), + defaults.conf_dir().to_string(), ), - ] + ]) } impl Environment for TestEnvironment { @@ -468,7 +469,7 @@ fn composer_shim_execs_installed_phar_through_php_shim() -> anyhow::Result<()> { "install".to_string(), "--dry-run".to_string(), ], - env: composer_exec_env(&home, &php_release), + env: composer_exec_env(&home, "8.4")?, }] ); assert_eq!(environment.text_request_count(), 0); @@ -496,6 +497,7 @@ fn composer_shim_sets_pv_owned_env_overlay() -> anyhow::Result<()> { } let pv_bin = pv_paths(&home).bin().to_string(); let composer_bin = pv_paths(&home).composer().join("vendor/bin").to_string(); + let defaults = resources::php_track_defaults(&pv_paths(&home), "8.4")?; let existing_path = format!("/usr/bin:{pv_bin}:/bin:{composer_bin}"); let environment = TestEnvironment::new(&home, ¤t_dir, ScriptedClient::new()) .with_var("PATH", existing_path); @@ -525,10 +527,10 @@ fn composer_shim_sets_pv_owned_env_overlay() -> anyhow::Result<()> { "PATH".to_string(), format!("{pv_bin}:{composer_bin}:/usr/bin:/bin"), ), - ("PHPRC".to_string(), php_release.join("etc").to_string()), + ("PHPRC".to_string(), defaults.etc_dir().to_string()), ( "PHP_INI_SCAN_DIR".to_string(), - php_release.join("etc/conf.d").to_string(), + defaults.conf_dir().to_string(), ), ], }] @@ -559,6 +561,7 @@ fn composer_shim_forwards_help_and_version_flags() -> anyhow::Result<()> { run_pv(&["shim:composer", "-V"], &environment)?, ]; let exec_calls = environment.exec_calls(); + let env = composer_exec_env(&home, "8.4")?; assert!( outputs @@ -577,7 +580,7 @@ fn composer_shim_forwards_help_and_version_flags() -> anyhow::Result<()> { composer_release.join("composer.phar").to_string(), arg.to_string(), ], - env: composer_exec_env(&home, &php_release), + env: env.clone(), }) .collect::>() ); @@ -618,7 +621,7 @@ fn composer_shim_uses_cached_manifest_default_without_network() -> anyhow::Resul composer_release.join("composer.phar").to_string(), "about".to_string(), ], - env: composer_exec_env(&home, &php_release), + env: composer_exec_env(&home, "8.4")?, }] ); assert_eq!(environment.text_request_count(), 0); diff --git a/crates/cli/tests/php.rs b/crates/cli/tests/php.rs index b9f4f09a..548bdf65 100644 --- a/crates/cli/tests/php.rs +++ b/crates/cli/tests/php.rs @@ -14,7 +14,7 @@ use insta::assert_debug_snapshot; use resources::{ResourceHttpClient, ResourcesError, TargetPlatform}; use state::{ Database, LinkProjectInput, ManagedResourceDesiredState, ManagedResourceTrackRecord, - ProjectRecord, PvPaths, + ProjectRecord, PvPaths, fs, }; const MANIFEST_URL: &str = "https://artifacts.example.test/manifest.json"; @@ -75,14 +75,16 @@ fn exec_env_snapshot(env: &[(OsString, OsString)]) -> Vec<(String, String)> { .collect() } -fn php_exec_env(release: &Utf8Path) -> Vec<(String, String)> { - vec![ - ("PHPRC".to_string(), release.join("etc").to_string()), +fn php_exec_env(home: &Utf8Path, track: &str) -> anyhow::Result> { + let defaults = resources::php_track_defaults(&pv_paths(home), track)?; + + Ok(vec![ + ("PHPRC".to_string(), defaults.etc_dir().to_string()), ( "PHP_INI_SCAN_DIR".to_string(), - release.join("etc/conf.d").to_string(), + defaults.conf_dir().to_string(), ), - ] + ]) } impl Environment for TestEnvironment { @@ -192,7 +194,7 @@ fn php_shim_execs_resolved_project_track() -> anyhow::Result<()> { vec![ExecCall { program: release.join("bin/php").as_std_path().to_path_buf(), args: vec!["-v".to_string()], - env: php_exec_env(&release), + env: php_exec_env(&home, "8.4")?, }] ); assert_eq!(environment.text_request_count(), 0); @@ -227,9 +229,14 @@ fn php_shim_sets_only_php_ini_env_overlay() -> anyhow::Result<()> { vec![ExecCall { program: release.join("bin/php").as_std_path().to_path_buf(), args: vec!["--ini".to_string()], - env: php_exec_env(&release), + env: php_exec_env(&home, "8.4")?, }] ); + let defaults = resources::php_track_defaults(&pv_paths(&home), "8.4")?; + assert_eq!( + fs::read_to_string(defaults.php_ini())?, + resources::PHP_TRACK_DEFAULT_INI + ); Ok(()) } @@ -258,7 +265,7 @@ fn php_shim_execs_global_default_track_outside_project() -> anyhow::Result<()> { vec![ExecCall { program: release.join("bin/php").as_std_path().to_path_buf(), args: vec!["-r".to_string(), "echo 1;".to_string()], - env: php_exec_env(&release), + env: php_exec_env(&home, "8.3")?, }] ); assert_eq!(environment.text_request_count(), 0); @@ -291,6 +298,7 @@ fn php_shim_forwards_help_and_version_flags() -> anyhow::Result<()> { run_pv(&["shim:php", "-V"], &environment)?, ]; let exec_calls = environment.exec_calls(); + let env = php_exec_env(&home, "8.4")?; assert!( outputs @@ -306,7 +314,7 @@ fn php_shim_forwards_help_and_version_flags() -> anyhow::Result<()> { .map(|arg| ExecCall { program: release.join("bin/php").as_std_path().to_path_buf(), args: vec![arg.to_string()], - env: php_exec_env(&release), + env: env.clone(), }) .collect::>() ); @@ -342,7 +350,7 @@ fn php_shim_uses_cached_manifest_default_without_network() -> anyhow::Result<()> vec![ExecCall { program: release.join("bin/php").as_std_path().to_path_buf(), args: vec!["--ini".to_string()], - env: php_exec_env(&release), + env: php_exec_env(&home, "8.4")?, }] ); assert_eq!(environment.text_request_count(), 0); diff --git a/crates/cli/tests/snapshots/composer__composer_shim_execs_installed_phar_through_php_shim.snap b/crates/cli/tests/snapshots/composer__composer_shim_execs_installed_phar_through_php_shim.snap index 24a90d5d..445fc03a 100644 --- a/crates/cli/tests/snapshots/composer__composer_shim_execs_installed_phar_through_php_shim.snap +++ b/crates/cli/tests/snapshots/composer__composer_shim_execs_installed_phar_through_php_shim.snap @@ -35,11 +35,11 @@ expression: "(output, exec_calls)" ), ( "PHPRC", - "/home/.pv/resources/php/8.4/releases/8.4.8-pv1/etc", + "/home/.pv/resources/php/8.4/etc", ), ( "PHP_INI_SCAN_DIR", - "/home/.pv/resources/php/8.4/releases/8.4.8-pv1/etc/conf.d", + "/home/.pv/resources/php/8.4/etc/conf.d", ), ], }, diff --git a/crates/cli/tests/snapshots/composer__composer_shim_forwards_help_and_version_flags.snap b/crates/cli/tests/snapshots/composer__composer_shim_forwards_help_and_version_flags.snap index e5676ff5..2f7491d6 100644 --- a/crates/cli/tests/snapshots/composer__composer_shim_forwards_help_and_version_flags.snap +++ b/crates/cli/tests/snapshots/composer__composer_shim_forwards_help_and_version_flags.snap @@ -63,11 +63,11 @@ expression: "(outputs, exec_calls)" ), ( "PHPRC", - "/home/.pv/resources/php/8.4/releases/8.4.8-pv1/etc", + "/home/.pv/resources/php/8.4/etc", ), ( "PHP_INI_SCAN_DIR", - "/home/.pv/resources/php/8.4/releases/8.4.8-pv1/etc/conf.d", + "/home/.pv/resources/php/8.4/etc/conf.d", ), ], }, @@ -92,11 +92,11 @@ expression: "(outputs, exec_calls)" ), ( "PHPRC", - "/home/.pv/resources/php/8.4/releases/8.4.8-pv1/etc", + "/home/.pv/resources/php/8.4/etc", ), ( "PHP_INI_SCAN_DIR", - "/home/.pv/resources/php/8.4/releases/8.4.8-pv1/etc/conf.d", + "/home/.pv/resources/php/8.4/etc/conf.d", ), ], }, @@ -121,11 +121,11 @@ expression: "(outputs, exec_calls)" ), ( "PHPRC", - "/home/.pv/resources/php/8.4/releases/8.4.8-pv1/etc", + "/home/.pv/resources/php/8.4/etc", ), ( "PHP_INI_SCAN_DIR", - "/home/.pv/resources/php/8.4/releases/8.4.8-pv1/etc/conf.d", + "/home/.pv/resources/php/8.4/etc/conf.d", ), ], }, @@ -150,11 +150,11 @@ expression: "(outputs, exec_calls)" ), ( "PHPRC", - "/home/.pv/resources/php/8.4/releases/8.4.8-pv1/etc", + "/home/.pv/resources/php/8.4/etc", ), ( "PHP_INI_SCAN_DIR", - "/home/.pv/resources/php/8.4/releases/8.4.8-pv1/etc/conf.d", + "/home/.pv/resources/php/8.4/etc/conf.d", ), ], }, diff --git a/crates/cli/tests/snapshots/composer__composer_shim_uses_cached_manifest_default_without_network.snap b/crates/cli/tests/snapshots/composer__composer_shim_uses_cached_manifest_default_without_network.snap index b7552610..f285ca62 100644 --- a/crates/cli/tests/snapshots/composer__composer_shim_uses_cached_manifest_default_without_network.snap +++ b/crates/cli/tests/snapshots/composer__composer_shim_uses_cached_manifest_default_without_network.snap @@ -34,11 +34,11 @@ expression: "(output, exec_calls)" ), ( "PHPRC", - "/home/.pv/resources/php/8.4/releases/8.4.8-pv1/etc", + "/home/.pv/resources/php/8.4/etc", ), ( "PHP_INI_SCAN_DIR", - "/home/.pv/resources/php/8.4/releases/8.4.8-pv1/etc/conf.d", + "/home/.pv/resources/php/8.4/etc/conf.d", ), ], }, diff --git a/crates/cli/tests/snapshots/php__php_shim_execs_global_default_track_outside_project.snap b/crates/cli/tests/snapshots/php__php_shim_execs_global_default_track_outside_project.snap index 88431ed4..4246df9b 100644 --- a/crates/cli/tests/snapshots/php__php_shim_execs_global_default_track_outside_project.snap +++ b/crates/cli/tests/snapshots/php__php_shim_execs_global_default_track_outside_project.snap @@ -22,11 +22,11 @@ expression: "(output, exec_calls)" env: [ ( "PHPRC", - "/home/.pv/resources/php/8.3/releases/8.3.12-pv1/etc", + "/home/.pv/resources/php/8.3/etc", ), ( "PHP_INI_SCAN_DIR", - "/home/.pv/resources/php/8.3/releases/8.3.12-pv1/etc/conf.d", + "/home/.pv/resources/php/8.3/etc/conf.d", ), ], }, diff --git a/crates/cli/tests/snapshots/php__php_shim_execs_resolved_project_track.snap b/crates/cli/tests/snapshots/php__php_shim_execs_resolved_project_track.snap index 2113257a..57938e66 100644 --- a/crates/cli/tests/snapshots/php__php_shim_execs_resolved_project_track.snap +++ b/crates/cli/tests/snapshots/php__php_shim_execs_resolved_project_track.snap @@ -21,11 +21,11 @@ expression: "(output, exec_calls)" env: [ ( "PHPRC", - "/home/.pv/resources/php/8.4/releases/8.4.8-pv1/etc", + "/home/.pv/resources/php/8.4/etc", ), ( "PHP_INI_SCAN_DIR", - "/home/.pv/resources/php/8.4/releases/8.4.8-pv1/etc/conf.d", + "/home/.pv/resources/php/8.4/etc/conf.d", ), ], }, diff --git a/crates/cli/tests/snapshots/php__php_shim_forwards_help_and_version_flags.snap b/crates/cli/tests/snapshots/php__php_shim_forwards_help_and_version_flags.snap index 3bb30b0d..371e3120 100644 --- a/crates/cli/tests/snapshots/php__php_shim_forwards_help_and_version_flags.snap +++ b/crates/cli/tests/snapshots/php__php_shim_forwards_help_and_version_flags.snap @@ -50,11 +50,11 @@ expression: "(outputs, exec_calls)" env: [ ( "PHPRC", - "/home/.pv/resources/php/8.4/releases/8.4.8-pv1/etc", + "/home/.pv/resources/php/8.4/etc", ), ( "PHP_INI_SCAN_DIR", - "/home/.pv/resources/php/8.4/releases/8.4.8-pv1/etc/conf.d", + "/home/.pv/resources/php/8.4/etc/conf.d", ), ], }, @@ -66,11 +66,11 @@ expression: "(outputs, exec_calls)" env: [ ( "PHPRC", - "/home/.pv/resources/php/8.4/releases/8.4.8-pv1/etc", + "/home/.pv/resources/php/8.4/etc", ), ( "PHP_INI_SCAN_DIR", - "/home/.pv/resources/php/8.4/releases/8.4.8-pv1/etc/conf.d", + "/home/.pv/resources/php/8.4/etc/conf.d", ), ], }, @@ -82,11 +82,11 @@ expression: "(outputs, exec_calls)" env: [ ( "PHPRC", - "/home/.pv/resources/php/8.4/releases/8.4.8-pv1/etc", + "/home/.pv/resources/php/8.4/etc", ), ( "PHP_INI_SCAN_DIR", - "/home/.pv/resources/php/8.4/releases/8.4.8-pv1/etc/conf.d", + "/home/.pv/resources/php/8.4/etc/conf.d", ), ], }, @@ -98,11 +98,11 @@ expression: "(outputs, exec_calls)" env: [ ( "PHPRC", - "/home/.pv/resources/php/8.4/releases/8.4.8-pv1/etc", + "/home/.pv/resources/php/8.4/etc", ), ( "PHP_INI_SCAN_DIR", - "/home/.pv/resources/php/8.4/releases/8.4.8-pv1/etc/conf.d", + "/home/.pv/resources/php/8.4/etc/conf.d", ), ], }, diff --git a/crates/cli/tests/snapshots/php__php_shim_uses_cached_manifest_default_without_network.snap b/crates/cli/tests/snapshots/php__php_shim_uses_cached_manifest_default_without_network.snap index 96e1064b..b5e6880e 100644 --- a/crates/cli/tests/snapshots/php__php_shim_uses_cached_manifest_default_without_network.snap +++ b/crates/cli/tests/snapshots/php__php_shim_uses_cached_manifest_default_without_network.snap @@ -21,11 +21,11 @@ expression: "(output, exec_calls)" env: [ ( "PHPRC", - "/home/.pv/resources/php/8.4/releases/8.4.8-pv1/etc", + "/home/.pv/resources/php/8.4/etc", ), ( "PHP_INI_SCAN_DIR", - "/home/.pv/resources/php/8.4/releases/8.4.8-pv1/etc/conf.d", + "/home/.pv/resources/php/8.4/etc/conf.d", ), ], }, From 09ab4d8f4e318dc55f55b7ecc7f8ff4f624cd031 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sun, 21 Jun 2026 12:34:54 -0400 Subject: [PATCH 08/15] fix(daemon): pass PHP track defaults to workers --- .superpowers/sdd/task-4-report.md | 94 +++++++++++++++++++ crates/daemon/src/gateway.rs | 54 +++++++++-- crates/daemon/tests/gateway_config.rs | 5 +- crates/daemon/tests/gateway_reconciliation.rs | 37 +++++++- .../daemon/tests/real_artifact_gateway_e2e.rs | 2 +- ..._command_and_process_specs_are_stable.snap | 3 +- 6 files changed, 182 insertions(+), 13 deletions(-) create mode 100644 .superpowers/sdd/task-4-report.md diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md new file mode 100644 index 00000000..00f8c3fb --- /dev/null +++ b/.superpowers/sdd/task-4-report.md @@ -0,0 +1,94 @@ +# Task 4 Report: Use Track Defaults For FrankenPHP Worker Validation And Runtime + +## What I implemented + +- Added fallible FrankenPHP worker private environment wiring that combines the existing XDG environment with PHP track defaults from `resources::php_track_environment`. +- Kept Gateway process private environment PHP-neutral; it still only receives XDG config/data paths. +- Changed worker process spec creation to return `Result` so invalid PHP track/default environment failures are propagated instead of guessed around. +- Ensured PHP track defaults with `resources::ensure_php_track_defaults` before worker config validation and recorded PHP worker runtime errors if default seeding/env construction fails. +- Passed an explicit private environment into config promotion validation so Gateway validation receives Gateway XDG env and worker validation receives worker env with `PHPRC` and `PHP_INI_SCAN_DIR`. +- Added worker Caddyfile coverage to guard against generated `php_ini` defaults appearing in rendered worker configs. +- Updated the process spec snapshot so only the PHP worker shows redacted `PHPRC` and `PHP_INI_SCAN_DIR`. + +## What I tested and results + +- `cargo fmt --all --check` - PASS +- `cargo insta test --accept --test-runner nextest -p daemon -- frankenphp_command_and_process_specs_are_stable` - PASS, accepted updated process spec snapshot +- `cargo insta test --accept --test-runner nextest -p daemon -- worker_config_renderer_outputs_track_caddyfile` - PASS, no snapshot changes +- `cargo nextest run -p daemon -E 'test(frankenphp_config_validation_receives_xdg_environment)'` - PASS +- `cargo nextest run -p daemon -E 'test(frankenphp_command_and_process_specs_are_stable) | test(frankenphp_config_validation_receives_xdg_environment)'` - PASS +- `cargo nextest run -p daemon -E 'test(worker_config_renderer_outputs_track_caddyfile) | test(gateway_reconciliation_starts_gateway_and_one_worker_per_php_track)'` - PASS + +## TDD Evidence + +### RED + +Command: + +```shell +cargo nextest run -p daemon -E 'test(frankenphp_command_and_process_specs_are_stable) | test(frankenphp_config_validation_receives_xdg_environment)' +``` + +Output summary: + +```text +FAIL daemon::gateway_reconciliation frankenphp_command_and_process_specs_are_stable +assertion `left == right` failed + left: None + right: Some("/var/folders/.../home/.pv/resources/php/8.4/etc") +Summary: 1/2 tests run: 0 passed, 1 failed, 213 skipped +warning: 1/2 tests were not run due to test failure +``` + +### GREEN + +Command: + +```shell +cargo nextest run -p daemon -E 'test(frankenphp_command_and_process_specs_are_stable) | test(frankenphp_config_validation_receives_xdg_environment)' +``` + +Output summary: + +```text +PASS daemon::gateway_reconciliation frankenphp_command_and_process_specs_are_stable +PASS daemon::gateway_reconciliation frankenphp_config_validation_receives_xdg_environment +Summary: 2 tests run: 2 passed, 213 skipped +``` + +Additional focused command: + +```shell +cargo nextest run -p daemon -E 'test(worker_config_renderer_outputs_track_caddyfile) | test(gateway_reconciliation_starts_gateway_and_one_worker_per_php_track)' +``` + +Output summary: + +```text +PASS daemon::gateway_config worker_config_renderer_outputs_track_caddyfile +PASS daemon::gateway_reconciliation gateway_reconciliation_starts_gateway_and_one_worker_per_php_track +Summary: 2 tests run: 2 passed, 213 skipped +``` + +## Files changed + +- `crates/daemon/src/gateway.rs` +- `crates/daemon/tests/gateway_config.rs` +- `crates/daemon/tests/gateway_reconciliation.rs` +- `crates/daemon/tests/real_artifact_gateway_e2e.rs` +- `crates/daemon/tests/snapshots/gateway_reconciliation__frankenphp_command_and_process_specs_are_stable.snap` +- `.superpowers/sdd/task-4-report.md` + +## Self-review findings + +- Gateway runtime remains PHP-neutral: no `PHPRC` or `PHP_INI_SCAN_DIR` are added to `gateway_process_spec`. +- Worker runtime uses process-level PHP ini discovery paths through private environment, not Caddyfile `php_ini` directives. +- Worker config validation uses the same environment helper as worker process startup. +- Defaults are seeded before worker validation; failures are recorded against the PHP worker runtime subject before returning. +- No `panic!`, `unreachable!`, `.unwrap()`, `.expect()`, unsafe code, or clippy rule ignores were added. +- `crates/daemon/tests/real_artifact_gateway_e2e.rs` needed a mechanical call-site update because `worker_process_spec` is now fallible. +- Existing unrelated untracked files `docs/superpowers/plans/2026-06-21-php-track-defaults.md` and `php.ini` were left untouched and will not be staged. + +## Concerns + +None for the implemented task. diff --git a/crates/daemon/src/gateway.rs b/crates/daemon/src/gateway.rs index 5952d0bb..626f0d0a 100644 --- a/crates/daemon/src/gateway.rs +++ b/crates/daemon/src/gateway.rs @@ -172,12 +172,20 @@ pub async fn reconcile_gateway_runtimes_with_readiness_timeout( let subject = RuntimeSubject::PhpWorker { php_track: worker.php_track.clone(), }; + let process_spec = match worker_process_spec(paths, &worker.php_track, &worker_command) { + Ok(process_spec) => process_spec, + Err(error) => { + record_runtime_error(paths, subject.clone(), &error)?; + + return Err(error); + } + }; let promoted_config = reconcile_worker_config(paths, &worker_command, worker).await?; start_or_adopt_promoted_runtime( paths, &supervisor, promoted_config, - worker_process_spec(paths, &worker.php_track, &worker_command), + process_spec, ReadinessCheck::Tcp { host: "127.0.0.1".to_owned(), port: worker.port, @@ -404,19 +412,19 @@ pub fn worker_process_spec( paths: &PvPaths, php_track: &str, command: &FrankenphpCommand, -) -> ProcessSpec { - ProcessSpec { +) -> Result { + Ok(ProcessSpec { name: format!("php-worker-{php_track}"), command: command.executable.clone(), arguments: command.run_arguments(&paths.worker_root_config(php_track)), - private_environment: frankenphp_xdg_environment(paths), + private_environment: frankenphp_worker_environment(paths, php_track)?, config_path: paths.worker_root_config(php_track), log_path: paths.worker_log(php_track), pid_path: paths.worker_pid(php_track), metadata_path: paths.worker_runtime_metadata(php_track), resource_name: "php-worker".to_owned(), track: php_track.to_owned(), - } + }) } pub fn build_runtime_plan(paths: &PvPaths) -> Result { @@ -658,6 +666,7 @@ async fn reconcile_gateway_config( paths.gateway_root_config(), &candidate_content, &active_content, + frankenphp_xdg_environment(paths), || promote_config_dir(&active_dir, &candidate_dir), command, ) @@ -689,6 +698,14 @@ async fn reconcile_worker_config( let subject = RuntimeSubject::PhpWorker { php_track: worker.php_track.clone(), }; + let private_environment = match worker_config_private_environment(paths, &worker.php_track) { + Ok(private_environment) => private_environment, + Err(error) => { + record_runtime_error(paths, subject.clone(), &error)?; + + return Err(error); + } + }; let active_dir = paths.worker_projects_config_dir(&worker.php_track); let candidate_dir = candidate_config_dir_for(&active_dir); let fragments = worker_project_config_fragments(paths, worker)?; @@ -741,6 +758,7 @@ async fn reconcile_worker_config( paths.worker_root_config(&worker.php_track), &candidate_content, &active_content, + private_environment, || promote_config_dir(&active_dir, &candidate_dir), command, ) @@ -762,6 +780,7 @@ async fn promote_runtime_config_tree( config_path: Utf8PathBuf, candidate_content: &str, active_content: &str, + private_environment: BTreeMap, promote_fragments: impl FnOnce() -> Result, command: &FrankenphpCommand, ) -> Result { @@ -769,10 +788,8 @@ async fn promote_runtime_config_tree( &config_path, candidate_content, active_content, - |candidate_path| { - let private_environment = frankenphp_xdg_environment(paths); - - async move { validate_config(command, &candidate_path, &private_environment).await } + |candidate_path| async move { + validate_config(command, &candidate_path, &private_environment).await }, promote_fragments, ) @@ -1333,6 +1350,25 @@ fn frankenphp_xdg_environment(paths: &PvPaths) -> BTreeMap { ]) } +fn frankenphp_worker_environment( + paths: &PvPaths, + php_track: &str, +) -> Result, StateError> { + let mut environment = frankenphp_xdg_environment(paths); + environment.extend(resources::php_track_environment(paths, php_track)?); + + Ok(environment) +} + +fn worker_config_private_environment( + paths: &PvPaths, + php_track: &str, +) -> Result, DaemonError> { + resources::ensure_php_track_defaults(paths, php_track)?; + + Ok(frankenphp_worker_environment(paths, php_track)?) +} + #[cfg(test)] mod tests { use anyhow::Result; diff --git a/crates/daemon/tests/gateway_config.rs b/crates/daemon/tests/gateway_config.rs index d625ae6d..ac27c135 100644 --- a/crates/daemon/tests/gateway_config.rs +++ b/crates/daemon/tests/gateway_config.rs @@ -43,7 +43,10 @@ fn worker_config_renderer_outputs_track_caddyfile() -> Result<()> { }], }; - assert_snapshot!(render_php_worker_config(&input)?); + let rendered = render_php_worker_config(&input)?; + + assert!(!rendered.contains("php_ini")); + assert_snapshot!(rendered); Ok(()) } diff --git a/crates/daemon/tests/gateway_reconciliation.rs b/crates/daemon/tests/gateway_reconciliation.rs index 96032021..f5ed1708 100644 --- a/crates/daemon/tests/gateway_reconciliation.rs +++ b/crates/daemon/tests/gateway_reconciliation.rs @@ -1337,7 +1337,7 @@ fn frankenphp_command_and_process_specs_are_stable() -> Result<()> { let paths = PvPaths::for_home(tempdir.path().join("home")); let command = FrankenphpCommand::new(tempdir.path().join("frankenphp")); let gateway = gateway_process_spec(&paths, &command); - let worker = worker_process_spec(&paths, "8.4", &command); + let worker = worker_process_spec(&paths, "8.4", &command)?; assert_eq!( gateway @@ -1367,6 +1367,19 @@ fn frankenphp_command_and_process_specs_are_stable() -> Result<()> { .map(String::as_str), Some(paths.certificates().as_str()) ); + assert_eq!(gateway.private_environment.get("PHPRC"), None); + assert_eq!(gateway.private_environment.get("PHP_INI_SCAN_DIR"), None); + assert_eq!( + worker.private_environment.get("PHPRC").map(String::as_str), + Some(paths.resources().join("php/8.4/etc").as_str()) + ); + assert_eq!( + worker + .private_environment + .get("PHP_INI_SCAN_DIR") + .map(String::as_str), + Some(paths.resources().join("php/8.4/etc/conf.d").as_str()) + ); assert_process_spec_snapshot( tempdir.path(), @@ -1390,6 +1403,8 @@ async fn frankenphp_config_validation_receives_xdg_environment() -> Result<()> { let xdg_data_home = tempdir.path().join("pv-data"); let observed_config_home = tempdir.path().join("observed-config-home"); let observed_data_home = tempdir.path().join("observed-data-home"); + let observed_phprc = tempdir.path().join("observed-phprc"); + let observed_scan_dir = tempdir.path().join("observed-scan-dir"); fs::write_sensitive_file( &validator, &format!( @@ -1397,10 +1412,14 @@ async fn frankenphp_config_validation_receives_xdg_environment() -> Result<()> { set -eu printf '%s' "${{XDG_CONFIG_HOME}}" > {} printf '%s' "${{XDG_DATA_HOME}}" > {} +printf '%s' "${{PHPRC}}" > {} +printf '%s' "${{PHP_INI_SCAN_DIR}}" > {} exit 0 "#, shell_single_quoted(observed_config_home.as_str()), shell_single_quoted(observed_data_home.as_str()), + shell_single_quoted(observed_phprc.as_str()), + shell_single_quoted(observed_scan_dir.as_str()), ), )?; set_executable(&validator)?; @@ -1414,6 +1433,14 @@ exit 0 "XDG_DATA_HOME".to_owned(), xdg_data_home.as_str().to_owned(), ), + ( + "PHPRC".to_owned(), + tempdir.path().join("php/etc").as_str().to_owned(), + ), + ( + "PHP_INI_SCAN_DIR".to_owned(), + tempdir.path().join("php/etc/conf.d").as_str().to_owned(), + ), ]); validate_config( @@ -1431,6 +1458,14 @@ exit 0 state::testing::read_to_string(&observed_data_home)?, xdg_data_home.as_str() ); + assert_eq!( + state::testing::read_to_string(&observed_phprc)?, + tempdir.path().join("php/etc").to_string() + ); + assert_eq!( + state::testing::read_to_string(&observed_scan_dir)?, + tempdir.path().join("php/etc/conf.d").to_string() + ); Ok(()) } diff --git a/crates/daemon/tests/real_artifact_gateway_e2e.rs b/crates/daemon/tests/real_artifact_gateway_e2e.rs index 148c236f..fa8cb465 100644 --- a/crates/daemon/tests/real_artifact_gateway_e2e.rs +++ b/crates/daemon/tests/real_artifact_gateway_e2e.rs @@ -166,7 +166,7 @@ async fn stop_gateway_runtimes( if let Some(gateway) = supervisor.adopt(&gateway_process_spec(paths, command))? { gateway.stop(Duration::from_secs(1)).await?; } - if let Some(worker) = supervisor.adopt(&worker_process_spec(paths, php_track, command))? { + if let Some(worker) = supervisor.adopt(&worker_process_spec(paths, php_track, command)?)? { worker.stop(Duration::from_secs(1)).await?; } diff --git a/crates/daemon/tests/snapshots/gateway_reconciliation__frankenphp_command_and_process_specs_are_stable.snap b/crates/daemon/tests/snapshots/gateway_reconciliation__frankenphp_command_and_process_specs_are_stable.snap index 7fe99371..50cfd9d5 100644 --- a/crates/daemon/tests/snapshots/gateway_reconciliation__frankenphp_command_and_process_specs_are_stable.snap +++ b/crates/daemon/tests/snapshots/gateway_reconciliation__frankenphp_command_and_process_specs_are_stable.snap @@ -1,6 +1,5 @@ --- source: crates/daemon/tests/gateway_reconciliation.rs -assertion_line: 1591 expression: snapshot --- ( @@ -50,6 +49,8 @@ expression: snapshot "caddyfile", ], private_environment: { + "PHPRC": "", + "PHP_INI_SCAN_DIR": "", "XDG_CONFIG_HOME": "", "XDG_DATA_HOME": "", }, From 40cbaa08cc86e3ebd5bac39eea13f78d953aaea9 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sun, 21 Jun 2026 12:46:47 -0400 Subject: [PATCH 09/15] fix(release): use safe PHP ini fallback paths --- .superpowers/sdd/task-5-report.md | 166 +++++++++++++++++++++++++ crates/pv-release/tests/smoke.rs | 150 ++++++++++++++++++++-- release/artifacts/recipes/php/build.sh | 2 + release/artifacts/recipes/php/smoke.sh | 15 ++- 4 files changed, 318 insertions(+), 15 deletions(-) create mode 100644 .superpowers/sdd/task-5-report.md diff --git a/.superpowers/sdd/task-5-report.md b/.superpowers/sdd/task-5-report.md new file mode 100644 index 00000000..6d430b03 --- /dev/null +++ b/.superpowers/sdd/task-5-report.md @@ -0,0 +1,166 @@ +# Task 5 Report: Move PHP Artifact Fallback Ini Paths Away From /usr/local + +## What I Implemented + +- Added StaticPHP build flags in `release/artifacts/recipes/php/build.sh`: + - `--with-config-file-path=/var/empty/com.prvious.pv/php` + - `--with-config-file-scan-dir=/var/empty/com.prvious.pv/php/conf.d` +- Updated `release/artifacts/recipes/php/smoke.sh` so standalone PHP rejects `/usr/local/etc/php` in `php --ini` output with exit code 46. +- Updated the FrankenPHP smoke page to include `phpinfo(INFO_CONFIGURATION)` and reject `/usr/local/etc/php` in the served response with exit code 46. +- Updated `crates/pv-release/tests/smoke.rs`: + - Renamed the focused build smoke test to `php_build_recipe_smoke` so the required nextest filter runs it. + - Required the safe StaticPHP fallback argv flags and rejected `/usr/local/etc/php` in the `spc` argv log. + - Added fixture coverage for unsafe standalone `php --ini` output. + - Added fixture coverage for unsafe FrankenPHP `phpinfo()` response output. + - Updated the positive FrankenPHP fixture to verify the generated smoke page includes `phpinfo(INFO_CONFIGURATION)`. + +## What I Tested and Results + +- `cargo nextest run -p pv-release -E 'test(php_build_recipe_smoke)'` - PASS +- `cargo nextest run -p pv-release -E 'test(php_smoke_rejects_usr_local_ini_path_from_php_ini_output)'` - PASS +- `cargo nextest run -p pv-release -E 'test(php_smoke_rejects_usr_local_ini_path_from_frankenphp_response)'` - PASS +- `cargo nextest run -p pv-release -E 'test(php_smoke_validates_frankenphp_when_cli_binary_is_also_present)'` - PASS +- `cargo nextest run -p pv-release -E 'test(php_smoke_normalizes_realistic_module_output)'` - PASS +- `cargo nextest run -p pv-release -E 'test(php_smoke_allows_extra_extensions)'` - PASS +- `sh -n release/artifacts/recipes/php/build.sh` - PASS +- `sh -n release/artifacts/recipes/php/smoke.sh` - PASS +- `shellcheck release/artifacts/recipes/php/build.sh release/artifacts/recipes/php/smoke.sh` - PASS +- `cargo fmt --all` - PASS +- `git diff --check` - PASS + +## TDD Evidence + +### RED: StaticPHP argv flags + +Command: + +```sh +cargo nextest run -p pv-release -E 'test(php_build_recipe_smoke)' +``` + +Result: FAIL as expected. + +Key output: + +```text +FAIL pv-release::smoke php_build_recipe_smoke +assertion `left == right` failed +left: argv=[build:php][json][--build-cli][--build-frankenphp][--enable-zts][--dl-with-php=8.4.20]... +right: argv=[build:php][json][--build-cli][--build-frankenphp][--enable-zts][--with-config-file-path=/var/empty/com.prvious.pv/php][--with-config-file-scan-dir=/var/empty/com.prvious.pv/php/conf.d][--dl-with-php=8.4.20]... +``` + +Note: the task-specified filter initially matched zero tests because the existing test had a longer descriptive name. I renamed that existing test to `php_build_recipe_smoke`, then reran the same command to capture the meaningful RED above. + +### RED: standalone PHP unsafe ini output + +Command: + +```sh +cargo nextest run -p pv-release -E 'test(php_smoke_rejects_usr_local_ini_path_from_php_ini_output)' +``` + +Result: FAIL as expected. + +Key output: + +```text +smoke hook should reject unsafe PHP ini fallback: ( + true, + Some(0), + "", + "", +) +``` + +### RED: FrankenPHP unsafe ini output + +Command: + +```sh +cargo nextest run -p pv-release -E 'test(php_smoke_rejects_usr_local_ini_path_from_frankenphp_response)' +``` + +Result: FAIL as expected. + +Key output: + +```text +smoke hook should reject unsafe FrankenPHP ini fallback: ( + true, + Some(0), + "", + "", +) +``` + +### RED: FrankenPHP phpinfo response coverage + +Command: + +```sh +cargo nextest run -p pv-release -E 'test(php_smoke_validates_frankenphp_when_cli_binary_is_also_present)' +``` + +Result: FAIL as expected. + +Key output: + +```text +smoke hook should serve phpinfo(INFO_CONFIGURATION): php-cli -r version +php-cli -r extensions +php-server 127.0.0.1:49647 missing-phpinfo +``` + +### GREEN + +Command: + +```sh +cargo nextest run -p pv-release -E 'test(php_build_recipe_smoke)' +``` + +Result: + +```text +PASS pv-release::smoke php_build_recipe_smoke +Summary: 1 test run: 1 passed, 170 skipped +``` + +Command: + +```sh +cargo nextest run -p pv-release -E 'test(php_smoke_rejects_usr_local_ini_path_from_php_ini_output)' +cargo nextest run -p pv-release -E 'test(php_smoke_rejects_usr_local_ini_path_from_frankenphp_response)' +cargo nextest run -p pv-release -E 'test(php_smoke_validates_frankenphp_when_cli_binary_is_also_present)' +``` + +Result: all PASS. + +Shell checks: + +```sh +sh -n release/artifacts/recipes/php/build.sh +sh -n release/artifacts/recipes/php/smoke.sh +shellcheck release/artifacts/recipes/php/build.sh release/artifacts/recipes/php/smoke.sh +``` + +Result: all PASS with no output. + +## Files Changed + +- `release/artifacts/recipes/php/build.sh` +- `release/artifacts/recipes/php/smoke.sh` +- `crates/pv-release/tests/smoke.rs` +- `.superpowers/sdd/task-5-report.md` + +## Self-Review Findings + +- Runtime Caddyfile environment handling and runtime `php_ini` behavior were not touched. +- The root `/Users/clovismuneza/Apps/pv/php.ini` sample was not modified or tracked. +- Shell changes preserve POSIX `sh` style and pass `sh -n` plus `shellcheck`. +- Rust changes are test-only and do not introduce `panic!`, `unreachable!`, `.unwrap()`, `.expect()`, unsafe code, or clippy rule ignores. +- Existing insta snapshots were preserved with explicit names after renaming the focused test for the required nextest filter. + +## Concerns + +- None outstanding. Verification was focused to the task-requested checks and related PHP smoke fixtures; the full workspace test suite was not run. diff --git a/crates/pv-release/tests/smoke.rs b/crates/pv-release/tests/smoke.rs index d50680da..0ccfb57b 100644 --- a/crates/pv-release/tests/smoke.rs +++ b/crates/pv-release/tests/smoke.rs @@ -106,17 +106,26 @@ case "${1:-}" in php-server) shift listen= + root= while [ "$#" -gt 0 ]; do case "$1" in --listen) shift listen=${1:-} ;; + --root) + shift + root=${1:-} + ;; esac shift done [ "$listen" != "127.0.0.1:48123" ] || exit 70 - printf 'php-server %s\n' "$listen" >>"$PV_FRANKENPHP_LOG" + phpinfo_state=missing-phpinfo + if [ -n "$root" ] && grep -F 'phpinfo(INFO_CONFIGURATION);' "$root/index.php" >/dev/null; then + phpinfo_state=phpinfo + fi + printf 'php-server %s %s\n' "$listen" "$phpinfo_state" >>"$PV_FRANKENPHP_LOG" exec sleep 60 ;; *) exit 99 ;; @@ -131,6 +140,7 @@ i=0 while [ "$i" -lt 5 ]; do if grep -F 'php-server 127.0.0.1:' "$PV_FRANKENPHP_LOG" >/dev/null; then printf '%s\n' 'pv-frankenphp-ok' + printf '%s\n' 'Configuration File (php.ini) Path => /var/empty/com.prvious.pv/php' exit 0 fi i=$((i + 1)) @@ -160,7 +170,11 @@ exit 28 .starts_with("php-cli -r version\nphp-cli -r extensions\nphp-server 127.0.0.1:") ); assert!( - !frankenphp_log.contains("php-server 127.0.0.1:48123\n"), + frankenphp_log.contains(" phpinfo\n"), + "smoke hook should serve phpinfo(INFO_CONFIGURATION): {frankenphp_log}" + ); + assert!( + !frankenphp_log.contains("php-server 127.0.0.1:48123 "), "smoke hook should not use the old fixed loopback port: {frankenphp_log}" ); @@ -180,6 +194,7 @@ fn php_smoke_normalizes_realistic_module_output() -> Result<()> { set -eu case "$1" in -v) printf '%s\n' 'PHP 8.4.20 (cli)' ;; + --ini) printf '%s\n' 'Configuration File (php.ini) Path: /var/empty/com.prvious.pv/php' ;; -m) printf '%s\n' \ '[PHP Modules]' \ @@ -229,6 +244,7 @@ fn php_smoke_allows_extra_extensions() -> Result<()> { set -eu case "$1" in -v) printf '%s\n' 'PHP 8.4.20 (cli)' ;; + --ini) printf '%s\n' 'Configuration File (php.ini) Path: /var/empty/com.prvious.pv/php' ;; -m) printf '%s\n' 'json' 'xdebug' ;; *) exit 99 ;; esac @@ -252,6 +268,106 @@ esac Ok(()) } +#[test] +fn php_smoke_rejects_usr_local_ini_path_from_php_ini_output() -> Result<()> { + let tempdir = tempdir()?; + let artifact_root = tempdir.path().join("artifact"); + let artifact_bin = artifact_root.join("bin"); + + create_dir_all(&artifact_bin)?; + write_executable( + &artifact_bin.join("php"), + r#"#!/bin/sh +set -eu +case "$1" in + -v) printf '%s\n' 'PHP 8.4.20 (cli)' ;; + -m) printf '%s\n' 'json' ;; + --ini) printf '%s\n' 'Configuration File (php.ini) Path: /usr/local/etc/php' ;; + *) exit 99 ;; +esac +"#, + )?; + + let smoke_hook = php_smoke_hook(); + let output = StdCommand::new(smoke_hook) + .arg(&artifact_root) + .env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin") + .env("PV_EXPECTED_EXTENSIONS", "json") + .env("PV_UPSTREAM_VERSION", "8.4.20") + .output()?; + + assert!( + !output.status.success(), + "smoke hook should reject unsafe PHP ini fallback: {}", + command_output_debug(&output) + ); + assert_eq!(output.status.code(), Some(46)); + + Ok(()) +} + +#[test] +fn php_smoke_rejects_usr_local_ini_path_from_frankenphp_response() -> Result<()> { + let tempdir = tempdir()?; + let artifact_root = tempdir.path().join("artifact"); + let artifact_bin = artifact_root.join("bin"); + let command_bin = tempdir.path().join("commands"); + + create_dir_all(&artifact_bin)?; + create_dir_all(&command_bin)?; + write_executable( + &artifact_bin.join("frankenphp"), + r#"#!/bin/sh +set -eu +case "${1:-}" in + php-cli) + [ "${2:-}" = "-r" ] || exit 99 + code=${3:-} + if [ "$code" = 'printf("PHP %s\n", PHP_VERSION);' ]; then + printf '%s\n' 'PHP 8.4.20' + elif [ "$code" = 'foreach (get_loaded_extensions() as $extension) { echo $extension, PHP_EOL; }' ]; then + printf '%s\n' 'json' + else + exit 99 + fi + ;; + php-server) + exec sleep 60 + ;; + *) exit 99 ;; +esac +"#, + )?; + write_executable( + &command_bin.join("curl"), + r#"#!/bin/sh +set -eu +printf '%s\n' 'pv-frankenphp-ok' +printf '%s\n' 'Loaded Configuration File => /usr/local/etc/php/php.ini' +"#, + )?; + + let smoke_hook = php_smoke_hook(); + let output = StdCommand::new(smoke_hook) + .arg(&artifact_root) + .env( + "PATH", + format!("{command_bin}:/usr/bin:/bin:/usr/sbin:/sbin"), + ) + .env("PV_EXPECTED_EXTENSIONS", "json") + .env("PV_UPSTREAM_VERSION", "8.4.20-frankenphp1.12.3") + .output()?; + + assert!( + !output.status.success(), + "smoke hook should reject unsafe FrankenPHP ini fallback: {}", + command_output_debug(&output) + ); + assert_eq!(output.status.code(), Some(46)); + + Ok(()) +} + #[test] fn composer_smoke_requires_php_binary() -> Result<()> { let tempdir = tempdir()?; @@ -311,7 +427,7 @@ printf '%s\n' 'Composer version 2.10.10 2026-01-01 00:00:00' } #[test] -fn php_pair_build_smoke_builds_cli_and_frankenphp_from_one_staticphp_buildroot() -> Result<()> { +fn php_build_recipe_smoke() -> Result<()> { let run = run_php_build_recipe_smoke()?; let php_source_dir = format!("{}/sources/php-8.4.20-source/php-source", run.out_dir); let frankenphp_source_dir = format!( @@ -320,7 +436,7 @@ fn php_pair_build_smoke_builds_cli_and_frankenphp_from_one_staticphp_buildroot() ); let expected_log = format!( "pwd={}/work/php-pair-8.4-darwin-arm64/staticphp\n\ -argv=[build:php][json][--build-cli][--build-frankenphp][--enable-zts][--dl-with-php=8.4.20][--dl-retry=3][--dl-custom-local][php-src:{php_source_dir}][--dl-custom-local][frankenphp:{frankenphp_source_dir}]\n", +argv=[build:php][json][--build-cli][--build-frankenphp][--enable-zts][--with-config-file-path=/var/empty/com.prvious.pv/php][--with-config-file-scan-dir=/var/empty/com.prvious.pv/php/conf.d][--dl-with-php=8.4.20][--dl-retry=3][--dl-custom-local][php-src:{php_source_dir}][--dl-custom-local][frankenphp:{frankenphp_source_dir}]\n", run.out_dir ); @@ -330,6 +446,11 @@ argv=[build:php][json][--build-cli][--build-frankenphp][--enable-zts][--dl-with- command_output_debug(&run.output) ); assert_eq!(run.spc_log, expected_log); + assert!( + !run.spc_log.contains("/usr/local/etc/php"), + "PHP recipe must not pass /usr/local/etc/php fallback paths: {}", + run.spc_log + ); let expected_curl_log = format!( "argv=[-L][--fail][--show-error][--silent][--retry][3][--retry-delay][2][--retry-all-errors][--connect-timeout][20][--max-time][600][https://sources.example.test/php.tar.gz][-o][{}/sources/php-8.4.20-source.tar.gz]\n\ argv=[-L][--fail][--show-error][--silent][--retry][3][--retry-delay][2][--retry-all-errors][--connect-timeout][20][--max-time][600][https://sources.example.test/frankenphp.tar.gz][-o][{}/sources/frankenphp-8.4.20-frankenphp1.12.3-pv1-source.tar.gz]\n", @@ -347,15 +468,18 @@ argv=[-L][--fail][--show-error][--silent][--retry][3][--retry-delay][2][--retry- run.frankenphp_archive_exists, "FrankenPHP archive was not written" ); - assert_debug_snapshot!(build_recipe_record_provenance( - run.php_record_json.as_deref() - )?); - assert_debug_snapshot!(build_recipe_record_provenance( - run.frankenphp_record_json.as_deref() - )?); - assert_debug_snapshot!(build_recipe_notice_source_lines( - run.frankenphp_notice.as_deref() - )?); + assert_debug_snapshot!( + "php_pair_build_smoke_builds_cli_and_frankenphp_from_one_staticphp_buildroot", + build_recipe_record_provenance(run.php_record_json.as_deref())? + ); + assert_debug_snapshot!( + "php_pair_build_smoke_builds_cli_and_frankenphp_from_one_staticphp_buildroot-2", + build_recipe_record_provenance(run.frankenphp_record_json.as_deref())? + ); + assert_debug_snapshot!( + "php_pair_build_smoke_builds_cli_and_frankenphp_from_one_staticphp_buildroot-3", + build_recipe_notice_source_lines(run.frankenphp_notice.as_deref())? + ); Ok(()) } diff --git a/release/artifacts/recipes/php/build.sh b/release/artifacts/recipes/php/build.sh index 214fe94c..1274c7fa 100755 --- a/release/artifacts/recipes/php/build.sh +++ b/release/artifacts/recipes/php/build.sh @@ -279,6 +279,8 @@ prepare_staticphp_php83_frankenphp_patch_context "$php_source_dir" "$frankenphp_ --build-cli \ --build-frankenphp \ --enable-zts \ + --with-config-file-path=/var/empty/com.prvious.pv/php \ + --with-config-file-scan-dir=/var/empty/com.prvious.pv/php/conf.d \ --dl-with-php="$PHP_PHP_VERSION" \ --dl-retry=3 \ --dl-custom-local "php-src:$php_source_dir" \ diff --git a/release/artifacts/recipes/php/smoke.sh b/release/artifacts/recipes/php/smoke.sh index 94445003..b778489c 100755 --- a/release/artifacts/recipes/php/smoke.sh +++ b/release/artifacts/recipes/php/smoke.sh @@ -90,14 +90,21 @@ if [ -x "$artifact_root/bin/frankenphp" ]; then need python3 site_dir=$(mktemp -d) cat >"$site_dir/index.php" <<'PHP' -/dev/null || true; wait "$pid" 2>/dev/null || true; rm -rf "$site_dir"' 0 for _ in 1 2 3 4 5 6 7 8 9 10; do - if curl --fail --silent "http://127.0.0.1:$port/" | grep -F pv-frankenphp-ok >/dev/null; then + response=$(curl --fail --silent "http://127.0.0.1:$port/" || true) + if printf '%s' "$response" | grep -F pv-frankenphp-ok >/dev/null; then + if printf '%s' "$response" | grep -F '/usr/local/etc/php' >/dev/null; then + printf '%s\n' "FrankenPHP artifact reports unsafe /usr/local/etc/php ini fallback" >&2 + exit 46 + fi exit 0 fi sleep 1 @@ -110,6 +117,10 @@ if [ -x "$artifact_root/bin/php" ]; then php_binary="$artifact_root/bin/php" "$php_binary" -v | grep -F "PHP $expected_version" >/dev/null check_extensions "$php_binary" -m + if "$php_binary" --ini 2>&1 | grep -F '/usr/local/etc/php' >/dev/null; then + printf '%s\n' "PHP artifact reports unsafe /usr/local/etc/php ini fallback" >&2 + exit 46 + fi exit 0 fi From cf03ad9f0e7bdc96612edff36d6543d739c7eaff Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sun, 21 Jun 2026 12:51:33 -0400 Subject: [PATCH 10/15] docs: document PHP track defaults --- DESIGN.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/DESIGN.md b/DESIGN.md index b01f9e63..3edd8f57 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -373,6 +373,8 @@ PV builds its own FrankenPHP artifacts for the PHP tracks it supports because up PV v1 does not support custom PHP ini settings in Project config. +For each installed PHP track, PV seeds track-level PHP defaults under `~/.pv/resources/php//etc/php.ini` and `~/.pv/resources/php//etc/conf.d/`. The defaults are mutable track data, not artifact release payload data, so artifact updates and old-release pruning do not remove user edits. PV runs standalone PHP, Composer-through-PHP, and Project-serving FrankenPHP workers with process-level `PHPRC` and `PHP_INI_SCAN_DIR` pointing at the track defaults. PV does not pass these ini discovery paths through Caddyfile `env` and does not expand the default profile into Caddyfile `php_ini` directives. + - If there are 5 Projects and all of them use the same PHP version, PV provisions 1 Project-serving FrankenPHP process. - If 2 Projects use PHP 8.3, 2 use PHP 8.4, and 1 uses PHP 8.5, PV provisions 3 Project-serving FrankenPHP processes. The Gateway proxies each Project hostname to the worker for that Project's PHP version. From 291944423c2eaf5016fc61c430b60ecc068b4825 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sun, 21 Jun 2026 13:01:18 -0400 Subject: [PATCH 11/15] fix(daemon): group runtime config promotion inputs --- .superpowers/sdd/final-fix-clippy-report.md | 28 +++++++++ crates/daemon/src/gateway.rs | 66 +++++++++++++-------- 2 files changed, 69 insertions(+), 25 deletions(-) create mode 100644 .superpowers/sdd/final-fix-clippy-report.md diff --git a/.superpowers/sdd/final-fix-clippy-report.md b/.superpowers/sdd/final-fix-clippy-report.md new file mode 100644 index 00000000..86caf96d --- /dev/null +++ b/.superpowers/sdd/final-fix-clippy-report.md @@ -0,0 +1,28 @@ +# Final Clippy Fix Report + +## Change + +- Reduced `promote_runtime_config_tree` argument count by grouping its inputs into `RuntimeConfigTreePromotion`. +- Preserved the existing environment flow: + - Gateway validation uses `frankenphp_xdg_environment(paths)`. + - Worker validation uses `worker_config_private_environment(paths, &worker.php_track)`. + - Worker startup remains on `frankenphp_worker_environment(paths, php_track)`. +- No Caddyfile `env` or `php_ini` rendering behavior was changed. + +## Tests + +No new tests were added because this is a behavior-preserving call-shape refactor for a clippy lint. + +## Verification + +```text +$ cargo fmt --all -- --check +exit code: 0 +``` + +```text +$ cargo clippy --workspace --all-targets --all-features --locked -- -D warnings + Blocking waiting for file lock on build directory + Finished `dev` profile [unoptimized + debuginfo] target(s) in 20.81s +exit code: 0 +``` diff --git a/crates/daemon/src/gateway.rs b/crates/daemon/src/gateway.rs index 626f0d0a..0d046d07 100644 --- a/crates/daemon/src/gateway.rs +++ b/crates/daemon/src/gateway.rs @@ -660,17 +660,17 @@ async fn reconcile_gateway_config( }; let result = match write_project_config_fragments(&candidate_dir, &fragments) { Ok(()) => { - promote_runtime_config_tree( - paths, - RuntimeSubject::Gateway, - paths.gateway_root_config(), - &candidate_content, - &active_content, - frankenphp_xdg_environment(paths), - || promote_config_dir(&active_dir, &candidate_dir), + let promotion = RuntimeConfigTreePromotion { + subject: RuntimeSubject::Gateway, + config_path: paths.gateway_root_config(), + candidate_content: &candidate_content, + active_content: &active_content, + private_environment: frankenphp_xdg_environment(paths), + promote_fragments: || promote_config_dir(&active_dir, &candidate_dir), command, - ) - .await + }; + + promote_runtime_config_tree(paths, promotion).await } Err(error) => { record_runtime_error(paths, RuntimeSubject::Gateway, &error)?; @@ -752,17 +752,17 @@ async fn reconcile_worker_config( }; let result = match write_project_config_fragments(&candidate_dir, &fragments) { Ok(()) => { - promote_runtime_config_tree( - paths, + let promotion = RuntimeConfigTreePromotion { subject, - paths.worker_root_config(&worker.php_track), - &candidate_content, - &active_content, + config_path: paths.worker_root_config(&worker.php_track), + candidate_content: &candidate_content, + active_content: &active_content, private_environment, - || promote_config_dir(&active_dir, &candidate_dir), + promote_fragments: || promote_config_dir(&active_dir, &candidate_dir), command, - ) - .await + }; + + promote_runtime_config_tree(paths, promotion).await } Err(error) => { record_runtime_error(paths, subject, &error)?; @@ -774,16 +774,32 @@ async fn reconcile_worker_config( result } -async fn promote_runtime_config_tree( - paths: &PvPaths, +struct RuntimeConfigTreePromotion<'a, PromoteFragments> { subject: RuntimeSubject, config_path: Utf8PathBuf, - candidate_content: &str, - active_content: &str, + candidate_content: &'a str, + active_content: &'a str, private_environment: BTreeMap, - promote_fragments: impl FnOnce() -> Result, - command: &FrankenphpCommand, -) -> Result { + promote_fragments: PromoteFragments, + command: &'a FrankenphpCommand, +} + +async fn promote_runtime_config_tree( + paths: &PvPaths, + promotion: RuntimeConfigTreePromotion<'_, PromoteFragments>, +) -> Result +where + PromoteFragments: FnOnce() -> Result, +{ + let RuntimeConfigTreePromotion { + subject, + config_path, + candidate_content, + active_content, + private_environment, + promote_fragments, + command, + } = promotion; let result = promote_validated_config_tree_async( &config_path, candidate_content, From 96b96cbfb63d68d907c7c20391107343f50074ca Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sun, 21 Jun 2026 13:23:57 -0400 Subject: [PATCH 12/15] chore: track PHP defaults plan cleanup --- .superpowers/sdd/final-fix-clippy-report.md | 28 - .superpowers/sdd/task-1-report.md | 248 --- .superpowers/sdd/task-2-report.md | 102 -- .superpowers/sdd/task-3-report.md | 110 -- .superpowers/sdd/task-4-report.md | 94 -- .superpowers/sdd/task-5-report.md | 166 --- .../plans/2026-06-21-php-track-defaults.md | 1323 +++++++++++++++++ 7 files changed, 1323 insertions(+), 748 deletions(-) delete mode 100644 .superpowers/sdd/final-fix-clippy-report.md delete mode 100644 .superpowers/sdd/task-1-report.md delete mode 100644 .superpowers/sdd/task-2-report.md delete mode 100644 .superpowers/sdd/task-3-report.md delete mode 100644 .superpowers/sdd/task-4-report.md delete mode 100644 .superpowers/sdd/task-5-report.md create mode 100644 docs/superpowers/plans/2026-06-21-php-track-defaults.md diff --git a/.superpowers/sdd/final-fix-clippy-report.md b/.superpowers/sdd/final-fix-clippy-report.md deleted file mode 100644 index 86caf96d..00000000 --- a/.superpowers/sdd/final-fix-clippy-report.md +++ /dev/null @@ -1,28 +0,0 @@ -# Final Clippy Fix Report - -## Change - -- Reduced `promote_runtime_config_tree` argument count by grouping its inputs into `RuntimeConfigTreePromotion`. -- Preserved the existing environment flow: - - Gateway validation uses `frankenphp_xdg_environment(paths)`. - - Worker validation uses `worker_config_private_environment(paths, &worker.php_track)`. - - Worker startup remains on `frankenphp_worker_environment(paths, php_track)`. -- No Caddyfile `env` or `php_ini` rendering behavior was changed. - -## Tests - -No new tests were added because this is a behavior-preserving call-shape refactor for a clippy lint. - -## Verification - -```text -$ cargo fmt --all -- --check -exit code: 0 -``` - -```text -$ cargo clippy --workspace --all-targets --all-features --locked -- -D warnings - Blocking waiting for file lock on build directory - Finished `dev` profile [unoptimized + debuginfo] target(s) in 20.81s -exit code: 0 -``` diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md deleted file mode 100644 index 0ec4c98b..00000000 --- a/.superpowers/sdd/task-1-report.md +++ /dev/null @@ -1,248 +0,0 @@ -What you implemented - -- Added the shared bundled PHP defaults asset at `crates/resources/src/php-defaults.ini` using the exact stripped values from the task brief. -- Added `crates/resources/src/php_defaults.rs` with: - - `PHP_TRACK_DEFAULT_INI` - - `PhpTrackDefaults { etc_dir, php_ini, conf_dir }` - - `php_track_defaults(&PvPaths, &str) -> PhpTrackDefaults` - - `ensure_php_track_defaults(&PvPaths, &str) -> Result` - - `php_track_environment(&PvPaths, &str) -> BTreeMap` - - `php_track_exec_environment(&PvPaths, &str) -> Vec<(OsString, OsString)>` -- Exported the new API from `crates/resources/src/lib.rs`. -- Added focused integration tests in `crates/resources/tests/php_defaults.rs` for one-time seeding, blocking-path rejection, and environment helper output. - -What you tested and results - -- Ran `cargo nextest run -p resources -E 'test(php_track_defaults_)'`. -- Result: 3 tests passed, 0 failed. - -TDD Evidence: RED command/output and GREEN command/output - -RED command: - -```shell -cargo nextest run -p resources -E 'test(php_track_defaults_)' -``` - -RED output: - -```text -error[E0432]: unresolved imports `resources::PHP_TRACK_DEFAULT_INI`, `resources::ensure_php_track_defaults`, `resources::php_track_defaults`, `resources::php_track_environment`, `resources::php_track_exec_environment` - --> crates/resources/tests/php_defaults.rs:6:5 - | -6 | PHP_TRACK_DEFAULT_INI, ensure_php_track_defaults, php_track_defaults, - | ^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^ no `php_track_defaults` in the root - | | | - | | no `ensure_php_track_defaults` in the root - | no `PHP_TRACK_DEFAULT_INI` in the root -7 | php_track_environment, php_track_exec_environment, - | ^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^ no `php_track_exec_environment` in the root - | | - | no `php_track_environment` in the root - -For more information about this error, try `rustc --explain E0432`. -error: could not compile `resources` (test "php_defaults") due to 1 previous error -error: command `/Users/clovismuneza/.rustup/toolchains/stable-aarch64-apple-darwin/bin/cargo test --no-run --message-format json-render-diagnostics --package resources` exited with code 101 -``` - -GREEN command: - -```shell -cargo nextest run -p resources -E 'test(php_track_defaults_)' -``` - -GREEN output: - -```text -Finished `test` profile [unoptimized + debuginfo] target(s) in 5.04s -──────────── - Nextest run ID 658696da-43e8-486a-8b87-c0c55ca1d59a with nextest profile: default - Starting 3 tests across 8 binaries (111 tests skipped) - PASS [ 0.013s] (1/3) resources::php_defaults php_track_defaults_env_helpers_point_at_track_etc - PASS [ 0.014s] (2/3) resources::php_defaults php_track_defaults_reject_blocking_paths - PASS [ 0.015s] (3/3) resources::php_defaults php_track_defaults_seed_stripped_sample_once -──────────── - Summary [ 0.016s] 3 tests run: 3 passed, 111 skipped -``` - -Files changed - -- `crates/resources/src/php-defaults.ini` -- `crates/resources/src/php_defaults.rs` -- `crates/resources/src/lib.rs` -- `crates/resources/tests/php_defaults.rs` -- `.superpowers/sdd/task-1-report.md` - -Self-review findings - -- The implementation is intentionally narrow: it seeds the per-track `etc/php.ini` once, creates `conf.d`, and exposes env helpers without adding validation or cross-crate behavior not required by this task. -- Blocking `conf.d` or `etc` paths return a `StateError::Filesystem` with a task-specific message so later callers can surface a clear failure. -- The root sample file `php.ini` was not modified or tracked. - -Any concerns - -- None for Task 1 scope. - -Fix follow-up from review - -What changed - -- Completed the bundled defaults asset tail after `[soap]` with the required active SOAP cache settings plus `[sysvshm]`, `[ldap]`, `[dba]`, `[opcache]`, `[curl]`, `[openssl]`, and `[ffi]` in the required order. -- Tightened `ensure_php_track_defaults` to: - - reject unsupported tracks outside `8.3`, `8.4`, and `8.5` - - validate an existing `php.ini` is a regular file - - validate an existing `php.ini` is readable by attempting to read it -- Changed `crates/resources/src/lib.rs` to `pub mod php_defaults;` to match the brief. -- Expanded the focused integration tests to cover: - - exact required asset tail content/order - - unsupported-track rejection - - blocking `php.ini` directory rejection - -Review-fix TDD evidence - -RED command: - -```shell -cargo nextest run -p resources -E 'test(php_track_defaults_)' -``` - -RED output: - -```text -──────────── - Nextest run ID 89d9113d-b0f1-4f1c-8bab-ae38f26eeb38 with nextest profile: default - Starting 5 tests across 8 binaries (111 tests skipped) - PASS [ 0.013s] (1/5) resources::php_defaults php_track_defaults_env_helpers_point_at_track_etc - FAIL [ 0.014s] (2/5) resources::php_defaults php_track_defaults_reject_blocking_php_ini_paths -Error: expected blocking php.ini path to fail - FAIL [ 0.015s] (3/5) resources::php_defaults php_track_defaults_reject_unsupported_tracks -Error: expected unsupported PHP track to fail - FAIL [ 0.015s] (4/5) resources::php_defaults php_track_defaults_seed_stripped_sample_once -assertion failed: PHP_TRACK_DEFAULT_INI.ends_with(...) - PASS [ 0.016s] (5/5) resources::php_defaults php_track_defaults_reject_blocking_paths -──────────── - Summary [ 0.016s] 5 tests run: 2 passed, 3 failed, 111 skipped -``` - -GREEN command: - -```shell -cargo nextest run -p resources -E 'test(php_track_defaults_)' -``` - -GREEN output: - -```text -Finished `test` profile [unoptimized + debuginfo] target(s) in 0.85s -──────────── - Nextest run ID 5c7f6d68-cd55-4a9e-8578-d160b28c8bb1 with nextest profile: default - Starting 5 tests across 8 binaries (111 tests skipped) - PASS [ 0.008s] (1/5) resources::php_defaults php_track_defaults_reject_unsupported_tracks - PASS [ 0.008s] (2/5) resources::php_defaults php_track_defaults_env_helpers_point_at_track_etc - PASS [ 0.009s] (3/5) resources::php_defaults php_track_defaults_reject_blocking_paths - PASS [ 0.009s] (4/5) resources::php_defaults php_track_defaults_reject_blocking_php_ini_paths - PASS [ 0.009s] (5/5) resources::php_defaults php_track_defaults_seed_stripped_sample_once -──────────── - Summary [ 0.010s] 5 tests run: 5 passed, 111 skipped -``` - -Files changed for review fixes - -- `crates/resources/src/php-defaults.ini` -- `crates/resources/src/php_defaults.rs` -- `crates/resources/src/lib.rs` -- `crates/resources/tests/php_defaults.rs` -- `.superpowers/sdd/task-1-report.md` - -Self-review for fixes - -- The new track gate is enforced at the seeding entrypoint, which is where arbitrary-track mutation could occur. -- Existing `php.ini` validation now fails fast for non-files and unreadable files, while preserving the existing file content when valid. -- The root `/Users/clovismuneza/Apps/pv/php.ini` remained unchanged and untracked. - -Review-fix second pass: strict public helpers and symlink rejection - -What changed - -- Changed `php_track_defaults`, `php_track_environment`, and `php_track_exec_environment` to return `Result<..., StateError>` and validate the PHP track before constructing paths or env overlays. -- Kept default path construction behind a private helper that is called only after supported-track validation. -- Changed existing `php.ini` validation to use `state::fs::path_entry_exists` and `state::fs::path_is_file`, which are based on `symlink_metadata`, so symlinked `php.ini` paths are rejected instead of followed. -- Added integration coverage for unsupported public helper APIs and symlinked `php.ini` rejection. - -TDD RED command: - -```shell -cargo nextest run -p resources -E 'test(php_track_defaults_)' -``` - -TDD RED output: - -```text -Compiling resources v0.1.3 (/Users/clovismuneza/Apps/pv/crates/resources) -error[E0277]: the `?` operator can only be applied to values that implement `Try` - --> crates/resources/tests/php_defaults.rs:79:20 - | -79 | let defaults = php_track_defaults(&paths, "8.5")?; - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the `?` operator cannot be applied to type `PhpTrackDefaults` - -error[E0308]: mismatched types - --> crates/resources/tests/php_defaults.rs:148:26 - | -148 | assert_invalid_track(php_track_defaults(&paths, "8.2"), "8.2")?; - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Result<_, StateError>`, found `PhpTrackDefaults` - -error[E0308]: mismatched types - --> crates/resources/tests/php_defaults.rs:149:26 - | -149 | assert_invalid_track(php_track_environment(&paths, "8.2"), "8.2")?; - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Result<_, StateError>`, found `BTreeMap` - -error[E0308]: mismatched types - --> crates/resources/tests/php_defaults.rs:150:26 - | -150 | assert_invalid_track(php_track_exec_environment(&paths, "8.2"), "8.2")?; - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Result<_, StateError>`, found `Vec<(OsString, OsString)>` - -error: could not compile `resources` (test "php_defaults") due to 8 previous errors -``` - -TDD GREEN command: - -```shell -cargo fmt --all && cargo nextest run -p resources -E 'test(php_track_defaults_)' -``` - -TDD GREEN output: - -```text -Finished `test` profile [unoptimized + debuginfo] target(s) in 0.75s -──────────── - Nextest run ID a7022687-cad4-4125-bd8e-48188c296f20 with nextest profile: default - Starting 7 tests across 8 binaries (111 tests skipped) - PASS [ 0.015s] (1/7) resources::php_defaults php_track_defaults_reject_unsupported_tracks - PASS [ 0.015s] (2/7) resources::php_defaults php_track_defaults_helpers_reject_unsupported_tracks - PASS [ 0.015s] (3/7) resources::php_defaults php_track_defaults_env_helpers_point_at_track_etc - PASS [ 0.017s] (4/7) resources::php_defaults php_track_defaults_reject_blocking_paths - PASS [ 0.018s] (5/7) resources::php_defaults php_track_defaults_reject_blocking_php_ini_paths - PASS [ 0.018s] (6/7) resources::php_defaults php_track_defaults_seed_stripped_sample_once - PASS [ 0.018s] (7/7) resources::php_defaults php_track_defaults_reject_symlinked_php_ini_paths -──────────── - Summary [ 0.019s] 7 tests run: 7 passed, 111 skipped -``` - -Files changed - -- `crates/resources/src/php_defaults.rs` -- `crates/resources/tests/php_defaults.rs` -- `.superpowers/sdd/task-1-report.md` - -Self-review findings - -- Unsupported PHP tracks now fail before any public helper can synthesize defaults paths or env overlays. -- `ensure_php_track_defaults` still preserves a valid existing seeded `php.ini`, but rejects directories and symlinks before checking readability. -- The implementation uses existing `state::fs` helpers for symlink-aware filesystem checks and does not add panic, assert, unwrap, unsafe code, or clippy ignores. -- The root `/Users/clovismuneza/Apps/pv/php.ini` sample remained untouched and untracked. - -Any concerns - -- No functional concerns. I did not add a chmod-based unreadable-file fixture because local macOS permission behavior can make that unreliable; readable-file validation is still exercised by the implementation through `state::fs::read_to_string`. diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md deleted file mode 100644 index b2137dbc..00000000 --- a/.superpowers/sdd/task-2-report.md +++ /dev/null @@ -1,102 +0,0 @@ -# Task 2 Report: Seed Defaults During PHP Pair Install And Update - -## What I Implemented - -- Added `ManagedResourceCommands::ensure_php_pair_defaults`. -- Call PHP default seeding before recording PHP pair install/update state. -- Call PHP default seeding before recording Composer-with-PHP-pair state. -- Added integration coverage for: - - PHP pair install seeds `resources/php//etc/php.ini` and `conf.d`. - - Composer-with-PHP-pair install seeds the PHP track defaults. - - PHP pair update preserves an existing customized `php.ini`. - -## What I Tested And Results - -- `cargo insta test --accept --test-runner nextest -p resources -- managed_resource_commands_install_php_pair_seeds_track_defaults` - - PASS: 1 test passed; snapshot accepted. -- `cargo insta test --accept --test-runner nextest -p resources -- managed_resource_commands_install_composer_with_php_pair_seeds_track_defaults` - - PASS: 1 test passed; snapshot accepted. -- `cargo insta test --accept --test-runner nextest -p resources -- managed_resource_commands_update_php_pairs_preserves_existing_php_ini` - - PASS: 1 test passed. -- `cargo fmt --all` - - PASS: no output. -- `cargo nextest run -p resources -E 'test(managed_resource_commands_install_php_pair_seeds_track_defaults) | test(managed_resource_commands_update_php_pairs_preserves_existing_php_ini) | test(managed_resource_commands_install_composer_with_php_pair_seeds_track_defaults)'` - - PASS: 3 tests passed; 118 skipped. -- `cargo nextest run -p resources --test managed_resource_commands` - - PASS: 35 tests passed. -- `git diff --check` - - PASS: no whitespace errors. - -## TDD Evidence - -### RED - -Command: - -```shell -cargo nextest run -p resources -E 'test(managed_resource_commands_install_php_pair_seeds_track_defaults) | test(managed_resource_commands_update_php_pairs_preserves_existing_php_ini) | test(managed_resource_commands_install_composer_with_php_pair_seeds_track_defaults)' -``` - -Output summary: - -```text -Starting 3 tests across 8 binaries -FAIL resources::managed_resource_commands managed_resource_commands_install_php_pair_seeds_track_defaults -Error: filesystem error at .../home/.pv/resources/php/8.4/etc/php.ini: No such file or directory (os error 2) - -FAIL resources::managed_resource_commands managed_resource_commands_install_composer_with_php_pair_seeds_track_defaults -Error: filesystem error at .../home/.pv/resources/php/8.4/etc/php.ini: No such file or directory (os error 2) - -FAIL resources::managed_resource_commands managed_resource_commands_update_php_pairs_preserves_existing_php_ini -stored new snapshot ...managed_resource_commands_update_php_pairs_preserves_existing_php_ini.snap.new - -Summary: 3 tests run: 0 passed, 3 failed, 118 skipped -error: test run failed -``` - -### GREEN - -Command: - -```shell -cargo nextest run -p resources -E 'test(managed_resource_commands_install_php_pair_seeds_track_defaults) | test(managed_resource_commands_update_php_pairs_preserves_existing_php_ini) | test(managed_resource_commands_install_composer_with_php_pair_seeds_track_defaults)' -``` - -Output summary: - -```text -Starting 3 tests across 8 binaries -PASS resources::managed_resource_commands managed_resource_commands_install_php_pair_seeds_track_defaults -PASS resources::managed_resource_commands managed_resource_commands_install_composer_with_php_pair_seeds_track_defaults -PASS resources::managed_resource_commands managed_resource_commands_update_php_pairs_preserves_existing_php_ini -Summary: 3 tests run: 3 passed, 118 skipped -``` - -Broader focused verification: - -```text -cargo nextest run -p resources --test managed_resource_commands -Summary: 35 tests run: 35 passed, 0 skipped -``` - -## Files Changed - -- `crates/resources/src/command.rs` -- `crates/resources/tests/managed_resource_commands.rs` -- `crates/resources/tests/snapshots/managed_resource_commands__managed_resource_commands_install_php_pair_seeds_track_defaults.snap` -- `crates/resources/tests/snapshots/managed_resource_commands__managed_resource_commands_install_composer_with_php_pair_seeds_track_defaults.snap` -- `crates/resources/tests/snapshots/managed_resource_commands__managed_resource_commands_update_php_pairs_preserves_existing_php_ini.snap` -- `.superpowers/sdd/task-2-report.md` - -## Self-Review Findings - -- The default seeding happens before `Database::open` and before managed resource state is recorded. -- The same helper is used by install, update, and Composer-with-PHP-pair recording paths. -- Existing seeded `php.ini` content is preserved by the `ensure_php_track_defaults` helper. -- Tests use fallible `php_track_defaults(...)?` per the Task 1 API shape. -- No root `php.ini` sample changes were made. -- No unrelated untracked files were modified or staged. - -## Concerns - -- None. diff --git a/.superpowers/sdd/task-3-report.md b/.superpowers/sdd/task-3-report.md deleted file mode 100644 index 20f1c214..00000000 --- a/.superpowers/sdd/task-3-report.md +++ /dev/null @@ -1,110 +0,0 @@ -# Task 3 Report: Point CLI PHP And Composer At Track Defaults - -## What I implemented - -- Updated the PHP shim so it ensures PHP track defaults before exec and appends `PHPRC` / `PHP_INI_SCAN_DIR` from `resources::php_track_exec_environment(&paths, &track)?`. -- Removed the old PHP shim release-path env overlay that pointed ini discovery at `~/.pv/resources/php//releases//etc`. -- Kept `InstalledPhp.release` and derive the executable path from the installed artifact release path. -- Updated PHP shim test helpers and assertions to expect track defaults under `~/.pv/resources/php//etc`. -- Updated Composer shim test helpers and all affected expected env calls to expect track defaults. Composer still execs through the PHP artifact binary via the PHP shim path. -- Added a PHP shim assertion that running the shim seeds the track default `php.ini` with `resources::PHP_TRACK_DEFAULT_INI`. -- Accepted affected insta snapshots for PHP and Composer shim env output. - -## What I tested and results - -- `cargo nextest run -p cli -E 'test(php_shim_sets_only_php_ini_env_overlay) | test(composer_shim_execs_installed_phar_through_php_shim) | test(composer_shim_sets_pv_owned_env_overlay)'` - - Result: PASS, 3 passed. -- `cargo nextest run -p cli -E 'test(php_shim) | test(composer_shim)'` - - Result: PASS, 12 passed. -- `cargo insta test --accept --test-runner nextest -p cli -- php_shim` - - Result: PASS, 7 passed; accepted affected PHP shim snapshots and `composer_shim_execs_installed_phar_through_php_shim`. -- `cargo insta test --accept --test-runner nextest -p cli -- composer_shim` - - Result: PASS, 6 passed; accepted remaining Composer shim snapshots. - -## TDD Evidence - -### RED - -Command: - -```shell -cargo nextest run -p cli -E 'test(php_shim_sets_only_php_ini_env_overlay) | test(composer_shim_execs_installed_phar_through_php_shim) | test(composer_shim_sets_pv_owned_env_overlay)' -``` - -Key output: - -```text -Summary [0.038s] 3 tests run: 0 passed, 3 failed, 194 skipped -FAIL cli::php php_shim_sets_only_php_ini_env_overlay -FAIL cli::composer composer_shim_sets_pv_owned_env_overlay -FAIL cli::composer composer_shim_execs_installed_phar_through_php_shim -``` - -The expected failure showed `PHPRC` and `PHP_INI_SCAN_DIR` still coming from: - -```text -~/.pv/resources/php/8.4/releases/8.4.8-pv1/etc -~/.pv/resources/php/8.4/releases/8.4.8-pv1/etc/conf.d -``` - -instead of: - -```text -~/.pv/resources/php/8.4/etc -~/.pv/resources/php/8.4/etc/conf.d -``` - -### GREEN - -Command: - -```shell -cargo nextest run -p cli -E 'test(php_shim_sets_only_php_ini_env_overlay) | test(composer_shim_execs_installed_phar_through_php_shim) | test(composer_shim_sets_pv_owned_env_overlay)' -``` - -Output: - -```text -Summary [0.039s] 3 tests run: 3 passed, 194 skipped -PASS cli::php php_shim_sets_only_php_ini_env_overlay -PASS cli::composer composer_shim_sets_pv_owned_env_overlay -PASS cli::composer composer_shim_execs_installed_phar_through_php_shim -``` - -Broader affected shim verification: - -```shell -cargo nextest run -p cli -E 'test(php_shim) | test(composer_shim)' -``` - -Output: - -```text -Summary [0.110s] 12 tests run: 12 passed, 185 skipped -``` - -## Files changed - -- `crates/cli/src/commands/php.rs` -- `crates/cli/tests/php.rs` -- `crates/cli/tests/composer.rs` -- `crates/cli/tests/snapshots/composer__composer_shim_execs_installed_phar_through_php_shim.snap` -- `crates/cli/tests/snapshots/composer__composer_shim_forwards_help_and_version_flags.snap` -- `crates/cli/tests/snapshots/composer__composer_shim_uses_cached_manifest_default_without_network.snap` -- `crates/cli/tests/snapshots/php__php_shim_execs_global_default_track_outside_project.snap` -- `crates/cli/tests/snapshots/php__php_shim_execs_resolved_project_track.snap` -- `crates/cli/tests/snapshots/php__php_shim_forwards_help_and_version_flags.snap` -- `crates/cli/tests/snapshots/php__php_shim_uses_cached_manifest_default_without_network.snap` -- `.superpowers/sdd/task-3-report.md` - -## Self-review findings - -- Shim ini discovery now uses process-level `PHPRC` and `PHP_INI_SCAN_DIR`; no Caddyfile env directives were introduced. -- Composer expected env calls using the shared helper were updated, including help/version and cached-manifest cases beyond the three tests named in the brief. -- The PHP executable still comes from the installed artifact release path. -- Existing seeded `php.ini` preservation remains owned by `resources::ensure_php_track_defaults`; this task calls that API rather than reimplementing seeding. -- No unrelated files were reverted or staged. - -## Concerns - -None. diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md deleted file mode 100644 index 00f8c3fb..00000000 --- a/.superpowers/sdd/task-4-report.md +++ /dev/null @@ -1,94 +0,0 @@ -# Task 4 Report: Use Track Defaults For FrankenPHP Worker Validation And Runtime - -## What I implemented - -- Added fallible FrankenPHP worker private environment wiring that combines the existing XDG environment with PHP track defaults from `resources::php_track_environment`. -- Kept Gateway process private environment PHP-neutral; it still only receives XDG config/data paths. -- Changed worker process spec creation to return `Result` so invalid PHP track/default environment failures are propagated instead of guessed around. -- Ensured PHP track defaults with `resources::ensure_php_track_defaults` before worker config validation and recorded PHP worker runtime errors if default seeding/env construction fails. -- Passed an explicit private environment into config promotion validation so Gateway validation receives Gateway XDG env and worker validation receives worker env with `PHPRC` and `PHP_INI_SCAN_DIR`. -- Added worker Caddyfile coverage to guard against generated `php_ini` defaults appearing in rendered worker configs. -- Updated the process spec snapshot so only the PHP worker shows redacted `PHPRC` and `PHP_INI_SCAN_DIR`. - -## What I tested and results - -- `cargo fmt --all --check` - PASS -- `cargo insta test --accept --test-runner nextest -p daemon -- frankenphp_command_and_process_specs_are_stable` - PASS, accepted updated process spec snapshot -- `cargo insta test --accept --test-runner nextest -p daemon -- worker_config_renderer_outputs_track_caddyfile` - PASS, no snapshot changes -- `cargo nextest run -p daemon -E 'test(frankenphp_config_validation_receives_xdg_environment)'` - PASS -- `cargo nextest run -p daemon -E 'test(frankenphp_command_and_process_specs_are_stable) | test(frankenphp_config_validation_receives_xdg_environment)'` - PASS -- `cargo nextest run -p daemon -E 'test(worker_config_renderer_outputs_track_caddyfile) | test(gateway_reconciliation_starts_gateway_and_one_worker_per_php_track)'` - PASS - -## TDD Evidence - -### RED - -Command: - -```shell -cargo nextest run -p daemon -E 'test(frankenphp_command_and_process_specs_are_stable) | test(frankenphp_config_validation_receives_xdg_environment)' -``` - -Output summary: - -```text -FAIL daemon::gateway_reconciliation frankenphp_command_and_process_specs_are_stable -assertion `left == right` failed - left: None - right: Some("/var/folders/.../home/.pv/resources/php/8.4/etc") -Summary: 1/2 tests run: 0 passed, 1 failed, 213 skipped -warning: 1/2 tests were not run due to test failure -``` - -### GREEN - -Command: - -```shell -cargo nextest run -p daemon -E 'test(frankenphp_command_and_process_specs_are_stable) | test(frankenphp_config_validation_receives_xdg_environment)' -``` - -Output summary: - -```text -PASS daemon::gateway_reconciliation frankenphp_command_and_process_specs_are_stable -PASS daemon::gateway_reconciliation frankenphp_config_validation_receives_xdg_environment -Summary: 2 tests run: 2 passed, 213 skipped -``` - -Additional focused command: - -```shell -cargo nextest run -p daemon -E 'test(worker_config_renderer_outputs_track_caddyfile) | test(gateway_reconciliation_starts_gateway_and_one_worker_per_php_track)' -``` - -Output summary: - -```text -PASS daemon::gateway_config worker_config_renderer_outputs_track_caddyfile -PASS daemon::gateway_reconciliation gateway_reconciliation_starts_gateway_and_one_worker_per_php_track -Summary: 2 tests run: 2 passed, 213 skipped -``` - -## Files changed - -- `crates/daemon/src/gateway.rs` -- `crates/daemon/tests/gateway_config.rs` -- `crates/daemon/tests/gateway_reconciliation.rs` -- `crates/daemon/tests/real_artifact_gateway_e2e.rs` -- `crates/daemon/tests/snapshots/gateway_reconciliation__frankenphp_command_and_process_specs_are_stable.snap` -- `.superpowers/sdd/task-4-report.md` - -## Self-review findings - -- Gateway runtime remains PHP-neutral: no `PHPRC` or `PHP_INI_SCAN_DIR` are added to `gateway_process_spec`. -- Worker runtime uses process-level PHP ini discovery paths through private environment, not Caddyfile `php_ini` directives. -- Worker config validation uses the same environment helper as worker process startup. -- Defaults are seeded before worker validation; failures are recorded against the PHP worker runtime subject before returning. -- No `panic!`, `unreachable!`, `.unwrap()`, `.expect()`, unsafe code, or clippy rule ignores were added. -- `crates/daemon/tests/real_artifact_gateway_e2e.rs` needed a mechanical call-site update because `worker_process_spec` is now fallible. -- Existing unrelated untracked files `docs/superpowers/plans/2026-06-21-php-track-defaults.md` and `php.ini` were left untouched and will not be staged. - -## Concerns - -None for the implemented task. diff --git a/.superpowers/sdd/task-5-report.md b/.superpowers/sdd/task-5-report.md deleted file mode 100644 index 6d430b03..00000000 --- a/.superpowers/sdd/task-5-report.md +++ /dev/null @@ -1,166 +0,0 @@ -# Task 5 Report: Move PHP Artifact Fallback Ini Paths Away From /usr/local - -## What I Implemented - -- Added StaticPHP build flags in `release/artifacts/recipes/php/build.sh`: - - `--with-config-file-path=/var/empty/com.prvious.pv/php` - - `--with-config-file-scan-dir=/var/empty/com.prvious.pv/php/conf.d` -- Updated `release/artifacts/recipes/php/smoke.sh` so standalone PHP rejects `/usr/local/etc/php` in `php --ini` output with exit code 46. -- Updated the FrankenPHP smoke page to include `phpinfo(INFO_CONFIGURATION)` and reject `/usr/local/etc/php` in the served response with exit code 46. -- Updated `crates/pv-release/tests/smoke.rs`: - - Renamed the focused build smoke test to `php_build_recipe_smoke` so the required nextest filter runs it. - - Required the safe StaticPHP fallback argv flags and rejected `/usr/local/etc/php` in the `spc` argv log. - - Added fixture coverage for unsafe standalone `php --ini` output. - - Added fixture coverage for unsafe FrankenPHP `phpinfo()` response output. - - Updated the positive FrankenPHP fixture to verify the generated smoke page includes `phpinfo(INFO_CONFIGURATION)`. - -## What I Tested and Results - -- `cargo nextest run -p pv-release -E 'test(php_build_recipe_smoke)'` - PASS -- `cargo nextest run -p pv-release -E 'test(php_smoke_rejects_usr_local_ini_path_from_php_ini_output)'` - PASS -- `cargo nextest run -p pv-release -E 'test(php_smoke_rejects_usr_local_ini_path_from_frankenphp_response)'` - PASS -- `cargo nextest run -p pv-release -E 'test(php_smoke_validates_frankenphp_when_cli_binary_is_also_present)'` - PASS -- `cargo nextest run -p pv-release -E 'test(php_smoke_normalizes_realistic_module_output)'` - PASS -- `cargo nextest run -p pv-release -E 'test(php_smoke_allows_extra_extensions)'` - PASS -- `sh -n release/artifacts/recipes/php/build.sh` - PASS -- `sh -n release/artifacts/recipes/php/smoke.sh` - PASS -- `shellcheck release/artifacts/recipes/php/build.sh release/artifacts/recipes/php/smoke.sh` - PASS -- `cargo fmt --all` - PASS -- `git diff --check` - PASS - -## TDD Evidence - -### RED: StaticPHP argv flags - -Command: - -```sh -cargo nextest run -p pv-release -E 'test(php_build_recipe_smoke)' -``` - -Result: FAIL as expected. - -Key output: - -```text -FAIL pv-release::smoke php_build_recipe_smoke -assertion `left == right` failed -left: argv=[build:php][json][--build-cli][--build-frankenphp][--enable-zts][--dl-with-php=8.4.20]... -right: argv=[build:php][json][--build-cli][--build-frankenphp][--enable-zts][--with-config-file-path=/var/empty/com.prvious.pv/php][--with-config-file-scan-dir=/var/empty/com.prvious.pv/php/conf.d][--dl-with-php=8.4.20]... -``` - -Note: the task-specified filter initially matched zero tests because the existing test had a longer descriptive name. I renamed that existing test to `php_build_recipe_smoke`, then reran the same command to capture the meaningful RED above. - -### RED: standalone PHP unsafe ini output - -Command: - -```sh -cargo nextest run -p pv-release -E 'test(php_smoke_rejects_usr_local_ini_path_from_php_ini_output)' -``` - -Result: FAIL as expected. - -Key output: - -```text -smoke hook should reject unsafe PHP ini fallback: ( - true, - Some(0), - "", - "", -) -``` - -### RED: FrankenPHP unsafe ini output - -Command: - -```sh -cargo nextest run -p pv-release -E 'test(php_smoke_rejects_usr_local_ini_path_from_frankenphp_response)' -``` - -Result: FAIL as expected. - -Key output: - -```text -smoke hook should reject unsafe FrankenPHP ini fallback: ( - true, - Some(0), - "", - "", -) -``` - -### RED: FrankenPHP phpinfo response coverage - -Command: - -```sh -cargo nextest run -p pv-release -E 'test(php_smoke_validates_frankenphp_when_cli_binary_is_also_present)' -``` - -Result: FAIL as expected. - -Key output: - -```text -smoke hook should serve phpinfo(INFO_CONFIGURATION): php-cli -r version -php-cli -r extensions -php-server 127.0.0.1:49647 missing-phpinfo -``` - -### GREEN - -Command: - -```sh -cargo nextest run -p pv-release -E 'test(php_build_recipe_smoke)' -``` - -Result: - -```text -PASS pv-release::smoke php_build_recipe_smoke -Summary: 1 test run: 1 passed, 170 skipped -``` - -Command: - -```sh -cargo nextest run -p pv-release -E 'test(php_smoke_rejects_usr_local_ini_path_from_php_ini_output)' -cargo nextest run -p pv-release -E 'test(php_smoke_rejects_usr_local_ini_path_from_frankenphp_response)' -cargo nextest run -p pv-release -E 'test(php_smoke_validates_frankenphp_when_cli_binary_is_also_present)' -``` - -Result: all PASS. - -Shell checks: - -```sh -sh -n release/artifacts/recipes/php/build.sh -sh -n release/artifacts/recipes/php/smoke.sh -shellcheck release/artifacts/recipes/php/build.sh release/artifacts/recipes/php/smoke.sh -``` - -Result: all PASS with no output. - -## Files Changed - -- `release/artifacts/recipes/php/build.sh` -- `release/artifacts/recipes/php/smoke.sh` -- `crates/pv-release/tests/smoke.rs` -- `.superpowers/sdd/task-5-report.md` - -## Self-Review Findings - -- Runtime Caddyfile environment handling and runtime `php_ini` behavior were not touched. -- The root `/Users/clovismuneza/Apps/pv/php.ini` sample was not modified or tracked. -- Shell changes preserve POSIX `sh` style and pass `sh -n` plus `shellcheck`. -- Rust changes are test-only and do not introduce `panic!`, `unreachable!`, `.unwrap()`, `.expect()`, unsafe code, or clippy rule ignores. -- Existing insta snapshots were preserved with explicit names after renaming the focused test for the required nextest filter. - -## Concerns - -- None outstanding. Verification was focused to the task-requested checks and related PHP smoke fixtures; the full workspace test suite was not run. diff --git a/docs/superpowers/plans/2026-06-21-php-track-defaults.md b/docs/superpowers/plans/2026-06-21-php-track-defaults.md new file mode 100644 index 00000000..e840cd37 --- /dev/null +++ b/docs/superpowers/plans/2026-06-21-php-track-defaults.md @@ -0,0 +1,1323 @@ +# PHP Track Defaults Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** Seed PV-owned PHP defaults per track and run CLI PHP, Composer, and FrankenPHP workers with the same track-level `PHPRC` and `PHP_INI_SCAN_DIR`. + +**Architecture:** Add one shared `resources::php_defaults` component that owns PHP track default paths, seeding, validation, and environment overlays. Resource install/update paths seed defaults before recording a PHP/FrankenPHP pair as installed; CLI shims and daemon worker specs consume the shared environment helpers. Artifact recipes move compiled fallback ini paths away from `/usr/local/etc/php`, while normal runtime uses explicit process environment. + +**Tech Stack:** Rust 2024, `resources`, `cli`, `daemon`, `state::fs`, `pv-release`, POSIX shell, `cargo nextest`, `insta`, `shellcheck`. + +## Global Constraints + +- Read `CONTRIBUTING.md` for tool guidance before executing tasks. +- Consult `DESIGN.md` before product or implementation decisions; ask if a decision is not covered. +- Prefer integration tests under `it/...` or crate integration tests over narrow unit tests. +- Prefer `insta` snapshots following nearby tests over substring assertions. +- Avoid `panic!`, `unreachable!`, `.unwrap()`, unsafe code, and clippy rule ignores. +- Prefer `if let` and let chains over nested fallible branching. +- Prefer top-level imports over local imports or fully qualified names. +- Do not update all dependencies in `Cargo.lock`; use `cargo update --precise` for lockfile changes. +- PV v1 is macOS-only and targets macOS 13 and newer. +- PHP track defaults live under `~/.pv/resources/php//etc`. +- Supported PHP tracks for this default profile are `8.3`, `8.4`, and `8.5`. +- PV must not render the seeded defaults into Caddyfile `php_ini` directives. +- PV must not pass PHP ini discovery paths through Caddyfile `env` directives. +- Normal PV execution must use process-level `PHPRC` and `PHP_INI_SCAN_DIR`. + +--- + +## File Structure + +- Create `crates/resources/src/php_defaults.rs`: shared PHP track default paths, seeding, validation, and env overlay helpers. +- Create `crates/resources/src/php-defaults.ini`: tracked stripped default profile generated from the approved root `php.ini` sample. +- Modify `crates/resources/src/lib.rs`: export PHP default helpers. +- Modify `crates/resources/src/command.rs`: seed PHP defaults when PHP/FrankenPHP pair installs or updates are recorded. +- Create `crates/resources/tests/php_defaults.rs`: focused integration tests for the shared defaults component. +- Modify `crates/resources/tests/managed_resource_commands.rs`: install/update behavior tests for default seeding and preservation. +- Modify `crates/cli/src/commands/php.rs`: use track-level defaults instead of artifact release `etc`. +- Modify `crates/cli/tests/php.rs`: update shim env expectations and assert shim seeding. +- Modify `crates/cli/tests/composer.rs`: update Composer-through-PHP env expectations. +- Modify `crates/daemon/src/gateway.rs`: use track-level worker env for validation and process specs, while leaving Gateway env PHP-neutral. +- Modify `crates/daemon/tests/gateway_reconciliation.rs`: update worker process spec and validation env coverage. +- Modify `crates/daemon/tests/gateway_config.rs` snapshots only if the worker root Caddyfile snapshot changes; it should not gain `php_ini`. +- Modify `release/artifacts/recipes/php/build.sh`: pass safe compiled fallback ini paths to StaticPHP. +- Modify `release/artifacts/recipes/php/smoke.sh`: check real PHP/FrankenPHP artifacts do not report `/usr/local/etc/php`. +- Modify `crates/pv-release/tests/smoke.rs`: assert StaticPHP receives safe config-file path flags. +- Modify `DESIGN.md`: document PHP track defaults. +- Modify `docs/superpowers/plans/2026-06-21-php-track-defaults.md`: check off steps as tasks are completed. + +--- + +### Task 1: Shared PHP Track Defaults Component + +**Files:** + +- Create: `crates/resources/src/php-defaults.ini` +- Create: `crates/resources/src/php_defaults.rs` +- Create: `crates/resources/tests/php_defaults.rs` +- Modify: `crates/resources/src/lib.rs` + +**Interfaces:** + +- Produces: `PHP_TRACK_DEFAULT_INI: &str` +- Produces: `PhpTrackDefaults { etc_dir, php_ini, conf_dir }` +- Produces: `php_track_defaults(paths: &PvPaths, track: &str) -> PhpTrackDefaults` +- Produces: `ensure_php_track_defaults(paths: &PvPaths, track: &str) -> Result` +- Produces: `php_track_environment(paths: &PvPaths, track: &str) -> BTreeMap` +- Produces: `php_track_exec_environment(paths: &PvPaths, track: &str) -> Vec<(OsString, OsString)>` +- Consumes: `state::PvPaths`, `state::fs` + +- [x] **Step 1: Write the failing integration tests** + +Create `crates/resources/tests/php_defaults.rs`: + +```rust +use std::collections::BTreeMap; + +use anyhow::Result; +use camino_tempfile::tempdir; +use resources::{ + PHP_TRACK_DEFAULT_INI, ensure_php_track_defaults, php_track_defaults, + php_track_environment, php_track_exec_environment, +}; +use state::{PvPaths, fs}; + +#[test] +fn php_track_defaults_seed_stripped_sample_once() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let defaults = ensure_php_track_defaults(&paths, "8.4")?; + let first_content = fs::read_to_string(defaults.php_ini())?; + + assert_eq!(defaults.etc_dir(), paths.resources().join("php/8.4/etc")); + assert_eq!(defaults.conf_dir(), paths.resources().join("php/8.4/etc/conf.d")); + assert_eq!(first_content, PHP_TRACK_DEFAULT_INI); + assert!(first_content.starts_with("[PHP]\nengine = On\n")); + assert!(first_content.contains("\n[Date]\n")); + assert!(first_content.contains("\nunserialize_callback_func =\n")); + assert!(!first_content.contains("; About php.ini")); + + fs::write_sensitive_file(defaults.php_ini(), "memory_limit = 768M\n")?; + let seeded_again = ensure_php_track_defaults(&paths, "8.4")?; + + assert_eq!(seeded_again, defaults); + assert_eq!(fs::read_to_string(defaults.php_ini())?, "memory_limit = 768M\n"); + + Ok(()) +} + +#[test] +fn php_track_defaults_reject_blocking_paths() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let defaults = php_track_defaults(&paths, "8.5"); + fs::ensure_user_dir(defaults.etc_dir())?; + fs::write_sensitive_file(defaults.conf_dir(), "not a directory\n")?; + + let error = match ensure_php_track_defaults(&paths, "8.5") { + Ok(_) => anyhow::bail!("expected blocking conf.d path to fail"), + Err(error) => error, + }; + + assert!( + error + .to_string() + .contains("PHP track defaults conf.d path is not a directory") + ); + + Ok(()) +} + +#[test] +fn php_track_defaults_env_helpers_point_at_track_etc() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + + assert_eq!( + php_track_environment(&paths, "8.3"), + BTreeMap::from([ + ( + "PHPRC".to_owned(), + paths.resources().join("php/8.3/etc").to_string(), + ), + ( + "PHP_INI_SCAN_DIR".to_owned(), + paths.resources().join("php/8.3/etc/conf.d").to_string(), + ), + ]) + ); + + let exec_env = php_track_exec_environment(&paths, "8.3") + .into_iter() + .map(|(key, value)| { + ( + key.to_string_lossy().into_owned(), + value.to_string_lossy().into_owned(), + ) + }) + .collect::>(); + + assert_eq!( + exec_env, + vec![ + ( + "PHPRC".to_owned(), + paths.resources().join("php/8.3/etc").to_string(), + ), + ( + "PHP_INI_SCAN_DIR".to_owned(), + paths.resources().join("php/8.3/etc/conf.d").to_string(), + ), + ] + ); + + Ok(()) +} +``` + +- [x] **Step 2: Run the tests to verify they fail** + +Run: + +```shell +cargo nextest run -p resources -E 'test(php_track_defaults_)' +``` + +Expected: FAIL because `resources::PHP_TRACK_DEFAULT_INI`, `ensure_php_track_defaults`, `php_track_defaults`, `php_track_environment`, and `php_track_exec_environment` do not exist. + +- [x] **Step 3: Add the stripped PHP defaults asset** + +Create `crates/resources/src/php-defaults.ini` with exactly: + +```ini +[PHP] +engine = On +short_open_tag = Off +precision = 14 +output_buffering = 4096 +zlib.output_compression = Off +implicit_flush = Off +unserialize_callback_func = +serialize_precision = -1 +disable_functions = +zend.enable_gc = On +zend.exception_ignore_args = Off +zend.exception_string_param_max_len = 15 +expose_php = On +max_execution_time = 30 +max_input_time = 60 +memory_limit = 1024M +max_memory_limit = -1 +error_reporting = E_ALL +display_errors = On +display_startup_errors = On +log_errors = On +ignore_repeated_errors = Off +ignore_repeated_source = Off +variables_order = "GPCS" +request_order = "GP" +auto_globals_jit = On +post_max_size = 128M +auto_prepend_file = +auto_append_file = +default_mimetype = "text/html" +default_charset = "UTF-8" +doc_root = +user_dir = +enable_dl = Off +file_uploads = On +upload_max_filesize = 128M +max_file_uploads = 20 +allow_url_fopen = On +allow_url_include = Off +default_socket_timeout = 60 +[CLI Server] +cli_server.color = On +[Date] +[filter] +[iconv] +[intl] +[sqlite3] +[Pcre] +[Pdo] +[Pdo_mysql] +pdo_mysql.default_socket= +[Phar] +[mail function] +SMTP = localhost +smtp_port = 25 +mail.add_x_header = Off +mail.mixed_lf_and_crlf = Off +mail.cr_lf_mode = crlf +[ODBC] +odbc.allow_persistent = On +odbc.check_persistent = On +odbc.max_persistent = -1 +odbc.max_links = -1 +odbc.defaultlrl = 4096 +odbc.defaultbinmode = 1 +[MySQLi] +mysqli.max_persistent = -1 +mysqli.allow_persistent = On +mysqli.max_links = -1 +mysqli.default_port = 3306 +mysqli.default_socket = +mysqli.default_host = +mysqli.default_user = +mysqli.default_pw = +[mysqlnd] +mysqlnd.collect_statistics = On +mysqlnd.collect_memory_statistics = On +[PostgreSQL] +pgsql.allow_persistent = On +pgsql.auto_reset_persistent = Off +pgsql.max_persistent = -1 +pgsql.max_links = -1 +pgsql.ignore_notice = 0 +pgsql.log_notice = 0 +[bcmath] +bcmath.scale = 0 +[browscap] +[Session] +session.save_handler = files +session.use_strict_mode = 0 +session.use_cookies = 1 +session.use_only_cookies = 1 +session.name = PHPSESSID +session.auto_start = 0 +session.cookie_lifetime = 0 +session.cookie_path = / +session.cookie_domain = +session.cookie_httponly = +session.cookie_samesite = +session.serialize_handler = php +session.gc_probability = 1 +session.gc_divisor = 1000 +session.gc_maxlifetime = 1440 +session.referer_check = +session.cache_limiter = nocache +session.cache_expire = 180 +session.use_trans_sid = 0 +session.trans_sid_tags = "a=href,area=href,frame=src,form=" +[Assertion] +zend.assertions = 1 +[COM] +[mbstring] +[gd] +[exif] +[Tidy] +tidy.clean_output = Off +[soap] +soap.wsdl_cache_enabled=1 +soap.wsdl_cache_dir="/tmp" +soap.wsdl_cache_ttl=86400 +soap.wsdl_cache_limit = 5 +[sysvshm] +[ldap] +ldap.max_links = -1 +[dba] +[opcache] +[curl] +[openssl] +[ffi] +``` + +- [x] **Step 4: Implement the defaults module** + +Create `crates/resources/src/php_defaults.rs`: + +```rust +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::io; + +use camino::{Utf8Path, Utf8PathBuf}; +use state::{PvPaths, StateError, fs}; + +pub const PHP_TRACK_DEFAULT_INI: &str = include_str!("php-defaults.ini"); + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PhpTrackDefaults { + etc_dir: Utf8PathBuf, + php_ini: Utf8PathBuf, + conf_dir: Utf8PathBuf, +} + +impl PhpTrackDefaults { + pub fn etc_dir(&self) -> &Utf8Path { + &self.etc_dir + } + + pub fn php_ini(&self) -> &Utf8Path { + &self.php_ini + } + + pub fn conf_dir(&self) -> &Utf8Path { + &self.conf_dir + } +} + +pub fn php_track_defaults(paths: &PvPaths, track: &str) -> PhpTrackDefaults { + let etc_dir = paths.resources().join("php").join(track).join("etc"); + let php_ini = etc_dir.join("php.ini"); + let conf_dir = etc_dir.join("conf.d"); + + PhpTrackDefaults { + etc_dir, + php_ini, + conf_dir, + } +} + +pub fn ensure_php_track_defaults( + paths: &PvPaths, + track: &str, +) -> Result { + let defaults = php_track_defaults(paths, track); + + fs::ensure_user_dir(defaults.etc_dir())?; + validate_existing_php_ini(&defaults)?; + validate_existing_conf_dir(&defaults)?; + + if !fs::path_entry_exists(defaults.conf_dir())? { + fs::ensure_user_dir(defaults.conf_dir())?; + } + if !fs::path_entry_exists(defaults.php_ini())? { + fs::write_sensitive_file(defaults.php_ini(), PHP_TRACK_DEFAULT_INI)?; + } + + Ok(defaults) +} + +pub fn php_track_environment(paths: &PvPaths, track: &str) -> BTreeMap { + let defaults = php_track_defaults(paths, track); + + BTreeMap::from([ + ("PHPRC".to_owned(), defaults.etc_dir().to_string()), + ( + "PHP_INI_SCAN_DIR".to_owned(), + defaults.conf_dir().to_string(), + ), + ]) +} + +pub fn php_track_exec_environment(paths: &PvPaths, track: &str) -> Vec<(OsString, OsString)> { + let defaults = php_track_defaults(paths, track); + + vec![ + ( + OsString::from("PHPRC"), + defaults.etc_dir().as_std_path().as_os_str().to_os_string(), + ), + ( + OsString::from("PHP_INI_SCAN_DIR"), + defaults.conf_dir().as_std_path().as_os_str().to_os_string(), + ), + ] +} + +fn validate_existing_php_ini(defaults: &PhpTrackDefaults) -> Result<(), StateError> { + if !fs::path_entry_exists(defaults.php_ini())? { + return Ok(()); + } + if !fs::path_is_file(defaults.php_ini())? { + return invalid_path( + defaults.php_ini(), + "PHP track defaults php.ini path is not a regular file", + ); + } + + fs::read_to_string(defaults.php_ini())?; + + Ok(()) +} + +fn validate_existing_conf_dir(defaults: &PhpTrackDefaults) -> Result<(), StateError> { + if !fs::path_entry_exists(defaults.conf_dir())? { + return Ok(()); + } + if !fs::path_is_directory(defaults.conf_dir())? { + return invalid_path( + defaults.conf_dir(), + "PHP track defaults conf.d path is not a directory", + ); + } + + Ok(()) +} + +fn invalid_path(path: &Utf8Path, reason: &'static str) -> Result { + Err(StateError::Filesystem { + path: path.to_path_buf(), + source: io::Error::new(io::ErrorKind::InvalidData, reason), + }) +} +``` + +- [x] **Step 5: Export the module** + +Modify `crates/resources/src/lib.rs`: + +```rust +pub mod php_defaults; +``` + +Add exports near the other `pub use` blocks: + +```rust +pub use php_defaults::{ + PHP_TRACK_DEFAULT_INI, PhpTrackDefaults, ensure_php_track_defaults, + php_track_defaults, php_track_environment, php_track_exec_environment, +}; +``` + +- [x] **Step 6: Run the component tests** + +Run: + +```shell +cargo nextest run -p resources -E 'test(php_track_defaults_)' +``` + +Expected: PASS. + +- [x] **Step 7: Commit** + +```shell +git add crates/resources/src/lib.rs crates/resources/src/php_defaults.rs crates/resources/src/php-defaults.ini crates/resources/tests/php_defaults.rs +git commit -m "feat(resources): add PHP track defaults" +``` + +--- + +### Task 2: Seed Defaults During PHP Pair Install And Update + +**Files:** + +- Modify: `crates/resources/src/command.rs` +- Modify: `crates/resources/tests/managed_resource_commands.rs` +- Test snapshots: `crates/resources/tests/snapshots/managed_resource_commands__*.snap` + +**Interfaces:** + +- Consumes: `ensure_php_track_defaults(paths: &PvPaths, track: &str) -> Result` from Task 1. +- Produces: PHP pair install/update records only after defaults are usable. + +- [x] **Step 1: Write failing install/update tests** + +Add these tests near existing PHP pair command tests in `crates/resources/tests/managed_resource_commands.rs`: + +```rust +#[test] +fn managed_resource_commands_install_php_pair_seeds_track_defaults() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let commands = + ManagedResourceCommands::new(paths.clone(), MANIFEST_URL, TargetPlatform::DarwinArm64); + let php_artifact = runtime_fixture_artifact("php", "8.4.8-pv1", "bin/php", "php 8.4")?; + let frankenphp_artifact = runtime_fixture_artifact( + "frankenphp", + "8.4.8-pv1", + "bin/frankenphp", + "frankenphp 8.4", + )?; + let manifest = manifest_with_resources(&[ + manifest_resource( + "php", + "8.4", + vec![manifest_track("8.4", vec![&php_artifact])], + ), + manifest_resource( + "frankenphp", + "8.4", + vec![manifest_track("8.4", vec![&frankenphp_artifact])], + ), + ]); + let client = ScriptedClient::new() + .with_text(&manifest) + .with_bytes(php_artifact.bytes()) + .with_bytes(frankenphp_artifact.bytes()); + + let installed = commands.install_php_pair(TrackSelector::Latest, &client)?; + let defaults = resources::php_track_defaults(&paths, installed.php().track().as_str()); + + assert_eq!( + state::fs::read_to_string(defaults.php_ini())?, + resources::PHP_TRACK_DEFAULT_INI + ); + assert!(state::fs::path_is_directory(defaults.conf_dir())?); + assert_debug_snapshot!(( + install_summary(installed.php(), tempdir.path())?, + install_summary(installed.frankenphp(), tempdir.path())?, + defaults.php_ini().strip_prefix(tempdir.path())?.to_string(), + defaults.conf_dir().strip_prefix(tempdir.path())?.to_string(), + )); + + Ok(()) +} + +#[test] +fn managed_resource_commands_update_php_pairs_preserves_existing_php_ini() -> Result<()> { + let tempdir = tempdir()?; + let paths = PvPaths::for_home(tempdir.path().join("home")); + let commands = + ManagedResourceCommands::new(paths.clone(), MANIFEST_URL, TargetPlatform::DarwinArm64); + let old_php = runtime_fixture_artifact("php", "8.4.7-pv1", "bin/php", "old php")?; + let old_frankenphp = + runtime_fixture_artifact("frankenphp", "8.4.7-pv1", "bin/frankenphp", "old fpm")?; + let new_php = runtime_fixture_artifact("php", "8.4.8-pv1", "bin/php", "new php")?; + let new_frankenphp = + runtime_fixture_artifact("frankenphp", "8.4.8-pv1", "bin/frankenphp", "new fpm")?; + let initial_manifest = manifest_with_resources(&[ + manifest_resource("php", "8.4", vec![manifest_track("8.4", vec![&old_php])]), + manifest_resource( + "frankenphp", + "8.4", + vec![manifest_track("8.4", vec![&old_frankenphp])], + ), + ]); + let updated_manifest = manifest_with_resources(&[ + manifest_resource( + "php", + "8.4", + vec![manifest_track("8.4", vec![&old_php, &new_php])], + ), + manifest_resource( + "frankenphp", + "8.4", + vec![manifest_track("8.4", vec![&old_frankenphp, &new_frankenphp])], + ), + ]); + let client = ScriptedClient::new() + .with_text(&initial_manifest) + .with_bytes(old_php.bytes()) + .with_bytes(old_frankenphp.bytes()) + .with_text(&updated_manifest) + .with_bytes(new_php.bytes()) + .with_bytes(new_frankenphp.bytes()); + + commands.install_php_pair(TrackSelector::Latest, &client)?; + let defaults = resources::php_track_defaults(&paths, "8.4"); + state::fs::write_sensitive_file(defaults.php_ini(), "memory_limit = 768M\n")?; + + let updated = commands.update_php_pairs(&client)?; + + assert_eq!( + state::fs::read_to_string(defaults.php_ini())?, + "memory_limit = 768M\n" + ); + assert_debug_snapshot!(update_summary(&updated, tempdir.path())?); + + Ok(()) +} +``` + +- [x] **Step 2: Run the tests to verify they fail** + +Run: + +```shell +cargo nextest run -p resources -E 'test(managed_resource_commands_install_php_pair_seeds_track_defaults) | test(managed_resource_commands_update_php_pairs_preserves_existing_php_ini)' +``` + +Expected: FAIL because `install_php_pair` and `update_php_pairs` prepare artifacts and state but do not seed `resources/php//etc`. + +- [x] **Step 3: Seed defaults before recording PHP pair installs** + +Modify `crates/resources/src/command.rs`. + +Add this helper inside `impl ManagedResourceCommands`: + +```rust + fn ensure_php_pair_defaults( + &self, + install: &PhpPairInstall, + ) -> ManagedResourceCommandResult<()> { + crate::php_defaults::ensure_php_track_defaults(&self.paths, install.php.track.as_str())?; + + Ok(()) + } +``` + +Update `record_php_pair_install`: + +```rust + fn record_php_pair_install( + &self, + install: &PhpPairInstall, + ) -> ManagedResourceCommandResult<()> { + self.ensure_php_pair_defaults(install)?; + + let mut database = Database::open(&self.paths)?; + database.record_managed_resource_tracks_desired_and_installed(&[ + ManagedResourceTrackInstallInput { + resource_name: install.php.resource_name.as_str(), + track: install.php.track.as_str(), + installed_version: install.php.artifact_version.as_str(), + current_artifact_path: &install.php.current_artifact_path, + }, + ManagedResourceTrackInstallInput { + resource_name: install.frankenphp.resource_name.as_str(), + track: install.frankenphp.track.as_str(), + installed_version: install.frankenphp.artifact_version.as_str(), + current_artifact_path: &install.frankenphp.current_artifact_path, + }, + ])?; + + Ok(()) + } +``` + +Update `record_composer_with_php_pair_install` so Composer installs also seed PHP defaults before state is recorded: + +```rust + fn record_composer_with_php_pair_install( + &self, + php_pair: &PhpPairInstall, + composer: &ManagedResourceInstall, + ) -> ManagedResourceCommandResult<()> { + self.ensure_php_pair_defaults(php_pair)?; + + let mut database = Database::open(&self.paths)?; + database.record_managed_resource_tracks_desired_and_installed(&[ + ManagedResourceTrackInstallInput { + resource_name: php_pair.php.resource_name.as_str(), + track: php_pair.php.track.as_str(), + installed_version: php_pair.php.artifact_version.as_str(), + current_artifact_path: &php_pair.php.current_artifact_path, + }, + ManagedResourceTrackInstallInput { + resource_name: php_pair.frankenphp.resource_name.as_str(), + track: php_pair.frankenphp.track.as_str(), + installed_version: php_pair.frankenphp.artifact_version.as_str(), + current_artifact_path: &php_pair.frankenphp.current_artifact_path, + }, + ManagedResourceTrackInstallInput { + resource_name: composer.resource_name.as_str(), + track: composer.track.as_str(), + installed_version: composer.artifact_version.as_str(), + current_artifact_path: &composer.current_artifact_path, + }, + ])?; + + Ok(()) + } +``` + +- [x] **Step 4: Run and accept focused snapshots** + +Run: + +```shell +cargo insta test --accept --test-runner nextest -p resources -- managed_resource_commands_install_php_pair_seeds_track_defaults +cargo insta test --accept --test-runner nextest -p resources -- managed_resource_commands_update_php_pairs_preserves_existing_php_ini +``` + +Expected: PASS and snapshot files are created or updated under `crates/resources/tests/snapshots/`. + +- [x] **Step 5: Commit** + +```shell +git add crates/resources/src/command.rs crates/resources/tests/managed_resource_commands.rs crates/resources/tests/snapshots +git commit -m "feat(resources): seed PHP track defaults on install" +``` + +--- + +### Task 3: Point CLI PHP And Composer At Track Defaults + +**Files:** + +- Modify: `crates/cli/src/commands/php.rs` +- Modify: `crates/cli/tests/php.rs` +- Modify: `crates/cli/tests/composer.rs` +- Test snapshots: `crates/cli/tests/snapshots/*.snap` if affected + +**Interfaces:** + +- Consumes: `ensure_php_track_defaults` and `php_track_exec_environment` from Task 1. +- Produces: PHP shim and Composer shim env entries using `resources/php//etc`, not `resources/php//releases//etc`. + +- [x] **Step 1: Write failing CLI PHP shim assertions** + +In `crates/cli/tests/php.rs`, replace the helper: + +```rust +fn php_exec_env(home: &Utf8Path, track: &str) -> Vec<(String, String)> { + let defaults = resources::php_track_defaults(&pv_paths(home), track); + + vec![ + ("PHPRC".to_string(), defaults.etc_dir().to_string()), + ( + "PHP_INI_SCAN_DIR".to_string(), + defaults.conf_dir().to_string(), + ), + ] +} +``` + +Update the `php_shim_sets_only_php_ini_env_overlay` assertion: + +```rust +assert_eq!( + exec_calls, + vec![ExecCall { + program: release.join("bin/php").as_std_path().to_path_buf(), + args: vec!["--ini".to_string()], + env: php_exec_env(&home, "8.4"), + }] +); +let defaults = resources::php_track_defaults(&pv_paths(&home), "8.4"); +assert_eq!( + state::fs::read_to_string(defaults.php_ini())?, + resources::PHP_TRACK_DEFAULT_INI +); +``` + +Update every `php_exec_env(&release)` call in `crates/cli/tests/php.rs` to `php_exec_env(&home, "")` with the concrete track from that test. + +- [x] **Step 2: Write failing Composer shim assertions** + +In `crates/cli/tests/composer.rs`, replace the helper: + +```rust +fn composer_exec_env(home: &Utf8Path, php_track: &str) -> Vec<(String, String)> { + let paths = pv_paths(home); + let defaults = resources::php_track_defaults(&paths, php_track); + + vec![ + ("COMPOSER_HOME".to_string(), paths.composer().to_string()), + ( + "COMPOSER_CACHE_DIR".to_string(), + paths.composer().join("cache").to_string(), + ), + ( + "PATH".to_string(), + format!("{}:{}", paths.bin(), paths.composer().join("vendor/bin")), + ), + ("PHPRC".to_string(), defaults.etc_dir().to_string()), + ( + "PHP_INI_SCAN_DIR".to_string(), + defaults.conf_dir().to_string(), + ), + ] +} +``` + +Update Composer expected calls: + +```rust +env: composer_exec_env(&home, "8.4"), +``` + +For `composer_shim_sets_pv_owned_env_overlay`, keep the explicit expected `PATH`, but replace the last two entries: + +```rust +let defaults = resources::php_track_defaults(&pv_paths(&home), "8.4"); +("PHPRC".to_string(), defaults.etc_dir().to_string()), +( + "PHP_INI_SCAN_DIR".to_string(), + defaults.conf_dir().to_string(), +), +``` + +- [x] **Step 3: Run the shim tests to verify they fail** + +Run: + +```shell +cargo nextest run -p cli -E 'test(php_shim_sets_only_php_ini_env_overlay) | test(composer_shim_execs_installed_phar_through_php_shim) | test(composer_shim_sets_pv_owned_env_overlay)' +``` + +Expected: FAIL because `crates/cli/src/commands/php.rs` still builds `PHPRC` from the artifact release path. + +- [x] **Step 4: Update PHP shim implementation** + +Modify `crates/cli/src/commands/php.rs`. + +In `shim_with_args_and_env`, replace: + +```rust + env.extend(php_env_overlay(&installed.release)); +``` + +with: + +```rust + resources::ensure_php_track_defaults(&paths, &track)?; + env.extend(resources::php_track_exec_environment(&paths, &track)); +``` + +Delete `fn php_env_overlay(release: &Utf8Path) -> Vec<(OsString, OsString)>`. + +Remove the now-unused `Utf8Path` import if it becomes unused: + +```rust +use camino::Utf8PathBuf; +``` + +Keep `InstalledPhp.release` because the executable path still comes from the installed artifact path. + +- [x] **Step 5: Run the focused CLI tests** + +Run: + +```shell +cargo nextest run -p cli -E 'test(php_shim_sets_only_php_ini_env_overlay) | test(composer_shim_execs_installed_phar_through_php_shim) | test(composer_shim_sets_pv_owned_env_overlay)' +``` + +Expected: PASS. + +- [x] **Step 6: Accept CLI snapshots if the filtered paths changed** + +Run: + +```shell +cargo insta test --accept --test-runner nextest -p cli -- php_shim_sets_only_php_ini_env_overlay +cargo insta test --accept --test-runner nextest -p cli -- composer_shim_execs_installed_phar_through_php_shim +cargo insta test --accept --test-runner nextest -p cli -- composer_shim_sets_pv_owned_env_overlay +``` + +Expected: PASS. Snapshot diffs should show `resources/php//etc` instead of `resources/php//releases//etc`. + +- [x] **Step 7: Commit** + +```shell +git add crates/cli/src/commands/php.rs crates/cli/tests/php.rs crates/cli/tests/composer.rs crates/cli/tests/snapshots +git commit -m "fix(cli): use PHP track defaults in shims" +``` + +--- + +### Task 4: Use Track Defaults For FrankenPHP Worker Validation And Runtime + +**Files:** + +- Modify: `crates/daemon/src/gateway.rs` +- Modify: `crates/daemon/tests/gateway_reconciliation.rs` +- Modify: `crates/daemon/tests/gateway_config.rs` only if snapshots need explicit no-`php_ini` assertions +- Test snapshots: `crates/daemon/tests/snapshots/*.snap` + +**Interfaces:** + +- Consumes: `ensure_php_track_defaults` and `php_track_environment` from Task 1. +- Produces: `worker_process_spec` includes `PHPRC` and `PHP_INI_SCAN_DIR`; `gateway_process_spec` does not. +- Produces: worker config validation receives the same private env as worker process startup. + +- [x] **Step 1: Write failing worker process spec assertions** + +In `crates/daemon/tests/gateway_reconciliation.rs`, update `frankenphp_command_and_process_specs_are_stable`: + +```rust + assert_eq!(gateway.private_environment.get("PHPRC"), None); + assert_eq!(gateway.private_environment.get("PHP_INI_SCAN_DIR"), None); + assert_eq!( + worker.private_environment.get("PHPRC").map(String::as_str), + Some(paths.resources().join("php/8.4/etc").as_str()) + ); + assert_eq!( + worker + .private_environment + .get("PHP_INI_SCAN_DIR") + .map(String::as_str), + Some(paths.resources().join("php/8.4/etc/conf.d").as_str()) + ); +``` + +- [x] **Step 2: Extend validation env coverage** + +In `frankenphp_config_validation_receives_xdg_environment`, add two observed files: + +```rust + let observed_phprc = tempdir.path().join("observed-phprc"); + let observed_scan_dir = tempdir.path().join("observed-scan-dir"); +``` + +Extend the fake validator script: + +```rust +printf '%s' "${PHPRC}" > {} +printf '%s' "${PHP_INI_SCAN_DIR}" > {} +``` + +Add both paths to the `format!` call using `shell_single_quoted`. + +Extend `private_environment`: + +```rust + ( + "PHPRC".to_owned(), + tempdir.path().join("php/etc").as_str().to_owned(), + ), + ( + "PHP_INI_SCAN_DIR".to_owned(), + tempdir.path().join("php/etc/conf.d").as_str().to_owned(), + ), +``` + +Assert both values after validation: + +```rust + assert_eq!( + state::fs::read_to_string(&observed_phprc)?, + tempdir.path().join("php/etc").to_string() + ); + assert_eq!( + state::fs::read_to_string(&observed_scan_dir)?, + tempdir.path().join("php/etc/conf.d").to_string() + ); +``` + +- [x] **Step 3: Run the daemon tests to verify they fail** + +Run: + +```shell +cargo nextest run -p daemon -E 'test(frankenphp_command_and_process_specs_are_stable) | test(frankenphp_config_validation_receives_xdg_environment)' +``` + +Expected: `frankenphp_command_and_process_specs_are_stable` fails because worker specs only contain XDG env today. The direct validation test may still pass because it supplies the env manually. + +- [x] **Step 4: Build worker and gateway private environment helpers** + +Modify `crates/daemon/src/gateway.rs`. + +Add: + +```rust +fn frankenphp_worker_environment(paths: &PvPaths, php_track: &str) -> BTreeMap { + let mut environment = frankenphp_xdg_environment(paths); + environment.extend(resources::php_track_environment(paths, php_track)); + environment +} +``` + +Update `worker_process_spec`: + +```rust + private_environment: frankenphp_worker_environment(paths, php_track), +``` + +Leave `gateway_process_spec` unchanged: + +```rust + private_environment: frankenphp_xdg_environment(paths), +``` + +- [x] **Step 5: Ensure defaults and validate worker configs with worker env** + +In `reconcile_worker_config`, after `subject` is created, add: + +```rust + if let Err(error) = resources::ensure_php_track_defaults(paths, &worker.php_track) { + let error = DaemonError::from(error); + record_runtime_error(paths, subject.clone(), &error)?; + + return Err(error); + } +``` + +Change `promote_runtime_config_tree` signature: + +```rust +async fn promote_runtime_config_tree( + paths: &PvPaths, + subject: RuntimeSubject, + config_path: Utf8PathBuf, + candidate_content: &str, + active_content: &str, + private_environment: BTreeMap, + promote_fragments: impl FnOnce() -> Result, + command: &FrankenphpCommand, +) -> Result { +``` + +In its validation closure, remove the local `frankenphp_xdg_environment(paths)` allocation: + +```rust + |candidate_path| { + let private_environment = private_environment.clone(); + + async move { validate_config(command, &candidate_path, &private_environment).await } + }, +``` + +Update the gateway caller: + +```rust + frankenphp_xdg_environment(paths), + || promote_config_dir(&active_dir, &candidate_dir), + command, +``` + +Update the worker caller: + +```rust + frankenphp_worker_environment(paths, &worker.php_track), + || promote_config_dir(&active_dir, &candidate_dir), + command, +``` + +- [x] **Step 6: Assert worker Caddyfile snapshots stay free of generated php_ini defaults** + +In `crates/daemon/tests/gateway_config.rs`, extend `worker_config_renderer_outputs_track_caddyfile`: + +```rust + let rendered = render_php_worker_config(&input)?; + + assert!(!rendered.contains("php_ini")); + assert_snapshot!(rendered); +``` + +- [x] **Step 7: Run and accept daemon snapshots** + +Run: + +```shell +cargo insta test --accept --test-runner nextest -p daemon -- frankenphp_command_and_process_specs_are_stable +cargo insta test --accept --test-runner nextest -p daemon -- worker_config_renderer_outputs_track_caddyfile +cargo nextest run -p daemon -E 'test(frankenphp_config_validation_receives_xdg_environment)' +``` + +Expected: PASS. The process spec snapshot should show redacted `PHPRC` and `PHP_INI_SCAN_DIR` keys for the PHP worker only. + +- [x] **Step 8: Commit** + +```shell +git add crates/daemon/src/gateway.rs crates/daemon/tests/gateway_reconciliation.rs crates/daemon/tests/gateway_config.rs crates/daemon/tests/snapshots +git commit -m "fix(daemon): pass PHP track defaults to workers" +``` + +--- + +### Task 5: Move PHP Artifact Fallback Ini Paths Away From /usr/local + +**Files:** + +- Modify: `release/artifacts/recipes/php/build.sh` +- Modify: `release/artifacts/recipes/php/smoke.sh` +- Modify: `crates/pv-release/tests/smoke.rs` +- Test snapshots or inline expected strings in `crates/pv-release/tests/smoke.rs` + +**Interfaces:** + +- Produces: StaticPHP build command includes `--with-config-file-path=/var/empty/com.prvious.pv/php`. +- Produces: StaticPHP build command includes `--with-config-file-scan-dir=/var/empty/com.prvious.pv/php/conf.d`. +- Produces: PHP smoke rejects `/usr/local/etc/php` in `php --ini` and FrankenPHP `phpinfo()`. + +- [x] **Step 1: Write failing StaticPHP argv assertion** + +In `crates/pv-release/tests/smoke.rs`, find the test that asserts `run.spc_log` for `php_build_recipe_smoke`. Add: + +```rust + assert!( + run.spc_log + .contains("[--with-config-file-path=/var/empty/com.prvious.pv/php]"), + "PHP recipe should set safe compiled php.ini fallback path: {}", + run.spc_log + ); + assert!( + run.spc_log + .contains("[--with-config-file-scan-dir=/var/empty/com.prvious.pv/php/conf.d]"), + "PHP recipe should set safe compiled php.ini scan fallback path: {}", + run.spc_log + ); + assert!( + !run.spc_log.contains("/usr/local/etc/php"), + "PHP recipe must not pass /usr/local/etc/php fallback paths: {}", + run.spc_log + ); +``` + +If the test uses an exact `assert_debug_snapshot!` for `run.spc_log`, update the expected argv to include: + +```text +[--with-config-file-path=/var/empty/com.prvious.pv/php][--with-config-file-scan-dir=/var/empty/com.prvious.pv/php/conf.d] +``` + +- [x] **Step 2: Run the failing recipe test** + +Run: + +```shell +cargo nextest run -p pv-release -E 'test(php_build_recipe_smoke)' +``` + +Expected: FAIL because `release/artifacts/recipes/php/build.sh` does not pass safe config-file path flags. + +- [x] **Step 3: Add safe fallback flags to the build script** + +Modify the `spc build:php` invocation in `release/artifacts/recipes/php/build.sh`: + +```sh + spc build:php "$PHP_BUILD_EXTENSIONS" \ + --build-cli \ + --build-frankenphp \ + --enable-zts \ + --with-config-file-path=/var/empty/com.prvious.pv/php \ + --with-config-file-scan-dir=/var/empty/com.prvious.pv/php/conf.d \ + --dl-with-php="$PHP_PHP_VERSION" \ + --dl-retry=3 \ + --dl-custom-local "php-src:$php_source_dir" \ + --dl-custom-local "frankenphp:$frankenphp_source_dir" +``` + +- [x] **Step 4: Update real smoke checks** + +Modify `release/artifacts/recipes/php/smoke.sh`. + +For standalone PHP, after the extension check, add: + +```sh + if "$php_binary" --ini 2>&1 | grep -F '/usr/local/etc/php' >/dev/null; then + printf '%s\n' "PHP artifact reports unsafe /usr/local/etc/php ini fallback" >&2 + exit 46 + fi +``` + +For FrankenPHP, after the extension check, create the smoke `index.php` with `phpinfo()` content that can expose loaded config paths: + +```sh + cat >"$site_dir/index.php" <<'PHP' +/dev/null; then + if printf '%s' "$response" | grep -F '/usr/local/etc/php' >/dev/null; then + printf '%s\n' "FrankenPHP artifact reports unsafe /usr/local/etc/php ini fallback" >&2 + exit 46 + fi + exit 0 + fi +``` + +- [x] **Step 5: Run focused recipe checks** + +Run: + +```shell +cargo nextest run -p pv-release -E 'test(php_build_recipe_smoke)' +sh -n release/artifacts/recipes/php/build.sh +sh -n release/artifacts/recipes/php/smoke.sh +``` + +Expected: PASS. + +If `shellcheck` is installed, also run: + +```shell +shellcheck release/artifacts/recipes/php/build.sh release/artifacts/recipes/php/smoke.sh +``` + +Expected: PASS. + +- [x] **Step 6: Commit** + +```shell +git add release/artifacts/recipes/php/build.sh release/artifacts/recipes/php/smoke.sh crates/pv-release/tests/smoke.rs +git commit -m "fix(release): use safe PHP ini fallback paths" +``` + +--- + +### Task 6: Documentation And End-To-End Verification + +**Files:** + +- Modify: `DESIGN.md` +- Modify: `docs/2026-06-20-php-track-defaults-design.md` only if implementation discovers a correction +- Modify: `docs/superpowers/plans/2026-06-21-php-track-defaults.md` checkboxes as tasks are completed + +**Interfaces:** + +- Consumes: behavior implemented in Tasks 1-5. +- Produces: project design docs aligned with implemented behavior. + +- [x] **Step 1: Update DESIGN.md** + +Add a paragraph after the existing multi-version PHP ini statements around `DESIGN.md`'s Multi-version PHP section: + +```markdown +For each installed PHP track, PV seeds track-level PHP defaults under `~/.pv/resources/php//etc/php.ini` and `~/.pv/resources/php//etc/conf.d/`. The defaults are mutable track data, not artifact release payload data, so artifact updates and old-release pruning do not remove user edits. PV runs standalone PHP, Composer-through-PHP, and Project-serving FrankenPHP workers with process-level `PHPRC` and `PHP_INI_SCAN_DIR` pointing at the track defaults. PV does not pass these ini discovery paths through Caddyfile `env` and does not expand the default profile into Caddyfile `php_ini` directives. +``` + +- [x] **Step 2: Run focused test suites** + +Run: + +```shell +cargo nextest run -p resources -E 'test(php_track_defaults_) | test(managed_resource_commands_install_php_pair_seeds_track_defaults) | test(managed_resource_commands_update_php_pairs_preserves_existing_php_ini)' +cargo nextest run -p cli -E 'test(php_shim_sets_only_php_ini_env_overlay) | test(composer_shim_execs_installed_phar_through_php_shim) | test(composer_shim_sets_pv_owned_env_overlay)' +cargo nextest run -p daemon -E 'test(frankenphp_command_and_process_specs_are_stable) | test(frankenphp_config_validation_receives_xdg_environment) | test(worker_config_renderer_outputs_track_caddyfile)' +cargo nextest run -p pv-release -E 'test(php_build_recipe_smoke)' +``` + +Expected: PASS. + +- [x] **Step 3: Run formatting and diff checks** + +Run: + +```shell +cargo fmt --all -- --check +git diff --check +``` + +Expected: PASS. + +- [x] **Step 4: Run clippy if local prerequisites are available** + +Run: + +```shell +cargo clippy --workspace --all-targets --all-features --locked -- -D warnings +``` + +Expected: PASS. If `shellcheck` or `cargo-shear` prerequisites are missing in the local environment, record that in the final handoff instead of treating it as a code failure. + +- [x] **Step 5: Inspect final diff for scope** + +Run: + +```shell +git status --short +git diff --stat +git diff -- docs/2026-06-20-php-track-defaults-design.md DESIGN.md +``` + +Expected: only PHP defaults, artifact fallback, test, snapshot, and documentation changes are present. The root `php.ini` sample may remain untracked if it was not intentionally added. + +- [x] **Step 6: Commit** + +```shell +git add DESIGN.md docs/superpowers/plans/2026-06-21-php-track-defaults.md +git commit -m "docs: document PHP track defaults" +``` + +--- + +## Final Verification Before Handoff + +- [x] Run the focused commands from Task 6 Step 2. +- [x] Run `cargo fmt --all -- --check`. +- [x] Run `git diff --check`. +- [x] Run `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` when local prerequisites are installed. +- [x] Confirm snapshots were accepted intentionally. +- [x] Confirm no code path uses `/usr/local/etc/php` except historical docs/spec discussion or tests proving it is absent. +- [x] Confirm `php.ini` remains seed-only and existing files are preserved. +- [x] Confirm Gateway specs do not receive `PHPRC` or `PHP_INI_SCAN_DIR`. +- [x] Confirm worker validation and worker runtime specs use identical PHP track env values. + +## Self-Review Notes + +- Spec coverage: Tasks 1-4 cover track defaults, seed-only behavior, CLI/Composer/worker env, and no Caddyfile `php_ini`; Task 5 covers artifact fallback and smoke checks; Task 6 covers `DESIGN.md`. +- Scope: single implementation plan is appropriate because all tasks are coupled through one PHP defaults behavior and are independently testable. +- Ambiguity resolved: Caddyfile `env` is not used for `PHPRC` or `PHP_INI_SCAN_DIR`; process environment is the only runtime path. From 84d0a22506619bfc2848129d7c94a173cc30774a Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sun, 21 Jun 2026 13:47:42 -0400 Subject: [PATCH 13/15] fix(daemon): strip inherited php ini env --- crates/daemon/src/gateway.rs | 6 +- crates/daemon/src/supervisor.rs | 4 + crates/daemon/tests/gateway_reconciliation.rs | 143 ++++++++++++++++++ crates/daemon/tests/supervisor_foundation.rs | 96 ++++++++++++ 4 files changed, 248 insertions(+), 1 deletion(-) diff --git a/crates/daemon/src/gateway.rs b/crates/daemon/src/gateway.rs index 0d046d07..61cf7bd9 100644 --- a/crates/daemon/src/gateway.rs +++ b/crates/daemon/src/gateway.rs @@ -35,6 +35,7 @@ use crate::{DaemonError, ProcessSpec, ProcessSupervisor, ReadinessCheck, wait_fo )] type FrankenphpProcessCommand = tokio::process::Command; +const PHP_INI_ENVIRONMENT_KEYS: [&str; 2] = ["PHPRC", "PHP_INI_SCAN_DIR"]; const CONFIG_VALIDATION_TIMEOUT: Duration = Duration::from_secs(10); const RUNTIME_READINESS_TIMEOUT: Duration = Duration::from_secs(60); const FOREIGN_LISTENER_PROBE_TIMEOUT: Duration = Duration::from_millis(100); @@ -327,10 +328,13 @@ async fn run_validation_command( let mut command_process = FrankenphpProcessCommand::new(command.executable()); command_process .args(command.validate_arguments(config_path)) - .envs(private_environment) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); + for key in PHP_INI_ENVIRONMENT_KEYS { + command_process.env_remove(key); + } + command_process.envs(private_environment); #[cfg(unix)] command_process.process_group(0); diff --git a/crates/daemon/src/supervisor.rs b/crates/daemon/src/supervisor.rs index 41876e69..a71f56f9 100644 --- a/crates/daemon/src/supervisor.rs +++ b/crates/daemon/src/supervisor.rs @@ -24,6 +24,7 @@ const READINESS_POLL_INTERVAL: Duration = Duration::from_millis(25); const READINESS_PROBE_TIMEOUT: Duration = Duration::from_secs(1); const PRIVATE_ENVIRONMENT_REDACTION: &str = ""; const PRIVATE_ENVIRONMENT_FINGERPRINT_PREFIX: &str = "sha256:v1:"; +const PHP_INI_ENVIRONMENT_KEYS: [&str; 2] = ["PHPRC", "PHP_INI_SCAN_DIR"]; #[expect( clippy::disallowed_types, @@ -610,6 +611,9 @@ async fn wait_for_process_group_exit( fn process_command(spec: &ProcessSpec) -> tokio::process::Command { let mut command = tokio::process::Command::new(&spec.command); command.args(&spec.arguments); + for key in PHP_INI_ENVIRONMENT_KEYS { + command.env_remove(key); + } command.envs(&spec.private_environment); #[cfg(unix)] command.process_group(0); diff --git a/crates/daemon/tests/gateway_reconciliation.rs b/crates/daemon/tests/gateway_reconciliation.rs index f5ed1708..d4c4a165 100644 --- a/crates/daemon/tests/gateway_reconciliation.rs +++ b/crates/daemon/tests/gateway_reconciliation.rs @@ -16,12 +16,20 @@ use state::{ RUNTIME_PORT_FALLBACK_END, RUNTIME_PORT_FALLBACK_START, fs, }; use std::collections::BTreeMap; +use std::ffi::OsString; use std::net::TcpListener; +use std::process::Output; use std::time::Duration; use tokio::time::{sleep, timeout}; const GATEWAY_RECONCILIATION_SUMMARY: &str = "Gateway runtime reconciled"; +#[expect( + clippy::disallowed_types, + reason = "regression tests spawn a nested test process to control inherited env without unsafe mutation" +)] +type TestProcessCommand = std::process::Command; + #[tokio::test] async fn gateway_reconciliation_starts_gateway_and_one_worker_per_php_track() -> Result<()> { let tempdir = tempdir()?; @@ -1470,6 +1478,108 @@ exit 0 Ok(()) } +#[tokio::test] +async fn gateway_config_validation_strips_parent_php_ini_env_when_private_env_omits_it() +-> Result<()> { + let tempdir = tempdir()?; + let output = run_ignored_test_with_parent_php_ini_env( + "gateway_config_validation_strips_parent_php_ini_env_inner", + tempdir.path(), + )?; + + assert_nested_test_succeeded(output) +} + +#[tokio::test] +#[ignore] +async fn gateway_config_validation_strips_parent_php_ini_env_inner() -> Result<()> { + let root = Utf8Path::new("."); + let validator = root.join("env-validator"); + let config_path = root.join("Caddyfile"); + let observed_phprc = root.join("observed-phprc"); + let observed_scan_dir = root.join("observed-scan-dir"); + fs::write_sensitive_file( + &validator, + &format!( + r#"#!/bin/sh +set -eu +printf '%s' "${{PHPRC-}}" > {} +printf '%s' "${{PHP_INI_SCAN_DIR-}}" > {} +exit 0 +"#, + shell_single_quoted(observed_phprc.as_str()), + shell_single_quoted(observed_scan_dir.as_str()), + ), + )?; + set_executable(&validator)?; + fs::write_sensitive_file(&config_path, "{}\n")?; + let command = FrankenphpCommand::new(&validator); + let paths = PvPaths::for_home(root.join("home")); + let private_environment = gateway_process_spec(&paths, &command).private_environment; + + validate_config(&command, &config_path, &private_environment).await?; + + assert_eq!(state::testing::read_to_string(&observed_phprc)?, ""); + assert_eq!(state::testing::read_to_string(&observed_scan_dir)?, ""); + + Ok(()) +} + +#[tokio::test] +async fn worker_config_validation_keeps_private_php_ini_env_after_parent_removal() -> Result<()> { + let tempdir = tempdir()?; + let output = run_ignored_test_with_parent_php_ini_env( + "worker_config_validation_keeps_private_php_ini_env_after_parent_removal_inner", + tempdir.path(), + )?; + + assert_nested_test_succeeded(output) +} + +#[tokio::test] +#[ignore] +async fn worker_config_validation_keeps_private_php_ini_env_after_parent_removal_inner() +-> Result<()> { + let root = Utf8Path::new("."); + let validator = root.join("env-validator"); + let config_path = root.join("Caddyfile"); + let observed_phprc = root.join("observed-phprc"); + let observed_scan_dir = root.join("observed-scan-dir"); + fs::write_sensitive_file( + &validator, + &format!( + r#"#!/bin/sh +set -eu +printf '%s' "${{PHPRC-}}" > {} +printf '%s' "${{PHP_INI_SCAN_DIR-}}" > {} +exit 0 +"#, + shell_single_quoted(observed_phprc.as_str()), + shell_single_quoted(observed_scan_dir.as_str()), + ), + )?; + set_executable(&validator)?; + fs::write_sensitive_file(&config_path, "{}\n")?; + let command = FrankenphpCommand::new(&validator); + let paths = PvPaths::for_home(root.join("home")); + let expected_phprc = paths.resources().join("php/8.4/etc").to_string(); + let expected_scan_dir = paths.resources().join("php/8.4/etc/conf.d").to_string(); + let private_environment = worker_process_spec(&paths, "8.4", &command)?.private_environment; + + validate_config(&command, &config_path, &private_environment).await?; + + assert_eq!( + state::testing::read_to_string(&observed_phprc)?, + expected_phprc + ); + assert_eq!( + state::testing::read_to_string(&observed_scan_dir)?, + expected_scan_dir + ); + + Ok(()) +} + fn create_project(project_root: &Utf8Path, config_source: &str) -> Result<()> { fs::write_sensitive_file(&project_root.join("public/index.php"), " String { format!("'{}'", value.replace('\'', "'\"'\"'")) } +fn run_ignored_test_with_parent_php_ini_env( + test_name: &str, + working_dir: &Utf8Path, +) -> Result { + let mut command = TestProcessCommand::new(current_test_binary()?); + command + .args(["--exact", test_name, "--ignored", "--nocapture"]) + .current_dir(working_dir) + .env("PHPRC", "parent-phprc") + .env("PHP_INI_SCAN_DIR", "parent-scan-dir"); + + Ok(command.output()?) +} + +fn current_test_binary() -> Result { + std::env::args_os() + .next() + .ok_or_else(|| anyhow::anyhow!("test binary path was missing")) +} + +fn assert_nested_test_succeeded(output: Output) -> Result<()> { + if output.status.success() { + return Ok(()); + } + + anyhow::bail!( + "nested test failed: status={}; stdout={}; stderr={}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + fn seed_stable_runtime_plan_ports(database: &mut Database, php_tracks: &[&str]) -> Result<()> { database.assign_gateway_ports(|_port| true)?; diff --git a/crates/daemon/tests/supervisor_foundation.rs b/crates/daemon/tests/supervisor_foundation.rs index b04ee5db..2fc5a9cd 100644 --- a/crates/daemon/tests/supervisor_foundation.rs +++ b/crates/daemon/tests/supervisor_foundation.rs @@ -1,4 +1,6 @@ use std::collections::BTreeMap; +use std::ffi::OsString; +use std::process::Output; use std::sync::Arc; use std::time::Duration; @@ -18,6 +20,12 @@ use tokio::net::TcpListener; use tokio::time::{sleep, timeout}; use tokio_rustls::TlsAcceptor; +#[expect( + clippy::disallowed_types, + reason = "regression tests spawn a nested test process to control inherited env without unsafe mutation" +)] +type TestProcessCommand = std::process::Command; + #[tokio::test] async fn tcp_readiness_succeeds_for_listening_ports_and_times_out() -> Result<()> { let listener = TcpListener::bind(("127.0.0.1", 0)).await?; @@ -493,6 +501,57 @@ async fn supervisor_rejects_owned_runtime_when_private_environment_changes() -> Ok(()) } +#[tokio::test] +async fn supervisor_start_strips_parent_php_ini_env_when_private_env_omits_it() -> Result<()> { + let tempdir = tempdir()?; + let output = run_ignored_test_with_parent_php_ini_env( + "supervisor_start_strips_parent_php_ini_env_inner", + tempdir.path(), + )?; + + assert_nested_test_succeeded(output) +} + +#[tokio::test] +#[ignore] +async fn supervisor_start_strips_parent_php_ini_env_inner() -> Result<()> { + let root = Utf8Path::new("."); + let paths = PvPaths::for_home(root.join("home")); + state::fs::ensure_layout(&paths)?; + let runtime = root.join("env-runtime"); + let ready = root.join("runtime-ready"); + let observed_phprc = root.join("observed-phprc"); + let observed_scan_dir = root.join("observed-scan-dir"); + state::fs::write_sensitive_file( + &runtime, + &format!( + r#"#!/bin/sh +set -eu +printf '%s' "${{PHPRC-}}" > {} +printf '%s' "${{PHP_INI_SCAN_DIR-}}" > {} +touch {} +while true; do sleep 1; done +"#, + shell_single_quoted(observed_phprc.as_str()), + shell_single_quoted(observed_scan_dir.as_str()), + shell_single_quoted(ready.as_str()), + ), + )?; + set_executable(&runtime)?; + let spec = process_spec(&paths, "env-runtime", runtime.clone(), Vec::new()); + let process = ProcessSupervisor::new(paths).start(spec).await?; + + wait_for_path(&ready).await?; + let phprc = state::testing::read_to_string(&observed_phprc)?; + let scan_dir = state::testing::read_to_string(&observed_scan_dir)?; + process.stop(Duration::from_secs(1)).await?; + + assert_eq!(phprc, ""); + assert_eq!(scan_dir, ""); + + Ok(()) +} + #[tokio::test] async fn supervisor_sends_reload_signal_to_owned_runtime() -> Result<()> { let tempdir = tempdir()?; @@ -842,6 +901,39 @@ fn with_normalized_process_values(assertion: impl FnOnce() -> Result<()>) -> Res Settings::clone_current().bind(assertion) } +fn run_ignored_test_with_parent_php_ini_env( + test_name: &str, + working_dir: &Utf8Path, +) -> Result { + let mut command = TestProcessCommand::new(current_test_binary()?); + command + .args(["--exact", test_name, "--ignored", "--nocapture"]) + .current_dir(working_dir) + .env("PHPRC", "parent-phprc") + .env("PHP_INI_SCAN_DIR", "parent-scan-dir"); + + Ok(command.output()?) +} + +fn current_test_binary() -> Result { + std::env::args_os() + .next() + .ok_or_else(|| anyhow!("test binary path was missing")) +} + +fn assert_nested_test_succeeded(output: Output) -> Result<()> { + if output.status.success() { + return Ok(()); + } + + anyhow::bail!( + "nested test failed: status={}; stdout={}; stderr={}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + fn process_spec( paths: &PvPaths, name: &str, @@ -861,3 +953,7 @@ fn process_spec( track: "test".to_string(), } } + +fn shell_single_quoted(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} From 012539b4ddaff12d94acae510330f2817d560ffe Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sun, 21 Jun 2026 16:35:42 -0400 Subject: [PATCH 14/15] fix(release): bump PHP artifact revision --- crates/pv-release/tests/recipe_fixtures.rs | 24 ++-- crates/pv-release/tests/recipe_metadata.rs | 2 +- ...lidates_archives_records_and_manifest.snap | 108 +++++++++--------- release/artifacts/recipes/php/tracks.toml | 2 +- 4 files changed, 68 insertions(+), 68 deletions(-) diff --git a/crates/pv-release/tests/recipe_fixtures.rs b/crates/pv-release/tests/recipe_fixtures.rs index 6f0e77a7..ea622da1 100644 --- a/crates/pv-release/tests/recipe_fixtures.rs +++ b/crates/pv-release/tests/recipe_fixtures.rs @@ -80,37 +80,37 @@ fn recipe_fixture_generation_validates_archives_records_and_manifest() -> Result "frankenphp", "8.3", "darwin-amd64", - "frankenphp-8.3.31-frankenphp1.12.4-pv2-darwin-amd64", + "frankenphp-8.3.31-frankenphp1.12.4-pv3-darwin-amd64", ), ArchiveRoot::new( "frankenphp", "8.3", "darwin-arm64", - "frankenphp-8.3.31-frankenphp1.12.4-pv2-darwin-arm64", + "frankenphp-8.3.31-frankenphp1.12.4-pv3-darwin-arm64", ), ArchiveRoot::new( "frankenphp", "8.4", "darwin-amd64", - "frankenphp-8.4.22-frankenphp1.12.4-pv2-darwin-amd64", + "frankenphp-8.4.22-frankenphp1.12.4-pv3-darwin-amd64", ), ArchiveRoot::new( "frankenphp", "8.4", "darwin-arm64", - "frankenphp-8.4.22-frankenphp1.12.4-pv2-darwin-arm64", + "frankenphp-8.4.22-frankenphp1.12.4-pv3-darwin-arm64", ), ArchiveRoot::new( "frankenphp", "8.5", "darwin-amd64", - "frankenphp-8.5.7-frankenphp1.12.4-pv2-darwin-amd64", + "frankenphp-8.5.7-frankenphp1.12.4-pv3-darwin-amd64", ), ArchiveRoot::new( "frankenphp", "8.5", "darwin-arm64", - "frankenphp-8.5.7-frankenphp1.12.4-pv2-darwin-arm64", + "frankenphp-8.5.7-frankenphp1.12.4-pv3-darwin-arm64", ), ArchiveRoot::new( "mailpit", @@ -160,12 +160,12 @@ fn recipe_fixture_generation_validates_archives_records_and_manifest() -> Result "darwin-arm64", "mysql-9.7.0-pv1-darwin-arm64" ), - ArchiveRoot::new("php", "8.3", "darwin-amd64", "php-8.3.31-pv2-darwin-amd64"), - ArchiveRoot::new("php", "8.3", "darwin-arm64", "php-8.3.31-pv2-darwin-arm64"), - ArchiveRoot::new("php", "8.4", "darwin-amd64", "php-8.4.22-pv2-darwin-amd64"), - ArchiveRoot::new("php", "8.4", "darwin-arm64", "php-8.4.22-pv2-darwin-arm64"), - ArchiveRoot::new("php", "8.5", "darwin-amd64", "php-8.5.7-pv2-darwin-amd64"), - ArchiveRoot::new("php", "8.5", "darwin-arm64", "php-8.5.7-pv2-darwin-arm64"), + ArchiveRoot::new("php", "8.3", "darwin-amd64", "php-8.3.31-pv3-darwin-amd64"), + ArchiveRoot::new("php", "8.3", "darwin-arm64", "php-8.3.31-pv3-darwin-arm64"), + ArchiveRoot::new("php", "8.4", "darwin-amd64", "php-8.4.22-pv3-darwin-amd64"), + ArchiveRoot::new("php", "8.4", "darwin-arm64", "php-8.4.22-pv3-darwin-arm64"), + ArchiveRoot::new("php", "8.5", "darwin-amd64", "php-8.5.7-pv3-darwin-amd64"), + ArchiveRoot::new("php", "8.5", "darwin-arm64", "php-8.5.7-pv3-darwin-arm64"), ArchiveRoot::new( "postgres", "17", diff --git a/crates/pv-release/tests/recipe_metadata.rs b/crates/pv-release/tests/recipe_metadata.rs index bce0e96c..00d194eb 100644 --- a/crates/pv-release/tests/recipe_metadata.rs +++ b/crates/pv-release/tests/recipe_metadata.rs @@ -114,7 +114,7 @@ fn committed_recipe_metadata_parses() -> Result<()> { ManifestDefaults::load(&workspace_root.join("release/artifacts/default-tracks.toml"))?; assert_eq!(php.default_track().as_str(), "8.5"); - assert_eq!(php.pv_build_revision(), "pv2"); + assert_eq!(php.pv_build_revision(), "pv3"); assert_eq!(php.tracks().len(), 3); assert_eq!( php.tracks() diff --git a/crates/pv-release/tests/snapshots/recipe_fixtures__recipe_fixture_generation_validates_archives_records_and_manifest.snap b/crates/pv-release/tests/snapshots/recipe_fixtures__recipe_fixture_generation_validates_archives_records_and_manifest.snap index 296846ec..c7e79d58 100644 --- a/crates/pv-release/tests/snapshots/recipe_fixtures__recipe_fixture_generation_validates_archives_records_and_manifest.snap +++ b/crates/pv-release/tests/snapshots/recipe_fixtures__recipe_fixture_generation_validates_archives_records_and_manifest.snap @@ -42,12 +42,12 @@ expression: manifest_json "name": "8.3", "artifacts": [ { - "artifact_version": "8.3.31-frankenphp1.12.4-pv2", + "artifact_version": "8.3.31-frankenphp1.12.4-pv3", "upstream_version": "8.3.31-frankenphp1.12.4", - "pv_build_revision": "pv2", + "pv_build_revision": "pv3", "platform": "darwin-amd64", - "url": "https://artifacts.example.test/resources/frankenphp/8.3/8.3.31-frankenphp1.12.4-pv2/darwin-amd64/frankenphp-8.3.31-frankenphp1.12.4-pv2-darwin-amd64.tar.gz", - "sha256": "1df0e75148ae2b60bc40d785c68ac5d96e3a5444a003e54102c80262900f4cf6", + "url": "https://artifacts.example.test/resources/frankenphp/8.3/8.3.31-frankenphp1.12.4-pv3/darwin-amd64/frankenphp-8.3.31-frankenphp1.12.4-pv3-darwin-amd64.tar.gz", + "sha256": "c7f85e34ed1e4cb07acdeea9771ce2044ff0fc65d660e57fa55b61398819a619", "size": 219, "published_at": "2026-01-01T00:00:00Z", "provenance": { @@ -71,13 +71,13 @@ expression: manifest_json } }, { - "artifact_version": "8.3.31-frankenphp1.12.4-pv2", + "artifact_version": "8.3.31-frankenphp1.12.4-pv3", "upstream_version": "8.3.31-frankenphp1.12.4", - "pv_build_revision": "pv2", + "pv_build_revision": "pv3", "platform": "darwin-arm64", - "url": "https://artifacts.example.test/resources/frankenphp/8.3/8.3.31-frankenphp1.12.4-pv2/darwin-arm64/frankenphp-8.3.31-frankenphp1.12.4-pv2-darwin-arm64.tar.gz", - "sha256": "4119ecdb50bafe509e113c480d9062614c5eefe6b73b3fc44bfb8f7ea9523989", - "size": 218, + "url": "https://artifacts.example.test/resources/frankenphp/8.3/8.3.31-frankenphp1.12.4-pv3/darwin-arm64/frankenphp-8.3.31-frankenphp1.12.4-pv3-darwin-arm64.tar.gz", + "sha256": "cd06f096193c6d94ee0e198134421eee59161ccf550faa5e30e2bc0af298fdf6", + "size": 219, "published_at": "2026-01-01T00:00:00Z", "provenance": { "source_url": "https://github.com/php/frankenphp/archive/refs/tags/v1.12.4.tar.gz", @@ -105,13 +105,13 @@ expression: manifest_json "name": "8.4", "artifacts": [ { - "artifact_version": "8.4.22-frankenphp1.12.4-pv2", + "artifact_version": "8.4.22-frankenphp1.12.4-pv3", "upstream_version": "8.4.22-frankenphp1.12.4", - "pv_build_revision": "pv2", + "pv_build_revision": "pv3", "platform": "darwin-amd64", - "url": "https://artifacts.example.test/resources/frankenphp/8.4/8.4.22-frankenphp1.12.4-pv2/darwin-amd64/frankenphp-8.4.22-frankenphp1.12.4-pv2-darwin-amd64.tar.gz", - "sha256": "ba4b7ad0187b27d8054b9d8bc483ab07b4c59b0c22a83047d5f8e1af1f6ea979", - "size": 218, + "url": "https://artifacts.example.test/resources/frankenphp/8.4/8.4.22-frankenphp1.12.4-pv3/darwin-amd64/frankenphp-8.4.22-frankenphp1.12.4-pv3-darwin-amd64.tar.gz", + "sha256": "a6bf15a2529d5547b6e495e567a9edc752b7ccfe2c65c5cf7f5d6a565016b76c", + "size": 219, "published_at": "2026-01-01T00:00:00Z", "provenance": { "source_url": "https://github.com/php/frankenphp/archive/refs/tags/v1.12.4.tar.gz", @@ -134,12 +134,12 @@ expression: manifest_json } }, { - "artifact_version": "8.4.22-frankenphp1.12.4-pv2", + "artifact_version": "8.4.22-frankenphp1.12.4-pv3", "upstream_version": "8.4.22-frankenphp1.12.4", - "pv_build_revision": "pv2", + "pv_build_revision": "pv3", "platform": "darwin-arm64", - "url": "https://artifacts.example.test/resources/frankenphp/8.4/8.4.22-frankenphp1.12.4-pv2/darwin-arm64/frankenphp-8.4.22-frankenphp1.12.4-pv2-darwin-arm64.tar.gz", - "sha256": "bfcc17ef6ceca9fe4e95c6f5db618949da43fd12bb01d2d2ee55b15ce045bd98", + "url": "https://artifacts.example.test/resources/frankenphp/8.4/8.4.22-frankenphp1.12.4-pv3/darwin-arm64/frankenphp-8.4.22-frankenphp1.12.4-pv3-darwin-arm64.tar.gz", + "sha256": "ed82266b245c0412999b930137f10ee19159dd956c6989ae9cf941fa006ccbf5", "size": 219, "published_at": "2026-01-01T00:00:00Z", "provenance": { @@ -168,13 +168,13 @@ expression: manifest_json "name": "8.5", "artifacts": [ { - "artifact_version": "8.5.7-frankenphp1.12.4-pv2", + "artifact_version": "8.5.7-frankenphp1.12.4-pv3", "upstream_version": "8.5.7-frankenphp1.12.4", - "pv_build_revision": "pv2", + "pv_build_revision": "pv3", "platform": "darwin-amd64", - "url": "https://artifacts.example.test/resources/frankenphp/8.5/8.5.7-frankenphp1.12.4-pv2/darwin-amd64/frankenphp-8.5.7-frankenphp1.12.4-pv2-darwin-amd64.tar.gz", - "sha256": "8981aa4fd980eadd41319aa27b345ba88e7372690b32abe919fc97ed44b3b27f", - "size": 217, + "url": "https://artifacts.example.test/resources/frankenphp/8.5/8.5.7-frankenphp1.12.4-pv3/darwin-amd64/frankenphp-8.5.7-frankenphp1.12.4-pv3-darwin-amd64.tar.gz", + "sha256": "c544de029b13fadf4a04abf746a58ac6ded0c351d0ca71cfac1873760aca90db", + "size": 218, "published_at": "2026-01-01T00:00:00Z", "provenance": { "source_url": "https://github.com/php/frankenphp/archive/refs/tags/v1.12.4.tar.gz", @@ -197,13 +197,13 @@ expression: manifest_json } }, { - "artifact_version": "8.5.7-frankenphp1.12.4-pv2", + "artifact_version": "8.5.7-frankenphp1.12.4-pv3", "upstream_version": "8.5.7-frankenphp1.12.4", - "pv_build_revision": "pv2", + "pv_build_revision": "pv3", "platform": "darwin-arm64", - "url": "https://artifacts.example.test/resources/frankenphp/8.5/8.5.7-frankenphp1.12.4-pv2/darwin-arm64/frankenphp-8.5.7-frankenphp1.12.4-pv2-darwin-arm64.tar.gz", - "sha256": "20d29c8edaf7ab1b913ef8fa361068f0fa8cc92d488e879be566a74fc7325164", - "size": 217, + "url": "https://artifacts.example.test/resources/frankenphp/8.5/8.5.7-frankenphp1.12.4-pv3/darwin-arm64/frankenphp-8.5.7-frankenphp1.12.4-pv3-darwin-arm64.tar.gz", + "sha256": "13b6806799d07428f1c81af9f1999cc0499940f77d01f83d8ba943db4a797953", + "size": 218, "published_at": "2026-01-01T00:00:00Z", "provenance": { "source_url": "https://github.com/php/frankenphp/archive/refs/tags/v1.12.4.tar.gz", @@ -405,12 +405,12 @@ expression: manifest_json "name": "8.3", "artifacts": [ { - "artifact_version": "8.3.31-pv2", + "artifact_version": "8.3.31-pv3", "upstream_version": "8.3.31", - "pv_build_revision": "pv2", + "pv_build_revision": "pv3", "platform": "darwin-amd64", - "url": "https://artifacts.example.test/resources/php/8.3/8.3.31-pv2/darwin-amd64/php-8.3.31-pv2-darwin-amd64.tar.gz", - "sha256": "ba0671a135040f4ea20994bf69b270e30fa0917c36e0338bc01f4bd070478be1", + "url": "https://artifacts.example.test/resources/php/8.3/8.3.31-pv3/darwin-amd64/php-8.3.31-pv3-darwin-amd64.tar.gz", + "sha256": "d7acccf46b3ff9ab8d21a9655ffe00b5f4ba6fcf2f3a92111cafefafbd3adacc", "size": 204, "published_at": "2026-01-01T00:00:00Z", "provenance": { @@ -422,12 +422,12 @@ expression: manifest_json } }, { - "artifact_version": "8.3.31-pv2", + "artifact_version": "8.3.31-pv3", "upstream_version": "8.3.31", - "pv_build_revision": "pv2", + "pv_build_revision": "pv3", "platform": "darwin-arm64", - "url": "https://artifacts.example.test/resources/php/8.3/8.3.31-pv2/darwin-arm64/php-8.3.31-pv2-darwin-arm64.tar.gz", - "sha256": "38b05830ef814188e55864a99d8e8780a5c4793926a74ac9e1e0adba9df1c8f9", + "url": "https://artifacts.example.test/resources/php/8.3/8.3.31-pv3/darwin-arm64/php-8.3.31-pv3-darwin-arm64.tar.gz", + "sha256": "26db44a319310483c74a6faa72021c41161540e505c890b42873e96840e92a75", "size": 204, "published_at": "2026-01-01T00:00:00Z", "provenance": { @@ -444,12 +444,12 @@ expression: manifest_json "name": "8.4", "artifacts": [ { - "artifact_version": "8.4.22-pv2", + "artifact_version": "8.4.22-pv3", "upstream_version": "8.4.22", - "pv_build_revision": "pv2", + "pv_build_revision": "pv3", "platform": "darwin-amd64", - "url": "https://artifacts.example.test/resources/php/8.4/8.4.22-pv2/darwin-amd64/php-8.4.22-pv2-darwin-amd64.tar.gz", - "sha256": "50a2fb355d06af9eb631a66dbae66ccd0095ae93b9ad68ffae8c226f2be68516", + "url": "https://artifacts.example.test/resources/php/8.4/8.4.22-pv3/darwin-amd64/php-8.4.22-pv3-darwin-amd64.tar.gz", + "sha256": "96fb3d4069f4f62fe9431bb7cd49ea1a1de36bf76846f5c5f57e9d71e77baf1e", "size": 204, "published_at": "2026-01-01T00:00:00Z", "provenance": { @@ -461,12 +461,12 @@ expression: manifest_json } }, { - "artifact_version": "8.4.22-pv2", + "artifact_version": "8.4.22-pv3", "upstream_version": "8.4.22", - "pv_build_revision": "pv2", + "pv_build_revision": "pv3", "platform": "darwin-arm64", - "url": "https://artifacts.example.test/resources/php/8.4/8.4.22-pv2/darwin-arm64/php-8.4.22-pv2-darwin-arm64.tar.gz", - "sha256": "076566d437a9e598fa6024f1e07a0ecef248c42f174ac88d29b80b51dccdbc0b", + "url": "https://artifacts.example.test/resources/php/8.4/8.4.22-pv3/darwin-arm64/php-8.4.22-pv3-darwin-arm64.tar.gz", + "sha256": "1cc28c97e2658e1aad008e4650ce8abdf7616788e50592c1733b2e073e268395", "size": 204, "published_at": "2026-01-01T00:00:00Z", "provenance": { @@ -483,13 +483,13 @@ expression: manifest_json "name": "8.5", "artifacts": [ { - "artifact_version": "8.5.7-pv2", + "artifact_version": "8.5.7-pv3", "upstream_version": "8.5.7", - "pv_build_revision": "pv2", + "pv_build_revision": "pv3", "platform": "darwin-amd64", - "url": "https://artifacts.example.test/resources/php/8.5/8.5.7-pv2/darwin-amd64/php-8.5.7-pv2-darwin-amd64.tar.gz", - "sha256": "1eff01af9879c26122d190e043e76afb8eeda73fd8108b3e168cf937ac0b8238", - "size": 203, + "url": "https://artifacts.example.test/resources/php/8.5/8.5.7-pv3/darwin-amd64/php-8.5.7-pv3-darwin-amd64.tar.gz", + "sha256": "e3b55b9c9ac9a807adfaf4792ffd96b05bea12bbe819e74b7b3a1f12034c57de", + "size": 202, "published_at": "2026-01-01T00:00:00Z", "provenance": { "source_url": "https://www.php.net/distributions/php-8.5.7.tar.gz", @@ -500,13 +500,13 @@ expression: manifest_json } }, { - "artifact_version": "8.5.7-pv2", + "artifact_version": "8.5.7-pv3", "upstream_version": "8.5.7", - "pv_build_revision": "pv2", + "pv_build_revision": "pv3", "platform": "darwin-arm64", - "url": "https://artifacts.example.test/resources/php/8.5/8.5.7-pv2/darwin-arm64/php-8.5.7-pv2-darwin-arm64.tar.gz", - "sha256": "ccd9655a44d3e72c7a81c4f0fc28fb61cf512ec676208e7190994606f933f167", - "size": 203, + "url": "https://artifacts.example.test/resources/php/8.5/8.5.7-pv3/darwin-arm64/php-8.5.7-pv3-darwin-arm64.tar.gz", + "sha256": "6edddbb21343432516a1edc28b8133869ff80824a47ec746d8e4656a622cf6db", + "size": 204, "published_at": "2026-01-01T00:00:00Z", "provenance": { "source_url": "https://www.php.net/distributions/php-8.5.7.tar.gz", diff --git a/release/artifacts/recipes/php/tracks.toml b/release/artifacts/recipes/php/tracks.toml index 6644a012..bfb8c763 100644 --- a/release/artifacts/recipes/php/tracks.toml +++ b/release/artifacts/recipes/php/tracks.toml @@ -3,7 +3,7 @@ resources = ["php", "frankenphp"] default_track = "8.5" platforms = ["darwin-arm64", "darwin-amd64"] minimum_pv_version = "0.1.0" -pv_build_revision = "pv2" +pv_build_revision = "pv3" license_files = ["LICENSE"] notice_files = ["NOTICE"] From d057824b9633ec5156a6f3a3ba8a81120abc124c Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sun, 21 Jun 2026 19:29:43 -0400 Subject: [PATCH 15/15] docs(release): require per-step release confirmations --- .../SKILL.md | 33 +++++++++++++++++-- .../skills/revoke-resource-artifact/SKILL.md | 8 ++--- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/.agents/skills/release-resource-upstream-version/SKILL.md b/.agents/skills/release-resource-upstream-version/SKILL.md index 7fe06ce1..83528b9f 100644 --- a/.agents/skills/release-resource-upstream-version/SKILL.md +++ b/.agents/skills/release-resource-upstream-version/SKILL.md @@ -137,9 +137,24 @@ git push origin main Use `feat(release)` because a new installable artifact version becomes available. +## Confirm Build Dispatch + +Immediately before dispatching `artifact-recipes.yml`, restate the finalized build inputs: + +- workflow file +- git ref +- resource +- track +- platform +- whether publication will be considered after the build + +Confirm with the user that this exact build dispatch should proceed. A prior input confirmation does not satisfy this gate. + +Do not continue to `Build Artifacts` unless the user explicitly confirms this workflow dispatch. + ## Build Artifacts -After confirming the exact inputs with the user, dispatch: +After the build dispatch has been confirmed, dispatch: ```sh gh workflow run artifact-recipes.yml \ @@ -174,11 +189,25 @@ gh api repos///actions/runs//artifacts \ --jq '.artifacts[] | {name, size_in_bytes, expired}' ``` +## Confirm Publication Dispatch + +After the build completes and uploaded artifacts are verified, restate the finalized publication inputs: + +- workflow file +- git ref +- source run ID +- versioned manifest prefix +- required native platforms + +Confirm with the user that this exact publication dispatch should proceed, explicitly noting that published artifacts become available to all clients. + +Do not continue to `Publish Artifacts` unless the user explicitly confirms this workflow dispatch. A prior build confirmation does not satisfy this gate. + ## Publish Artifacts Publication must use the same commit as the successful recipe run. -After confirming publication inputs with the user, dispatch: +After the publication dispatch has been confirmed, dispatch: ```sh gh workflow run artifact-publication.yml \ diff --git a/.agents/skills/revoke-resource-artifact/SKILL.md b/.agents/skills/revoke-resource-artifact/SKILL.md index f9c65a95..64a0086a 100644 --- a/.agents/skills/revoke-resource-artifact/SKILL.md +++ b/.agents/skills/revoke-resource-artifact/SKILL.md @@ -162,12 +162,12 @@ manifests/runs//manifest.json manifest.json ``` -The safe order is: +The safe order is gated. Do not batch these mutations under one approval; a prior confirmation does not authorize later R2 mutations. -1. Upload the revocation JSON as an immutable object, failing if it already exists. +1. Confirm with the user before uploading the revocation JSON as an immutable object, failing if it already exists. Stop unless the user explicitly approves this upload. 2. Generate and validate a complete manifest from published records plus all revocations. -3. Upload a versioned manifest. -4. Update stable `manifest.json` last. +3. Confirm with the user before uploading the versioned manifest. Stop unless the user explicitly approves this upload. +4. Confirm with the user before updating stable `manifest.json`, explicitly noting that clients will observe the new revocation state after this mutation. Stop unless the user explicitly approves this update. 5. Verify clients see the revoked state. Do not hand-edit the stable manifest JSON directly.