diff --git a/.githooks/commit-msg b/.githooks/commit-msg index 721581a..a8a29d3 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -1,2 +1,7 @@ #!/usr/bin/env bash -npx -y conventional-commit-msg "$1" \ No newline at end of file +# `@latest` + explicit `--package` dodge two npm footguns: an inherited +# npm_config_package (agent wrappers like axexec/axrun set it, hijacking +# `npx -y` into exit 127), and local-root shadowing — without the version, +# `npm exec` resolves the conventional-commit-msg repo's own package and the +# bin is "not found", breaking the hook in that repo after prepare wires it up. +npm exec --yes --package=conventional-commit-msg@latest -- conventional-commit-msg "$1" diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 3ba6bcc..71c5439 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -2,9 +2,21 @@ # Native Git hook - enable with: git config core.hooksPath .githooks set -euo pipefail -git diff --cached --check +ignored_staged_paths=( + ":(exclude).agents/**" + ":(exclude).claude/**" + ":(exclude).clawpatch/**" + ":(exclude).codex/**" +) + +git diff --cached --check -- . "${ignored_staged_paths[@]}" has_staged_changes() { + if [[ "$#" -eq 0 ]]; then + git diff --cached --quiet -- . "${ignored_staged_paths[@]}" || return 0 + return 1 + fi + git diff --cached --quiet -- "$@" || return 0 return 1 } @@ -13,6 +25,10 @@ if [[ ! -f package.json ]]; then exit 0 fi +if ! has_staged_changes; then + exit 0 +fi + # `npm pkg fix` normalizes package.json (e.g., sorts keys, trims invalid # fields). Only run it when package.json is already staged, so the hook never # pulls unrelated working-tree edits into a commit. @@ -21,52 +37,63 @@ if has_staged_changes package.json && command -v npm >/dev/null 2>&1; then git add package.json 2>/dev/null || true fi +run_package_script() { + local script_name="$1" + # --if-present runs the script when package.json defines it and exits 0 when it + # doesn't, so the hook can list a superset of scripts no repo fully implements. + if command -v pnpm >/dev/null 2>&1; then + pnpm run --if-present "$script_name" + elif command -v npm >/dev/null 2>&1; then + npm run --if-present "$script_name" + else + # return, not exit: callers redirect this function's output into a log + # they only dump on failure — exit here would abort before the dump, + # leaving the commit to die with no visible error. + echo "No package manager available to run script: $script_name" >&2 + return 127 + fi +} + +script_log=$(mktemp) +trap 'rm -f "$script_log"' EXIT + +# Logs a "==> " header, then runs the script with output captured and shown only on failure before exiting non-zero. run_script() { local script_name="$1" - # Check if the script exists in package.json (node - reads from stdin, arg becomes argv[2]) - if node - "$script_name" <<'EOF' -const fs = require("fs"); -const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); -const scripts = pkg.scripts || {}; -process.exit(scripts[process.argv[2]] ? 0 : 1); -EOF - then - if command -v pnpm >/dev/null 2>&1; then - pnpm -s run "$script_name" - elif command -v npm >/dev/null 2>&1; then - npm run --silent "$script_name" - else - echo "No package manager available to run script: $script_name" >&2 - exit 1 - fi + echo "==> $script_name" >&2 + if run_package_script "$script_name" > "$script_log" 2>&1; then + return 0 fi + cat "$script_log" >&2 + exit 1 } if [[ -f pnpm-lock.yaml ]] && command -v pnpm >/dev/null 2>&1; then if has_staged_changes package.json pnpm-lock.yaml pnpm-workspace.yaml; then # Order matters: dedupe is the cheaper read-only check, so fail fast on # lockfile bloat before the heavier lockfile-vs-package.json sync check. - pnpm dedupe --check + pnpm dedupe --check --config.confirm-modules-purge=false pnpm install --frozen-lockfile --ignore-scripts --config.confirm-modules-purge=false fi fi -if has_staged_changes; then - run_script "format:check" -fi - +run_script "format:check" run_script "knip" run_script "typecheck" run_script "lint" run_script "fta" - -# Run tests quietly - only show output on failure -test_log=$(mktemp) -trap 'rm -f "$test_log"' EXIT - -if run_script "test" > "$test_log" 2>&1; then - : # Success, stay silent -else - cat "$test_log" >&2 +# Scrub git's hook env vars so a test that shells out to git hits a scratch repo, not this repo's real gitdir. +run_test_script() { + local script_name="$1" + echo "==> $script_name" >&2 + if ( + unset GIT_DIR GIT_INDEX_FILE GIT_WORK_TREE GIT_COMMON_DIR GIT_PREFIX GIT_OBJECT_DIRECTORY + run_package_script "$script_name" + ) > "$script_log" 2>&1; then + return 0 + fi + cat "$script_log" >&2 exit 1 -fi +} + +run_test_script "test" diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index b8a627d..eff76c5 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -15,6 +15,10 @@ jobs: - name: Checkout code uses: actions/checkout@v6 + - name: Strip untrusted project install config + # pull_request checks out attacker-controlled code: drop committed .npmrc (redirects installs / leaks the registry token) and both pnpmfile variants .pnpmfile.cjs/.pnpmfile.mjs (pnpm 11 loads .mjs first, and their install hooks run even under --ignore-scripts). + run: rm -f .npmrc .pnpmfile.cjs .pnpmfile.mjs + - name: Setup pnpm uses: pnpm/action-setup@v5 @@ -24,8 +28,27 @@ jobs: node-version-file: "package.json" cache: "pnpm" + - name: Verify install secret + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -eu + if [ -z "${NODE_AUTH_TOKEN:-}" ]; then + echo "::error::Missing required install secret NPM_TOKEN; set it in the repo or org Actions secrets." + exit 1 + fi + + - name: Configure registry auth + run: echo "//npm.j4k.dev/:_authToken=${NODE_AUTH_TOKEN}" > ~/.npmrc + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --ignore-scripts --ignore-pnpmfile + + - name: Strip registry token + # pull_request gets secrets on same-repo PRs; remove the token before any PR-controlled script reads ~/.npmrc. + run: rm -f ~/.npmrc - name: Run knip run: pnpm knip @@ -42,8 +65,8 @@ jobs: - name: Run fta run: pnpm fta - - name: Run tests - run: pnpm run test - - name: Run build run: pnpm build + + - name: Run tests + run: pnpm run test diff --git a/.github/workflows/commit-msg.yml b/.github/workflows/commit-msg.yml index c4147f8..39569b0 100644 --- a/.github/workflows/commit-msg.yml +++ b/.github/workflows/commit-msg.yml @@ -1,9 +1,10 @@ --- name: commit-msg +# Validate on pull_request only; a post-merge --last check would choke on the +# immutable, auto-composed squash body whose lines exceed the body-length limit. + on: - push: - branches: [main] pull_request: permissions: @@ -31,17 +32,24 @@ jobs: path: ~/.npm key: npm-conventional-commit-msg-${{ env.COMMITLINT_PACKAGE_VERSION }} - - name: Validate commits with commitlint + - name: Validate commit messages with commitlint + # A squash merge (the default) lands the PR title as main's subject; a + # rebase merge lands the PR's commits. Validate both so semantic-release + # never sees an unparseable subject on main. shell: bash + env: + # Pass the attacker-controllable PR title via env, never inline in run: (script injection). + PR_TITLE: ${{ github.event.pull_request.title }} run: | set -euo pipefail - if [[ "${{ github.event_name }}" == "pull_request" ]]; then - npm exec --yes --package=conventional-commit-msg@${COMMITLINT_PACKAGE_VERSION} -- \ - conventional-commit-msg \ - --from "${{ github.event.pull_request.base.sha }}" \ - --to "${{ github.event.pull_request.head.sha }}" - else - npm exec --yes --package=conventional-commit-msg@${COMMITLINT_PACKAGE_VERSION} -- \ - conventional-commit-msg --last - fi + # Squash: the PR title becomes main's subject. + printf '%s\n' "$PR_TITLE" \ + | npm exec --yes --package=conventional-commit-msg@${COMMITLINT_PACKAGE_VERSION} -- \ + conventional-commit-msg + + # Rebase: the PR's individual commits land on main. + npm exec --yes --package=conventional-commit-msg@${COMMITLINT_PACKAGE_VERSION} -- \ + conventional-commit-msg \ + --from "${{ github.event.pull_request.base.sha }}" \ + --to "${{ github.event.pull_request.head.sha }}" diff --git a/.github/workflows/dedupe-check.yml b/.github/workflows/dedupe-check.yml index a1bf0b9..48c892f 100644 --- a/.github/workflows/dedupe-check.yml +++ b/.github/workflows/dedupe-check.yml @@ -19,6 +19,10 @@ jobs: - name: Checkout code uses: actions/checkout@v6 + - name: Strip untrusted project install config + # pull_request checks out attacker-controlled code: drop committed .npmrc (redirects installs / leaks the registry token) and both pnpmfile variants .pnpmfile.cjs/.pnpmfile.mjs (pnpm 11 loads .mjs first, and their hooks run under --ignore-scripts during both install and dedupe, which keep the token on disk). + run: rm -f .npmrc .pnpmfile.cjs .pnpmfile.mjs + - name: Setup pnpm uses: pnpm/action-setup@v5 @@ -28,8 +32,24 @@ jobs: node-version-file: "package.json" cache: "pnpm" + - name: Verify install secret + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -eu + if [ -z "${NODE_AUTH_TOKEN:-}" ]; then + echo "::error::Missing required install secret NPM_TOKEN; set it in the repo or org Actions secrets." + exit 1 + fi + + - name: Configure registry auth + run: echo "//npm.j4k.dev/:_authToken=${NODE_AUTH_TOKEN}" > ~/.npmrc + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --ignore-scripts --ignore-pnpmfile - name: Check for dedupe opportunities - run: pnpm dedupe --check + # No token strip here (unlike checks.yml, which strips before untrusted build/test): install ran --ignore-scripts --ignore-pnpmfile and `pnpm dedupe --check` is a builtin, so no untrusted code runs with ~/.npmrc present — stripping it would only risk breaking dedupe on private-registry repos. + run: pnpm dedupe --check --ignore-scripts diff --git a/.github/workflows/pr-review-on-open.yml b/.github/workflows/pr-review-on-open.yml deleted file mode 100644 index 9fdc479..0000000 --- a/.github/workflows/pr-review-on-open.yml +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: PR Approach Review - -on: - pull_request_target: - types: [opened] - workflow_dispatch: - inputs: - pr_number: - description: "PR number to review" - required: true - type: number - -jobs: - review: - permissions: - contents: read - pull-requests: write - actions: read - uses: Jercik/axgithub/.github/workflows/pr-review.yml@v1 - with: - label: approach - recipes: '["pr-review-approach-1","pr-review-approach-2","pr-review-approach-3","pr-review-approach-4"]' - pr_number: ${{ github.event.pull_request.number || inputs.pr_number }} - secrets: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - AXRECIPE_API_KEY: ${{ secrets.AXRECIPE_API_KEY }} diff --git a/.github/workflows/pr-review-on-push.yml b/.github/workflows/pr-review-on-push.yml deleted file mode 100644 index 5ab565e..0000000 --- a/.github/workflows/pr-review-on-push.yml +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: PR Code Review - -on: - pull_request_target: - types: [opened, synchronize, reopened] - workflow_dispatch: - inputs: - pr_number: - description: "PR number to review" - required: true - type: number - -jobs: - review: - permissions: - contents: read - pull-requests: write - actions: read - uses: Jercik/axgithub/.github/workflows/pr-review.yml@v1 - with: - label: code - recipes: '["pr-review-code-1","pr-review-code-2"]' - pr_number: ${{ github.event.pull_request.number || inputs.pr_number }} - secrets: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - AXRECIPE_API_KEY: ${{ secrets.AXRECIPE_API_KEY }} diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml new file mode 100644 index 0000000..387afee --- /dev/null +++ b/.github/workflows/pr-review.yml @@ -0,0 +1,42 @@ +--- +name: PR Review + +on: + pull_request_target: + types: [opened, synchronize, reopened] + workflow_dispatch: + inputs: + pr_number: + description: "PR number to review" + required: true + type: number + +jobs: + approach: + if: ${{ github.event_name == 'workflow_dispatch' || github.event.action == 'opened' }} + permissions: + contents: read + pull-requests: write + actions: read + uses: Jercik/axgithub/.github/workflows/pr-review.yml@v1 + with: + label: approach + recipes: '[{"recipe":"pr-review-approach-smart","name":"smart draw 1"},{"recipe":"pr-review-approach-smart","name":"smart draw 2"},"pr-review-approach-2","pr-review-approach-3"]' + pr_number: ${{ github.event.pull_request.number || inputs.pr_number }} + secrets: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + AXRECIPE_API_KEY: ${{ secrets.AXRECIPE_API_KEY }} + + code: + permissions: + contents: read + pull-requests: write + actions: read + uses: Jercik/axgithub/.github/workflows/pr-review.yml@v1 + with: + label: code + recipes: '[{"recipe":"pr-review-code-smart","name":"smart draw 1"},{"recipe":"pr-review-code-smart","name":"smart draw 2"}]' + pr_number: ${{ github.event.pull_request.number || inputs.pr_number }} + secrets: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + AXRECIPE_API_KEY: ${{ secrets.AXRECIPE_API_KEY }} diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml index 1b9a611..8bff940 100644 --- a/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -42,12 +42,29 @@ jobs: uses: actions/setup-node@v6 with: node-version-file: "package.json" - registry-url: https://registry.npmjs.org cache: "pnpm" + - name: Verify install secret + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -eu + if [ -z "${NODE_AUTH_TOKEN:-}" ]; then + echo "::error::Missing required install secret NPM_TOKEN; set it in the repo or org Actions secrets." + exit 1 + fi + + - name: Configure registry auth (install) + run: echo "//npm.j4k.dev/:_authToken=${NODE_AUTH_TOKEN}" > ~/.npmrc + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Configure public npm registry (publish) + run: echo "registry=https://registry.npmjs.org/" >> ~/.npmrc + - name: Release id: release env: diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 92877f3..508ebdd 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -3,5 +3,14 @@ "sortPackageJson": { "sortScripts": true }, - "ignorePatterns": ["pnpm-lock.yaml", "**/*.hbs", ".agents/", ".claude/", "AGENTS.md", "CLAUDE.md"] + "ignorePatterns": [ + "pnpm-lock.yaml", + "**/*.hbs", + ".agents/", + ".claude/", + ".clawpatch/", + ".codex/", + "AGENTS.md", + "CLAUDE.md" + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index aba257d..eb3b23d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,3 @@ { - "js/ts.experimental.useTsgo": true, - "typescript.native-preview.tsdk": "./node_modules/@typescript/native-preview" + "js/ts.experimental.useTsgo": true } diff --git a/package.json b/package.json index a47b0cf..a0691ff 100644 --- a/package.json +++ b/package.json @@ -71,5 +71,5 @@ "engines": { "node": ">=24.0.0" }, - "packageManager": "pnpm@10.33.0" + "packageManager": "pnpm@11.8.0" } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a95e5cf..c813e2e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,5 @@ allowBuilds: esbuild: true enableGlobalVirtualStore: true +registries: + default: https://npm.j4k.dev/ diff --git a/release.config.mjs b/release.config.mjs new file mode 100644 index 0000000..737f484 --- /dev/null +++ b/release.config.mjs @@ -0,0 +1,11 @@ +const releaseConfig = { + plugins: [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + "@semantic-release/npm", + // Phantom #N refs in commit messages 404 the success-comment step and fail the run. + ["@semantic-release/github", { successCommentCondition: false }], + ], +}; + +export default releaseConfig; diff --git a/tsconfig.app.json b/tsconfig.app.json index 52896bb..4ad617d 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -5,14 +5,12 @@ "lib": ["ES2025", "ESNext.Array", "ESNext.Error"], "types": ["node"], "jsx": "react-jsx", + "composite": true, "outDir": "./dist", "rootDir": "./src", - "composite": true, - "declaration": true, - "declarationMap": true, "sourceMap": true, "rewriteRelativeImportExtensions": true }, - "include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*.d.ts"], + "include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*.d.ts", "src/**/*.json"], "exclude": ["node_modules", "dist", "**/*.test.*", "**/*.spec.*"] }