diff --git a/assets/workspace/.cursor/agent-models.toml b/.claude/agent-models.toml similarity index 89% rename from assets/workspace/.cursor/agent-models.toml rename to .claude/agent-models.toml index 94947f2d..d9e498d8 100644 --- a/assets/workspace/.cursor/agent-models.toml +++ b/.claude/agent-models.toml @@ -1,5 +1,5 @@ -# .cursor/agent-models.toml -# Single source of truth for cursor-agent model assignments. +# .claude/agent-models.toml +# Single source of truth for agent model assignments. # Referenced by: justfile.worktree (worktree-start recipe) [models] diff --git a/.claude/commands/ci_check.md b/.claude/commands/ci_check.md index fb7778f0..37b88a7e 100644 --- a/.claude/commands/ci_check.md +++ b/.claude/commands/ci_check.md @@ -1,3 +1,3 @@ -Read and follow the workflow in `.cursor/skills/ci_check/SKILL.md`. +Read and follow the workflow in `.claude/skills/ci_check/SKILL.md`. Context: $ARGUMENTS diff --git a/.claude/commands/ci_fix.md b/.claude/commands/ci_fix.md index f98a8018..1cb9e096 100644 --- a/.claude/commands/ci_fix.md +++ b/.claude/commands/ci_fix.md @@ -1,3 +1,3 @@ -Read and follow the workflow in `.cursor/skills/ci_fix/SKILL.md`. +Read and follow the workflow in `.claude/skills/ci_fix/SKILL.md`. Context: $ARGUMENTS diff --git a/.claude/commands/code_debug.md b/.claude/commands/code_debug.md index d83c2e78..4fa1d831 100644 --- a/.claude/commands/code_debug.md +++ b/.claude/commands/code_debug.md @@ -1,3 +1,3 @@ -Read and follow the workflow in `.cursor/skills/code_debug/SKILL.md`. +Read and follow the workflow in `.claude/skills/code_debug/SKILL.md`. Context: $ARGUMENTS diff --git a/.claude/commands/code_execute.md b/.claude/commands/code_execute.md index 8136d943..62e09305 100644 --- a/.claude/commands/code_execute.md +++ b/.claude/commands/code_execute.md @@ -1,3 +1,3 @@ -Read and follow the workflow in `.cursor/skills/code_execute/SKILL.md`. +Read and follow the workflow in `.claude/skills/code_execute/SKILL.md`. Context: $ARGUMENTS diff --git a/.claude/commands/code_review.md b/.claude/commands/code_review.md index aea5b81f..6ef5db0a 100644 --- a/.claude/commands/code_review.md +++ b/.claude/commands/code_review.md @@ -1,3 +1,3 @@ -Read and follow the workflow in `.cursor/skills/code_review/SKILL.md`. +Read and follow the workflow in `.claude/skills/code_review/SKILL.md`. Context: $ARGUMENTS diff --git a/.claude/commands/code_tdd.md b/.claude/commands/code_tdd.md index 977871fc..cf07560b 100644 --- a/.claude/commands/code_tdd.md +++ b/.claude/commands/code_tdd.md @@ -1,4 +1,4 @@ -Read and follow the workflow in `.cursor/skills/code_tdd/SKILL.md`. -Also read `.cursor/rules/tdd.mdc` for the scenario checklist and TDD rules. +Read and follow the workflow in `.claude/skills/code_tdd/SKILL.md`. +Also read `.claude/skills/tdd/SKILL.md` for the scenario checklist and TDD rules. Context: $ARGUMENTS diff --git a/.claude/commands/code_verify.md b/.claude/commands/code_verify.md index b6e2c146..4f5c8d75 100644 --- a/.claude/commands/code_verify.md +++ b/.claude/commands/code_verify.md @@ -1,3 +1,3 @@ -Read and follow the workflow in `.cursor/skills/code_verify/SKILL.md`. +Read and follow the workflow in `.claude/skills/code_verify/SKILL.md`. Context: $ARGUMENTS diff --git a/.claude/commands/design_brainstorm.md b/.claude/commands/design_brainstorm.md index f6df728e..fa455306 100644 --- a/.claude/commands/design_brainstorm.md +++ b/.claude/commands/design_brainstorm.md @@ -1,3 +1,3 @@ -Read and follow the workflow in `.cursor/skills/design_brainstorm/SKILL.md`. +Read and follow the workflow in `.claude/skills/design_brainstorm/SKILL.md`. Context: $ARGUMENTS diff --git a/.claude/commands/design_plan.md b/.claude/commands/design_plan.md index b81c7537..b11d2d59 100644 --- a/.claude/commands/design_plan.md +++ b/.claude/commands/design_plan.md @@ -1,3 +1,3 @@ -Read and follow the workflow in `.cursor/skills/design_plan/SKILL.md`. +Read and follow the workflow in `.claude/skills/design_plan/SKILL.md`. Context: $ARGUMENTS diff --git a/.claude/commands/git_commit.md b/.claude/commands/git_commit.md index 8e6c8fee..ea7beae6 100644 --- a/.claude/commands/git_commit.md +++ b/.claude/commands/git_commit.md @@ -1,4 +1,4 @@ -Read and follow the workflow in `.cursor/skills/git_commit/SKILL.md`. -Also read `.cursor/rules/commit-messages.mdc` for the commit message format. +Read and follow the workflow in `.claude/skills/git_commit/SKILL.md`. +Also read the Commit Message Standard in `CLAUDE.md` for the commit message format. Context: $ARGUMENTS diff --git a/.claude/commands/inception_architect.md b/.claude/commands/inception_architect.md index 76185627..1a17b3e2 100644 --- a/.claude/commands/inception_architect.md +++ b/.claude/commands/inception_architect.md @@ -1,3 +1,3 @@ -Read and follow the workflow in `.cursor/skills/inception_architect/SKILL.md`. +Read and follow the workflow in `.claude/skills/inception_architect/SKILL.md`. Context: $ARGUMENTS diff --git a/.claude/commands/inception_explore.md b/.claude/commands/inception_explore.md index 77b0ffb7..e6bc266c 100644 --- a/.claude/commands/inception_explore.md +++ b/.claude/commands/inception_explore.md @@ -1,3 +1,3 @@ -Read and follow the workflow in `.cursor/skills/inception_explore/SKILL.md`. +Read and follow the workflow in `.claude/skills/inception_explore/SKILL.md`. Context: $ARGUMENTS diff --git a/.claude/commands/inception_plan.md b/.claude/commands/inception_plan.md index f3863602..03962ef7 100644 --- a/.claude/commands/inception_plan.md +++ b/.claude/commands/inception_plan.md @@ -1,3 +1,3 @@ -Read and follow the workflow in `.cursor/skills/inception_plan/SKILL.md`. +Read and follow the workflow in `.claude/skills/inception_plan/SKILL.md`. Context: $ARGUMENTS diff --git a/.claude/commands/inception_scope.md b/.claude/commands/inception_scope.md index 57fe4dbe..cb6d983c 100644 --- a/.claude/commands/inception_scope.md +++ b/.claude/commands/inception_scope.md @@ -1,3 +1,3 @@ -Read and follow the workflow in `.cursor/skills/inception_scope/SKILL.md`. +Read and follow the workflow in `.claude/skills/inception_scope/SKILL.md`. Context: $ARGUMENTS diff --git a/.claude/commands/issue_claim.md b/.claude/commands/issue_claim.md index 05481965..8b79bd2e 100644 --- a/.claude/commands/issue_claim.md +++ b/.claude/commands/issue_claim.md @@ -1,3 +1,3 @@ -Read and follow the workflow in `.cursor/skills/issue_claim/SKILL.md`. +Read and follow the workflow in `.claude/skills/issue_claim/SKILL.md`. Context: $ARGUMENTS diff --git a/.claude/commands/issue_create.md b/.claude/commands/issue_create.md index 8d1bd943..39c7c041 100644 --- a/.claude/commands/issue_create.md +++ b/.claude/commands/issue_create.md @@ -1,4 +1,4 @@ -Read and follow the workflow in `.cursor/skills/issue_create/SKILL.md`. +Read and follow the workflow in `.claude/skills/issue_create/SKILL.md`. Also read `.github/ISSUE_TEMPLATE/` and `.github/label-taxonomy.toml` for templates and labels. Context: $ARGUMENTS diff --git a/.claude/commands/issue_triage.md b/.claude/commands/issue_triage.md index afa2b1df..a892b8ca 100644 --- a/.claude/commands/issue_triage.md +++ b/.claude/commands/issue_triage.md @@ -1,4 +1,4 @@ -Read and follow the workflow in `.cursor/skills/issue_triage/SKILL.md`. +Read and follow the workflow in `.claude/skills/issue_triage/SKILL.md`. Also read `.github/label-taxonomy.toml` for canonical labels. Context: $ARGUMENTS diff --git a/.claude/commands/pr_create.md b/.claude/commands/pr_create.md index d1a80a5e..74e04d29 100644 --- a/.claude/commands/pr_create.md +++ b/.claude/commands/pr_create.md @@ -1,4 +1,4 @@ -Read and follow the workflow in `.cursor/skills/pr_create/SKILL.md`. +Read and follow the workflow in `.claude/skills/pr_create/SKILL.md`. Also read `.github/pull_request_template.md` for the PR template. Context: $ARGUMENTS diff --git a/.claude/commands/pr_post-merge.md b/.claude/commands/pr_post-merge.md index a0c5f3ed..a07e9f45 100644 --- a/.claude/commands/pr_post-merge.md +++ b/.claude/commands/pr_post-merge.md @@ -1,3 +1,3 @@ -Read and follow the workflow in `.cursor/skills/pr_post-merge/SKILL.md`. +Read and follow the workflow in `.claude/skills/pr_post-merge/SKILL.md`. Context: $ARGUMENTS diff --git a/.claude/commands/pr_solve.md b/.claude/commands/pr_solve.md index b02fba97..c3236405 100644 --- a/.claude/commands/pr_solve.md +++ b/.claude/commands/pr_solve.md @@ -1,3 +1,3 @@ -Read and follow the workflow in `.cursor/skills/pr_solve/SKILL.md`. +Read and follow the workflow in `.claude/skills/pr_solve/SKILL.md`. Context: $ARGUMENTS diff --git a/.claude/commands/worktree_ask.md b/.claude/commands/worktree_ask.md index 4969a39f..9f2d8ca6 100644 --- a/.claude/commands/worktree_ask.md +++ b/.claude/commands/worktree_ask.md @@ -1,3 +1,3 @@ -Read and follow the workflow in `.cursor/skills/worktree_ask/SKILL.md`. +Read and follow the workflow in `.claude/skills/worktree_ask/SKILL.md`. Context: $ARGUMENTS diff --git a/.claude/commands/worktree_brainstorm.md b/.claude/commands/worktree_brainstorm.md index d58bb67e..fe8da381 100644 --- a/.claude/commands/worktree_brainstorm.md +++ b/.claude/commands/worktree_brainstorm.md @@ -1,3 +1,3 @@ -Read and follow the workflow in `.cursor/skills/worktree_brainstorm/SKILL.md`. +Read and follow the workflow in `.claude/skills/worktree_brainstorm/SKILL.md`. Context: $ARGUMENTS diff --git a/.claude/commands/worktree_ci-check.md b/.claude/commands/worktree_ci-check.md index 5a31788e..0f911397 100644 --- a/.claude/commands/worktree_ci-check.md +++ b/.claude/commands/worktree_ci-check.md @@ -1,3 +1,3 @@ -Read and follow the workflow in `.cursor/skills/worktree_ci-check/SKILL.md`. +Read and follow the workflow in `.claude/skills/worktree_ci-check/SKILL.md`. Context: $ARGUMENTS diff --git a/.claude/commands/worktree_ci-fix.md b/.claude/commands/worktree_ci-fix.md index 7b87cf42..0345d76b 100644 --- a/.claude/commands/worktree_ci-fix.md +++ b/.claude/commands/worktree_ci-fix.md @@ -1,3 +1,3 @@ -Read and follow the workflow in `.cursor/skills/worktree_ci-fix/SKILL.md`. +Read and follow the workflow in `.claude/skills/worktree_ci-fix/SKILL.md`. Context: $ARGUMENTS diff --git a/.claude/commands/worktree_execute.md b/.claude/commands/worktree_execute.md index 8633f595..1dd6079c 100644 --- a/.claude/commands/worktree_execute.md +++ b/.claude/commands/worktree_execute.md @@ -1,4 +1,4 @@ -Read and follow the workflow in `.cursor/skills/worktree_execute/SKILL.md`. -Also read `.cursor/rules/tdd.mdc` for TDD rules. +Read and follow the workflow in `.claude/skills/worktree_execute/SKILL.md`. +Also read `.claude/skills/tdd/SKILL.md` for TDD rules. Context: $ARGUMENTS diff --git a/.claude/commands/worktree_plan.md b/.claude/commands/worktree_plan.md index ad10b693..baf1d5e4 100644 --- a/.claude/commands/worktree_plan.md +++ b/.claude/commands/worktree_plan.md @@ -1,3 +1,3 @@ -Read and follow the workflow in `.cursor/skills/worktree_plan/SKILL.md`. +Read and follow the workflow in `.claude/skills/worktree_plan/SKILL.md`. Context: $ARGUMENTS diff --git a/.claude/commands/worktree_pr.md b/.claude/commands/worktree_pr.md index 46673b3c..071cc35f 100644 --- a/.claude/commands/worktree_pr.md +++ b/.claude/commands/worktree_pr.md @@ -1,3 +1,3 @@ -Read and follow the workflow in `.cursor/skills/worktree_pr/SKILL.md`. +Read and follow the workflow in `.claude/skills/worktree_pr/SKILL.md`. Context: $ARGUMENTS diff --git a/.claude/commands/worktree_solve-and-pr.md b/.claude/commands/worktree_solve-and-pr.md index d59bc799..496bf75d 100644 --- a/.claude/commands/worktree_solve-and-pr.md +++ b/.claude/commands/worktree_solve-and-pr.md @@ -1,4 +1,4 @@ -Read and follow the workflow in `.cursor/skills/worktree_solve-and-pr/SKILL.md`. -Also read `.cursor/rules/subagent-delegation.mdc` for delegation patterns. +Read and follow the workflow in `.claude/skills/worktree_solve-and-pr/SKILL.md`. +Also read `.claude/skills/subagent-delegation/SKILL.md` for delegation patterns. Context: $ARGUMENTS diff --git a/.claude/commands/worktree_verify.md b/.claude/commands/worktree_verify.md index ca292ba3..4149cd39 100644 --- a/.claude/commands/worktree_verify.md +++ b/.claude/commands/worktree_verify.md @@ -1,3 +1,3 @@ -Read and follow the workflow in `.cursor/skills/worktree_verify/SKILL.md`. +Read and follow the workflow in `.claude/skills/worktree_verify/SKILL.md`. Context: $ARGUMENTS diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index c6e09c32..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(gh pr:*)" - ] - } -} diff --git a/assets/workspace/.cursor/rules/branch-naming.mdc b/.claude/skills/branch-naming/SKILL.md similarity index 95% rename from assets/workspace/.cursor/rules/branch-naming.mdc rename to .claude/skills/branch-naming/SKILL.md index ab062c91..7f0a472b 100644 --- a/assets/workspace/.cursor/rules/branch-naming.mdc +++ b/.claude/skills/branch-naming/SKILL.md @@ -1,6 +1,7 @@ --- -description: Topic branch naming and workflow for starting work on an issue. Attach when creating branches, starting work on issues, or checking out branches. -alwaysApply: false +name: branch-naming +description: Topic branch naming and workflow for starting work on an issue. Use when creating branches, starting work on issues, or checking out branches. +disable-model-invocation: true --- # Topic Branch Naming and Workflow @@ -40,15 +41,19 @@ When the user asks to create or start work on an issue (e.g. "create branch for ## Branch name format (reference) ### Issue-tied branches + ``` /- ``` + Example: `feature/36-standardize-commit-messages`, `bugfix/42-fix-login-bug` ### Chore branches (no issue required) + ``` chore/ ``` + Example: `chore/sync-main-to-dev`, `chore/update-dependencies` ## Branch types (reference) diff --git a/.cursor/skills/ci_check/SKILL.md b/.claude/skills/ci_check/SKILL.md similarity index 100% rename from .cursor/skills/ci_check/SKILL.md rename to .claude/skills/ci_check/SKILL.md diff --git a/.cursor/skills/ci_fix/SKILL.md b/.claude/skills/ci_fix/SKILL.md similarity index 100% rename from .cursor/skills/ci_fix/SKILL.md rename to .claude/skills/ci_fix/SKILL.md diff --git a/.cursor/skills/code_debug/SKILL.md b/.claude/skills/code_debug/SKILL.md similarity index 100% rename from .cursor/skills/code_debug/SKILL.md rename to .claude/skills/code_debug/SKILL.md diff --git a/.cursor/skills/code_execute/SKILL.md b/.claude/skills/code_execute/SKILL.md similarity index 100% rename from .cursor/skills/code_execute/SKILL.md rename to .claude/skills/code_execute/SKILL.md diff --git a/.cursor/skills/code_review/SKILL.md b/.claude/skills/code_review/SKILL.md similarity index 97% rename from .cursor/skills/code_review/SKILL.md rename to .claude/skills/code_review/SKILL.md index 09b0b620..bcb1f55f 100644 --- a/.cursor/skills/code_review/SKILL.md +++ b/.claude/skills/code_review/SKILL.md @@ -65,7 +65,7 @@ STEPS: Flag any change NOT traceable to a requirement (scope creep). 4. Check project standards: - Changelog: is CHANGELOG.md updated under ## Unreleased? Does the entry match? - - Commit messages: do all commits follow the format in .cursor/rules/commit-messages.mdc? + - Commit messages: do all commits follow the format in CLAUDE.md (Commit Message Standard)? - Tests: are there tests for new/changed behavior? - Docs: are documentation changes needed? 5. Produce your report in EXACTLY this structure: diff --git a/.cursor/skills/code_tdd/SKILL.md b/.claude/skills/code_tdd/SKILL.md similarity index 100% rename from .cursor/skills/code_tdd/SKILL.md rename to .claude/skills/code_tdd/SKILL.md diff --git a/.cursor/skills/code_verify/SKILL.md b/.claude/skills/code_verify/SKILL.md similarity index 100% rename from .cursor/skills/code_verify/SKILL.md rename to .claude/skills/code_verify/SKILL.md diff --git a/.cursor/skills/design_brainstorm/SKILL.md b/.claude/skills/design_brainstorm/SKILL.md similarity index 100% rename from .cursor/skills/design_brainstorm/SKILL.md rename to .claude/skills/design_brainstorm/SKILL.md diff --git a/.cursor/skills/design_plan/SKILL.md b/.claude/skills/design_plan/SKILL.md similarity index 100% rename from .cursor/skills/design_plan/SKILL.md rename to .claude/skills/design_plan/SKILL.md diff --git a/.cursor/skills/git_commit/SKILL.md b/.claude/skills/git_commit/SKILL.md similarity index 100% rename from .cursor/skills/git_commit/SKILL.md rename to .claude/skills/git_commit/SKILL.md diff --git a/.cursor/skills/inception_architect/SKILL.md b/.claude/skills/inception_architect/SKILL.md similarity index 100% rename from .cursor/skills/inception_architect/SKILL.md rename to .claude/skills/inception_architect/SKILL.md diff --git a/.cursor/skills/inception_explore/README.md b/.claude/skills/inception_explore/README.md similarity index 98% rename from .cursor/skills/inception_explore/README.md rename to .claude/skills/inception_explore/README.md index 4fe110fd..0192a46c 100644 --- a/.cursor/skills/inception_explore/README.md +++ b/.claude/skills/inception_explore/README.md @@ -180,4 +180,4 @@ User: "Add multi-tenancy to the system" - [RFC template](../../templates/RFC.md) - [DESIGN template](../../templates/DESIGN.md) - [Keep a Changelog](https://keepachangelog.com/) — format for CHANGELOG.md entries -- [Single Source of Truth rule](../../../.cursor/rules/single-source-of-truth.mdc) +- [Single Source of Truth rule](../../../CLAUDE.md) diff --git a/.cursor/skills/inception_explore/SKILL.md b/.claude/skills/inception_explore/SKILL.md similarity index 100% rename from .cursor/skills/inception_explore/SKILL.md rename to .claude/skills/inception_explore/SKILL.md diff --git a/.cursor/skills/inception_plan/SKILL.md b/.claude/skills/inception_plan/SKILL.md similarity index 100% rename from .cursor/skills/inception_plan/SKILL.md rename to .claude/skills/inception_plan/SKILL.md diff --git a/.cursor/skills/inception_scope/SKILL.md b/.claude/skills/inception_scope/SKILL.md similarity index 100% rename from .cursor/skills/inception_scope/SKILL.md rename to .claude/skills/inception_scope/SKILL.md diff --git a/.cursor/skills/issue_claim/SKILL.md b/.claude/skills/issue_claim/SKILL.md similarity index 100% rename from .cursor/skills/issue_claim/SKILL.md rename to .claude/skills/issue_claim/SKILL.md diff --git a/.cursor/skills/issue_create/SKILL.md b/.claude/skills/issue_create/SKILL.md similarity index 97% rename from .cursor/skills/issue_create/SKILL.md rename to .claude/skills/issue_create/SKILL.md index d1f11da9..a9f5c7d8 100644 --- a/.cursor/skills/issue_create/SKILL.md +++ b/.claude/skills/issue_create/SKILL.md @@ -33,7 +33,7 @@ Create a new GitHub issue using the appropriate issue template. - Draft the body with all required fields from the chosen template. - Include a Changelog Category value based on the issue type. - For testable issue types (`feature`, `bug`, `refactor`), include a TDD acceptance criterion: - `- [ ] TDD compliance (see .cursor/rules/tdd.mdc)` + `- [ ] TDD compliance (see .claude/skills/tdd/SKILL.md)` 4. **Show draft and ask for confirmation** - Present the title, labels, and body to the user. diff --git a/.cursor/skills/issue_triage/SKILL.md b/.claude/skills/issue_triage/SKILL.md similarity index 100% rename from .cursor/skills/issue_triage/SKILL.md rename to .claude/skills/issue_triage/SKILL.md diff --git a/.cursor/skills/pr_create/SKILL.md b/.claude/skills/pr_create/SKILL.md similarity index 100% rename from .cursor/skills/pr_create/SKILL.md rename to .claude/skills/pr_create/SKILL.md diff --git a/.cursor/skills/pr_post-merge/SKILL.md b/.claude/skills/pr_post-merge/SKILL.md similarity index 100% rename from .cursor/skills/pr_post-merge/SKILL.md rename to .claude/skills/pr_post-merge/SKILL.md diff --git a/.cursor/skills/pr_solve/SKILL.md b/.claude/skills/pr_solve/SKILL.md similarity index 100% rename from .cursor/skills/pr_solve/SKILL.md rename to .claude/skills/pr_solve/SKILL.md diff --git a/.cursor/skills/solve-and-pr/SKILL.md b/.claude/skills/solve-and-pr/SKILL.md similarity index 97% rename from .cursor/skills/solve-and-pr/SKILL.md rename to .claude/skills/solve-and-pr/SKILL.md index d8696eb9..01dc4732 100644 --- a/.cursor/skills/solve-and-pr/SKILL.md +++ b/.claude/skills/solve-and-pr/SKILL.md @@ -29,7 +29,7 @@ This command: - Resolves or creates the linked branch - Sets up the environment (`uv sync`, `pre-commit install`) - Captures the local gh user as the reviewer (`gh api user --jq '.login'`) -- Launches a tmux session running `cursor-agent` with `--yolo` mode +- Launches a tmux session running `claude --dangerously-skip-permissions` - Passes `/worktree-solve-and-pr` as the initial prompt ### 3. Report back to the user diff --git a/.cursor/rules/subagent-delegation.mdc b/.claude/skills/subagent-delegation/SKILL.md similarity index 89% rename from .cursor/rules/subagent-delegation.mdc rename to .claude/skills/subagent-delegation/SKILL.md index e52f5bf1..680c6514 100644 --- a/.cursor/rules/subagent-delegation.mdc +++ b/.claude/skills/subagent-delegation/SKILL.md @@ -1,10 +1,16 @@ +--- +name: subagent-delegation +description: How to delegate mechanical sub-steps to lightweight subagents when executing skills. Use when running a skill that has data-gathering, formatting, or structured-review sub-steps. +disable-model-invocation: true +--- + # Subagent Delegation When executing skills, delegate mechanical sub-steps to lightweight subagents via the Task tool to reduce token consumption on the primary model. ## Model Tiers -See [.cursor/agent-models.toml](../.cursor/agent-models.toml) for the single source of truth. Summary: +See [.claude/agent-models.toml](../../agent-models.toml) for the single source of truth. Summary: - **lightweight** (`composer-1.5`) — CLI commands, API calls, file reading, parsing, template filling - **standard** (`sonnet-4.5`) — structured analysis, code review with clear inputs @@ -84,7 +90,7 @@ The following steps SHOULD be delegated to reduce token consumption: - **Step 6** (publish comment): Spawn a Task subagent with `model: "fast"` that posts the formatted comment and returns the comment URL. -Reference: [subagent-delegation rule](../../rules/subagent-delegation.mdc) +Reference: [subagent-delegation skill](../subagent-delegation/SKILL.md) ``` ## Important Notes diff --git a/.cursor/rules/tdd.mdc b/.claude/skills/tdd/SKILL.md similarity index 84% rename from .cursor/rules/tdd.mdc rename to .claude/skills/tdd/SKILL.md index 7a6fcc23..062845d0 100644 --- a/.cursor/rules/tdd.mdc +++ b/.claude/skills/tdd/SKILL.md @@ -1,14 +1,7 @@ --- -description: TDD discipline and test scenario guidance when writing code -alwaysApply: false -globs: - - "**/*.py" - - "**/*.ts" - - "**/*.js" - - "**/*.sh" - - "**/test_*" - - "**/*_test.*" - - "**/tests/**" +name: tdd +description: TDD discipline and test scenario guidance when writing code. Use when implementing features or fixes that have testable behavior. +disable-model-invocation: true --- # TDD @@ -16,11 +9,11 @@ globs: When implementing features or fixes that have testable behavior: 1. Write the failing test first. Run it. Confirm it fails. -2. **Commit** the failing test following [commit-messages.mdc](commit-messages.mdc) (`test: ...`). Do not proceed before committing. +2. **Commit** the failing test following the commit message standard in `CLAUDE.md` (`test: ...`). Do not proceed before committing. 3. Write minimal code to make the test pass. Run it. Confirm it passes. **Commit** the implementation. 4. Refactor. Run tests. Confirm no regressions. **Commit** the refactor if meaningful. -All commits must follow [commit-messages.mdc](commit-messages.mdc). Never use `--no-verify`. +All commits must follow the commit message standard in `CLAUDE.md`. Never use `--no-verify`. Each phase gets its own commit so the git history proves TDD compliance. diff --git a/.cursor/skills/worktree_ask/SKILL.md b/.claude/skills/worktree_ask/SKILL.md similarity index 100% rename from .cursor/skills/worktree_ask/SKILL.md rename to .claude/skills/worktree_ask/SKILL.md diff --git a/.cursor/skills/worktree_brainstorm/SKILL.md b/.claude/skills/worktree_brainstorm/SKILL.md similarity index 100% rename from .cursor/skills/worktree_brainstorm/SKILL.md rename to .claude/skills/worktree_brainstorm/SKILL.md diff --git a/.cursor/skills/worktree_ci-check/SKILL.md b/.claude/skills/worktree_ci-check/SKILL.md similarity index 100% rename from .cursor/skills/worktree_ci-check/SKILL.md rename to .claude/skills/worktree_ci-check/SKILL.md diff --git a/.cursor/skills/worktree_ci-fix/SKILL.md b/.claude/skills/worktree_ci-fix/SKILL.md similarity index 100% rename from .cursor/skills/worktree_ci-fix/SKILL.md rename to .claude/skills/worktree_ci-fix/SKILL.md diff --git a/.cursor/skills/worktree_execute/SKILL.md b/.claude/skills/worktree_execute/SKILL.md similarity index 100% rename from .cursor/skills/worktree_execute/SKILL.md rename to .claude/skills/worktree_execute/SKILL.md diff --git a/.cursor/skills/worktree_plan/SKILL.md b/.claude/skills/worktree_plan/SKILL.md similarity index 100% rename from .cursor/skills/worktree_plan/SKILL.md rename to .claude/skills/worktree_plan/SKILL.md diff --git a/.cursor/skills/worktree_pr/SKILL.md b/.claude/skills/worktree_pr/SKILL.md similarity index 100% rename from .cursor/skills/worktree_pr/SKILL.md rename to .claude/skills/worktree_pr/SKILL.md diff --git a/.cursor/skills/worktree_solve-and-pr/SKILL.md b/.claude/skills/worktree_solve-and-pr/SKILL.md similarity index 100% rename from .cursor/skills/worktree_solve-and-pr/SKILL.md rename to .claude/skills/worktree_solve-and-pr/SKILL.md diff --git a/.cursor/skills/worktree_verify/SKILL.md b/.claude/skills/worktree_verify/SKILL.md similarity index 100% rename from .cursor/skills/worktree_verify/SKILL.md rename to .claude/skills/worktree_verify/SKILL.md diff --git a/.cursor/worktrees.json b/.claude/worktrees.json similarity index 100% rename from .cursor/worktrees.json rename to .claude/worktrees.json diff --git a/.cursor/rules/changelog.mdc b/.cursor/rules/changelog.mdc deleted file mode 100644 index 3739a876..00000000 --- a/.cursor/rules/changelog.mdc +++ /dev/null @@ -1,79 +0,0 @@ ---- -description: When and how to update CHANGELOG.md during development. Attach when editing CHANGELOG.md, committing changes, or preparing PRs. -alwaysApply: false -globs: - - CHANGELOG.md ---- - -# Changelog Update Rules - -When making code changes, follow these rules for updating [CHANGELOG.md](CHANGELOG.md). - -## When to update - -- **Always update** for `feat`, `fix`, `refactor`, `build`, `revert`, `style`, `test`, `docs` changes that affect user-visible behavior, public API, or developer workflow. -- **Always update** for dependency version bumps (including Dependabot PRs) — users and operators need to know what changed. -- **Skip** for `chore` commits that are purely internal (CI-only config tweaks, formatting) unless they have user-visible impact. -- When in doubt, add an entry — it's easier to remove during review than to add later. - -## Where to update - -- **On `dev` and feature/bugfix branches targeting `dev`:** Edit the `## Unreleased` section at the top of `CHANGELOG.md`. -- **On `release/*` branches:** There is no `## Unreleased` section. Edit the `## [X.Y.Z] - TBD` section directly. Place entries under the correct category heading within that section. -- Place the entry under the correct category heading. Create the heading if it doesn't exist yet. -- **Never** modify entries below the active section (released versions with dates). -- **Never** change the release date or version number of any section. -- **Sort order:** add new entries chronologically (newest at the bottom of each category). Entries are reordered by issue on release. -- **Editing unreleased entries:** entries in the active section represent the atomic user-facing state between versions, not a copy of commit history. You may update or consolidate existing entries across PRs (e.g. fixing a bug introduced in an earlier unreleased PR). - -## Category headings (in order) - -Use these [Keep a Changelog](https://keepachangelog.com/) categories: - -``` -### Added — new features, capabilities, tools -### Changed — changes to existing functionality -### Deprecated — features that will be removed -### Removed — features that were removed -### Fixed — bug fixes -### Security — vulnerability fixes or security improvements -``` - -## Entry format - -Follow the existing style in the file: - -```markdown -- **Bold short title** ([#](/)) - - Detail bullet explaining what was done - - Additional detail bullet if needed -``` - -Rules: -- Start with `- **Bold title**` followed by the issue link in parentheses. -- Determine the repo issues URL with `gh repo view --json url --jq '.url + "/issues"'`. -- Use sub-bullets (indented with two spaces) for implementation details. -- Reference the GitHub issue number from the `Refs:` line in the commit. -- If multiple issues are related, list them: `([#12](url), [#13](url))`. -- Keep descriptions concise and user-focused (what changed, not how). - -## Example - -```markdown -## Unreleased - -### Added - -- **SSH agent forwarding** ([#42](/42)) - - Forward host SSH agent into devcontainer for seamless git authentication - - Integration tests for SSH socket availability - -### Fixed - -- **Broken venv prompt after rename** ([#43](/43)) - - Post-create script now correctly updates the activate script prompt -``` - -## Relationship to issue templates - -If the issue has a **Changelog Category** field (e.g. "Added", "Fixed"), use that as the category. If the field says "No changelog needed", skip the changelog update. diff --git a/.cursor/rules/coding-principles.mdc b/.cursor/rules/coding-principles.mdc deleted file mode 100644 index afd06f91..00000000 --- a/.cursor/rules/coding-principles.mdc +++ /dev/null @@ -1,21 +0,0 @@ ---- -description: Coding principles enforced on every file edit -alwaysApply: true ---- - -# Coding Principles - -1. **YAGNI** -- Implement only what the issue or user explicitly requests. No speculative features. Ask before adding anything unasked. -2. **Minimal diff** -- Touch only files and lines required for the task. No drive-by refactors, renames, or reformats. Mention improvements separately; don't silently change them. -3. **DRY** -- Don't duplicate logic. Extract shared code only after the pattern appears twice. Prefer existing abstractions over new ones. -4. **No secrets** -- Never hardcode tokens, passwords, keys, or connection strings. Use env vars. Don't commit .env or credential files. Flag existing secrets to the user. -5. **Traceability** -- Every change must link to a GitHub issue. No out-of-scope fixes. Suggest a new issue instead of bundling unrelated changes. -6. **Single responsibility** -- One function = one job. Prefer new functions over extending existing ones. Split functions exceeding ~50 lines or handling multiple concerns. - -## Stop if - -- Adding code the issue didn't ask for -- Editing files outside the task scope -- Hardcoding a secret or credential -- Making changes not traceable to an issue -- Growing a function beyond one clear purpose diff --git a/.cursor/rules/commit-messages.mdc b/.cursor/rules/commit-messages.mdc deleted file mode 100644 index 42694dfa..00000000 --- a/.cursor/rules/commit-messages.mdc +++ /dev/null @@ -1,45 +0,0 @@ ---- -description: Commit message format and rules (type, Refs). Attach when committing, writing commit messages, or preparing PRs. -alwaysApply: false -globs: - - .gitmessage ---- - -# Commit Message Standard - -When suggesting or generating commit messages, follow the repository standard. Full reference: [docs/COMMIT_MESSAGE_STANDARD.md](docs/COMMIT_MESSAGE_STANDARD.md). - -## Format (exactly) - -``` -type(scope)!: short description - -Refs: # -``` - -- **First line:** `type(scope)!: short description` — imperative, no period. Use only: `feat`, `fix`, `docs`, `chore`, `refactor`, `test`, `ci`, `build`, `revert`, `style`. Scope optional; `!` only for breaking changes. -- **Blank line** after the subject. -- **Optional body** (what/why). If present, end body with a blank line. -- **Refs line** — mandatory for most types. At least one GitHub issue, e.g. `Refs: #36` or `Refs: #36, #37`. May add `REQ-...`, `RISK-...`, `SOP-...` after the issue. -- **Exactly one Refs line** — no duplicate `Refs:` lines; Refs must be the last line. -- **Exemption:** `chore` commits may omit the `Refs:` line when no issue/PR is directly related. Include `Refs:` when one is available. - -## Examples - -``` -feat(ci): add commit-msg validation hook - -Refs: #36 -``` - -``` -fix: correct subject pattern for optional scope - -Refs: #36 -``` - -## Do not use - -- Emojis or semantic-release style. -- Types outside the list (e.g. `feature`, `bugfix`). -- Commit messages without a `Refs:` line or without at least one issue ID (e.g. `#36`), except for `chore` type where `Refs:` is optional. diff --git a/.cursor/rules/single-source-of-truth.mdc b/.cursor/rules/single-source-of-truth.mdc deleted file mode 100644 index 385cddbd..00000000 --- a/.cursor/rules/single-source-of-truth.mdc +++ /dev/null @@ -1,27 +0,0 @@ ---- -description: Single Source of Truth — no duplication of knowledge -alwaysApply: true ---- - -# Single Source of Truth (SSoT) - -Every piece of knowledge must live in exactly one place. Reference it everywhere else. - -## Core Principle - -If information exists in a file, **link to it** — never copy it. - -## Applies to - -- **Documentation as code** — docs live in the repo, version-controlled alongside the code they describe. -- **Config as code** — configuration is declarative, checked in, and machine-readable. No manual portal settings. -- **Infrastructure as code** — all infra is defined in versioned templates/scripts. No click-ops. -- **Rules & standards** — define once in a canonical file, reference via path or link. Never duplicate across READMEs, comments, or wikis. -- **Comments & docstrings** — don't repeat what a referenced doc already says. Link to the source instead. - -## In Practice - -- Before writing explanatory text, check if a canonical source already exists. -- If it does → link to it (`see docs/COMMIT_MESSAGE_STANDARD.md`). -- If it doesn't → create the canonical file first, then link to it. -- Never maintain the same information in two places. diff --git a/.envrc b/.envrc index 3550a30f..4a5330ab 100644 --- a/.envrc +++ b/.envrc @@ -1 +1,17 @@ +# nix-direnv: GC-rooted, cached flake evaluation so re-entry is instant and the +# dev-shell closure is not garbage-collected. Falls back to bare `use flake` +# when the nix-direnv library is unavailable. +# +# Prefer a user-installed nix-direnv (sourced from ~/.config/direnv/direnvrc); +# otherwise self-bootstrap the pinned library into .direnv/ on first allow. +if ! has use_flake 2>/dev/null && ! declare -f use_flake >/dev/null 2>&1; then + nix_direnv_version="3.0.6" + nix_direnv_sha="sha256-RYcUJaRMf8oF5LznDrlCXbkOQrywm0HDv1VjYGaJGdM=" + if ! source_url \ + "https://raw.githubusercontent.com/nix-community/nix-direnv/${nix_direnv_version}/direnvrc" \ + "${nix_direnv_sha}" 2>/dev/null; then + echo "direnv: nix-direnv unavailable; falling back to bare 'use flake'." >&2 + fi +fi + use flake diff --git a/.githooks/commit-msg b/.githooks/commit-msg index 7681de59..ed519f47 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Set error handling, fail on any error during script execution set -euo pipefail diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 81bf5253..eeb5042b 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Set error handling, fail on any error during script execution set -euo pipefail diff --git a/.githooks/prepare-commit-msg b/.githooks/prepare-commit-msg index d76294c5..207874ef 100755 --- a/.githooks/prepare-commit-msg +++ b/.githooks/prepare-commit-msg @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Strip AI agent trailers from commit message before validation. # Refs: #163 set -euo pipefail diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a0b879fa..cb7aeb5a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,7 +5,7 @@ # changes to matching files. Later rules take precedence over earlier ones. # agent workflows -.cursor/skills/ @gerchowl +.claude/skills/ @gerchowl justfile.worktree @gerchowl @@ -14,8 +14,6 @@ justfile.worktree @gerchowl .github/actions/ @c-vigo # Build and release scripts -scripts/build.sh @c-vigo -scripts/prepare-build.sh @c-vigo scripts/clean.sh @c-vigo scripts/sync_manifest.py @c-vigo @@ -35,4 +33,3 @@ renovate.json @c-vigo assets/workspace/.github/renovate-default.json @c-vigo .github/CODEOWNERS @c-vigo SECURITY.md @c-vigo -Containerfile @c-vigo diff --git a/.github/actions/build-image/action.yml b/.github/actions/build-image/action.yml index 2255b0bd..70dc6bf5 100644 --- a/.github/actions/build-image/action.yml +++ b/.github/actions/build-image/action.yml @@ -1,49 +1,25 @@ -# Composite action to build container images +# Composite action to build the Nix-built devcontainer image to a tar. # -# This action handles the complete container image build process: -# - Prepares build directory with version metadata -# - Builds architecture-specific container image -# - Outputs image as tar file or pushes to registry -# -# Docker Hub (optional, Refs: #473): -# docker/setup-buildx-action pulls moby/buildkit from Docker Hub; anonymous pulls on -# shared runners hit rate limits. Pass dockerhub-username and dockerhub-token from -# workflow secrets (DOCKERHUB_USERNAME, DOCKERHUB_TOKEN) to log in before Buildx. -# Recommended for org/repo CI; omit for forks (secrets unavailable) — behavior matches -# pre-auth CI (anonymous pulls). +# Builds `packages.devcontainerImage` (flake `dockerTools.buildLayeredImage`) +# natively for the runner's architecture and writes a gzipped OCI tar. Nix is +# the only build path (the Debian Containerfile was decommissioned in #642). # # Inputs: -# version: Semantic version (e.g., 1.0.0, dev-abc123) -# arch: Target architecture (amd64, arm64) -# release-date: Release date in YYYY-MM-DD format -# release-url: URL to release page -# build-timestamp: Build timestamp in ISO 8601 format -# vcs-ref: Git commit SHA +# version: Semantic version (e.g., 1.0.0, dev-abc123) — used for the image tag +# arch: Target architecture (amd64, arm64) — used for the image tag +# release-date / release-url / build-timestamp / vcs-ref: release metadata +# (kept for caller compatibility; the Nix image stamps its own static, +# reproducible OCI labels in flake.nix, so these are not baked into it) # registry: Container registry URL (default: ghcr.io/vig-os/devcontainer) -# output-type: Output type - "tar" saves to file, "registry" pushes to registry -# output-file: Path for tar output (used if output-type=tar) -# push: Whether to push to registry (used if output-type=registry) -# dockerhub-username: Docker Hub user (optional; from secrets.DOCKERHUB_USERNAME) -# dockerhub-token: Docker Hub access token (optional; from secrets.DOCKERHUB_TOKEN) +# output-file: Path for the tar output +# cachix-cache / cachix-auth-token: passed to flake provisioning (setup-env) # # Outputs: # image-tag: Full image tag (e.g., ghcr.io/vig-os/devcontainer:1.0.0-amd64) -# tar-file: Path to saved tar file (if output-type=tar) -# -# Usage: -# - uses: ./.github/actions/build-image -# with: -# version: '1.0.0' -# arch: 'amd64' -# release-date: '2026-02-06' -# release-url: 'https://github.com/vig-os/devcontainer/releases/tag/1.0.0' -# build-timestamp: '2026-02-06T12:00:00Z' -# vcs-ref: ${{ github.sha }} -# dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} -# dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} +# tar-file: Path to the saved tar file name: 'Build Container Image' -description: 'Build architecture-specific container image' +description: 'Build the Nix-built devcontainer image (flake devcontainerImage) for an architecture' inputs: version: @@ -53,39 +29,35 @@ inputs: description: 'Target architecture (amd64, arm64)' required: true release-date: - description: 'Release date in YYYY-MM-DD format' - required: true + description: 'Release date in YYYY-MM-DD format (release metadata)' + required: false + default: '' release-url: - description: 'URL to release page' - required: true + description: 'URL to release page (release metadata)' + required: false + default: '' build-timestamp: - description: 'Build timestamp in ISO 8601 format' - required: true + description: 'Build timestamp in ISO 8601 format (release metadata)' + required: false + default: '' vcs-ref: - description: 'Git commit SHA' - required: true + description: 'Git commit SHA (release metadata)' + required: false + default: '' registry: description: 'Container registry URL' required: false default: 'ghcr.io/vig-os/devcontainer' - output-type: - description: 'Output type: "tar" (save to file) or "registry" (push to registry)' - required: false - default: 'tar' output-file: - description: 'Path for tar output (if output-type=tar)' + description: 'Path for the tar output' required: false default: '/tmp/image.tar' - push: - description: 'Whether to push to registry (if output-type=registry)' - required: false - default: 'false' - dockerhub-username: - description: 'Docker Hub username for authenticated pulls (optional; omit on forks)' + cachix-cache: + description: 'Cachix binary cache name (passed to flake provisioning)' required: false default: '' - dockerhub-token: - description: 'Docker Hub access token for authenticated pulls (optional; omit on forks)' + cachix-auth-token: + description: 'Cachix auth token (optional; pulls need no token)' required: false default: '' @@ -94,81 +66,40 @@ outputs: description: 'Full image tag' value: ${{ steps.set-tag.outputs.tag }} tar-file: - description: 'Path to tar file (only set when output-type=tar)' + description: 'Path to the saved tar file' value: ${{ steps.tar-output.outputs.tar-file }} runs: using: composite steps: - - name: Login to Docker Hub - if: inputs.dockerhub-username != '' && inputs.dockerhub-token != '' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 - with: - registry: docker.io - username: ${{ inputs.dockerhub-username }} - password: ${{ inputs.dockerhub-token }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - - name: Set up environment + - name: Set up environment (provision Nix + toolchain from the flake) uses: ./.github/actions/setup-env with: sync-dependencies: 'true' - - - name: Prepare build directory - shell: bash - run: | - set -euo pipefail - echo "Preparing build directory..." - ./scripts/prepare-build.sh "${{ inputs.version }}" - echo "Build directory prepared" + provision-via-flake: 'true' + cachix-cache: ${{ inputs.cachix-cache }} + cachix-auth-token: ${{ inputs.cachix-auth-token }} - name: Set image tag id: set-tag shell: bash run: | TAG="${{ inputs.registry }}:${{ inputs.version }}-${{ inputs.arch }}" - echo "tag=$TAG" >> $GITHUB_OUTPUT + echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "Image tag: $TAG" - - name: Extract metadata - id: meta - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 - with: - images: ${{ inputs.registry }} - tags: | - type=raw,value=${{ inputs.version }}-${{ inputs.arch }} - labels: | - org.opencontainers.image.title=vigOS development environment - org.opencontainers.image.description=Development environment with common tools and utilities - org.opencontainers.image.version=${{ inputs.version }} - org.opencontainers.image.created=${{ inputs.build-timestamp }} - org.opencontainers.image.revision=${{ inputs.vcs-ref }} - org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} - org.opencontainers.image.vendor=vigOS - org.opencontainers.image.licenses=MIT - - - name: Build image (tar output) - if: inputs.output-type == 'tar' - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 - with: - context: ./build - file: ./build/Containerfile - platforms: linux/${{ inputs.arch }} - push: false - outputs: type=docker,dest=${{ inputs.output-file }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - build-args: | - BUILD_DATE=${{ inputs.build-timestamp }} - VCS_REF=${{ inputs.vcs-ref }} - IMAGE_TAG=${{ inputs.version }} - cache-from: type=gha - cache-to: type=gha,mode=max + - name: Build the devcontainer image with Nix + shell: bash + run: | + set -euo pipefail + # Native per-arch: `nix build` resolves to the runner's own architecture + # (x86_64-linux / aarch64-linux). buildLayeredImage emits a gzipped OCI + # tar; docker/podman load handles gzip. The flake sets the OCI labels. + nix build .#devcontainerImage --print-build-logs + cp -L result "${{ inputs.output-file }}" + ls -lL "${{ inputs.output-file }}" - name: Verify tar output was created - if: inputs.output-type == 'tar' shell: bash run: | set -euo pipefail @@ -180,7 +111,6 @@ runs: fi # Verify file size is reasonable (at least 100MB for a valid image) - # Using Linux stat syntax since this action only runs on ubuntu-22.04 FILE_SIZE=$(stat -c%s "${{ inputs.output-file }}") MIN_SIZE=$((100 * 1024 * 1024)) # 100 MB @@ -195,23 +125,5 @@ runs: - name: Set tar-file output id: tar-output - if: inputs.output-type == 'tar' shell: bash - run: echo "tar-file=${{ inputs.output-file }}" >> $GITHUB_OUTPUT - - - name: Build image (registry output) - if: inputs.output-type == 'registry' - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 - with: - context: ./build - file: ./build/Containerfile - platforms: linux/${{ inputs.arch }} - push: ${{ inputs.push }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - build-args: | - BUILD_DATE=${{ inputs.build-timestamp }} - VCS_REF=${{ inputs.vcs-ref }} - IMAGE_TAG=${{ inputs.version }} - cache-from: type=gha - cache-to: type=gha,mode=max + run: echo "tar-file=${{ inputs.output-file }}" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/setup-env/action.yml b/.github/actions/setup-env/action.yml index 24e18298..0826fc9a 100644 --- a/.github/actions/setup-env/action.yml +++ b/.github/actions/setup-env/action.yml @@ -4,9 +4,16 @@ # - Podman (for container operations) # - Node.js (for JS tooling) # - Devcontainer CLI + docker-compose wrapper (for integration tests) -# - hadolint (for Containerfile linting in pre-commit) # - BATS + helper libraries (for shell script testing) # +# Flake provisioning (provision-via-flake, Refs #632): +# - When 'true', the toolchain comes from the Nix flake (the SSoT) via +# `nix develop` instead of the ad-hoc installs below: Nix + Cachix are +# installed, the dev-shell is built (a warm vig-os Cachix pull), and its +# tool bin dirs are prepended to PATH. Python/uv, just, taplo, and BATS +# ad-hoc steps are skipped; podman, Node.js, and the devcontainer CLI +# keep their dedicated steps (not flake-provided or host-integration tools). +# # IMPORTANT: # - This action does NOT checkout code, allowing callers to control ref, token, # persist-credentials, and other checkout options. @@ -21,7 +28,6 @@ # install-node: Install Node.js (default: false) # node-version: Node.js version (default: '24') # install-devcontainer-cli: Install devcontainer CLI + docker-compose wrapper (default: false) -# install-hadolint: Install hadolint binary (default: false) # install-taplo: Install taplo TOML linter/formatter (default: false) # install-bats: Install BATS + helper libraries (default: false) # @@ -53,7 +59,7 @@ # install-devcontainer-cli: 'true' name: 'Setup Environment' -description: 'Set up CI environment with Python, uv, and optional tools (podman, Node.js, devcontainer CLI, hadolint, BATS)' +description: 'Set up CI environment with Python, uv, and optional tools (podman, Node.js, devcontainer CLI, BATS)' inputs: install-python: @@ -84,10 +90,6 @@ inputs: description: 'Install @devcontainers/cli and docker-compose wrapper (requires Node.js)' required: false default: 'false' - install-hadolint: - description: 'Install hadolint binary for Containerfile linting' - required: false - default: 'false' install-just: description: 'Install just command runner for Justfile support' required: false @@ -100,6 +102,27 @@ inputs: description: 'Install BATS and helper libraries (support, assert, file) for shell testing' required: false default: 'false' + provision-via-flake: + description: >- + Provision the toolchain from the Nix flake (the toolchain SSoT) instead of + ad-hoc installs. When 'true', installs Nix + Cachix, builds the flake + dev-shell, and prepends its tools to PATH so every subsequent step runs as + if inside `nix develop`. Tools provided by the flake (Python/uv, just, + taplo, Node.js, BATS) skip their ad-hoc install steps. podman is kept + on the apt path even under flake provisioning because rootless podman on + GitHub runners needs the host's setuid newuidmap/newgidmap and container + config; the devcontainer CLI is not in the flake and keeps its dedicated + step. Refs #632, #695. + required: false + default: 'false' + cachix-cache: + description: 'Cachix binary cache name (used when provision-via-flake is true)' + required: false + default: '' + cachix-auth-token: + description: 'Cachix auth token for pushing (optional; pulls need no token)' + required: false + default: '' outputs: uv-version: @@ -109,22 +132,92 @@ outputs: runs: using: composite steps: + # ── Nix flake provisioning (toolchain SSoT) ───────────────────────── + # When provision-via-flake is true, install Nix + Cachix and build the + # flake dev-shell, then prepend its tool bin dirs to GITHUB_PATH so every + # subsequent step runs as if inside `nix develop`. The ad-hoc tool installs + # below (Python/uv, just, taplo, Node.js) are gated off in this + # mode. The Nix installer is SHA-pinned to match nix-cachix.yml and the + # vig-os Cachix substituter makes the dev-shell a fast binary-cache pull. + # Refs #632. + - name: Install Nix (upstream CppNix) + if: inputs.provision-via-flake == 'true' + uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + with: + extra_nix_config: | + experimental-features = nix-command flakes + accept-flake-config = true + + - name: Configure Cachix + if: inputs.provision-via-flake == 'true' && inputs.cachix-cache != '' + uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 + with: + name: ${{ inputs.cachix-cache }} + authToken: ${{ inputs.cachix-auth-token }} + + - name: Build flake dev-shell and enter it + if: inputs.provision-via-flake == 'true' + shell: bash + run: | + set -euo pipefail + # Build the dev-shell into a gcroot profile (warm Cachix pull). + nix develop --profile "$RUNNER_TEMP/dev-profile" --command true + + # Capture the dev-shell PATH and prepend its nix-store bin dirs to + # GITHUB_PATH so all following steps resolve the flake's tools first. + # + # Exclude the flake's podman: rootless podman on GitHub runners relies + # on the host's setuid newuidmap/newgidmap and container config, which + # the nix-store podman does not see, so `podman info` fails. The host's + # preinstalled podman is kept on PATH by NOT shadowing it here (matches + # the apt-podman intent documented on the provision-via-flake input). + # Refs #632. + SHELL_PATH="$(nix develop --profile "$RUNNER_TEMP/dev-profile" \ + --command bash -c 'printf "%s" "$PATH"')" + + printf '%s' "$SHELL_PATH" | tr ':' '\n' \ + | grep '^/nix/store' \ + | grep -v '/nix/store/[^/]*-podman[^/]*/bin' >> "$GITHUB_PATH" + + # Propagate the dev-shell's uv Python-download metadata URL to later + # steps. GITHUB_PATH only carries PATH, not env vars, so this var (set + # in the flake dev-shell, the SSoT) must be forwarded explicitly so + # `uv sync` can fetch the project's pinned CPython, which nixpkgs does + # not package and the nixpkgs uv cannot download on its own. Refs #632. + # The dev-shell's shellHook prints a banner to stdout, so capture the + # var on its own line and extract just that line (mirrors the PATH + # filtering above). + UV_DL_JSON_URL="$(nix develop --profile "$RUNNER_TEMP/dev-profile" \ + --command bash -c 'printf "UV_PYTHON_DOWNLOADS_JSON_URL=%s\n" "${UV_PYTHON_DOWNLOADS_JSON_URL:-}"' \ + | grep '^UV_PYTHON_DOWNLOADS_JSON_URL=' | tail -n1 | cut -d= -f2-)" + if [ -n "$UV_DL_JSON_URL" ]; then + echo "UV_PYTHON_DOWNLOADS_JSON_URL=$UV_DL_JSON_URL" >> "$GITHUB_ENV" + echo "Forwarded UV_PYTHON_DOWNLOADS_JSON_URL=$UV_DL_JSON_URL" + fi + + echo "Flake dev-shell provisioned; tools added to PATH:" + printf '%s' "$SHELL_PATH" | tr ':' '\n' | grep '^/nix/store' | head -50 + # ── Python ─────────────────────────────────────────────────────────── + # Skipped under flake provisioning: uv (from the flake) manages the + # project interpreter via `uv sync` / `uv run`. - name: "Set up Python from pyproject" - if: inputs.install-python == 'true' && hashFiles('pyproject.toml') != '' + if: inputs.provision-via-flake != 'true' && inputs.install-python == 'true' && hashFiles('pyproject.toml') != '' uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version-file: "pyproject.toml" - name: "Set up Python fallback" - if: inputs.install-python == 'true' && hashFiles('pyproject.toml') == '' + if: inputs.provision-via-flake != 'true' && inputs.install-python == 'true' && hashFiles('pyproject.toml') == '' uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: ${{ inputs.python-version }} # ── uv ───────────────────────────────────────────────────────────── + # Skipped under flake provisioning: uv comes from the flake dev-shell. - name: Install uv id: setup-uv + if: inputs.provision-via-flake != 'true' continue-on-error: true uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: @@ -215,49 +308,17 @@ runs: node-version: ${{ inputs.node-version }} # ── Just (task runner) ────────────────────────────────────────────── + # Skipped under flake provisioning: just comes from the flake dev-shell. - name: Install just - if: inputs.install-just == 'true' + if: inputs.provision-via-flake != 'true' && inputs.install-just == 'true' uses: taiki-e/install-action@ab08a3b50948bd57d91bd2980f025da7e0a88231 # just with: tool: just - # ── hadolint (Containerfile linter) ─────────────────────────────────── - - name: Install hadolint - if: inputs.install-hadolint == 'true' - shell: bash - run: | - set -euo pipefail - - case "$(uname -m)" in - x86_64) ARCH="linux-x86_64" ;; - aarch64|arm64) ARCH="linux-arm64" ;; - *) - echo "Unsupported architecture: $(uname -m)" - exit 1 - ;; - esac - - HADOLINT_VERSION="v2.14.0" - BASE_URL="https://github.com/hadolint/hadolint/releases/download/${HADOLINT_VERSION}" - BIN_FILE="hadolint-${ARCH}" - SHA_FILE="${BIN_FILE}.sha256" - - retry --retries 3 --backoff 5 --max-backoff 60 -- \ - curl -fsSL "${BASE_URL}/${BIN_FILE}" -o "${BIN_FILE}" - retry --retries 3 --backoff 5 --max-backoff 60 -- \ - curl -fsSL "${BASE_URL}/${SHA_FILE}" -o "${SHA_FILE}" - - EXPECTED_SHA="$(awk '{print $1}' "${SHA_FILE}")" - echo "${EXPECTED_SHA} ${BIN_FILE}" | sha256sum -c - - - sudo install -m 0755 "${BIN_FILE}" /usr/local/bin/hadolint - rm -f "${BIN_FILE}" "${SHA_FILE}" - - hadolint --version - # ── taplo (TOML linter/formatter) ────────────────────────────────── + # Skipped under flake provisioning: taplo comes from the flake dev-shell. - name: Install taplo - if: inputs.install-taplo == 'true' + if: inputs.provision-via-flake != 'true' && inputs.install-taplo == 'true' shell: bash run: | set -euo pipefail @@ -309,11 +370,13 @@ runs: echo "devcontainer $(devcontainer --version)" # ── BATS (shell testing) ───────────────────────────────────────── - # Installs BATS core and helper libraries via the official action. - # Versions match package.json to keep local and CI environments in sync. + # Under flake provisioning BATS comes from the flake (the SSoT): the + # bats.withLibraries wrapper is on PATH and exports BATS_LIB_PATH, so these + # ad-hoc steps are skipped (mirrors just/taplo). The official action remains + # for non-flake callers, installing BATS core + helper libraries. Refs #695. - name: Setup BATS and libraries id: bats - if: inputs.install-bats == 'true' + if: inputs.provision-via-flake != 'true' && inputs.install-bats == 'true' uses: bats-core/bats-action@77d6fb60505b4d0d1d73e48bd035b55074bbfb43 # v4.0.0 with: support-version: '0.3.0' @@ -322,7 +385,7 @@ runs: detik-install: 'false' - name: Export BATS_LIB_PATH - if: inputs.install-bats == 'true' + if: inputs.provision-via-flake != 'true' && inputs.install-bats == 'true' shell: bash run: | # The bats-core/bats-action installs libraries to standard system paths diff --git a/.github/actions/test-image/action.yml b/.github/actions/test-image/action.yml index b47e52c4..facffcd8 100644 --- a/.github/actions/test-image/action.yml +++ b/.github/actions/test-image/action.yml @@ -58,6 +58,14 @@ inputs: description: 'Git ref to checkout (e.g., commit SHA, branch, tag)' required: false default: '' + cachix-cache: + description: 'Cachix binary cache name (passed to flake provisioning)' + required: false + default: '' + cachix-auth-token: + description: 'Cachix auth token (optional; pulls need no token)' + required: false + default: '' outputs: test-result: @@ -76,7 +84,12 @@ runs: uses: ./.github/actions/setup-env with: sync-dependencies: 'true' + # podman stays on the apt path (rootless host integration). uv and the + # rest of the toolchain come from the flake (SSoT). Refs #632. install-podman: 'true' + provision-via-flake: 'true' + cachix-cache: ${{ inputs.cachix-cache }} + cachix-auth-token: ${{ inputs.cachix-auth-token }} - name: Load image from tar if: inputs.image-source == 'tar' diff --git a/.github/actions/test-integration/action.yml b/.github/actions/test-integration/action.yml index 78778d07..7d6611c9 100644 --- a/.github/actions/test-integration/action.yml +++ b/.github/actions/test-integration/action.yml @@ -36,6 +36,14 @@ inputs: description: 'Git ref to checkout (e.g., commit SHA, branch, tag)' required: false default: '' + cachix-cache: + description: 'Cachix binary cache name (passed to flake provisioning)' + required: false + default: '' + cachix-auth-token: + description: 'Cachix auth token (optional; pulls need no token)' + required: false + default: '' outputs: test-result: @@ -54,8 +62,14 @@ runs: uses: ./.github/actions/setup-env with: sync-dependencies: 'true' + # podman and the devcontainer CLI keep their dedicated install paths + # (host integration / npm-global); uv and the rest come from the flake + # (SSoT). Refs #632. install-podman: 'true' install-devcontainer-cli: 'true' + provision-via-flake: 'true' + cachix-cache: ${{ inputs.cachix-cache }} + cachix-auth-token: ${{ inputs.cachix-auth-token }} - name: Load image from tar shell: bash diff --git a/.github/actions/test-project/action.yml b/.github/actions/test-project/action.yml index 803ea8b0..84cbd44e 100644 --- a/.github/actions/test-project/action.yml +++ b/.github/actions/test-project/action.yml @@ -35,6 +35,16 @@ inputs: required: false default: '-v -s --tb=short' + cachix-cache: + description: 'Cachix binary cache name (passed to flake provisioning)' + required: false + default: '' + + cachix-auth-token: + description: 'Cachix auth token (optional; pulls need no token)' + required: false + default: '' + runs: using: composite steps: @@ -45,9 +55,12 @@ runs: uses: ./.github/actions/setup-env with: sync-dependencies: 'true' - install-hadolint: 'true' install-taplo: 'true' install-bats: 'true' + # Provision the toolchain from the flake (SSoT). Refs #632. + provision-via-flake: 'true' + cachix-cache: ${{ inputs.cachix-cache }} + cachix-auth-token: ${{ inputs.cachix-auth-token }} - name: Cache pre-commit hooks if: inputs.suite == 'all' || inputs.suite == 'lint' diff --git a/.github/agent-blocklist.toml b/.github/agent-blocklist.toml index 94c28bfd..184f6cd8 100644 --- a/.github/agent-blocklist.toml +++ b/.github/agent-blocklist.toml @@ -24,6 +24,6 @@ emails = ["cursoragent@cursor.com", "noreply@cursor.com", "github-actions[bot]"] # Patterns that legitimately contain blocked names (regex, stripped before checking) # These are removed from content before name/email matching runs. allow_patterns = [ - "\\.[a-zA-Z][\\w-]*/[\\w./-]*", # dotfile paths: .cursor/skills/, .claude/commands/ + "\\.[a-zA-Z][\\w-]*/[\\w./-]*", # dotfile paths: .claude/skills/, .claude/commands/ "[A-Z]+\\.md", # doc files: CLAUDE.md ] diff --git a/.github/label-taxonomy.toml b/.github/label-taxonomy.toml index 7d547fac..c804dc9d 100644 --- a/.github/label-taxonomy.toml +++ b/.github/label-taxonomy.toml @@ -1,8 +1,8 @@ # Canonical repository labels. # Single source of truth — referenced by: # - uv run setup-labels (provision labels on a repo) -# - .cursor/skills/issue_triage/SKILL.md (triage label check) -# - .cursor/skills/issue_create/SKILL.md (agent label mapping) +# - .claude/skills/issue_triage/SKILL.md (triage label check) +# - .claude/skills/issue_create/SKILL.md (agent label mapping) # - .github/ISSUE_TEMPLATE/*.yml (template label values) # # Label reconciliation: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4cbb383..08671cf1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,9 +87,8 @@ jobs: release-url: ${{ steps.version.outputs.release_url }} build-timestamp: ${{ steps.version.outputs.build_timestamp }} vcs-ref: ${{ steps.version.outputs.vcs_ref }} - dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} - dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} - output-type: tar + cachix-cache: ${{ vars.CACHIX_CACHE }} + cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} output-file: /tmp/image.tar - name: Upload image artifact @@ -125,6 +124,8 @@ jobs: with: image-tag: ${{ needs.build-image.outputs.version }}-amd64 image-source: tar + cachix-cache: ${{ vars.CACHIX_CACHE }} + cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} test-integration: name: Integration Tests @@ -150,6 +151,8 @@ jobs: uses: ./.github/actions/test-integration with: image-tag: ${{ needs.build-image.outputs.version }}-amd64 + cachix-cache: ${{ vars.CACHIX_CACHE }} + cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - name: Upload test artifacts on failure if: failure() @@ -177,6 +180,16 @@ jobs: - name: Run project checks uses: ./.github/actions/test-project + with: + cachix-cache: ${{ vars.CACHIX_CACHE }} + cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} + + # Gate the flake itself: `test-project` provisions Nix + the flake + # dev-shell, so nix is on PATH here. `nix flake check` runs the flake's + # lightweight checks (nixfmt --check, dev-shell build, devShellTools + # eval). The richer pytest parity test stays in `test-project`. Refs #674. + - name: Check flake quality gates + run: nix flake check --accept-flake-config python-security: name: Python Security Scan @@ -244,11 +257,11 @@ jobs: permissions: contents: read - # PR CI runs Trivy as a blocking GATE only (fail on fixable HIGH/CRITICAL) - # plus non-blocking reports in the job log. It does NOT upload SARIF to the - # Security tab: the nightly scheduled scan of the published :latest image - # (security-scan.yml, category container-image-latest) is the single source - # of truth for the GitHub Security tab. Refs #604. + # The Nix image has no apt/dpkg DB, so Trivy's OS-package scanner is largely + # dark here; the authoritative CVE gate is the nightly vulnix scan + # (security-scan.yml, scan-nix-image — blocking via vulnix-gate). PR CI runs + # Trivy non-blocking for awareness + a CycloneDX SBOM artifact, and validates + # the exception registers' expiry. Refs #642, #637. steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -258,8 +271,8 @@ jobs: with: sync-dependencies: 'true' - - name: Validate .trivyignore exception expirations - run: uv run check-expirations .trivyignore + - name: Validate .trivyignore/.vulnixignore exception expirations + run: uv run check-expirations .trivyignore .vulnixignore - name: Download image artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -267,19 +280,7 @@ jobs: name: container-image-${{ needs.build-image.outputs.version }}-amd64 path: /tmp - - name: Scan image for vulnerabilities - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 - with: - version: 'v0.71.2' - input: /tmp/image.tar - format: 'table' - severity: 'HIGH,CRITICAL' - exit-code: '1' - ignore-unfixed: true - trivyignores: '.trivyignore' - - - name: Report unfixed HIGH/CRITICAL vulnerabilities (non-blocking) - if: always() + - name: Report HIGH/CRITICAL vulnerabilities (non-blocking) uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: version: 'v0.71.2' diff --git a/.github/workflows/nix-cachix.yml b/.github/workflows/nix-cachix.yml new file mode 100644 index 00000000..ad7783c7 --- /dev/null +++ b/.github/workflows/nix-cachix.yml @@ -0,0 +1,67 @@ +# Nix dev-shell cache warming (Cachix) +# +# Builds the flake dev-shell and pushes its closure to the `vig-os` Cachix +# binary cache so later tracks (#633, T2.x) and contributors get a warm cache. +# +# This workflow is intentionally NON-BLOCKING: it is a standalone workflow that +# is not part of the required CI gate, and the build/push step is guarded with +# `continue-on-error: true`. It can never fail the existing CI. +# +# Evaluator choice: upstream CppNix via cachix/install-nix-action. The flake is +# installer-agnostic — swapping to the Lix or Determinate installer needs no +# flake changes. +# +# Triggers: +# - Push to the migration epic / main branches (warm the cache on merge) +# - workflow_dispatch (manually warm from any branch, incl. feature branches) + +name: Nix Cachix + +on: # yamllint disable-line rule:truthy + push: + branches: + - main + - dev + - 'feature/625-nix-claude-migration' + paths: + - 'flake.nix' + - 'flake.lock' + - '.github/workflows/nix-cachix.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + push-devshell: + name: Build & push dev-shell to Cachix + runs-on: ubuntu-24.04 + timeout-minutes: 30 + # Non-blocking: this job is not a required check and never fails CI. + continue-on-error: true + + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install Nix (upstream CppNix) + uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + with: + extra_nix_config: | + experimental-features = nix-command flakes + accept-flake-config = true + + - name: Configure Cachix + uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 + with: + name: ${{ vars.CACHIX_CACHE }} + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - name: Build dev-shell and push closure to Cachix + run: | + set -euo pipefail + # Build the dev-shell derivation and its full closure. + nix develop --profile dev-profile --command true + # Push the dev-shell closure (and its build-time deps) to the cache. + nix path-info --recursive ./dev-profile \ + | cachix push "${{ vars.CACHIX_CACHE }}" diff --git a/.github/workflows/nix-image.yml b/.github/workflows/nix-image.yml new file mode 100644 index 00000000..7d5f94ed --- /dev/null +++ b/.github/workflows/nix-image.yml @@ -0,0 +1,245 @@ +# Nix-built devcontainer image (discovery phase, multi-arch, non-cutover) +# +# Builds the devcontainer image with Nix (`dockerTools.buildLayeredImage`, NOT a +# Dockerfile `FROM`) natively on amd64 + arm64, runs the portable testinfra suite +# (#635) against each arch, pushes per-arch discovery tags, and assembles a +# multi-arch index so downstream digest-pinning can resolve a top-level index +# (#636). +# +# The portable testinfra suite now passes on the Nix image, so the +# build-and-test job GATES on it (#666); the FHS/bootstrap discovery gaps are +# closed. The per-arch push and the multi-arch-index job stay +# `continue-on-error` so a registry hiccup cannot fail the build/test gate. +# - It pushes ONLY the disposable `nix-dev*` discovery tags (per-arch + +# index). It never touches the versioned or `:latest` cutover tags — the +# publish-cutover is #639. These tags exist so `imagetools inspect` can prove +# the index shape and so the arm64 closure lands in Cachix. +# +# Evaluator choice: upstream CppNix via cachix/install-nix-action, matching +# nix-cachix.yml. The flake bakes CppNix (`pkgs.nix`) into the image closure so +# `nix`/`direnv` are live inside the container. +# +# Multi-arch: each leg builds natively on its own runner (no QEMU / +# cross-compilation) — `nix build .#devcontainerImage` resolves to x86_64-linux +# on the amd64 runner and aarch64-linux on the arm64 runner, so the per-arch +# image config carries the correct `architecture`. The index is then assembled +# with `docker buildx imagetools create`, mirroring the proven release.yml flow. +# +# Triggers: +# - Push to the migration epic branch when the flake or this workflow changes. +# - workflow_dispatch (so it can be triggered later from the default branch). + +name: Nix Image (discovery) + +on: # yamllint disable-line rule:truthy + push: + branches: + - 'feature/625-nix-claude-migration' + paths: + - 'flake.nix' + - 'flake.lock' + - '.github/workflows/nix-image.yml' + workflow_dispatch: + +permissions: + contents: read + packages: write # push per-arch discovery tags + assemble the multi-arch index + +env: + REGISTRY: ghcr.io/vig-os/devcontainer + # Disposable discovery index tag. NOT a cutover tag (versioned/:latest is #639). + INDEX_TAG: nix-dev + +jobs: + build-and-test: + name: Build Nix image & run portable testinfra (${{ matrix.arch }}) + runs-on: ${{ matrix.arch == 'amd64' && 'ubuntu-24.04' || 'ubuntu-24.04-arm' }} + timeout-minutes: 45 + # Gating (#666): the Nix image now passes the full portable testinfra suite, + # so a regression here fails CI. (The per-arch push step below stays + # continue-on-error — a registry hiccup must not fail the build/test gate.) + strategy: + fail-fast: false + matrix: + arch: [amd64, arm64] + + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install Nix (upstream CppNix) + uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + with: + extra_nix_config: | + experimental-features = nix-command flakes + accept-flake-config = true + + # authToken set => cachix-action pushes every store path built in this job + # on post-run, so the arm64 closure lands in the `vig-os` cache too (#636). + - name: Configure Cachix (push arch closure) + uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 + with: + name: ${{ vars.CACHIX_CACHE }} + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - name: Build the devcontainer image with Nix + run: | + set -euo pipefail + # buildLayeredImage emits a gzipped OCI tarball at ./result. Built + # natively for ${{ matrix.arch }} on this runner's own architecture. + nix build .#devcontainerImage --print-build-logs + # Stage it where the test-image composite action expects a tar file. + cp -L result /tmp/nix-devcontainer-image.tar.gz + ls -lL /tmp/nix-devcontainer-image.tar.gz + + - name: Record image size + run: | + set -euo pipefail + echo "Compressed tarball size (${{ matrix.arch }}):" + du -h /tmp/nix-devcontainer-image.tar.gz + + # Reuse the shared composite action: it loads the tar into podman, retags + # it to ghcr.io/vig-os/devcontainer:nix-image (local only, never pushed), + # and runs tests/test_image.py via TEST_CONTAINER_TAG. Gating now (#666): + # the Nix image passes the full suite, so a failure here fails CI. + - name: Load image and run portable testinfra + id: testinfra + uses: ./.github/actions/test-image + with: + image-tag: 'nix-image' + image-source: 'tar' + tar-file: '/tmp/nix-devcontainer-image.tar.gz' + skip-container-check: 'true' + + - name: Report testinfra result + if: always() + run: | + echo "Portable testinfra result code: ${{ steps.testinfra.outputs.test-result }}" + + # Runtime smoke test (#675): the portable testinfra suite only asserts that + # `nix`/`direnv`/`nix-direnv` are PRESENT, not that the baked Nix runtime + # actually FUNCTIONS. A regression that left the binaries on PATH but broke + # the evaluator or direnv would slip through. Run the self-contained smoke + # script INSIDE the image (loaded into podman by the test-image action as + # ${REGISTRY}:nix-image) — it checks `nix --version`, `direnv version`, a + # real `nix eval` (evaluator + nix-command/flakes), and a direnv + # allow/exec round-trip, all network-free, and fails CI if any is broken. + - name: Smoke-test the in-container Nix runtime (#675) + run: | + set -euo pipefail + # Bind-mount the repo's scripts/ read-only and execute the smoke + # script with the image's baked bash, so we never bake the test into + # the image. Gates this job: a non-zero exit fails the build/test gate. + podman run --rm \ + -v "${PWD}/scripts":/smoke:ro \ + "${REGISTRY}:nix-image" \ + bash /smoke/nix_runtime_smoke.sh + + - name: Log in to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ github.token }} + + - name: Push the per-arch discovery tag + # A registry hiccup must not fail the build/test gate (#666). + continue-on-error: true + run: | + set -euo pipefail + # Load into docker (separate engine from the podman testinfra step) and + # read back whatever tag the flake stamped, so we never hardcode it. + LOADED=$(docker load -i /tmp/nix-devcontainer-image.tar.gz \ + | sed -n 's/^Loaded image: //p' | head -n1) + echo "Loaded: ${LOADED}" + ARCH_TAG="${REGISTRY}:${INDEX_TAG}-${{ matrix.arch }}" + docker tag "${LOADED}" "${ARCH_TAG}" + docker push "${ARCH_TAG}" + echo "✓ Pushed ${ARCH_TAG}" + + # Regression guard (#664): a Nix image bakes the workspace template as + # read-only /nix/store symlinks, so the scaffold must produce REAL, writable + # files (not dangling symlinks on the host). The install/integration suite + # only covers the Debian image, so assert the Nix scaffold here. + - name: Assert the scaffold has no dangling store symlinks (#664) + if: matrix.arch == 'amd64' + run: | + set -euo pipefail + dest="$(mktemp -d)" + # `just sync` at the end may fail offline; the scaffold (rsync -L + + # chmod u+w) runs first, which is what we assert. Bound the run. + timeout 300 docker run --rm \ + -e SHORT_NAME=ci -e ORG_NAME=ci -e GITHUB_REPOSITORY=ci/ci \ + -v "${dest}":/workspace "${REGISTRY}:${INDEX_TAG}-amd64" \ + /root/assets/init-workspace.sh --no-prompts --mode both || true + echo "Scaffolded top-level:"; ls -A "${dest}" + dangling="$(find "${dest}" -xtype l || true)" + if [ -n "${dangling}" ]; then + echo "::error::scaffold contains dangling symlinks (store-symlink bug regressed):" + echo "${dangling}" + exit 1 + fi + if [ ! -f "${dest}/flake.nix" ] || [ -L "${dest}/flake.nix" ]; then + echo "::error::flake.nix missing or a symlink — expected a real file" + exit 1 + fi + echo "✓ scaffold is real files with no dangling symlinks" + + multi-arch-index: + name: Assemble & verify the multi-arch index + needs: build-and-test + runs-on: ubuntu-24.04 + timeout-minutes: 10 + # Non-blocking: discovery phase (cutover is #639). + continue-on-error: true + permissions: + contents: read + packages: write + + steps: + - name: Log in to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ github.token }} + + - name: Create the multi-arch index + run: | + set -euo pipefail + # Mirror release.yml: combine the per-arch tags into a single top-level + # OCI image index (what downstream digest-pinning resolves against). + docker buildx imagetools create \ + --tag "${REGISTRY}:${INDEX_TAG}" \ + "${REGISTRY}:${INDEX_TAG}-amd64" \ + "${REGISTRY}:${INDEX_TAG}-arm64" + echo "✓ Created index ${REGISTRY}:${INDEX_TAG}" + + - name: Verify the index covers amd64 + arm64 + run: | + set -euo pipefail + docker buildx imagetools inspect "${REGISTRY}:${INDEX_TAG}" | tee /tmp/index-inspect.txt + missing=0 + for plat in linux/amd64 linux/arm64; do + if grep -q "${plat}" /tmp/index-inspect.txt; then + echo "✓ index advertises ${plat}" + else + echo "::error::index ${REGISTRY}:${INDEX_TAG} is missing ${plat}" + missing=1 + fi + done + [ "${missing}" -eq 0 ] + + - name: Write index summary + if: always() + run: | + { + echo "## Nix multi-arch index (discovery)" + echo "" + echo "- **Index tag:** \`${REGISTRY}:${INDEX_TAG}\` (disposable; cutover is #639)" + echo "- **Per-arch tags:** \`${INDEX_TAG}-amd64\`, \`${INDEX_TAG}-arm64\`" + echo "" + echo '```' + cat /tmp/index-inspect.txt 2>/dev/null || echo "(index not assembled)" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ad4f054c..fc6545c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -763,9 +763,8 @@ jobs: release-url: ${{ needs.validate.outputs.release_url }} build-timestamp: ${{ needs.validate.outputs.build_timestamp }} vcs-ref: ${{ github.sha }} - dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} - dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} - output-type: tar + cachix-cache: ${{ vars.CACHIX_CACHE }} + cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} output-file: /tmp/image.tar - name: Run image tests diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 78ab99fb..f7541b03 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -1,25 +1,19 @@ # Scheduled Security Scan # -# Nightly scan of the latest published container image from GHCR (covers main via -# :latest) without rebuilding. This scan (category: container-image-latest) is the -# single source of truth for the GitHub Security tab. Pull requests to -# dev/release/main run full CI (ci.yml), where Trivy acts as a blocking GATE only -# (fail on fixable HIGH/CRITICAL) and does NOT upload SARIF to the Security tab. +# Nightly CVE scan of the Nix-built devcontainer image. vulnix (nixpkgs-native) +# is the primary scanner — the image has no apt/dpkg DB — gated by `vulnix-gate` +# against the `.vulnixignore` exception register; Trivy emits a CycloneDX SBOM + +# an SBOM-mode view for defence in depth. (The Debian `:latest` Trivy job was +# removed with the Debian path in #642.) # # Triggers: # - Nightly schedule: 05:00 UTC # # Jobs: -# 1. scan-latest - Pull ghcr.io/vig-os/devcontainer:latest and Trivy + SBOM + SARIF -# -# When fixable HIGH/CRITICAL vulnerabilities are found (after .trivyignore): -# - Workflow fails (GitHub notifies watchers) -# - One open issue with label security-scan is created (deduplicated) -# - Step summary records verdict and links +# 1. scan-nix-image - build the image closure, run vulnix (+ Trivy SBOM) # # Artifacts: -# - SBOM (CycloneDX) with all packages -# - SARIF report uploaded to GitHub Security tab (category: container-image-latest) +# - vulnix findings (JSON + table) and a CycloneDX SBOM (uploaded) name: Scheduled Security Scan @@ -32,174 +26,116 @@ permissions: contents: read jobs: - scan-latest: - name: Scan GHCR Latest Image + # vulnix CVE scan of the Nix-built image (T3.1, #637). + # + # A Nix image has no apt/dpkg DB, so Trivy's OS-package scanner goes dark. + # vulnix (nixpkgs-native) becomes the PRIMARY CVE signal here, scanning the + # image's package closure (flake `devcontainerImageEnv`); Trivy stays on for a + # CycloneDX SBOM + an SBOM-mode vuln view (defense in depth). The vulnix-gate + # step is BLOCKING (#639): the nixpkgs baseline was advanced to 26.05 and the + # residual findings triaged into .vulnixignore (see docs/security/ + # nix-cutover-scan-overlap.md), so any new unexcepted HIGH/CRITICAL fails the + # scan. SARIF upload to the Security tab + a deduplicated issue can be added + # alongside the actual publish-cutover. + scan-nix-image: + name: Scan Nix image (vulnix + SBOM) runs-on: ubuntu-24.04 - timeout-minutes: 20 + timeout-minutes: 45 permissions: contents: read - issues: write # create deduplicated security-scan issue on gate failure - security-events: write # upload SARIF results to GitHub Security tab steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Set up environment + - name: Install Nix (upstream CppNix) + uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + with: + extra_nix_config: | + experimental-features = nix-command flakes + accept-flake-config = true + + - name: Configure Cachix (pull-only substituter) + uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 + with: + name: ${{ vars.CACHIX_CACHE }} + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - name: Set up environment (uv for the gate utilities) uses: ./.github/actions/setup-env with: sync-dependencies: 'true' - - name: Validate .trivyignore exception expirations - run: uv run check-expirations .trivyignore + - name: Validate .vulnixignore exception expirations + run: uv run check-expirations .vulnixignore - - name: Pull latest image and save as tar - id: pull - continue-on-error: true + - name: Build the vulnix scan target (image package closure) run: | set -euo pipefail - IMAGE=ghcr.io/vig-os/devcontainer:latest - docker pull "$IMAGE" - docker save "$IMAGE" -o /tmp/image-latest.tar - RESOLVED=$(docker inspect --format '{{range .RepoDigests}}{{.}}{{"\n"}}{{end}}' "$IMAGE" | head -n1 | tr -d '\n') - if [ -z "$RESOLVED" ] || [ "$RESOLVED" = '' ]; then - RESOLVED=$(docker inspect --format='{{.Id}}' "$IMAGE") - fi - echo "image_ref=${RESOLVED}" >> "$GITHUB_OUTPUT" - - - name: Log when latest image is unavailable - if: steps.pull.outcome != 'success' + nix build .#devcontainerImageEnv --print-build-logs + + - name: Run vulnix (primary CVE scanner) run: | - echo "::error::Scan skipped: GHCR devcontainer:latest pull failed" - exit 1 + set -euo pipefail + # vulnix exits non-zero when advisories are found; the gate decides + # pass/fail, so don't let the scan itself fail the step. + nix run .#vulnix -- --closure ./result --json > vulnix-findings.json || true + nix run .#vulnix -- --closure ./result | tee vulnix-report.txt || true + + - name: Gate on unexcepted HIGH/CRITICAL vulnix findings + id: vulnix-gate + # BLOCKING (#639): fails the nightly scan on any HIGH/CRITICAL not covered + # by a non-expired entry in .vulnixignore. This is the go/no-go gate for + # the publish-cutover. + run: uv run vulnix-gate vulnix-findings.json --register .vulnixignore + + - name: Build the Nix image for SBOM generation + run: | + set -euo pipefail + nix build .#devcontainerImage --print-build-logs + cp -L result /tmp/nix-image.tar.gz - - name: Scan latest image for all vulnerabilities - if: steps.pull.outcome == 'success' + - name: Generate Nix image SBOM (CycloneDX) uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: version: 'v0.71.2' - input: /tmp/image-latest.tar - format: 'table' - severity: 'UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL' - exit-code: '0' # Non-blocking: for awareness only - trivyignores: '.trivyignore' + input: /tmp/nix-image.tar.gz + format: 'cyclonedx' + output: 'sbom-nix-cyclonedx.json' - - name: Gate on fixable HIGH/CRITICAL vulnerabilities - id: gate - if: steps.pull.outcome == 'success' + - name: Trivy SBOM-mode scan (defense in depth, awareness only) continue-on-error: true uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: version: 'v0.71.2' - input: /tmp/image-latest.tar + scan-type: 'sbom' + scan-ref: 'sbom-nix-cyclonedx.json' format: 'table' severity: 'HIGH,CRITICAL' - exit-code: '1' - ignore-unfixed: true - trivyignores: '.trivyignore' - - - name: Generate latest image SBOM (CycloneDX) - if: steps.pull.outcome == 'success' - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 - with: - version: 'v0.71.2' - input: /tmp/image-latest.tar - format: 'cyclonedx' - output: 'sbom-latest-cyclonedx.json' + exit-code: '0' - - name: Generate latest image SARIF report - if: steps.pull.outcome == 'success' - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 - with: - version: 'v0.71.2' - input: /tmp/image-latest.tar - format: 'sarif' - output: 'trivy-latest-results.sarif' - severity: 'UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL' - trivyignores: '.trivyignore' - - - name: Upload latest SBOM artifact - if: steps.pull.outcome == 'success' + - name: Upload vulnix findings + SBOM (Trivy-vs-vulnix overlap evidence) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: sbom-latest - path: sbom-latest-cyclonedx.json + name: nix-image-cve-scan + path: | + vulnix-findings.json + vulnix-report.txt + sbom-nix-cyclonedx.json retention-days: 90 - - name: Upload latest SARIF to GitHub Security - if: steps.pull.outcome == 'success' - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 - with: - sarif_file: trivy-latest-results.sarif - category: 'container-image-latest' - - - name: Write scan summary - if: steps.pull.outcome == 'success' - env: - IMAGE_REF: ${{ steps.pull.outputs.image_ref }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - SECURITY_URL: ${{ github.server_url }}/${{ github.repository }}/security + - name: Write Nix image scan summary + if: always() run: | set -euo pipefail { - echo "## Scheduled security scan (:latest)" + echo "## Nix image CVE scan (vulnix + SBOM)" echo "" - echo "- **Image (resolved):** \`${IMAGE_REF}\`" - echo "- **Tag pulled:** \`ghcr.io/vig-os/devcontainer:latest\`" + echo "- **Scan target:** flake \`devcontainerImageEnv\` (image package closure)" + echo "- **Primary scanner:** vulnix (nixpkgs-native)" echo "- **Date (UTC):** $(date -u +%Y-%m-%dT%H:%M:%SZ)" - echo "- **Workflow run:** [${{ github.run_id }}](${RUN_URL})" - echo "" - if [ "${{ steps.gate.outcome }}" = "failure" ]; then - echo "### Result" - echo "" - echo "**Fixable HIGH/CRITICAL vulnerabilities detected** (see Trivy table in job logs and [Security](${SECURITY_URL}))." - else - echo "### Result" - echo "" - echo "No fixable HIGH/CRITICAL vulnerabilities passed the gate (after \`.trivyignore\`)." - fi + echo "- **Gate:** ${{ steps.vulnix-gate.outcome }} (non-blocking during discovery; blocking at #639)" echo "" - echo "Full dependency results: [Code security](${SECURITY_URL})." + echo "vulnix over-reports CVEs already patched in nixpkgs; triage findings" + echo "into \`.vulnixignore\` and advance the nixpkgs rev before #639." } >> "$GITHUB_STEP_SUMMARY" - - - name: Create GitHub issue on gate failure - if: steps.pull.outcome == 'success' && steps.gate.outcome == 'failure' - env: - GH_TOKEN: ${{ github.token }} - IMAGE_REF: ${{ steps.pull.outputs.image_ref }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - SECURITY_URL: ${{ github.server_url }}/${{ github.repository }}/security - run: | - set -euo pipefail - gh label create security-scan \ - --description 'Automated nightly security scan findings for :latest image' \ - --color BFD4F2 2>/dev/null || true - EXISTING=$(gh issue list --label security-scan --state open --limit 1 --json number -q '.[0].number // empty') - if [ -n "$EXISTING" ]; then - echo "Open security-scan issue already exists: #$EXISTING -- skipping create" - exit 0 - fi - BODY="$(cat < ./result) +/result +/result-* # Environments .env @@ -206,6 +209,10 @@ cython_debug/ # you could uncomment the following to ignore the entire vscode folder # .vscode/ +# Claude Code +# Per-clone local settings & tool permissions; must stay untracked. +.claude/settings.local.json + # Ruff stuff: .ruff_cache/ diff --git a/.hadolint.yaml b/.hadolint.yaml deleted file mode 100644 index 649b54fa..00000000 --- a/.hadolint.yaml +++ /dev/null @@ -1,24 +0,0 @@ ---- -# Hadolint configuration file -# This file configures the Containerfile linter - -# Ignore specific rules -ignored: - - DL3008 # Pin versions in apt-get - # - DL3009 # Delete the apt-get lists after installing something - - DL3013 # Pin versions in pip - - DL4006 # Set the SHELL option -o pipefail before RUN with pipes - - DL4001 # Either use Wget or Curl but not both - -# Set the output format -# tty | json | checkstyle | codeclimate | gitlab_codeclimate | -# gnu | codacy | sonarqube | sarif -format: tty - -# Set the failure threshold (error | warning | info | style | ignore | none) -failure-threshold: warning - -# Trusted registries (optional) -trustedRegistries: - - docker.io - - ghcr.io diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c925af76..59740dca 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,13 +34,19 @@ repos: - id: mixed-line-ending - id: trailing-whitespace - # Python Linting and Formatting (Ruff) - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: aad66557af3b56ba6d4d69cd1b6cba87cef50cbb # v0.14.3 + # Python Linting and Formatting (Ruff, sourced from the flake dev-shell) + - repo: local hooks: - id: ruff - args: [--fix] + name: ruff (lint/fix python) + entry: ruff check --fix + language: system + types: [python] - id: ruff-format + name: ruff-format (format python) + entry: ruff format + language: system + types: [python] # YAML Linting - repo: https://github.com/adrienverge/yamllint @@ -69,17 +75,10 @@ repos: hooks: - id: shellcheck name: shellcheck + # .envrc files (root + the scaffolded template stub) are direnv stdlib + # scripts with no shebang; exclude them from SC2148 at any path. args: ["-x"] - exclude: ^\.envrc$ - - # Containerfile - - repo: local - hooks: - - id: hadolint - name: hadolint - entry: hadolint - language: system - files: ^(.*/)?(Containerfile|Dockerfile)$ + exclude: (^|/)\.envrc$ # Markdown Linting (excludes auto-generated docs) - repo: https://github.com/jackdewinter/pymarkdown @@ -100,6 +99,16 @@ repos: files: ^justfile(\..*)?$ pass_filenames: false + # Nix formatting (nixfmt-rfc-style from the flake dev-shell toolchain) + - repo: local + hooks: + - id: nixfmt + name: nixfmt (format/check nix files) + entry: nixfmt --check + language: system + files: \.nix$ + types: [file] + # Documentation Generation (auto-regenerate on template/requirements changes) - repo: local hooks: @@ -107,7 +116,7 @@ repos: name: generate-docs (regenerate from templates) entry: uv run python docs/generate.py language: system - files: ^(docs/templates/.*\.j2|docs/narrative/.*\.md|scripts/requirements\.yaml|justfile|CHANGELOG\.md)$ + files: ^(docs/templates/.*\.j2|docs/narrative/.*\.md|scripts/requirements\.yaml|justfile|CHANGELOG\.md|\.claude/skills/.*/SKILL\.md)$ pass_filenames: false # Workspace sync (keep assets/workspace in sync with manifest) @@ -119,11 +128,13 @@ repos: language: system pass_filenames: false - # Typo Linting - - repo: https://github.com/crate-ci/typos - rev: 07d900b8fa1097806b8adb6391b0d3e0ac2fdea7 # v1.39.0 + # Typo Linting (typos sourced from the flake dev-shell) + - repo: local hooks: - id: typos + name: typos (source typo checker) + entry: typos + language: system # License Compliance Check (runs only when dependencies change) - repo: local @@ -160,19 +171,19 @@ repos: hooks: - id: check-skill-names name: check-skill-names (enforce naming convention) - entry: uv run check-skill-names .cursor/skills + entry: uv run check-skill-names .claude/skills language: system - files: ^\.cursor/skills/ + files: ^\.claude/skills/ pass_filenames: false # Security exception expiry enforcement (Refs: #566) - repo: local hooks: - id: check-expirations - name: check-expirations (.trivyignore expiry enforcement) - entry: uv run check-expirations .trivyignore + name: check-expirations (.trivyignore/.vulnixignore expiry enforcement) + entry: uv run check-expirations .trivyignore .vulnixignore language: system - files: ^\.trivyignore$ + files: ^\.(trivyignore|vulnixignore)$ pass_filenames: false # AI agent identity: strip trailers before commit-msg (Refs: #163) @@ -208,13 +219,3 @@ repos: "--refs-optional-types", "chore", "--blocked-patterns", ".github/agent-blocklist.toml", ] - - # Sync manifest - - repo: local - hooks: - - id: sync-manifest - name: sync manifest - entry: uv run python scripts/sync_manifest.py sync assets/workspace/ - language: system - files: ^scripts/manifest\.toml$ - pass_filenames: false diff --git a/.trivyignore b/.trivyignore index 30fce2c6..1e230e50 100644 --- a/.trivyignore +++ b/.trivyignore @@ -17,18 +17,6 @@ Expiration: 2026-09-01 CVE-2026-42504 -# CVE-2026-55388: piscina Prototype Pollution gadget -> RCE in cursor-agent CLI -# Risk Assessment: LOW (devcontainer context) -# - HIGH severity in piscina 4.9.0 bundled in the cursor-agent CLI -# (/root/.local/share/cursor-agent/.../node_modules/piscina); fixed upstream in piscina 4.9.3+ -# - cursor-agent is installed unpinned from cursor.com/install; we do not control its bundled -# deps and the fix can only arrive via a future cursor-agent build -# - Gadget requires attacker-controlled piscina options (inherited options.filename); not an -# attacker-reachable surface in devcontainer/CI use -# - Tracking: https://github.com/vig-os/devcontainer/issues/602, #512 -Expiration: 2026-09-01 -CVE-2026-55388 - # jwt-token: Trivy secret scan false positive # Risk Assessment: N/A (not a vulnerability) # - JWT-like string in typos crate test fixtures under /opt/pre-commit-cache @@ -36,119 +24,3 @@ CVE-2026-55388 # - Tracking: https://github.com/vig-os/devcontainer/issues/512 Expiration: 2026-09-01 jwt-token - -# Debian won't-fix LOW CVEs (Debian 12.14 OS packages) -# Risk Assessment: LOW (devcontainer context) -# - 78 unfixed LOW findings in Debian base packages; no upstream patch available -# - Ancient or Debian-marked won't-fix/affected; not exploitable in isolated devcontainer use -# - CI gates only fixable HIGH/CRITICAL (ignore-unfixed); these never block release -# - Re-scan after each base-image digest bump; drop entries when Debian ships fixes -# - Tracking: https://github.com/vig-os/devcontainer/issues/566, #512, #521 -Expiration: 2026-12-01 -# glibc -CVE-2010-4756 -CVE-2018-20796 -CVE-2019-1010022 -CVE-2019-1010023 -CVE-2019-1010024 -CVE-2019-1010025 -CVE-2019-9192 -# perl -CVE-2011-4116 -CVE-2023-31486 -# openssh-client -CVE-2007-2243 -CVE-2007-2768 -CVE-2008-3234 -CVE-2016-20012 -CVE-2018-15919 -CVE-2019-6110 -CVE-2020-14145 -CVE-2020-15778 -# curl -CVE-2024-2379 -CVE-2025-0725 -CVE-2025-10148 -CVE-2025-10966 -CVE-2025-14017 -CVE-2025-14524 -CVE-2025-14819 -CVE-2025-15079 -CVE-2025-15224 -# krb5 -CVE-2018-5709 -CVE-2024-26458 -CVE-2024-26461 -# systemd -CVE-2013-4392 -CVE-2023-31437 -CVE-2023-31438 -CVE-2023-31439 -CVE-2026-40228 -# openldap -CVE-2015-3276 -CVE-2017-14159 -CVE-2017-17740 -CVE-2020-15719 -CVE-2026-22185 -# git -CVE-2018-1000021 -CVE-2022-24975 -CVE-2024-52005 -# sqlite -CVE-2021-45346 -CVE-2025-29088 -CVE-2025-70873 -# expat -CVE-2023-52426 -CVE-2024-28757 -CVE-2026-24515 -CVE-2026-41080 -# jq -CVE-2024-23337 -CVE-2025-9403 -CVE-2026-40612 -# util-linux -CVE-2022-0563 -CVE-2025-14104 -# coreutils -CVE-2016-2781 -CVE-2017-18018 -CVE-2025-5278 -# gnupg -CVE-2022-3219 -# ncurses -CVE-2025-6141 -# gcc-binutils -CVE-2022-27943 -# libgcrypt -CVE-2018-6829 -CVE-2024-2236 -# iptables -CVE-2012-2663 -# tar -CVE-2005-2541 -TEMP-0290435-0B57B5 -# gnutls -CVE-2011-3389 -# glib2 -CVE-2012-0039 -# openssl -CVE-2025-27587 -# libtasn1 -CVE-2025-13151 -# rsync -CVE-2026-41035 -CVE-2026-45232 -# nano -CVE-2026-6842 -# sysvinit -TEMP-0517018-A83CE6 -# bash -TEMP-0841856-B18BAF -# shadow-utils -CVE-2007-5686 -CVE-2024-56433 -TEMP-0628843-DBAD28 -# apt -CVE-2011-3374 diff --git a/.typos.toml b/.typos.toml index e64ddb71..d1d6b033 100644 --- a/.typos.toml +++ b/.typos.toml @@ -4,3 +4,5 @@ [default.extend-words] # Shell scripting: sed label syntax uses 'ba' (branch to label 'a') ba = "ba" +# CVE policy term (#637): a finding "unexcepted" = not in the exception register +unexcepted = "unexcepted" diff --git a/.vulnixignore b/.vulnixignore new file mode 100644 index 00000000..1ece24bd --- /dev/null +++ b/.vulnixignore @@ -0,0 +1,83 @@ +# vulnix CVE Exception Register (Nix image) +# +# Companion to .trivyignore for the Nix-built image. vulnix scans the image's +# Nix-store package closure (flake `devcontainerImageEnv`); this register lists +# CVEs accepted as non-blocking, each with a risk note and an expiration date. +# Format is identical to .trivyignore and is validated by `check-expirations` +# (pre-commit + CI): an `Expiration: YYYY-MM-DD` directive applies to every +# entry below it until the next directive, and expired entries fail CI to force +# periodic review (IEC 62304 exception-register model). +# +# `vulnix-gate` consults this file: a HIGH/CRITICAL finding (CVSS v3 >= 7.0) is +# blocking only when it is NOT covered by a non-expired entry here. This is the +# objective go/no-go input for the publish-cutover (#639). +# +# NOTE (vulnix over-reporting): vulnix matches NVD by package name + upstream +# version and does NOT see nixpkgs' backported security patches, so it reports +# CVEs already fixed in the shipped derivation. The primary remediation lever is +# to advance the pinned nixpkgs rev (Renovate `nix` manager + lockFileMaintenance, +# #638); genuinely-not-applicable findings are accepted here with a rationale. +# Each accepted entry should record WHY (patched-in-nixpkgs / not-exploitable / +# awaiting-upstream) per docs/CONTAINER_SECURITY.md. +# +# Tracking: https://github.com/vig-os/devcontainer/issues/637 + +# ============================================================================ +# Triage for the nixos-26.05 baseline (#639 publish-cutover prep), 2026-06-23. +# +# Bumping the pinned channel nixos-25.05 -> nixos-26.05 (the "advance the rev" +# lever) cut the vulnix HIGH/CRITICAL surface from 83 to 27 unique CVEs. The +# residual splits into two classes, triaged below. The actual publish stays +# paused pending review of these entries (especially the CRITICALs). +# ============================================================================ + +# --- Class 1: not applicable (vulnix CPE mismatch — the CVE is a DIFFERENT +# product that shares a name). Definitive false positives; yearly re-check +# in case the NVD CPE data is corrected. --- +Expiration: 2027-06-23 +# shellcheck 0.11.0 — CVE-2021-28794 is an RCE in the *VS Code ShellCheck +# extension* (vscode-shellcheck), not the ShellCheck binary shipped here. +CVE-2021-28794 +# git 2.54.0 — Jenkins *Git plugin* advisories (XSS/CSRF/permission checks), +# not the git VCS. vulnix matches the "git" CPE against the wrong product. +CVE-2022-30947 +CVE-2022-36882 +CVE-2022-36883 + +# --- Class 2: recent CVEs vulnix matches by version against the current-stable +# nixpkgs (26.05). Low exploitability in an interactive, single-user dev +# container (no untrusted network services or inputs); the primary +# remediation is advancing the pinned nixpkgs rev as fixes land (Renovate +# `nix` manager + lockFileMaintenance, #638). Short expiry forces re-review +# each quarter as the pin advances. --- +Expiration: 2026-09-23 +# glibc 2.42 +CVE-2025-15281 +CVE-2026-4046 +CVE-2026-4437 +CVE-2026-5435 +CVE-2026-5450 +CVE-2026-5928 +# openssl 3.6.2 +CVE-2026-7383 +CVE-2026-9076 +CVE-2026-34180 +CVE-2026-34181 +CVE-2026-34182 +CVE-2026-34183 +CVE-2026-42764 +CVE-2026-42765 +CVE-2026-45445 +CVE-2026-45447 +# perl 5.42.0 +CVE-2026-4176 +# zlib 1.3.2 +CVE-2026-27820 +# sqlite 3.51.2 +CVE-2026-11822 +CVE-2026-11824 +# ldns 1.9.0 +CVE-2026-10846 +# libmicrohttpd 1.0.2 +CVE-2025-59777 +CVE-2025-62689 diff --git a/CHANGELOG.md b/CHANGELOG.md index fc9f41aa..a62d9709 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,16 +9,151 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Consolidated `docs/NIX.md` Nix reference** ([#255](https://github.com/vig-os/devcontainer/issues/255)) + - Added a single onboarding/architecture doc for the flake: the `devTools` toolchain SSoT and the dev-shell ↔ image parity guard, the stable/unstable channel split + fast-mover overlay, the Nix-built (`buildLayeredImage`) reproducible multi-arch image, the CppNix-vs-Lix and `pre-commit`-vs-`prek` decisions, the `vig-os` Cachix `direnv allow` flow, how `nixpkgs` bumps flow (Renovate `nix` manager + `vulnix` before/after), and the #639 publish-cutover — cross-linking `CONTRIBUTE.md`, `docs/NIX2CONTAINER.md`, and `docs/CONTAINER_SECURITY.md` +- **In-container Nix runtime smoke test** ([#675](https://github.com/vig-os/devcontainer/issues/675)) + - The `Nix Image (discovery)` workflow now runs a self-contained, network-free smoke script (`scripts/nix_runtime_smoke.sh`) inside the built image to prove the baked Nix toolchain actually *functions* (not merely that it is present, which is all the portable testinfra suite checked): `nix --version`, `direnv version`, a real `nix eval` exercising the evaluator with `nix-command`/`flakes`, and a `direnv allow`/`exec` round-trip — gating the build/test job so a broken in-container `nix`/`direnv` fails CI +- **Nix flake quality gates** ([#674](https://github.com/vig-os/devcontainer/issues/674)) + - Added a `formatter` output (`nixfmt-rfc-style`) so `nix fmt` formats nix files idempotently, a `nixfmt --check` pre-commit hook (nixfmt sourced from the flake dev-shell), lightweight flake `checks` (format check, dev-shell build, `devShellTools` eval), and a `nix flake check --accept-flake-config` step in the CI project-checks job +- **Install/init delivery-mode picker (`--mode devcontainer|direnv|both`)** ([#641](https://github.com/vig-os/devcontainer/issues/641)) + - `install.sh` gained a `--mode devcontainer|direnv|both` flag (accepts both `--mode X` and `--mode=X`), validated up front and passed through to `init-workspace.sh`. Empty means "let init-workspace decide": the one-line install runs non-interactively and defaults to `both` (unchanged behaviour) + - `init-workspace.sh` gained the same `--mode` flag plus an interactive prompt when the mode is unset and prompts are enabled (default selection `both`); under `--no-prompts`/`--smoke-test` with no `--mode` it defaults to `both`. After the rsync scaffold it prunes to the chosen mode: `devcontainer` removes the `flake.nix` + `.envrc` stub, `direnv` removes the `.devcontainer/` scaffold, and `both` keeps everything (prune is idempotent and scoped to the new workspace) +- **Downstream minimal flake stub (non-overwriting) + `nix2container` production builder** ([#640](https://github.com/vig-os/devcontainer/issues/640)) + - Scaffold `assets/workspace/flake.nix` (a minimal stub consuming the shared toolchain as a flake input — `vigos.url = github:vig-os/devcontainer`, `nixpkgs.follows = vigos/nixpkgs`, `vigos.lib.mkProjectShell` + a placeholder `extraPackages`) and `assets/workspace/.envrc` (`use flake` via nix-direnv). Updating the dev environment is `nix flake update vigos`; it never overwrites user files + - Added both to the `PRESERVE_FILES` never-overwrite class in `init-workspace.sh` (same guarantee as `justfile.project`) and committed the template `.envrc` (un-ignored in the template `.gitignore`, with `.direnv/`/`.envrc.local` still ignored) + - Documented the `nix2container` production-image pattern (`docs/NIX2CONTAINER.md`) with a buildable example (`examples/nix2container-production/`) that derives a minimal runtime image from the same pinned `nixpkgs`, plus a note on the future opt-in modular language shells +- **`vulnix` + SBOM CVE scanning for the Nix image; re-authored security policy** ([#637](https://github.com/vig-os/devcontainer/issues/637)) + - Added a nightly `scan-nix-image` job that builds the image's package closure (new flake `packages.devcontainerImageEnv`) and runs `vulnix` (the nixpkgs-native CVE scanner) as the primary signal, since a Nix image has no apt/dpkg database for Trivy's OS scanner; Trivy stays on to emit a CycloneDX SBOM and an SBOM-mode vuln view (defence in depth), and both scanners' output is archived as `vulnix`-vs-Trivy overlap evidence + - Added the `vulnix-gate` utility (`packages/vig-utils`) and the `.vulnixignore` exception register: a HIGH/CRITICAL finding (CVSS v3 ≥ 7.0) blocks only when it is not covered by a non-expired exception. `.vulnixignore` reuses the `.trivyignore` `Expiration:` format and the `check-expirations` validator (pre-commit + CI), and exposes a pinned `packages.vulnix` for reproducible scans. The gate is non-blocking during discovery and becomes the #639 go/no-go gate at cutover + - Re-authored `docs/CONTAINER_SECURITY.md` for the Nix posture: dropped the `apt --only-upgrade` escape hatch and the "why not `apt-get upgrade`" section, made "advance the pinned `nixpkgs` rev" the primary CVE lever, and documented the dual `.vulnixignore`/`.trivyignore` registers and the residual Debian `:latest` scan until decommission (#642) +- **Multi-arch Nix image (amd64 + arm64) discovery build** ([#636](https://github.com/vig-os/devcontainer/issues/636)) + - The `Nix Image (discovery)` workflow now builds `packages.devcontainerImage` natively on an amd64 (`ubuntu-24.04`) + arm64 (`ubuntu-24.04-arm`) matrix — no QEMU or cross-compilation — pushes per-arch discovery tags (`nix-dev-amd64`, `nix-dev-arm64`), and assembles a top-level multi-arch index (`nix-dev`) with `docker buildx imagetools create`, verifying both platforms via `imagetools inspect` + - `cachix-action` runs with an auth token on every leg so the arm64 closure is pushed to the `vig-os` Cachix cache; the workflow stays `continue-on-error` and only touches the disposable `nix-dev*` tags — the versioned/`:latest` publish-cutover remains #639 +- **Renovate `nix` manager for `flake.lock` maintenance** ([#638](https://github.com/vig-os/devcontainer/issues/638)) + - Enabled the Renovate `nix` manager and weekly `lockFileMaintenance` in `renovate.json` so flake inputs (notably `nixpkgs`) are bumped through the normal PR/CI gate; the existing `pep621`, `npm`, `github-actions`, and `dockerfile` managers are retained + - Documented the compensating control in `docs/CONTAINER_SECURITY.md`: every `flake.lock`/nixpkgs-bump PR must include a `vulnix` before/after diff, since the `nix` manager reports only the input revision change and not which CVE a bump fixes +- **De-duplicate the flake into the toolchain SSoT** ([#631](https://github.com/vig-os/devcontainer/issues/631)) + - Factored a single `devTools` list in `flake.nix` as the source of truth shared by the dev-shell now and the image later, absorbing the agent-CLI toolkit (`rg`, `fd`, `bat`, `eza`, `delta`, `lazygit`, `zoxide`, `starship`, `freeze`, `expect`, `nvim`) plus `claude` ([#545](https://github.com/vig-os/devcontainer/issues/545)) + - Pinned `nixpkgs` to `nixos-25.05` and added a `nixpkgs-unstable` input overlaid only for fast-movers (`uv`, `gh`, `claude-code`); refreshed `flake.lock` + - Added reusable flake outputs `lib.mkProjectShell`, `overlays.default`, and a `packages.devcontainerImage` stub for the later image build + - Added a non-blocking `Nix Cachix` workflow (with `workflow_dispatch`) that builds the dev-shell and pushes its closure to the `vig-os` Cachix cache + - Added a per-tool `nix develop -c --version` parity test driven from the flake SSoT to guard against future dev-shell/image drift +- **nix-direnv onboarding fast path** ([#633](https://github.com/vig-os/devcontainer/issues/633)) + - Switched `.envrc` from bare `use flake` to nix-direnv: the dev-shell evaluation is now GC-rooted and cached under `.direnv/`, so re-entering the directory is instant and the closure is never garbage-collected; nix-direnv self-bootstraps on first `direnv allow` and falls back to bare `use flake` when unavailable + - Documented the clone → `direnv allow` onboarding flow, the `vig-os` Cachix substituter (binary fetch instead of from-source build on first allow), and enabling the `nix-command`/`flakes` experimental features in `CONTRIBUTE.md` ([#255](https://github.com/vig-os/devcontainer/issues/255)) +- **Build the devcontainer image with Nix (`buildLayeredImage`, non-publishing)** ([#634](https://github.com/vig-os/devcontainer/issues/634)) + - Fleshed out `packages.devcontainerImage` from a stub into a real, bit-reproducible image assembled by `dockerTools.buildLayeredImage` (not a Dockerfile `FROM`); a `--rebuild` verifies the closure hash is identical + - Baked the in-container Nix evaluator (upstream CppNix, `pkgs.nix`) plus `direnv`/`nix-direnv` into the closure so `nix`/`direnv` are live inside the container; documented the CppNix-vs-Lix and `pre-commit`-vs-`prek` decisions in the flake + - Reproduced the Debian bootstrap layers in Nix: locale via `glibcLocales` + `LOCALE_ARCHIVE` (no `locale-gen`), `/root/assets`, pre-commit cache dir, template `.venv` scaffold (`UV_PYTHON_DOWNLOADS=never`, `UV_PYTHON=`), the `precommit`/`cc`/`cld` aliases, and `IS_SANDBOX=1` + - Added `fakeNss` (root uid-0 user database) and a sticky `/tmp` to close the first FHS gaps surfaced by the portable testinfra (fixing `ssh`, `whoami`, and `tmux`) + - Added a non-publishing `Nix Image (discovery)` workflow (with `workflow_dispatch`) that builds the image and runs the portable testinfra under `continue-on-error: true` + ### Changed +- **README now describes the Nix-built image** ([#673](https://github.com/vig-os/devcontainer/issues/673)) + - Replaced the stale `python:3.12-slim-trixie` Debian base-image claim with the actual build: a Nix flake assembled via `dockerTools.buildLayeredImage` (no Debian/Docker base), with CPython 3.14 and the toolchain from a pinned `nixpkgs`, bit-reproducible +- **Make `just init` Nix-first** ([#671](https://github.com/vig-os/devcontainer/issues/671)) + - Rewrote `scripts/init.sh` from a multi-OS package installer into a Nix-first gate + bootstrapper: it requires Nix (and direnv, unless `--no-direnv`) and the dev-shell toolchain, then performs one-time, idempotent project bootstrap (`uv sync --frozen --all-extras`, git hooks path, commit-message template, `pre-commit install-hooks`) with advisory `podman info` / `gh auth status` checks. It no longer installs any tool — the toolchain is the flake's `devTools` — and short-circuits inside the built image (`IN_CONTAINER=true`) + - Repointed `docs/generate.py` and the `CONTRIBUTE.md.j2` template: the per-OS "Requirements" table is now a "Prerequisites: Nix + direnv + a working host container runtime" section, with the toolchain sourced from `flake.nix` + +- **Nix image passes the full testinfra suite (toolchain parity)** ([#666](https://github.com/vig-os/devcontainer/issues/666)) + - Packaged `vig-utils` (and `pip-licenses` from its PyPI wheel, as it is not in nixpkgs) as Nix python packages exposed through a `python314.withPackages` env, and added `ruff`, `bandit`, `cargo-binstall`, `just-lsp`, and `typstyle` from nixpkgs — the Nix image now carries the project Python toolchain hermetically, replacing the Debian image's build-time `uv pip install` + - Relaxed `requires-python` from `==3.14.6` to `>=3.14,<3.15` across the root, `vig-utils`, and workspace-template pyprojects: `flake.lock` is the reproducibility anchor now, so the exact pin was redundant and unsatisfiable against nixpkgs (3.14.4) + - Adapted `tests/test_image.py` to the Nix toolchain (version prefixes are nixpkgs-pinned, so fast-movers/mismatched tools are checked for presence/run only; the pre-commit cache dir is asserted present rather than pre-populated, since a hermetic build cannot fetch hook repos), taking the suite to 63/63 — and made the `nix-image.yml` `build-and-test` job gate on it (discovery phase closed) +- **Stage the Nix publish-cutover; advance the nixpkgs baseline to 26.05** ([#639](https://github.com/vig-os/devcontainer/issues/639)) + - Bumped the pinned channel `nixos-25.05` → `nixos-26.05` (the "advance the rev" CVE lever), cutting the vulnix HIGH/CRITICAL surface 83 → 27 and Trivy HIGH 244 → 14 on the image; triaged the residual 27 into `.vulnixignore` (4 CPE-mismatch false positives — VS Code/Jenkins, not the binaries; 23 recent CVEs accepted as low-risk in an interactive dev container with a 3-month re-review) + - Made the nightly `vulnix-gate` **blocking** (the #639 go/no-go gate) now that it is legitimately green, and archived the `vulnix`-vs-Trivy scan overlap in `docs/security/nix-cutover-scan-overlap.md` (zero overlap — disjoint surfaces, no finding class lost in the Debian→Nix switch) + - Staged the publish-cutover so the versioned/`:latest` publish stays paused pending a deliberate Nix release: the nightly `vulnix-gate` is the go/no-go signal. The build pipeline became Nix-only once the Debian path was decommissioned (#642), so no `builder` toggle remains — the interim `builder: debian|nix` selector this issue introduced was superseded by that decommission +- **Make `.claude/` the single source of truth for agent rules and skills** ([#626](https://github.com/vig-os/devcontainer/issues/626)) + - Moved the 30 agent skills from `.cursor/skills/` to `.claude/skills/` and rewrote the 29 `.claude/commands/*.md` wrappers to point at the new paths + - Split the seven `.cursor/rules/*.mdc`: static principles (coding principles, commit messages, changelog, single source of truth) are now consolidated in `CLAUDE.md`; workflow rules (`branch-naming`, `tdd`, `subagent-delegation`) became on-demand `.claude/skills/` + - Ported `agent-models.toml` and `worktrees.json` to `.claude/`, updated the docs generator, pre-commit hooks, shell entrypoints, and the workspace sync manifest, and deleted the root `.cursor/` directory +- **Drive autonomous worktree pipelines with the `claude` CLI** ([#627](https://github.com/vig-os/devcontainer/issues/627)) + - `just worktree-start`/`worktree-attach` now launch `claude --dangerously-skip-permissions` in the tmux session instead of `cursor-agent` (`agent chat --yolo --approve-mcps`); the cursor-specific directory-trust step and the `tmux send-keys "a"` approval trigger are no longer needed and have been removed + - Prerequisite, authentication (`claude auth status`/`claude auth login`, `ANTHROPIC_API_KEY`), and `scripts/requirements.yaml` now reference the `claude` CLI rather than the Cursor Agent CLI +- **Migrate the workspace template and editor glue off Cursor (VS Code only)** ([#629](https://github.com/vig-os/devcontainer/issues/629)) + - New workspaces now scaffold `.claude/` (skills, `agent-models.toml`, `worktrees.json`) instead of the removed `.cursor/` template tree; the sync manifest carries the `.claude/` payload accordingly + - `just open` launches VS Code only (dropped the `command -v cursor` fallback), and `verify-auth.sh` no longer scans the `cursor-remote-ssh` SSH-agent socket + - `COMMIT_MESSAGE_STANDARD.md` now refers to VS Code rather than "VS Code / Cursor" +- **Make the image testinfra suite portable across Debian and Nix images** ([#635](https://github.com/vig-os/devcontainer/issues/635)) + - Replace dpkg `host.package(...).is_installed` checks (git, curl, openssh-client, nano, tmux, rsync) with path-agnostic `--version`/`-V` runs + - Resolve `gh`, `just`, `hadolint`, `taplo` and cargo-installed tools via PATH (`command -v`) instead of hardcoded `/usr/local/bin` / `/root/.cargo/bin` / `/root/.local/bin` locations + - Drop the `DEBIAN_FRONTEND` environment assertion and the apt-sourced version-prefix checks (git, curl, tmux, rsync) from `EXPECTED_VERSIONS` +- **Provision CI build/test tooling from the flake dev-shell** ([#632](https://github.com/vig-os/devcontainer/issues/632)) + - The `setup-env` action gained a `provision-via-flake` mode that installs Nix (SHA-pinned `install-nix-action`) and the `vig-os` Cachix substituter, builds the flake dev-shell, and prepends its tools to `PATH`, replacing the ad-hoc installs of `uv`/Python, `just`, `hadolint`, and `taplo` + - Enabled the mode in the CI build/test path (`build-image`, `test-image`, `test-integration`, `project-checks`) so jobs run inside the flake shell (the toolchain SSoT); `podman`, Node.js, BATS, and the devcontainer CLI keep their dedicated install paths + - Set `UV_PYTHON_DOWNLOADS_JSON_URL` in the flake dev-shell so the nixpkgs `uv` (whose embedded Python-download list is stripped) can fetch the project's pinned CPython `3.14.6`, which nixpkgs does not package, letting `uv sync --frozen` succeed under flake provisioning + - Keep `podman` off the flake-provisioned `PATH` so the runner's rootless-configured host `podman` is used (the nix-store `podman` cannot reach the host's setuid `newuidmap`/`newgidmap`, so `podman info` failed) + ### Deprecated ### Removed +- **Retire `scripts/requirements.yaml`** ([#671](https://github.com/vig-os/devcontainer/issues/671)) + - Deleted the per-OS dependency manifest and its consumers (the `load_requirements`/`format_requirements_table`/`format_install_commands` helpers in `docs/generate.py` and their tests). `flake.nix` `devTools` is now the single source of truth for the toolchain, ending the dual-SSoT drift + +- **Decommission the Debian build path** ([#642](https://github.com/vig-os/devcontainer/issues/642)) + - Deleted the root `Containerfile`, `scripts/prepare-build.sh`, `scripts/build.sh`, and the `.hadolint.yaml` config (plus its synced workspace copy); the image now builds Nix-only + - Removed the `hadolint` pre-commit hook and its `setup-env`/`test-project` install wiring, the `hadolint` and Containerfile entries from `scripts/requirements.yaml` and `scripts/manifest.toml`, and the `Containerfile`/build-script `CODEOWNERS` entries + - `build-image`, `release.yml`, and `ci.yml` are now Nix-only + - Dropped the Debian `scan-latest` nightly Trivy job, the ~78 Debian OS-package CVE entries from `.trivyignore`, and the Renovate `dockerfile` manager; `docs/CONTAINER_SECURITY.md` now reads as Nix-only + +- **Remove the `cursor-agent` CLI install from the image** ([#628](https://github.com/vig-os/devcontainer/issues/628)) + - Dropped the unpinned `curl … cursor.com/install` build step and its `/root/.local/bin` PATH entry, leaving an all-nixpkgs toolchain ahead of the Nix migration + - Removed the coupled `test_cursor_agent_installed` image test + ### Fixed +- **Nix dev-shell no longer breaks `just` on non-NixOS hosts (Nix C++ runtime leaked onto `LD_LIBRARY_PATH`)** ([#703](https://github.com/vig-os/devcontainer/issues/703)) + - The [#698](https://github.com/vig-os/devcontainer/issues/698) fix exported `${stdenv.cc.cc.lib}/lib` (the Nix C++ runtime, linked against glibc 2.42) onto the dev-shell `LD_LIBRARY_PATH` unconditionally. On an FHS host whose system glibc is older (e.g. Ubuntu 24.04 ships 2.39), that `libstdc++` is pulled into host binaries — every `just` recipe's `#!/usr/bin/env bash`, plus anything an `/etc/ld.so.preload` agent forces `libstdc++` into — dragging in the Nix `libm.so.6` and aborting with `version 'GLIBC_ABI_DT_X86_64_PLT' not found`, so every `just` recipe failed inside `nix develop`. It worked on NixOS only because there the system glibc *is* the Nix glibc (no skew) + - `mkProjectShell` now injects the Nix C++ runtime onto `LD_LIBRARY_PATH` only on NixOS (`[ -e /etc/NIXOS ]`), where it is both required (libstdc++ is off the default loader path) and ABI-safe (the system glibc is the Nix glibc); FHS hosts resolve `libstdc++` from the system loader, so the injection is a no-op there and nothing leaks. The [#698](https://github.com/vig-os/devcontainer/issues/698) dev-shell parity tests are gated to NixOS and an FHS leak-guard (`test_devshell_no_nix_cxx_runtime_leak_on_fhs_host`) was added +- **Integration tests now exercise the freshly-built image, not the published `DEVCONTAINER_VERSION`** ([#701](https://github.com/vig-os/devcontainer/issues/701)) + - The integration suite scaffolded a workspace from the image under test (`TEST_CONTAINER_TAG`) but then brought the devcontainer up from whatever `DEVCONTAINER_VERSION` resolved to (the published `0.3.9`), so it validated fresh scaffolding running inside a stale image. The `devcontainer_up`/`devcontainer_with_sidecar` fixtures now export `DEVCONTAINER_VERSION=TEST_CONTAINER_TAG`; compose reads the shell environment over `.env`, so the scaffolded `docker-compose.yml` resolves to the build under test (and every `devcontainer exec`, which inherits the environment, agrees). Added `test_devcontainer_runs_image_under_test` asserting the running container's image + - Guarded the `post-create.sh` venv-prompt `sed` and moved it after `just sync`: the Nix image populates `/root/assets/workspace/.venv` during post-create rather than baking it at image-build time (as the Debian image did), so the unguarded `sed` aborted bring-up with `can't read .venv/bin/activate` + - Reverted the [#697](https://github.com/vig-os/devcontainer/issues/697) scaffold decoupling: the scaffolded `ruff`/`ruff-format`/`typos` hooks are `language: system` again (resolved from the toolchain baked into the image, like the repo's own config) instead of self-contained upstream manylinux hooks, which the non-FHS Nix userland cannot execute. Removed the now-unused `ReplacePrecommitRepoBlock` sync-manifest transform and its tests +- **`pymarkdown` pre-commit hook no longer fails on NixOS (`pyjson5` C extension missing `libstdc++`)** ([#698](https://github.com/vig-os/devcontainer/issues/698)) + - The `pymarkdown` hook runs from pre-commit's own manylinux-wheel Python env, whose dependency `pyjson5` is a C extension linked against `libstdc++.so.6`; on a NixOS host that library is not on the loader path outside an FHS environment, so the hook aborted with `ImportError: libstdc++.so.6: cannot open shared object file` and forced `--no-verify`. Unlike the standalone binaries in [#697](https://github.com/vig-os/devcontainer/issues/697), `pymarkdown` is not in nixpkgs, so the "add to `devTools` + `language: system`" recipe does not apply + - `mkProjectShell` now appends `${pkgs.stdenv.cc.cc.lib}/lib` to `LD_LIBRARY_PATH` in the dev-shell, so the wheel's C extension resolves the Nix C++ runtime. It is the same `libstdc++` the Nix toolchain itself links, so the other dev-shell binaries keep working (no version clash); the existing mkShell-injected `LD_LIBRARY_PATH` is appended to, not clobbered. Generalises to any future C-extension Python hook. Documented in `docs/NIX.md` + - Added dev-shell parity tests asserting `LD_LIBRARY_PATH` carries `libstdc++.so.6` and that the C library loads under the dev-shell loader +- **pre-commit ruff/ruff-format/typos hooks now run on NixOS hosts (sourced from the flake)** ([#697](https://github.com/vig-os/devcontainer/issues/697)) + - The `ruff`, `ruff-format`, and `typos` hooks pulled compiled tools as generic-linux (manylinux) wheels from `astral-sh/ruff-pre-commit` and `crate-ci/typos`; a NixOS host cannot execute those binaries out of the box (no FHS `ld-linux`), forcing `--no-verify` on every local commit + - Added `ruff` and `typos` to the flake `devTools` SSoT and converted the three hooks to `repo: local` / `language: system` (`ruff check --fix`, `ruff format`, `typos`), so they resolve their tool from the Nix dev-shell like the other local hooks — no host setup needed inside the dev-shell. Re-synced the scaffolded `assets/workspace/.pre-commit-config.yaml`. Hook versions now track `nixpkgs`/`flake.lock` (Renovate `nix` manager) instead of upstream `rev:` pins, consistent with the #625 toolchain consolidation + - Removed `ruff` from the project's uv dependency groups (`pyproject.toml`/`uv.lock`) and repointed `just lint`/`just format` to the flake `ruff` (dropping `uv run`). Otherwise the venv's `ruff` (a manylinux wheel) shadowed the flake `ruff` under `uv run` — which is how the `.githooks/pre-commit` hook and the `just` recipes invoke it — so `ruff` stayed broken on NixOS; the flake is now the single `ruff` source (its `[tool.ruff]` config is unchanged) + - Declared `PATH` in the devcontainer image's OCI `config.Env`. `buildLayeredImage` symlinks the toolchain into `/bin` but set no PATH; `podman run` injects a default (so it worked), but `docker-compose` / `devcontainer exec` inherit `config.Env` verbatim, leaving the baked toolchain off PATH. Added an image test asserting the OCI config declares a PATH containing `/bin` + - Kept the **scaffolded** config's `ruff`/`ruff-format`/`typos` as self-contained upstream hooks (via a new `ReplacePrecommitRepoBlock` sync-manifest transform), reverting the `language: system` conversion for downstream workspaces only. A downstream workspace commits inside the published image without the flake toolchain on PATH, so its hooks must be pre-commit-managed; the repo's own config stays `language: system`. Without this the integration suite's in-container `git commit` failed with `Executable 'typos' not found` +- **BATS suite no longer fails locally on the Nix toolchain (helper libraries unresolved)** ([#695](https://github.com/vig-os/devcontainer/issues/695)) + - `tests/bats/test_helper.bash` resolved the BATS helper libraries (`bats-support`/`-assert`/`-file`) from `node_modules` (npm) or the now-removed Debian `/usr/lib` path; on the Nix toolchain neither exists locally, so every `.bats` file errored in `setup()` (`Could not find library 'bats-support'`) and all 246 tests failed + - Added `bats` wrapped with its helper libraries to the flake `devTools` SSoT and exported `BATS_LIB_PATH` in the dev-shell and image, so `bats_load_library` resolves the helpers from the Nix store; simplified `test_helper.bash` to that single path, switched `just test-bats` to the flake-provided `bats`, and removed the now-unused `bats*` npm dependencies. CI provisions BATS from the flake under `provision-via-flake` (the ad-hoc `bats-action` steps now run only for non-flake callers) +- **Host-executed scripts no longer fail on NixOS (non-portable `#!/bin/bash` shebang)** ([#687](https://github.com/vig-os/devcontainer/issues/687)) + - `install.sh`, `assets/workspace/.devcontainer/scripts/initialize.sh`, and `assets/workspace/.devcontainer/scripts/version-check.sh` hardcoded `#!/bin/bash`, which has no `/bin/bash` on NixOS and similar hosts, so they failed to execute (and `just test` aborted). Switched all three to the portable `#!/usr/bin/env bash` (already used by `scripts/init.sh`), which resolves `bash` via `PATH` +- **`allowed-signers` integration test no longer rejects valid ECDSA / security-key SSH keys** ([#688](https://github.com/vig-os/devcontainer/issues/688)) + - `test_allowed_signers_file_exists` only accepted `ssh-ed25519`/`ssh-rsa`, so a valid ECDSA (or FIDO `sk-*`) signing key spuriously failed; the assertion now accepts the full OpenSSH signing key-type set (mirroring the canonical list already used in `test_git_signing_key_configured`), including the `ecdsa-sha2-nistp*` curves and the `sk-ssh-ed25519@openssh.com` / `sk-ecdsa-sha2-nistp256@openssh.com` security-key variants +- **Install-script test suite no longer trips a pytest-10-removal deprecation (class-scoped fixture as instance method)** ([#691](https://github.com/vig-os/devcontainer/issues/691)) + - `TestInstallScriptIntegration.install_workspace` was a class-scoped fixture defined as an instance method, which pytest 9 flags with `PytestRemovedIn10Warning` and pytest 10 removes — a future `pytest` bump would then error at collection and take out the whole install-script suite. Converted it to a `@staticmethod` (it never used `self`), preserving the class-scope "run `install.sh` once per class" behaviour; verified with `-W error::pytest.PytestRemovedIn10Warning` +- **`just build` no longer fails on dev-shell-only podman hosts (missing containers `policy.json`)** ([#685](https://github.com/vig-os/devcontainer/issues/685)) + - On a NixOS host that gets `podman` purely from the flake dev-shell (no `virtualisation.containers` module), no signature-verification `policy.json` exists at `/etc/containers/policy.json` or `~/.config/containers/policy.json`, so `podman load` (`just build`) failed even though `nix build` and the advisory `podman info` check (`just init`) were green + - `just init` now ensures the user-level `~/.config/containers/policy.json` with the standard permissive default (`{ "default": [ { "type": "insecureAcceptAnything" } ] }`, the same content `containers-common` / the NixOS module ship); the write is idempotent and never overwrites a system or user policy. Documented in `docs/NIX.md` +- **`just init` no longer fails on NixOS hosts (uv downloaded a CPython NixOS cannot execute)** ([#683](https://github.com/vig-os/devcontainer/issues/683)) + - The flake dev-shell carried no Python and let the nixpkgs `uv` fetch a managed CPython — a generic, dynamically-linked ELF a NixOS host cannot execute out of the box (no FHS `ld-linux`) — so `uv sync` (`just init`) aborted on NixOS hosts while FHS hosts were unaffected + - `mkProjectShell` now pins a Nix store CPython via `UV_PYTHON` and sets `UV_PYTHON_DOWNLOADS=never`, so the dev-shell builds the venv from a store interpreter (patched to the store loader) that runs on both NixOS and FHS hosts instead of a downloaded one + - CI keeps its managed-download path (`UV_PYTHON_DOWNLOADS_JSON_URL`) and does **not** receive `UV_PYTHON`: the `provision-via-flake` jobs run outside `nix develop` on an FHS runner, where a Nix store interpreter cannot load pre-commit's manylinux-wheel C extensions (`libstdc++.so.6`) + - Added dev-shell tests asserting `UV_PYTHON_DOWNLOADS=never` and `UV_PYTHON` pinned to a runnable Nix store CPython 3.14 +- **Nix image no longer scaffolds dangling, read-only symlinks into a new workspace** ([#664](https://github.com/vig-os/devcontainer/issues/664)) + - The Nix-built image bakes the workspace template as read-only `/nix/store` symlinks (how `buildLayeredImage` represents the layer); `init-workspace.sh` now rsyncs with `--copy-links` and `chmod -R u+w "$WORKSPACE_DIR"`, so a scaffolded workspace gets real, writable files instead of symlinks that dangle on the host (and the placeholder `sed -i` no longer fails on read-only files). No-op on the Debian image + - Added a static bats guard (scaffold rsync uses `--copy-links`; workspace made writable) and a behavioural step in `nix-image.yml` that scaffolds via the real Nix image and asserts no dangling symlinks — the install/integration suite otherwise only exercises the Debian image +- **`just wt-start` no longer aborts on its helper-CLI prerequisite check** ([#657](https://github.com/vig-os/devcontainer/issues/657)) + - `derive-branch-summary` now handles `-h`/`--help` (prints usage, exits 0) instead of treating the flag as an issue title and failing; the worktree launcher probes availability with `--help`, so the bug blocked worktree creation entirely +- **CONTRIBUTE prerequisites now document the direnv shell hook** ([#633](https://github.com/vig-os/devcontainer/issues/633)) + - The `direnv` prerequisite promised the dev-shell "loads automatically on `cd`" but never documented installing direnv's shell hook (`eval "$(direnv hook bash)"`), the step that behaviour depends on. Without the hook, `direnv allow` still succeeds yet the flake never activates on `cd` and host tooling (e.g. an old system Node) is used with no warning. Documented the hook in the prerequisites table and as a fast-path note, with `nix develop` as the hook-free fallback +- **Workspace python interpreter pointed at the dead `/opt/venv` path** ([#706](https://github.com/vig-os/devcontainer/issues/706)) + - The synced `.vscode/settings.json` rewrote `python.defaultInterpreterPath` to `/opt/venv/bin/python3`, which no longer exists on the Nix image, breaking the VS Code interpreter for downstream projects + - The interpreter now stays workspace-relative (`${workspaceFolder}/.venv/bin/python3`), matching the `uv`-created `.venv` in the opened project + ### Security +- **Drop the piscina CVE ignore tied to `cursor-agent`** ([#628](https://github.com/vig-os/devcontainer/issues/628)) + - Removed the `CVE-2026-55388` (piscina) `.trivyignore` entry, which only existed for the now-removed `cursor-agent` CLI + ## [0.3.9](https://github.com/vig-os/devcontainer/releases/tag/0.3.9) - 2026-06-23 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 136e5ce9..da526d2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,8 @@ -# Project: eXoma Devcontainer +# Project: vigOS Devcontainer ## Custom Commands -Available slash commands (SSoT: `.cursor/skills/`, mapped via `.claude/commands/`): +Available slash commands (SSoT: `.claude/skills/`, mapped via `.claude/commands/`): | Command | Description | |---------|-------------| @@ -40,12 +40,12 @@ Available slash commands (SSoT: `.cursor/skills/`, mapped via `.claude/commands/ ## Always-Apply Rules -Rules SSoT: `.cursor/rules/` (read these files for full detail). +This file is the SSoT for always-on principles. Workflow-style rules live as +on-demand skills in `.claude/skills/` (`branch-naming`, `tdd`, +`subagent-delegation`). ### Coding Principles -See `.cursor/rules/coding-principles.mdc` for full detail. - 1. **YAGNI** -- Implement only what the issue or user explicitly requests. No speculative features. Ask before adding anything unasked. 2. **Minimal diff** -- Touch only files and lines required for the task. No drive-by refactors, renames, or reformats. Mention improvements separately; don't silently change them. 3. **DRY** -- Don't duplicate logic. Extract shared code only after the pattern appears twice. Prefer existing abstractions over new ones. @@ -57,7 +57,7 @@ See `.cursor/rules/coding-principles.mdc` for full detail. ### Commit Message Standard -See `.cursor/rules/commit-messages.mdc` and `docs/COMMIT_MESSAGE_STANDARD.md` for full detail. +See `docs/COMMIT_MESSAGE_STANDARD.md` for the full reference. Format: @@ -85,7 +85,7 @@ Refs: # ### Branch Naming -See `.cursor/rules/branch-naming.mdc` for full detail. +See the `branch-naming` skill (`.claude/skills/branch-naming/SKILL.md`) for full detail. Format: `/-` @@ -99,7 +99,7 @@ Every piece of knowledge lives in exactly one place. Reference it everywhere els ### TDD -See `.cursor/rules/tdd.mdc` for the scenario checklist and full detail. +See the `tdd` skill (`.claude/skills/tdd/SKILL.md`) for the scenario checklist and full detail. 1. Write the failing test first. Run it. Confirm it fails. 2. **Commit** the failing test (`test: ...`) following the Commit Message Standard above. Do not proceed before committing. diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md index 59f3651c..1c7a3dde 100644 --- a/CONTRIBUTE.md +++ b/CONTRIBUTE.md @@ -6,96 +6,93 @@ This guide explains how to develop, build, test, and release the vigOS development container image. -## Requirements - -| Component | Version | Purpose | -|----------------------|---------|---------| -| **podman** | >=4.0 | Container runtime, compose, and image building | -| **just** | >=1.40.0 | Command runner for task automation | -| **git** | >=2.34 | Version control and pre-commit hooks | -| **ssh** | latest | GitHub authentication and commit signing | -| **gh** | latest | GitHub CLI for repository and PR/issue management | -| **jq** | latest | JSON parsing for worktree commands and issue metadata | -| **tmux** | latest | Session manager required by worktree-start and worktree-attach | -| **agent** | latest | Cursor Agent CLI required by worktree-start/worktree-attach flows | -| **npm** | latest | Node.js package manager (for DevContainer CLI) | -| **uv** | >=0.8 | Python package and project manager | -| **bats** | 1.13.0 | Bash Automated Testing System for shell script tests | -| **devcontainer** | 0.81.1 | DevContainer CLI for testing devcontainer functionality | -| **hadolint** | latest | Containerfile/Dockerfile linter used by pre-commit | -| **taplo** | latest | TOML formatter and linter used by pre-commit | -| **parallel** | latest | Parallelizes BATS test execution for faster test runs | - -**Ubuntu/Debian:** +## Prerequisites -```bash -sudo apt update -sudo apt install -y podman git openssh-client jq tmux nodejs npm parallel -# just -curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | sudo bash -s -- --to /usr/local/bin - -# gh -curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg -echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null -sudo apt update && sudo apt install -y gh - -# hadolint -case "$(dpkg --print-architecture)" in - amd64) ARCH="linux-x86_64" ;; - arm64) ARCH="linux-arm64" ;; - *) - echo "Unsupported architecture: $(dpkg --print-architecture)" - exit 1 - ;; -esac -BASE_URL="https://github.com/hadolint/hadolint/releases/latest/download" -BIN_FILE="hadolint-${ARCH}" -SHA_FILE="${BIN_FILE}.sha256" -curl -fsSL "${BASE_URL}/${BIN_FILE}" -o "${BIN_FILE}" -curl -fsSL "${BASE_URL}/${SHA_FILE}" -o "${SHA_FILE}" -EXPECTED_SHA="$(awk '{print $1}' "${SHA_FILE}")" -echo "${EXPECTED_SHA} ${BIN_FILE}" | sha256sum -c - -sudo install -m 0755 "${BIN_FILE}" /usr/local/bin/hadolint -rm -f "${BIN_FILE}" "${SHA_FILE}" - -# taplo -case "$(dpkg --print-architecture)" in - amd64) ARCH="x86_64" ;; - arm64) ARCH="aarch64" ;; - *) - echo "Unsupported architecture: $(dpkg --print-architecture)" - exit 1 - ;; -esac -BASE_URL="https://github.com/tamasfe/taplo/releases/latest/download" -BIN_FILE="taplo-linux-${ARCH}.gz" -curl -fsSL "${BASE_URL}/${BIN_FILE}" -o "${BIN_FILE}" -gunzip "${BIN_FILE}" -sudo install -m 0755 "taplo-linux-${ARCH}" /usr/local/bin/taplo -rm -f "taplo-linux-${ARCH}" +This repository is **Nix-first**: the toolchain is defined by the Nix flake +(`flake.nix` — its `devTools` list is the single source of truth) and provisioned +into your shell by [direnv](https://direnv.net/) or `nix develop`. You only need +three things on the host: -``` +| Prerequisite | Purpose | +|--------------|---------| +| **[Nix](https://nixos.org/download)** | Provides the entire dev toolchain (just, git, gh, uv, node, jq, tmux, ripgrep, claude, …) from the flake — no manual installs | +| **[direnv](https://direnv.net/)** | Loads the flake dev-shell automatically on `cd` — **once its [shell hook](https://direnv.net/docs/hook.html) is installed** (e.g. `eval "$(direnv hook bash)"` in `~/.bashrc`). Without the hook, `direnv allow` still succeeds but nothing loads on `cd` and you silently fall back to host tools. Recommended; `nix develop` works without direnv | +| **A working container runtime** (podman or Docker) | Building and testing the image needs a usable rootless runtime. The flake ships the `podman` CLI, but rootless operation depends on host setup — `subuid`/`subgid` + `uidmap` on Linux, or `podman machine` on macOS | -**macOS (Homebrew):** +Everything else comes from the flake. See the fast path below to get set up. -```bash -brew install podman just git openssh gh jq tmux node hadolint taplo parallel -``` +## Nix dev shell (fast path) + +The repository ships a Nix flake (`flake.nix`) whose `devTools` list is the single +source of truth for the toolchain. With [Nix](https://nixos.org/download) and +[direnv](https://direnv.net/) installed you get the full dev environment on +`cd` into the clone — no manual dependency install. On a warm +[Cachix](https://www.cachix.org/) cache this is a binary fetch, not a from-source +build, so the first `direnv allow` completes in seconds. -- For other Linux distributions, use your package manager (e.g., `dnf`, `yum`, `zypper`, `apk`) to install these dependencies. -- Run `./scripts/init.sh` to check dependencies and get OS-specific installation commands. -- Ensure Docker is installed if you plan to use it instead of Podman. +1. **Enable the flakes experimental features.** Add to `~/.config/nix/nix.conf` + (or `/etc/nix/nix.conf`): + + ```conf + experimental-features = nix-command flakes + ``` + +2. **Add the `vig-os` Cachix substituter** so the dev-shell closure is fetched + from the binary cache instead of built locally. Add to the same `nix.conf`: + + ```conf + substituters = https://cache.nixos.org https://vig-os.cachix.org + trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= vig-os.cachix.org-1:yoOYRi3bvnM6ThxO0joLt7vtzhTfkq3r6jykeUMg7Bk= + ``` + + Pulling from the public `vig-os` cache needs no token. (If you have the Cachix + CLI: `cachix use vig-os` writes the same lines for you.) + +3. **Clone and allow direnv:** + + ```bash + git clone git@github.com:vig-os/devcontainer.git + cd devcontainer + direnv allow # first allow fetches the closure from Cachix (seconds on a warm cache) + ``` + + > **First time using direnv on this machine?** Install its shell hook first — + > add `eval "$(direnv hook bash)"` (or the + > [equivalent for your shell](https://direnv.net/docs/hook.html)) to your shell + > rc and start a new shell. The hook is what loads/unloads the environment on + > `cd`; without it `direnv allow` reports success but the flake never activates, + > so you keep host tooling (e.g. an old system Node) with no warning. Prefer not + > to install the hook? Use `nix develop` instead. + + The committed `.envrc` uses + [nix-direnv](https://github.com/nix-community/nix-direnv): the dev-shell + evaluation is cached and GC-rooted (under `.direnv/`, which is gitignored), so + re-entering the directory is instant and the closure is never garbage-collected. + nix-direnv is self-bootstrapped by `.envrc` on first allow; if you already + source it from `~/.config/direnv/direnvrc`, that installation is used instead. + +This Nix dev shell is an alternative to the devcontainer image below; use whichever +fits your workflow. Downstream workspaces scaffolded by `install.sh` choose between +the two (or both) via the delivery mode: `--mode devcontainer|direnv|both` +(default `both`; the interactive `init-workspace.sh` prompts, defaulting to +`both`). `devcontainer` scaffolds `.devcontainer/` only, `direnv` scaffolds +`flake.nix` + `.envrc` only, and `both` scaffolds everything. ## Setup -Clone this repository and prepare it for container development: +Clone this repository, enter the Nix dev shell, then bootstrap the project: ```bash git clone git@github.com:vig-os/devcontainer.git cd devcontainer -just init # Install dependencies and setup development environment +direnv allow # (recommended) loads the flake toolchain — or run `nix develop` +just init # Gate prerequisites and bootstrap the project (venv, git hooks, pre-commit) ``` +`just init` does not install tools — it verifies the Nix prerequisites are in +place and then performs one-time project bootstrap (`uv sync`, git hooks, commit +template, pre-commit). It is safe to re-run. + ## Development Workflow When contributing to this project, follow this workflow: @@ -175,7 +172,7 @@ Available recipes: docs # Generate documentation from templates help # Show available commands info # Show image information - init *args # Install system dependencies and setup development environment + init *args # Gate Nix prerequisites and bootstrap the project (venv, git hooks, pre-commit) login # Test login to GHCR sync-workspace # Sync workspace templates from repo root to assets/workspace/ @@ -219,7 +216,7 @@ Available recipes: worktree-attach issue # before attaching. See tests/bats/worktree.bats for integration tests. [alias: wt-attach] worktree-clean mode="" # Default (no args): clean only stopped worktrees. Use 'all' to clean everything. [alias: wt-clean] worktree-list # List active worktrees and their tmux sessions [alias: wt-list] - worktree-start issue prompt="" reviewer="" # Create a worktree for an issue, open tmux session, launch cursor-agent [alias: wt-start] + worktree-start issue prompt="" reviewer="" # Create a worktree for an issue, open tmux session, launch the claude CLI [alias: wt-start] worktree-stop issue # Stop a worktree's tmux session and remove the worktree [alias: wt-stop] ``` diff --git a/Containerfile b/Containerfile deleted file mode 100644 index dcc217d5..00000000 --- a/Containerfile +++ /dev/null @@ -1,311 +0,0 @@ -# Use Python 3.14 as base image (pinned to digest for supply chain integrity) -# Renovate (dockerfile manager) will propose digest updates automatically -# Updated to bookworm (stable) for better security patch cadence -# -# IMPORTANT: this MUST be the multi-arch *index* digest (the top-level -# `Digest:` from `docker buildx imagetools inspect python:3.14-slim-bookworm`), -# never a per-platform child manifest. Pinning a single-arch (amd64) child -# manifest breaks the arm64 release build with "exec format error" (see #578). -FROM python:3.14-slim-bookworm@sha256:7e2f3044e0eccc2d61476a63a9ff0564dacc7064b4e514e3e6fce7bf80b3cf0d - -# Add metadata -# By default, we build the dev version unless specified as an argument -ARG IMAGE_TAG="dev" -LABEL maintainer="Carlos Vigo " -LABEL description="vigOS development environment" -LABEL version="${IMAGE_TAG}" - -# OCI standard labels -LABEL org.opencontainers.image.title="vigOS development environment" -LABEL org.opencontainers.image.description="Development environment with common tools and utilities" -LABEL org.opencontainers.image.version="${IMAGE_TAG}" -LABEL org.opencontainers.image.authors="Carlos Vigo , Lars Gerchow " -LABEL org.opencontainers.image.vendor="vigOS" -LABEL org.opencontainers.image.source="https://github.com/vig-os/devcontainer" -LABEL org.opencontainers.image.licenses="MIT" -LABEL org.opencontainers.image.documentation="https://github.com/vig-os/devcontainer/blob/main/README.md" -LABEL org.opencontainers.image.url="https://github.com/vig-os/devcontainer" - -# Build and runtime information (injected at build time) -ARG BUILD_DATE="" -ARG VCS_REF="" -LABEL org.opencontainers.image.created="${BUILD_DATE}" -LABEL org.opencontainers.image.revision="${VCS_REF}" -LABEL org.opencontainers.image.ref.name="${IMAGE_TAG}" - -# Prevent interactive prompts during package installation -ENV DEBIAN_FRONTEND=noninteractive - -# Security patching strategy: we do NOT run blanket apt-get upgrade/dist-upgrade. -# The base image digest pin (line 4) guarantees reproducible builds. A blanket -# upgrade silently changes packages between builds, defeating that guarantee. -# -# Instead we rely on: -# 1. Renovate proposing base-image digest updates (covers most CVEs). -# 2. Nightly Trivy scans (.github/workflows/security-scan.yml) for visibility. -# 3. Targeted --only-upgrade for HIGH/CRITICAL CVEs that cannot wait for a -# new base image rebuild. Each entry must reference a CVE. -# -# See docs/CONTAINER_SECURITY.md for the full policy. -# -# Uncomment and add packages below when a critical CVE needs an immediate fix. -# Remove entries once the base image digest is updated to include the patch. -# RUN apt-get update && apt-get install -y --only-upgrade \ -# = \ # CVE-XXXX-XXXXX -# && apt-get clean && rm -rf /var/lib/apt/lists/* - -# CVE-2026-33845, CVE-2026-33846, CVE-2026-3833, CVE-2026-42009, CVE-2026-42010 (GnuTLS; bookworm-security) -RUN apt-get update && apt-get install -y --no-install-recommends --only-upgrade \ - libgnutls30=3.7.9-2+deb12u7 \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# CVE-2026-45447 (OpenSSL PKCS#7/S-MIME; bookworm-security) -RUN apt-get update && apt-get install -y --no-install-recommends --only-upgrade \ - libssl3=3.0.20-1~deb12u2 \ - openssl=3.0.20-1~deb12u2 \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Install minimal system dependencies -RUN apt-get update && apt-get install -y --no-install-recommends \ - curl \ - git \ - jq \ - openssh-client \ - locales \ - ca-certificates \ - nano \ - minisign \ - podman \ - rsync \ - tmux \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Generate en_US.UTF-8 locale -RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen - -# Set locale environment variables -ENV LANG=en_US.UTF-8 -ENV LANGUAGE=en_US:en -ENV LC_ALL=en_US.UTF-8 - -# Install latest GitHub CLI manually from releases -# TARGETARCH is automatically provided by Docker BuildKit for multi-platform builds -ARG TARGETARCH -RUN set -eux; \ - case "${TARGETARCH}" in \ - amd64) ARCH=linux_amd64 ;; \ - arm64) ARCH=linux_arm64 ;; \ - *) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;; \ - esac; \ - GH_VERSION="$(curl -fsSL https://api.github.com/repos/cli/cli/releases/latest | sed -n 's/.*"tag_name": *"v\?\([^"]*\)".*/\1/p')"; \ - URL=https://github.com/cli/cli/releases/download; \ - BINARY="${URL}/v${GH_VERSION}/gh_${GH_VERSION}_${ARCH}.tar.gz"; \ - CHECKSUM=$(curl -fsSL "${URL}/v${GH_VERSION}/gh_${GH_VERSION}_checksums.txt" | grep "gh_${GH_VERSION}_${ARCH}.tar.gz" | awk '{print $1}'); \ - FILE=gh.tar.gz; \ - curl -fsSL "$BINARY" -o "$FILE"; \ - echo "${CHECKSUM} ${FILE}" | sha256sum -c -; \ - tar -xzf "$FILE"; \ - mv "gh_${GH_VERSION}_${ARCH}/bin/gh" /usr/local/bin/gh; \ - chmod +x /usr/local/bin/gh; \ - rm -rf "gh_${GH_VERSION}_${ARCH}" "$FILE"; \ - gh --version; - -# Install latest just with checksum verification -RUN set -eux; \ - case "${TARGETARCH}" in \ - amd64) ARCH=x86_64-unknown-linux-musl ;; \ - arm64) ARCH=aarch64-unknown-linux-musl ;; \ - *) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;; \ - esac; \ - JUST_VERSION="$(curl -fsSL https://api.github.com/repos/casey/just/releases/latest | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')"; \ - URL="https://github.com/casey/just/releases/download/${JUST_VERSION}"; \ - FILE="just-${JUST_VERSION}-${ARCH}.tar.gz"; \ - curl -fsSL "${URL}/${FILE}" -o "$FILE"; \ - CHECKSUM=$(curl -fsSL "${URL}/SHA256SUMS" | grep "${FILE}" | awk '{print $1}'); \ - echo "${CHECKSUM} ${FILE}" | sha256sum -c -; \ - tar -xzf "$FILE" -C /usr/local/bin just; \ - chmod +x /usr/local/bin/just; \ - rm "$FILE"; \ - just --version; - -# Install hadolint binary with checksum verification -RUN set -eux; \ - case "${TARGETARCH}" in \ - amd64) ARCH=linux-x86_64 ;; \ - arm64) ARCH=linux-arm64 ;; \ - *) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;; \ - esac; \ - HADOLINT_VERSION="v2.14.0"; \ - URL="https://github.com/hadolint/hadolint/releases/download/${HADOLINT_VERSION}"; \ - FILE="hadolint-${ARCH}"; \ - SHA_FILE="${FILE}.sha256"; \ - curl -fsSL "${URL}/${FILE}" -o "$FILE"; \ - curl -fsSL "${URL}/${SHA_FILE}" -o "$SHA_FILE"; \ - EXPECTED_SHA="$(awk '{print $1}' "$SHA_FILE")"; \ - echo "${EXPECTED_SHA} ${FILE}" | sha256sum -c -; \ - install -m 0755 "$FILE" /usr/local/bin/hadolint; \ - rm "$FILE" "$SHA_FILE"; \ - hadolint --version; - -# Install taplo binary (TOML formatter/linter) -RUN set -eux; \ - case "${TARGETARCH}" in \ - amd64) ARCH=x86_64 ;; \ - arm64) ARCH=aarch64 ;; \ - *) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;; \ - esac; \ - TAPLO_VERSION="$(curl -fsSL https://api.github.com/repos/tamasfe/taplo/releases/latest | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')"; \ - URL="https://github.com/tamasfe/taplo/releases/download/${TAPLO_VERSION}"; \ - FILE="taplo-linux-${ARCH}.gz"; \ - curl -fsSL "${URL}/${FILE}" -o "$FILE"; \ - gunzip "$FILE"; \ - install -m 0755 "taplo-linux-${ARCH}" /usr/local/bin/taplo; \ - rm -f "taplo-linux-${ARCH}"; \ - taplo --version; - -# Install cursor-agent CLI (installs to ~/.local/bin) -ENV PATH="/root/.local/bin:${PATH}" -RUN set -eux; \ - INSTALLER="/tmp/cursor-install.sh"; \ - for attempt in 1 2 3; do \ - if curl -fsSL https://cursor.com/install -o "${INSTALLER}" \ - && bash "${INSTALLER}" \ - && agent --version; then \ - rm -f "${INSTALLER}"; \ - exit 0; \ - fi; \ - rm -f "${INSTALLER}"; \ - echo "cursor-agent install attempt ${attempt} failed, retrying in 10s..."; \ - sleep 10; \ - done; \ - echo "WARNING: cursor-agent install failed after 3 attempts (external CDN issue); skipping"; \ - echo "Install manually: curl -fsSL https://cursor.com/install | bash"; - -# Install latest cargo-binstall from release archive with minisign signature verification -# cargo-binstall uses minisign for signing releases. Each release has an ephemeral key. -ENV PATH="/root/.cargo/bin:${PATH}" -RUN set -eux; \ - case "${TARGETARCH}" in \ - amd64) ARCH=x86_64-unknown-linux-musl ;; \ - arm64) ARCH=aarch64-unknown-linux-musl ;; \ - *) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;; \ - esac; \ - BINSTALL_VERSION="$( \ - curl -fsSLI -o /dev/null -w '%{url_effective}' https://github.com/cargo-bins/cargo-binstall/releases/latest \ - | sed -n 's#.*/tag/v\([^/?]*\).*#\1#p' \ - )"; \ - if [ -z "$BINSTALL_VERSION" ]; then \ - echo "Failed to resolve cargo-binstall latest version"; \ - exit 1; \ - fi; \ - URL="https://github.com/cargo-bins/cargo-binstall/releases/download/v${BINSTALL_VERSION}"; \ - FILE="cargo-binstall-${ARCH}.tgz"; \ - SIG_FILE="${FILE}.sig"; \ - PUBKEY_FILE="minisign.pub"; \ - curl -fsSL "${URL}/${FILE}" -o "$FILE"; \ - curl -fsSL "${URL}/${SIG_FILE}" -o "$SIG_FILE"; \ - curl -fsSL "${URL}/${PUBKEY_FILE}" -o "$PUBKEY_FILE"; \ - PUBKEY="$(grep -v '^untrusted comment:' "$PUBKEY_FILE")"; \ - minisign -V -m "$FILE" -x "$SIG_FILE" -P "$PUBKEY"; \ - mkdir -p /root/.cargo/bin; \ - tar -xzf "$FILE" -C /root/.cargo/bin; \ - chmod +x /root/.cargo/bin/cargo-binstall; \ - rm "$FILE" "$SIG_FILE" "$PUBKEY_FILE"; \ - INSTALLED_VERSION="$(cargo-binstall -V | cut -d ' ' -f2)"; \ - if [ "$INSTALLED_VERSION" != "$BINSTALL_VERSION" ]; then \ - echo "Version mismatch: expected ${BINSTALL_VERSION}, got ${INSTALLED_VERSION}"; \ - exit 1; \ - fi; \ - echo "cargo-binstall ${INSTALLED_VERSION} verified with minisign"; - -# Install just LSP -RUN cargo-binstall just-lsp; \ - just-lsp --version; - -# Install typstyle -RUN cargo-binstall typstyle; \ - typstyle --version; - -# Install latest uv verifying checksum -RUN set -eux; \ - case "${TARGETARCH}" in \ - amd64) ARCH=x86_64-unknown-linux-gnu ;; \ - arm64) ARCH=aarch64-unknown-linux-gnu ;; \ - *) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;; \ - esac; \ - UV_VERSION="$(curl -fsSL https://api.github.com/repos/astral-sh/uv/releases/latest | sed -n 's/.*"tag_name": *"v\?\([^"]*\)".*/\1/p')"; \ - URL=https://github.com/astral-sh/uv/releases/download; \ - BINARY="${URL}/${UV_VERSION}/uv-${ARCH}.tar.gz"; \ - CHECKSUM=$(curl -fsSL "${BINARY}.sha256" | awk '{print $1}'); \ - FILE=uv.tar.gz; \ - curl -fsSL "$BINARY" -o "$FILE"; \ - echo "${CHECKSUM} ${FILE}" | sha256sum -c -; \ - tar -xzf "$FILE" -C /usr/local/bin --strip-components=1; \ - rm "$FILE"; - -# Install Python development tools from root pyproject.toml (SSoT) -# and upgrade pip to fix CVE-2025-8869 (symbolic link extraction vulnerability) -# vig-utils must be present before uv export because uv.lock references it as a workspace member -WORKDIR /build -COPY packages/vig-utils packages/vig-utils -COPY pyproject.toml uv.lock ./ -RUN uv export --only-group devcontainer --no-emit-project -o /tmp/devcontainer-reqs.txt && \ - uv pip install --system -r /tmp/devcontainer-reqs.txt && \ - uv pip install --system --upgrade pip && \ - rm /tmp/devcontainer-reqs.txt - -# Install vig-utils system-wide -RUN uv pip install --system packages/vig-utils - -# Copy assets into container image -COPY assets /root/assets - -# Set execute permissions on all shell scripts in the assets -RUN find /root/assets -type f -name "*.sh" -exec chmod +x {} \; - -# Note: Container socket configuration is now handled at runtime -# The initialize.sh script detects the host OS and writes CONTAINER_SOCKET_PATH to .env -# docker-compose.yml uses this environment variable for the socket mount - -# Generate build-time manifest of files containing placeholders -# This avoids expensive runtime searching in init-workspace.sh -RUN grep -rl '{{SHORT_NAME}}\|{{ORG_NAME}}\|{{IMAGE_TAG}}\|{{GITHUB_REPOSITORY}}' /root/assets/workspace/ \ - --exclude-dir=.git \ - --exclude-dir=.venv \ - --exclude-dir=.pre-commit-cache \ - 2>/dev/null > /root/assets/.placeholder-manifest.txt || true - -# Pre-initialize pre-commit hooks to system cache location -# This cache is used by the container (not copied to workspace by init-workspace.sh) -# Host users will use their own cache (~/.cache/pre-commit or project-local) -WORKDIR /root/assets/workspace -RUN git config --global init.defaultBranch main && \ - git init && \ - PRE_COMMIT_HOME=/opt/pre-commit-cache \ - pre-commit install-hooks && \ - rm -rf .git - -# Pre-build Python virtual environment with template dependencies -# This venv is used directly via UV_PROJECT_ENVIRONMENT (not copied to workspace) -# Temporarily replace {{SHORT_NAME}} placeholder for uv sync, then restore for init-workspace.sh -RUN sed -i 's/{{SHORT_NAME}}/template_project/g' pyproject.toml && \ - uv sync --all-extras --no-install-project && \ - uv pip list && \ - sed -i 's/template_project/{{SHORT_NAME}}/g' pyproject.toml - -# Create workspace directory -RUN mkdir -p /workspace -WORKDIR /workspace - -# Set environment variables -ENV PYTHONUNBUFFERED="1" -ENV IN_CONTAINER="true" -ENV PRE_COMMIT_HOME="/opt/pre-commit-cache" -ENV UV_PROJECT_ENVIRONMENT="/root/assets/workspace/.venv" -ENV VIRTUAL_ENV="/root/assets/workspace/.venv" - -# Create aliases for pre-commit -RUN echo 'alias precommit="pre-commit run"' >> /root/.bashrc - -# Default command - interactive shell -CMD ["/bin/bash"] diff --git a/README.md b/README.md index 3273fa6c..453b6216 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,14 @@ This will: - Pull the latest devcontainer image - Initialize your project with the devcontainer template +**Delivery mode.** A workspace can run on a VS Code **devcontainer**, on a Nix +flake + **direnv**, or **both**. Pass `--mode devcontainer|direnv|both` to choose +(both forms `--mode X` and `--mode=X` work). The one-line install runs +non-interactively and defaults to `both`; run `init-workspace.sh` directly (see +Manual Setup) without `--mode` to be prompted, where the default selection is +also `both`. `devcontainer` scaffolds `.devcontainer/` only; `direnv` scaffolds +`flake.nix` + `.envrc` only; `both` scaffolds everything. + **Options:** ```bash @@ -45,6 +53,9 @@ curl -sSf https://raw.githubusercontent.com/vig-os/devcontainer/main/install.sh # Override organization name (default: vigOS) curl -sSf https://raw.githubusercontent.com/vig-os/devcontainer/main/install.sh | bash -s -- --org MyOrg ~/my-project +# Choose the delivery mode: devcontainer | direnv | both (default: both) +curl -sSf https://raw.githubusercontent.com/vig-os/devcontainer/main/install.sh | bash -s -- --mode direnv ~/my-project + # Preview without executing curl -sSf https://raw.githubusercontent.com/vig-os/devcontainer/main/install.sh | bash -s -- --dry-run ~/my-project @@ -86,6 +97,11 @@ curl -sSf https://raw.githubusercontent.com/vig-os/devcontainer/main/install.sh The script copies the devcontainer template (`.devcontainer/`), git hooks, README/CHANGELOG, and auth helpers into your project. + Run interactively (no `-it` dropped), the script prompts for the delivery mode + (`devcontainer`/`direnv`/`both`, default `both`). Pass `--mode ` to skip + the prompt; under `--no-prompts` (e.g. the one-line install) it defaults to + `both`. + 3. **Run with `--force` when overwriting or updating an existing project** ```bash @@ -126,7 +142,7 @@ Available recipes: docs # Generate documentation from templates help # Show available commands info # Show image information - init *args # Install system dependencies and setup development environment + init *args # Gate Nix prerequisites and bootstrap the project (venv, git hooks, pre-commit) login # Test login to GHCR sync-workspace # Sync workspace templates from repo root to assets/workspace/ @@ -170,7 +186,7 @@ Available recipes: worktree-attach issue # before attaching. See tests/bats/worktree.bats for integration tests. [alias: wt-attach] worktree-clean mode="" # Default (no args): clean only stopped worktrees. Use 'all' to clean everything. [alias: wt-clean] worktree-list # List active worktrees and their tmux sessions [alias: wt-list] - worktree-start issue prompt="" reviewer="" # Create a worktree for an issue, open tmux session, launch cursor-agent [alias: wt-start] + worktree-start issue prompt="" reviewer="" # Create a worktree for an issue, open tmux session, launch the claude CLI [alias: wt-start] worktree-stop issue # Stop a worktree's tmux session and remove the worktree [alias: wt-stop] ``` @@ -179,7 +195,7 @@ For detailed command descriptions, run `just --list --unsorted` or `just --help` ## Image Details -- **Base Image**: `python:3.12-slim-trixie` +- **Build**: Nix flake via `dockerTools.buildLayeredImage` (no Debian/Docker base image); bit-reproducible - **Registry**: `ghcr.io/vig-os/devcontainer` - **Architecture**: Multi-platform support (AMD64, ARM64) - **License**: Apache @@ -188,9 +204,9 @@ For detailed command descriptions, run `just --list --unsorted` or `just --help` ## Features -### **Base Image** +### **Build** -- **python:3.12-slim-trixie** – Minimal Python base image (Debian Trixie) for lightweight and robust foundation +- **Nix flake** – The image is assembled entirely by Nix via `dockerTools.buildLayeredImage` (no Debian/Docker base image). Python (CPython 3.14) and the whole toolchain come from a pinned `nixpkgs`, so the build is bit-reproducible ### **System Tools** @@ -204,8 +220,7 @@ For detailed command descriptions, run `just --list --unsorted` or `just --help` ### **Python Environment** -- **Python 3.12** - Latest stable Python version -- **pip, setuptools, wheel** - Python packaging tools (included with base image) +- **Python 3.14** - CPython from the pinned `nixpkgs` - **uv** - Fast Python package installer and resolver ### **Development Tools** diff --git a/TESTING.md b/TESTING.md index 3bd137e6..b1235e2a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -30,7 +30,7 @@ These tests run against a running container instance to verify the image itself (installed tools, versions, environment variables, file structure). - `TestSystemTools` - git, curl, openssh-client, gh, just -- `TestPythonEnvironment` - Python 3.12, uv +- `TestPythonEnvironment` - Python 3.14, uv - `TestDevelopmentTools` - pre-commit, ruff, just - `TestEnvironmentVariables` - environment variables - `TestFileStructure` - file structure diff --git a/assets/init-workspace.sh b/assets/init-workspace.sh index f0c4cd9e..f5033335 100755 --- a/assets/init-workspace.sh +++ b/assets/init-workspace.sh @@ -1,12 +1,17 @@ #!/bin/bash # Initialize workspace by copying template files # -# Usage: init-workspace [--force] [--no-prompts] [--smoke-test] +# Usage: init-workspace [--force] [--no-prompts] [--smoke-test] [--mode MODE] # # Options: # --force Overwrite existing files (for upgrades) # --no-prompts Run non-interactively (requires SHORT_NAME env var) # --smoke-test Deploy smoke-test-specific assets +# --mode MODE Delivery mode: devcontainer | direnv | both +# devcontainer scaffold .devcontainer/ only (no flake.nix/.envrc) +# direnv scaffold flake.nix + .envrc only (no .devcontainer/) +# both scaffold everything (default) +# Unset: prompt interactively, or default to "both" with --no-prompts # # Environment variables (used with --no-prompts): # SHORT_NAME - Project short name (required) @@ -20,6 +25,8 @@ WORKSPACE_DIR="/workspace" FORCE=false NO_PROMPTS=false SMOKE_TEST=false +# Delivery mode: devcontainer | direnv | both. Empty = prompt (or "both" with --no-prompts). +MODE="" # Files to preserve during --force upgrades (never overwrite if they exist) # These are user/project customization files that should survive upgrades @@ -33,6 +40,10 @@ PRESERVE_FILES=( ".github/workflows/release-extension.yml" "justfile.project" "renovate.json" + # direnv/flake stub (#640): the user owns the extraPackages block, so a + # dev-env upgrade must never clobber it — same class as justfile.project. + "flake.nix" + ".envrc" ) # Get script directory for manifest location @@ -44,25 +55,45 @@ MANIFEST_FILE="$SCRIPT_DIR/.placeholder-manifest.txt" source "$SCRIPT_DIR/parse-github-remote-lib.sh" # Parse arguments -for arg in "$@"; do - case "$arg" in +while [[ $# -gt 0 ]]; do + case "$1" in --force) FORCE=true + shift ;; --no-prompts) NO_PROMPTS=true + shift ;; --smoke-test) SMOKE_TEST=true + shift + ;; + --mode) + MODE="$2" + shift 2 + ;; + --mode=*) + MODE="${1#--mode=}" + shift ;; *) - echo "Unknown option: $arg" >&2 - echo "Usage: init-workspace [--force] [--no-prompts] [--smoke-test]" >&2 + echo "Unknown option: $1" >&2 + echo "Usage: init-workspace [--force] [--no-prompts] [--smoke-test] [--mode MODE]" >&2 exit 1 ;; esac done +# Validate delivery mode (empty handled later: prompt, or default to "both"). +case "$MODE" in + ""|devcontainer|direnv|both) ;; + *) + echo "Error: Invalid --mode: $MODE (expected: devcontainer | direnv | both)" >&2 + exit 1 + ;; +esac + # Smoke mode must run unattended and allow overwriting existing content. if [[ "$SMOKE_TEST" == "true" ]]; then NO_PROMPTS=true @@ -144,6 +175,32 @@ else fi echo "Organization name set to: $ORG_NAME" +# Get MODE - from flag, prompt, or default. Selects which delivery the workspace +# scaffolds: a devcontainer, the Nix/direnv stub, or both. +if [[ -z "$MODE" ]]; then + if [[ "$NO_PROMPTS" == "true" ]] || [[ ! -t 0 ]]; then + # Non-interactive (--no-prompts, or no TTY: CI / piped stdin): default to + # "both" without blocking on the prompt, preserving prior behaviour. + MODE="both" + else + # Interactive mode: prompt user (default selection: both). + echo "Choose how this workspace runs its dev environment:" + echo " 1) devcontainer - VS Code Dev Containers (.devcontainer/)" + echo " 2) direnv - Nix flake + direnv (flake.nix + .envrc)" + echo " 3) both - scaffold both (default)" + read -rp "Delivery mode [devcontainer/direnv/both] (default: both): " MODE + MODE="${MODE:-both}" + case "$MODE" in + devcontainer|direnv|both) ;; + *) + echo "Error: Invalid mode: $MODE (expected: devcontainer | direnv | both)" >&2 + exit 1 + ;; + esac + fi +fi +echo "Delivery mode set to: $MODE" + # Helper: check if a file is in the preserve list is_preserved_file() { local file="$1" @@ -225,12 +282,12 @@ echo "Copying files from $TEMPLATE_DIR to $WORKSPACE_DIR..." # Pre-commit cache is now at /opt/pre-commit-cache (not in assets/workspace) if [[ "$SMOKE_TEST" == "true" ]]; then # Smoke mode: clean deploy (--delete removes stale files), then overlay smoke-test assets - rsync -av --delete --exclude='.git' --exclude='.venv' --exclude='docs/issues/' --exclude='docs/pull-requests/' "$TEMPLATE_DIR/" "$WORKSPACE_DIR/" + rsync -avL --delete --exclude='.git' --exclude='.venv' --exclude='docs/issues/' --exclude='docs/pull-requests/' "$TEMPLATE_DIR/" "$WORKSPACE_DIR/" SMOKE_TEST_DIR="$SCRIPT_DIR/smoke-test" if [[ -d "$SMOKE_TEST_DIR" ]]; then echo "Deploying smoke-test-specific files..." - rsync -av "$SMOKE_TEST_DIR/" "$WORKSPACE_DIR/" + rsync -avL "$SMOKE_TEST_DIR/" "$WORKSPACE_DIR/" else echo "Warning: Smoke-test directory not found at $SMOKE_TEST_DIR" >&2 fi @@ -255,9 +312,35 @@ else fi done - rsync -av --exclude='.git' --exclude='.venv' "${EXCLUDE_ARGS[@]}" "$TEMPLATE_DIR/" "$WORKSPACE_DIR/" + rsync -avL --exclude='.git' --exclude='.venv' "${EXCLUDE_ARGS[@]}" "$TEMPLATE_DIR/" "$WORKSPACE_DIR/" fi +# The Nix-built image stores the baked template as read-only symlinks into the +# Nix store. The rsync `-L` (--copy-links) above dereferences them into real +# files, but those inherit the store's read-only (0444) mode. Make the scaffold +# user-writable so the placeholder substitution below — and the user's own edits +# — work. No-op on the Debian image (its template files are already writable). +chmod -R u+w "$WORKSPACE_DIR" + +# Prune the scaffold to the chosen delivery mode. Idempotent and safe: only +# removes paths inside the new workspace. +# devcontainer -> remove the flake.nix + .envrc stub +# direnv -> remove the .devcontainer/ scaffold +# both -> keep everything +case "$MODE" in + devcontainer) + echo "Pruning to 'devcontainer' mode: removing flake.nix and .envrc..." + rm -f "$WORKSPACE_DIR/flake.nix" "$WORKSPACE_DIR/.envrc" + ;; + direnv) + echo "Pruning to 'direnv' mode: removing .devcontainer/..." + rm -rf "$WORKSPACE_DIR/.devcontainer" + ;; + both) + : # keep everything + ;; +esac + resolve_github_repository # Replace placeholders in files (using pre-built manifest from image) diff --git a/.cursor/agent-models.toml b/assets/workspace/.claude/agent-models.toml similarity index 89% rename from .cursor/agent-models.toml rename to assets/workspace/.claude/agent-models.toml index 94947f2d..d9e498d8 100644 --- a/.cursor/agent-models.toml +++ b/assets/workspace/.claude/agent-models.toml @@ -1,5 +1,5 @@ -# .cursor/agent-models.toml -# Single source of truth for cursor-agent model assignments. +# .claude/agent-models.toml +# Single source of truth for agent model assignments. # Referenced by: justfile.worktree (worktree-start recipe) [models] diff --git a/.cursor/rules/branch-naming.mdc b/assets/workspace/.claude/skills/branch-naming/SKILL.md similarity index 95% rename from .cursor/rules/branch-naming.mdc rename to assets/workspace/.claude/skills/branch-naming/SKILL.md index ab062c91..7f0a472b 100644 --- a/.cursor/rules/branch-naming.mdc +++ b/assets/workspace/.claude/skills/branch-naming/SKILL.md @@ -1,6 +1,7 @@ --- -description: Topic branch naming and workflow for starting work on an issue. Attach when creating branches, starting work on issues, or checking out branches. -alwaysApply: false +name: branch-naming +description: Topic branch naming and workflow for starting work on an issue. Use when creating branches, starting work on issues, or checking out branches. +disable-model-invocation: true --- # Topic Branch Naming and Workflow @@ -40,15 +41,19 @@ When the user asks to create or start work on an issue (e.g. "create branch for ## Branch name format (reference) ### Issue-tied branches + ``` /- ``` + Example: `feature/36-standardize-commit-messages`, `bugfix/42-fix-login-bug` ### Chore branches (no issue required) + ``` chore/ ``` + Example: `chore/sync-main-to-dev`, `chore/update-dependencies` ## Branch types (reference) diff --git a/assets/workspace/.cursor/skills/ci_check/SKILL.md b/assets/workspace/.claude/skills/ci_check/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/ci_check/SKILL.md rename to assets/workspace/.claude/skills/ci_check/SKILL.md diff --git a/assets/workspace/.cursor/skills/ci_fix/SKILL.md b/assets/workspace/.claude/skills/ci_fix/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/ci_fix/SKILL.md rename to assets/workspace/.claude/skills/ci_fix/SKILL.md diff --git a/assets/workspace/.cursor/skills/code_debug/SKILL.md b/assets/workspace/.claude/skills/code_debug/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/code_debug/SKILL.md rename to assets/workspace/.claude/skills/code_debug/SKILL.md diff --git a/assets/workspace/.cursor/skills/code_execute/SKILL.md b/assets/workspace/.claude/skills/code_execute/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/code_execute/SKILL.md rename to assets/workspace/.claude/skills/code_execute/SKILL.md diff --git a/assets/workspace/.cursor/skills/code_review/SKILL.md b/assets/workspace/.claude/skills/code_review/SKILL.md similarity index 97% rename from assets/workspace/.cursor/skills/code_review/SKILL.md rename to assets/workspace/.claude/skills/code_review/SKILL.md index 09b0b620..bcb1f55f 100644 --- a/assets/workspace/.cursor/skills/code_review/SKILL.md +++ b/assets/workspace/.claude/skills/code_review/SKILL.md @@ -65,7 +65,7 @@ STEPS: Flag any change NOT traceable to a requirement (scope creep). 4. Check project standards: - Changelog: is CHANGELOG.md updated under ## Unreleased? Does the entry match? - - Commit messages: do all commits follow the format in .cursor/rules/commit-messages.mdc? + - Commit messages: do all commits follow the format in CLAUDE.md (Commit Message Standard)? - Tests: are there tests for new/changed behavior? - Docs: are documentation changes needed? 5. Produce your report in EXACTLY this structure: diff --git a/assets/workspace/.cursor/skills/code_tdd/SKILL.md b/assets/workspace/.claude/skills/code_tdd/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/code_tdd/SKILL.md rename to assets/workspace/.claude/skills/code_tdd/SKILL.md diff --git a/assets/workspace/.cursor/skills/code_verify/SKILL.md b/assets/workspace/.claude/skills/code_verify/SKILL.md similarity index 96% rename from assets/workspace/.cursor/skills/code_verify/SKILL.md rename to assets/workspace/.claude/skills/code_verify/SKILL.md index b391f6f4..d61510b1 100644 --- a/assets/workspace/.cursor/skills/code_verify/SKILL.md +++ b/assets/workspace/.claude/skills/code_verify/SKILL.md @@ -21,7 +21,7 @@ Run verification and provide evidence before claiming work is done. ```bash just test # full test suite -just test-image # or specific suite +just test # or specific suite just lint # linters just precommit # pre-commit hooks on all files ``` diff --git a/assets/workspace/.cursor/skills/design_brainstorm/SKILL.md b/assets/workspace/.claude/skills/design_brainstorm/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/design_brainstorm/SKILL.md rename to assets/workspace/.claude/skills/design_brainstorm/SKILL.md diff --git a/assets/workspace/.cursor/skills/design_plan/SKILL.md b/assets/workspace/.claude/skills/design_plan/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/design_plan/SKILL.md rename to assets/workspace/.claude/skills/design_plan/SKILL.md diff --git a/assets/workspace/.cursor/skills/git_commit/SKILL.md b/assets/workspace/.claude/skills/git_commit/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/git_commit/SKILL.md rename to assets/workspace/.claude/skills/git_commit/SKILL.md diff --git a/assets/workspace/.cursor/skills/inception_architect/SKILL.md b/assets/workspace/.claude/skills/inception_architect/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/inception_architect/SKILL.md rename to assets/workspace/.claude/skills/inception_architect/SKILL.md diff --git a/assets/workspace/.cursor/skills/inception_explore/README.md b/assets/workspace/.claude/skills/inception_explore/README.md similarity index 98% rename from assets/workspace/.cursor/skills/inception_explore/README.md rename to assets/workspace/.claude/skills/inception_explore/README.md index 4fe110fd..0192a46c 100644 --- a/assets/workspace/.cursor/skills/inception_explore/README.md +++ b/assets/workspace/.claude/skills/inception_explore/README.md @@ -180,4 +180,4 @@ User: "Add multi-tenancy to the system" - [RFC template](../../templates/RFC.md) - [DESIGN template](../../templates/DESIGN.md) - [Keep a Changelog](https://keepachangelog.com/) — format for CHANGELOG.md entries -- [Single Source of Truth rule](../../../.cursor/rules/single-source-of-truth.mdc) +- [Single Source of Truth rule](../../../CLAUDE.md) diff --git a/assets/workspace/.cursor/skills/inception_explore/SKILL.md b/assets/workspace/.claude/skills/inception_explore/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/inception_explore/SKILL.md rename to assets/workspace/.claude/skills/inception_explore/SKILL.md diff --git a/assets/workspace/.cursor/skills/inception_plan/SKILL.md b/assets/workspace/.claude/skills/inception_plan/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/inception_plan/SKILL.md rename to assets/workspace/.claude/skills/inception_plan/SKILL.md diff --git a/assets/workspace/.cursor/skills/inception_scope/SKILL.md b/assets/workspace/.claude/skills/inception_scope/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/inception_scope/SKILL.md rename to assets/workspace/.claude/skills/inception_scope/SKILL.md diff --git a/assets/workspace/.cursor/skills/issue_claim/SKILL.md b/assets/workspace/.claude/skills/issue_claim/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/issue_claim/SKILL.md rename to assets/workspace/.claude/skills/issue_claim/SKILL.md diff --git a/assets/workspace/.cursor/skills/issue_create/SKILL.md b/assets/workspace/.claude/skills/issue_create/SKILL.md similarity index 97% rename from assets/workspace/.cursor/skills/issue_create/SKILL.md rename to assets/workspace/.claude/skills/issue_create/SKILL.md index d1f11da9..a9f5c7d8 100644 --- a/assets/workspace/.cursor/skills/issue_create/SKILL.md +++ b/assets/workspace/.claude/skills/issue_create/SKILL.md @@ -33,7 +33,7 @@ Create a new GitHub issue using the appropriate issue template. - Draft the body with all required fields from the chosen template. - Include a Changelog Category value based on the issue type. - For testable issue types (`feature`, `bug`, `refactor`), include a TDD acceptance criterion: - `- [ ] TDD compliance (see .cursor/rules/tdd.mdc)` + `- [ ] TDD compliance (see .claude/skills/tdd/SKILL.md)` 4. **Show draft and ask for confirmation** - Present the title, labels, and body to the user. diff --git a/assets/workspace/.cursor/skills/issue_triage/SKILL.md b/assets/workspace/.claude/skills/issue_triage/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/issue_triage/SKILL.md rename to assets/workspace/.claude/skills/issue_triage/SKILL.md diff --git a/assets/workspace/.cursor/skills/pr_create/SKILL.md b/assets/workspace/.claude/skills/pr_create/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/pr_create/SKILL.md rename to assets/workspace/.claude/skills/pr_create/SKILL.md diff --git a/assets/workspace/.cursor/skills/pr_post-merge/SKILL.md b/assets/workspace/.claude/skills/pr_post-merge/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/pr_post-merge/SKILL.md rename to assets/workspace/.claude/skills/pr_post-merge/SKILL.md diff --git a/assets/workspace/.cursor/skills/pr_solve/SKILL.md b/assets/workspace/.claude/skills/pr_solve/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/pr_solve/SKILL.md rename to assets/workspace/.claude/skills/pr_solve/SKILL.md diff --git a/assets/workspace/.cursor/skills/solve-and-pr/SKILL.md b/assets/workspace/.claude/skills/solve-and-pr/SKILL.md similarity index 97% rename from assets/workspace/.cursor/skills/solve-and-pr/SKILL.md rename to assets/workspace/.claude/skills/solve-and-pr/SKILL.md index d8696eb9..01dc4732 100644 --- a/assets/workspace/.cursor/skills/solve-and-pr/SKILL.md +++ b/assets/workspace/.claude/skills/solve-and-pr/SKILL.md @@ -29,7 +29,7 @@ This command: - Resolves or creates the linked branch - Sets up the environment (`uv sync`, `pre-commit install`) - Captures the local gh user as the reviewer (`gh api user --jq '.login'`) -- Launches a tmux session running `cursor-agent` with `--yolo` mode +- Launches a tmux session running `claude --dangerously-skip-permissions` - Passes `/worktree-solve-and-pr` as the initial prompt ### 3. Report back to the user diff --git a/assets/workspace/.cursor/rules/subagent-delegation.mdc b/assets/workspace/.claude/skills/subagent-delegation/SKILL.md similarity index 89% rename from assets/workspace/.cursor/rules/subagent-delegation.mdc rename to assets/workspace/.claude/skills/subagent-delegation/SKILL.md index e52f5bf1..680c6514 100644 --- a/assets/workspace/.cursor/rules/subagent-delegation.mdc +++ b/assets/workspace/.claude/skills/subagent-delegation/SKILL.md @@ -1,10 +1,16 @@ +--- +name: subagent-delegation +description: How to delegate mechanical sub-steps to lightweight subagents when executing skills. Use when running a skill that has data-gathering, formatting, or structured-review sub-steps. +disable-model-invocation: true +--- + # Subagent Delegation When executing skills, delegate mechanical sub-steps to lightweight subagents via the Task tool to reduce token consumption on the primary model. ## Model Tiers -See [.cursor/agent-models.toml](../.cursor/agent-models.toml) for the single source of truth. Summary: +See [.claude/agent-models.toml](../../agent-models.toml) for the single source of truth. Summary: - **lightweight** (`composer-1.5`) — CLI commands, API calls, file reading, parsing, template filling - **standard** (`sonnet-4.5`) — structured analysis, code review with clear inputs @@ -84,7 +90,7 @@ The following steps SHOULD be delegated to reduce token consumption: - **Step 6** (publish comment): Spawn a Task subagent with `model: "fast"` that posts the formatted comment and returns the comment URL. -Reference: [subagent-delegation rule](../../rules/subagent-delegation.mdc) +Reference: [subagent-delegation skill](../subagent-delegation/SKILL.md) ``` ## Important Notes diff --git a/assets/workspace/.cursor/rules/tdd.mdc b/assets/workspace/.claude/skills/tdd/SKILL.md similarity index 84% rename from assets/workspace/.cursor/rules/tdd.mdc rename to assets/workspace/.claude/skills/tdd/SKILL.md index 7a6fcc23..062845d0 100644 --- a/assets/workspace/.cursor/rules/tdd.mdc +++ b/assets/workspace/.claude/skills/tdd/SKILL.md @@ -1,14 +1,7 @@ --- -description: TDD discipline and test scenario guidance when writing code -alwaysApply: false -globs: - - "**/*.py" - - "**/*.ts" - - "**/*.js" - - "**/*.sh" - - "**/test_*" - - "**/*_test.*" - - "**/tests/**" +name: tdd +description: TDD discipline and test scenario guidance when writing code. Use when implementing features or fixes that have testable behavior. +disable-model-invocation: true --- # TDD @@ -16,11 +9,11 @@ globs: When implementing features or fixes that have testable behavior: 1. Write the failing test first. Run it. Confirm it fails. -2. **Commit** the failing test following [commit-messages.mdc](commit-messages.mdc) (`test: ...`). Do not proceed before committing. +2. **Commit** the failing test following the commit message standard in `CLAUDE.md` (`test: ...`). Do not proceed before committing. 3. Write minimal code to make the test pass. Run it. Confirm it passes. **Commit** the implementation. 4. Refactor. Run tests. Confirm no regressions. **Commit** the refactor if meaningful. -All commits must follow [commit-messages.mdc](commit-messages.mdc). Never use `--no-verify`. +All commits must follow the commit message standard in `CLAUDE.md`. Never use `--no-verify`. Each phase gets its own commit so the git history proves TDD compliance. diff --git a/assets/workspace/.cursor/skills/worktree_ask/SKILL.md b/assets/workspace/.claude/skills/worktree_ask/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/worktree_ask/SKILL.md rename to assets/workspace/.claude/skills/worktree_ask/SKILL.md diff --git a/assets/workspace/.cursor/skills/worktree_brainstorm/SKILL.md b/assets/workspace/.claude/skills/worktree_brainstorm/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/worktree_brainstorm/SKILL.md rename to assets/workspace/.claude/skills/worktree_brainstorm/SKILL.md diff --git a/assets/workspace/.cursor/skills/worktree_ci-check/SKILL.md b/assets/workspace/.claude/skills/worktree_ci-check/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/worktree_ci-check/SKILL.md rename to assets/workspace/.claude/skills/worktree_ci-check/SKILL.md diff --git a/assets/workspace/.cursor/skills/worktree_ci-fix/SKILL.md b/assets/workspace/.claude/skills/worktree_ci-fix/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/worktree_ci-fix/SKILL.md rename to assets/workspace/.claude/skills/worktree_ci-fix/SKILL.md diff --git a/assets/workspace/.cursor/skills/worktree_execute/SKILL.md b/assets/workspace/.claude/skills/worktree_execute/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/worktree_execute/SKILL.md rename to assets/workspace/.claude/skills/worktree_execute/SKILL.md diff --git a/assets/workspace/.cursor/skills/worktree_plan/SKILL.md b/assets/workspace/.claude/skills/worktree_plan/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/worktree_plan/SKILL.md rename to assets/workspace/.claude/skills/worktree_plan/SKILL.md diff --git a/assets/workspace/.cursor/skills/worktree_pr/SKILL.md b/assets/workspace/.claude/skills/worktree_pr/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/worktree_pr/SKILL.md rename to assets/workspace/.claude/skills/worktree_pr/SKILL.md diff --git a/assets/workspace/.cursor/skills/worktree_solve-and-pr/SKILL.md b/assets/workspace/.claude/skills/worktree_solve-and-pr/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/worktree_solve-and-pr/SKILL.md rename to assets/workspace/.claude/skills/worktree_solve-and-pr/SKILL.md diff --git a/assets/workspace/.cursor/skills/worktree_verify/SKILL.md b/assets/workspace/.claude/skills/worktree_verify/SKILL.md similarity index 100% rename from assets/workspace/.cursor/skills/worktree_verify/SKILL.md rename to assets/workspace/.claude/skills/worktree_verify/SKILL.md diff --git a/assets/workspace/.cursor/worktrees.json b/assets/workspace/.claude/worktrees.json similarity index 100% rename from assets/workspace/.cursor/worktrees.json rename to assets/workspace/.claude/worktrees.json diff --git a/assets/workspace/.cursor/rules/changelog.mdc b/assets/workspace/.cursor/rules/changelog.mdc deleted file mode 100644 index 3739a876..00000000 --- a/assets/workspace/.cursor/rules/changelog.mdc +++ /dev/null @@ -1,79 +0,0 @@ ---- -description: When and how to update CHANGELOG.md during development. Attach when editing CHANGELOG.md, committing changes, or preparing PRs. -alwaysApply: false -globs: - - CHANGELOG.md ---- - -# Changelog Update Rules - -When making code changes, follow these rules for updating [CHANGELOG.md](CHANGELOG.md). - -## When to update - -- **Always update** for `feat`, `fix`, `refactor`, `build`, `revert`, `style`, `test`, `docs` changes that affect user-visible behavior, public API, or developer workflow. -- **Always update** for dependency version bumps (including Dependabot PRs) — users and operators need to know what changed. -- **Skip** for `chore` commits that are purely internal (CI-only config tweaks, formatting) unless they have user-visible impact. -- When in doubt, add an entry — it's easier to remove during review than to add later. - -## Where to update - -- **On `dev` and feature/bugfix branches targeting `dev`:** Edit the `## Unreleased` section at the top of `CHANGELOG.md`. -- **On `release/*` branches:** There is no `## Unreleased` section. Edit the `## [X.Y.Z] - TBD` section directly. Place entries under the correct category heading within that section. -- Place the entry under the correct category heading. Create the heading if it doesn't exist yet. -- **Never** modify entries below the active section (released versions with dates). -- **Never** change the release date or version number of any section. -- **Sort order:** add new entries chronologically (newest at the bottom of each category). Entries are reordered by issue on release. -- **Editing unreleased entries:** entries in the active section represent the atomic user-facing state between versions, not a copy of commit history. You may update or consolidate existing entries across PRs (e.g. fixing a bug introduced in an earlier unreleased PR). - -## Category headings (in order) - -Use these [Keep a Changelog](https://keepachangelog.com/) categories: - -``` -### Added — new features, capabilities, tools -### Changed — changes to existing functionality -### Deprecated — features that will be removed -### Removed — features that were removed -### Fixed — bug fixes -### Security — vulnerability fixes or security improvements -``` - -## Entry format - -Follow the existing style in the file: - -```markdown -- **Bold short title** ([#](/)) - - Detail bullet explaining what was done - - Additional detail bullet if needed -``` - -Rules: -- Start with `- **Bold title**` followed by the issue link in parentheses. -- Determine the repo issues URL with `gh repo view --json url --jq '.url + "/issues"'`. -- Use sub-bullets (indented with two spaces) for implementation details. -- Reference the GitHub issue number from the `Refs:` line in the commit. -- If multiple issues are related, list them: `([#12](url), [#13](url))`. -- Keep descriptions concise and user-focused (what changed, not how). - -## Example - -```markdown -## Unreleased - -### Added - -- **SSH agent forwarding** ([#42](/42)) - - Forward host SSH agent into devcontainer for seamless git authentication - - Integration tests for SSH socket availability - -### Fixed - -- **Broken venv prompt after rename** ([#43](/43)) - - Post-create script now correctly updates the activate script prompt -``` - -## Relationship to issue templates - -If the issue has a **Changelog Category** field (e.g. "Added", "Fixed"), use that as the category. If the field says "No changelog needed", skip the changelog update. diff --git a/assets/workspace/.cursor/rules/coding-principles.mdc b/assets/workspace/.cursor/rules/coding-principles.mdc deleted file mode 100644 index afd06f91..00000000 --- a/assets/workspace/.cursor/rules/coding-principles.mdc +++ /dev/null @@ -1,21 +0,0 @@ ---- -description: Coding principles enforced on every file edit -alwaysApply: true ---- - -# Coding Principles - -1. **YAGNI** -- Implement only what the issue or user explicitly requests. No speculative features. Ask before adding anything unasked. -2. **Minimal diff** -- Touch only files and lines required for the task. No drive-by refactors, renames, or reformats. Mention improvements separately; don't silently change them. -3. **DRY** -- Don't duplicate logic. Extract shared code only after the pattern appears twice. Prefer existing abstractions over new ones. -4. **No secrets** -- Never hardcode tokens, passwords, keys, or connection strings. Use env vars. Don't commit .env or credential files. Flag existing secrets to the user. -5. **Traceability** -- Every change must link to a GitHub issue. No out-of-scope fixes. Suggest a new issue instead of bundling unrelated changes. -6. **Single responsibility** -- One function = one job. Prefer new functions over extending existing ones. Split functions exceeding ~50 lines or handling multiple concerns. - -## Stop if - -- Adding code the issue didn't ask for -- Editing files outside the task scope -- Hardcoding a secret or credential -- Making changes not traceable to an issue -- Growing a function beyond one clear purpose diff --git a/assets/workspace/.cursor/rules/commit-messages.mdc b/assets/workspace/.cursor/rules/commit-messages.mdc deleted file mode 100644 index 57dc3cf0..00000000 --- a/assets/workspace/.cursor/rules/commit-messages.mdc +++ /dev/null @@ -1,44 +0,0 @@ ---- -description: Commit message format and rules (type, Refs). Attach when committing, writing commit messages, or preparing PRs. -alwaysApply: false -globs: - - .gitmessage ---- - -# Commit Message Standard - - -## Format (exactly) - -``` -type(scope)!: short description - -Refs: # -``` - -- **First line:** `type(scope)!: short description` — imperative, no period. Use only: `feat`, `fix`, `docs`, `chore`, `refactor`, `test`, `ci`, `build`, `revert`, `style`. Scope optional; `!` only for breaking changes. -- **Blank line** after the subject. -- **Optional body** (what/why). If present, end body with a blank line. -- **Refs line** — mandatory for most types. At least one GitHub issue, e.g. `Refs: #36` or `Refs: #36, #37`. May add `REQ-...`, `RISK-...`, `SOP-...` after the issue. -- **Exactly one Refs line** — no duplicate `Refs:` lines; Refs must be the last line. -- **Exemption:** `chore` commits may omit the `Refs:` line when no issue/PR is directly related. Include `Refs:` when one is available. - -## Examples - -``` -feat(ci): add commit-msg validation hook - -Refs: #36 -``` - -``` -fix: correct subject pattern for optional scope - -Refs: #36 -``` - -## Do not use - -- Emojis or semantic-release style. -- Types outside the list (e.g. `feature`, `bugfix`). -- Commit messages without a `Refs:` line or without at least one issue ID (e.g. `#36`), except for `chore` type where `Refs:` is optional. diff --git a/assets/workspace/.cursor/rules/single-source-of-truth.mdc b/assets/workspace/.cursor/rules/single-source-of-truth.mdc deleted file mode 100644 index 385cddbd..00000000 --- a/assets/workspace/.cursor/rules/single-source-of-truth.mdc +++ /dev/null @@ -1,27 +0,0 @@ ---- -description: Single Source of Truth — no duplication of knowledge -alwaysApply: true ---- - -# Single Source of Truth (SSoT) - -Every piece of knowledge must live in exactly one place. Reference it everywhere else. - -## Core Principle - -If information exists in a file, **link to it** — never copy it. - -## Applies to - -- **Documentation as code** — docs live in the repo, version-controlled alongside the code they describe. -- **Config as code** — configuration is declarative, checked in, and machine-readable. No manual portal settings. -- **Infrastructure as code** — all infra is defined in versioned templates/scripts. No click-ops. -- **Rules & standards** — define once in a canonical file, reference via path or link. Never duplicate across READMEs, comments, or wikis. -- **Comments & docstrings** — don't repeat what a referenced doc already says. Link to the source instead. - -## In Practice - -- Before writing explanatory text, check if a canonical source already exists. -- If it does → link to it (`see docs/COMMIT_MESSAGE_STANDARD.md`). -- If it doesn't → create the canonical file first, then link to it. -- Never maintain the same information in two places. diff --git a/assets/workspace/.devcontainer/CHANGELOG.md b/assets/workspace/.devcontainer/CHANGELOG.md index fc9f41aa..a62d9709 100644 --- a/assets/workspace/.devcontainer/CHANGELOG.md +++ b/assets/workspace/.devcontainer/CHANGELOG.md @@ -9,16 +9,151 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Consolidated `docs/NIX.md` Nix reference** ([#255](https://github.com/vig-os/devcontainer/issues/255)) + - Added a single onboarding/architecture doc for the flake: the `devTools` toolchain SSoT and the dev-shell ↔ image parity guard, the stable/unstable channel split + fast-mover overlay, the Nix-built (`buildLayeredImage`) reproducible multi-arch image, the CppNix-vs-Lix and `pre-commit`-vs-`prek` decisions, the `vig-os` Cachix `direnv allow` flow, how `nixpkgs` bumps flow (Renovate `nix` manager + `vulnix` before/after), and the #639 publish-cutover — cross-linking `CONTRIBUTE.md`, `docs/NIX2CONTAINER.md`, and `docs/CONTAINER_SECURITY.md` +- **In-container Nix runtime smoke test** ([#675](https://github.com/vig-os/devcontainer/issues/675)) + - The `Nix Image (discovery)` workflow now runs a self-contained, network-free smoke script (`scripts/nix_runtime_smoke.sh`) inside the built image to prove the baked Nix toolchain actually *functions* (not merely that it is present, which is all the portable testinfra suite checked): `nix --version`, `direnv version`, a real `nix eval` exercising the evaluator with `nix-command`/`flakes`, and a `direnv allow`/`exec` round-trip — gating the build/test job so a broken in-container `nix`/`direnv` fails CI +- **Nix flake quality gates** ([#674](https://github.com/vig-os/devcontainer/issues/674)) + - Added a `formatter` output (`nixfmt-rfc-style`) so `nix fmt` formats nix files idempotently, a `nixfmt --check` pre-commit hook (nixfmt sourced from the flake dev-shell), lightweight flake `checks` (format check, dev-shell build, `devShellTools` eval), and a `nix flake check --accept-flake-config` step in the CI project-checks job +- **Install/init delivery-mode picker (`--mode devcontainer|direnv|both`)** ([#641](https://github.com/vig-os/devcontainer/issues/641)) + - `install.sh` gained a `--mode devcontainer|direnv|both` flag (accepts both `--mode X` and `--mode=X`), validated up front and passed through to `init-workspace.sh`. Empty means "let init-workspace decide": the one-line install runs non-interactively and defaults to `both` (unchanged behaviour) + - `init-workspace.sh` gained the same `--mode` flag plus an interactive prompt when the mode is unset and prompts are enabled (default selection `both`); under `--no-prompts`/`--smoke-test` with no `--mode` it defaults to `both`. After the rsync scaffold it prunes to the chosen mode: `devcontainer` removes the `flake.nix` + `.envrc` stub, `direnv` removes the `.devcontainer/` scaffold, and `both` keeps everything (prune is idempotent and scoped to the new workspace) +- **Downstream minimal flake stub (non-overwriting) + `nix2container` production builder** ([#640](https://github.com/vig-os/devcontainer/issues/640)) + - Scaffold `assets/workspace/flake.nix` (a minimal stub consuming the shared toolchain as a flake input — `vigos.url = github:vig-os/devcontainer`, `nixpkgs.follows = vigos/nixpkgs`, `vigos.lib.mkProjectShell` + a placeholder `extraPackages`) and `assets/workspace/.envrc` (`use flake` via nix-direnv). Updating the dev environment is `nix flake update vigos`; it never overwrites user files + - Added both to the `PRESERVE_FILES` never-overwrite class in `init-workspace.sh` (same guarantee as `justfile.project`) and committed the template `.envrc` (un-ignored in the template `.gitignore`, with `.direnv/`/`.envrc.local` still ignored) + - Documented the `nix2container` production-image pattern (`docs/NIX2CONTAINER.md`) with a buildable example (`examples/nix2container-production/`) that derives a minimal runtime image from the same pinned `nixpkgs`, plus a note on the future opt-in modular language shells +- **`vulnix` + SBOM CVE scanning for the Nix image; re-authored security policy** ([#637](https://github.com/vig-os/devcontainer/issues/637)) + - Added a nightly `scan-nix-image` job that builds the image's package closure (new flake `packages.devcontainerImageEnv`) and runs `vulnix` (the nixpkgs-native CVE scanner) as the primary signal, since a Nix image has no apt/dpkg database for Trivy's OS scanner; Trivy stays on to emit a CycloneDX SBOM and an SBOM-mode vuln view (defence in depth), and both scanners' output is archived as `vulnix`-vs-Trivy overlap evidence + - Added the `vulnix-gate` utility (`packages/vig-utils`) and the `.vulnixignore` exception register: a HIGH/CRITICAL finding (CVSS v3 ≥ 7.0) blocks only when it is not covered by a non-expired exception. `.vulnixignore` reuses the `.trivyignore` `Expiration:` format and the `check-expirations` validator (pre-commit + CI), and exposes a pinned `packages.vulnix` for reproducible scans. The gate is non-blocking during discovery and becomes the #639 go/no-go gate at cutover + - Re-authored `docs/CONTAINER_SECURITY.md` for the Nix posture: dropped the `apt --only-upgrade` escape hatch and the "why not `apt-get upgrade`" section, made "advance the pinned `nixpkgs` rev" the primary CVE lever, and documented the dual `.vulnixignore`/`.trivyignore` registers and the residual Debian `:latest` scan until decommission (#642) +- **Multi-arch Nix image (amd64 + arm64) discovery build** ([#636](https://github.com/vig-os/devcontainer/issues/636)) + - The `Nix Image (discovery)` workflow now builds `packages.devcontainerImage` natively on an amd64 (`ubuntu-24.04`) + arm64 (`ubuntu-24.04-arm`) matrix — no QEMU or cross-compilation — pushes per-arch discovery tags (`nix-dev-amd64`, `nix-dev-arm64`), and assembles a top-level multi-arch index (`nix-dev`) with `docker buildx imagetools create`, verifying both platforms via `imagetools inspect` + - `cachix-action` runs with an auth token on every leg so the arm64 closure is pushed to the `vig-os` Cachix cache; the workflow stays `continue-on-error` and only touches the disposable `nix-dev*` tags — the versioned/`:latest` publish-cutover remains #639 +- **Renovate `nix` manager for `flake.lock` maintenance** ([#638](https://github.com/vig-os/devcontainer/issues/638)) + - Enabled the Renovate `nix` manager and weekly `lockFileMaintenance` in `renovate.json` so flake inputs (notably `nixpkgs`) are bumped through the normal PR/CI gate; the existing `pep621`, `npm`, `github-actions`, and `dockerfile` managers are retained + - Documented the compensating control in `docs/CONTAINER_SECURITY.md`: every `flake.lock`/nixpkgs-bump PR must include a `vulnix` before/after diff, since the `nix` manager reports only the input revision change and not which CVE a bump fixes +- **De-duplicate the flake into the toolchain SSoT** ([#631](https://github.com/vig-os/devcontainer/issues/631)) + - Factored a single `devTools` list in `flake.nix` as the source of truth shared by the dev-shell now and the image later, absorbing the agent-CLI toolkit (`rg`, `fd`, `bat`, `eza`, `delta`, `lazygit`, `zoxide`, `starship`, `freeze`, `expect`, `nvim`) plus `claude` ([#545](https://github.com/vig-os/devcontainer/issues/545)) + - Pinned `nixpkgs` to `nixos-25.05` and added a `nixpkgs-unstable` input overlaid only for fast-movers (`uv`, `gh`, `claude-code`); refreshed `flake.lock` + - Added reusable flake outputs `lib.mkProjectShell`, `overlays.default`, and a `packages.devcontainerImage` stub for the later image build + - Added a non-blocking `Nix Cachix` workflow (with `workflow_dispatch`) that builds the dev-shell and pushes its closure to the `vig-os` Cachix cache + - Added a per-tool `nix develop -c --version` parity test driven from the flake SSoT to guard against future dev-shell/image drift +- **nix-direnv onboarding fast path** ([#633](https://github.com/vig-os/devcontainer/issues/633)) + - Switched `.envrc` from bare `use flake` to nix-direnv: the dev-shell evaluation is now GC-rooted and cached under `.direnv/`, so re-entering the directory is instant and the closure is never garbage-collected; nix-direnv self-bootstraps on first `direnv allow` and falls back to bare `use flake` when unavailable + - Documented the clone → `direnv allow` onboarding flow, the `vig-os` Cachix substituter (binary fetch instead of from-source build on first allow), and enabling the `nix-command`/`flakes` experimental features in `CONTRIBUTE.md` ([#255](https://github.com/vig-os/devcontainer/issues/255)) +- **Build the devcontainer image with Nix (`buildLayeredImage`, non-publishing)** ([#634](https://github.com/vig-os/devcontainer/issues/634)) + - Fleshed out `packages.devcontainerImage` from a stub into a real, bit-reproducible image assembled by `dockerTools.buildLayeredImage` (not a Dockerfile `FROM`); a `--rebuild` verifies the closure hash is identical + - Baked the in-container Nix evaluator (upstream CppNix, `pkgs.nix`) plus `direnv`/`nix-direnv` into the closure so `nix`/`direnv` are live inside the container; documented the CppNix-vs-Lix and `pre-commit`-vs-`prek` decisions in the flake + - Reproduced the Debian bootstrap layers in Nix: locale via `glibcLocales` + `LOCALE_ARCHIVE` (no `locale-gen`), `/root/assets`, pre-commit cache dir, template `.venv` scaffold (`UV_PYTHON_DOWNLOADS=never`, `UV_PYTHON=`), the `precommit`/`cc`/`cld` aliases, and `IS_SANDBOX=1` + - Added `fakeNss` (root uid-0 user database) and a sticky `/tmp` to close the first FHS gaps surfaced by the portable testinfra (fixing `ssh`, `whoami`, and `tmux`) + - Added a non-publishing `Nix Image (discovery)` workflow (with `workflow_dispatch`) that builds the image and runs the portable testinfra under `continue-on-error: true` + ### Changed +- **README now describes the Nix-built image** ([#673](https://github.com/vig-os/devcontainer/issues/673)) + - Replaced the stale `python:3.12-slim-trixie` Debian base-image claim with the actual build: a Nix flake assembled via `dockerTools.buildLayeredImage` (no Debian/Docker base), with CPython 3.14 and the toolchain from a pinned `nixpkgs`, bit-reproducible +- **Make `just init` Nix-first** ([#671](https://github.com/vig-os/devcontainer/issues/671)) + - Rewrote `scripts/init.sh` from a multi-OS package installer into a Nix-first gate + bootstrapper: it requires Nix (and direnv, unless `--no-direnv`) and the dev-shell toolchain, then performs one-time, idempotent project bootstrap (`uv sync --frozen --all-extras`, git hooks path, commit-message template, `pre-commit install-hooks`) with advisory `podman info` / `gh auth status` checks. It no longer installs any tool — the toolchain is the flake's `devTools` — and short-circuits inside the built image (`IN_CONTAINER=true`) + - Repointed `docs/generate.py` and the `CONTRIBUTE.md.j2` template: the per-OS "Requirements" table is now a "Prerequisites: Nix + direnv + a working host container runtime" section, with the toolchain sourced from `flake.nix` + +- **Nix image passes the full testinfra suite (toolchain parity)** ([#666](https://github.com/vig-os/devcontainer/issues/666)) + - Packaged `vig-utils` (and `pip-licenses` from its PyPI wheel, as it is not in nixpkgs) as Nix python packages exposed through a `python314.withPackages` env, and added `ruff`, `bandit`, `cargo-binstall`, `just-lsp`, and `typstyle` from nixpkgs — the Nix image now carries the project Python toolchain hermetically, replacing the Debian image's build-time `uv pip install` + - Relaxed `requires-python` from `==3.14.6` to `>=3.14,<3.15` across the root, `vig-utils`, and workspace-template pyprojects: `flake.lock` is the reproducibility anchor now, so the exact pin was redundant and unsatisfiable against nixpkgs (3.14.4) + - Adapted `tests/test_image.py` to the Nix toolchain (version prefixes are nixpkgs-pinned, so fast-movers/mismatched tools are checked for presence/run only; the pre-commit cache dir is asserted present rather than pre-populated, since a hermetic build cannot fetch hook repos), taking the suite to 63/63 — and made the `nix-image.yml` `build-and-test` job gate on it (discovery phase closed) +- **Stage the Nix publish-cutover; advance the nixpkgs baseline to 26.05** ([#639](https://github.com/vig-os/devcontainer/issues/639)) + - Bumped the pinned channel `nixos-25.05` → `nixos-26.05` (the "advance the rev" CVE lever), cutting the vulnix HIGH/CRITICAL surface 83 → 27 and Trivy HIGH 244 → 14 on the image; triaged the residual 27 into `.vulnixignore` (4 CPE-mismatch false positives — VS Code/Jenkins, not the binaries; 23 recent CVEs accepted as low-risk in an interactive dev container with a 3-month re-review) + - Made the nightly `vulnix-gate` **blocking** (the #639 go/no-go gate) now that it is legitimately green, and archived the `vulnix`-vs-Trivy scan overlap in `docs/security/nix-cutover-scan-overlap.md` (zero overlap — disjoint surfaces, no finding class lost in the Debian→Nix switch) + - Staged the publish-cutover so the versioned/`:latest` publish stays paused pending a deliberate Nix release: the nightly `vulnix-gate` is the go/no-go signal. The build pipeline became Nix-only once the Debian path was decommissioned (#642), so no `builder` toggle remains — the interim `builder: debian|nix` selector this issue introduced was superseded by that decommission +- **Make `.claude/` the single source of truth for agent rules and skills** ([#626](https://github.com/vig-os/devcontainer/issues/626)) + - Moved the 30 agent skills from `.cursor/skills/` to `.claude/skills/` and rewrote the 29 `.claude/commands/*.md` wrappers to point at the new paths + - Split the seven `.cursor/rules/*.mdc`: static principles (coding principles, commit messages, changelog, single source of truth) are now consolidated in `CLAUDE.md`; workflow rules (`branch-naming`, `tdd`, `subagent-delegation`) became on-demand `.claude/skills/` + - Ported `agent-models.toml` and `worktrees.json` to `.claude/`, updated the docs generator, pre-commit hooks, shell entrypoints, and the workspace sync manifest, and deleted the root `.cursor/` directory +- **Drive autonomous worktree pipelines with the `claude` CLI** ([#627](https://github.com/vig-os/devcontainer/issues/627)) + - `just worktree-start`/`worktree-attach` now launch `claude --dangerously-skip-permissions` in the tmux session instead of `cursor-agent` (`agent chat --yolo --approve-mcps`); the cursor-specific directory-trust step and the `tmux send-keys "a"` approval trigger are no longer needed and have been removed + - Prerequisite, authentication (`claude auth status`/`claude auth login`, `ANTHROPIC_API_KEY`), and `scripts/requirements.yaml` now reference the `claude` CLI rather than the Cursor Agent CLI +- **Migrate the workspace template and editor glue off Cursor (VS Code only)** ([#629](https://github.com/vig-os/devcontainer/issues/629)) + - New workspaces now scaffold `.claude/` (skills, `agent-models.toml`, `worktrees.json`) instead of the removed `.cursor/` template tree; the sync manifest carries the `.claude/` payload accordingly + - `just open` launches VS Code only (dropped the `command -v cursor` fallback), and `verify-auth.sh` no longer scans the `cursor-remote-ssh` SSH-agent socket + - `COMMIT_MESSAGE_STANDARD.md` now refers to VS Code rather than "VS Code / Cursor" +- **Make the image testinfra suite portable across Debian and Nix images** ([#635](https://github.com/vig-os/devcontainer/issues/635)) + - Replace dpkg `host.package(...).is_installed` checks (git, curl, openssh-client, nano, tmux, rsync) with path-agnostic `--version`/`-V` runs + - Resolve `gh`, `just`, `hadolint`, `taplo` and cargo-installed tools via PATH (`command -v`) instead of hardcoded `/usr/local/bin` / `/root/.cargo/bin` / `/root/.local/bin` locations + - Drop the `DEBIAN_FRONTEND` environment assertion and the apt-sourced version-prefix checks (git, curl, tmux, rsync) from `EXPECTED_VERSIONS` +- **Provision CI build/test tooling from the flake dev-shell** ([#632](https://github.com/vig-os/devcontainer/issues/632)) + - The `setup-env` action gained a `provision-via-flake` mode that installs Nix (SHA-pinned `install-nix-action`) and the `vig-os` Cachix substituter, builds the flake dev-shell, and prepends its tools to `PATH`, replacing the ad-hoc installs of `uv`/Python, `just`, `hadolint`, and `taplo` + - Enabled the mode in the CI build/test path (`build-image`, `test-image`, `test-integration`, `project-checks`) so jobs run inside the flake shell (the toolchain SSoT); `podman`, Node.js, BATS, and the devcontainer CLI keep their dedicated install paths + - Set `UV_PYTHON_DOWNLOADS_JSON_URL` in the flake dev-shell so the nixpkgs `uv` (whose embedded Python-download list is stripped) can fetch the project's pinned CPython `3.14.6`, which nixpkgs does not package, letting `uv sync --frozen` succeed under flake provisioning + - Keep `podman` off the flake-provisioned `PATH` so the runner's rootless-configured host `podman` is used (the nix-store `podman` cannot reach the host's setuid `newuidmap`/`newgidmap`, so `podman info` failed) + ### Deprecated ### Removed +- **Retire `scripts/requirements.yaml`** ([#671](https://github.com/vig-os/devcontainer/issues/671)) + - Deleted the per-OS dependency manifest and its consumers (the `load_requirements`/`format_requirements_table`/`format_install_commands` helpers in `docs/generate.py` and their tests). `flake.nix` `devTools` is now the single source of truth for the toolchain, ending the dual-SSoT drift + +- **Decommission the Debian build path** ([#642](https://github.com/vig-os/devcontainer/issues/642)) + - Deleted the root `Containerfile`, `scripts/prepare-build.sh`, `scripts/build.sh`, and the `.hadolint.yaml` config (plus its synced workspace copy); the image now builds Nix-only + - Removed the `hadolint` pre-commit hook and its `setup-env`/`test-project` install wiring, the `hadolint` and Containerfile entries from `scripts/requirements.yaml` and `scripts/manifest.toml`, and the `Containerfile`/build-script `CODEOWNERS` entries + - `build-image`, `release.yml`, and `ci.yml` are now Nix-only + - Dropped the Debian `scan-latest` nightly Trivy job, the ~78 Debian OS-package CVE entries from `.trivyignore`, and the Renovate `dockerfile` manager; `docs/CONTAINER_SECURITY.md` now reads as Nix-only + +- **Remove the `cursor-agent` CLI install from the image** ([#628](https://github.com/vig-os/devcontainer/issues/628)) + - Dropped the unpinned `curl … cursor.com/install` build step and its `/root/.local/bin` PATH entry, leaving an all-nixpkgs toolchain ahead of the Nix migration + - Removed the coupled `test_cursor_agent_installed` image test + ### Fixed +- **Nix dev-shell no longer breaks `just` on non-NixOS hosts (Nix C++ runtime leaked onto `LD_LIBRARY_PATH`)** ([#703](https://github.com/vig-os/devcontainer/issues/703)) + - The [#698](https://github.com/vig-os/devcontainer/issues/698) fix exported `${stdenv.cc.cc.lib}/lib` (the Nix C++ runtime, linked against glibc 2.42) onto the dev-shell `LD_LIBRARY_PATH` unconditionally. On an FHS host whose system glibc is older (e.g. Ubuntu 24.04 ships 2.39), that `libstdc++` is pulled into host binaries — every `just` recipe's `#!/usr/bin/env bash`, plus anything an `/etc/ld.so.preload` agent forces `libstdc++` into — dragging in the Nix `libm.so.6` and aborting with `version 'GLIBC_ABI_DT_X86_64_PLT' not found`, so every `just` recipe failed inside `nix develop`. It worked on NixOS only because there the system glibc *is* the Nix glibc (no skew) + - `mkProjectShell` now injects the Nix C++ runtime onto `LD_LIBRARY_PATH` only on NixOS (`[ -e /etc/NIXOS ]`), where it is both required (libstdc++ is off the default loader path) and ABI-safe (the system glibc is the Nix glibc); FHS hosts resolve `libstdc++` from the system loader, so the injection is a no-op there and nothing leaks. The [#698](https://github.com/vig-os/devcontainer/issues/698) dev-shell parity tests are gated to NixOS and an FHS leak-guard (`test_devshell_no_nix_cxx_runtime_leak_on_fhs_host`) was added +- **Integration tests now exercise the freshly-built image, not the published `DEVCONTAINER_VERSION`** ([#701](https://github.com/vig-os/devcontainer/issues/701)) + - The integration suite scaffolded a workspace from the image under test (`TEST_CONTAINER_TAG`) but then brought the devcontainer up from whatever `DEVCONTAINER_VERSION` resolved to (the published `0.3.9`), so it validated fresh scaffolding running inside a stale image. The `devcontainer_up`/`devcontainer_with_sidecar` fixtures now export `DEVCONTAINER_VERSION=TEST_CONTAINER_TAG`; compose reads the shell environment over `.env`, so the scaffolded `docker-compose.yml` resolves to the build under test (and every `devcontainer exec`, which inherits the environment, agrees). Added `test_devcontainer_runs_image_under_test` asserting the running container's image + - Guarded the `post-create.sh` venv-prompt `sed` and moved it after `just sync`: the Nix image populates `/root/assets/workspace/.venv` during post-create rather than baking it at image-build time (as the Debian image did), so the unguarded `sed` aborted bring-up with `can't read .venv/bin/activate` + - Reverted the [#697](https://github.com/vig-os/devcontainer/issues/697) scaffold decoupling: the scaffolded `ruff`/`ruff-format`/`typos` hooks are `language: system` again (resolved from the toolchain baked into the image, like the repo's own config) instead of self-contained upstream manylinux hooks, which the non-FHS Nix userland cannot execute. Removed the now-unused `ReplacePrecommitRepoBlock` sync-manifest transform and its tests +- **`pymarkdown` pre-commit hook no longer fails on NixOS (`pyjson5` C extension missing `libstdc++`)** ([#698](https://github.com/vig-os/devcontainer/issues/698)) + - The `pymarkdown` hook runs from pre-commit's own manylinux-wheel Python env, whose dependency `pyjson5` is a C extension linked against `libstdc++.so.6`; on a NixOS host that library is not on the loader path outside an FHS environment, so the hook aborted with `ImportError: libstdc++.so.6: cannot open shared object file` and forced `--no-verify`. Unlike the standalone binaries in [#697](https://github.com/vig-os/devcontainer/issues/697), `pymarkdown` is not in nixpkgs, so the "add to `devTools` + `language: system`" recipe does not apply + - `mkProjectShell` now appends `${pkgs.stdenv.cc.cc.lib}/lib` to `LD_LIBRARY_PATH` in the dev-shell, so the wheel's C extension resolves the Nix C++ runtime. It is the same `libstdc++` the Nix toolchain itself links, so the other dev-shell binaries keep working (no version clash); the existing mkShell-injected `LD_LIBRARY_PATH` is appended to, not clobbered. Generalises to any future C-extension Python hook. Documented in `docs/NIX.md` + - Added dev-shell parity tests asserting `LD_LIBRARY_PATH` carries `libstdc++.so.6` and that the C library loads under the dev-shell loader +- **pre-commit ruff/ruff-format/typos hooks now run on NixOS hosts (sourced from the flake)** ([#697](https://github.com/vig-os/devcontainer/issues/697)) + - The `ruff`, `ruff-format`, and `typos` hooks pulled compiled tools as generic-linux (manylinux) wheels from `astral-sh/ruff-pre-commit` and `crate-ci/typos`; a NixOS host cannot execute those binaries out of the box (no FHS `ld-linux`), forcing `--no-verify` on every local commit + - Added `ruff` and `typos` to the flake `devTools` SSoT and converted the three hooks to `repo: local` / `language: system` (`ruff check --fix`, `ruff format`, `typos`), so they resolve their tool from the Nix dev-shell like the other local hooks — no host setup needed inside the dev-shell. Re-synced the scaffolded `assets/workspace/.pre-commit-config.yaml`. Hook versions now track `nixpkgs`/`flake.lock` (Renovate `nix` manager) instead of upstream `rev:` pins, consistent with the #625 toolchain consolidation + - Removed `ruff` from the project's uv dependency groups (`pyproject.toml`/`uv.lock`) and repointed `just lint`/`just format` to the flake `ruff` (dropping `uv run`). Otherwise the venv's `ruff` (a manylinux wheel) shadowed the flake `ruff` under `uv run` — which is how the `.githooks/pre-commit` hook and the `just` recipes invoke it — so `ruff` stayed broken on NixOS; the flake is now the single `ruff` source (its `[tool.ruff]` config is unchanged) + - Declared `PATH` in the devcontainer image's OCI `config.Env`. `buildLayeredImage` symlinks the toolchain into `/bin` but set no PATH; `podman run` injects a default (so it worked), but `docker-compose` / `devcontainer exec` inherit `config.Env` verbatim, leaving the baked toolchain off PATH. Added an image test asserting the OCI config declares a PATH containing `/bin` + - Kept the **scaffolded** config's `ruff`/`ruff-format`/`typos` as self-contained upstream hooks (via a new `ReplacePrecommitRepoBlock` sync-manifest transform), reverting the `language: system` conversion for downstream workspaces only. A downstream workspace commits inside the published image without the flake toolchain on PATH, so its hooks must be pre-commit-managed; the repo's own config stays `language: system`. Without this the integration suite's in-container `git commit` failed with `Executable 'typos' not found` +- **BATS suite no longer fails locally on the Nix toolchain (helper libraries unresolved)** ([#695](https://github.com/vig-os/devcontainer/issues/695)) + - `tests/bats/test_helper.bash` resolved the BATS helper libraries (`bats-support`/`-assert`/`-file`) from `node_modules` (npm) or the now-removed Debian `/usr/lib` path; on the Nix toolchain neither exists locally, so every `.bats` file errored in `setup()` (`Could not find library 'bats-support'`) and all 246 tests failed + - Added `bats` wrapped with its helper libraries to the flake `devTools` SSoT and exported `BATS_LIB_PATH` in the dev-shell and image, so `bats_load_library` resolves the helpers from the Nix store; simplified `test_helper.bash` to that single path, switched `just test-bats` to the flake-provided `bats`, and removed the now-unused `bats*` npm dependencies. CI provisions BATS from the flake under `provision-via-flake` (the ad-hoc `bats-action` steps now run only for non-flake callers) +- **Host-executed scripts no longer fail on NixOS (non-portable `#!/bin/bash` shebang)** ([#687](https://github.com/vig-os/devcontainer/issues/687)) + - `install.sh`, `assets/workspace/.devcontainer/scripts/initialize.sh`, and `assets/workspace/.devcontainer/scripts/version-check.sh` hardcoded `#!/bin/bash`, which has no `/bin/bash` on NixOS and similar hosts, so they failed to execute (and `just test` aborted). Switched all three to the portable `#!/usr/bin/env bash` (already used by `scripts/init.sh`), which resolves `bash` via `PATH` +- **`allowed-signers` integration test no longer rejects valid ECDSA / security-key SSH keys** ([#688](https://github.com/vig-os/devcontainer/issues/688)) + - `test_allowed_signers_file_exists` only accepted `ssh-ed25519`/`ssh-rsa`, so a valid ECDSA (or FIDO `sk-*`) signing key spuriously failed; the assertion now accepts the full OpenSSH signing key-type set (mirroring the canonical list already used in `test_git_signing_key_configured`), including the `ecdsa-sha2-nistp*` curves and the `sk-ssh-ed25519@openssh.com` / `sk-ecdsa-sha2-nistp256@openssh.com` security-key variants +- **Install-script test suite no longer trips a pytest-10-removal deprecation (class-scoped fixture as instance method)** ([#691](https://github.com/vig-os/devcontainer/issues/691)) + - `TestInstallScriptIntegration.install_workspace` was a class-scoped fixture defined as an instance method, which pytest 9 flags with `PytestRemovedIn10Warning` and pytest 10 removes — a future `pytest` bump would then error at collection and take out the whole install-script suite. Converted it to a `@staticmethod` (it never used `self`), preserving the class-scope "run `install.sh` once per class" behaviour; verified with `-W error::pytest.PytestRemovedIn10Warning` +- **`just build` no longer fails on dev-shell-only podman hosts (missing containers `policy.json`)** ([#685](https://github.com/vig-os/devcontainer/issues/685)) + - On a NixOS host that gets `podman` purely from the flake dev-shell (no `virtualisation.containers` module), no signature-verification `policy.json` exists at `/etc/containers/policy.json` or `~/.config/containers/policy.json`, so `podman load` (`just build`) failed even though `nix build` and the advisory `podman info` check (`just init`) were green + - `just init` now ensures the user-level `~/.config/containers/policy.json` with the standard permissive default (`{ "default": [ { "type": "insecureAcceptAnything" } ] }`, the same content `containers-common` / the NixOS module ship); the write is idempotent and never overwrites a system or user policy. Documented in `docs/NIX.md` +- **`just init` no longer fails on NixOS hosts (uv downloaded a CPython NixOS cannot execute)** ([#683](https://github.com/vig-os/devcontainer/issues/683)) + - The flake dev-shell carried no Python and let the nixpkgs `uv` fetch a managed CPython — a generic, dynamically-linked ELF a NixOS host cannot execute out of the box (no FHS `ld-linux`) — so `uv sync` (`just init`) aborted on NixOS hosts while FHS hosts were unaffected + - `mkProjectShell` now pins a Nix store CPython via `UV_PYTHON` and sets `UV_PYTHON_DOWNLOADS=never`, so the dev-shell builds the venv from a store interpreter (patched to the store loader) that runs on both NixOS and FHS hosts instead of a downloaded one + - CI keeps its managed-download path (`UV_PYTHON_DOWNLOADS_JSON_URL`) and does **not** receive `UV_PYTHON`: the `provision-via-flake` jobs run outside `nix develop` on an FHS runner, where a Nix store interpreter cannot load pre-commit's manylinux-wheel C extensions (`libstdc++.so.6`) + - Added dev-shell tests asserting `UV_PYTHON_DOWNLOADS=never` and `UV_PYTHON` pinned to a runnable Nix store CPython 3.14 +- **Nix image no longer scaffolds dangling, read-only symlinks into a new workspace** ([#664](https://github.com/vig-os/devcontainer/issues/664)) + - The Nix-built image bakes the workspace template as read-only `/nix/store` symlinks (how `buildLayeredImage` represents the layer); `init-workspace.sh` now rsyncs with `--copy-links` and `chmod -R u+w "$WORKSPACE_DIR"`, so a scaffolded workspace gets real, writable files instead of symlinks that dangle on the host (and the placeholder `sed -i` no longer fails on read-only files). No-op on the Debian image + - Added a static bats guard (scaffold rsync uses `--copy-links`; workspace made writable) and a behavioural step in `nix-image.yml` that scaffolds via the real Nix image and asserts no dangling symlinks — the install/integration suite otherwise only exercises the Debian image +- **`just wt-start` no longer aborts on its helper-CLI prerequisite check** ([#657](https://github.com/vig-os/devcontainer/issues/657)) + - `derive-branch-summary` now handles `-h`/`--help` (prints usage, exits 0) instead of treating the flag as an issue title and failing; the worktree launcher probes availability with `--help`, so the bug blocked worktree creation entirely +- **CONTRIBUTE prerequisites now document the direnv shell hook** ([#633](https://github.com/vig-os/devcontainer/issues/633)) + - The `direnv` prerequisite promised the dev-shell "loads automatically on `cd`" but never documented installing direnv's shell hook (`eval "$(direnv hook bash)"`), the step that behaviour depends on. Without the hook, `direnv allow` still succeeds yet the flake never activates on `cd` and host tooling (e.g. an old system Node) is used with no warning. Documented the hook in the prerequisites table and as a fast-path note, with `nix develop` as the hook-free fallback +- **Workspace python interpreter pointed at the dead `/opt/venv` path** ([#706](https://github.com/vig-os/devcontainer/issues/706)) + - The synced `.vscode/settings.json` rewrote `python.defaultInterpreterPath` to `/opt/venv/bin/python3`, which no longer exists on the Nix image, breaking the VS Code interpreter for downstream projects + - The interpreter now stays workspace-relative (`${workspaceFolder}/.venv/bin/python3`), matching the `uv`-created `.venv` in the opened project + ### Security +- **Drop the piscina CVE ignore tied to `cursor-agent`** ([#628](https://github.com/vig-os/devcontainer/issues/628)) + - Removed the `CVE-2026-55388` (piscina) `.trivyignore` entry, which only existed for the now-removed `cursor-agent` CLI + ## [0.3.9](https://github.com/vig-os/devcontainer/releases/tag/0.3.9) - 2026-06-23 ### Fixed diff --git a/assets/workspace/.devcontainer/justfile.devc b/assets/workspace/.devcontainer/justfile.devc index 15ecabb4..48319f3b 100644 --- a/assets/workspace/.devcontainer/justfile.devc +++ b/assets/workspace/.devcontainer/justfile.devc @@ -202,7 +202,7 @@ restart *args: exit 1 fi -# Open Cursor/VS Code attached to the running container +# Open VS Code attached to the running container [group('devcontainer')] open: #!/usr/bin/env bash @@ -210,12 +210,10 @@ open: echo "[ERROR] Run this from the host: just open" exit 1 fi - if command -v cursor &>/dev/null; then - cursor . - elif command -v code &>/dev/null; then + if command -v code &>/dev/null; then code . else - echo "[ERROR] Neither cursor nor code found. Install Cursor or VS Code." + echo "[ERROR] code not found. Install VS Code." exit 1 fi diff --git a/assets/workspace/.devcontainer/justfile.worktree b/assets/workspace/.devcontainer/justfile.worktree index 7e106efd..c9b43788 100644 --- a/assets/workspace/.devcontainer/justfile.worktree +++ b/assets/workspace/.devcontainer/justfile.worktree @@ -12,7 +12,7 @@ alias wt-attach := worktree-attach alias wt-stop := worktree-stop alias wt-clean := worktree-clean # NOTE: Cursor's native worktree UI does NOT work inside devcontainers (Feb 2026). -# These recipes provide a CLI-based alternative using tmux + cursor-agent. +# These recipes provide a CLI-based alternative using tmux + the claude CLI. # Native worktree support (.cursor/worktrees.json) works on macOS/Linux local only. # Tracked: https://forum.cursor.com/t/cursor-parallel-agents-in-wsl-devcontainers-misresolve-worktree-paths-and-context/145711 # =============================================================================== @@ -25,7 +25,7 @@ _wt_base := "../" + _wt_repo + "-worktrees" # START # ------------------------------------------------------------------------------- -# Create a worktree for an issue, open tmux session, launch cursor-agent +# Create a worktree for an issue, open tmux session, launch the claude CLI [group('worktree')] worktree-start issue prompt="" reviewer="": #!/usr/bin/env bash @@ -36,9 +36,9 @@ worktree-start issue prompt="" reviewer="": echo "[ERROR] tmux is not installed. Install it first." exit 1 fi - if ! command -v agent >/dev/null 2>&1; then - echo "[ERROR] cursor-agent CLI is not installed." - echo "Install: curl https://cursor.com/install -fsSL | bash" + if ! command -v claude >/dev/null 2>&1; then + echo "[ERROR] claude CLI is not installed." + echo "Install: npm install -g @anthropic-ai/claude-code" exit 1 fi if ! resolve-branch --help >/dev/null 2>&1; then @@ -52,24 +52,6 @@ worktree-start issue prompt="" reviewer="": exit 1 fi - # Helper: ensure a directory is in cursor-agent's trustedDirectories - _wt_ensure_trust() { - local dir_abs - dir_abs=$(cd "$1" && pwd) - local cfg="${HOME}/.cursor/cli-config.json" - mkdir -p "$(dirname "$cfg")" - if [ ! -f "$cfg" ]; then - echo '{}' > "$cfg" - fi - if ! jq -e --arg d "$dir_abs" '.trustedDirectories // [] | index($d)' "$cfg" >/dev/null 2>&1; then - jq --arg d "$dir_abs" '.trustedDirectories = ((.trustedDirectories // []) + [$d])' "$cfg" > "${cfg}.tmp" \ - && mv "${cfg}.tmp" "$cfg" - echo "[OK] Trusted directory added: $dir_abs" - else - echo "[OK] Directory already trusted: $dir_abs" - fi - } - # Helper: read agent model from config _read_model() { local tier="$1" @@ -77,19 +59,19 @@ worktree-start issue prompt="" reviewer="": grep "^${tier}" "$cfg" | sed 's/.*= *"//' | sed 's/".*//' } - # Auth: check existing login first, then fall back to CURSOR_API_KEY - if agent status 2>/dev/null | grep -qi "logged in\|authenticated"; then - echo "[OK] cursor-agent: authenticated via browser login" - elif [ -n "${CURSOR_API_KEY:-}" ]; then - echo "[OK] cursor-agent: using CURSOR_API_KEY" + # Auth: check existing login first, then fall back to ANTHROPIC_API_KEY + if claude auth status 2>/dev/null | grep -qi "logged in\|authenticated"; then + echo "[OK] claude: authenticated via browser login" + elif [ -n "${ANTHROPIC_API_KEY:-}" ]; then + echo "[OK] claude: using ANTHROPIC_API_KEY" else - echo "[!] cursor-agent: not authenticated. Attempting browser login..." - if agent login; then - echo "[OK] cursor-agent: browser login successful" + echo "[!] claude: not authenticated. Attempting browser login..." + if claude auth login; then + echo "[OK] claude: browser login successful" else echo "[ERROR] Authentication failed. Either:" - echo " 1. Run 'agent login' to authenticate via browser, or" - echo " 2. Export CURSOR_API_KEY in your shell profile or .env" + echo " 1. Run 'claude auth login' to authenticate via browser, or" + echo " 2. Export ANTHROPIC_API_KEY in your shell profile or .env" exit 1 fi fi @@ -129,17 +111,15 @@ worktree-start issue prompt="" reviewer="": # Check if worktree already exists if [ -d "$WT_DIR" ]; then echo "[!] Worktree already exists at $WT_DIR" - _wt_ensure_trust "$WT_DIR" if tmux has-session -t "$SESSION" 2>/dev/null; then echo " tmux session '$SESSION' is running. Use: just worktree-attach $ISSUE" else echo " No tmux session found. Starting one..." if [ -n "$PROMPT" ]; then - tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --yolo --approve-mcps \"$PROMPT\"" + tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "claude --dangerously-skip-permissions \"$PROMPT\"" else - tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --approve-mcps" + tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "claude --dangerously-skip-permissions" fi - sleep 2 && tmux send-keys -t "$SESSION" "a" 2>/dev/null || true echo "[OK] tmux session '$SESSION' started. Use: just worktree-attach $ISSUE" fi exit 0 @@ -233,17 +213,14 @@ worktree-start issue prompt="" reviewer="": fi popd >/dev/null - # Ensure worktree directory is trusted by cursor-agent - _wt_ensure_trust "$WT_DIR" - # Start tmux session - # --yolo: auto-approve all shell commands (autonomous agent, no human at the terminal) + # --dangerously-skip-permissions: bypass all permission and MCP approval + # prompts (autonomous agent, no human at the terminal) if [ -n "$PROMPT" ]; then - tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --yolo --approve-mcps \"$PROMPT\"" + tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "claude --dangerously-skip-permissions \"$PROMPT\"" else - tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --approve-mcps" + tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "claude --dangerously-skip-permissions" fi - sleep 2 && tmux send-keys -t "$SESSION" "a" 2>/dev/null || true echo "" echo "[OK] Worktree created at $WT_DIR" @@ -307,23 +284,6 @@ worktree-attach issue: #!/usr/bin/env bash set -euo pipefail - _wt_ensure_trust() { - local dir_abs - dir_abs=$(cd "$1" && pwd) - local cfg="${HOME}/.cursor/cli-config.json" - mkdir -p "$(dirname "$cfg")" - if [ ! -f "$cfg" ]; then - echo '{}' > "$cfg" - fi - if ! jq -e --arg d "$dir_abs" '.trustedDirectories // [] | index($d)' "$cfg" >/dev/null 2>&1; then - jq --arg d "$dir_abs" '.trustedDirectories = ((.trustedDirectories // []) + [$d])' "$cfg" > "${cfg}.tmp" \ - && mv "${cfg}.tmp" "$cfg" - echo "[OK] Trusted directory added: $dir_abs" - else - echo "[OK] Directory already trusted: $dir_abs" - fi - } - ISSUE="{{ issue }}" SESSION="wt-${ISSUE}" WT_DIR="{{ _wt_base }}/${ISSUE}" @@ -331,14 +291,12 @@ worktree-attach issue: if ! tmux has-session -t "$SESSION" 2>/dev/null; then if [ -d "$WT_DIR" ]; then echo "[!] tmux session '$SESSION' stopped. Restarting..." - _wt_ensure_trust "$WT_DIR" REVIEWER=$(gh api user --jq '.login' 2>/dev/null || echo "") if [ -n "${WORKTREE_ATTACH_RESTART_CMD:-}" ]; then tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "$WORKTREE_ATTACH_RESTART_CMD" else - tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --approve-mcps" + tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "claude --dangerously-skip-permissions" fi - sleep 2 && tmux send-keys -t "$SESSION" "a" 2>/dev/null || true echo "[OK] tmux session '$SESSION' restarted" else echo "[ERROR] No tmux session '$SESSION' found." @@ -385,7 +343,7 @@ worktree-stop issue: # CLEAN # ------------------------------------------------------------------------------- -# Remove cursor-managed worktrees and tmux sessions. +# Remove managed worktrees and tmux sessions. # Default (no args): clean only stopped worktrees. Use 'all' to clean everything. [group('worktree')] worktree-clean mode="": diff --git a/assets/workspace/.devcontainer/scripts/initialize.sh b/assets/workspace/.devcontainer/scripts/initialize.sh index c0055f50..9c5b4843 100755 --- a/assets/workspace/.devcontainer/scripts/initialize.sh +++ b/assets/workspace/.devcontainer/scripts/initialize.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Initialize script - runs on host before container starts # This script is called from initializeCommand in devcontainer.json diff --git a/assets/workspace/.devcontainer/scripts/post-create.sh b/assets/workspace/.devcontainer/scripts/post-create.sh index 439f7bd7..3193e067 100644 --- a/assets/workspace/.devcontainer/scripts/post-create.sh +++ b/assets/workspace/.devcontainer/scripts/post-create.sh @@ -22,9 +22,6 @@ if [ ! -d "$PROJECT_ROOT" ]; then exit 1 fi -# Set venv prompt -sed -i 's/template-project/{{SHORT_NAME}}/g' /root/assets/workspace/.venv/bin/activate - # One-time setup: git repo, config, hooks, gh auth "$SCRIPT_DIR/init-git.sh" "$SCRIPT_DIR/setup-git-conf.sh" @@ -35,6 +32,18 @@ sed -i 's/template-project/{{SHORT_NAME}}/g' /root/assets/workspace/.venv/bin/ac echo "Syncing dependencies..." just --justfile "$PROJECT_ROOT/justfile" --working-directory "$PROJECT_ROOT" sync +# Set the venv prompt to the project name. Runs after `just sync` because the +# Nix image populates /root/assets/workspace/.venv at this stage rather than +# baking it at image-build time (the Debian image baked a venv whose prompt was +# the literal "template-project"). `uv` writes the prompt as the basename of the +# venv's parent dir, so rewrite the VIRTUAL_ENV_PROMPT assignment directly +# instead of substituting a fixed string. Guarded so a missing activate script +# never aborts post-create. +venv_activate="/root/assets/workspace/.venv/bin/activate" +if [ -f "$venv_activate" ]; then + sed -i -E 's/^([[:space:]]*VIRTUAL_ENV_PROMPT=)"[^"]*"/\1"{{SHORT_NAME}}"/' "$venv_activate" +fi + # User specific setup # Add your custom setup commands here to install any dependencies or tools needed for your project diff --git a/assets/workspace/.devcontainer/scripts/verify-auth.sh b/assets/workspace/.devcontainer/scripts/verify-auth.sh index 18da325d..8efefa0b 100755 --- a/assets/workspace/.devcontainer/scripts/verify-auth.sh +++ b/assets/workspace/.devcontainer/scripts/verify-auth.sh @@ -50,7 +50,7 @@ verify_ssh_agent() { local found_socket="" local socket_count=0 - for sock in /tmp/cursor-remote-ssh-*.sock /tmp/ssh-*/agent.* /run/user/*/openssh_agent; do + for sock in /tmp/ssh-*/agent.* /run/user/*/openssh_agent; do [ ! -S "$sock" ] 2>/dev/null && continue socket_count=$((socket_count + 1)) diff --git a/assets/workspace/.devcontainer/scripts/version-check.sh b/assets/workspace/.devcontainer/scripts/version-check.sh index a5718cbd..9eebc121 100755 --- a/assets/workspace/.devcontainer/scripts/version-check.sh +++ b/assets/workspace/.devcontainer/scripts/version-check.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash ############################################################################### # version-check.sh - Devcontainer Update Checker # diff --git a/assets/workspace/.envrc b/assets/workspace/.envrc new file mode 100644 index 00000000..4a5330ab --- /dev/null +++ b/assets/workspace/.envrc @@ -0,0 +1,17 @@ +# nix-direnv: GC-rooted, cached flake evaluation so re-entry is instant and the +# dev-shell closure is not garbage-collected. Falls back to bare `use flake` +# when the nix-direnv library is unavailable. +# +# Prefer a user-installed nix-direnv (sourced from ~/.config/direnv/direnvrc); +# otherwise self-bootstrap the pinned library into .direnv/ on first allow. +if ! has use_flake 2>/dev/null && ! declare -f use_flake >/dev/null 2>&1; then + nix_direnv_version="3.0.6" + nix_direnv_sha="sha256-RYcUJaRMf8oF5LznDrlCXbkOQrywm0HDv1VjYGaJGdM=" + if ! source_url \ + "https://raw.githubusercontent.com/nix-community/nix-direnv/${nix_direnv_version}/direnvrc" \ + "${nix_direnv_sha}" 2>/dev/null; then + echo "direnv: nix-direnv unavailable; falling back to bare 'use flake'." >&2 + fi +fi + +use flake diff --git a/assets/workspace/.github/agent-blocklist.toml b/assets/workspace/.github/agent-blocklist.toml index 94c28bfd..184f6cd8 100644 --- a/assets/workspace/.github/agent-blocklist.toml +++ b/assets/workspace/.github/agent-blocklist.toml @@ -24,6 +24,6 @@ emails = ["cursoragent@cursor.com", "noreply@cursor.com", "github-actions[bot]"] # Patterns that legitimately contain blocked names (regex, stripped before checking) # These are removed from content before name/email matching runs. allow_patterns = [ - "\\.[a-zA-Z][\\w-]*/[\\w./-]*", # dotfile paths: .cursor/skills/, .claude/commands/ + "\\.[a-zA-Z][\\w-]*/[\\w./-]*", # dotfile paths: .claude/skills/, .claude/commands/ "[A-Z]+\\.md", # doc files: CLAUDE.md ] diff --git a/assets/workspace/.github/label-taxonomy.toml b/assets/workspace/.github/label-taxonomy.toml index 7d547fac..c804dc9d 100644 --- a/assets/workspace/.github/label-taxonomy.toml +++ b/assets/workspace/.github/label-taxonomy.toml @@ -1,8 +1,8 @@ # Canonical repository labels. # Single source of truth — referenced by: # - uv run setup-labels (provision labels on a repo) -# - .cursor/skills/issue_triage/SKILL.md (triage label check) -# - .cursor/skills/issue_create/SKILL.md (agent label mapping) +# - .claude/skills/issue_triage/SKILL.md (triage label check) +# - .claude/skills/issue_create/SKILL.md (agent label mapping) # - .github/ISSUE_TEMPLATE/*.yml (template label values) # # Label reconciliation: diff --git a/assets/workspace/.gitignore b/assets/workspace/.gitignore index 505ac3f7..6c911edd 100644 --- a/assets/workspace/.gitignore +++ b/assets/workspace/.gitignore @@ -149,7 +149,10 @@ activemq-data/ # Environments .env -.envrc +# .envrc (use flake) is committed for nix-direnv onboarding (#640); the local +# evaluation cache and any per-user overrides are not. +.direnv/ +.envrc.local .venv env/ venv/ diff --git a/assets/workspace/.hadolint.yaml b/assets/workspace/.hadolint.yaml deleted file mode 100644 index 649b54fa..00000000 --- a/assets/workspace/.hadolint.yaml +++ /dev/null @@ -1,24 +0,0 @@ ---- -# Hadolint configuration file -# This file configures the Containerfile linter - -# Ignore specific rules -ignored: - - DL3008 # Pin versions in apt-get - # - DL3009 # Delete the apt-get lists after installing something - - DL3013 # Pin versions in pip - - DL4006 # Set the SHELL option -o pipefail before RUN with pipes - - DL4001 # Either use Wget or Curl but not both - -# Set the output format -# tty | json | checkstyle | codeclimate | gitlab_codeclimate | -# gnu | codacy | sonarqube | sarif -format: tty - -# Set the failure threshold (error | warning | info | style | ignore | none) -failure-threshold: warning - -# Trusted registries (optional) -trustedRegistries: - - docker.io - - ghcr.io diff --git a/assets/workspace/.pre-commit-config.yaml b/assets/workspace/.pre-commit-config.yaml index dcbac881..87ecb0da 100644 --- a/assets/workspace/.pre-commit-config.yaml +++ b/assets/workspace/.pre-commit-config.yaml @@ -34,13 +34,19 @@ repos: - id: mixed-line-ending - id: trailing-whitespace - # Python Linting and Formatting (Ruff) - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: aad66557af3b56ba6d4d69cd1b6cba87cef50cbb # v0.14.3 + # Python Linting and Formatting (Ruff, sourced from the flake dev-shell) + - repo: local hooks: - id: ruff - args: [--fix] + name: ruff (lint/fix python) + entry: ruff check --fix + language: system + types: [python] - id: ruff-format + name: ruff-format (format python) + entry: ruff format + language: system + types: [python] # YAML Linting - repo: https://github.com/adrienverge/yamllint @@ -55,8 +61,10 @@ repos: hooks: - id: shellcheck name: shellcheck + # .envrc files (root + the scaffolded template stub) are direnv stdlib + # scripts with no shebang; exclude them from SC2148 at any path. args: ["-x"] - exclude: ^\.envrc$ + exclude: (^|/)\.envrc$ # Markdown Linting (excludes auto-generated docs) - repo: https://github.com/jackdewinter/pymarkdown @@ -77,18 +85,30 @@ repos: files: ^justfile(\..*)?$ pass_filenames: false - # Typo Linting - - repo: https://github.com/crate-ci/typos - rev: 07d900b8fa1097806b8adb6391b0d3e0ac2fdea7 # v1.39.0 + # Nix formatting (nixfmt-rfc-style from the flake dev-shell toolchain) + - repo: local + hooks: + - id: nixfmt + name: nixfmt (format/check nix files) + entry: nixfmt --check + language: system + files: \.nix$ + types: [file] + + # Typo Linting (typos sourced from the flake dev-shell) + - repo: local hooks: - id: typos + name: typos (source typo checker) + entry: typos + language: system # Security exception expiry enforcement (Refs: #566) - repo: local hooks: - id: check-expirations - name: check-expirations (.trivyignore expiry enforcement) - entry: uv run check-expirations .trivyignore + name: check-expirations (.trivyignore/.vulnixignore expiry enforcement) + entry: uv run check-expirations .trivyignore .vulnixignore language: system - files: ^\.trivyignore$ + files: ^\.(trivyignore|vulnixignore)$ pass_filenames: false diff --git a/assets/workspace/.vscode/settings.json b/assets/workspace/.vscode/settings.json index c5220cd9..f491c850 100644 --- a/assets/workspace/.vscode/settings.json +++ b/assets/workspace/.vscode/settings.json @@ -4,7 +4,7 @@ "Justfile": "just", "justfile.*": "just" }, - "python.defaultInterpreterPath": "/opt/venv/bin/python3", + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python3", "[python]": { "editor.defaultFormatter": "charliermarsh.ruff", "editor.formatOnSave": true, diff --git a/assets/workspace/docs/COMMIT_MESSAGE_STANDARD.md b/assets/workspace/docs/COMMIT_MESSAGE_STANDARD.md index b7152342..b5eb863c 100644 --- a/assets/workspace/docs/COMMIT_MESSAGE_STANDARD.md +++ b/assets/workspace/docs/COMMIT_MESSAGE_STANDARD.md @@ -21,7 +21,7 @@ Refs: - **Body** — Optional. Include additional context on _what_ and _why_. May have multiple paragraphs. If present, end the body with a blank line before the Refs line. - **Refs line** — Mandatory for most types. Exactly one line starting with `Refs:`; it must be the last non-empty line. Include at least one GitHub issue ID (e.g. `#36`); other references (e.g. `REQ-...`, `RISK-...`, `SOP-...`) may follow. See [Exemptions](#exemptions) for types where `Refs:` is optional. -## Enforcing the template in VS Code / Cursor +## Enforcing the template in VS Code - **Git commit template** — A `.gitmessage` file in the repo root is used as the default message when you run `git commit` from the terminal (no `-m`). After `just init` or devcontainer setup, `commit.template` is set to `.gitmessage` so the template is loaded when Git opens the editor. - **Source Control + AI** — When using the Source Control panel and the GitHub extension to generate the message: diff --git a/assets/workspace/flake.nix b/assets/workspace/flake.nix new file mode 100644 index 00000000..05b9f74f --- /dev/null +++ b/assets/workspace/flake.nix @@ -0,0 +1,59 @@ +{ + description = "Project development environment (vigOS toolchain)."; + + # Downstream repos consume the shared toolchain as a flake INPUT, so updating + # the dev environment means bumping that input — it never overwrites your + # files. To update: `nix flake update vigos`. + inputs = { + # The shared vigOS toolchain (single source of truth). + vigos.url = "github:vig-os/devcontainer"; + # Follow vigos's pinned nixpkgs + flake-utils so your tools match the + # toolchain exactly (one resolved nixpkgs, no drift). + nixpkgs.follows = "vigos/nixpkgs"; + flake-utils.follows = "vigos/flake-utils"; + }; + + outputs = + { + self, + vigos, + nixpkgs, + flake-utils, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ vigos.overlays.default ]; + config.allowUnfree = true; + }; + + # ──────────────────────────────────────────────────────────────────── + # Your project tools go here. This block is YOURS: a dev-environment + # update never overwrites it (scaffold-once / never-overwrite, the same + # guarantee as justfile.project and docker-compose.project.yaml). + # + # extraPackages = pkgs: [ + # pkgs.postgresql_16 + # pkgs.ffmpeg + # ]; + # ──────────────────────────────────────────────────────────────────── + extraPackages = pkgs: [ + # add project tools here + ]; + in + { + # The dev shell = the shared vigOS toolchain + your extras. + # `direnv allow` (via .envrc) or `nix develop` enters it. + devShells.default = vigos.lib.mkProjectShell { + inherit pkgs; + extraPackages = extraPackages pkgs; + }; + + # Future (upstream, opt-in): vigos may expose modular language shells — + # e.g. `vigos.devShells.${system}.{cpp,geant4,dataAnalysis}` — that you + # select without changing this scaffold. Out of scope today. + } + ); +} diff --git a/assets/workspace/pyproject.toml b/assets/workspace/pyproject.toml index 72e6114b..58281289 100644 --- a/assets/workspace/pyproject.toml +++ b/assets/workspace/pyproject.toml @@ -2,7 +2,7 @@ name = "{{SHORT_NAME}}" version = "0.1.0" description = "{{SHORT_NAME}} project" -requires-python = "==3.14.6" +requires-python = ">=3.14,<3.15" dependencies = [] [project.optional-dependencies] diff --git a/docs/COMMIT_MESSAGE_STANDARD.md b/docs/COMMIT_MESSAGE_STANDARD.md index b7152342..b5eb863c 100644 --- a/docs/COMMIT_MESSAGE_STANDARD.md +++ b/docs/COMMIT_MESSAGE_STANDARD.md @@ -21,7 +21,7 @@ Refs: - **Body** — Optional. Include additional context on _what_ and _why_. May have multiple paragraphs. If present, end the body with a blank line before the Refs line. - **Refs line** — Mandatory for most types. Exactly one line starting with `Refs:`; it must be the last non-empty line. Include at least one GitHub issue ID (e.g. `#36`); other references (e.g. `REQ-...`, `RISK-...`, `SOP-...`) may follow. See [Exemptions](#exemptions) for types where `Refs:` is optional. -## Enforcing the template in VS Code / Cursor +## Enforcing the template in VS Code - **Git commit template** — A `.gitmessage` file in the repo root is used as the default message when you run `git commit` from the terminal (no `-m`). After `just init` or devcontainer setup, `commit.template` is set to `.gitmessage` so the template is loaded when Git opens the editor. - **Source Control + AI** — When using the Source Control panel and the GitHub extension to generate the message: diff --git a/docs/CONTAINER_SECURITY.md b/docs/CONTAINER_SECURITY.md index 5f065429..bb1d15e4 100644 --- a/docs/CONTAINER_SECURITY.md +++ b/docs/CONTAINER_SECURITY.md @@ -1,135 +1,166 @@ # Container Security Patching Strategy -This document describes how the devcontainer image handles system-level -security vulnerabilities (CVEs) in OS packages. +This document describes how the devcontainer image handles software +vulnerabilities (CVEs). + +The image is a **Nix-built image** (`dockerTools.buildLayeredImage`, see +`flake.nix`). This document describes the **Nix posture** — the mechanisms now in +place. The Debian/`apt` build path has been decommissioned (#642). ## Principles -1. **Reproducibility first** – Every build from the same commit must produce - the same image. Non-deterministic operations (`apt-get upgrade`) are - forbidden in the default build path. -2. **Defence in depth** – Multiple layers detect and remediate CVEs at - different speeds so that no single mechanism is a bottleneck. -3. **Minimal blast radius** – When manual patching is necessary, only the - specific vulnerable package is upgraded, and the change is traceable to a - CVE identifier. +1. **Reproducibility first** – Every build from the same commit and the same + `flake.lock` produces a byte-identical image closure. There is no + non-deterministic upgrade step (no `apt-get upgrade`) in the build path. +2. **Defence in depth** – Multiple scanners and levers detect and remediate CVEs + at different speeds and over different surfaces so no single mechanism is a + bottleneck. +3. **Minimal blast radius** – When a CVE must be remediated out-of-band, the + change is the smallest pin that fixes it and is traceable to a CVE identifier. ## Layers of defence -### 1. Base image digest pinning (primary) - -The `FROM` line in the Containerfile pins the base image to an immutable -SHA-256 digest: +### 1. Pinned `nixpkgs` revision (primary) -```dockerfile -FROM python:3.14-slim-bookworm@sha256: -``` +The toolchain and the image contents come from a single pinned `nixpkgs` +revision in `flake.lock`. Because the closure is fully pinned, the CVE surface +is exactly what that revision ships. Renovate keeps the pin current through two +mechanisms in `renovate.json`: -Renovate (configured with the `dockerfile` manager in `renovate.json`) monitors -the upstream image and opens a pull request whenever a new digest is published. -Because the upstream maintainers rebuild the image to include Debian security -patches, most CVEs are resolved simply by merging the Renovate PR. +- The **`nix` manager** detects flake inputs and proposes pinned-input updates. +- **`lockFileMaintenance`** (enabled, scheduled weekly) refreshes the locked + revisions of all inputs (notably `nixpkgs`) so upstream security fixes land + through the normal PR/CI gate rather than a manual `nix flake update`. -**Typical remediation time:** 1–7 days after the upstream image is rebuilt. +**Typical remediation time:** within the weekly `lockFileMaintenance` cycle, or +immediately by merging an out-of-cycle `nixpkgs`-bump PR. -### 2. Nightly Trivy scan (detection) +### 2. Nightly `vulnix` scan (primary detection) -The scheduled workflow (`.github/workflows/security-scan.yml`) pulls the -published `:latest` image nightly (05:00 UTC) and runs a full Trivy vulnerability -scan. Results are: +The scheduled workflow (`.github/workflows/security-scan.yml`, job +`scan-nix-image`) builds the image's package closure (the flake +`devcontainerImageEnv` target) nightly and runs **`vulnix`**, the nixpkgs-native +CVE scanner. A Nix image has no `apt`/`dpkg` database, so Trivy's OS-package +scanner goes dark; `vulnix` matches the Nix store closure against the NVD feeds +instead. -- Printed as a table in the workflow log. -- Uploaded as a SARIF report to the GitHub Security tab. -- Accompanied by a CycloneDX SBOM artifact. +- HIGH/CRITICAL findings (CVSS v3 ≥ 7.0) are gated by `vulnix-gate` + (`packages/vig-utils`) against the `.vulnixignore` exception register; a + finding blocks only when it is **not** covered by a non-expired exception. +- Sub-threshold and unscored CVEs are awareness-only and never gate. -This scan is **non-blocking** for the full report (exit-code 0) and serves as -an early-warning system for newly published CVEs. A separate gate step fails -on fixable HIGH/CRITICAL findings (`ignore-unfixed: true`). +> **`vulnix` over-reporting.** `vulnix` matches by package name + *upstream* +> version and does not see `nixpkgs`' backported security patches, so it reports +> CVEs already fixed in the shipped derivation. The primary lever is therefore to +> advance the `nixpkgs` rev (layer 1); genuinely-not-applicable findings are +> accepted in `.vulnixignore` with a rationale (layer 5). -### 3. Targeted package upgrades (escape hatch) +During the discovery phase the gate is **non-blocking** (`continue-on-error`). +The publish-cutover (#639) flips it to blocking and wires SARIF upload and a +deduplicated issue. -When a HIGH or CRITICAL CVE is detected that: +### 3. CycloneDX SBOM + Trivy SBOM-mode scan (defence in depth) -- Has a fix available in the Debian stable repository, **and** -- Cannot wait for the next base image rebuild (e.g., actively exploited), +The same nightly job emits a **CycloneDX SBOM** of the Nix image (via Trivy) and +runs Trivy in **SBOM-scan mode** over it for a second, independent vulnerability +view. `vulnix` (Nix store closure) and Trivy (SBOM components) cover different +surfaces, so both outputs are uploaded as an artifact to support a +`vulnix`-vs-Trivy overlap comparison (confidence evidence, not a numeric-parity +gate). -a targeted upgrade is added to the Containerfile: +### 4. Advance the `nixpkgs` rev (remediation lever) -```dockerfile -RUN apt-get update && apt-get install -y --only-upgrade \ - libfoo=1.2.3-1+deb12u1 \ # CVE-2026-XXXXX - && apt-get clean && rm -rf /var/lib/apt/lists/* -``` +When a HIGH/CRITICAL CVE is real (not a `vulnix` false positive) and fixed +upstream: -Rules for targeted upgrades: +- **Preferred:** bump the pinned `nixpkgs` rev (merge the Renovate + `nix`-manager / `lockFileMaintenance` PR, or open an out-of-cycle bump) so the + patched derivation enters the closure. This is reproducible and is captured by + the PR/CI gate. +- **Rare escape hatch:** if only some inputs can move, pin the single patched + package through a flake overlay, referencing the CVE in a comment, and remove + the override once the base `nixpkgs` rev includes the fix. | Rule | Rationale | |------|-----------| -| Each package must reference a CVE in a comment | Auditability | -| Pin the package to an exact version | Reproducibility | -| Remove the entry once the base image digest includes the fix | Avoid drift | -| Never use blanket `apt-get upgrade` or `dist-upgrade` | Reproducibility | +| Reference the CVE in the PR / overlay comment | Auditability | +| Move the smallest pin that fixes it | Minimal blast radius | +| Remove an overlay override once `nixpkgs` ships the fix | Avoid drift | +| Never disable the pin to "take latest everything" | Reproducibility | + +**Compensating control — `vulnix` before/after diff.** A `nixpkgs` revision bump +does not declare *which* CVE it fixes (the `nix` manager reports only the +old → new git revision). To keep the audit trail, each `flake.lock` / +`nixpkgs`-bump PR should include a `vulnix` scan diff taken **before and after** +the bump, showing which advisories the new revision clears (or introduces). -### 4. Trivy ignore list (risk acceptance) +### 5. Exception registers (risk acceptance) -Low-risk CVEs that are not exploitable in the devcontainer context are -documented in `.trivyignore` with: +CVEs that are not exploitable in the devcontainer context, or are `vulnix` +false positives (already patched in `nixpkgs`), are accepted in an exception +register with: -- A risk assessment explaining why the CVE is acceptable. +- A risk assessment / rationale (patched-in-nixpkgs, not-exploitable, or + awaiting-upstream). - An expiration date after which the entry must be re-evaluated. - A link to the tracking issue. -Expired entries fail CI via `check-expirations` (pre-commit hook and CI -workflows), forcing periodic review consistent with the IEC 62304 exception -register model. +Two registers share one format and one validator: + +- **`.vulnixignore`** — `vulnix` findings on the Nix image (consumed by + `vulnix-gate`). +- **`.trivyignore`** — image-agnostic Trivy findings on the Nix image (bundled- + binary CVEs) and Trivy secret-scan false positives. -As of the next release image (Debian 12.14 base), 78 unfixed LOW CVEs in OS -packages are accepted in `.trivyignore` with expiration 2026-12-01. These -have no available Debian patch; the nightly gate only fails on fixable -HIGH/CRITICAL findings. Re-scan after each base-image digest bump and drop -entries when Debian ships fixes. Tracking: #566, #512, #521. +Both use the `Expiration: YYYY-MM-DD` directive format and are validated by +`check-expirations` (pre-commit hook and CI). Expired entries fail CI, forcing +periodic review consistent with the IEC 62304 exception-register model. -## Why not `apt-get upgrade`? +## Why pin `nixpkgs` (and not track an unpinned channel)? -Running `apt-get upgrade` (or `dist-upgrade`) in the Containerfile has several -drawbacks: +Building from an unpinned/rolling input has the same drawbacks the old +`apt-get upgrade` escape hatch had: | Problem | Explanation | |---------|-------------| -| **Non-reproducible builds** | The same Containerfile produces different images on different days because the Debian mirror contents change constantly. | -| **Defeats digest pinning** | The digest guarantees a known starting point; upgrading everything immediately discards that guarantee. | -| **Untraceable changes** | There is no record of *which* packages changed or *why*. A targeted upgrade with a CVE comment is auditable. | -| **`dist-upgrade` risk** | `dist-upgrade` can remove packages or change dependencies, potentially breaking the image silently. | -| **Cache invalidation** | A blanket upgrade invalidates the Docker layer cache on every build, increasing build times. | +| **Non-reproducible builds** | The same flake produces different closures on different days as the channel moves. | +| **Defeats pinning** | The lock guarantees a known closure; tracking latest immediately discards that guarantee. | +| **Untraceable changes** | There is no record of *which* packages changed or *why*. A pinned bump with a `vulnix` diff is auditable. | +| **Cache invalidation** | A wholesale input move rebuilds (and re-pushes) the entire closure on every build. | ## Decision flow ``` -New CVE detected by Trivy +New CVE reported by vulnix (Nix image) │ ▼ - Is severity HIGH or CRITICAL? + Is severity HIGH or CRITICAL (CVSS v3 >= 7.0)? │ No ──┤──── Yes │ │ ▼ ▼ - Add to Is a fix available in Debian stable? - .trivyignore │ - (with risk No ──┤──── Yes - assessment) │ │ - ▼ ▼ - Add to Can it wait for a base image rebuild? - .trivyignore │ - (with risk No ──┤──── Yes - assessment) │ │ - ▼ ▼ - Add targeted Wait for Renovate - --only-upgrade digest update PR - to Containerfile + Awareness Is it real (not already patched in nixpkgs / not a vulnix FP)? + only │ + No ──┤──── Yes + │ │ + ▼ ▼ + Accept in Is the fix available upstream in a newer nixpkgs? + .vulnixignore │ + (patched-in- No ──┤──── Yes + nixpkgs, │ │ + with expiry) ▼ ▼ + Accept in Advance the nixpkgs rev + .vulnixignore (Renovate bump / overlay + (awaiting- pinning the patched pkg) + upstream, + with expiry) ``` ## References -- [Containerfile](../Containerfile) – Build definition with inline comments -- [.trivyignore](../.trivyignore) – Accepted low-risk CVEs +- [flake.nix](../flake.nix) – Nix image (`devcontainerImage`), scan target + (`devcontainerImageEnv`), and pinned `vulnix` +- [.vulnixignore](../.vulnixignore) – Accepted `vulnix` findings (Nix image) +- [.trivyignore](../.trivyignore) – Accepted Trivy findings (Nix image, image-agnostic) - [security-scan.yml](../.github/workflows/security-scan.yml) – Nightly scan workflow +- `vulnix-gate` / `check-expirations` (`packages/vig-utils`) – Gate and expiry validators diff --git a/docs/NIX.md b/docs/NIX.md new file mode 100644 index 00000000..7cbe5dc0 --- /dev/null +++ b/docs/NIX.md @@ -0,0 +1,223 @@ +# Nix in the vigOS devcontainer + +This repository is **Nix-first**: the Nix flake (`flake.nix`) is the single source +of truth for the development toolchain *and* the basis of the built devcontainer +image, so the dev-shell and the image can never drift. This document is the +consolidated reference for how the flake is structured and why. For day-one +onboarding (clone → `direnv allow`) see the fast path in +[`CONTRIBUTE.md`](../CONTRIBUTE.md); for the downstream production-image pattern +see [`docs/NIX2CONTAINER.md`](NIX2CONTAINER.md). + +## The flake as the toolchain SSoT + +The flake exposes one list, `devTools`, that enumerates every CLI in the +environment (`just`, `git`, `gh`, `uv`, `nodejs`, `jq`, `tmux`, `ripgrep`, `fd`, +`bat`, `eza`, `delta`, `lazygit`, `zoxide`, `starship`, `neovim`, `claude-code`, +`podman`, `hadolint`, `taplo`, `shellcheck`, …). Adding a tool there adds it +everywhere — the dev-shell now and the image's `imageTools` set. + +- **`devShells.default`** is built from `devTools` via `mkProjectShell`, so + `nix develop` (or `direnv`) gives you exactly that toolchain. +- **`mkProjectShell`** is also a reusable `lib` output: downstream repos build + their own shell as `devTools ++ extraPackages` (see the scaffolded + `assets/workspace/flake.nix`). +- **`overlays.default`** and **`lib.{mkProjectShell,devTools}`** are exported as + system-independent outputs so consumers can follow the same pinned `nixpkgs`. + +### Dev-shell ↔ image parity guard + +Because both the dev-shell and the image are assembled from `devTools`, a single +test keeps them honest. `tests/test_flake_devshell.py` reads the binary names +straight from the flake (`nix eval .#devShellTools`, derived from each package's +`meta.mainProgram`) and runs `nix develop -c --version` for every tool, +asserting it exits 0. The test list is generated *from* the SSoT, so it can never +drift from the tool list it guards. It is skipped automatically when `nix` is not +on `PATH` (e.g. the podman image CI lane). + +## Stable / unstable channel split and the fast-mover overlay + +The flake pins two inputs: + +- **`nixpkgs`** → `github:NixOS/nixpkgs/nixos-26.05` — the controlled, pinned + stable channel (the "version document", anchored by `flake.lock`). Everything + comes from here unless explicitly overridden. +- **`nixpkgs-unstable`** → `github:NixOS/nixpkgs/nixpkgs-unstable` — overlaid + **only** for a small set of fast-moving tools. + +The `overlay` (also exported as `overlays.default`) replaces just the `fastMovers` +list — `uv`, `gh`, and `claude-code` — with their `nixpkgs-unstable` builds. +These ship frequently and we want the latest version in both shell and image; +everything else stays on the pinned stable channel for reproducibility. + +### uv and the project interpreter + +The dev-shell carries no Python on `PATH` (the project venv is uv-managed), so +`uv sync` must be told which interpreter to build the venv from. `mkProjectShell` +pins a Nix store CPython via `UV_PYTHON` and forbids downloads with +`UV_PYTHON_DOWNLOADS=never`. This avoids letting the nixpkgs `uv` fetch a managed +CPython: that download is a generic, dynamically-linked ELF a NixOS host cannot +execute out of the box (no FHS `ld-linux`), so `uv sync` (`just init`) aborted +there (#683). A store interpreter is patched to the store loader and runs in the +dev-shell on both NixOS and FHS hosts. The **image** path uses the same two +variables, baking the interpreter and toolchain from nixpkgs. + +**CI is the exception.** The `provision-via-flake` jobs (#632) run *outside* +`nix develop` — they only prepend the dev-shell's tool `PATH` — on an FHS runner, +where a Nix store interpreter cannot load pre-commit's manylinux-wheel C +extensions (`libstdc++.so.6`). So the dev-shell also keeps +`UV_PYTHON_DOWNLOADS_JSON_URL` set (pinned to the provisioned `uv` release), and +the `setup-env` action forwards **that URL only** — not `UV_PYTHON` — so the +runner's stripped nixpkgs `uv` downloads a managed CPython instead. Locally the +pin wins and no download happens; the URL matters only on the CI runner. + +## The Nix-built image + +`packages.devcontainerImage` is assembled entirely by Nix via +**`dockerTools.buildLayeredImage`** — not a Dockerfile `FROM`. Key properties: + +- **Bit-reproducible.** Every build from the same commit and `flake.lock` + produces a byte-identical image closure (the epic's "identical image digest on + rebuild" criterion). A deterministic `created = "1970-01-01T00:00:00Z"` epoch + keeps the digest stable; there is no non-deterministic upgrade step. +- **Multi-arch.** The image builds natively on an amd64 + arm64 matrix (no QEMU, + no cross-compilation); per-arch discovery tags are assembled into a multi-arch + index. +- **Contents = `imageTools`.** That is `devTools` plus the runtime substrate a + bare layered image lacks (an FHS base distro would otherwise supply it): the + Nix evaluator (`nix`, `direnv`, `nix-direnv`), `glibcLocales` for locale + support, the project Python env (`vig-utils` + `pip-licenses` baked via + `python314.withPackages`), `pre-commit`/`ruff`/`bandit`, Rust/just tooling + (`cargo-binstall`, `just-lsp`, `typstyle`), core GNU utilities, `cacert`, + `openssh`, and `dockerTools.fakeNss` (a root uid-0 user database, without which + `ssh`/`tmux`/`git` fail with "No user exists for uid 0"). + +A `bootstrap` layer bakes the workspace assets, the pre-commit cache dir, the +template `.venv` scaffold, a sticky `/tmp`, and the `precommit`/`cc`/`cld` +aliases. The image's interpreter is pinned via `UV_PYTHON=` and +`UV_PYTHON_DOWNLOADS=never`. + +### Host container runtime (`policy.json`) + +`just build` ends in `podman load -i result`, and podman's containers/image +library refuses to load any image unless a signature-verification `policy.json` +exists at `~/.config/containers/policy.json` or `/etc/containers/policy.json` +(this podman build has no `--signature-policy` flag and no env override). The +flake dev-shell ships the **podman CLI** but not that host file: on NixOS the +`virtualisation.containers` module normally installs `/etc/containers/policy.json`, +so a host that gets podman purely from the dev-shell never receives one and +`podman load` fails — even though `podman info` (the `just init` advisory check) +is green. + +`just init` closes this gap: if neither lookup path has a policy, it writes the +user-level default `~/.config/containers/policy.json` with the standard permissive +content (the same `{ "default": [ { "type": "insecureAcceptAnything" } ] }` that +`containers-common` / the NixOS module ship). The write is idempotent and never +overwrites a system or user policy. To do it by hand: + +```bash +mkdir -p ~/.config/containers +printf '{ "default": [ { "type": "insecureAcceptAnything" } ] }\n' > ~/.config/containers/policy.json +``` + +## Evaluator and pre-commit decisions + +These are decided inline in `flake.nix`; summarized here. + +- **CppNix vs Lix (#634).** The image ships upstream **CppNix** (`pkgs.nix`) as + the in-container evaluator. It is the channel default, needs no overlay, and the + flake is installer-agnostic, so swapping to `pkgs.lix` later is a one-line + change. `pkgs.lix` is left out for now to keep the closure smaller. +- **`pre-commit` vs `prek` (#40).** The image bakes upstream **`pre-commit`** to + match the prior Debian build and the pinned `pyproject` version. Migrating the + cache layer to `prek` is deferred to #40; both are in nixpkgs, so it is a + drop-in swap once that issue lands. + +### `libstdc++` for C-extension pre-commit hooks (#698) + +Some pre-commit hooks run from pre-commit's **own** manylinux-wheel Python env +(not the project venv) and ship a C extension. The `pymarkdown` hook is the case +in point: its dependency `pyjson5` is a C extension linked against +`libstdc++.so.6`, which a NixOS host does not put on the loader path outside an +FHS environment — so the hook aborted with +`ImportError: libstdc++.so.6: cannot open shared object file` and forced +`--no-verify`. Unlike the standalone binaries in #697 (`ruff`/`typos`), +`pymarkdown` is **not** in nixpkgs, so the "add to `devTools` + `language: +system`" recipe does not apply. + +`mkProjectShell` therefore **appends** `${pkgs.stdenv.cc.cc.lib}/lib` to +`LD_LIBRARY_PATH` in the dev-shell, so the wheel resolves the Nix C++ runtime. +That is the same `libstdc++` the Nix toolchain itself links, so the other +dev-shell binaries keep working (no version clash), and the existing +mkShell-injected `LD_LIBRARY_PATH` is appended to rather than clobbered. The fix +generalises to any future C-extension Python hook. A `nix-ld` host config +(`programs.nix-ld.enable` + `libraries = [ pkgs.stdenv.cc.cc ]`) would also work +but is per-contributor system config the repo cannot enforce, so it is at most a +fallback, not the fix. + +## Cachix and the `direnv allow` onboarding flow + +The dev-shell closure is published to the public **`vig-os`** Cachix binary +cache, so the first `direnv allow` is a binary fetch (seconds) rather than a +from-source build. To use it, enable flakes and add the substituter to your Nix +config (`~/.config/nix/nix.conf` or `/etc/nix/nix.conf`): + +```conf +experimental-features = nix-command flakes +substituters = https://cache.nixos.org https://vig-os.cachix.org +trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= vig-os.cachix.org-1:yoOYRi3bvnM6ThxO0joLt7vtzhTfkq3r6jykeUMg7Bk= +``` + +Pulling from the public `vig-os` cache needs no token (`cachix use vig-os` writes +the same lines). Then: + +```bash +git clone git@github.com:vig-os/devcontainer.git +cd devcontainer +direnv allow # first allow fetches the closure from Cachix +``` + +The committed `.envrc` uses [nix-direnv](https://github.com/nix-community/nix-direnv): +the dev-shell evaluation is cached and GC-rooted under `.direnv/` (gitignored), so +re-entry is instant and the closure is never garbage-collected. nix-direnv +self-bootstraps the pinned library on first allow, or uses your +`~/.config/direnv/direnvrc` installation if you already source it; it falls back +to bare `use flake` when unavailable. The full fast path lives in +[`CONTRIBUTE.md`](../CONTRIBUTE.md). + +## How `nixpkgs` bumps flow (Renovate + vulnix) + +The pinned `nixpkgs` revision in `flake.lock` defines the image's entire CVE +surface, so advancing the pin is the **primary CVE-remediation lever** (see +[`docs/CONTAINER_SECURITY.md`](CONTAINER_SECURITY.md) for the full strategy). +Renovate keeps the pin current through `renovate.json`: + +- The **`nix` manager** detects flake inputs and proposes pinned-input updates + (committed as `build(nix): …`). +- **`lockFileMaintenance`** (enabled, scheduled weekly) refreshes the locked + revisions of all inputs so upstream security fixes land through the normal + PR/CI gate rather than a manual `nix flake update`. + +**vulnix before/after requirement.** A `nixpkgs`-rev bump does not declare *which* +CVE it fixes — the `nix` manager reports only the old → new revision. To preserve +the audit trail, each `flake.lock` / `nixpkgs`-bump PR should include a `vulnix` +scan diff taken **before and after** the bump, showing which advisories the new +revision clears (or introduces). The nightly `vulnix` scan runs against the +`devcontainerImageEnv` closure; HIGH/CRITICAL findings are gated by `vulnix-gate` +against the `.vulnixignore` exception register. + +## Publish cutover + +The Nix image build is currently in the **discovery phase**: the workflows are +non-publishing (`continue-on-error`) and touch only disposable discovery tags. The +publish-cutover — flipping the versioned/`:latest` publish to the Nix builder and +making the `vulnix` gate blocking — is tracked in **issue #639** (the release +pipeline exposes a `builder: debian|nix` selector for the deliberate cutover run). + +## See also + +- [`CONTRIBUTE.md`](../CONTRIBUTE.md) — onboarding fast path (clone → `direnv allow`). +- [`docs/NIX2CONTAINER.md`](NIX2CONTAINER.md) — the downstream production-image + pattern with `nix2container` (distinct from this image's `buildLayeredImage`). +- [`docs/CONTAINER_SECURITY.md`](CONTAINER_SECURITY.md) — the full CVE-patching + strategy (pinned `nixpkgs`, `vulnix`, SBOM/Trivy, exception registers). +- [`flake.nix`](../flake.nix) — the authoritative source for all of the above. diff --git a/docs/NIX2CONTAINER.md b/docs/NIX2CONTAINER.md new file mode 100644 index 00000000..87467368 --- /dev/null +++ b/docs/NIX2CONTAINER.md @@ -0,0 +1,55 @@ +# Production images with `nix2container` + +The devcontainer image is built with `dockerTools.buildLayeredImage` (see +`flake.nix`). For **production / runtime images in downstream packages**, use +**`nix2container`** instead — it gives finer layer control and much faster +push/pull than `dockerTools`, while still deriving from the **same pinned +`nixpkgs`** as the shared toolchain (so dev and prod never drift). + +This keeps two builders for two jobs: + +| Image | Builder | Contents | +|-------|---------|----------| +| **devcontainer** (this repo) | `dockerTools.buildLayeredImage` | full dev toolchain + Nix | +| **production** (your package) | `nix2container` | your app + its runtime closure only | + +## Pattern + +A complete, copy-pasteable example lives in +[`examples/nix2container-production/`](../examples/nix2container-production/flake.nix). +The essentials: + +```nix +inputs = { + vigos.url = "github:vig-os/devcontainer"; # shared toolchain SSoT + nixpkgs.follows = "vigos/nixpkgs"; # same pinned nixpkgs as dev + nix2container.url = "github:nlewo/nix2container"; + nix2container.inputs.nixpkgs.follows = "nixpkgs"; +}; +# ... +n2c = nix2container.packages.${system}.nix2container; +packages.productionImage = n2c.buildImage { + name = "ghcr.io/your-org/your-app"; + tag = "latest"; + copyToRoot = [ app pkgs.cacert ]; # app + runtime deps ONLY + config.Cmd = [ "${app}/bin/hello" ]; +}; +``` + +Build and push: + +```bash +nix build .#productionImage +./result/bin/... copy-to docker://ghcr.io/your-org/your-app:latest # nix2container skopeo helper +``` + +## Why follow `vigos/nixpkgs` + +`nixpkgs.follows = "vigos/nixpkgs"` resolves the production image against the +*same* pinned revision the dev shell uses, so a CVE fixed by advancing the +toolchain pin (see [`CONTAINER_SECURITY.md`](CONTAINER_SECURITY.md)) lands in +production too, through the same Renovate-driven bump. + +> The example references the published `github:vig-os/devcontainer` flake +> outputs (`lib`, `overlays.default`), so a full `nix build` of it works once +> the Nix toolchain migration (#625) is published to the default branch. diff --git a/docs/RELEASE_CYCLE.md b/docs/RELEASE_CYCLE.md index 07520068..98ba8b8b 100644 --- a/docs/RELEASE_CYCLE.md +++ b/docs/RELEASE_CYCLE.md @@ -1018,6 +1018,6 @@ Follow [Semantic Versioning 2.0.0](https://semver.org/): - [CHANGELOG Format](../CHANGELOG.md) - Keep a Changelog standard - [Commit Message Standard](COMMIT_MESSAGE_STANDARD.md) - Commit format and validation - [Downstream Release Workflows](DOWNSTREAM_RELEASE.md) - Release process for consumer projects using `assets/workspace/` templates (not this repo’s pipeline) -- [Branch Naming Rules](../.cursor/rules/branch-naming.mdc) - Topic branch conventions +- [Branch Naming Rules](../.claude/skills/branch-naming/SKILL.md) - Topic branch conventions - [IEC 62304](https://www.iso.org/standard/38421.html) - Medical device software lifecycle - [Semantic Versioning](https://semver.org/) - Version numbering scheme diff --git a/docs/SKILL_PIPELINE.md b/docs/SKILL_PIPELINE.md index 5409b905..86417e14 100644 --- a/docs/SKILL_PIPELINE.md +++ b/docs/SKILL_PIPELINE.md @@ -7,7 +7,7 @@ How the `/command` skills fit together, what each one does, and when to use them ## Overview -Skills are markdown playbooks that live in `.cursor/skills/`. Each one defines a repeatable workflow the agent follows when invoked via `/skill-name`. They are grouped into phases that form two parallel pipelines: **interactive** (human-in-the-loop) and **autonomous** (worktree, no user prompts). +Skills are markdown playbooks that live in `.claude/skills/`. Each one defines a repeatable workflow the agent follows when invoked via `/skill-name`. They are grouped into phases that form two parallel pipelines: **interactive** (human-in-the-loop) and **autonomous** (worktree, no user prompts). ``` ┌─────────────────────────────────────────────┐ @@ -198,7 +198,7 @@ Each phase produces concrete artifacts that feed into the next. This is the data ### Autonomous Pipeline (same artifacts, no human prompts) -> **Important:** `worktree_*` skills are **not** invoked directly in your editor session. They only work inside a worktree launched via `just worktree-start`, which sets up the isolated environment, tmux session, and `cursor-agent` process they depend on. +> **Important:** `worktree_*` skills are **not** invoked directly in your editor session. They only work inside a worktree launched via `just worktree-start`, which sets up the isolated environment, tmux session, and `claude` process they depend on. | Step | Output | Where it lives | |------|--------|---------------| @@ -226,7 +226,7 @@ This makes the autonomous pipeline **idempotent** — re-running it picks up whe ## Subagent Delegation -Skills can delegate mechanical sub-steps (CLI calls, template filling, comment posting) to lightweight subagents via the Task tool, keeping the primary model focused on reasoning. Delegation tiers are defined in `.cursor/rules/subagent-delegation.mdc`: +Skills can delegate mechanical sub-steps (CLI calls, template filling, comment posting) to lightweight subagents via the Task tool, keeping the primary model focused on reasoning. Delegation tiers are defined in `.claude/skills/subagent-delegation/SKILL.md`: - **Lightweight** — CLI commands, file reading, template filling, comment posting - **Standard** — code review, log analysis, structured verification @@ -234,13 +234,13 @@ Skills can delegate mechanical sub-steps (CLI calls, template filling, comment p ## Worktree Infrastructure (`justfile.worktree`) -The autonomous pipeline doesn't run inside your current editor session. It runs in an isolated **git worktree** managed by `just` recipes, with a `cursor-agent` process inside a `tmux` session. This is the runtime layer that makes `worktree_*` skills work. +The autonomous pipeline doesn't run inside your current editor session. It runs in an isolated **git worktree** managed by `just` recipes, with a `claude` process inside a `tmux` session. This is the runtime layer that makes `worktree_*` skills work. ### Lifecycle Recipes | Recipe | What it does | |--------|-------------| -| `just worktree-start ""` | Creates a worktree, resolves (or creates) the linked branch, sets up the environment (`uv sync`, `pre-commit install`, `.env` copy), trusts the directory for `cursor-agent`, then launches a `tmux` session running `agent chat --yolo` with the given prompt. If the worktree already exists, it reuses it. | +| `just worktree-start ""` | Creates a worktree, resolves (or creates) the linked branch, sets up the environment (`uv sync`, `pre-commit install`, `.env` copy), then launches a `tmux` session running `claude --dangerously-skip-permissions` with the given prompt. If the worktree already exists, it reuses it. | | `just worktree-attach ` | Attaches to the running `tmux` session so you can watch or intervene. | | `just worktree-list` | Lists all worktrees with their branch, issue number, and tmux status (`[RUNNING]` / `[STOPPED]`). | | `just worktree-stop ` | Kills the tmux session, removes the worktree directory, and deletes the local branch. | @@ -248,8 +248,8 @@ The autonomous pipeline doesn't run inside your current editor session. It runs ### What `worktree-start` Does Under the Hood -1. **Prerequisites** — checks for `tmux` and `cursor-agent` CLI. -2. **Authentication** — tries existing browser login, falls back to `CURSOR_API_KEY`, or prompts `agent login`. +1. **Prerequisites** — checks for `tmux` and the `claude` CLI. +2. **Authentication** — tries existing browser login, falls back to `ANTHROPIC_API_KEY`, or prompts `claude auth login`. 3. **Branch resolution** — calls `gh issue develop --list` to find the linked branch. If none exists, it: - Fetches issue metadata, infers branch type from labels (`bugfix` vs `feature`). - Uses a lightweight agent call to derive a kebab-case summary from the issue title. @@ -257,15 +257,14 @@ The autonomous pipeline doesn't run inside your current editor session. It runs - Creates and links the branch via `gh issue develop`. - Assigns the issue to `@me`. 4. **Worktree setup** — `git worktree add`, then inside the worktree: `uv sync`, `pre-commit install`, copies `.env` from the main worktree. -5. **Trust** — adds the worktree path to `~/.cursor/cli-config.json` `trustedDirectories`. -6. **Launch** — starts `tmux new-session` running `agent chat --model --yolo ""`. +5. **Launch** — starts `tmux new-session` running `claude --dangerously-skip-permissions ""`. -The `--yolo` flag means the agent auto-approves all shell commands — appropriate because there's no human at this terminal. +The `--dangerously-skip-permissions` flag means the agent auto-approves all shell commands — appropriate because there's no human at this terminal (the container is the trust boundary; `IS_SANDBOX=1` is set in the image). ### Model Selection -Agent models are read from `.cursor/agent-models.toml`. The worktree recipes use: -- **`autonomous` tier** for the main `agent chat` session (design, code, reasoning). +Agent models are read from `.claude/agent-models.toml`. The worktree recipes use: +- **`autonomous` tier** for the main `claude` session (design, code, reasoning). - **`lightweight` tier** for the one-shot branch-naming call. ## Typical Interactive Workflow diff --git a/docs/generate.py b/docs/generate.py index 476149cd..b504856c 100644 --- a/docs/generate.py +++ b/docs/generate.py @@ -1,14 +1,13 @@ #!/usr/bin/env python3 -"""Generate documentation from narrative sources, requirements.yaml, skills, and just help output. +"""Generate documentation from narrative sources, skills, and just help output. This script implements "docs as code" by generating documentation from: - Narrative markdown files (docs/narrative/) -- Requirements definitions (scripts/requirements.yaml) -- Agent skill definitions (.cursor/skills/*/SKILL.md frontmatter) +- Agent skill definitions (.claude/skills/*/SKILL.md frontmatter) - Just recipe help output (just --list) -Single source of truth principle: All dependency information comes from requirements.yaml, -all skill metadata comes from SKILL.md frontmatter. +Single source of truth principle: the toolchain is defined by the Nix flake +(`flake.nix` devTools); all skill metadata comes from SKILL.md frontmatter. """ import re @@ -62,31 +61,6 @@ def get_release_date_from_changelog() -> str: return datetime.now().isoformat(timespec="seconds") -def load_requirements() -> dict: - """Load requirements from requirements.yaml. - - Returns a dictionary with: - - dependencies: List of required dependencies - - optional: List of optional dependencies - """ - requirements_file = Path(__file__).parent.parent / "scripts" / "requirements.yaml" - - if not requirements_file.exists(): - print( - f"Warning: Requirements file not found: {requirements_file}", - file=sys.stderr, - ) - return {"dependencies": [], "optional": [], "auto_install": []} - - with requirements_file.open() as f: - data = yaml.safe_load(f) - - return { - "dependencies": data.get("dependencies", []), - "optional": data.get("optional", []), - } - - SKILL_GROUP_ORDER = [ ("inception", "Inception (Project Bootstrap)"), ("issue", "Issue Management"), @@ -121,11 +95,11 @@ def load_requirements() -> dict: def load_skills() -> list[dict]: - """Scan .cursor/skills/*/SKILL.md and return parsed skill metadata. + """Scan .claude/skills/*/SKILL.md and return parsed skill metadata. Each entry has: name, trigger, description, group (prefix before underscore). """ - skills_dir = Path(__file__).parent.parent / ".cursor" / "skills" + skills_dir = Path(__file__).parent.parent / ".claude" / "skills" skills = [] if not skills_dir.is_dir(): @@ -187,76 +161,11 @@ def group_skills(skills: list[dict]) -> list[dict]: return [g for g in groups if g["skills"]] -def format_requirements_table(requirements: dict) -> str: - """Generate markdown table from requirements data.""" - lines = [ - "| Component | Version | Purpose |", - "|----------------------|---------|---------|", - ] - - # Required dependencies (manual install) - for dep in requirements["dependencies"]: - name = dep.get("name", "unknown") - version = dep.get("version", "latest") - purpose = dep.get("purpose", "") - lines.append(f"| **{name}** | {version} | {purpose} |") - - return "\n".join(lines) - - -def format_install_commands(requirements: dict, os_type: str) -> str: - """Generate installation command for a specific OS.""" - deps = requirements["dependencies"] - install_field = { - "macos": "macos", - "debian": "debian", - "fedora": "fedora", - }.get(os_type, "debian") - - # Collect package names for package manager installs - brew_packages = [] - apt_packages = [] - other_commands = [] - - for dep in deps: - install_info = dep.get("install", {}) - cmd = install_info.get(install_field, "") - - if not cmd: - continue - - # Parse common package manager patterns - if os_type == "macos" and cmd.startswith("brew install "): - brew_packages.append(cmd.replace("brew install ", "").strip()) - elif os_type == "debian" and cmd.startswith("sudo apt install -y "): - apt_packages.append(cmd.replace("sudo apt install -y ", "").strip()) - elif "|" in cmd or "\n" in cmd: - # Multi-line or piped commands - keep separate - other_commands.append(f"# {dep.get('name', 'unknown')}\n{cmd}") - else: - other_commands.append(cmd) - - result = [] - - if os_type == "macos" and brew_packages: - result.append(f"brew install {' '.join(brew_packages)}") - elif os_type == "debian" and apt_packages: - result.append("sudo apt update") - result.append(f"sudo apt install -y {' '.join(apt_packages)}") - - result.extend(other_commands) - - return "\n".join(result) - - def generate_docs() -> bool: """Generate documentation from templates.""" docs_dir = Path(__file__).parent root_dir = docs_dir.parent - # Load requirements - requirements = load_requirements() - # Set up Jinja2 environment env = jinja2.Environment( loader=jinja2.FileSystemLoader(docs_dir / "templates"), @@ -289,11 +198,6 @@ def include_narrative(filename: str) -> str: "version": get_version_from_changelog(), "release_date": get_release_date_from_changelog(), "release_url": f"https://github.com/vig-os/devcontainer/releases/tag/{get_version_from_changelog()}", - # Requirements data - "requirements": requirements, - "requirements_table": format_requirements_table(requirements), - "install_macos": format_install_commands(requirements, "macos"), - "install_debian": format_install_commands(requirements, "debian"), # Skill data "skill_groups": group_skills(skills), } diff --git a/docs/security/nix-cutover-scan-overlap.md b/docs/security/nix-cutover-scan-overlap.md new file mode 100644 index 00000000..cb81d106 --- /dev/null +++ b/docs/security/nix-cutover-scan-overlap.md @@ -0,0 +1,64 @@ +# Nix cutover — vulnix vs Trivy scan overlap + +Go/no-go evidence for the publish-cutover (#639) and the confidence check from +#637: comparing the two CVE scanners over a one-cycle overlap so we can confirm +no class of finding silently disappears when the published image moves from the +Debian/apt build (Trivy OS-package scan) to the Nix build (vulnix). + +## Method + +- **Baseline:** `nixos-26.05` (rev `34268251`, 2026-06-22), bumped from the + year-old `nixos-25.05` as the primary CVE lever (see `CONTAINER_SECURITY.md`). +- **vulnix** (primary gate) scans the image package closure — + `nix build .#devcontainerImageEnv` then `vulnix --closure`. Matches the Nix + store derivations by name + upstream version against NVD. +- **Trivy** (defence in depth) scans the built image — + `trivy image --input `. Detects bundled binaries and + language dependencies (e.g. Go stdlib inside `gh`/`podman`/`runc`) and flags + their CVEs. +- Snapshot date: **2026-06-23**. HIGH/CRITICAL = CVSS v3 ≥ 7.0. + +## Results (26.05 baseline) + +| Scanner | HIGH/CRITICAL (unique) | Disposition | +|---------|------------------------|-------------| +| **vulnix** | 27 | All triaged in `.vulnixignore` (gate green) | +| **Trivy** | 14 | Awareness / defence-in-depth (non-gating) | +| **Overlap (both)** | **0** | — | + +- The bump cut the surface for **both** scanners: vulnix **83 → 27** unique + HIGH/CRITICAL, Trivy HIGH **244 → 14**. +- **Zero overlap.** The two scanners flag completely disjoint CVE sets because + they examine different surfaces: vulnix sees Nix-store packages (glibc, + openssl, perl, …); Trivy sees vendored/bundled components inside binaries (Go + stdlib, npm/Go modules). Neither is redundant — together they widen coverage, + and **no class of finding disappears** in the Debian→Nix scanner switch + (Trivy's OS-package surface is replaced by vulnix's store surface; Trivy's + bundled-binary surface is retained). + +## vulnix residual (27, all excepted in `.vulnixignore`) + +- **Not applicable — CPE mismatch (4):** `shellcheck` CVE-2021-28794 (the VS Code + *extension*, not the binary) and three `git` CVEs that are *Jenkins Git-plugin* + advisories. +- **Accepted recent CVEs (23):** `glibc`, `openssl`, `perl`, `zlib`, `sqlite`, + `ldns`, `libmicrohttpd` — version-matched against current-stable nixpkgs; low + exploitability in an interactive single-user dev container; remediation is + advancing the pinned `nixpkgs` rev as fixes land (#638). 3-month re-review. + +## Trivy residual (14 HIGH, non-gating) + +Bundled-binary CVEs (predominantly Go stdlib inside Go-based tools). Not gated by +`vulnix-gate` (Trivy is the SBOM / defence-in-depth view); they shrink as the +pinned `nixpkgs` rev advances. The nightly `scan-nix-image` job keeps emitting +the CycloneDX SBOM + this comparison so the set is tracked each cycle. + +## Verdict + +- **vulnix gate: GREEN** after the 26.05 bump + triage (`vulnix-gate` exits 0). +- **Confidence check: satisfied** — disjoint surfaces documented; no finding + class lost in the scanner switch. +- **Publish remains PAUSED.** This batch stages the cutover (gate green + + blocking, release pipeline able to build the Nix image) but does not run a + release / promote `:latest`. That deliberate trigger — and a final review of + the CRITICAL acceptances above — is left to a maintainer. diff --git a/docs/templates/CONTRIBUTE.md.j2 b/docs/templates/CONTRIBUTE.md.j2 index c8f86b8d..df70c96a 100644 --- a/docs/templates/CONTRIBUTE.md.j2 +++ b/docs/templates/CONTRIBUTE.md.j2 @@ -1,5 +1,5 @@ {# CONTRIBUTE.md.j2 - Template for contributor documentation - Generated from: scripts/requirements.yaml + just --list output + Generated from: just --list output (toolchain SSoT is flake.nix devTools) DO NOT EDIT CONTRIBUTE.md directly - edit this template instead #} @@ -8,36 +8,93 @@ This guide explains how to develop, build, test, and release the vigOS development container image. -## Requirements +## Prerequisites -{{ requirements_table }} +This repository is **Nix-first**: the toolchain is defined by the Nix flake +(`flake.nix` — its `devTools` list is the single source of truth) and provisioned +into your shell by [direnv](https://direnv.net/) or `nix develop`. You only need +three things on the host: -**Ubuntu/Debian:** +| Prerequisite | Purpose | +|--------------|---------| +| **[Nix](https://nixos.org/download)** | Provides the entire dev toolchain (just, git, gh, uv, node, jq, tmux, ripgrep, claude, …) from the flake — no manual installs | +| **[direnv](https://direnv.net/)** | Loads the flake dev-shell automatically on `cd` — **once its [shell hook](https://direnv.net/docs/hook.html) is installed** (e.g. `eval "$(direnv hook bash)"` in `~/.bashrc`). Without the hook, `direnv allow` still succeeds but nothing loads on `cd` and you silently fall back to host tools. Recommended; `nix develop` works without direnv | +| **A working container runtime** (podman or Docker) | Building and testing the image needs a usable rootless runtime. The flake ships the `podman` CLI, but rootless operation depends on host setup — `subuid`/`subgid` + `uidmap` on Linux, or `podman machine` on macOS | -```bash -{{ install_debian }} -``` +Everything else comes from the flake. See the fast path below to get set up. -**macOS (Homebrew):** +## Nix dev shell (fast path) -```bash -{{ install_macos }} -``` +The repository ships a Nix flake (`flake.nix`) whose `devTools` list is the single +source of truth for the toolchain. With [Nix](https://nixos.org/download) and +[direnv](https://direnv.net/) installed you get the full dev environment on +`cd` into the clone — no manual dependency install. On a warm +[Cachix](https://www.cachix.org/) cache this is a binary fetch, not a from-source +build, so the first `direnv allow` completes in seconds. + +1. **Enable the flakes experimental features.** Add to `~/.config/nix/nix.conf` + (or `/etc/nix/nix.conf`): + + ```conf + experimental-features = nix-command flakes + ``` + +2. **Add the `vig-os` Cachix substituter** so the dev-shell closure is fetched + from the binary cache instead of built locally. Add to the same `nix.conf`: -- For other Linux distributions, use your package manager (e.g., `dnf`, `yum`, `zypper`, `apk`) to install these dependencies. -- Run `./scripts/init.sh` to check dependencies and get OS-specific installation commands. -- Ensure Docker is installed if you plan to use it instead of Podman. + ```conf + substituters = https://cache.nixos.org https://vig-os.cachix.org + trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= vig-os.cachix.org-1:yoOYRi3bvnM6ThxO0joLt7vtzhTfkq3r6jykeUMg7Bk= + ``` + + Pulling from the public `vig-os` cache needs no token. (If you have the Cachix + CLI: `cachix use vig-os` writes the same lines for you.) + +3. **Clone and allow direnv:** + + ```bash + git clone git@github.com:vig-os/devcontainer.git + cd devcontainer + direnv allow # first allow fetches the closure from Cachix (seconds on a warm cache) + ``` + + > **First time using direnv on this machine?** Install its shell hook first — + > add `eval "$(direnv hook bash)"` (or the + > [equivalent for your shell](https://direnv.net/docs/hook.html)) to your shell + > rc and start a new shell. The hook is what loads/unloads the environment on + > `cd`; without it `direnv allow` reports success but the flake never activates, + > so you keep host tooling (e.g. an old system Node) with no warning. Prefer not + > to install the hook? Use `nix develop` instead. + + The committed `.envrc` uses + [nix-direnv](https://github.com/nix-community/nix-direnv): the dev-shell + evaluation is cached and GC-rooted (under `.direnv/`, which is gitignored), so + re-entering the directory is instant and the closure is never garbage-collected. + nix-direnv is self-bootstrapped by `.envrc` on first allow; if you already + source it from `~/.config/direnv/direnvrc`, that installation is used instead. + +This Nix dev shell is an alternative to the devcontainer image below; use whichever +fits your workflow. Downstream workspaces scaffolded by `install.sh` choose between +the two (or both) via the delivery mode: `--mode devcontainer|direnv|both` +(default `both`; the interactive `init-workspace.sh` prompts, defaulting to +`both`). `devcontainer` scaffolds `.devcontainer/` only, `direnv` scaffolds +`flake.nix` + `.envrc` only, and `both` scaffolds everything. ## Setup -Clone this repository and prepare it for container development: +Clone this repository, enter the Nix dev shell, then bootstrap the project: ```bash git clone git@github.com:vig-os/devcontainer.git cd devcontainer -just init # Install dependencies and setup development environment +direnv allow # (recommended) loads the flake toolchain — or run `nix develop` +just init # Gate prerequisites and bootstrap the project (venv, git hooks, pre-commit) ``` +`just init` does not install tools — it verifies the Nix prerequisites are in +place and then performs one-time project bootstrap (`uv sync`, git hooks, commit +template, pre-commit). It is safe to re-run. + ## Development Workflow When contributing to this project, follow this workflow: diff --git a/docs/templates/README.md.j2 b/docs/templates/README.md.j2 index 5717895d..ba8b04cb 100644 --- a/docs/templates/README.md.j2 +++ b/docs/templates/README.md.j2 @@ -28,6 +28,14 @@ This will: - Pull the latest devcontainer image - Initialize your project with the devcontainer template +**Delivery mode.** A workspace can run on a VS Code **devcontainer**, on a Nix +flake + **direnv**, or **both**. Pass `--mode devcontainer|direnv|both` to choose +(both forms `--mode X` and `--mode=X` work). The one-line install runs +non-interactively and defaults to `both`; run `init-workspace.sh` directly (see +Manual Setup) without `--mode` to be prompted, where the default selection is +also `both`. `devcontainer` scaffolds `.devcontainer/` only; `direnv` scaffolds +`flake.nix` + `.envrc` only; `both` scaffolds everything. + **Options:** ```bash @@ -43,6 +51,9 @@ curl -sSf https://raw.githubusercontent.com/vig-os/devcontainer/main/install.sh # Override organization name (default: vigOS) curl -sSf https://raw.githubusercontent.com/vig-os/devcontainer/main/install.sh | bash -s -- --org MyOrg ~/my-project +# Choose the delivery mode: devcontainer | direnv | both (default: both) +curl -sSf https://raw.githubusercontent.com/vig-os/devcontainer/main/install.sh | bash -s -- --mode direnv ~/my-project + # Preview without executing curl -sSf https://raw.githubusercontent.com/vig-os/devcontainer/main/install.sh | bash -s -- --dry-run ~/my-project @@ -84,6 +95,11 @@ curl -sSf https://raw.githubusercontent.com/vig-os/devcontainer/main/install.sh The script copies the devcontainer template (`.devcontainer/`), git hooks, README/CHANGELOG, and auth helpers into your project. + Run interactively (no `-it` dropped), the script prompts for the delivery mode + (`devcontainer`/`direnv`/`both`, default `both`). Pass `--mode ` to skip + the prompt; under `--no-prompts` (e.g. the one-line install) it defaults to + `both`. + 3. **Run with `--force` when overwriting or updating an existing project** ```bash @@ -113,7 +129,7 @@ For detailed command descriptions, run `just --list --unsorted` or `just --help` ## Image Details -- **Base Image**: `python:3.12-slim-trixie` +- **Build**: Nix flake via `dockerTools.buildLayeredImage` (no Debian/Docker base image); bit-reproducible - **Registry**: `ghcr.io/vig-os/devcontainer` - **Architecture**: Multi-platform support (AMD64, ARM64) - **License**: Apache @@ -122,9 +138,9 @@ For detailed command descriptions, run `just --list --unsorted` or `just --help` ## Features -### **Base Image** +### **Build** -- **python:3.12-slim-trixie** – Minimal Python base image (Debian Trixie) for lightweight and robust foundation +- **Nix flake** – The image is assembled entirely by Nix via `dockerTools.buildLayeredImage` (no Debian/Docker base image). Python (CPython 3.14) and the whole toolchain come from a pinned `nixpkgs`, so the build is bit-reproducible ### **System Tools** @@ -138,8 +154,7 @@ For detailed command descriptions, run `just --list --unsorted` or `just --help` ### **Python Environment** -- **Python 3.12** - Latest stable Python version -- **pip, setuptools, wheel** - Python packaging tools (included with base image) +- **Python 3.14** - CPython from the pinned `nixpkgs` - **uv** - Fast Python package installer and resolver ### **Development Tools** diff --git a/docs/templates/SKILL_PIPELINE.md.j2 b/docs/templates/SKILL_PIPELINE.md.j2 index 7a0639ba..f6de88d6 100644 --- a/docs/templates/SKILL_PIPELINE.md.j2 +++ b/docs/templates/SKILL_PIPELINE.md.j2 @@ -7,7 +7,7 @@ How the `/command` skills fit together, what each one does, and when to use them ## Overview -Skills are markdown playbooks that live in `.cursor/skills/`. Each one defines a repeatable workflow the agent follows when invoked via `/skill-name`. They are grouped into phases that form two parallel pipelines: **interactive** (human-in-the-loop) and **autonomous** (worktree, no user prompts). +Skills are markdown playbooks that live in `.claude/skills/`. Each one defines a repeatable workflow the agent follows when invoked via `/skill-name`. They are grouped into phases that form two parallel pipelines: **interactive** (human-in-the-loop) and **autonomous** (worktree, no user prompts). ``` ┌─────────────────────────────────────────────┐ @@ -127,7 +127,7 @@ Each phase produces concrete artifacts that feed into the next. This is the data ### Autonomous Pipeline (same artifacts, no human prompts) -> **Important:** `worktree_*` skills are **not** invoked directly in your editor session. They only work inside a worktree launched via `just worktree-start`, which sets up the isolated environment, tmux session, and `cursor-agent` process they depend on. +> **Important:** `worktree_*` skills are **not** invoked directly in your editor session. They only work inside a worktree launched via `just worktree-start`, which sets up the isolated environment, tmux session, and `claude` process they depend on. | Step | Output | Where it lives | |------|--------|---------------| @@ -155,7 +155,7 @@ This makes the autonomous pipeline **idempotent** — re-running it picks up whe ## Subagent Delegation -Skills can delegate mechanical sub-steps (CLI calls, template filling, comment posting) to lightweight subagents via the Task tool, keeping the primary model focused on reasoning. Delegation tiers are defined in `.cursor/rules/subagent-delegation.mdc`: +Skills can delegate mechanical sub-steps (CLI calls, template filling, comment posting) to lightweight subagents via the Task tool, keeping the primary model focused on reasoning. Delegation tiers are defined in `.claude/skills/subagent-delegation/SKILL.md`: - **Lightweight** — CLI commands, file reading, template filling, comment posting - **Standard** — code review, log analysis, structured verification @@ -163,13 +163,13 @@ Skills can delegate mechanical sub-steps (CLI calls, template filling, comment p ## Worktree Infrastructure (`justfile.worktree`) -The autonomous pipeline doesn't run inside your current editor session. It runs in an isolated **git worktree** managed by `just` recipes, with a `cursor-agent` process inside a `tmux` session. This is the runtime layer that makes `worktree_*` skills work. +The autonomous pipeline doesn't run inside your current editor session. It runs in an isolated **git worktree** managed by `just` recipes, with a `claude` process inside a `tmux` session. This is the runtime layer that makes `worktree_*` skills work. ### Lifecycle Recipes | Recipe | What it does | |--------|-------------| -| `just worktree-start ""` | Creates a worktree, resolves (or creates) the linked branch, sets up the environment (`uv sync`, `pre-commit install`, `.env` copy), trusts the directory for `cursor-agent`, then launches a `tmux` session running `agent chat --yolo` with the given prompt. If the worktree already exists, it reuses it. | +| `just worktree-start ""` | Creates a worktree, resolves (or creates) the linked branch, sets up the environment (`uv sync`, `pre-commit install`, `.env` copy), then launches a `tmux` session running `claude --dangerously-skip-permissions` with the given prompt. If the worktree already exists, it reuses it. | | `just worktree-attach ` | Attaches to the running `tmux` session so you can watch or intervene. | | `just worktree-list` | Lists all worktrees with their branch, issue number, and tmux status (`[RUNNING]` / `[STOPPED]`). | | `just worktree-stop ` | Kills the tmux session, removes the worktree directory, and deletes the local branch. | @@ -177,8 +177,8 @@ The autonomous pipeline doesn't run inside your current editor session. It runs ### What `worktree-start` Does Under the Hood -1. **Prerequisites** — checks for `tmux` and `cursor-agent` CLI. -2. **Authentication** — tries existing browser login, falls back to `CURSOR_API_KEY`, or prompts `agent login`. +1. **Prerequisites** — checks for `tmux` and the `claude` CLI. +2. **Authentication** — tries existing browser login, falls back to `ANTHROPIC_API_KEY`, or prompts `claude auth login`. 3. **Branch resolution** — calls `gh issue develop --list` to find the linked branch. If none exists, it: - Fetches issue metadata, infers branch type from labels (`bugfix` vs `feature`). - Uses a lightweight agent call to derive a kebab-case summary from the issue title. @@ -186,15 +186,14 @@ The autonomous pipeline doesn't run inside your current editor session. It runs - Creates and links the branch via `gh issue develop`. - Assigns the issue to `@me`. 4. **Worktree setup** — `git worktree add`, then inside the worktree: `uv sync`, `pre-commit install`, copies `.env` from the main worktree. -5. **Trust** — adds the worktree path to `~/.cursor/cli-config.json` `trustedDirectories`. -6. **Launch** — starts `tmux new-session` running `agent chat --model --yolo ""`. +5. **Launch** — starts `tmux new-session` running `claude --dangerously-skip-permissions ""`. -The `--yolo` flag means the agent auto-approves all shell commands — appropriate because there's no human at this terminal. +The `--dangerously-skip-permissions` flag means the agent auto-approves all shell commands — appropriate because there's no human at this terminal (the container is the trust boundary; `IS_SANDBOX=1` is set in the image). ### Model Selection -Agent models are read from `.cursor/agent-models.toml`. The worktree recipes use: -- **`autonomous` tier** for the main `agent chat` session (design, code, reasoning). +Agent models are read from `.claude/agent-models.toml`. The worktree recipes use: +- **`autonomous` tier** for the main `claude` session (design, code, reasoning). - **`lightweight` tier** for the one-shot branch-naming call. ## Typical Interactive Workflow diff --git a/docs/templates/TESTING.md.j2 b/docs/templates/TESTING.md.j2 index 3bd137e6..b1235e2a 100644 --- a/docs/templates/TESTING.md.j2 +++ b/docs/templates/TESTING.md.j2 @@ -30,7 +30,7 @@ These tests run against a running container instance to verify the image itself (installed tools, versions, environment variables, file structure). - `TestSystemTools` - git, curl, openssh-client, gh, just -- `TestPythonEnvironment` - Python 3.12, uv +- `TestPythonEnvironment` - Python 3.14, uv - `TestDevelopmentTools` - pre-commit, ruff, just - `TestEnvironmentVariables` - environment variables - `TestFileStructure` - file structure diff --git a/examples/nix2container-production/flake.nix b/examples/nix2container-production/flake.nix new file mode 100644 index 00000000..b4eb4d6f --- /dev/null +++ b/examples/nix2container-production/flake.nix @@ -0,0 +1,53 @@ +{ + description = "Example: a nix2container production image derived from the vigOS toolchain SSoT."; + + # Production/runtime images for downstream packages are built with + # `nix2container` (better layering + push performance than dockerTools), + # NOT the devcontainer's buildLayeredImage. They still follow the same pinned + # nixpkgs as the shared toolchain (vigos), so dev and prod agree on versions. + inputs = { + vigos.url = "github:vig-os/devcontainer"; + nixpkgs.follows = "vigos/nixpkgs"; + flake-utils.follows = "vigos/flake-utils"; + nix2container.url = "github:nlewo/nix2container"; + nix2container.inputs.nixpkgs.follows = "nixpkgs"; + }; + + outputs = + { + self, + vigos, + nixpkgs, + flake-utils, + nix2container, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ vigos.overlays.default ]; + }; + n2c = nix2container.packages.${system}.nix2container; + + # Replace with your built application. A minimal runtime closure — the + # app and its runtime deps only, NOT the dev toolchain — is the point: + # production images stay small while sharing the pinned nixpkgs. + app = pkgs.hello; + in + { + packages.productionImage = n2c.buildImage { + name = "ghcr.io/your-org/your-app"; + tag = "latest"; + copyToRoot = [ + app + pkgs.cacert + ]; + config = { + Cmd = [ "${app}/bin/hello" ]; + Env = [ "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" ]; + }; + }; + } + ); +} diff --git a/flake.lock b/flake.lock index 13c3d1c2..abc495da 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,27 @@ }, "nixpkgs": { "locked": { - "lastModified": 1772736753, - "narHash": "sha256-au/m3+EuBLoSzWUCb64a/MZq6QUtOV8oC0D9tY2scPQ=", + "lastModified": 1782116945, + "narHash": "sha256-G3tw/IXmaH6IQ2upZvhuN9sG8CkuX+BLuJDpE8hz0Ds=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "917fec990948658ef1ccd07cef2a1ef060786846", + "rev": "34268251cf5547d39063f2c5ea9a196246f7f3a6", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-26.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-unstable": { + "locked": { + "lastModified": 1781607440, + "narHash": "sha256-rxO+uc/KFbSJp+pgyXRuAX6QlG9hJdnt0BXpEQRXY+U=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "3e41b24abd260e8f71dbe2f5737d24122f972158", "type": "github" }, "original": { @@ -37,7 +53,8 @@ "root": { "inputs": { "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "nixpkgs-unstable": "nixpkgs-unstable" } }, "systems": { diff --git a/flake.nix b/flake.nix index e5243bd8..87ccaa6e 100644 --- a/flake.nix +++ b/flake.nix @@ -1,49 +1,507 @@ { - description = "eXoma devcontainer – host development environment"; + description = "vigOS devcontainer – toolchain SSoT (dev-shell + image basis)"; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + # Pinned stable channel: the controlled version document (flake.lock). + nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; + # Secondary channel, overlaid only for fast-moving tools (uv, gh, claude). + nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { self, nixpkgs, flake-utils }: - flake-utils.lib.eachDefaultSystem (system: + outputs = + { + self, + nixpkgs, + nixpkgs-unstable, + flake-utils, + }: + let + # --------------------------------------------------------------------- + # Overlay: pull fast-movers from nixpkgs-unstable. + # + # The stable channel (nixos-26.05) lags on tools that ship frequently and + # whose latest version we want in both the dev-shell and the image. We + # overlay only those few packages from unstable; everything else stays on + # the pinned stable channel for reproducibility. + # --------------------------------------------------------------------- + fastMovers = [ + "uv" + "gh" + # claude (claude-code) is an agent CLI that moves very fast; track unstable. + "claude-code" + ]; + + # bats + helper libraries as one wrapped package. The wrapper exports a + # BATS_LIB_PATH covering bats-support/-assert/-file so `bats_load_library` + # (tests/bats/test_helper.bash) resolves them from the Nix store — the + # flake SSoT — replacing the npm (node_modules) / Debian (/usr/lib) + # resolution that does not exist on the Nix toolchain. Refs #695. + batsWithLibs = + pkgs: + pkgs.bats.withLibraries (p: [ + p.bats-support + p.bats-assert + p.bats-file + ]); + + overlay = + final: prev: + let + unstable = import nixpkgs-unstable { + inherit (final) system; + config.allowUnfree = true; + }; + in + builtins.listToAttrs ( + map (name: { + inherit name; + value = unstable.${name}; + }) fastMovers + ); + + # --------------------------------------------------------------------- + # devTools — the single source of truth for the toolchain. + # + # This list is the shared basis for the dev-shell now and the image later + # (#634). Adding a tool here adds it everywhere; the per-tool parity test + # (tests/test_flake_devshell.py) reads `devShellTools` so it can never + # drift from this list. + # --------------------------------------------------------------------- + devTools = + pkgs: with pkgs; [ + # Build automation + just + + # Version control & GitHub (gh from unstable via overlay) + git + gh + lazygit + delta + + # Python tooling (uv from unstable via overlay) + uv + + # Node.js (devcontainer CLI via npm) + nodejs + + # Shell testing: bats core + helper libraries (support/assert/file). + # Wrapped so BATS_LIB_PATH is exported for bats_load_library. Refs #695. + (batsWithLibs pkgs) + + # Shell & JSON utilities + jq + tmux + shellcheck + + # Linting + hadolint + taplo + nixfmt-rfc-style # nix file formatter (flake `formatter`, pre-commit hook) + ruff # python linter/formatter (pre-commit ruff/ruff-format hooks) + typos # source typo checker (pre-commit typos hook) + + # Container runtime + podman + + # Agent / terminal toolkit (absorbed from #545) + ripgrep # rg + fd + bat + eza + zoxide + starship + charm-freeze # freeze (charmbracelet terminal screenshots) + expect + neovim # nvim + claude-code # claude + ]; + + # Binary names exposed for the parity test. Prefer the package's declared + # `meta.mainProgram` (the canonical executable name, e.g. ripgrep -> rg, + # neovim -> nvim, claude-code -> claude); fall back to the pname. + devShellToolNames = + pkgs: + map (drv: drv.meta.mainProgram or drv.pname or (builtins.parseDrvName drv.name).name) ( + devTools pkgs + ); + + # --------------------------------------------------------------------- + # mkProjectShell — reusable dev-shell builder for downstream repos. + # + # Consumers can build a shell with the shared toolchain plus their own + # extra packages: + # devShells.default = inputs.devcontainer.lib.mkProjectShell { + # inherit pkgs; + # extraPackages = [ pkgs.foo ]; + # }; + # --------------------------------------------------------------------- + # uv's Python-download metadata, pinned to the uv release we provision. + # The nixpkgs build of uv ships with its embedded Python-download list + # stripped, so it cannot fetch a managed CPython on its own. CI provisions + # FROM this dev-shell on an FHS runner and forwards this URL (see the + # setup-env action) so the runner's uv can download a managed CPython for + # `uv sync` / pre-commit — a Nix-store interpreter cannot load pre-commit's + # manylinux-wheel C extensions outside `nix develop`. Refs #632, #666, #683. + uvPythonDownloadsJsonUrl = "https://raw.githubusercontent.com/astral-sh/uv/0.11.23/crates/uv-python/download-metadata.json"; + + mkProjectShell = + { + pkgs, + extraPackages ? [ ], + shellHook ? ''echo "devcontainer dev environment loaded (nix)"'', + }: + let + # CPython matching `requires-python` (>=3.14,<3.15). The dev-shell + # carries no Python on PATH (the project venv is uv-managed). Pin a + # Nix store CPython via UV_PYTHON and forbid downloads + # (UV_PYTHON_DOWNLOADS=never): the nixpkgs uv would otherwise fetch a + # generic, dynamically-linked managed CPython a NixOS host cannot + # execute out of the box (no FHS ld-linux), so `uv sync` (`just init`) + # aborted there (#683). A store interpreter is patched to the store + # loader and runs in the dev-shell on both NixOS and FHS hosts. The + # IMAGE path sets the same two vars (baking pythonEnv). Refs #666, #683. + python = pkgs.python314; + + # The C++ runtime (libstdc++.so.6). The `pymarkdown` pre-commit hook + # runs from pre-commit's OWN manylinux-wheel Python env (not the project + # venv), whose dependency `pyjson5` is a C extension linked against + # `libstdc++.so.6`. On a NixOS host that library is not on the loader + # path outside an FHS environment, so the hook aborts with + # `ImportError: libstdc++.so.6: cannot open shared object file`. Exposing + # the Nix C++ runtime on LD_LIBRARY_PATH lets the wheel resolve it; it is + # the same libstdc++ the Nix toolchain itself links (`stdenv.cc.cc.lib`). + # pymarkdown is not in nixpkgs, so the #697 "add to devTools + + # language:system" recipe does not apply here. Refs #698. + ldLibraryPath = "${pkgs.stdenv.cc.cc.lib}/lib"; + + # Inject it ONLY on NixOS, where it is both required (above) and ABI-safe + # (the system glibc IS the Nix glibc). On an FHS host the system + # libstdc++ already resolves the wheel, and exporting the Nix one — built + # against a newer glibc — leaks into host binaries (every `just` recipe's + # `#!/usr/bin/env bash`, plus anything an `/etc/ld.so.preload` agent pulls + # `libstdc++` into), dragging in the Nix `libm.so.6` and aborting them with + # `version 'GLIBC_ABI_DT_X86_64_PLT' not found`. `/etc/NIXOS` marks NixOS. + # mkShell may itself inject an LD_LIBRARY_PATH from propagated libs; + # APPEND rather than clobber so that value (and any host-set one) survives. + # Refs #703. + ldLibraryPathHook = '' + if [ -e /etc/NIXOS ]; then + export LD_LIBRARY_PATH="''${LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}${ldLibraryPath}" + fi + ''; + in + pkgs.mkShell { + packages = (devTools pkgs) ++ extraPackages; + shellHook = ldLibraryPathHook + "\n" + shellHook; + + UV_PYTHON = "${python}/bin/python3.14"; + UV_PYTHON_DOWNLOADS = "never"; + + # Resolve the bats helper libraries from the Nix store. The wrapper + # also sets this when `bats` runs, but exporting it in the dev-shell + # makes the path visible (and works for a bare `bats` too). Refs #695. + BATS_LIB_PATH = "${batsWithLibs pkgs}/share/bats"; + + # For CI only: the pin above means downloads never happen in the + # dev-shell, but CI forwards this URL (NOT UV_PYTHON) so its FHS runner + # downloads a managed CPython for pre-commit's manylinux-wheel hooks, + # which a Nix-store interpreter cannot load there. Refs #632, #683. + UV_PYTHON_DOWNLOADS_JSON_URL = uvPythonDownloadsJsonUrl; + }; + in + flake-utils.lib.eachDefaultSystem ( + system: let - pkgs = nixpkgs.legacyPackages.${system}; + pkgs = import nixpkgs { + inherit system; + overlays = [ overlay ]; + config.allowUnfree = true; + }; + + python = pkgs.python314; + + # vig-utils packaged for the image (T2.4, #666): a pure-Python hatchling + # package (single runtime dep `rich`) built by Nix, so `import vig_utils` + # and its console scripts (check-expirations, vulnix-gate, …) are present + # without a network-populated uv venv (impossible in a hermetic build). + vigUtils = python.pkgs.buildPythonPackage { + pname = "vig-utils"; + version = "0.1.0"; + pyproject = true; + src = ./packages/vig-utils; + build-system = [ python.pkgs.hatchling ]; + dependencies = [ python.pkgs.rich ]; + pythonImportsCheck = [ "vig_utils" ]; + # The package's own tests need pytest + the repo; CI covers them. + doCheck = false; + }; + + # pip-licenses is not packaged in nixpkgs, so install it from its PyPI + # wheel (pinned to the project's locked version + hash). Using the wheel + # avoids its setuptools-scm/setuptools>=82 build backend; its only runtime + # dep, prettytable, is in nixpkgs. Refs #666. + pipLicenses = python.pkgs.buildPythonPackage { + pname = "pip-licenses"; + version = "5.5.5"; + format = "wheel"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/2a/9a/6acfdb8d463eac7cdae7534d35d72237eca63f5fbafe797289d8a5fae447/pip_licenses-5.5.5-py3-none-any.whl"; + sha256 = "f4c4c6d9e6a03612cf59f29f19dc8ab54904d82e055b8e191498f2279a224e14"; + }; + dependencies = [ python.pkgs.prettytable ]; + pythonImportsCheck = [ "piplicenses" ]; + }; + + # The image's Python interpreter, with the project's Python tools + # (vig-utils + pip-licenses) and their console scripts on PATH. Replaces + # the bare interpreter in imageTools. + pythonEnv = python.withPackages (_ps: [ + vigUtils + pipLicenses + ]); + + # The toolchain SSoT plus the runtime substrate a bare layered image + # lacks (an FHS base distro would provide these; here we add them + # explicitly — this is the discovery surface for FHS gaps). Shared by + # the image (`devcontainerImage`) and its vulnix scan target + # (`devcontainerImageEnv`, #637). + imageTools = + (devTools pkgs) + ++ (with pkgs; [ + # Nix package manager in the closure (CppNix). + nix + direnv + nix-direnv + + # Locale support without locale-gen. + glibcLocales + + # Python (with vig-utils baked) + the project Python toolchain. + # The Debian image installed these via `uv pip install` at build; + # the hermetic Nix build takes them from nixpkgs instead (#666). + pythonEnv + pre-commit + bandit + + # Rust/cargo + just LSP/formatter tools. The Debian image installed + # these via cargo-binstall; Nix-native from nixpkgs here (#666). + cargo-binstall + just-lsp + typstyle + + # Base runtime substrate (no FHS base distro to inherit). + bashInteractive + coreutils-full + findutils + gnugrep + gnused + gawk + gnutar + gzip + which + cacert + curl + openssh + nano + rsync + + # /etc/passwd + /etc/group with a root (uid 0) entry. A bare + # layered image has no FHS user database, so anything that + # resolves the current uid (ssh, tmux, git) fails with + # "No user exists for uid 0". fakeNss provides the minimal + # nss files an FHS base distro would have supplied. + dockerTools.fakeNss + ]); in { - devShells.default = pkgs.mkShell { - packages = with pkgs; [ - # Build automation - just + devShells.default = mkProjectShell { inherit pkgs; }; - # Version control & GitHub - git - gh + # Binary names of every tool in devTools — read by the parity test. + devShellTools = devShellToolNames pkgs; - # Python tooling - uv + # ------------------------------------------------------------------ + # formatter — `nix fmt` formats every *.nix file with nixfmt-rfc-style. + # + # Same package as the `nixfmt` pre-commit hook (sourced from devTools) + # so editor `nix fmt`, the hook, and the `checks.format` gate below all + # agree on one formatting. Refs #674. + # ------------------------------------------------------------------ + formatter = pkgs.nixfmt-rfc-style; - # Node.js (bats, devcontainer CLI via npm) - nodejs + # ------------------------------------------------------------------ + # checks — lightweight flake quality gates run by `nix flake check`. + # + # Kept deliberately lightweight. The richer dev-shell/image parity test + # (tests/test_flake_devshell.py) is NOT wrapped as a flake check: nix + # checks build in a sandbox with no recursive nix access, so a check + # that itself runs `nix eval`/`nix develop` cannot work here. That test + # therefore stays in CI as a pytest (the project-checks job), and the + # flake checks cover what a sandbox can: the flake formats cleanly, the + # dev-shell builds, and devShellTools evaluates. Refs #674. + checks = { + # Every *.nix file is nixfmt-clean (the `nix fmt` idempotency gate). + format = pkgs.runCommand "nixfmt-check" { nativeBuildInputs = [ pkgs.nixfmt-rfc-style ]; } '' + nixfmt --check ${./flake.nix} + touch "$out" + ''; - # Shell & JSON utilities - jq - tmux - shellcheck + # The dev-shell evaluates and its closure builds. + devShell = self.devShells.${system}.default; - # Linting - hadolint - taplo + # devShellTools (the parity-test SSoT) evaluates to a non-empty list. + devShellTools = pkgs.runCommand "devshell-tools-eval" { } '' + count=${toString (builtins.length (devShellToolNames pkgs))} + test "$count" -gt 0 + touch "$out" + ''; + }; - # Container runtime - podman - ]; + packages = { + # ----------------------------------------------------------------- + # devcontainerImage — Nix-built devcontainer image (T2.1, #634). + # + # Assembled entirely by Nix via `dockerTools.buildLayeredImage` (NOT + # a Dockerfile `FROM`) so the build is bit-reproducible — the epic's + # "identical image digest on rebuild" criterion can hold. The Nix + # package manager (CppNix, `pkgs.nix`) is part of the closure so + # `nix`/`direnv` are live inside the container, identical to the + # direnv path; `nix2container` stays reserved for production images. + # + # Evaluator decision (#634): ship upstream CppNix (`pkgs.nix`) as the + # in-container evaluator. It is the channel default, needs no overlay, + # and the flake is installer-agnostic, so swapping to `pkgs.lix` later + # is a one-line change. `pkgs.lix` is left out for now to keep the + # closure smaller. + # + # pre-commit vs prek (#40): this image bakes upstream `pre-commit` + # (matches the Debian build and the pinned pyproject version). + # Migrating the cache layer to `prek` is deferred to #40; both are in + # nixpkgs, so it is a drop-in swap once that issue lands. + devcontainerImage = + let + # Bake the workspace assets, pre-commit cache dir and template + # .venv scaffold as a normal image layer. UV_PYTHON pins the Nix + # interpreter and UV_PYTHON_DOWNLOADS=never forbids uv from + # fetching a managed CPython (absent in the sandbox anyway). The + # venv/pre-commit population needs network, so it is best-effort + # here and the directories are created unconditionally. + bootstrap = + pkgs.runCommand "devcontainer-bootstrap" + { + nativeBuildInputs = [ + pkgs.coreutils + pkgs.findutils + ]; + } + '' + mkdir -p "$out/root/assets" + cp -r ${./assets}/. "$out/root/assets/" + chmod -R u+w "$out/root/assets" + find "$out/root/assets" -type f -name "*.sh" -exec chmod +x {} \; - shellHook = '' - echo "devcontainer dev environment loaded (nix)" - ''; + # Bake the devcontainer version into the scaffolded `.vig-os`, + # replacing the {{IMAGE_TAG}} placeholder. The Debian build + # relied on the IMAGE_TAG build-arg; the reproducible Nix image + # reads the repo's pinned DEVCONTAINER_VERSION, so a scaffolded + # workspace pins the devcontainer release it was built from. #642. + dcver="$(sed -n 's/^DEVCONTAINER_VERSION=//p' ${./.vig-os})" + sed -i "s/{{IMAGE_TAG}}/$dcver/g" "$out/root/assets/workspace/.vig-os" + + # /root/.bashrc with carried aliases: precommit (Debian + # build) plus cc/cld (#545). + cat > "$out/root/.bashrc" <<'BASHRC' + alias precommit="pre-commit run" + alias cc="claude" + alias cld="claude --dangerously-skip-permissions" + BASHRC + + mkdir -p "$out/opt/pre-commit-cache" + mkdir -p "$out/workspace" + + # /tmp with the sticky bit. A bare layered image has no + # /tmp; tools that need a scratch/socket dir (tmux, uv, + # pytest) fail without it ("no suitable socket path"). An + # FHS base distro would have supplied it. + mkdir -p "$out/tmp" + chmod 1777 "$out/tmp" + ''; + in + pkgs.dockerTools.buildLayeredImage { + # Name matches the published repo so the portable testinfra + # (#635), which targets ghcr.io/vig-os/devcontainer:, runs + # unchanged against the loaded image under a unique tag. + name = "ghcr.io/vig-os/devcontainer"; + # Disposable discovery tag, matching the CI workflow's + # INDEX_TAG (.github/workflows/nix-image.yml). The versioned + # / :latest cutover is handled separately (#639). + tag = "nix-dev"; + + contents = imageTools ++ [ bootstrap ]; + + # Deterministic epoch timestamp keeps the digest reproducible. + created = "1970-01-01T00:00:00Z"; + + config = { + Cmd = [ "${pkgs.bashInteractive}/bin/bash" ]; + WorkingDir = "/workspace"; + Env = [ + # Declare PATH explicitly. buildLayeredImage symlinks every + # tool's bin into /bin but sets no PATH in the OCI config; a + # Debian base used to provide one. `podman run` masks this by + # injecting a default PATH, but the docker-compose + + # `devcontainer exec` path (and VS Code) does not, so the + # baked toolchain was off PATH there — breaking pre-commit's + # `language: system` ruff/typos hooks (`Executable not found`) + # during an in-container `git commit`. Refs #697, #698. + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + "LANG=en_US.UTF-8" + "LANGUAGE=en_US:en" + "LC_ALL=en_US.UTF-8" + "LOCALE_ARCHIVE=${pkgs.glibcLocales}/lib/locale/locale-archive" + "PYTHONUNBUFFERED=1" + "IN_CONTAINER=true" + # #545: the container is the trust boundary; bypass the uid-0 + # check for `claude --dangerously-skip-permissions`. + "IS_SANDBOX=1" + "PRE_COMMIT_HOME=/opt/pre-commit-cache" + "UV_PROJECT_ENVIRONMENT=/root/assets/workspace/.venv" + "VIRTUAL_ENV=/root/assets/workspace/.venv" + "UV_PYTHON_DOWNLOADS=never" + "UV_PYTHON=${python}/bin/python3.14" + "BATS_LIB_PATH=${batsWithLibs pkgs}/share/bats" + "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + "NIX_SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + "HOME=/root" + ]; + Labels = { + "org.opencontainers.image.title" = "vigOS development environment"; + "org.opencontainers.image.source" = "https://github.com/vig-os/devcontainer"; + "org.opencontainers.image.licenses" = "MIT"; + }; + }; + }; + + # devcontainerImageEnv — vulnix scan target (T3.1, #637). A buildEnv + # whose runtime closure equals the image's package set (imageTools), + # so `vulnix --closure` sees exactly what ships in the image. The OCI + # tarball itself is gzipped and exposes no scannable store references, + # hence this dedicated env rather than scanning the image output. + devcontainerImageEnv = pkgs.buildEnv { + name = "devcontainer-image-env"; + paths = imageTools; + ignoreCollisions = true; + }; + + # vulnix — pinned CVE scanner (#637) from the locked nixpkgs so the + # nightly scan is reproducible rather than tracking a rolling channel. + vulnix = pkgs.vulnix; }; } - ); + ) + // { + # System-independent reusable outputs. + lib = { inherit mkProjectShell devTools; }; + overlays.default = overlay; + }; } diff --git a/install.sh b/install.sh index 77f1133a..519eefa0 100755 --- a/install.sh +++ b/install.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # vigOS devcontainer quick install script # # Usage: @@ -13,6 +13,7 @@ # --name NAME Override project name (SHORT_NAME) # --org ORG Override organization name (default: vigOS) # --repo OWNER/REPO GitHub repo for Renovate preset (default: detect from origin or OWNER/REPO) +# --mode MODE Delivery mode: devcontainer | direnv | both (default: prompt, both non-interactively) # --smoke-test Deploy smoke-test-specific assets # --dry-run Show what would be done without executing # -h, --help Show this help message @@ -36,6 +37,7 @@ PROJECT_PATH="" PROJECT_NAME="" ORG_NAME="vigOS" GITHUB_REPO_OVERRIDE="" +MODE="" SMOKE_TEST="" # Colors (disabled if not a tty) @@ -70,6 +72,8 @@ OPTIONS: --name NAME Override project name (SHORT_NAME, used for module name) --org ORG Override organization name (default: vigOS) --repo OWNER/REPO GitHub repository for Renovate (default: git origin or OWNER/REPO) + --mode MODE Delivery mode: devcontainer | direnv | both + (default: prompt interactively; "both" non-interactively) --smoke-test Deploy smoke-test-specific assets --dry-run Show what would be done -h, --help Show this help @@ -92,6 +96,9 @@ EXAMPLES: # Use custom organization name curl -sSf ... | bash -s -- --org MyOrg ./my-project + + # Scaffold only the Nix/direnv stub (no .devcontainer/) + curl -sSf ... | bash -s -- --mode direnv ./my-project EOF } @@ -307,6 +314,14 @@ while [ $# -gt 0 ]; do GITHUB_REPO_OVERRIDE="$2" shift 2 ;; + --mode) + MODE="$2" + shift 2 + ;; + --mode=*) + MODE="${1#--mode=}" + shift + ;; --dry-run) DRY_RUN=true shift @@ -335,6 +350,16 @@ while [ $# -gt 0 ]; do esac done +# Validate delivery mode (empty = let init-workspace.sh prompt / default to both) +case "$MODE" in + ""|devcontainer|direnv|both) ;; + *) + err "Invalid --mode: $MODE (expected: devcontainer | direnv | both)" + usage + exit 1 + ;; +esac + # Validate and set project path PROJECT_PATH="${PROJECT_PATH:-.}" if [ ! -d "$PROJECT_PATH" ]; then @@ -446,6 +471,10 @@ if [ -n "$SMOKE_TEST" ]; then CMD+=(--smoke-test) fi +if [ -n "$MODE" ]; then + CMD+=(--mode "$MODE") +fi + if [ "$DRY_RUN" = true ]; then info "Would execute:" printf " %s" "$RUNTIME run --rm -e SHORT_NAME=\"$PROJECT_NAME\" -e ORG_NAME=\"$ORG_NAME\" -e GITHUB_REPOSITORY=\"$GITHUB_REPOSITORY\" -v \"$PROJECT_PATH\":/workspace \"$IMAGE\" /root/assets/init-workspace.sh --no-prompts" @@ -455,6 +484,9 @@ if [ "$DRY_RUN" = true ]; then if [ -n "$SMOKE_TEST" ]; then printf " %s" "--smoke-test" fi + if [ -n "$MODE" ]; then + printf " %s %s" "--mode" "$MODE" + fi printf "\n" exit 0 fi diff --git a/justfile b/justfile index 7fd5fbf1..b25de6ba 100644 --- a/justfile +++ b/justfile @@ -30,12 +30,12 @@ help: # Run all linters [group('quality')] lint: - uv run ruff check . + ruff check . # Format code [group('quality')] format: - uv run ruff format . + ruff format . # Run pre-commit hooks on all files [group('quality')] @@ -53,10 +53,10 @@ info: NATIVE_ARCH="linux/amd64" fi echo "Image: {{ repo }}" - echo "Containerfile: Containerfile" + echo "Image builder: Nix flake (.#devcontainerImage)" echo "Native arch: $NATIVE_ARCH" -# Install system dependencies and setup development environment +# Gate Nix prerequisites and bootstrap the project (venv, git hooks, pre-commit) [group('info')] init *args: ./scripts/init.sh {{ args }} @@ -86,17 +86,16 @@ login: [group('build')] build no_cache="": #!/usr/bin/env bash - ARCH=$(uname -m) - if [ "$ARCH" = "arm64" ] || [ "$ARCH" = "aarch64" ]; then - NATIVE_ARCH="linux/arm64" - else - NATIVE_ARCH="linux/amd64" - fi - if [ -n "{{ no_cache }}" ]; then - ./scripts/build.sh --no-cache dev "{{ repo }}" "$NATIVE_ARCH" - else - ./scripts/build.sh dev "{{ repo }}" "$NATIVE_ARCH" - fi + set -euo pipefail + # Nix-only (#642): build the layered image from the flake and load it into + # podman under the local `dev` tag. Builds natively for the host arch. + # `no_cache` is accepted for compatibility but is a no-op — Nix builds are + # content-addressed (there is no Docker layer cache to bust). + echo "Building the Nix devcontainer image (.#devcontainerImage)..." + nix build .#devcontainerImage --accept-flake-config --print-build-logs + loaded=$(podman load -i result | sed -n 's/^Loaded image: //p' | head -n1) + podman tag "${loaded}" "{{ repo }}:dev" + echo "Loaded and tagged {{ repo }}:dev (from ${loaded})" # =============================================================================== # TEST @@ -158,13 +157,15 @@ test-vig-utils: [group('test')] test-bats: #!/usr/bin/env bash + # bats and its helper libraries come from the flake (the toolchain SSoT); + # the wrapper exports BATS_LIB_PATH so test_helper.bash resolves them. #695. # Use GNU parallel if available for faster test execution if command -v parallel >/dev/null 2>&1; then echo "Running BATS tests in parallel..." - find tests/bats -name '*.bats' -print0 | parallel -0 -j+0 npx bats {} + find tests/bats -name '*.bats' -print0 | parallel -0 -j+0 bats {} else echo "Running BATS tests sequentially (install 'parallel' for faster execution)..." - npx bats tests/bats/ + bats tests/bats/ fi # Validate tracked Renovate configs with renovate-config-validator --strict @@ -247,7 +248,7 @@ clean version="dev": [group('build')] clean-test-containers: #!/usr/bin/env bash - echo "Cleaning Cleaning up lingering test containers..." + echo "Cleaning up lingering test containers..." FMT=$(printf '\x7b\x7b.ID\x7d\x7d') DEVCONTAINERS=$(podman ps -a --filter "name=workspace-devcontainer" --format "$FMT" 2>/dev/null) SIDECARS=$(podman ps -a --filter "name=test-sidecar" --format "$FMT" 2>/dev/null) diff --git a/justfile.worktree b/justfile.worktree index bddc6d93..a94823ad 100644 --- a/justfile.worktree +++ b/justfile.worktree @@ -12,7 +12,7 @@ alias wt-attach := worktree-attach alias wt-stop := worktree-stop alias wt-clean := worktree-clean # NOTE: Cursor's native worktree UI does NOT work inside devcontainers (Feb 2026). -# These recipes provide a CLI-based alternative using tmux + cursor-agent. +# These recipes provide a CLI-based alternative using tmux + the claude CLI. # Native worktree support (.cursor/worktrees.json) works on macOS/Linux local only. # Tracked: https://forum.cursor.com/t/cursor-parallel-agents-in-wsl-devcontainers-misresolve-worktree-paths-and-context/145711 # =============================================================================== @@ -25,7 +25,7 @@ _wt_base := "../" + _wt_repo + "-worktrees" # START # ------------------------------------------------------------------------------- -# Create a worktree for an issue, open tmux session, launch cursor-agent +# Create a worktree for an issue, open tmux session, launch the claude CLI [group('worktree')] worktree-start issue prompt="" reviewer="": #!/usr/bin/env bash @@ -36,9 +36,9 @@ worktree-start issue prompt="" reviewer="": echo "[ERROR] tmux is not installed. Install it first." exit 1 fi - if ! command -v agent >/dev/null 2>&1; then - echo "[ERROR] cursor-agent CLI is not installed." - echo "Install: curl https://cursor.com/install -fsSL | bash" + if ! command -v claude >/dev/null 2>&1; then + echo "[ERROR] claude CLI is not installed." + echo "Install: npm install -g @anthropic-ai/claude-code" exit 1 fi if ! uv run resolve-branch --help >/dev/null 2>&1; then @@ -52,24 +52,6 @@ worktree-start issue prompt="" reviewer="": exit 1 fi - # Helper: ensure a directory is in cursor-agent's trustedDirectories - _wt_ensure_trust() { - local dir_abs - dir_abs=$(cd "$1" && pwd) - local cfg="${HOME}/.cursor/cli-config.json" - mkdir -p "$(dirname "$cfg")" - if [ ! -f "$cfg" ]; then - echo '{}' > "$cfg" - fi - if ! jq -e --arg d "$dir_abs" '.trustedDirectories // [] | index($d)' "$cfg" >/dev/null 2>&1; then - jq --arg d "$dir_abs" '.trustedDirectories = ((.trustedDirectories // []) + [$d])' "$cfg" > "${cfg}.tmp" \ - && mv "${cfg}.tmp" "$cfg" - echo "[OK] Trusted directory added: $dir_abs" - else - echo "[OK] Directory already trusted: $dir_abs" - fi - } - # Helper: read agent model from config _read_model() { local tier="$1" @@ -77,19 +59,19 @@ worktree-start issue prompt="" reviewer="": grep "^${tier}" "$cfg" | sed 's/.*= *"//' | sed 's/".*//' } - # Auth: check existing login first, then fall back to CURSOR_API_KEY - if agent status 2>/dev/null | grep -qi "logged in\|authenticated"; then - echo "[OK] cursor-agent: authenticated via browser login" - elif [ -n "${CURSOR_API_KEY:-}" ]; then - echo "[OK] cursor-agent: using CURSOR_API_KEY" + # Auth: check existing login first, then fall back to ANTHROPIC_API_KEY + if claude auth status 2>/dev/null | grep -qi "logged in\|authenticated"; then + echo "[OK] claude: authenticated via browser login" + elif [ -n "${ANTHROPIC_API_KEY:-}" ]; then + echo "[OK] claude: using ANTHROPIC_API_KEY" else - echo "[!] cursor-agent: not authenticated. Attempting browser login..." - if agent login; then - echo "[OK] cursor-agent: browser login successful" + echo "[!] claude: not authenticated. Attempting browser login..." + if claude auth login; then + echo "[OK] claude: browser login successful" else echo "[ERROR] Authentication failed. Either:" - echo " 1. Run 'agent login' to authenticate via browser, or" - echo " 2. Export CURSOR_API_KEY in your shell profile or .env" + echo " 1. Run 'claude auth login' to authenticate via browser, or" + echo " 2. Export ANTHROPIC_API_KEY in your shell profile or .env" exit 1 fi fi @@ -129,17 +111,15 @@ worktree-start issue prompt="" reviewer="": # Check if worktree already exists if [ -d "$WT_DIR" ]; then echo "[!] Worktree already exists at $WT_DIR" - _wt_ensure_trust "$WT_DIR" if tmux has-session -t "$SESSION" 2>/dev/null; then echo " tmux session '$SESSION' is running. Use: just worktree-attach $ISSUE" else echo " No tmux session found. Starting one..." if [ -n "$PROMPT" ]; then - tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --yolo --approve-mcps \"$PROMPT\"" + tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "claude --dangerously-skip-permissions \"$PROMPT\"" else - tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --approve-mcps" + tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "claude --dangerously-skip-permissions" fi - sleep 2 && tmux send-keys -t "$SESSION" "a" 2>/dev/null || true echo "[OK] tmux session '$SESSION' started. Use: just worktree-attach $ISSUE" fi exit 0 @@ -233,17 +213,14 @@ worktree-start issue prompt="" reviewer="": fi popd >/dev/null - # Ensure worktree directory is trusted by cursor-agent - _wt_ensure_trust "$WT_DIR" - # Start tmux session - # --yolo: auto-approve all shell commands (autonomous agent, no human at the terminal) + # --dangerously-skip-permissions: bypass all permission and MCP approval + # prompts (autonomous agent, no human at the terminal) if [ -n "$PROMPT" ]; then - tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --yolo --approve-mcps \"$PROMPT\"" + tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "claude --dangerously-skip-permissions \"$PROMPT\"" else - tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --approve-mcps" + tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "claude --dangerously-skip-permissions" fi - sleep 2 && tmux send-keys -t "$SESSION" "a" 2>/dev/null || true echo "" echo "[OK] Worktree created at $WT_DIR" @@ -307,23 +284,6 @@ worktree-attach issue: #!/usr/bin/env bash set -euo pipefail - _wt_ensure_trust() { - local dir_abs - dir_abs=$(cd "$1" && pwd) - local cfg="${HOME}/.cursor/cli-config.json" - mkdir -p "$(dirname "$cfg")" - if [ ! -f "$cfg" ]; then - echo '{}' > "$cfg" - fi - if ! jq -e --arg d "$dir_abs" '.trustedDirectories // [] | index($d)' "$cfg" >/dev/null 2>&1; then - jq --arg d "$dir_abs" '.trustedDirectories = ((.trustedDirectories // []) + [$d])' "$cfg" > "${cfg}.tmp" \ - && mv "${cfg}.tmp" "$cfg" - echo "[OK] Trusted directory added: $dir_abs" - else - echo "[OK] Directory already trusted: $dir_abs" - fi - } - ISSUE="{{ issue }}" SESSION="wt-${ISSUE}" WT_DIR="{{ _wt_base }}/${ISSUE}" @@ -331,14 +291,12 @@ worktree-attach issue: if ! tmux has-session -t "$SESSION" 2>/dev/null; then if [ -d "$WT_DIR" ]; then echo "[!] tmux session '$SESSION' stopped. Restarting..." - _wt_ensure_trust "$WT_DIR" REVIEWER=$(gh api user --jq '.login' 2>/dev/null || echo "") if [ -n "${WORKTREE_ATTACH_RESTART_CMD:-}" ]; then tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "$WORKTREE_ATTACH_RESTART_CMD" else - tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "agent chat --approve-mcps" + tmux new-session -d -s "$SESSION" -c "$WT_DIR" -e "PR_REVIEWER=$REVIEWER" "claude --dangerously-skip-permissions" fi - sleep 2 && tmux send-keys -t "$SESSION" "a" 2>/dev/null || true echo "[OK] tmux session '$SESSION' restarted" else echo "[ERROR] No tmux session '$SESSION' found." @@ -385,7 +343,7 @@ worktree-stop issue: # CLEAN # ------------------------------------------------------------------------------- -# Remove cursor-managed worktrees and tmux sessions. +# Remove managed worktrees and tmux sessions. # Default (no args): clean only stopped worktrees. Use 'all' to clean everything. [group('worktree')] worktree-clean mode="": diff --git a/package-lock.json b/package-lock.json index 4acc96d1..3ef4a600 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,11 +6,7 @@ "": { "name": "devcontainer-ci-deps", "dependencies": { - "@devcontainers/cli": "0.87.0", - "bats": "1.13.0", - "bats-assert": "github:bats-core/bats-assert#v2.2.4", - "bats-file": "github:bats-core/bats-file#v0.4.0", - "bats-support": "github:bats-core/bats-support#v0.3.0" + "@devcontainers/cli": "0.87.0" } }, "node_modules/@devcontainers/cli": { @@ -24,36 +20,6 @@ "engines": { "node": ">=20.0.0" } - }, - "node_modules/bats": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/bats/-/bats-1.13.0.tgz", - "integrity": "sha512-giSYKGTOcPZyJDbfbTtzAedLcNWdjCLbXYU3/MwPnjyvDXzu6Dgw8d2M+8jHhZXSmsCMSQqCp+YBsJ603UO4vQ==", - "license": "MIT", - "bin": { - "bats": "bin/bats" - } - }, - "node_modules/bats-assert": { - "version": "2.2.4", - "resolved": "git+ssh://git@github.com/bats-core/bats-assert.git#f1e9280eaae8f86cbe278a687e6ba755bc802c1a", - "integrity": "sha512-EcaY4Z+Tbz1c7pnC1SrVSq0epr7tLwFpz6qt7KUW9K8uSw8V12DTfH9d2HxZWvBEATaCuMsZ7KoZMFiSQPRoXw==", - "license": "CC0-1.0", - "peerDependencies": { - "bats": "0.4 || ^1", - "bats-support": "^0.3" - } - }, - "node_modules/bats-file": { - "version": "0.2.0", - "resolved": "git+ssh://git@github.com/bats-core/bats-file.git#13ad5e2ffcc360281432db3d43a306f7b3667d60", - "peerDependencies": { - "bats-support": "git+https://github.com/bats-core/bats-support.git#v0.3.0" - } - }, - "node_modules/bats-support": { - "version": "0.3.0", - "resolved": "git+ssh://git@github.com/bats-core/bats-support.git#24a72e14349690bcbf7c151b9d2d1cdd32d36eb1" } } } diff --git a/package.json b/package.json index 97d78123..592a6393 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,6 @@ "private": true, "description": "CI-only npm dependencies tracked by Dependabot", "dependencies": { - "@devcontainers/cli": "0.87.0", - "bats": "1.13.0", - "bats-support": "github:bats-core/bats-support#v0.3.0", - "bats-assert": "github:bats-core/bats-assert#v2.2.4", - "bats-file": "github:bats-core/bats-file#v0.4.0" + "@devcontainers/cli": "0.87.0" } } diff --git a/packages/vig-utils/README.md b/packages/vig-utils/README.md index d429913c..97d178d7 100644 --- a/packages/vig-utils/README.md +++ b/packages/vig-utils/README.md @@ -178,7 +178,7 @@ Examples: ```bash check-skill-names -check-skill-names .cursor/skills +check-skill-names .claude/skills ``` ### `setup-labels` diff --git a/packages/vig-utils/pyproject.toml b/packages/vig-utils/pyproject.toml index 1f4bd3ee..6c26c81f 100644 --- a/packages/vig-utils/pyproject.toml +++ b/packages/vig-utils/pyproject.toml @@ -8,7 +8,7 @@ version = "0.1.0" description = "Reusable CLI utilities for development workflows" readme = "README.md" license = "MIT" -requires-python = "==3.14.6" +requires-python = ">=3.14,<3.15" dependencies = ["rich"] authors = [ { name = "Carlos Vigo", email = "carlos.vigo@exoma.ch" }, @@ -27,6 +27,7 @@ resolve-branch = "vig_utils.resolve_branch:main" derive-branch-summary = "vig_utils.derive_branch_summary:main" check-skill-names = "vig_utils.check_skill_names:main" check-expirations = "vig_utils.check_expirations:main" +vulnix-gate = "vig_utils.vulnix_gate:main" setup-labels = "vig_utils.setup_labels:main" retry = "vig_utils.retry:main" renovate-changelog-pr = "vig_utils.renovate_changelog_pr:main" diff --git a/packages/vig-utils/src/vig_utils/shell/check-skill-names.sh b/packages/vig-utils/src/vig_utils/shell/check-skill-names.sh index 08e94c1a..b455fb79 100644 --- a/packages/vig-utils/src/vig_utils/shell/check-skill-names.sh +++ b/packages/vig-utils/src/vig_utils/shell/check-skill-names.sh @@ -3,13 +3,13 @@ # lowercase letters, digits, hyphens, and underscores. # # Usage: check-skill-names.sh [skills_dir] -# skills_dir Path to scan (default: .cursor/skills) +# skills_dir Path to scan (default: .claude/skills) # # Exit 0 if all names are valid, 1 if any are invalid. set -euo pipefail -skills_dir="${1:-.cursor/skills}" +skills_dir="${1:-.claude/skills}" if [[ ! -d "$skills_dir" ]]; then echo "Error: directory not found: $skills_dir" >&2 diff --git a/packages/vig-utils/src/vig_utils/shell/derive-branch-summary.sh b/packages/vig-utils/src/vig_utils/shell/derive-branch-summary.sh index 94f4ce02..cfcb6f62 100644 --- a/packages/vig-utils/src/vig_utils/shell/derive-branch-summary.sh +++ b/packages/vig-utils/src/vig_utils/shell/derive-branch-summary.sh @@ -4,7 +4,7 @@ # # Usage: derive-branch-summary.sh [NAMING_RULE] [MODEL_TIER] # TITLE: issue title -# NAMING_RULE: path to branch-naming.mdc (default: .cursor/rules/branch-naming.mdc) +# NAMING_RULE: path to the branch-naming skill (default: .claude/skills/branch-naming/SKILL.md) # MODEL_TIER: agent-models.toml tier (default: lightweight). Use standard for retry. # # Env: BRANCH_SUMMARY_CMD — override for tests (e.g. "echo test-summary") @@ -13,16 +13,28 @@ # DERIVE_BRANCH_TIMEOUT — timeout in seconds (default: 30). Use 2 for tests. set -euo pipefail +case "${1:-}" in +-h | --help) + cat <<'USAGE' +Usage: derive-branch-summary <TITLE> [NAMING_RULE] [MODEL_TIER] + TITLE issue title + NAMING_RULE path to the branch-naming skill (default: .claude/skills/branch-naming/SKILL.md) + MODEL_TIER agent-models.toml tier (default: lightweight; use standard for retry) +USAGE + exit 0 + ;; +esac + TITLE="${1:?Usage: derive-branch-summary.sh <TITLE> [NAMING_RULE] [MODEL_TIER]}" REPO_ROOT="$(git rev-parse --show-toplevel)" -NAMING_RULE="${2:-${REPO_ROOT}/.cursor/rules/branch-naming.mdc}" +NAMING_RULE="${2:-${REPO_ROOT}/.claude/skills/branch-naming/SKILL.md}" MODEL_TIER="${3:-${BRANCH_SUMMARY_MODEL:-lightweight}}" TIMEOUT="${DERIVE_BRANCH_TIMEOUT:-30}" if [ -n "${BRANCH_SUMMARY_CMD:-}" ]; then SUMMARY=$(timeout "$TIMEOUT" sh -c "$BRANCH_SUMMARY_CMD" 2>/dev/null | tail -1 | tr -d '[:space:]') || true else - MODEL=$(grep "^${MODEL_TIER}" "${REPO_ROOT}/.cursor/agent-models.toml" | sed 's/.*= *"//' | sed 's/".*//') + MODEL=$(grep "^${MODEL_TIER}" "${REPO_ROOT}/.claude/agent-models.toml" | sed 's/.*= *"//' | sed 's/".*//') SUMMARY=$(timeout "$TIMEOUT" agent --print --yolo --trust --model "$MODEL" \ "Read the branch naming rules in ${NAMING_RULE}. " \ "The issue title is: ${TITLE} " \ diff --git a/packages/vig-utils/src/vig_utils/vulnix_gate.py b/packages/vig-utils/src/vig_utils/vulnix_gate.py new file mode 100644 index 00000000..7bdd6a54 --- /dev/null +++ b/packages/vig-utils/src/vig_utils/vulnix_gate.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Gate HIGH/CRITICAL vulnix findings against an expiry-validated register. + +`vulnix` scans the Nix image's package closure (the flake `devcontainerImageEnv` +target) and emits JSON findings. This gate fails (exit 1) when any HIGH/CRITICAL +CVE — CVSS v3 base score >= threshold (default 7.0) — is *not* covered by a +non-expired entry in the exception register (`.vulnixignore`, the same +`Expiration: YYYY-MM-DD` format as `.trivyignore`). + +Register-entry expiry is enforced separately by `check-expirations` (pre-commit ++ CI); this gate additionally refuses to mask a finding with an already-expired +exception. Sub-threshold and unscored CVEs are awareness-only and never gate, +mirroring the Trivy `ignore-unfixed` posture. + +This is the objective go/no-go input for the publish-cutover (#637 → #639). + +Exit codes: + 0 — No unexcepted HIGH/CRITICAL findings + 1 — Missing/invalid input, or unexcepted HIGH/CRITICAL findings + +Usage: + vulnix-gate vulnix-findings.json + vulnix-gate vulnix-findings.json --register .vulnixignore --threshold 7.0 + +Refs: #637 +""" + +from __future__ import annotations + +import argparse +import json +import sys +from datetime import date +from pathlib import Path + +from vig_utils.check_expirations import parse_entries + +DEFAULT_REGISTER = ".vulnixignore" +DEFAULT_THRESHOLD = 7.0 # CVSS v3 HIGH starts at 7.0 + + +def excepted_cves(register: Path, *, today: date | None = None) -> set[str]: + """Return the CVE IDs that have a non-expired exception in *register*.""" + review_date = today or date.today() + return { + entry_id + for entry_id, expiration in parse_entries(register) + if review_date <= expiration + } + + +def blocking_findings( + items: list[dict], + *, + excepted: set[str], + threshold: float = DEFAULT_THRESHOLD, +) -> list[dict]: + """Return the unexcepted HIGH/CRITICAL findings in vulnix JSON *items*. + + Each returned dict is ``{pname, version, cve, score}``. A CVE blocks only + when its CVSS v3 base score is known and ``>= threshold`` and it is not in + *excepted*; sub-threshold and unscored CVEs are skipped (awareness only). + """ + blocking: list[dict] = [] + for item in items: + scores = item.get("cvssv3_basescore") or {} + for cve in item.get("affected_by") or []: + score = scores.get(cve) + if score is None or score < threshold: + continue # unscored or sub-threshold: awareness only + if cve in excepted: + continue + blocking.append( + { + "pname": item.get("pname", "?"), + "version": item.get("version", "?"), + "cve": cve, + "score": score, + } + ) + return blocking + + +def main(today: date | None = None) -> int: + parser = argparse.ArgumentParser( + description="Gate HIGH/CRITICAL vulnix findings against the exception register." + ) + parser.add_argument( + "findings", + type=Path, + help="vulnix --json output file to gate", + ) + parser.add_argument( + "-r", + "--register", + type=Path, + default=Path(DEFAULT_REGISTER), + help=f"Exception register ({DEFAULT_REGISTER} format). Default: {DEFAULT_REGISTER}", + ) + parser.add_argument( + "-t", + "--threshold", + type=float, + default=DEFAULT_THRESHOLD, + help=f"Minimum CVSS v3 base score to gate on. Default: {DEFAULT_THRESHOLD}", + ) + args = parser.parse_args() + + if not args.findings.is_file(): + print(f"::error::{args.findings} not found", file=sys.stderr) + return 1 + + try: + items = json.loads(args.findings.read_text(encoding="utf-8")) + except (ValueError, OSError) as exc: + print(f"::error::failed to read {args.findings}: {exc}", file=sys.stderr) + return 1 + + try: + excepted = ( + excepted_cves(args.register, today=today) + if args.register.is_file() + else set() + ) + except ValueError as exc: + print(f"::error::{exc}", file=sys.stderr) + return 1 + + blocking = blocking_findings(items, excepted=excepted, threshold=args.threshold) + + if blocking: + print( + f"::error::{len(blocking)} unexcepted HIGH/CRITICAL vulnix finding(s) " + f"(CVSS >= {args.threshold}):", + file=sys.stderr, + ) + for finding in sorted(blocking, key=lambda f: (-f["score"], f["cve"])): + print( + f"::error:: - {finding['cve']} (CVSS {finding['score']}) " + f"in {finding['pname']} {finding['version']}", + file=sys.stderr, + ) + return 1 + + print( + f"No unexcepted HIGH/CRITICAL findings (CVSS >= {args.threshold}); " + f"{len(excepted)} exception(s) applied" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/vig-utils/tests/test_check_pr_agent_fingerprints.py b/packages/vig-utils/tests/test_check_pr_agent_fingerprints.py index 73a3c7b2..02561f61 100644 --- a/packages/vig-utils/tests/test_check_pr_agent_fingerprints.py +++ b/packages/vig-utils/tests/test_check_pr_agent_fingerprints.py @@ -1,8 +1,40 @@ """Tests for vig_utils.check_pr_agent_fingerprints.""" +from pathlib import Path from unittest.mock import patch +import pytest from vig_utils import check_pr_agent_fingerprints +from vig_utils.utils import ( + agent_blocklist_path, + contains_agent_fingerprint, + load_blocklist, +) + + +class TestCanonicalBlocklist: + """Guard the org 'never name an AI in history' control against regressions. + + The canonical ``.github/agent-blocklist.toml`` must keep rejecting BOTH the + legacy ``cursor`` identity and the current ``claude`` identity. These tests + exercise the real blocklist file (not a mock) so dropping either ``names`` + entry — e.g. while removing the Cursor toolchain — fails CI. + """ + + @pytest.fixture + def blocklist(self) -> dict: + path = agent_blocklist_path(start=Path(__file__)) + assert path.exists(), f"canonical blocklist missing at {path}" + return load_blocklist(path) + + @pytest.mark.parametrize("identity", ["cursor", "claude"]) + def test_identity_blocked_in_commit_message(self, blocklist, identity): + content = f"feat: add thing\n\nGenerated by {identity.capitalize()}" + assert contains_agent_fingerprint(content, blocklist) == identity + + def test_canonical_blocklist_lists_cursor_and_claude(self, blocklist): + assert "cursor" in blocklist["names"] + assert "claude" in blocklist["names"] class TestMain: diff --git a/packages/vig-utils/tests/test_claude_ssot.py b/packages/vig-utils/tests/test_claude_ssot.py new file mode 100644 index 00000000..7aae6a29 --- /dev/null +++ b/packages/vig-utils/tests/test_claude_ssot.py @@ -0,0 +1,62 @@ +"""Guards for the .claude/ single-source-of-truth migration (Refs: #626). + +After migrating agent rules and skills from .cursor/ to .claude/, no tracked +file outside the downstream workspace template (assets/workspace/, owned by +#629) may reference the old .cursor/skills/ path, and the root .cursor/ +directory must no longer exist. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[3] + +# Append-only archival snapshots of past issues/PRs. They record the historical +# text of issues/PRs verbatim (which legitimately quoted .cursor/ paths at the +# time) and are never rewritten, like dated CHANGELOG entries. +_ARCHIVAL_PREFIXES = ( + "assets/workspace/", # downstream template, migrated under #629 + "docs/issues/", + "docs/pull-requests/", + "docs/plans/", +) + +# Released CHANGELOG entries are append-only history (never rewritten) and may +# reference paths that were current at the time of release. This guard module +# itself must contain the search literal to do its job. +_THIS_FILE_REL = "packages/vig-utils/tests/test_claude_ssot.py" +_ARCHIVAL_FILES = ("CHANGELOG.md", _THIS_FILE_REL) + + +def _tracked_files() -> list[str]: + result = subprocess.run( + ["git", "ls-files"], + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=True, + ) + return [line for line in result.stdout.splitlines() if line] + + +def test_no_tracked_file_references_cursor_skills() -> None: + """No tracked file (outside the workspace template) references .cursor/skills/.""" + offenders: list[str] = [] + for rel in _tracked_files(): + if rel.startswith(_ARCHIVAL_PREFIXES) or rel in _ARCHIVAL_FILES: + continue + path = REPO_ROOT / rel + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError, FileNotFoundError: + continue + if ".cursor/skills/" in text: + offenders.append(rel) + assert not offenders, f"Files still reference .cursor/skills/: {offenders}" + + +def test_root_cursor_dir_deleted() -> None: + """The root .cursor/ directory is removed; .claude/ is the SSoT.""" + assert not (REPO_ROOT / ".cursor").exists(), "root .cursor/ should be deleted" diff --git a/packages/vig-utils/tests/test_shell_entrypoints.py b/packages/vig-utils/tests/test_shell_entrypoints.py index d8dcebe6..3d2e2f3c 100644 --- a/packages/vig-utils/tests/test_shell_entrypoints.py +++ b/packages/vig-utils/tests/test_shell_entrypoints.py @@ -69,16 +69,16 @@ def test_check_skill_names_reports_all_invalid_names(tmp_path: Path) -> None: def test_check_skill_names_passes_for_repo_skills_dir() -> None: - result = _run(["check-skill-names", ".cursor/skills"]) + result = _run(["check-skill-names", ".claude/skills"]) assert result.returncode == 0, result.stderr def test_check_skill_names_canary_invalid_repo_skill_is_detected() -> None: - canary_dir = REPO_ROOT / ".cursor/skills/bad:canary" + canary_dir = REPO_ROOT / ".claude/skills/bad:canary" canary_dir.mkdir(parents=True) try: - result = _run(["check-skill-names", ".cursor/skills"]) + result = _run(["check-skill-names", ".claude/skills"]) finally: canary_dir.rmdir() @@ -158,3 +158,11 @@ def test_derive_branch_summary_accepts_optional_model_tier_arg() -> None: assert result.returncode == 0 assert result.stdout.strip() == "retry-summary" + + +@pytest.mark.parametrize("flag", ["-h", "--help"]) +def test_derive_branch_summary_help_exits_zero(flag: str) -> None: + result = _run(["derive-branch-summary", flag]) + + assert result.returncode == 0, result.stderr + assert "Usage" in result.stdout diff --git a/packages/vig-utils/tests/test_utils.py b/packages/vig-utils/tests/test_utils.py index 46008849..f497c5ba 100644 --- a/packages/vig-utils/tests/test_utils.py +++ b/packages/vig-utils/tests/test_utils.py @@ -475,7 +475,7 @@ def test_contains_fingerprint_strips_allow_patterns(self): "allow_patterns": [re.compile(r"\.[a-zA-Z][\w-]*/[\w./-]*")], } assert ( - contains_agent_fingerprint("See .cursor/skills/ for details", blocklist) + contains_agent_fingerprint("See .claude/skills/ for details", blocklist) is None ) @@ -491,7 +491,7 @@ def test_contains_fingerprint_strips_allow_then_catches_attribution(self): } assert ( contains_agent_fingerprint( - "See .cursor/skills/ for docs generated by Cursor", blocklist + "See .claude/skills/ for docs generated by Cursor", blocklist ) == "cursor" ) diff --git a/packages/vig-utils/tests/test_vulnix_gate.py b/packages/vig-utils/tests/test_vulnix_gate.py new file mode 100644 index 00000000..a7e2197a --- /dev/null +++ b/packages/vig-utils/tests/test_vulnix_gate.py @@ -0,0 +1,164 @@ +"""Tests for vig_utils.vulnix_gate.""" + +from __future__ import annotations + +import json +import sys +from datetime import date +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + +from vig_utils.vulnix_gate import ( + blocking_findings, + excepted_cves, + main, +) + +# A trimmed vulnix --json item (shape confirmed against vulnix 1.12). +HIGH = { + "pname": "curl", + "version": "8.14.1", + "derivation": "/nix/store/x-curl-8.14.1", + "affected_by": ["CVE-2026-3805", "CVE-2026-3783"], + "whitelisted": [], + "cvssv3_basescore": {"CVE-2026-3805": 7.5, "CVE-2026-3783": 5.3}, +} +CRITICAL = { + "pname": "openssl", + "version": "3.0.0", + "derivation": "/nix/store/x-openssl", + "affected_by": ["CVE-2099-0001"], + "whitelisted": [], + "cvssv3_basescore": {"CVE-2099-0001": 9.8}, +} +UNSCORED = { + "pname": "crun", + "version": "1.21", + "derivation": "/nix/store/x-crun", + "affected_by": ["CVE-2026-30892"], + "whitelisted": [], + "cvssv3_basescore": {}, +} +LOW = { + "pname": "busybox", + "version": "1.36.1", + "derivation": "/nix/store/x-busybox", + "affected_by": ["CVE-2025-46394"], + "whitelisted": [], + "cvssv3_basescore": {"CVE-2025-46394": 3.2}, +} + + +class TestExceptedCves: + def test_non_expired_entries_are_excepted(self, tmp_path: Path): + path = tmp_path / ".vulnixignore" + path.write_text( + "Expiration: 2099-01-01\nCVE-2026-3805\nCVE-2099-0001\n", + encoding="utf-8", + ) + assert excepted_cves(path, today=date(2026, 6, 23)) == { + "CVE-2026-3805", + "CVE-2099-0001", + } + + def test_expired_entries_do_not_mask(self, tmp_path: Path): + # Expiry is enforced separately by check-expirations; an expired + # exception must NOT silently keep masking a HIGH finding here. + path = tmp_path / ".vulnixignore" + path.write_text( + "Expiration: 2020-01-01\nCVE-2026-3805\n", + encoding="utf-8", + ) + assert excepted_cves(path, today=date(2026, 6, 23)) == set() + + def test_empty_register_yields_no_exceptions(self, tmp_path: Path): + path = tmp_path / ".vulnixignore" + path.write_text("# only comments\n", encoding="utf-8") + assert excepted_cves(path, today=date(2026, 6, 23)) == set() + + +class TestBlockingFindings: + def test_high_unexcepted_is_blocking(self): + result = blocking_findings([HIGH], excepted=set()) + cves = {f["cve"] for f in result} + assert "CVE-2026-3805" in cves + # the MEDIUM CVE on the same derivation is not blocking + assert "CVE-2026-3783" not in cves + + def test_critical_is_blocking(self): + result = blocking_findings([CRITICAL], excepted=set()) + assert {f["cve"] for f in result} == {"CVE-2099-0001"} + + def test_excepted_high_is_not_blocking(self): + result = blocking_findings([HIGH], excepted={"CVE-2026-3805"}) + assert result == [] + + def test_low_and_unscored_are_not_blocking(self): + # < threshold and unknown-severity CVEs are awareness-only, never gate. + result = blocking_findings([LOW, UNSCORED], excepted=set()) + assert result == [] + + def test_threshold_is_configurable(self): + result = blocking_findings([LOW], excepted=set(), threshold=3.0) + assert {f["cve"] for f in result} == {"CVE-2025-46394"} + + +class TestMain: + def _write_findings(self, tmp_path: Path, items: list[dict]): + path = tmp_path / "vulnix.json" + path.write_text(json.dumps(items), encoding="utf-8") + return path + + def _run(self, argv: list[str], today: date) -> int: + orig = sys.argv + try: + sys.argv = ["vulnix-gate", *argv] + return main(today=today) + finally: + sys.argv = orig + + def test_passes_when_no_blocking_findings( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ): + findings = self._write_findings(tmp_path, [LOW, UNSCORED]) + register = tmp_path / ".vulnixignore" + register.write_text("# none\n", encoding="utf-8") + code = self._run( + [str(findings), "--register", str(register)], date(2026, 6, 23) + ) + assert code == 0 + assert "No unexcepted HIGH/CRITICAL" in capsys.readouterr().out + + def test_fails_on_unexcepted_high( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ): + findings = self._write_findings(tmp_path, [HIGH]) + register = tmp_path / ".vulnixignore" + register.write_text("# none\n", encoding="utf-8") + code = self._run( + [str(findings), "--register", str(register)], date(2026, 6, 23) + ) + assert code == 1 + assert "CVE-2026-3805" in capsys.readouterr().err + + def test_passes_when_high_is_excepted(self, tmp_path: Path): + findings = self._write_findings(tmp_path, [HIGH]) + register = tmp_path / ".vulnixignore" + register.write_text("Expiration: 2099-01-01\nCVE-2026-3805\n", encoding="utf-8") + code = self._run( + [str(findings), "--register", str(register)], date(2026, 6, 23) + ) + assert code == 0 + + def test_missing_findings_file_fails(self, tmp_path: Path): + register = tmp_path / ".vulnixignore" + register.write_text("# none\n", encoding="utf-8") + code = self._run( + [str(tmp_path / "missing.json"), "--register", str(register)], + date(2026, 6, 23), + ) + assert code == 1 diff --git a/pyproject.toml b/pyproject.toml index 5a4fe5c9..d31a5376 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,10 @@ name = "devcontainer" version = "0.1.1" description = "vigOS development environment" -requires-python = "==3.14.6" +# Range, not an exact pin: the Nix toolchain pins the exact interpreter via +# flake.lock (nixos-26.05 ships CPython 3.14.x), so an `==` pin is both +# redundant and unsatisfiable against nixpkgs. Refs #666. +requires-python = ">=3.14,<3.15" dependencies = [ "github-backup==0.63.0", "jinja2==3.1.6", @@ -23,16 +26,10 @@ dev = [ devcontainer = [ "rich==15.0.0", "pre-commit==4.6.0", - "ruff==0.15.18", - "pip-licenses==5.5.5", - "bandit[toml]==1.9.4", -] -lint = [ - "pre-commit==4.6.0", - "ruff==0.15.18", "pip-licenses==5.5.5", "bandit[toml]==1.9.4", ] +lint = ["pre-commit==4.6.0", "pip-licenses==5.5.5", "bandit[toml]==1.9.4"] test = [ "pytest==9.1.1", "pytest-cov==7.1.0", @@ -57,8 +54,8 @@ vig-utils = { path = "packages/vig-utils", editable = true } # Black-compatible line length line-length = 88 -# Target Python 3.12 -target-version = "py312" +# Target Python 3.14 +target-version = "py314" [tool.ruff.lint] diff --git a/renovate.json b/renovate.json index e3b1d8b9..938ac61c 100644 --- a/renovate.json +++ b/renovate.json @@ -3,13 +3,17 @@ "extends": [ "github>vig-os/devcontainer//assets/workspace/.github/renovate-default" ], - "enabledManagers": ["github-actions", "pep621", "npm", "dockerfile"], + "enabledManagers": ["github-actions", "pep621", "npm", "nix"], + "lockFileMaintenance": { + "enabled": true, + "schedule": ["before 9am on monday"] + }, "packageRules": [ { - "description": "Dockerfile / Containerfile", - "matchManagers": ["dockerfile"], + "description": "Nix flake.lock — bump flake inputs through the normal PR/CI gate", + "matchManagers": ["nix"], "semanticCommitType": "build", - "semanticCommitScope": "docker" + "semanticCommitScope": "nix" } ] } diff --git a/scripts/build.sh b/scripts/build.sh deleted file mode 100755 index 1e59363d..00000000 --- a/scripts/build.sh +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env bash -# Build container image -# Usage: build.sh [--no-cache] <version> <repo> [NATIVE_PLATFORM] -# -# This script prepares and builds a container image using podman: -# - Calls prepare-build.sh to prepare the build directory -# - Builds the container image using podman - -set -e - -echo "🔍 DEBUG: Script started" -echo "🔍 DEBUG: Raw arguments: $*" - -# Optional flag: --no-cache (must be first arg to keep positional semantics) -NO_CACHE=0 -if [ "${1:-}" = "--no-cache" ]; then - NO_CACHE=1 - shift - echo "🔍 DEBUG: --no-cache flag detected" -fi - -VERSION="${1:-dev}" -REPO="${2:-ghcr.io/vig-os/devcontainer}" -echo "🔍 DEBUG: VERSION='$VERSION'" -echo "🔍 DEBUG: REPO='$REPO'" - -# Detect native platform -NATIVE_ARCH=$(uname -m) -echo "🔍 DEBUG: Detected architecture: $NATIVE_ARCH" - -if [ "$NATIVE_ARCH" = "arm64" ] || [ "$NATIVE_ARCH" = "aarch64" ]; then - NATIVE_PLATFORM="${3:-linux/arm64}" -else - NATIVE_PLATFORM="${3:-linux/amd64}" -fi -echo "🔍 DEBUG: NATIVE_PLATFORM='$NATIVE_PLATFORM'" - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -echo "🔍 DEBUG: SCRIPT_DIR='$SCRIPT_DIR'" -echo "🔍 DEBUG: PROJECT_ROOT='$PROJECT_ROOT'" - -cd "$PROJECT_ROOT" -echo "🔍 DEBUG: Changed to PROJECT_ROOT" - -BUILD_DIR="build" -BUILD_VERSION="$VERSION" -BUILD_DATE="" -VCS_REF="" -echo "🔍 DEBUG: BUILD_DIR='$BUILD_DIR'" -echo "🔍 DEBUG: BUILD_VERSION='$BUILD_VERSION'" -echo "🔍 DEBUG: BUILD_DATE='$BUILD_DATE'" -echo "🔍 DEBUG: VCS_REF='$VCS_REF'" - -echo "Building $REPO:$VERSION..." - -# Prepare build directory -echo "Preparing build directory..." -"$SCRIPT_DIR/prepare-build.sh" "$VERSION" - -# Build the image from build folder -echo "Building image from build folder..." -echo "🔍 DEBUG: Running podman build with:" -echo "🔍 DEBUG: Platform: $NATIVE_PLATFORM" -echo "🔍 DEBUG: BUILD_DATE: $BUILD_DATE" -echo "🔍 DEBUG: VCS_REF: $VCS_REF" -echo "🔍 DEBUG: IMAGE_TAG: $BUILD_VERSION" -echo "🔍 DEBUG: Tag: $REPO:$BUILD_VERSION" -echo "🔍 DEBUG: Containerfile: $BUILD_DIR/Containerfile" -echo "🔍 DEBUG: Build context: $BUILD_DIR" -if [ "$NO_CACHE" -eq 1 ]; then - echo "🔍 DEBUG: No cache: enabled" -fi - -BUILD_CACHE_ARGS=() -if [ "$NO_CACHE" -eq 1 ]; then - BUILD_CACHE_ARGS+=(--no-cache) -fi - -if ! podman build --platform "$NATIVE_PLATFORM" \ - "${BUILD_CACHE_ARGS[@]}" \ - --build-arg BUILD_DATE="$BUILD_DATE" \ - --build-arg VCS_REF="$VCS_REF" \ - --build-arg IMAGE_TAG="$BUILD_VERSION" \ - -t "$REPO:$BUILD_VERSION" \ - -f "$BUILD_DIR/Containerfile" \ - "$BUILD_DIR"; then - BUILD_EXIT_CODE=$? - echo "❌ Build failed" - echo "🔍 DEBUG: Podman build command failed with exit code $BUILD_EXIT_CODE" - exit 1 -fi - -echo "🔍 DEBUG: Podman build completed successfully" -echo "✓ Built local development image $REPO:$BUILD_VERSION ($NATIVE_PLATFORM)" diff --git a/scripts/init.sh b/scripts/init.sh index 810af223..8fb59327 100755 --- a/scripts/init.sh +++ b/scripts/init.sh @@ -1,24 +1,22 @@ #!/usr/bin/env bash ############################################################################### -# init.sh - Development Environment Initializer +# init.sh - Nix-first development environment bootstrapper # -# Checks and installs development dependencies from requirements.yaml. -# Provides OS-sensitive installation with interactive confirmations. +# This repo's toolchain is defined by the Nix flake (`flake.nix` devTools) and +# provisioned by `direnv allow` (recommended) or `nix develop`. This script does +# NOT install tools. It: +# 1. Gates on the host prerequisites (Nix, and direnv unless --no-direnv). +# 2. Confirms the dev-shell toolchain is on PATH. +# 3. Performs one-time, idempotent project bootstrap (uv sync, git hooks, +# commit template, pre-commit) and advisory host checks (podman, gh). # # USAGE: -# ./scripts/init.sh # Interactive mode -# ./scripts/init.sh --check # Check only, don't install -# ./scripts/init.sh --yes # Auto-confirm all installations +# ./scripts/init.sh # Gate prerequisites, then bootstrap the project +# ./scripts/init.sh --check # Verify prerequisites only; do not bootstrap +# ./scripts/init.sh --no-direnv # Don't require direnv (using `nix develop`) # ./scripts/init.sh --help # Show this help # -# REQUIREMENTS FILE: -# scripts/requirements.yaml # Single source of truth for dependencies -# -# SUPPORTED PLATFORMS: -# - macOS (Homebrew) -# - Debian/Ubuntu (apt) -# - Fedora/RHEL (dnf) -# - Alpine (apk) +# TOOLCHAIN: see `flake.nix` (devTools) — the single source of truth. ############################################################################### set -euo pipefail @@ -27,11 +25,6 @@ set -euo pipefail # CONFIGURATION # ═══════════════════════════════════════════════════════════════════════════════ -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -REQUIREMENTS_FILE="$SCRIPT_DIR/requirements.yaml" -PYTHON_VERSION="3.12.10" - # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' @@ -43,19 +36,12 @@ BOLD='\033[1m' # Flags CHECK_ONLY=false -AUTO_YES=false -VERBOSE=false +REQUIRE_DIRENV=true # ═══════════════════════════════════════════════════════════════════════════════ # HELPER FUNCTIONS # ═══════════════════════════════════════════════════════════════════════════════ -print_header() { - echo -e "\n${BOLD}${BLUE}═══════════════════════════════════════════════════════════════${NC}" - echo -e "${BOLD}${BLUE} $1${NC}" - echo -e "${BOLD}${BLUE}═══════════════════════════════════════════════════════════════${NC}\n" -} - print_section() { echo -e "\n${BOLD}${CYAN}───────────────────────────────────────────────────────────────${NC}" echo -e "${BOLD}${CYAN} $1${NC}" @@ -75,605 +61,212 @@ log_warning() { } log_error() { - echo -e "${RED}✗${NC} $1" + echo -e "${RED}✗${NC} $1" >&2 } -log_debug() { - if $VERBOSE; then - echo -e "${CYAN}…${NC} $1" - fi -} - -# Prompt for yes/no confirmation -# Usage: confirm "Question?" && do_something -confirm() { - if $AUTO_YES; then - return 0 - fi - - local prompt="$1 [y/N]: " - local response +usage() { + cat <<'EOF' +init.sh - Nix-first development environment bootstrapper - echo -en "${YELLOW}?${NC} ${prompt}" - read -r response +USAGE: + ./scripts/init.sh Gate prerequisites, then bootstrap the project + ./scripts/init.sh --check Verify prerequisites only; do not bootstrap + ./scripts/init.sh --no-direnv Don't require direnv (using `nix develop`) + ./scripts/init.sh --help Show this help - case "$response" in - [yY][eE][sS]|[yY]) return 0 ;; - *) return 1 ;; - esac +The toolchain is provisioned by the Nix flake, not by this script. Enter the dev +shell with `direnv allow` (recommended) or `nix develop`, then run `just init`. +EOF } # ═══════════════════════════════════════════════════════════════════════════════ -# OS DETECTION +# PREREQUISITE GUIDANCE # ═══════════════════════════════════════════════════════════════════════════════ -detect_os() { - local os_type="" - local os_id="" - - case "$(uname -s)" in - Darwin) - os_type="macos" - ;; - Linux) - if [ -f /etc/os-release ]; then - # shellcheck source=/dev/null - . /etc/os-release - os_id="${ID:-unknown}" - - case "$os_id" in - debian|ubuntu|pop|linuxmint|elementary) - os_type="debian" - ;; - fedora|rhel|centos|rocky|alma) - os_type="fedora" - ;; - alpine) - os_type="alpine" - ;; - arch|manjaro) - os_type="arch" - ;; - *) - os_type="linux" - ;; - esac - else - os_type="linux" - fi - ;; - *) - os_type="unknown" - ;; - esac +# NOTE: these print with the printf builtin (not `cat`) so the gate still works +# when PATH carries no external tools — the whole point of the gate. +print_nix_guidance() { + log_error "Nix is required but was not found on PATH." + printf '%s\n' \ + '' \ + " This repository's toolchain is provided by the Nix flake. Install Nix, then" \ + ' re-enter the project to get every tool automatically.' \ + '' \ + ' 1. Install Nix:' \ + ' https://nixos.org/download' \ + '' \ + ' 2. Enable flakes — add to ~/.config/nix/nix.conf (or /etc/nix/nix.conf):' \ + ' experimental-features = nix-command flakes' \ + '' \ + ' 3. Add the vig-os binary cache so the dev-shell is a fast fetch, not a build:' \ + ' substituters = https://cache.nixos.org https://vig-os.cachix.org' \ + ' trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= vig-os.cachix.org-1:yoOYRi3bvnM6ThxO0joLt7vtzhTfkq3r6jykeUMg7Bk=' \ + '' \ + ' 4. Then enter the dev shell and re-run:' \ + ' direnv allow # (recommended) or: nix develop' \ + ' just init' \ + '' +} - echo "$os_type" +print_devshell_guidance() { + log_error "The dev-shell toolchain is not on PATH (uv was not found)." + printf '%s\n' \ + '' \ + ' Enter the Nix dev shell first, then re-run "just init":' \ + ' direnv allow # (recommended) or: nix develop' \ + '' } -get_os_pretty_name() { - case "$(uname -s)" in - Darwin) - echo "macOS $(sw_vers -productVersion 2>/dev/null || echo '')" +# ═══════════════════════════════════════════════════════════════════════════════ +# ARGUMENT PARSING +# ═══════════════════════════════════════════════════════════════════════════════ + +while [[ $# -gt 0 ]]; do + case "$1" in + --check | -c) + CHECK_ONLY=true + shift + ;; + --no-direnv) + REQUIRE_DIRENV=false + shift ;; - Linux) - if [ -f /etc/os-release ]; then - # shellcheck source=/dev/null - . /etc/os-release - echo "${PRETTY_NAME:-Linux}" - else - echo "Linux" - fi + --help | -h) + usage + exit 0 ;; *) - echo "Unknown OS" + log_error "Unknown option: $1" + echo "Use --help for usage information." >&2 + exit 1 ;; esac -} +done # ═══════════════════════════════════════════════════════════════════════════════ -# YAML PARSING (Pure Bash - no external dependencies) +# CONTAINER SHORT-CIRCUIT # ═══════════════════════════════════════════════════════════════════════════════ -# Simple YAML parser for our requirements.yaml format -# Extracts dependency information into associative arrays -parse_requirements() { - local yaml_file="$1" - local in_dependencies=false - local in_optional=false - local current_section="" - local multiline_install_field="" - - # Reset global arrays - DEPS_NAMES=() - DEPS_VERSIONS=() - DEPS_PURPOSES=() - DEPS_REQUIRED=() - DEPS_CHECK_CMDS=() - DEPS_INSTALL_MACOS=() - DEPS_INSTALL_DEBIAN=() - DEPS_INSTALL_FEDORA=() - DEPS_INSTALL_ALPINE=() - DEPS_INSTALL_ALL=() - DEPS_INSTALL_MANUAL=() - - local current_name="" - local current_version="" - local current_purpose="" - local current_required="true" - local current_check_cmd="" - local current_install_macos="" - local current_install_debian="" - local current_install_fedora="" - local current_install_alpine="" - local current_install_all="" - local current_install_manual="" - - while IFS= read -r line || [ -n "$line" ]; do - # Handle multiline install commands (YAML block scalar, e.g. "debian: |") - if [ -n "$multiline_install_field" ]; then - if [[ "$line" =~ ^[[:space:]]{8}(.*)$ ]]; then - if [ -n "${!multiline_install_field}" ]; then - printf -v "$multiline_install_field" '%s\n%s' "${!multiline_install_field}" "${BASH_REMATCH[1]}" - else - printf -v "$multiline_install_field" '%s' "${BASH_REMATCH[1]}" - fi - continue - fi - multiline_install_field="" - fi - - # Skip comments and empty lines - [[ "$line" =~ ^[[:space:]]*# ]] && continue - [[ -z "${line// /}" ]] && continue - - # Detect section starts - if [[ "$line" =~ ^dependencies: ]]; then - in_dependencies=true - in_optional=false - continue - elif [[ "$line" =~ ^optional: ]]; then - in_dependencies=false - in_optional=true - continue - fi - - # Process dependencies - if $in_dependencies || $in_optional; then - # New dependency entry (starts with " - name:") - if [[ "$line" =~ ^[[:space:]]{2}-[[:space:]]name:[[:space:]]*(.+) ]]; then - # Save previous dependency if exists - if [ -n "$current_name" ]; then - DEPS_NAMES+=("$current_name") - DEPS_VERSIONS+=("$current_version") - DEPS_PURPOSES+=("$current_purpose") - DEPS_REQUIRED+=("$current_required") - DEPS_CHECK_CMDS+=("$current_check_cmd") - DEPS_INSTALL_MACOS+=("$current_install_macos") - DEPS_INSTALL_DEBIAN+=("$current_install_debian") - DEPS_INSTALL_FEDORA+=("$current_install_fedora") - DEPS_INSTALL_ALPINE+=("$current_install_alpine") - DEPS_INSTALL_ALL+=("$current_install_all") - DEPS_INSTALL_MANUAL+=("$current_install_manual") - fi - - # Reset for new dependency - current_name="${BASH_REMATCH[1]}" - current_version="" - current_purpose="" - current_required="$($in_optional && echo "false" || echo "true")" - current_check_cmd="" - current_install_macos="" - current_install_debian="" - current_install_fedora="" - current_install_alpine="" - current_install_all="" - current_install_manual="" - current_section="" - continue - fi - - # Parse dependency fields - if [[ "$line" =~ ^[[:space:]]{4}version:[[:space:]]*[\"\']?([^\"\']*)[\"\']? ]]; then - current_version="${BASH_REMATCH[1]}" - elif [[ "$line" =~ ^[[:space:]]{4}purpose:[[:space:]]*(.+) ]]; then - current_purpose="${BASH_REMATCH[1]}" - elif [[ "$line" =~ ^[[:space:]]{4}required:[[:space:]]*(true|false) ]]; then - current_required="${BASH_REMATCH[1]}" - elif [[ "$line" =~ ^[[:space:]]{4}check: ]]; then - current_section="check" - elif [[ "$line" =~ ^[[:space:]]{4}install: ]]; then - current_section="install" - elif [[ "$line" =~ ^[[:space:]]{6}command:[[:space:]]*(.+) ]] && [ "$current_section" = "check" ]; then - current_check_cmd="${BASH_REMATCH[1]}" - elif [[ "$line" =~ ^[[:space:]]{6}(macos|debian|fedora|alpine|all):[[:space:]]*(.*)$ ]] && [ "$current_section" = "install" ]; then - local install_key="${BASH_REMATCH[1]}" - local install_value="${BASH_REMATCH[2]}" - local target_var="" - - case "$install_key" in - macos) target_var="current_install_macos" ;; - debian) target_var="current_install_debian" ;; - fedora) target_var="current_install_fedora" ;; - alpine) target_var="current_install_alpine" ;; - all) target_var="current_install_all" ;; - esac - - local scalar_marker - scalar_marker="$(echo "$install_value" | tr -d '[:space:]')" - if [ "$scalar_marker" = "|" ] || [ "$scalar_marker" = "|-" ] || [ "$scalar_marker" = "|+" ] || [ "$scalar_marker" = ">" ] || [ "$scalar_marker" = ">-" ] || [ "$scalar_marker" = ">+" ]; then - printf -v "$target_var" '%s' "" - multiline_install_field="$target_var" - else - printf -v "$target_var" '%s' "$install_value" - fi - elif [[ "$line" =~ ^[[:space:]]{6}manual:[[:space:]]*(.+) ]] && [ "$current_section" = "install" ]; then - current_install_manual="${BASH_REMATCH[1]}" - fi - fi - done < "$yaml_file" - - # Save last dependency - if [ -n "$current_name" ]; then - DEPS_NAMES+=("$current_name") - DEPS_VERSIONS+=("$current_version") - DEPS_PURPOSES+=("$current_purpose") - DEPS_REQUIRED+=("$current_required") - DEPS_CHECK_CMDS+=("$current_check_cmd") - DEPS_INSTALL_MACOS+=("$current_install_macos") - DEPS_INSTALL_DEBIAN+=("$current_install_debian") - DEPS_INSTALL_FEDORA+=("$current_install_fedora") - DEPS_INSTALL_ALPINE+=("$current_install_alpine") - DEPS_INSTALL_ALL+=("$current_install_all") - DEPS_INSTALL_MANUAL+=("$current_install_manual") - fi -} +# The built devcontainer image already bakes the toolchain, the project venv, +# and the pre-commit cache — there is nothing to bootstrap. +if [ "${IN_CONTAINER:-}" = "true" ]; then + log_success "Running inside the devcontainer image — already provisioned. Nothing to do." + exit 0 +fi # ═══════════════════════════════════════════════════════════════════════════════ -# DEPENDENCY CHECKING & INSTALLATION +# PREREQUISITE GATE (pure shell builtins only — runs before any external tool) # ═══════════════════════════════════════════════════════════════════════════════ -check_dependency() { - local check_cmd="$1" - - if [ -z "$check_cmd" ]; then - return 1 - fi - - # Execute check command in subshell - if bash -c "$check_cmd" >/dev/null 2>&1; then - return 0 - else - return 1 - fi -} - -get_install_command() { - local os_type="$1" - local idx="$2" - - local install_cmd="" +if ! command -v nix >/dev/null 2>&1; then + print_nix_guidance + exit 1 +fi - # Check for 'all' platforms first - if [ -n "${DEPS_INSTALL_ALL[$idx]:-}" ]; then - install_cmd="${DEPS_INSTALL_ALL[$idx]}" - else - case "$os_type" in - macos) - install_cmd="${DEPS_INSTALL_MACOS[$idx]:-}" - ;; - debian) - install_cmd="${DEPS_INSTALL_DEBIAN[$idx]:-}" - ;; - fedora) - install_cmd="${DEPS_INSTALL_FEDORA[$idx]:-}" - ;; - alpine) - install_cmd="${DEPS_INSTALL_ALPINE[$idx]:-}" - ;; - esac - fi +if [ "$REQUIRE_DIRENV" = true ] && ! command -v direnv >/dev/null 2>&1; then + log_warning "direnv not found — recommended for automatic dev-shell entry (https://direnv.net/)." + log_info "Continuing; use \`nix develop\` to enter the shell, or pass --no-direnv to silence this." +fi - # Substitute {{version}} placeholder with actual version - if [ -n "$install_cmd" ] && [ -n "${DEPS_VERSIONS[$idx]:-}" ]; then - install_cmd="${install_cmd//\{\{version\}\}/${DEPS_VERSIONS[$idx]}}" - fi +if ! command -v uv >/dev/null 2>&1; then + print_devshell_guidance + exit 1 +fi - echo "$install_cmd" -} - -install_dependency() { - local name="$1" - local install_cmd="$2" - local manual_url="${3:-}" - - if [ -z "$install_cmd" ]; then - log_error "No installation command available for $name on this platform" - if [ -n "$manual_url" ]; then - log_info "Manual installation: $manual_url" - fi - return 1 - fi - - log_info "Installing $name..." - log_debug "Command: $install_cmd" - - # Execute installation - if bash -c "$install_cmd"; then - log_success "$name installed successfully" - return 0 - else - log_error "Failed to install $name" - if [ -n "$manual_url" ]; then - log_info "Try manual installation: $manual_url" - fi - return 1 - fi -} +if [ "$CHECK_ONLY" = true ]; then + log_success "Prerequisites satisfied: Nix and the dev-shell toolchain are available." + exit 0 +fi # ═══════════════════════════════════════════════════════════════════════════════ -# MAIN LOGIC +# PROJECT BOOTSTRAP (one-time, idempotent) # ═══════════════════════════════════════════════════════════════════════════════ -show_help() { - sed -n '/^###############################################################################$/,/^###############################################################################$/p' "$0" | sed '1d;$d' - exit 0 -} - -main() { - # Parse arguments - while [[ $# -gt 0 ]]; do - case "$1" in - --check|-c) - CHECK_ONLY=true - shift - ;; - --yes|-y) - AUTO_YES=true - shift - ;; - --verbose|-v) - VERBOSE=true - shift - ;; - --help|-h) - show_help - ;; - *) - log_error "Unknown option: $1" - echo "Use --help for usage information" - exit 1 - ;; - esac - done - - print_header "Development Environment Initializer" - - # Check requirements file exists - if [ ! -f "$REQUIREMENTS_FILE" ]; then - log_error "Requirements file not found: $REQUIREMENTS_FILE" - exit 1 - fi - - # Detect OS - local os_type - os_type=$(detect_os) - local os_name - os_name=$(get_os_pretty_name) - - log_info "Detected OS: ${BOLD}$os_name${NC} (type: $os_type)" - - if [ "$os_type" = "unknown" ]; then - log_warning "Unknown OS type. Installation commands may not work." - fi - - # Parse requirements - log_info "Reading requirements from: $REQUIREMENTS_FILE" - parse_requirements "$REQUIREMENTS_FILE" - - local total_deps=${#DEPS_NAMES[@]} - log_info "Found $total_deps dependencies to check" - - # Check each dependency - print_section "Checking Dependencies" - - local missing_deps=() - local missing_indices=() - local installed_count=0 - - for i in "${!DEPS_NAMES[@]}"; do - local name="${DEPS_NAMES[$i]}" - local version="${DEPS_VERSIONS[$i]}" - local purpose="${DEPS_PURPOSES[$i]}" - local required="${DEPS_REQUIRED[$i]}" - local check_cmd="${DEPS_CHECK_CMDS[$i]}" - - local status_prefix="" - if [ "$required" = "false" ]; then - status_prefix="(optional) " - fi - - if check_dependency "$check_cmd"; then - log_success "${status_prefix}${BOLD}$name${NC} $version - installed" - installed_count=$((installed_count + 1)) - else - log_error "${status_prefix}${BOLD}$name${NC} $version - ${RED}not installed${NC}" - log_info " └─ $purpose" - missing_deps+=("$name") - missing_indices+=("$i") - fi - done - - # Summary - print_section "Summary" - - echo -e " ${GREEN}Installed:${NC} $installed_count" - echo -e " ${RED}Missing:${NC} ${#missing_deps[@]}" - - # Check-only mode - if $CHECK_ONLY; then - echo "" - log_warning "Missing dependencies: ${missing_deps[*]}" - log_info "Run without --check to install them" - exit 1 - fi - - # Offer to install missing dependencies - if [ ${#missing_deps[@]} -gt 0 ]; then - print_section "Install Missing Dependencies" - fi - - local install_count=0 - local failed_count=0 - - for idx in "${missing_indices[@]}"; do - local name="${DEPS_NAMES[$idx]}" - local version="${DEPS_VERSIONS[$idx]}" - local required="${DEPS_REQUIRED[$idx]}" - local manual="${DEPS_INSTALL_MANUAL[$idx]:-}" - - local install_cmd - install_cmd=$(get_install_command "$os_type" "$idx") - - echo "" - echo -e " ${BOLD}$name${NC} ($version)" - echo -e " ${CYAN}Purpose:${NC} ${DEPS_PURPOSES[$idx]}" - - if [ -z "$install_cmd" ]; then - log_warning "No automatic installation available for this platform" - if [ -n "$manual" ]; then - log_info "Manual installation: $manual" - fi - failed_count=$((failed_count + 1)) - continue - fi - - echo -e " ${CYAN}Command:${NC} $install_cmd" - - if confirm "Install $name?"; then - if install_dependency "$name" "$install_cmd" "$manual"; then - install_count=$((install_count + 1)) - else - failed_count=$((failed_count + 1)) - fi - else - log_info "Skipped $name" - if [ "$required" = "true" ]; then - failed_count=$((failed_count + 1)) - fi - fi - done - - # Summary - if [ ${#missing_deps[@]} -gt 0 ]; then - print_section "Installation Complete" - - echo -e " ${GREEN}Installed:${NC} $install_count" - echo -e " ${RED}Failed:${NC} $failed_count" - - if [ $failed_count -gt 0 ]; then - echo "" - log_warning "Some dependencies could not be installed." - log_info "You may need to install them manually before running ${BOLD}just setup${NC}" - exit 1 - fi - fi - - echo "" - log_success "All dependencies installed!" - - # Environment Setup - cd "$PROJECT_ROOT" - print_section "Environment Setup" - - # Create virtual environment with specific Python version - log_info "Creating virtual environment with Python $PYTHON_VERSION in: $PROJECT_ROOT/.venv" - if uv venv --python "$PYTHON_VERSION" .venv; then - log_success "Virtual environment created" - else - log_error "Failed to create virtual environment" - exit 1 - fi - - # Sync project dependencies from lockfile - log_info "Installing project dependencies (including dev dependencies)..." - if uv sync --frozen --all-extras; then - log_success "Project dependencies installed" - else - log_error "Failed to install project dependencies" - exit 1 - fi - - # Setup hooks - log_info "Setting up hooks..." - if git config core.hooksPath .githooks && chmod +x .githooks/pre-commit .githooks/prepare-commit-msg .githooks/commit-msg 2>/dev/null; then - log_success "Git hooks path configured" - else - log_warning "Could not configure Git hooks path (may not exist yet)" - fi - - # Commit message template (see docs/COMMIT_MESSAGE_STANDARD.md) - if [ -f .gitmessage ]; then - if git config commit.template .gitmessage 2>/dev/null; then - log_success "Commit message template configured (.gitmessage)" - fi - fi - - if uv run pre-commit install-hooks 2>/dev/null; then - log_success "Pre-commit hooks installed" - else - log_warning "Pre-commit hooks installation failed (may not be in dependencies)" - fi +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$PROJECT_ROOT" + +print_section "Project Bootstrap" + +# Materialize the project venv from the lockfile. uv builds it from the +# interpreter the flake dev-shell pins via UV_PYTHON (UV_PYTHON_DOWNLOADS=never); +# no interpreter is hardcoded here. +log_info "Syncing the project environment from the lockfile..." +if uv sync --frozen --all-extras; then + log_success "Project dependencies installed" +else + log_error "Failed to sync project dependencies" + exit 1 +fi + +# Git hooks live in .githooks (tracked); point core.hooksPath at them. +if git config core.hooksPath .githooks && chmod +x .githooks/* 2>/dev/null; then + log_success "Git hooks path configured (.githooks)" +else + log_warning "Could not configure the git hooks path" +fi + +# Commit message template (see docs/COMMIT_MESSAGE_STANDARD.md) +if [ -f .gitmessage ] && git config commit.template .gitmessage; then + log_success "Commit message template configured (.gitmessage)" +fi + +if uv run pre-commit install-hooks; then + log_success "Pre-commit hooks installed" +else + log_warning "Could not install pre-commit hooks" +fi - # Docker/Podman authentication - print_section "Container Registry Authentication" - - local DOCKER_CONFIG="$HOME/.docker/config.json" - if [ ! -f "$DOCKER_CONFIG" ]; then - log_info "Container registry config not found. Setting up GitHub Container Registry authentication..." - - mkdir -p "$HOME/.docker" - - if [ -t 0 ]; then - # Interactive mode - read -r -p "Enter GitHub Username: " GITHUB_USER - read -r -s -p "Enter GitHub Token: " GITHUB_TOKEN - echo - - if echo "$GITHUB_TOKEN" | podman login ghcr.io -u "$GITHUB_USER" --password-stdin; then - log_success "GitHub Container Registry authentication configured" - else - log_error "Failed to authenticate with GitHub Container Registry" - fi - else - log_warning "Non-interactive mode: Skipping GHCR authentication" - log_info "Run manually: podman login ghcr.io" - fi - else - log_info "Container registry config exists at $DOCKER_CONFIG" - if podman login ghcr.io --get-login >/dev/null 2>&1; then - log_success "GitHub Container Registry authentication verified" - else - log_warning "Could not verify authentication" - log_info "If you encounter authentication issues, run: podman login ghcr.io" - fi - fi +# ═══════════════════════════════════════════════════════════════════════════════ +# ADVISORY HOST CHECKS (non-fatal — these depend on host configuration) +# ═══════════════════════════════════════════════════════════════════════════════ - # Verify GitHub CLI authentication - if gh auth status >/dev/null 2>&1; then - log_success "GitHub CLI authentication verified" +print_section "Host Checks" + +# A working rootless container runtime is a host prerequisite: the flake ships +# the podman CLI, but rootless operation needs host setuid uid-mappers (Linux) +# or a podman machine (macOS), which Nix cannot provide. +if podman info >/dev/null 2>&1; then + log_success "Container runtime is working (podman info)" +else + log_warning "podman is not usable yet (rootless runtime needs host setup)." + if [ "$(uname -s)" = "Darwin" ]; then + log_info "macOS: initialize a VM with \`podman machine init && podman machine start\`." else - log_warning "GitHub CLI is not authenticated." - log_info "Run 'gh auth login' to authenticate with GitHub." + log_info "Linux: ensure rootless podman is configured (subuid/subgid, uidmap), then re-check with \`podman info\`." fi +fi + +# podman's containers/image library requires a signature-verification policy.json +# for `podman load` (used by `just build`) — a check that `podman info` does NOT +# perform. The NixOS `virtualisation.containers` module normally installs +# /etc/containers/policy.json, but a host that gets podman purely from the flake +# dev-shell never gets it, so `just build` fails at `podman load` even though +# `podman info` is green. This podman build exposes no `--signature-policy` flag +# and no env override, so the file must exist at one of the two lookup paths. +# Ensure the user-level default (idempotent; never overwrites a system or user one). +system_policy="/etc/containers/policy.json" +user_policy="${HOME}/.config/containers/policy.json" +if [ -f "$system_policy" ] || [ -f "$user_policy" ]; then + log_success "Containers signature policy present (podman load can run)" +elif mkdir -p "$(dirname "$user_policy")" 2>/dev/null && + printf '{ "default": [ { "type": "insecureAcceptAnything" } ] }\n' >"$user_policy"; then + log_success "Wrote a permissive containers policy to $user_policy (needed by podman load)" +else + log_warning "No containers policy.json and could not create $user_policy." + log_info "Create it manually: printf '{ \"default\": [ { \"type\": \"insecureAcceptAnything\" } ] }\\n' > $user_policy" +fi + +if gh auth status >/dev/null 2>&1; then + log_success "GitHub CLI is authenticated" +else + log_warning "GitHub CLI is not authenticated — run \`gh auth login\`." +fi - # Done - echo "" - print_section "Setup Complete" - log_success "Environment setup complete!" - echo "" - log_info "Run ${BOLD}just${NC} to see available commands." - -} +# ═══════════════════════════════════════════════════════════════════════════════ +# DONE +# ═══════════════════════════════════════════════════════════════════════════════ -# Run main function -main "$@" +print_section "Setup Complete" +log_success "Environment bootstrapped." +log_info "Run ${BOLD}just${NC} to see available commands." diff --git a/scripts/manifest.toml b/scripts/manifest.toml index b9dae26c..cb70e317 100644 --- a/scripts/manifest.toml +++ b/scripts/manifest.toml @@ -4,21 +4,26 @@ [[entries]] src = "docs/COMMIT_MESSAGE_STANDARD.md" -[[entries]] -src = ".cursor/rules/" +# Agent skills/config now live in .claude/ (SSoT, #626). They sync into the +# downstream template under assets/workspace/.claude/, replacing the stale +# assets/workspace/.cursor/ template tree removed by #629. The former +# .cursor/rules/ entry is dropped: workflow rules became skills (synced below) +# and static principles moved into CLAUDE.md. The template carries the same +# .claude/ payload the old .cursor/ template did: skills, agent-models.toml, +# and worktrees.json. Command wrappers (.claude/commands/) are not synced — +# the downstream template has no CLAUDE.md command table and never carried them. +[[entries]] +src = ".claude/skills/" transforms = [ - { type = "RemoveLines", pattern = "Full reference: \\[docs/COMMIT_MESSAGE_STANDARD\\.md\\]", target = "commit-messages.mdc" }, + { type = "Sed", pattern = "just test-image", replace = "just test", target = "code_verify/SKILL.md" }, + { type = "Sed", pattern = "just test-image", replace = "just test", target = "design_plan/SKILL.md" }, ] [[entries]] -src = ".cursor/skills/" -transforms = [ - { type = "Sed", pattern = "just test-image", replace = "just test", target = "code:verify/SKILL.md" }, - { type = "Sed", pattern = "just test-image", replace = "just test", target = "design:plan/SKILL.md" }, -] +src = ".claude/agent-models.toml" [[entries]] -src = ".cursor/worktrees.json" +src = ".claude/worktrees.json" [[entries]] src = ".gitmessage" @@ -36,14 +41,8 @@ src = ".pymarkdown.config.md" src = "CHANGELOG.md" dest = ".devcontainer/CHANGELOG.md" -[[entries]] -src = ".hadolint.yaml" - [[entries]] src = ".vscode/settings.json" -transforms = [ - { type = "Sed", pattern = "\\$\\{workspaceFolder\\}/\\.venv/bin/python3", replace = "/opt/venv/bin/python3" }, -] [[entries]] src = ".github/agent-blocklist.toml" @@ -98,8 +97,14 @@ transforms = [ [[entries]] src = ".pre-commit-config.yaml" transforms = [ + # ruff/ruff-format/typos stay as repo-local language:system hooks in the + # scaffold too, resolved from the toolchain baked into the devcontainer image + # (devTools SSoT). The #697 decoupling shipped self-contained upstream hooks + # because the integration suite still ran the published Debian image, whose + # PATH lacked those tools; that workaround is dropped now that the suite runs + # the freshly-built Nix image — whose non-FHS userland cannot even execute the + # upstream manylinux hook binaries. #701. { type = "RemovePrecommitHooks", hook_ids = [ - "hadolint", "generate-docs", "sync-manifest", "pip-licenses", diff --git a/scripts/nix_runtime_smoke.sh b/scripts/nix_runtime_smoke.sh new file mode 100755 index 00000000..35d212a8 --- /dev/null +++ b/scripts/nix_runtime_smoke.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# In-container Nix runtime smoke test (#675). +# +# Runs INSIDE the Nix-built devcontainer image to prove that the baked Nix +# toolchain (`nix`, `direnv`, `nix-direnv` — see flake.nix `imageTools`, #634) +# is not merely *present* but actually *functional* at runtime. The portable +# testinfra suite (tests/test_image.py, #635) only asserts tool presence, so a +# regression that left `nix`/`direnv` on PATH but broken would pass it; this +# script closes that gap. +# +# Every check is self-contained and network-free (no flake input fetch, no +# substituter round-trip), so it is fast and deterministic in CI and fails iff +# the in-container runtime is genuinely broken. +# +# Usage (from the workflow, against the loaded image): +# podman run --rm <image> bash /root/assets/../scripts/nix_runtime_smoke.sh +# In CI the repo's scripts/ dir is bind-mounted at /smoke and this is run as +# podman run --rm -v "$PWD/scripts":/smoke:ro <image> bash /smoke/nix_runtime_smoke.sh + +set -euo pipefail + +echo "== In-container Nix runtime smoke test (#675) ==" + +# 1) The Nix binary itself runs (dynamic linker, store access, self-test). +echo "-- nix --version" +nix --version + +# 2) direnv runs. +echo "-- direnv version" +direnv version + +# 3) The Nix evaluator actually evaluates with the experimental features the +# live closure must enable. `--version` does not exercise evaluation; this +# does, so a broken evaluator / missing experimental-features fails here. +echo "-- nix eval (evaluator + nix-command/flakes)" +result="$(nix eval --extra-experimental-features 'nix-command flakes' --expr '1 + 1')" +if [ "${result}" != "2" ]; then + echo "::error::nix eval returned '${result}', expected '2' — evaluator is broken" + exit 1 +fi +echo "nix eval '1 + 1' = ${result}" + +# 4) direnv's allow + exec runtime path works end-to-end. We use a trivial +# non-flake .envrc (a plain export) so this proves direnv's own runtime — +# load, allow, hook, exec — without depending on a network flake fetch. +echo "-- direnv allow + direnv exec" +work="$(mktemp -d)" +cd "${work}" +printf 'export VIGOS_SMOKE_OK=1\n' >.envrc +# direnv keys its allow-list on $HOME/.config/direnv; HOME is /root in the image. +direnv allow . +# Single quotes are intentional: VIGOS_SMOKE_OK must be expanded by the inner +# shell `direnv exec` spawns (with .envrc loaded), not by this outer shell. +# shellcheck disable=SC2016 +got="$(direnv exec . sh -c 'printf %s "${VIGOS_SMOKE_OK:-}"')" +if [ "${got}" != "1" ]; then + echo "::error::direnv exec did not load .envrc (got '${got}') — direnv runtime is broken" + exit 1 +fi +echo "direnv exec loaded .envrc (VIGOS_SMOKE_OK=${got})" + +echo "== All in-container Nix runtime smoke checks passed ==" diff --git a/scripts/prepare-build.sh b/scripts/prepare-build.sh deleted file mode 100755 index 5e0df0da..00000000 --- a/scripts/prepare-build.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env bash -# Prepare build directory for container image build -# Usage: prepare-build.sh <version> -# -# This script prepares the build directory: -# - Creates and clears the build directory -# - Copies Containerfile and assets -# - Syncs canonical files into build template (from manifest) -# - Replaces {{IMAGE_TAG}} placeholders in template files - -set -e - -VERSION="${1:-dev}" -echo "🔍 DEBUG: VERSION='$VERSION'" - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -echo "🔍 DEBUG: SCRIPT_DIR='$SCRIPT_DIR'" -echo "🔍 DEBUG: PROJECT_ROOT='$PROJECT_ROOT'" - -cd "$PROJECT_ROOT" -echo "🔍 DEBUG: Changed to PROJECT_ROOT" - -BUILD_DIR="build" -BUILD_VERSION="$VERSION" -echo "🔍 DEBUG: BUILD_DIR='$BUILD_DIR'" -echo "🔍 DEBUG: BUILD_VERSION='$BUILD_VERSION'" - -# Create and clear build folder -rm -rf "$BUILD_DIR" -mkdir -p "$BUILD_DIR" - -# Copy Containerfile, assets, packages, and pyproject for container build -cp Containerfile "$BUILD_DIR/" -cp pyproject.toml uv.lock "$BUILD_DIR/" -cp -r assets "$BUILD_DIR/" -cp -r packages "$BUILD_DIR/" - -# Sync canonical files into build template (from declarative Python manifest) -echo "Syncing canonical files into build template..." -uv run python "$SCRIPT_DIR/sync_manifest.py" sync "$BUILD_DIR/assets/workspace" \ - --project-root "$PROJECT_ROOT" - -# Replace {{IMAGE_TAG}} placeholders in template files -if [ -d "$BUILD_DIR/assets/workspace" ]; then - echo "Replacing {{IMAGE_TAG}} with $BUILD_VERSION in template files..." - - find "$BUILD_DIR/assets/workspace" -type f -print0 | while IFS= read -r -d '' file; do - uv run vig-utils sed "s|{{IMAGE_TAG}}|$BUILD_VERSION|g" "$file" - done - - # Verify replacements - if grep -r "{{IMAGE_TAG}}" "$BUILD_DIR/assets/workspace" 2>/dev/null; then - echo "❌ Some {{IMAGE_TAG}} placeholders were not replaced!" - exit 1 - fi - echo "✓ All {{IMAGE_TAG}} placeholders replaced" -fi - -# Update devcontainer README with version (if script exists and file exists) -BUILD_DEVCONTAINER_README="$BUILD_DIR/assets/workspace/.devcontainer/README.md" -if [ -f "$BUILD_DEVCONTAINER_README" ] && [ -f "scripts/update_readme.py" ] && [ "$BUILD_VERSION" != "dev" ]; then - RELEASE_DATE="$(date -u +%Y-%m-%d)" - RELEASE_URL="" - # Only update README if RELEASE_URL is provided (indicates a versioned release, not dev build) - if [ -n "$RELEASE_URL" ]; then - echo "Updating devcontainer README with version $BUILD_VERSION..." - if uv run vig-utils version "$BUILD_DEVCONTAINER_README" "$BUILD_VERSION" "$RELEASE_URL" "$RELEASE_DATE"; then - echo "✓ Updated devcontainer README with version $BUILD_VERSION" - else - echo "❌ Failed to update devcontainer README..." - exit 1 - fi - fi -fi - -echo "✓ Build directory prepared: $BUILD_DIR" diff --git a/scripts/requirements.yaml b/scripts/requirements.yaml deleted file mode 100644 index 9bb61329..00000000 --- a/scripts/requirements.yaml +++ /dev/null @@ -1,273 +0,0 @@ -# requirements.yaml -# Single source of truth for development dependencies -# Used by: init.sh (installation), docs/generate.py (documentation) -# -# Schema: -# name: Display name for the dependency -# version: Version constraint (for display) -# purpose: Brief description of why it's needed -# required: Whether the dependency is mandatory (default: true) -# check: -# command: Command to check if installed (returns 0 if installed) -# version_command: Command to get version string (optional) -# version_regex: Regex to extract version from version_command output (optional) -# install: -# macos: Homebrew command or instructions -# debian: apt command or instructions -# fedora: dnf command or instructions (optional) -# alpine: apk command or instructions (optional) -# manual: Manual installation instructions (fallback) - -dependencies: - # Container Runtime (required) - - name: podman - version: ">=4.0" - purpose: Container runtime, compose, and image building - required: true - check: - command: command -v podman - version_command: podman --version - version_regex: 'podman version (\d+\.\d+)' - install: - macos: brew install podman - debian: sudo apt install -y podman - fedora: sudo dnf install -y podman - manual: https://podman.io/getting-started/installation - - # Build Automation - - name: just - version: ">=1.40.0" - purpose: Command runner for task automation - required: true - check: - command: command -v just - version_command: just --version - version_regex: 'just (\d+\.\d+)' - install: - macos: brew install just - debian: | - curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | sudo bash -s -- --to /usr/local/bin - fedora: sudo dnf install -y just - manual: https://github.com/casey/just#installation - - # Version Control - - name: git - version: ">=2.34" - purpose: Version control and pre-commit hooks - required: true - check: - command: command -v git - version_command: git --version - version_regex: 'git version (\d+\.\d+)' - install: - macos: brew install git - debian: sudo apt install -y git - fedora: sudo dnf install -y git - manual: https://git-scm.com/downloads - - # SSH (for authentication) - - name: ssh - version: latest - purpose: GitHub authentication and commit signing - required: true - check: - command: command -v ssh - version_command: ssh -V 2>&1 - version_regex: 'OpenSSH_(\d+\.\d+)' - install: - macos: brew install openssh - debian: sudo apt install -y openssh-client - fedora: sudo dnf install -y openssh-clients - manual: Usually pre-installed on Unix systems - - # GitHub CLI - - name: gh - version: latest - purpose: GitHub CLI for repository and PR/issue management - required: true - check: - command: command -v gh - version_command: gh --version - version_regex: 'gh version (\d+\.\d+)' - install: - macos: brew install gh - debian: | - curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null - sudo apt update && sudo apt install -y gh - fedora: sudo dnf install -y gh - manual: https://cli.github.com/ - - # JSON processor (required by worktree trust/config commands) - - name: jq - version: latest - purpose: JSON parsing for worktree commands and issue metadata - required: true - check: - command: command -v jq - version_command: jq --version - version_regex: 'jq-(\d+\.\d+)' - install: - macos: brew install jq - debian: sudo apt install -y jq - fedora: sudo dnf install -y jq - alpine: sudo apk add jq - manual: https://jqlang.github.io/jq/download/ - - # Terminal multiplexer (required by worktree tmux sessions) - - name: tmux - version: latest - purpose: Session manager required by worktree-start and worktree-attach - required: true - check: - command: command -v tmux - version_command: tmux -V - version_regex: 'tmux (\d+\.\d+)' - install: - macos: brew install tmux - debian: sudo apt install -y tmux - fedora: sudo dnf install -y tmux - alpine: sudo apk add tmux - manual: https://github.com/tmux/tmux/wiki/Installing - - # Cursor agent CLI (required by worktree autonomous flows) - - name: agent - version: latest - purpose: Cursor Agent CLI required by worktree-start/worktree-attach flows - required: true - check: - command: command -v agent - version_command: agent --version - version_regex: '([0-9]+\.[0-9]+\.[0-9]+)' - install: - all: curl https://cursor.com/install -fsSL | bash - manual: https://cursor.com/install - - # Node.js (for devcontainer CLI) - - name: npm - version: latest - purpose: Node.js package manager (for DevContainer CLI) - required: true - check: - command: command -v npm - version_command: npm --version - version_regex: '(\d+\.\d+)' - install: - macos: brew install node - debian: sudo apt install -y nodejs npm - fedora: sudo dnf install -y nodejs npm - manual: https://nodejs.org/ - - # Python tooling (auto-installed by setup.sh) - - name: uv - version: ">=0.8" - purpose: Python package and project manager - required: true - check: - command: command -v uv - version_command: uv --version - version_regex: 'uv (\d+\.\d+)' - install: - all: curl -LsSf https://astral.sh/uv/install.sh | sh - manual: https://github.com/astral-sh/uv - - # Shell testing (auto-installed by npm install) - - name: bats - version: "1.13.0" - purpose: Bash Automated Testing System for shell script tests - required: true - check: - command: command -v bats || test -x node_modules/.bin/bats - version_command: npx bats --version - version_regex: 'Bats (\d+\.\d+)' - install: - all: npm install - manual: https://bats-core.readthedocs.io/en/stable/installation.html#any-os-npm - - # DevContainer CLI (auto-installed by npm install) - - name: devcontainer - version: "0.81.1" - purpose: DevContainer CLI for testing devcontainer functionality - required: true - check: - command: command -v devcontainer || test -x node_modules/.bin/devcontainer - version_command: npx devcontainer --version - version_regex: '(\d+\.\d+\.\d+)' - install: - all: npm install - manual: https://github.com/devcontainers/cli - - # Containerfile linting - - name: hadolint - version: latest - purpose: Containerfile/Dockerfile linter used by pre-commit - required: true - check: - command: command -v hadolint - version_command: hadolint --version - version_regex: 'Haskell Dockerfile Linter (\d+\.\d+)' - install: - macos: brew install hadolint - debian: | - case "$(dpkg --print-architecture)" in - amd64) ARCH="linux-x86_64" ;; - arm64) ARCH="linux-arm64" ;; - *) - echo "Unsupported architecture: $(dpkg --print-architecture)" - exit 1 - ;; - esac - BASE_URL="https://github.com/hadolint/hadolint/releases/latest/download" - BIN_FILE="hadolint-${ARCH}" - SHA_FILE="${BIN_FILE}.sha256" - curl -fsSL "${BASE_URL}/${BIN_FILE}" -o "${BIN_FILE}" - curl -fsSL "${BASE_URL}/${SHA_FILE}" -o "${SHA_FILE}" - EXPECTED_SHA="$(awk '{print $1}' "${SHA_FILE}")" - echo "${EXPECTED_SHA} ${BIN_FILE}" | sha256sum -c - - sudo install -m 0755 "${BIN_FILE}" /usr/local/bin/hadolint - rm -f "${BIN_FILE}" "${SHA_FILE}" - manual: https://github.com/hadolint/hadolint/releases - - # TOML linting - - name: taplo - version: latest - purpose: TOML formatter and linter used by pre-commit - required: true - check: - command: command -v taplo - version_command: taplo --version - version_regex: 'taplo (\d+\.\d+)' - install: - macos: brew install taplo - debian: | - case "$(dpkg --print-architecture)" in - amd64) ARCH="x86_64" ;; - arm64) ARCH="aarch64" ;; - *) - echo "Unsupported architecture: $(dpkg --print-architecture)" - exit 1 - ;; - esac - BASE_URL="https://github.com/tamasfe/taplo/releases/latest/download" - BIN_FILE="taplo-linux-${ARCH}.gz" - curl -fsSL "${BASE_URL}/${BIN_FILE}" -o "${BIN_FILE}" - gunzip "${BIN_FILE}" - sudo install -m 0755 "taplo-linux-${ARCH}" /usr/local/bin/taplo - rm -f "taplo-linux-${ARCH}" - manual: https://github.com/tamasfe/taplo/releases - - # Parallel (auto-installed by npm install) - - name: parallel - version: latest - purpose: Parallelizes BATS test execution for faster test runs - required: false - check: - command: command -v parallel - version_command: parallel --version - version_regex: 'GNU parallel (\d+)' - install: - macos: brew install parallel - debian: sudo apt install -y parallel - fedora: sudo dnf install -y parallel - alpine: sudo apk add parallel - manual: https://www.gnu.org/software/parallel/ diff --git a/scripts/sync_manifest.py b/scripts/sync_manifest.py index 27ecd2f4..bb1ad7e8 100644 --- a/scripts/sync_manifest.py +++ b/scripts/sync_manifest.py @@ -11,7 +11,6 @@ uv run python scripts/sync_manifest.py list --transformed Called by: - - scripts/prepare-build.sh (build-time: sync into build/assets/workspace/) - just sync-workspace (dev-time: sync into assets/workspace/) """ diff --git a/tests/README.md b/tests/README.md index fabf7964..d23d99e3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -16,6 +16,25 @@ When running from inside a devcontainer, the test infrastructure automatically: - Translates container paths to host paths using `HOST_WORKSPACE_PATH` - Handles all path translation transparently +## Image under test + +Integration and image tests run against a single image, selected by the +`TEST_CONTAINER_TAG` environment variable (default `dev`, the tag `just build` +loads the freshly-built Nix image under). The `just test`/`just test-integration` +recipes set it for you. + +This matters for the `devcontainer up` tests: the scaffolded +`docker-compose.yml` pins the runtime image as +`ghcr.io/vig-os/devcontainer:${DEVCONTAINER_VERSION:-latest}`, and +`initialize.sh` writes the scaffolded `.vig-os` version (a *published* release) +into `.devcontainer/.env`. To keep the suite validating the image under test +rather than a stale published image, the `devcontainer_up` and +`devcontainer_with_sidecar` fixtures export `DEVCONTAINER_VERSION=TEST_CONTAINER_TAG`. +Compose resolves shell environment variables ahead of `.env`, so the +freshly-built tag wins; `devcontainer exec` calls inherit the same environment. +To point the suite at a different build, set `TEST_CONTAINER_TAG` to that tag +(the image must already be loaded into podman). Refs #701. + ## Prerequisites ### From Host diff --git a/tests/bats/build.bats b/tests/bats/build.bats deleted file mode 100644 index 60b83dd3..00000000 --- a/tests/bats/build.bats +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env bats -# shellcheck disable=SC2016 -# BATS tests for build.sh -# -# Tests the build.sh script which prepares and builds a container image. -# These tests verify: -# - Argument parsing (version, repo, platform) -# - --no-cache flag handling -# - Architecture detection -# - Integration with prepare-build.sh -# - Error handling -# -# Note: SC2016 disabled because we intentionally use single quotes to search -# for literal shell variable syntax (e.g., '$VAR') in the target scripts. - -setup() { - load test_helper - BUILD_SH="$PROJECT_ROOT/scripts/build.sh" -} - -# ── script structure ────────────────────────────────────────────────────────── - -@test "build.sh is executable" { - run test -x "$BUILD_SH" - assert_success -} - -@test "build.sh has shebang" { - run head -1 "$BUILD_SH" - assert_output "#!/usr/bin/env bash" -} - -# ── argument parsing & defaults ─────────────────────────────────────────────── - -@test "build.sh defines VERSION variable with default 'dev'" { - run grep 'VERSION="\${1:-dev}"' "$BUILD_SH" - assert_success -} - -@test "build.sh defines REPO variable with default registry" { - run grep 'REPO="\${2:-ghcr.io/vig-os/devcontainer}"' "$BUILD_SH" - assert_success -} - -@test "build.sh accepts --no-cache as first flag" { - run grep 'if \[ "\${1:-}" = "--no-cache" \]' "$BUILD_SH" - assert_success -} - -# ── architecture detection ──────────────────────────────────────────────────── - -@test "build.sh detects architecture using uname" { - run grep 'NATIVE_ARCH=\$(uname -m)' "$BUILD_SH" - assert_success -} - -@test "build.sh handles arm64 architecture" { - run grep 'arm64' "$BUILD_SH" - assert_success -} - -@test "build.sh handles aarch64 architecture" { - run grep 'aarch64' "$BUILD_SH" - assert_success -} - -@test "build.sh defaults to linux/amd64 for standard x86" { - run grep 'NATIVE_PLATFORM="\${3:-linux/amd64}"' "$BUILD_SH" - assert_success -} - -@test "build.sh defaults to linux/arm64 for arm architectures" { - run grep 'NATIVE_PLATFORM="\${3:-linux/arm64}"' "$BUILD_SH" - assert_success -} - -# ── build directory preparation ─────────────────────────────────────────────── - -@test "build.sh calls prepare-build.sh" { - run grep '"\$SCRIPT_DIR/prepare-build.sh"' "$BUILD_SH" - assert_success -} - -@test "build.sh passes version to prepare-build.sh" { - run grep 'prepare-build.sh.*VERSION' "$BUILD_SH" - assert_success -} - -# ── podman build invocation ─────────────────────────────────────────────────── - -@test "build.sh invokes podman build" { - run grep 'podman build' "$BUILD_SH" - assert_success -} - -@test "build.sh passes --platform to podman build" { - run grep 'podman build --platform' "$BUILD_SH" - assert_success -} - -@test "build.sh supports --no-cache flag for podman" { - run grep 'BUILD_CACHE_ARGS' "$BUILD_SH" - assert_success -} - -@test "build.sh tags image with repository and version" { - run grep -- '-t "\$REPO:\$BUILD_VERSION"' "$BUILD_SH" - assert_success -} - -@test "build.sh uses Containerfile from build directory" { - run grep -- '-f "\$BUILD_DIR/Containerfile"' "$BUILD_SH" - assert_success -} - -# ── error handling ──────────────────────────────────────────────────────────── - -@test "build.sh uses strict mode (set -e)" { - run grep 'set -e' "$BUILD_SH" - assert_success -} - -@test "build.sh captures podman build exit code" { - run grep 'BUILD_EXIT_CODE=' "$BUILD_SH" - assert_success -} - -@test "build.sh exits with error on build failure" { - run grep 'exit 1' "$BUILD_SH" - assert_success -} - -# ── output messages ─────────────────────────────────────────────────────────── - -@test "build.sh outputs success message with platform info" { - run grep '✓ Built local development image' "$BUILD_SH" - assert_success -} - -@test "build.sh outputs error message on failure" { - run grep '❌ Build failed' "$BUILD_SH" - assert_success -} - -# ── directory management ────────────────────────────────────────────────────── - -@test "build.sh derives SCRIPT_DIR from script path" { - run grep 'SCRIPT_DIR=' "$BUILD_SH" - assert_success -} - -@test "build.sh derives PROJECT_ROOT as parent of SCRIPT_DIR" { - run grep 'PROJECT_ROOT=' "$BUILD_SH" - assert_success -} - -@test "build.sh changes to PROJECT_ROOT" { - run grep 'cd "\$PROJECT_ROOT"' "$BUILD_SH" - assert_success -} diff --git a/tests/bats/init-workspace.bats b/tests/bats/init-workspace.bats index 051eff13..74f000b0 100644 --- a/tests/bats/init-workspace.bats +++ b/tests/bats/init-workspace.bats @@ -7,6 +7,151 @@ setup() { load test_helper INIT_WORKSPACE_SH="$PROJECT_ROOT/assets/init-workspace.sh" PARSE_GITHUB_REMOTE_LIB="$PROJECT_ROOT/assets/parse-github-remote-lib.sh" + TEMPLATE_DIR="$PROJECT_ROOT/assets/workspace" +} + +# ── Claude-native template scaffold (#629) ──────────────────────────────────── +# init-workspace.sh rsyncs assets/workspace/ verbatim into a new workspace, so +# asserting on the template tree is a faithful, build-free proxy for "what new +# workspaces scaffold". + +@test "template scaffolds .claude/ directory" { + run test -d "$TEMPLATE_DIR/.claude" + assert_success +} + +@test "template scaffolds .claude/skills/" { + run test -d "$TEMPLATE_DIR/.claude/skills" + assert_success +} + +@test "template does NOT scaffold .cursor/ directory" { + run test -e "$TEMPLATE_DIR/.cursor" + assert_failure +} + +@test "template carries no Cursor editor glue (#629 scope)" { + # #629 owns: the cursor-remote-ssh socket glob and the `command -v cursor` + # editor launch. The remaining `cursor-agent` worktree-pipeline references + # are owned by #627; the AI blocklist's "cursor" entries by #630. + # Exclude CHANGELOG.md: released/Unreleased prose legitimately names the + # removed glue when describing the change. + run grep -rn --exclude=CHANGELOG.md \ + 'cursor-remote\|command -v cursor' "$TEMPLATE_DIR" + assert_failure +} + +# ── direnv / flake stub (#640) ──────────────────────────────────────────────── +# The downstream minimal flake stub + .envrc let a new repo `direnv allow` / +# `nix develop` into the shared toolchain. They are never-overwritten on +# upgrade (the user owns the extraPackages block). + +@test "template scaffolds the downstream flake.nix stub (#640)" { + run test -f "$TEMPLATE_DIR/flake.nix" + assert_success +} + +@test "template scaffolds the .envrc (use flake) stub (#640)" { + run test -f "$TEMPLATE_DIR/.envrc" + assert_success +} + +@test "downstream flake stub consumes the vigos toolchain SSoT (#640)" { + run grep -q 'vigos.lib.mkProjectShell' "$TEMPLATE_DIR/flake.nix" + assert_success + run grep -q 'vigos/nixpkgs' "$TEMPLATE_DIR/flake.nix" + assert_success +} + +@test "flake.nix and .envrc are preserved on --force upgrade (#640)" { + # shellcheck disable=SC2016 + run grep -E '"flake\.nix"' "$INIT_WORKSPACE_SH" + assert_success + # shellcheck disable=SC2016 + run grep -E '"\.envrc"' "$INIT_WORKSPACE_SH" + assert_success +} + +# ── delivery-mode picker (#641) ─────────────────────────────────────────────── +# init-workspace.sh scaffolds the template, then prunes to the chosen mode: +# devcontainer -> .devcontainer/ only (no flake.nix/.envrc) +# direnv -> flake.nix + .envrc only (no .devcontainer/) +# both -> everything (default, current behaviour) +# We exercise the prune on a copy of the template (build-free proxy for the +# in-container scaffold), and assert the flag/default wiring on script structure. + +# Apply the same prune the script does for a given mode to $1 (a workspace copy). +prune_mode() { + local ws="$1" mode="$2" + case "$mode" in + devcontainer) rm -f "$ws/flake.nix" "$ws/.envrc" ;; + direnv) rm -rf "$ws/.devcontainer" ;; + both) : ;; + esac +} + +@test "mode=devcontainer keeps .devcontainer/, drops flake.nix and .envrc (#641)" { + ws="$BATS_TEST_TMPDIR/ws-devcontainer" + cp -r "$TEMPLATE_DIR" "$ws" + prune_mode "$ws" devcontainer + run test -d "$ws/.devcontainer" + assert_success + run test -e "$ws/flake.nix" + assert_failure + run test -e "$ws/.envrc" + assert_failure +} + +@test "mode=direnv keeps flake.nix and .envrc, drops .devcontainer/ (#641)" { + ws="$BATS_TEST_TMPDIR/ws-direnv" + cp -r "$TEMPLATE_DIR" "$ws" + prune_mode "$ws" direnv + run test -f "$ws/flake.nix" + assert_success + run test -f "$ws/.envrc" + assert_success + run test -e "$ws/.devcontainer" + assert_failure +} + +@test "mode=both keeps .devcontainer/, flake.nix and .envrc (#641)" { + ws="$BATS_TEST_TMPDIR/ws-both" + cp -r "$TEMPLATE_DIR" "$ws" + prune_mode "$ws" both + run test -d "$ws/.devcontainer" + assert_success + run test -f "$ws/flake.nix" + assert_success + run test -f "$ws/.envrc" + assert_success +} + +@test "init-workspace.sh accepts a --mode flag (#641)" { + run grep -- '--mode' "$INIT_WORKSPACE_SH" + assert_success +} + +@test "init-workspace.sh validates --mode against the three modes (#641)" { + run grep -E 'devcontainer\|direnv\|both' "$INIT_WORKSPACE_SH" + assert_success +} + +@test "init-workspace.sh defaults to 'both' under --no-prompts (#641)" { + # shellcheck disable=SC2016 + run grep -A4 'if \[\[ -z "\$MODE" \]\]' "$INIT_WORKSPACE_SH" + assert_success + assert_output --partial 'MODE="both"' +} + +@test "init-workspace.sh prunes the scaffold by delivery mode (#641)" { + # devcontainer drops the flake stub; direnv drops the devcontainer scaffold. + # shellcheck disable=SC2016 + run grep -A12 'case "\$MODE" in' "$INIT_WORKSPACE_SH" + assert_success + # shellcheck disable=SC2016 + assert_output --partial 'rm -f "$WORKSPACE_DIR/flake.nix" "$WORKSPACE_DIR/.envrc"' + # shellcheck disable=SC2016 + assert_output --partial 'rm -rf "$WORKSPACE_DIR/.devcontainer"' } # ── script structure ────────────────────────────────────────────────────────── @@ -82,17 +227,37 @@ setup() { } @test "init-workspace.sh smoke mode uses rsync --delete for clean deploy" { - run grep 'rsync -av --delete' "$INIT_WORKSPACE_SH" + run grep 'rsync -avL --delete' "$INIT_WORKSPACE_SH" assert_success } @test "init-workspace.sh smoke mode excludes synced docs directories from delete" { - run grep -A1 'rsync -av --delete' "$INIT_WORKSPACE_SH" + run grep -A1 'rsync -avL --delete' "$INIT_WORKSPACE_SH" assert_success assert_output --partial "--exclude='docs/issues/'" assert_output --partial "--exclude='docs/pull-requests/'" } +# ── Nix-image scaffold: real, writable files (#664) ─────────────────────────── +# The Nix image bakes the template as read-only /nix/store symlinks. The scaffold +# rsync must --copy-links (-L) so a new workspace gets real files (not dangling +# symlinks on the host), and must restore writability (the store mode is 0444). + +@test "init-workspace.sh dereferences store symlinks when scaffolding (#664)" { + # Every template/asset rsync must copy referents, not symlinks. + run grep -nE 'rsync -avL' "$INIT_WORKSPACE_SH" + assert_success + # ...and none may scaffold with a plain `rsync -av ` (symlinks-as-symlinks). + run grep -nE 'rsync -av ' "$INIT_WORKSPACE_SH" + assert_failure +} + +@test "init-workspace.sh makes the scaffold user-writable (#664)" { + # shellcheck disable=SC2016 + run grep -E 'chmod -R u\+w "\$WORKSPACE_DIR"' "$INIT_WORKSPACE_SH" + assert_success +} + # ── parse-github-remote-lib (#509) ───────────────────────────────────────── @test "parse_github_remote parses HTTPS github.com URL" { diff --git a/tests/bats/init.bats b/tests/bats/init.bats index b1392c1b..4dfaa573 100644 --- a/tests/bats/init.bats +++ b/tests/bats/init.bats @@ -2,22 +2,26 @@ # shellcheck disable=SC2016 # BATS tests for init.sh # -# Tests the init.sh script which checks and installs development dependencies. +# init.sh is a Nix-first onboarding script: it gates on the host prerequisites +# (Nix + direnv) and the dev-shell toolchain, then performs one-time project +# bootstrap (uv sync, git hooks, commit template, pre-commit). The toolchain +# itself is provisioned by the flake (`flake.nix` devTools), NOT by this script. +# # These tests verify: -# - Command-line flag parsing (--check, --yes, --help, --verbose) -# - OS detection (macOS, Debian/Ubuntu, Fedora, Alpine) -# - YAML parsing of requirements.yaml -# - Dependency checking -# - Installation path detection -# - Error handling +# - Script structure and strict error handling +# - Flag parsing (--check, --no-direnv, --help) +# - The Nix/direnv prerequisite gate and dev-shell detection +# - The container short-circuit +# - That project bootstrap steps are wired up +# - That the legacy OS-detect / requirements.yaml installer is gone # # Note: SC2016 disabled because we intentionally use single quotes to search -# for literal shell variable syntax (e.g., '$VAR') in the target scripts. +# for literal shell syntax in the target script. setup() { load test_helper INIT_SH="$PROJECT_ROOT/scripts/init.sh" - REQUIREMENTS_YAML="$PROJECT_ROOT/scripts/requirements.yaml" + BASH_BIN="$(command -v bash)" } # ── script structure ────────────────────────────────────────────────────────── @@ -32,367 +36,171 @@ setup() { assert_output "#!/usr/bin/env bash" } -# ── error handling ──────────────────────────────────────────────────────────── - @test "init.sh uses strict error handling (set -euo pipefail)" { run grep 'set -euo pipefail' "$INIT_SH" assert_success } -# ── flag parsing ────────────────────────────────────────────────────────────── - -@test "init.sh initializes CHECK_ONLY flag as false" { - run grep 'CHECK_ONLY=false' "$INIT_SH" - assert_success -} - -@test "init.sh initializes AUTO_YES flag as false" { - run grep 'AUTO_YES=false' "$INIT_SH" - assert_success -} - -@test "init.sh initializes VERBOSE flag as false" { - run grep 'VERBOSE=false' "$INIT_SH" - assert_success -} - -@test "init.sh supports --check flag" { - run grep '\-\-check' "$INIT_SH" - assert_success -} - -@test "init.sh supports --yes flag" { - run grep '\-\-yes' "$INIT_SH" - assert_success -} - -@test "init.sh supports --help flag" { - run grep '\-\-help' "$INIT_SH" - assert_success -} - -@test "init.sh supports --verbose flag" { - run grep '\-\-verbose' "$INIT_SH" - assert_success -} - -# ── os detection ────────────────────────────────────────────────────────────── - -@test "init.sh defines detect_os function" { - run grep 'detect_os()' "$INIT_SH" - assert_success -} - -@test "init.sh detects OS using uname -s" { - run grep 'uname -s' "$INIT_SH" - assert_success -} - -@test "init.sh detects macOS as 'Darwin'" { - run grep 'Darwin)' "$INIT_SH" - assert_success -} - -@test "init.sh returns 'macos' for macOS" { - run grep 'os_type="macos"' "$INIT_SH" - assert_success -} - -@test "init.sh detects Linux" { - run grep 'Linux)' "$INIT_SH" - assert_success -} - -@test "init.sh reads /etc/os-release on Linux" { - run grep 'os-release' "$INIT_SH" - assert_success -} - -@test "init.sh detects Debian/Ubuntu" { - run grep 'debian' "$INIT_SH" - assert_success -} - -@test "init.sh detects Fedora/RHEL/CentOS" { - run grep 'fedora' "$INIT_SH" - assert_success -} - -@test "init.sh detects Alpine Linux" { - run grep 'alpine)' "$INIT_SH" - assert_success -} - -@test "init.sh detects Arch Linux" { - run grep 'arch' "$INIT_SH" - assert_success -} - -@test "init.sh returns 'unknown' for unrecognized OS" { - run grep 'os_type="unknown"' "$INIT_SH" - assert_success -} - -# ── os pretty name ──────────────────────────────────────────────────────────── - -@test "init.sh defines get_os_pretty_name function" { - run grep 'get_os_pretty_name()' "$INIT_SH" - assert_success -} - -@test "init.sh gets macOS version via sw_vers" { - run grep 'sw_vers -productVersion' "$INIT_SH" - assert_success -} +# ── flag parsing / help ───────────────────────────────────────────────────────── -@test "init.sh reads PRETTY_NAME from /etc/os-release" { - run grep 'PRETTY_NAME' "$INIT_SH" +@test "init.sh --help exits 0 and prints usage" { + run bash "$INIT_SH" --help assert_success + assert_output --partial "USAGE:" } -# ── yaml parsing ────────────────────────────────────────────────────────────── - -@test "init.sh defines parse_requirements function" { - run grep 'parse_requirements()' "$INIT_SH" +@test "init.sh --help documents the --check flag" { + run bash "$INIT_SH" --help assert_success + assert_output --partial "--check" } -@test "init.sh skips comment lines in YAML" { - run grep '# Skip comments' "$INIT_SH" +@test "init.sh --help documents the --no-direnv flag" { + run bash "$INIT_SH" --help assert_success + assert_output --partial "--no-direnv" } -@test "init.sh skips empty lines in YAML" { - run grep '# Skip.*empty' "$INIT_SH" - assert_success -} - -@test "init.sh detects dependencies section" { - run grep 'dependencies:' "$INIT_SH" - assert_success -} - -@test "init.sh detects optional section" { - run grep 'optional:' "$INIT_SH" - assert_success +@test "init.sh rejects unknown options" { + run bash "$INIT_SH" --definitely-not-a-flag + assert_failure + assert_output --partial "Unknown option" } -@test "init.sh parses dependency name" { - run grep 'name:' "$INIT_SH" - assert_success -} +# ── nix-first prerequisite gate ───────────────────────────────────────────────── -@test "init.sh parses dependency version" { - run grep 'version:' "$INIT_SH" - assert_success +@test "init.sh gates on Nix and points to the installer when absent" { + # Empty PATH: `command -v nix` fails; the gate must fire before any external + # tool is needed (pure shell builtins up to this point). + local stub="$BATS_TEST_TMPDIR/empty-bin" + mkdir -p "$stub" + run env PATH="$stub" "$BASH_BIN" "$INIT_SH" + assert_failure + assert_output --partial "nixos.org/download" } -@test "init.sh parses dependency purpose" { - run grep 'purpose:' "$INIT_SH" - assert_success +@test "init.sh gate explains how to enable flakes + the vig-os cache" { + local stub="$BATS_TEST_TMPDIR/empty-bin2" + mkdir -p "$stub" + run env PATH="$stub" "$BASH_BIN" "$INIT_SH" + assert_failure + assert_output --partial "experimental-features" + assert_output --partial "vig-os.cachix.org" } -@test "init.sh parses dependency required flag" { - run grep 'required:' "$INIT_SH" - assert_success +@test "init.sh tells you to enter the dev shell when the toolchain is missing" { + # nix present (stub), but the dev-shell toolchain (uv) is not on PATH. + local stub="$BATS_TEST_TMPDIR/nix-only-bin" + mkdir -p "$stub" + ln -s "$(command -v true)" "$stub/nix" + run env PATH="$stub" "$BASH_BIN" "$INIT_SH" + assert_failure + assert_output --partial "direnv allow" } -@test "init.sh parses check command" { - run grep 'check:' "$INIT_SH" - assert_success -} +# ── container short-circuit ───────────────────────────────────────────────────── -@test "init.sh parses install commands" { - run grep 'install:' "$INIT_SH" +@test "init.sh is a no-op inside the built image (IN_CONTAINER=true)" { + IN_CONTAINER=true run bash "$INIT_SH" assert_success + assert_output --partial "already provisioned" } -@test "init.sh supports macOS-specific install" { - run grep 'macos) target_var="current_install_macos"' "$INIT_SH" - assert_success -} +# ── check-only mode ───────────────────────────────────────────────────────────── -@test "init.sh supports Debian-specific install" { - run grep 'debian) target_var="current_install_debian"' "$INIT_SH" +@test "init.sh --check verifies prerequisites without bootstrapping" { + command -v nix >/dev/null || skip "nix not on PATH" + command -v uv >/dev/null || skip "uv not on PATH" + run bash "$INIT_SH" --check assert_success + assert_output --partial "Prerequisites" } -@test "init.sh supports Fedora-specific install" { - run grep 'fedora) target_var="current_install_fedora"' "$INIT_SH" - assert_success -} +# ── project bootstrap is wired up ─────────────────────────────────────────────── -@test "init.sh supports Alpine-specific install" { - run grep 'alpine) target_var="current_install_alpine"' "$INIT_SH" +@test "init.sh syncs the project venv from the lockfile" { + run grep 'uv sync --frozen --all-extras' "$INIT_SH" assert_success } -@test "init.sh supports platform-agnostic install" { - run grep 'all) target_var="current_install_all"' "$INIT_SH" +@test "init.sh configures the git hooks path" { + run grep 'core.hooksPath .githooks' "$INIT_SH" assert_success } -@test "init.sh supports manual install instructions" { - run grep 'manual:' "$INIT_SH" - assert_success -} - -# ── requirements file ───────────────────────────────────────────────────────── - -@test "requirements.yaml exists" { - run test -f "$REQUIREMENTS_YAML" +@test "init.sh configures the commit message template" { + run grep 'commit.template .gitmessage' "$INIT_SH" assert_success } -@test "requirements.yaml is readable" { - run test -r "$REQUIREMENTS_YAML" +@test "init.sh installs pre-commit hooks" { + run grep 'pre-commit install-hooks' "$INIT_SH" assert_success } -@test "requirements.yaml contains dependencies section" { - run grep '^dependencies:' "$REQUIREMENTS_YAML" +@test "init.sh probes the host container runtime (advisory)" { + run grep 'podman info' "$INIT_SH" assert_success } -@test "requirements.yaml has at least one dependency" { - run grep '^ - name:' "$REQUIREMENTS_YAML" +@test "init.sh ensures a containers signature policy for podman load" { + # `podman load` (just build) needs a policy.json that `podman info` does not; + # the dev-shell podman ships none, so init must handle it. + run grep 'policy.json' "$INIT_SH" assert_success } -@test "requirements.yaml dependencies have version" { - run grep 'version:' "$REQUIREMENTS_YAML" +@test "init.sh writes the permissive containers policy default" { + run grep 'insecureAcceptAnything' "$INIT_SH" assert_success } -@test "requirements.yaml dependencies have purpose" { - run grep 'purpose:' "$REQUIREMENTS_YAML" +@test "init.sh checks the system containers policy before writing a user one" { + # Idempotent / never-clobber: a system (or user) policy short-circuits the write. + run grep -F '/etc/containers/policy.json' "$INIT_SH" assert_success } -# ── path setup ──────────────────────────────────────────────────────────────── +# ── legacy installer is gone ──────────────────────────────────────────────────── -@test "init.sh derives SCRIPT_DIR from script path" { - run grep 'SCRIPT_DIR=' "$INIT_SH" - assert_success +@test "requirements.yaml has been retired" { + run test -f "$PROJECT_ROOT/scripts/requirements.yaml" + assert_failure } -@test "init.sh derives PROJECT_ROOT as parent of SCRIPT_DIR" { - run grep 'PROJECT_ROOT=' "$INIT_SH" - assert_success +@test "init.sh no longer references requirements.yaml" { + run grep -F 'requirements.yaml' "$INIT_SH" + assert_failure } -@test "init.sh sets REQUIREMENTS_FILE path" { - run grep 'REQUIREMENTS_FILE=' "$INIT_SH" - assert_success +@test "init.sh no longer detects the OS for package installs" { + run grep -E 'detect_os|parse_requirements' "$INIT_SH" + assert_failure } -@test "init.sh defines PYTHON_VERSION" { - run grep 'PYTHON_VERSION=' "$INIT_SH" - assert_success +@test "init.sh no longer hardcodes a Python version" { + run grep 'PYTHON_VERSION' "$INIT_SH" + assert_failure } -# ── output functions ───────────────────────────────────────────────────────── - -@test "init.sh defines print_header function" { - run grep 'print_header()' "$INIT_SH" - assert_success +@test "init.sh no longer installs packages via apt/brew/dnf/apk" { + run grep -E 'apt install|brew install|dnf install|apk add' "$INIT_SH" + assert_failure } -@test "init.sh defines print_section function" { - run grep 'print_section()' "$INIT_SH" - assert_success -} +# ── output helpers retained ───────────────────────────────────────────────────── -@test "init.sh defines log_info function" { +@test "init.sh defines log_info helper" { run grep 'log_info()' "$INIT_SH" assert_success } -@test "init.sh defines log_success function" { - run grep 'log_success()' "$INIT_SH" - assert_success -} - -@test "init.sh defines log_warning function" { - run grep 'log_warning()' "$INIT_SH" - assert_success -} - -@test "init.sh defines log_error function" { +@test "init.sh defines log_error helper" { run grep 'log_error()' "$INIT_SH" assert_success } -@test "init.sh defines log_debug function" { - run grep 'log_debug()' "$INIT_SH" - assert_success -} - -# ── user interaction ────────────────────────────────────────────────────────── - -@test "init.sh defines confirm function" { - run grep 'confirm()' "$INIT_SH" - assert_success -} - -@test "init.sh confirm function uses AUTO_YES" { - run grep 'if \$AUTO_YES' "$INIT_SH" - assert_success -} - -@test "init.sh confirm function reads user input" { - run grep 'read -r response' "$INIT_SH" - assert_success -} - -# ── color support ───────────────────────────────────────────────────────────── - -@test "init.sh defines RED color" { - run grep "RED=" "$INIT_SH" - assert_success -} - -@test "init.sh defines GREEN color" { - run grep "GREEN=" "$INIT_SH" - assert_success -} - -@test "init.sh defines YELLOW color" { - run grep "YELLOW=" "$INIT_SH" - assert_success -} - -@test "init.sh defines BLUE color" { - run grep "BLUE=" "$INIT_SH" - assert_success -} - -@test "init.sh defines CYAN color" { - run grep "CYAN=" "$INIT_SH" - assert_success -} - -@test "init.sh defines BOLD style" { - run grep "BOLD=" "$INIT_SH" - assert_success -} - -@test "init.sh defines NC (no color) reset" { - run grep "NC=" "$INIT_SH" - assert_success -} - -# ── devcontainer local install ─────────────────────────────────────────────── - -@test "requirements.yaml devcontainer check falls back to node_modules/.bin" { - run grep 'node_modules/.bin/devcontainer' "$REQUIREMENTS_YAML" - assert_success -} - -@test "requirements.yaml devcontainer does not use npm install -g" { - run grep 'npm install -g.*devcontainer' "$REQUIREMENTS_YAML" - assert_failure -} +# ── devcontainer CLI check (conftest, unrelated to package installs) ───────────── @test "conftest.py devcontainer check falls back to node_modules/.bin" { run grep 'node_modules/.bin/devcontainer' "$PROJECT_ROOT/tests/conftest.py" diff --git a/tests/bats/install.bats b/tests/bats/install.bats index 797fc5b1..72009fd7 100644 --- a/tests/bats/install.bats +++ b/tests/bats/install.bats @@ -349,5 +349,5 @@ setup() { @test "install.sh has shebang" { run head -1 "$INSTALL_SH" - assert_output "#!/bin/bash" + assert_output "#!/usr/bin/env bash" } diff --git a/tests/bats/prepare-build.bats b/tests/bats/prepare-build.bats deleted file mode 100644 index 12b2a063..00000000 --- a/tests/bats/prepare-build.bats +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env bats -# shellcheck disable=SC2016 -# BATS tests for prepare-build.sh -# -# Tests the prepare-build.sh script which prepares the build directory. -# These tests verify: -# - Build directory creation and cleanup -# - File copying (Containerfile, assets, packages) -# - Manifest syncing (via Python sync_manifest.py) -# - Template placeholder replacement ({{IMAGE_TAG}}) -# - Version handling -# -# Note: SC2016 disabled because we intentionally use single quotes to search -# for literal shell variable syntax (e.g., '$VAR') in the target scripts. - -setup() { - load test_helper - PREPARE_BUILD_SH="$PROJECT_ROOT/scripts/prepare-build.sh" -} - -# ── script structure ────────────────────────────────────────────────────────── - -@test "prepare-build.sh is executable" { - run test -x "$PREPARE_BUILD_SH" - assert_success -} - -@test "prepare-build.sh has shebang" { - run head -1 "$PREPARE_BUILD_SH" - assert_output "#!/usr/bin/env bash" -} - -# ── argument handling ───────────────────────────────────────────────────────── - -@test "prepare-build.sh accepts version as first argument" { - run grep 'VERSION="\${1:-dev}"' "$PREPARE_BUILD_SH" - assert_success -} - -@test "prepare-build.sh defaults version to 'dev'" { - run grep 'BUILD_VERSION="\$VERSION"' "$PREPARE_BUILD_SH" - assert_success -} - -# ── error handling ──────────────────────────────────────────────────────────── - -@test "prepare-build.sh uses strict mode (set -e)" { - run grep 'set -e' "$PREPARE_BUILD_SH" - assert_success -} - -# ── directory setup ─────────────────────────────────────────────────────────── - -@test "prepare-build.sh derives SCRIPT_DIR from script path" { - run grep 'SCRIPT_DIR=' "$PREPARE_BUILD_SH" - assert_success -} - -@test "prepare-build.sh derives PROJECT_ROOT as parent of SCRIPT_DIR" { - run grep 'PROJECT_ROOT=' "$PREPARE_BUILD_SH" - assert_success -} - -@test "prepare-build.sh changes to PROJECT_ROOT" { - run grep 'cd "\$PROJECT_ROOT"' "$PREPARE_BUILD_SH" - assert_success -} - -# ── build directory operations ──────────────────────────────────────────────── - -@test "prepare-build.sh removes existing build directory" { - run grep 'rm -rf "\$BUILD_DIR"' "$PREPARE_BUILD_SH" - assert_success -} - -@test "prepare-build.sh creates fresh build directory" { - run grep 'mkdir -p "\$BUILD_DIR"' "$PREPARE_BUILD_SH" - assert_success -} - -# ── file copying ────────────────────────────────────────────────────────────── - -@test "prepare-build.sh copies Containerfile" { - run grep 'cp Containerfile' "$PREPARE_BUILD_SH" - assert_success -} - -@test "prepare-build.sh copies assets directory recursively" { - run grep 'cp -r assets' "$PREPARE_BUILD_SH" - assert_success -} - -@test "prepare-build.sh copies packages directory recursively" { - run grep 'cp -r packages' "$PREPARE_BUILD_SH" - assert_success -} - -# ── manifest syncing (via Python) ──────────────────────────────────────────── - -@test "prepare-build.sh calls sync_manifest.py for workspace sync" { - run grep 'sync_manifest.py.*sync' "$PREPARE_BUILD_SH" - assert_success -} - -@test "prepare-build.sh passes project-root to sync_manifest.py" { - run grep -- '--project-root "\$PROJECT_ROOT"' "$PREPARE_BUILD_SH" - assert_success -} - -@test "prepare-build.sh syncs into build/assets/workspace" { - run grep 'sync.*\$BUILD_DIR/assets/workspace' "$PREPARE_BUILD_SH" - assert_success -} - -# ── template placeholder replacement ────────────────────────────────────────── - -@test "prepare-build.sh checks for assets/workspace directory" { - run grep 'if \[ -d "\$BUILD_DIR/assets/workspace" \]' "$PREPARE_BUILD_SH" - assert_success -} - -@test "prepare-build.sh replaces {{IMAGE_TAG}} placeholders" { - run grep '{{IMAGE_TAG}}' "$PREPARE_BUILD_SH" - assert_success -} - -@test "prepare-build.sh uses vig-utils for replacements" { - run grep 'uv run vig-utils sed' "$PREPARE_BUILD_SH" - assert_success -} - -@test "prepare-build.sh verifies placeholders were replaced" { - run grep 'grep -r "{{IMAGE_TAG}}"' "$PREPARE_BUILD_SH" - assert_success -} - -@test "prepare-build.sh exits if replacements fail" { - run grep 'exit 1' "$PREPARE_BUILD_SH" - assert_success -} - -@test "prepare-build.sh outputs success for replacements" { - run grep '✓ All {{IMAGE_TAG}} placeholders replaced' "$PREPARE_BUILD_SH" - assert_success -} - -# ── version and readme updates ──────────────────────────────────────────────── - -@test "prepare-build.sh checks for devcontainer README" { - run grep 'BUILD_DEVCONTAINER_README=' "$PREPARE_BUILD_SH" - assert_success -} - -@test "prepare-build.sh skips README update for dev builds" { - run grep 'BUILD_VERSION.*!=.*"dev"' "$PREPARE_BUILD_SH" - assert_success -} - -@test "prepare-build.sh outputs final success message" { - run grep '✓ Build directory prepared' "$PREPARE_BUILD_SH" - assert_success -} diff --git a/tests/bats/test_helper.bash b/tests/bats/test_helper.bash index b0541fce..92a45c45 100644 --- a/tests/bats/test_helper.bash +++ b/tests/bats/test_helper.bash @@ -3,29 +3,17 @@ # Usage (in every .bats file): # setup() { load test_helper; } # -# Library resolution order: -# 1. node_modules/ (local dev via npx bats) -# 2. /usr/lib/ (CI via bats-core/bats-action) -# 3. bats_load_library (fallback, uses BATS_LIB_PATH) +# bats and its helper libraries (bats-support/-assert/-file) come from the Nix +# flake (the toolchain SSoT). The `bats.withLibraries` wrapper and the +# dev-shell/image both export BATS_LIB_PATH, so `bats_load_library` resolves the +# helpers from the Nix store — no node_modules (npm) or /usr/lib (Debian) +# needed. Refs #695. # Resolve project root (two levels up from tests/bats/) PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" export PROJECT_ROOT -# Load BATS helper libraries -if [ -d "${PROJECT_ROOT}/node_modules/bats-support" ]; then - # Development mode: load from local node_modules - load "${PROJECT_ROOT}/node_modules/bats-support/load" - load "${PROJECT_ROOT}/node_modules/bats-assert/load" - load "${PROJECT_ROOT}/node_modules/bats-file/load" -elif [ -d "/usr/lib/bats-support" ]; then - # CI mode: load from system paths (BATS_LIB_PATH) - load "/usr/lib/bats-support/load" - load "/usr/lib/bats-assert/load" - load "/usr/lib/bats-file/load" -else - # Fallback: try using bats_load_library (available in BATS core) - bats_load_library bats-support - bats_load_library bats-assert - bats_load_library bats-file -fi +# Load BATS helper libraries via BATS_LIB_PATH (provided by the flake). +bats_load_library bats-support +bats_load_library bats-assert +bats_load_library bats-file diff --git a/tests/bats/worktree-claude-cli.bats b/tests/bats/worktree-claude-cli.bats new file mode 100644 index 00000000..fe8043aa --- /dev/null +++ b/tests/bats/worktree-claude-cli.bats @@ -0,0 +1,37 @@ +#!/usr/bin/env bats +# BATS tests for the claude-CLI migration of the worktree recipes (#627). +# +# Static recipe-grep checks only: assert that the worktree justfiles drive the +# `claude` CLI and that no `cursor-agent` invocation survives. The full +# functional rewrite of worktree.bats is tracked separately (#630). + +setup() { + load test_helper + WT_MAIN="${PROJECT_ROOT}/justfile.worktree" + WT_TEMPLATE="${PROJECT_ROOT}/assets/workspace/.devcontainer/justfile.worktree" +} + +@test "justfile.worktree has no cursor-agent invocation" { + run grep -nE 'cursor-agent|agent chat' "$WT_MAIN" + assert_failure +} + +@test "template justfile.worktree has no cursor-agent invocation" { + run grep -nE 'cursor-agent|agent chat' "$WT_TEMPLATE" + assert_failure +} + +@test "justfile.worktree drives the claude CLI in tmux sessions" { + run grep -nE 'claude --dangerously-skip-permissions' "$WT_MAIN" + assert_success +} + +@test "template justfile.worktree drives the claude CLI in tmux sessions" { + run grep -nE 'claude --dangerously-skip-permissions' "$WT_TEMPLATE" + assert_success +} + +@test "justfile.worktree checks for the claude binary as a prerequisite" { + run grep -nE 'command -v claude' "$WT_MAIN" + assert_success +} diff --git a/tests/bats/worktree.bats b/tests/bats/worktree.bats index e7e527b7..a1372ebf 100644 --- a/tests/bats/worktree.bats +++ b/tests/bats/worktree.bats @@ -41,20 +41,29 @@ setup() { assert_success } -@test "send-keys 'a' approves agent trust prompt in tmux session" { +# ── claude CLI launches without a trust prompt (#630) ────────────────────────── +# The worktree recipes drive the `claude` CLI with +# `--dangerously-skip-permissions`, which bypasses every permission and MCP +# approval prompt — so there is no interactive trust prompt to send-keys to +# (this replaces the old cursor-agent "send 'a' to approve" flow). Validate that +# the autonomous invocation runs inside a tmux session without stalling on a +# prompt. + +@test "claude CLI launches in tmux without an interactive trust prompt" { [ "${CI:-}" = "true" ] && skip "tmux integration tests require interactive TTY" command -v tmux >/dev/null 2>&1 || skip "tmux not installed" - command -v agent >/dev/null 2>&1 || skip "cursor-agent not installed" + command -v claude >/dev/null 2>&1 || skip "claude CLI not installed" - SESSION="wt-test-trust-$$" - TESTDIR="/tmp/bats-trust-$$" + SESSION="wt-test-claude-$$" + TESTDIR="/tmp/bats-claude-$$" mkdir -p "$TESTDIR" tmux new-session -d -s "$SESSION" -c "$TESTDIR" tmux set-option -t "$SESSION" remain-on-exit on - tmux send-keys -t "$SESSION" "agent chat --yolo --approve-mcps 'say hello'" Enter - sleep 5 - tmux send-keys -t "$SESSION" "a" 2>/dev/null || true + # Launch claude the same way the recipes do, but with a non-interactive + # subcommand: if a trust prompt were shown the pane would stall instead of + # printing the version string. + tmux send-keys -t "$SESSION" "claude --dangerously-skip-permissions --version" Enter sleep 5 run tmux capture-pane -t "$SESSION" -p @@ -62,7 +71,7 @@ setup() { rm -rf "$TESTDIR" assert_success - assert_output --partial "Cursor Agent" + refute_output --partial "trust" } @test "worktree-start detects branch already checked out via worktree list" { diff --git a/tests/conftest.py b/tests/conftest.py index c7bce580..f42f2935 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,6 +29,13 @@ import testinfra import yaml +# Timeout (seconds) for `just sync` to finish during interactive init. The +# test-project pulls heavy scientific extras (numpy, scipy, pandas, matplotlib, +# jupyter) and the image ships no warm uv cache, so a cold/slow network can take +# well over a minute to download them. Generous default, overridable via env for +# fast-cache/CI tuning. Refs: #692. +DEPS_SYNC_TIMEOUT = int(os.environ.get("INIT_DEPS_SYNC_TIMEOUT", "300")) + def pytest_sessionstart(session): """ @@ -162,7 +169,7 @@ def is_running_in_container() -> bool: try: with Path("/proc/1/cgroup").open() as f: return "docker" in f.read() or "podman" in f.read() - except (FileNotFoundError, PermissionError): + except FileNotFoundError, PermissionError: pass return False @@ -347,7 +354,9 @@ def _run_interactive_init(cmd, container_image): ("Replacing placeholders", "replacing_placeholders", 60), ("Setting executable permissions", "setting_permissions", 30), ("Syncing dependencies", "syncing_deps", 60), - ("Workspace initialized successfully", "completed", 30), + # `uv sync` downloads the heavy extras between this line and the success + # banner; allow a generous, env-overridable window. Refs: #692. + ("Workspace initialized successfully", "completed", DEPS_SYNC_TIMEOUT), ] renovate_repo_answer = "test-org/test-project" @@ -363,6 +372,11 @@ def _run_interactive_init(cmd, container_image): current_stage = "org_name_prompt" child.sendline(organization_name) + # Delivery-mode picker (#641): answer "both" to keep the full scaffold + # (prior behaviour) so the downstream structure assertions still hold. + child.expect("Delivery mode", timeout=30) + child.sendline("both") + pattern = "Copying files from" stage_name = "copying_files" timeout = 30 @@ -795,7 +809,7 @@ def _teardown_devcontainer_containers( @pytest.fixture(scope="session") -def devcontainer_up(initialized_workspace): +def devcontainer_up(initialized_workspace, container_tag): """ Set up a devcontainer using devcontainer CLI. @@ -819,6 +833,15 @@ def devcontainer_up(initialized_workspace): if bin_dir not in os.environ.get("PATH", ""): os.environ["PATH"] = bin_dir + os.pathsep + os.environ.get("PATH", "") + # Run the devcontainer from the image *under test*, not the published + # DEVCONTAINER_VERSION baked into the scaffolded .vig-os/.env. The + # scaffolded docker-compose.yml resolves the runtime image as + # ghcr.io/vig-os/devcontainer:${DEVCONTAINER_VERSION:-latest}; compose reads + # the shell environment over the .env file, so exporting DEVCONTAINER_VERSION + # here pins compose -- and every `devcontainer exec` below, which inherits + # os.environ -- to TEST_CONTAINER_TAG. Refs #701. + os.environ["DEVCONTAINER_VERSION"] = container_tag + docker_path = "podman" env, original_config = _prepare_devcontainer_env( workspace_path, docker_path, enable_ssh_forwarding=True @@ -908,7 +931,7 @@ def sidecar_image(): @pytest.fixture(scope="session") -def devcontainer_with_sidecar(initialized_workspace, sidecar_image): +def devcontainer_with_sidecar(initialized_workspace, sidecar_image, container_tag): """ Set up a devcontainer WITH a sidecar for testing multi-container setups. @@ -930,6 +953,10 @@ def devcontainer_with_sidecar(initialized_workspace, sidecar_image): if not _find_devcontainer_cli(): pytest.skip("devcontainer CLI not available. Install with: npm install") + # Pin compose to the image under test, not the published DEVCONTAINER_VERSION + # (see devcontainer_up for the rationale). Refs #701. + os.environ["DEVCONTAINER_VERSION"] = container_tag + docker_path = "podman" env, _ = _prepare_devcontainer_env( workspace_path, docker_path, enable_ssh_forwarding=False @@ -979,7 +1006,7 @@ def devcontainer_with_sidecar(initialized_workspace, sidecar_image): parts = error_message.split("Command failed:") if len(parts) > 1: podman_command = parts[1].strip() - except (json.JSONDecodeError, KeyError): + except json.JSONDecodeError, KeyError: pass # Extract actual podman error from stderr diff --git a/tests/test_flake_checks.py b/tests/test_flake_checks.py new file mode 100644 index 00000000..af3426a5 --- /dev/null +++ b/tests/test_flake_checks.py @@ -0,0 +1,121 @@ +"""Flake quality-gate tests: formatter + ``nix flake check`` (issue #674). + +The flake is the toolchain SSoT but was itself ungated. These tests assert the +two quality gates the flake now exposes: + +* ``flake.formatter.<system>`` is ``nixfmt`` (so ``nix fmt`` formats nix files), +* ``nix flake check`` succeeds (it evaluates the flake and runs the lightweight + ``checks`` — a ``nixfmt --check`` format gate, a dev-shell build, and an eval + of ``devShellTools``). + +The suite is skipped automatically when ``nix`` is not on PATH (mirroring the +dev-shell parity test) so it never breaks unrelated CI lanes. + +Refs: #674 +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +# Repository root (two levels up: tests/ -> repo root). +REPO_ROOT = Path(__file__).resolve().parent.parent + +pytestmark = pytest.mark.skipif( + shutil.which("nix") is None, + reason="nix is not installed; flake quality-gate tests require Nix", +) + + +def _nix_env() -> dict[str, str]: + """Environment for nix invocations with flakes enabled and the public cache.""" + env = os.environ.copy() + env.setdefault( + "NIX_CONFIG", + "experimental-features = nix-command flakes\n" + "extra-substituters = https://vig-os.cachix.org\n" + "extra-trusted-public-keys = " + "vig-os.cachix.org-1:yoOYRi3bvnM6ThxO0joLt7vtzhTfkq3r6jykeUMg7Bk=", + ) + return env + + +def _current_system() -> str: + """The Nix system double for the host (e.g. x86_64-linux).""" + result = subprocess.run( + ["nix", "eval", "--raw", "--impure", "--expr", "builtins.currentSystem"], + capture_output=True, + text=True, + env=_nix_env(), + timeout=120, + ) + if result.returncode != 0: + pytest.fail("Failed to resolve builtins.currentSystem:\n" + result.stderr) + return result.stdout.strip() + + +def test_formatter_is_nixfmt() -> None: + """``flake.formatter.<system>`` must resolve to nixfmt (so ``nix fmt`` works).""" + system = _current_system() + result = subprocess.run( + ["nix", "eval", "--raw", f"{REPO_ROOT}#formatter.{system}.name"], + capture_output=True, + text=True, + env=_nix_env(), + timeout=600, + ) + if result.returncode != 0: + pytest.fail("Failed to read formatter.<system>.name:\n" + result.stderr) + assert "nixfmt" in result.stdout.strip(), ( + f"formatter is not nixfmt: {result.stdout.strip()!r}" + ) + + +def test_checks_output_exposes_format_and_devshell_gates() -> None: + """``flake.checks.<system>`` must expose the lightweight quality gates. + + ``nix flake check`` on a flake with no ``checks`` output trivially succeeds, + so guard the actual gate: assert the ``checks`` attrset names the format + check, the dev-shell build, and the ``devShellTools`` eval. + """ + system = _current_system() + result = subprocess.run( + [ + "nix", + "eval", + "--json", + f"{REPO_ROOT}#checks.{system}", + "--apply", + "builtins.attrNames", + ], + capture_output=True, + text=True, + env=_nix_env(), + timeout=600, + ) + if result.returncode != 0: + pytest.fail("Failed to read checks.<system> attr names:\n" + result.stderr) + names = set(json.loads(result.stdout)) + required = {"format", "devShell", "devShellTools"} + missing = required - names + assert not missing, f"checks output is missing gates: {sorted(missing)}" + + +def test_flake_check_succeeds() -> None: + """``nix flake check`` evaluates the flake and runs the lightweight checks.""" + result = subprocess.run( + ["nix", "flake", "check", "--accept-flake-config", str(REPO_ROOT)], + capture_output=True, + text=True, + env=_nix_env(), + timeout=1800, + ) + assert result.returncode == 0, ( + "nix flake check failed:\n" + result.stdout + "\n" + result.stderr + ) diff --git a/tests/test_flake_devshell.py b/tests/test_flake_devshell.py new file mode 100644 index 00000000..18d99b5c --- /dev/null +++ b/tests/test_flake_devshell.py @@ -0,0 +1,370 @@ +"""Dev-shell / image toolchain parity tests for the Nix flake. + +These tests are the TDD anchor for the toolchain SSoT (issue #631). The flake +exposes a single ``devTools`` list; this module reads the per-tool *binary +names* straight from the flake (``nix eval .#devShellTools``) so the test can +never drift from the list it is meant to guard. + +For every tool in that SSoT it runs ``nix develop -c <bin> <version-flag>`` and +asserts the command exits 0 inside the dev-shell. This guards against +dev-shell / image drift (the ``EXPECTED_VERSIONS`` problem #27 calls out). + +The suite is skipped automatically when ``nix`` is not on PATH (e.g. inside the +podman image CI lane) so it never breaks unrelated jobs. + +Refs: #631 +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +# Repository root (two levels up: tests/ -> repo root). +REPO_ROOT = Path(__file__).resolve().parent.parent + +# Whether the host is NixOS. The dev-shell injects the Nix C++ runtime onto +# LD_LIBRARY_PATH only here: NixOS lacks libstdc++ on the default loader path +# (so the pymarkdown wheel needs it, #698) and its system glibc IS the Nix glibc +# (so the injection is ABI-safe). On FHS hosts the system libstdc++ already +# serves the wheel and the injection would leak a newer-glibc runtime into host +# binaries, breaking them with GLIBC_ABI_DT_X86_64_PLT (#703). +IS_NIXOS = Path("/etc/NIXOS").exists() + +# Tools whose executable name differs from a plain `<tool> --version` call. +# Default version flag is `--version`; override here when a tool differs. +VERSION_FLAG_OVERRIDES: dict[str, list[str]] = { + # expect is a Tcl interpreter; it has no --version. `-v` prints the version. + "expect": ["-v"], + # tmux uses -V (uppercase) to print its version. + "tmux": ["-V"], +} + +pytestmark = pytest.mark.skipif( + shutil.which("nix") is None, + reason="nix is not installed; dev-shell parity tests require Nix", +) + + +def _nix_env() -> dict[str, str]: + """Environment for nix invocations with flakes enabled and the public cache.""" + env = os.environ.copy() + env.setdefault( + "NIX_CONFIG", + "experimental-features = nix-command flakes\n" + "extra-substituters = https://vig-os.cachix.org\n" + "extra-trusted-public-keys = " + "vig-os.cachix.org-1:yoOYRi3bvnM6ThxO0joLt7vtzhTfkq3r6jykeUMg7Bk=", + ) + return env + + +@pytest.fixture(scope="session") +def current_system() -> str: + """The Nix system double for the host (e.g. x86_64-linux).""" + result = subprocess.run( + ["nix", "eval", "--raw", "--impure", "--expr", "builtins.currentSystem"], + capture_output=True, + text=True, + env=_nix_env(), + timeout=120, + ) + if result.returncode != 0: + pytest.fail("Failed to resolve builtins.currentSystem:\n" + result.stderr) + return result.stdout.strip() + + +@pytest.fixture(scope="session") +def dev_shell_env() -> dict[str, str]: + """Environment variables exported by the Nix dev-shell. + + Runs ``nix develop -c env`` once and parses the result so the UV-bootstrap + assertions below share a single (slow) shell instantiation. + """ + result = subprocess.run( + ["nix", "develop", str(REPO_ROOT), "-c", "env"], + capture_output=True, + text=True, + env=_nix_env(), + timeout=900, + ) + if result.returncode != 0: + pytest.fail("Failed to capture the dev-shell environment:\n" + result.stderr) + env: dict[str, str] = {} + for line in result.stdout.splitlines(): + key, sep, value = line.partition("=") + if sep: + env[key] = value + return env + + +@pytest.mark.skipif( + not IS_NIXOS, + reason=( + "The Nix C++ runtime is injected onto LD_LIBRARY_PATH only on NixOS; " + "FHS hosts resolve libstdc++ from the system loader (#703)" + ), +) +def test_devshell_ld_library_path_provides_libstdcpp( + dev_shell_env: dict[str, str], +) -> None: + """On NixOS the dev-shell exposes ``libstdc++.so.6`` on ``LD_LIBRARY_PATH`` (#698). + + The ``pymarkdown`` pre-commit hook runs from pre-commit's own manylinux-wheel + Python env, whose dependency ``pyjson5`` is a C extension linked against + ``libstdc++.so.6``. On a NixOS host that library is not on the loader path + outside an FHS environment, so the hook fails with + ``ImportError: libstdc++.so.6: cannot open shared object file``. The dev-shell + therefore exports ``LD_LIBRARY_PATH`` including the Nix C++ runtime so the + wheel resolves it (the same libstdc++ the Nix toolchain itself links, so no + version clash with the other dev-shell binaries). The injection is gated to + NixOS (#703), so this assertion only applies there. + """ + lib_path = dev_shell_env.get("LD_LIBRARY_PATH", "") + assert lib_path, "LD_LIBRARY_PATH must be set in the dev-shell" + roots = [Path(p) for p in lib_path.split(":") if p] + assert any((root / "libstdc++.so.6").exists() for root in roots), ( + f"libstdc++.so.6 not found under any LD_LIBRARY_PATH entry: {lib_path}" + ) + + +@pytest.mark.skipif( + not IS_NIXOS, + reason=( + "Exercises the NixOS-only LD_LIBRARY_PATH injection; FHS hosts resolve " + "libstdc++ from the system loader (#703)" + ), +) +def test_devshell_pymarkdown_c_extension_imports(dev_shell_env: dict[str, str]) -> None: + """``pyjson5``'s C extension must load under the dev-shell loader on NixOS (#698). + + Mirrors how the ``pymarkdown`` hook fails: load the manylinux C library with + the dev-shell's ``LD_LIBRARY_PATH`` in scope. With ``libstdc++`` on the loader + path the load succeeds; without it it raises the ``libstdc++.so.6`` + ``ImportError`` the hook hit on NixOS. Gated to NixOS, where the injection is + active (#703). + """ + lib_path = dev_shell_env.get("LD_LIBRARY_PATH", "") + assert lib_path, "LD_LIBRARY_PATH must be set in the dev-shell" + libstdcpp = next( + ( + p + for p in (Path(d) / "libstdc++.so.6" for d in lib_path.split(":") if d) + if p.exists() + ), + None, + ) + assert libstdcpp is not None, ( + f"libstdc++.so.6 not found under LD_LIBRARY_PATH: {lib_path}" + ) + # ctypes.CDLL exercises the exact dynamic-loader path the C extension uses, + # without depending on pyjson5 being installed in the project venv. + proc = subprocess.run( + [ + sys.executable, + "-c", + f"import ctypes; ctypes.CDLL({str(libstdcpp)!r}); print('ok')", + ], + capture_output=True, + text=True, + env={**os.environ, "LD_LIBRARY_PATH": lib_path}, + timeout=120, + ) + assert proc.returncode == 0 and "ok" in proc.stdout, ( + f"loading libstdc++ via LD_LIBRARY_PATH failed: rc={proc.returncode} " + f"stdout={proc.stdout!r} stderr={proc.stderr!r}" + ) + + +@pytest.mark.skipif( + IS_NIXOS, + reason=( + "On NixOS the system glibc IS the Nix glibc, so injecting the Nix C++ " + "runtime onto LD_LIBRARY_PATH is ABI-safe and required; this leak guard " + "only applies to FHS hosts (#703)" + ), +) +def test_devshell_no_nix_cxx_runtime_leak_on_fhs_host( + dev_shell_env: dict[str, str], +) -> None: + """On an FHS host the dev-shell must not put the Nix C++ runtime on ``LD_LIBRARY_PATH`` (#703). + + The Nix ``libstdc++`` is linked against a newer glibc (2.42) than an FHS + host's system glibc (e.g. Ubuntu 24.04 ships 2.39). Exporting it on + ``LD_LIBRARY_PATH`` leaks it into host binaries — notably ``/usr/bin/env``, + which every ``just`` recipe shebang invokes — dragging in the Nix + ``libm.so.6`` and aborting with ``version 'GLIBC_ABI_DT_X86_64_PLT' not + found``. FHS hosts already carry ``libstdc++`` on the default loader path, so + the #698 injection is gated to NixOS; here it must be absent. + """ + lib_path = dev_shell_env.get("LD_LIBRARY_PATH", "") + leaked = [ + entry + for entry in lib_path.split(":") + if entry.startswith("/nix/store/") and (Path(entry) / "libstdc++.so.6").exists() + ] + assert not leaked, ( + "FHS dev-shell must not expose the Nix C++ runtime on LD_LIBRARY_PATH " + "(it breaks host binaries linked against an older system glibc with " + f"GLIBC_ABI_DT_X86_64_PLT); leaked entries: {leaked}" + ) + + +def test_devshell_disables_uv_python_downloads(dev_shell_env: dict[str, str]) -> None: + """The dev-shell must forbid uv from downloading a managed CPython (#683). + + On a NixOS host a downloaded generic CPython is a dynamically-linked ELF the + host cannot execute out of the box, so ``uv sync`` (``just init``) aborts. + ``UV_PYTHON_DOWNLOADS=never`` forces uv to resolve a Nix store interpreter + instead, mirroring the image path. + """ + assert dev_shell_env.get("UV_PYTHON_DOWNLOADS") == "never", ( + "UV_PYTHON_DOWNLOADS must be 'never' so uv never fetches a generic " + f"CPython; got {dev_shell_env.get('UV_PYTHON_DOWNLOADS')!r}" + ) + + +def test_devshell_uv_python_pins_nix_store_interpreter( + dev_shell_env: dict[str, str], +) -> None: + """UV_PYTHON must point at a runnable Nix store CPython 3.14 (#683). + + A store interpreter is patched to the store's loader, so it runs on both + NixOS and FHS hosts — the cross-host fix for the failed ``uv sync``. + """ + uv_python = dev_shell_env.get("UV_PYTHON") + assert uv_python, "UV_PYTHON must be set in the dev-shell" + assert uv_python.startswith("/nix/store/"), ( + f"UV_PYTHON must be a Nix store interpreter, not {uv_python!r}" + ) + interpreter = Path(uv_python) + assert interpreter.is_file() and os.access(interpreter, os.X_OK), ( + f"UV_PYTHON does not point at an executable file: {uv_python}" + ) + proc = subprocess.run( + [uv_python, "--version"], + capture_output=True, + text=True, + timeout=120, + ) + assert proc.returncode == 0 and "3.14" in proc.stdout, ( + f"UV_PYTHON did not report Python 3.14: rc={proc.returncode} " + f"stdout={proc.stdout!r} stderr={proc.stderr!r}" + ) + + +@pytest.fixture(scope="session") +def dev_shell_tools(current_system: str) -> list[str]: + """Binary names of every tool in the flake's ``devTools`` SSoT.""" + result = subprocess.run( + ["nix", "eval", "--json", f"{REPO_ROOT}#devShellTools.{current_system}"], + capture_output=True, + text=True, + env=_nix_env(), + timeout=900, + ) + if result.returncode != 0: + pytest.fail("Failed to read devShellTools from the flake:\n" + result.stderr) + tools = json.loads(result.stdout) + assert isinstance(tools, list) and tools, "devShellTools must be a non-empty list" + return tools + + +def test_devshell_tools_is_superset_of_agent_toolkit( + dev_shell_tools: list[str], +) -> None: + """The SSoT must absorb issue #545's agent-CLI toolkit plus claude.""" + required = { + "rg", + "fd", + "bat", + "eza", + "delta", + "lazygit", + "zoxide", + "starship", + "freeze", + "expect", + "nvim", + "claude", + } + missing = required - set(dev_shell_tools) + assert not missing, f"devTools is missing agent-toolkit tools: {sorted(missing)}" + + +def test_devshell_provides_bats(dev_shell_tools: list[str]) -> None: + """The flake must provide BATS so shell tests run without npm (#695). + + The BATS helper libraries (bats-support/-assert/-file) were resolved from + ``node_modules`` (npm) or the now-removed Debian ``/usr/lib`` path. On the + Nix toolchain neither exists locally, so the suite must come from the flake + SSoT: ``bats.withLibraries`` puts ``bats`` on PATH and exports a + ``BATS_LIB_PATH`` covering the helper libraries. + """ + assert "bats" in dev_shell_tools, ( + "devTools must provide 'bats' (via bats.withLibraries) so the BATS " + "suite resolves its helper libraries from the flake, not node_modules" + ) + + +def test_devshell_provides_precommit_binary_hooks( + dev_shell_tools: list[str], +) -> None: + """The flake must provide ruff and typos so their pre-commit hooks run (#697). + + These hooks were sourced from upstream manylinux wheels + (``astral-sh/ruff-pre-commit``, ``crate-ci/typos``) that a NixOS host cannot + execute (no FHS ``ld-linux``), forcing ``--no-verify`` on every commit. They + are now ``language: system`` hooks that resolve their tool from the flake + dev-shell, so ``ruff`` and ``typos`` must be in the ``devTools`` SSoT. + """ + required = {"ruff", "typos"} + missing = required - set(dev_shell_tools) + assert not missing, ( + "devTools must provide the binary pre-commit tools so their " + f"language: system hooks resolve from the flake: missing {sorted(missing)}" + ) + + +def test_devshell_bats_lib_path_resolves_helpers(dev_shell_env: dict[str, str]) -> None: + """BATS_LIB_PATH in the dev-shell must expose the three helper libraries. + + ``bats_load_library bats-support`` (test_helper.bash) only works when + ``BATS_LIB_PATH`` points at a directory containing the helper libraries. + The ``bats.withLibraries`` wrapper exports it; assert the libraries are + actually reachable through it. Refs #695. + """ + lib_path = dev_shell_env.get("BATS_LIB_PATH", "") + assert lib_path, "BATS_LIB_PATH must be set in the dev-shell" + roots = [Path(p) for p in lib_path.split(":") if p] + for lib in ("bats-support", "bats-assert", "bats-file"): + assert any((root / lib).is_dir() for root in roots), ( + f"{lib} not found under any BATS_LIB_PATH entry: {lib_path}" + ) + + +def test_each_tool_runs_in_devshell(dev_shell_tools: list[str]) -> None: + """Every tool in ``devTools`` is runnable inside ``nix develop``.""" + failures: list[str] = [] + for tool in dev_shell_tools: + flag = VERSION_FLAG_OVERRIDES.get(tool, ["--version"]) + cmd = ["nix", "develop", str(REPO_ROOT), "-c", tool, *flag] + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + env=_nix_env(), + timeout=900, + ) + if proc.returncode != 0: + failures.append( + f"{tool} ({' '.join(flag)}) exited {proc.returncode}: " + f"{proc.stderr.strip()[:200]}" + ) + assert not failures, "Tools failed inside nix develop:\n" + "\n".join(failures) diff --git a/tests/test_image.py b/tests/test_image.py index 23735d4d..dfe7f7a7 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -10,30 +10,29 @@ """ import hashlib +import subprocess from pathlib import Path import pytest -# Expected versions for installed tools -# These should be updated when the Containerfile is updated +# Expected version prefixes for the few tools whose version we still assert. +# +# Under the Nix image the toolchain is pinned by flake.lock, so each tool's exact +# version is determined by nixpkgs and intentionally changes on a nixpkgs bump. +# Fast-movers (gh) and tools whose nixpkgs version simply differs from the old +# Debian pin (just, pre-commit, cargo-binstall, typstyle) are checked for +# presence/run only, not a version prefix — otherwise they'd need updating on +# every nixpkgs bump. System packages (git, curl, tmux, rsync) were already +# presence-only. Refs #635, #666. EXPECTED_VERSIONS = { - "git": "2.", # Major version check (from apt package) - "curl": "8.", # Major version check (from apt package) - "gh": "2.95.", # Minor version check (GitHub CLI, manually installed from latest release) - "uv": "0.11.", # Minor version check (manually installed from latest release) - "python": "3.14", # Python (from base image) - "pre_commit": "4.6.", # Minor version check (installed via uv pip) - "ruff": "0.15.", # Minor version check (installed via uv pip) - "bandit": "1.9.", # Minor version check (installed via uv pip) - "pip_licenses": "5.", # Major version check (installed via uv pip) - "just": "1.54.", # Minor version check (manually installed from latest release) - "hadolint": "2.14.", # Minor version check (manually installed from pinned release) - "taplo": "0.10.", # Minor version check (manually installed from latest release) - "cargo-binstall": "1.20.", # Minor version check (installed from latest release) - "typstyle": "0.15.", # Minor version check (installed from latest release) - "vig_utils": "0.1.", # Minor version check (installed via uv pip) - "tmux": "3.3", # Major.minor version check (from apt package) - "rsync": "3.2", # Major.minor version check (from apt package) + "uv": "0.11.", # uv (fast-mover overlaid from nixpkgs-unstable) + "python": "3.14", # interpreter major.minor (pinned to python314) + "ruff": "0.15.", # nixpkgs-26.05 + "bandit": "1.9.", # nixpkgs-26.05 + "pip_licenses": "5.", # PyPI wheel pinned in flake.nix + "hadolint": "2.14.", # nixpkgs-26.05 + "taplo": "0.10.", # nixpkgs-26.05 + "vig_utils": "0.1.", # our package version } @@ -74,84 +73,137 @@ def verify_file_identity(host, src_rel, dest_path): ) +def assert_tool_on_path(host, tool): + """ + Assert that a tool is installed and resolvable on PATH. + + Path-agnostic: works for both the Debian image (tools under /usr/bin, + /usr/local/bin) and the Nix image (tools under the Nix store), since it + relies on PATH resolution rather than a hardcoded FHS location. + + Args: + host: testinfra host object + tool: executable name to resolve (e.g. "gh", "just") + + Returns: + The resolved absolute path to the tool. + """ + result = host.run(f"command -v {tool}") + assert result.rc == 0, f"{tool} not found on PATH: {result.stderr}" + resolved = result.stdout.strip() + assert resolved, f"{tool} resolved to an empty path" + return resolved + + +def assert_tool_runs(host, *cmd): + """ + Assert that a tool runs successfully (exit code 0), proving it is installed. + + Path-agnostic replacement for distro-package checks (e.g. dpkg + `is_installed`): valid for both the Debian and Nix images. + + Args: + host: testinfra host object + cmd: command and args to run (e.g. "git", "--version") + + Returns: + The testinfra CommandResult. + """ + command = " ".join(cmd) + result = host.run(command) + assert result.rc == 0, f"{command} failed (tool not installed?): {result.stderr}" + return result + + +def test_image_oci_config_declares_path(container_image): + """The image's OCI config.Env must declare PATH including the toolchain (#697). + + ``buildLayeredImage`` symlinks every tool into ``/bin`` but sets no PATH in + the OCI config. ``podman run`` masks this by injecting a default PATH, but + docker-compose and ``devcontainer exec`` inherit ``config.Env`` verbatim — so + without a declared PATH the baked toolchain is off PATH there, and + pre-commit's ``language: system`` ruff/typos hooks fail with + ``Executable ... not found`` during an in-container ``git commit``. A + ``host.run`` check cannot catch this (its shell synthesises a default PATH), + so assert the declared config directly. + """ + result = subprocess.run( + [ + "podman", + "inspect", + container_image, + "--format", + "{{range .Config.Env}}{{println .}}{{end}}", + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"podman inspect failed: {result.stderr}" + path_lines = [ln for ln in result.stdout.splitlines() if ln.startswith("PATH=")] + assert path_lines, ( + "image OCI config declares no PATH; docker-compose / devcontainer exec " + "would run without the baked toolchain on PATH" + ) + path_dirs = path_lines[0][len("PATH=") :].split(":") + assert "/bin" in path_dirs, ( + f"image PATH must include /bin (the toolchain symlink dir): {path_lines[0]}" + ) + + class TestSystemTools: """Test that system tools are installed with correct versions.""" def test_git_installed(self, host): - """Test that git is installed.""" - assert host.package("git").is_installed, "Git is not installed" + """Test that git is installed (path-agnostic, via --version).""" + assert_tool_runs(host, "git", "--version") def test_git_version(self, host): - """Test that git version is correct.""" + """Test that git runs and reports a version.""" result = host.run("git --version") assert result.rc == 0, "git --version failed" assert "git version" in result.stdout.lower() - expected = EXPECTED_VERSIONS["git"] - assert expected in result.stdout, ( - f"Expected git {expected}x, got: {result.stdout}" - ) def test_curl_installed(self, host): - """Test that curl is installed.""" - assert host.package("curl").is_installed, "curl is not installed" + """Test that curl is installed (path-agnostic, via --version).""" + assert_tool_runs(host, "curl", "--version") def test_curl_version(self, host): - """Test that curl version is correct.""" + """Test that curl runs and reports a version.""" result = host.run("curl --version") assert result.rc == 0, "curl --version failed" assert "curl" in result.stdout.lower() - expected = EXPECTED_VERSIONS["curl"] - assert expected in result.stdout, ( - f"Expected curl {expected}x, got: {result.stdout}" - ) def test_openssh_client_installed(self, host): - """Test that openssh-client is installed.""" - assert host.package("openssh-client").is_installed, ( - "openssh-client is not installed" - ) + """Test that the openssh client is installed (path-agnostic).""" + assert_tool_runs(host, "ssh", "-V") def test_nano_installed(self, host): - """Test that nano is installed.""" - assert host.package("nano").is_installed, "nano is not installed" + """Test that nano is installed (path-agnostic, via --version).""" + assert_tool_runs(host, "nano", "--version") def test_gh_installed(self, host): - """Test that GitHub CLI (gh) is installed.""" - # gh is manually installed, so check for the binary file - assert host.file("/usr/local/bin/gh").exists, "GitHub CLI (gh) binary not found" - assert host.file("/usr/local/bin/gh").is_file, "GitHub CLI (gh) is not a file" + """Test that GitHub CLI (gh) is installed (path-agnostic).""" + assert_tool_on_path(host, "gh") def test_gh_version(self, host): - """Test that gh version is correct.""" + """Test that gh runs (version is nixpkgs-pinned via flake.lock, not asserted).""" result = host.run("gh --version") assert result.rc == 0, "gh --version failed" assert "gh version" in result.stdout.lower() - expected = EXPECTED_VERSIONS["gh"] - assert expected in result.stdout, ( - f"Expected gh {expected}, got: {result.stdout}" - ) def test_just_installed(self, host): - """Test that just is installed.""" - # just is manually installed, so check for the binary file - assert host.file("/usr/local/bin/just").exists, "just binary not found" - assert host.file("/usr/local/bin/just").is_file, "just is not a file" + """Test that just is installed (path-agnostic).""" + assert_tool_on_path(host, "just") def test_just_version(self, host): - """Test that just version is correct.""" + """Test that just runs (version is nixpkgs-pinned via flake.lock, not asserted).""" result = host.run("just --version") assert result.rc == 0, "just --version failed" assert "just" in result.stdout.lower() - expected = EXPECTED_VERSIONS["just"] - assert expected in result.stdout, ( - f"Expected just {expected}, got: {result.stdout}" - ) def test_hadolint_installed(self, host): - """Test that hadolint is installed.""" - # hadolint is manually installed, so check for the binary file - assert host.file("/usr/local/bin/hadolint").exists, "hadolint binary not found" - assert host.file("/usr/local/bin/hadolint").is_file, "hadolint is not a file" + """Test that hadolint is installed (path-agnostic).""" + assert_tool_on_path(host, "hadolint") def test_hadolint_version(self, host): """Test that hadolint version is correct.""" @@ -163,9 +215,8 @@ def test_hadolint_version(self, host): ) def test_taplo_installed(self, host): - """Test that taplo (TOML formatter/linter) is installed.""" - assert host.file("/usr/local/bin/taplo").exists, "taplo binary not found" - assert host.file("/usr/local/bin/taplo").is_file, "taplo is not a file" + """Test that taplo (TOML formatter/linter) is installed (path-agnostic).""" + assert_tool_on_path(host, "taplo") def test_taplo_version(self, host): """Test that taplo version is correct.""" @@ -176,29 +227,15 @@ def test_taplo_version(self, host): f"Expected taplo {expected}, got: {result.stdout}" ) - def test_cursor_agent_installed(self, host): - """Test that cursor-agent CLI (agent) is installed.""" - result = host.run("agent --version") - if result.rc != 0: - pytest.skip("cursor-agent not available (external CDN issue)") - def test_cargo_binstall(self, host): - """Test that cargo-binstall is installed and right version.""" + """Test that cargo-binstall runs (version nixpkgs-pinned, not asserted).""" result = host.run("cargo-binstall -V") assert result.rc == 0, "cargo-binstall -V failed" - expected = EXPECTED_VERSIONS["cargo-binstall"] - assert expected in result.stdout, ( - f"Expected cargo-binstall {expected}, got: {result.stdout}" - ) def test_typstyle(self, host): - """Test that typstyle is installed and right version.""" + """Test that typstyle runs (version nixpkgs-pinned, not asserted).""" result = host.run("typstyle --version") assert result.rc == 0, "typstyle --version failed" - expected = EXPECTED_VERSIONS["typstyle"] - assert expected in result.stdout, ( - f"Expected typstyle {expected}, got: {result.stdout}" - ) def test_just_lsp_installed(self, host): """Test that just-lsp is installed.""" @@ -209,30 +246,14 @@ def test_just_lsp_installed(self, host): ) def test_tmux_installed(self, host): - """Test that tmux is installed.""" - assert host.package("tmux").is_installed, "tmux is not installed" - - def test_tmux_version(self, host): - """Test that tmux version is correct.""" - result = host.run("tmux -V") - assert result.rc == 0, "tmux -V failed" - expected = EXPECTED_VERSIONS["tmux"] - assert expected in result.stdout, ( - f"Expected tmux {expected}, got: {result.stdout}" - ) + """Test that tmux is installed (path-agnostic, via -V).""" + result = assert_tool_runs(host, "tmux", "-V") + assert "tmux" in result.stdout.lower() def test_rsync_installed(self, host): - """Test that rsync is installed.""" - assert host.package("rsync").is_installed, "rsync is not installed" - - def test_rsync_version(self, host): - """Test that rsync version is correct.""" - result = host.run("rsync --version") - assert result.rc == 0, "rsync --version failed" - expected = EXPECTED_VERSIONS["rsync"] - assert expected in result.stdout, ( - f"Expected rsync {expected}, got: {result.stdout}" - ) + """Test that rsync is installed (path-agnostic, via --version).""" + result = assert_tool_runs(host, "rsync", "--version") + assert "rsync" in result.stdout.lower() def test_tmux_detached_session_survives(self, host): """Test that tmux can create a detached session with a background process.""" @@ -365,14 +386,10 @@ class TestDevelopmentTools: """Test that development tools are installed.""" def test_pre_commit_installed(self, host): - """Test that pre-commit is installed.""" + """Test that pre-commit runs (version nixpkgs-pinned via flake.lock).""" result = host.run("pre-commit --version") assert result.rc == 0, "pre-commit --version failed" assert "pre-commit" in result.stdout.lower() - expected = EXPECTED_VERSIONS["pre_commit"] - assert expected in result.stdout, ( - f"Expected pre-commit {expected}, got: {result.stdout}" - ) def test_ruff_installed(self, host): """Test that ruff is installed.""" @@ -465,8 +482,9 @@ class TestEnvironmentVariables: @pytest.mark.parametrize( ("name", "expected"), + # DEBIAN_FRONTEND is intentionally omitted: it is a Debian/apt-specific + # build-time variable that is not meaningful on the Nix image. [ - ("DEBIAN_FRONTEND", "noninteractive"), ("LANG", "en_US.UTF-8"), ("LANGUAGE", "en_US:en"), ("LC_ALL", "en_US.UTF-8"), @@ -477,7 +495,6 @@ class TestEnvironmentVariables: ("VIRTUAL_ENV", "/root/assets/workspace/.venv"), ], ids=[ - "debian_frontend", "lang", "language", "lc_all", @@ -497,21 +514,22 @@ def test_env_vars_set(self, host, name, expected): ) @pytest.mark.parametrize( - "path_entry", + "tool", [ - "/root/.local/bin", - "/root/.cargo/bin", + "cargo-binstall", + "typstyle", ], - ids=["cursor_agent_path", "cargo_path"], + ids=["cargo_binstall_on_path", "cargo_tool_on_path"], ) - def test_path_contains_required_entries(self, host, path_entry): - """Test that PATH includes required binary locations.""" - result = host.run("printenv PATH") - assert result.rc == 0, "Failed to read PATH" - path_entries = result.stdout.strip().split(":") - assert path_entry in path_entries, ( - f"Expected PATH to contain {path_entry}, got: {result.stdout.strip()}" - ) + def test_path_resolves_required_tools(self, host, tool): + """Test that cargo-installed tools resolve on PATH. + + Path-agnostic replacement for asserting hardcoded install dirs + (e.g. /root/.cargo/bin) are on PATH: we instead verify the tools + those dirs provide are reachable, which holds for both the Debian + and Nix images. + """ + assert_tool_on_path(host, tool) class TestFileStructure: @@ -609,19 +627,19 @@ def test_assets_workspace_structure(self, host): ) def test_workspace_template_pre_commit_hooks_initialized(self, host): - """Test that pre-commit hooks are pre-initialized at system cache location.""" - # Pre-commit cache is built to /opt/pre-commit-cache (not in workspace assets) - # This allows init-workspace.sh to skip excluding it during copy + """Test that the pre-commit cache dir exists at the system cache location. + + The dir is `PRE_COMMIT_HOME=/opt/pre-commit-cache` (set in the image env) + so init-workspace.sh need not exclude it during copy. Unlike the Debian + build, a hermetic Nix build cannot pre-fetch the hook repos (no network), + so we assert the cache *directory* is present; it populates on the first + `pre-commit run` / `install-hooks`. + """ cache_dir = host.file("/opt/pre-commit-cache") assert cache_dir.exists, ( "Pre-commit cache directory not found at /opt/pre-commit-cache" ) assert cache_dir.is_directory, "Pre-commit cache is not a directory" - # Verify the cache directory is not empty (contains installed hooks) - result = host.run('test -n "$(ls -A /opt/pre-commit-cache 2>/dev/null)"') - assert result.rc == 0, ( - "Pre-commit cache directory is empty - hooks were not initialized" - ) def test_manifest_files(self, host, parse_manifest): """Test that all files in manifest are copied to the image. diff --git a/tests/test_install_script.py b/tests/test_install_script.py index 727f5375..ae2e2fad 100644 --- a/tests/test_install_script.py +++ b/tests/test_install_script.py @@ -30,7 +30,8 @@ class TestInstallScriptIntegration: """ @pytest.fixture(scope="class") - def install_workspace(self, container_image): + @staticmethod + def install_workspace(container_image): """ Deploy devcontainer using install.sh (not init-workspace.sh directly). Tests the full user-facing workflow. @@ -385,3 +386,42 @@ def test_install_git_all_files_committed(self, install_workspace): assert result.returncode == 0, "Failed to check git status" # Should be empty (no uncommitted changes) assert not result.stdout.strip(), f"Found uncommitted changes:\n{result.stdout}" + + +class TestHostScriptShebangPortability: + """Assert host-executed scripts use a portable shebang. + + These scripts run on the *host* (not inside the container), so they must + not hardcode ``#!/bin/bash``: NixOS and other distros that follow the + Filesystem Hierarchy Standard loosely have no ``/bin/bash``, which makes + them fail to execute. The portable form ``#!/usr/bin/env bash`` resolves + ``bash`` via ``PATH`` and works everywhere. Refs #687. + + This is a pure content check — it needs no built container image — so it + runs in any pytest invocation that collects this module. + """ + + # Host-executed scripts that must carry the portable shebang. Scoped to + # the three scripts in issue #687; the broader in-container sweep is out + # of scope. + HOST_SCRIPTS = ( + "install.sh", + "assets/workspace/.devcontainer/scripts/initialize.sh", + "assets/workspace/.devcontainer/scripts/version-check.sh", + ) + + PORTABLE_SHEBANG = "#!/usr/bin/env bash" + + @pytest.mark.parametrize("rel_path", HOST_SCRIPTS) + def test_host_script_uses_portable_shebang(self, rel_path): + """Each host-executed script must start with #!/usr/bin/env bash.""" + project_root = Path(__file__).resolve().parents[1] + script = project_root / rel_path + assert script.exists(), f"Expected host script not found: {rel_path}" + + first_line = script.read_text().splitlines()[0] + assert first_line == self.PORTABLE_SHEBANG, ( + f"{rel_path} must use the portable shebang " + f"'{self.PORTABLE_SHEBANG}' (NixOS has no /bin/bash), " + f"but found: {first_line!r}" + ) diff --git a/tests/test_integration.py b/tests/test_integration.py index 0ee3e201..e53c8ed5 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -173,7 +173,14 @@ def test_allowed_signers_file_exists(self): # Verify it has some content content = allowed_signers.read_text() assert len(content.strip()) > 0, "Allowed signers file is empty" - assert "ssh-ed25519" in content or "ssh-rsa" in content, ( + key_types = ( + "ssh-ed25519", + "ssh-rsa", + "ecdsa-sha2-nistp", + "sk-ssh-ed25519@openssh.com", + "sk-ecdsa-sha2-nistp256@openssh.com", + ) + assert any(k in content for k in key_types), ( "Allowed signers file doesn't appear to contain SSH public keys" ) @@ -1434,6 +1441,48 @@ def test_setup_git_conf_falls_back_to_nano_for_invalid_editor( class TestDevContainerCLI: """Tests for the devcontainer CLI environment.""" + def test_devcontainer_runs_image_under_test(self, devcontainer_up, container_tag): + """The running devcontainer must use the freshly-built image under test. + + The scaffolded docker-compose.yml pins the runtime image as + ``ghcr.io/vig-os/devcontainer:${DEVCONTAINER_VERSION:-latest}`` and + ``initialize.sh`` writes the pinned ``DEVCONTAINER_VERSION`` (from the + scaffolded ``.vig-os``) into ``.env``. Without an override the suite + would validate fresh scaffolding running inside an old *published* + image, not the image actually being built. The ``devcontainer_up`` + fixture overrides ``DEVCONTAINER_VERSION`` to ``TEST_CONTAINER_TAG`` so + compose resolves the image to the build under test. Refs #701. + """ + workspace_path = devcontainer_up.resolve() + + result = subprocess.run( + [ + "podman", + "ps", + "--filter", + f"name={workspace_path.name}", + "--format", + "{{.Image}}", + ], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, ( + f"Failed to list running devcontainer\nstderr: {result.stderr}" + ) + images = [line.strip() for line in result.stdout.splitlines() if line.strip()] + assert images, ( + f"No running devcontainer found for workspace {workspace_path.name}" + ) + + expected_image = f"ghcr.io/vig-os/devcontainer:{container_tag}" + assert any(expected_image in image for image in images), ( + f"Devcontainer is running from {images}, but the suite must validate " + f"the image under test ({expected_image}). DEVCONTAINER_VERSION is not " + f"being overridden to TEST_CONTAINER_TAG." + ) + def test_ssh_github_authentication(self, devcontainer_up): """Test that SSH authentication to GitHub works in the devcontainer.""" workspace_path = str(devcontainer_up.resolve()) diff --git a/tests/test_transforms.py b/tests/test_transforms.py index f88c3f96..5c48203b 100644 --- a/tests/test_transforms.py +++ b/tests/test_transforms.py @@ -2,16 +2,17 @@ from __future__ import annotations +import importlib.util +import json import sys from pathlib import Path scripts_dir = Path(__file__).parent.parent / "scripts" -sys.path.insert(0, str(scripts_dir.parent)) +project_root = scripts_dir.parent +sys.path.insert(0, str(project_root)) def _load_transforms(): - import importlib.util - spec = importlib.util.spec_from_file_location( "transforms", scripts_dir / "transforms.py" ) @@ -21,6 +22,16 @@ def _load_transforms(): return module +def _load_sync_manifest(): + spec = importlib.util.spec_from_file_location( + "sync_manifest", scripts_dir / "sync_manifest.py" + ) + module = importlib.util.module_from_spec(spec) + sys.modules["sync_manifest"] = module + spec.loader.exec_module(module) + return module + + class TestTransformsModule: """Test that transforms module exists and exports transform classes.""" @@ -47,6 +58,21 @@ def test_remove_lines_transform_removes_matching_lines(self, tmp_path): assert f.read_text() == "keep\nkeep\n" +class TestWorkspaceInterpreterPath: + """The synced workspace settings must point at the workspace venv.""" + + def test_synced_settings_uses_workspace_relative_venv(self, tmp_path): + """Syncing must leave the python interpreter workspace-relative, never /opt/venv.""" + sync_manifest = _load_sync_manifest() + sync_manifest.sync(project_root, tmp_path) + + settings = json.loads((tmp_path / ".vscode" / "settings.json").read_text()) + interpreter = settings["python.defaultInterpreterPath"] + + assert interpreter == "${workspaceFolder}/.venv/bin/python3" + assert "/opt/venv" not in interpreter + + class TestRemovePrecommitHooks: """Tests for RemovePrecommitHooks transform.""" diff --git a/tests/test_utils.py b/tests/test_utils.py index 34613aa9..4f3d4add 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -258,199 +258,6 @@ def test_get_release_date_without_date_part(self, tmp_path): assert date_found is None -class TestLoadRequirements: - """Tests for load_requirements() from docs/generate.py.""" - - def test_loads_actual_requirements(self): - """Should load the real requirements.yaml successfully.""" - reqs = generate.load_requirements() - assert isinstance(reqs, dict) - assert "dependencies" in reqs - assert "optional" in reqs - assert len(reqs["dependencies"]) > 0 - - def test_returns_podman_as_first_dependency(self): - """Podman should appear in the dependencies list.""" - reqs = generate.load_requirements() - names = [d["name"] for d in reqs["dependencies"]] - assert "podman" in names - - def test_fallback_when_file_missing(self, tmp_path, monkeypatch): - """Should return empty lists when requirements.yaml is missing.""" - # Point the function at a non-existent file - fake_parent = tmp_path / "scripts" - fake_parent.mkdir() - monkeypatch.setattr( - generate, - "load_requirements", - lambda: _load_requirements_from(tmp_path / "nope.yaml"), - ) - result = generate.load_requirements() - assert result["dependencies"] == [] - - def test_handles_empty_yaml(self, tmp_path): - """Should handle a YAML file with no keys gracefully.""" - req_file = tmp_path / "requirements.yaml" - req_file.write_text("") - result = _load_requirements_from(req_file) - assert result["dependencies"] == [] - assert result["optional"] == [] - - -class TestFormatRequirementsTable: - """Tests for format_requirements_table() from docs/generate.py.""" - - def test_basic_table_generation(self): - """Should produce a markdown table with header and rows.""" - reqs = { - "dependencies": [ - {"name": "podman", "version": ">=4.0", "purpose": "Container runtime"}, - {"name": "just", "version": ">=1.40", "purpose": "Task runner"}, - ] - } - table = generate.format_requirements_table(reqs) - assert "| Component" in table - assert "**podman**" in table - assert "**just**" in table - assert "Container runtime" in table - assert "Task runner" in table - - def test_empty_dependencies(self): - """Should produce only the header when no deps exist.""" - table = generate.format_requirements_table({"dependencies": []}) - lines = table.strip().split("\n") - assert len(lines) == 2 # header + separator - - def test_missing_fields_use_defaults(self): - """Deps with missing keys should fallback to 'unknown'/'latest'/''.""" - reqs = {"dependencies": [{}]} - table = generate.format_requirements_table(reqs) - assert "unknown" in table - assert "latest" in table - - def test_actual_requirements(self): - """Table from real requirements should contain all dependency names.""" - reqs = generate.load_requirements() - table = generate.format_requirements_table(reqs) - for dep in reqs["dependencies"]: - assert dep["name"] in table - - -class TestFormatInstallCommands: - """Tests for format_install_commands() from docs/generate.py.""" - - def test_macos_brew_packages(self): - """Should combine brew install commands into one line.""" - reqs = { - "dependencies": [ - {"name": "podman", "install": {"macos": "brew install podman"}}, - {"name": "git", "install": {"macos": "brew install git"}}, - ] - } - result = generate.format_install_commands(reqs, "macos") - assert "brew install podman git" in result - - def test_debian_apt_packages(self): - """Should combine apt install commands and prepend apt update.""" - reqs = { - "dependencies": [ - {"name": "podman", "install": {"debian": "sudo apt install -y podman"}}, - {"name": "git", "install": {"debian": "sudo apt install -y git"}}, - ] - } - result = generate.format_install_commands(reqs, "debian") - assert "sudo apt update" in result - assert "sudo apt install -y podman git" in result - - def test_piped_command_kept_separate(self): - """Commands with pipes should be kept as separate lines.""" - reqs = { - "dependencies": [ - { - "name": "gh", - "install": {"debian": "curl -fsSL url | sudo dd of=key"}, - }, - ] - } - result = generate.format_install_commands(reqs, "debian") - assert "# gh" in result - assert "curl -fsSL url | sudo dd of=key" in result - - def test_multiline_command_kept_separate(self): - """Commands with newlines should be kept as separate entries.""" - reqs = { - "dependencies": [ - { - "name": "just", - "install": {"debian": "curl url\nbash install.sh"}, - }, - ] - } - result = generate.format_install_commands(reqs, "debian") - assert "# just" in result - - def test_unknown_os_falls_back_to_debian(self): - """An unknown os_type should fall back to 'debian' field.""" - reqs = { - "dependencies": [ - {"name": "git", "install": {"debian": "sudo apt install -y git"}}, - ] - } - result = generate.format_install_commands(reqs, "arch") - assert "sudo apt install -y git" in result - - def test_empty_dependencies(self): - """Should return empty string when no deps have install commands.""" - result = generate.format_install_commands({"dependencies": []}, "macos") - assert result == "" - - def test_dep_without_install_key(self): - """Deps missing 'install' key should be skipped gracefully.""" - reqs = {"dependencies": [{"name": "foo"}]} - result = generate.format_install_commands(reqs, "macos") - assert result == "" - - def test_dep_with_empty_install_for_os(self): - """Deps without a command for the requested OS should be skipped.""" - reqs = { - "dependencies": [ - {"name": "foo", "install": {"fedora": "dnf install foo"}}, - ] - } - result = generate.format_install_commands(reqs, "macos") - assert result == "" - - def test_non_package_manager_command(self): - """Commands not matching brew/apt patterns go to other_commands.""" - reqs = { - "dependencies": [ - { - "name": "uv", - "install": { - "macos": "curl -LsSf https://astral.sh/uv/install.sh | sh" - }, - }, - ] - } - result = generate.format_install_commands(reqs, "macos") - # Contains pipe so it goes to other_commands with a comment - assert "# uv" in result - - def test_actual_macos(self): - """Integration: real requirements should produce non-empty macos output.""" - reqs = generate.load_requirements() - result = generate.format_install_commands(reqs, "macos") - assert len(result) > 0 - assert "brew install" in result - - def test_actual_debian(self): - """Integration: real requirements should produce non-empty debian output.""" - reqs = generate.load_requirements() - result = generate.format_install_commands(reqs, "debian") - assert len(result) > 0 - assert "sudo apt" in result - - class TestGenerateDocs: """Tests for generate_docs() from docs/generate.py.""" @@ -473,11 +280,6 @@ def test_generate_docs_succeeds(self, tmp_path, monkeypatch): monkeypatch.setattr( generate, "get_release_date_from_changelog", lambda: "2026-02-11" ) - monkeypatch.setattr( - generate, - "load_requirements", - lambda: {"dependencies": [], "optional": []}, - ) # Inline the logic of generate_docs with patched paths import jinja2 @@ -590,20 +392,6 @@ def _get_version_from_file(changelog_path: Path) -> str: return "dev" -def _load_requirements_from(yaml_path: Path) -> dict: - """Replicates load_requirements logic against an arbitrary file.""" - if not yaml_path.exists(): - return {"dependencies": [], "optional": [], "auto_install": []} - import yaml - - with yaml_path.open() as f: - data = yaml.safe_load(f) or {} - return { - "dependencies": data.get("dependencies", []), - "optional": data.get("optional", []), - } - - class TestInstallScriptUnit: """Unit tests for install.sh - test script logic without containers.""" diff --git a/uv.lock b/uv.lock index 6c2cd4d5..8fd0f664 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = "==3.14.6" +requires-python = "==3.14.*" [manifest] constraints = [ @@ -201,7 +201,6 @@ dev = [ { name = "pytest-docker" }, { name = "pytest-testinfra" }, { name = "rich" }, - { name = "ruff" }, { name = "testcontainers" }, { name = "vig-utils" }, ] @@ -210,13 +209,11 @@ devcontainer = [ { name = "pip-licenses" }, { name = "pre-commit" }, { name = "rich" }, - { name = "ruff" }, ] lint = [ { name = "bandit" }, { name = "pip-licenses" }, { name = "pre-commit" }, - { name = "ruff" }, ] test = [ { name = "bcrypt" }, @@ -251,7 +248,6 @@ dev = [ { name = "pytest-docker", specifier = "==3.2.5" }, { name = "pytest-testinfra", specifier = "==10.2.2" }, { name = "rich", specifier = "==15.0.0" }, - { name = "ruff", specifier = "==0.15.18" }, { name = "testcontainers", specifier = "==4.14.2" }, { name = "vig-utils", editable = "packages/vig-utils" }, ] @@ -260,13 +256,11 @@ devcontainer = [ { name = "pip-licenses", specifier = "==5.5.5" }, { name = "pre-commit", specifier = "==4.6.0" }, { name = "rich", specifier = "==15.0.0" }, - { name = "ruff", specifier = "==0.15.18" }, ] lint = [ { name = "bandit", extras = ["toml"], specifier = "==1.9.4" }, { name = "pip-licenses", specifier = "==5.5.5" }, { name = "pre-commit", specifier = "==4.6.0" }, - { name = "ruff", specifier = "==0.15.18" }, ] test = [ { name = "bcrypt", specifier = "==5.0.0" }, @@ -656,31 +650,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] -[[package]] -name = "ruff" -version = "0.15.18" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/74/98/1295ad5a5aa9bc85bdcdfa5d82fe7b49c61af5657df4f227637ff9de0da6/ruff-0.15.18.tar.gz", hash = "sha256:2698a964c70e8bf402dcb99c8810472d270d141e7aa8c4e13599fd52033a2f33", size = 4761437, upload-time = "2026-06-18T18:25:39.224Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/d0/686e984941269621e2be72612d5c1e461f8f7b38415a2a7d7a81c8ae6715/ruff-0.15.18-py3-none-linux_armv6l.whl", hash = "sha256:8b6850172348c8381b8b3084c5915a4393c2373b9b54cd5b5e1ea15812bc10df", size = 10887308, upload-time = "2026-06-18T18:25:03.062Z" }, - { url = "https://files.pythonhosted.org/packages/ed/21/bc4123e3f5515ee99f8ce1eb93a14a0628fe4d1678663cd08f933ac16931/ruff-0.15.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3fccc153a85417dcd976883160cacce486997b0a0058dd18f54b8aaaac7d1ce2", size = 11281305, upload-time = "2026-06-18T18:25:30.026Z" }, - { url = "https://files.pythonhosted.org/packages/51/93/4769464c25cf7ab2acb3c7dda9cad3d867eb41c59565b3e2a9d17249c90c/ruff-0.15.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08d4c86a68f2c3ec2c9d56380a71fb4a4f65373055cbb8caabd645e9102f38d4", size = 10641215, upload-time = "2026-06-18T18:25:15.802Z" }, - { url = "https://files.pythonhosted.org/packages/6c/42/56926d17120db2c208d76bf60a1a019644dd9e91dc27f0f95c9caddb1366/ruff-0.15.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37e5108745c2c0705da916d7d4de533ddf547051ef45f62888c31bae73f66318", size = 10957224, upload-time = "2026-06-18T18:25:36.955Z" }, - { url = "https://files.pythonhosted.org/packages/22/4f/d43fab8d8189afde803103022d000a8ef9f230616d436d52a8b2b8d63b50/ruff-0.15.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56949a6ce8b3abde54c0bcb22cebfe57e8771cadc84b407ae8b8eaf67ebdcd43", size = 10699024, upload-time = "2026-06-18T18:25:05.707Z" }, - { url = "https://files.pythonhosted.org/packages/63/42/1e3e4c68bd408b9768cf3e439acbe2c78245225faef253f7028a0cdb63e0/ruff-0.15.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01a754cd6a1b630d3f97e33eb452cf7a98040482318e870f8bc52a5a30e62657", size = 11491458, upload-time = "2026-06-18T18:25:20.275Z" }, - { url = "https://files.pythonhosted.org/packages/20/77/47a3484bea8521e14a203d98c389c5c97846675e4f02734672da4a69b52a/ruff-0.15.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ba7a07e03a44dbf10bb086ee06705b173625014ec99f73a7e6836a5e5590a0c", size = 12383752, upload-time = "2026-06-18T18:25:22.535Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ca/054159590787023d83b658a1a1819c4c8910114e7015069340b71c0961cb/ruff-0.15.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a2c40a41a4cadbcf5897b548ab29dfe248b20c540961c0247d98a3973c70403", size = 11577923, upload-time = "2026-06-18T18:25:10.702Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ff/d353d6b7bbd73cc0ec37f4463d7540e45e894338abdd9964eee0de332708/ruff-0.15.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f0480ce690cbb6c4db6e5d08f19fce98e10ba131a8b60c1bcdac42771e3ae2d", size = 11583925, upload-time = "2026-06-18T18:25:32.391Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4a/891f89b9c296ed3e5f3ece1a5629badc989d9a8fdaa30431aaf4774bc1c2/ruff-0.15.18-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2330215f1f393fa8733f55edce04fcf94c36a2c460fcde31f78cc84e4951e9b1", size = 11582834, upload-time = "2026-06-18T18:25:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/32/a3/ed9e370154bf85de360b93c03026157f02d4943b2d01ff4945f4429f8e8a/ruff-0.15.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6aa6a3d979e48ae617578183674bf264fbe7d0114a796a26bd678d67963c7ff", size = 10927328, upload-time = "2026-06-18T18:25:34.676Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d1/5cf5909329fedb5d39d555ee818ba5cf4638e1a301b89785d34f2905bfcb/ruff-0.15.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a81beadbbff2c9c245561ae3f77b16709d87f35eec650d0501679239d3449b22", size = 10693187, upload-time = "2026-06-18T18:25:08.245Z" }, - { url = "https://files.pythonhosted.org/packages/fd/44/ff6c635cf2c4f4e7b618b6640da057376baa36014695487d88aed4794268/ruff-0.15.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2186d9e940ae332ab293623a75b5f4fe49565f449954d50a72a046683aa6b809", size = 11208721, upload-time = "2026-06-18T18:25:41.327Z" }, - { url = "https://files.pythonhosted.org/packages/88/d9/5baa2a30861adfb7022cf33c1e35b2fc18085b08c16f83eff4c7b99a5f48/ruff-0.15.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5c2abf140438032bc77b2284a6c9944ecd8a19e5f1c7b52b1b8e4a0a80d19a7a", size = 11678599, upload-time = "2026-06-18T18:25:13.607Z" }, - { url = "https://files.pythonhosted.org/packages/c3/1a/0725a7cfdc32ff769efb96ee782bec882e16448c5d9e3be947ec4c04ce27/ruff-0.15.18-py3-none-win32.whl", hash = "sha256:02299e6e9fa5b297a3f6d5d10d7bcd655c925b028bb8b9d4588214549c6b9ec4", size = 10901903, upload-time = "2026-06-18T18:25:24.755Z" }, - { url = "https://files.pythonhosted.org/packages/f3/51/805d9f6fb7970505c3504794a5ec350f605361b807fef4dcf214ebd35e72/ruff-0.15.18-py3-none-win_amd64.whl", hash = "sha256:dac80dc8d26b2257dbefabed62f5d255c3937b4ccb122da1fc634794fa3578b3", size = 12041189, upload-time = "2026-06-18T18:25:17.915Z" }, - { url = "https://files.pythonhosted.org/packages/29/4c/67bb45e41609eb4726f1bfeb59e083cf91d14c696d4bd14c234a980be93d/ruff-0.15.18-py3-none-win_arm64.whl", hash = "sha256:b2c9257fcbd4a3e5b977a1904e6facca016bafe2edc17df24db67cfaee03b4e4", size = 11329958, upload-time = "2026-06-18T18:25:43.686Z" }, -] - [[package]] name = "stevedore" version = "5.7.0"