diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index abcbe99..01fa159 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -47,6 +47,19 @@ jobs: with: tag-name: ${{ needs.release-please.outputs.tag-name }} + # Only stable releases get binaries. A prerelease publishes to npm without + # cutting a GitHub release, so there would be nothing to attach them to. The + # build itself lives in `sea.yml`, which pull requests run too. + publish-binaries: + name: Attach binaries + needs: release-please + if: needs.release-please.outputs.release-created == 'true' + permissions: + contents: write + uses: ./.github/workflows/sea.yml + with: + tag-name: ${{ needs.release-please.outputs.tag-name }} + publish-prerelease: name: Publish prerelease # Skip the version-bump merges release-please pushes onto main — those are diff --git a/.github/workflows/sea.yml b/.github/workflows/sea.yml new file mode 100644 index 0000000..bd519ef --- /dev/null +++ b/.github/workflows/sea.yml @@ -0,0 +1,105 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json + +# The single executables, built one way for two callers: a pull request that +# touches the build proves every platform still produces a working binary, and +# `release-please.yml` calls this with a tag to attach them to the release. One +# file, so the check and the release cannot drift apart. + +name: Single executable + +on: + # Not a required check, so a path filter is safe here — unlike `CI / Gate`, + # a skipped run blocks nothing. + pull_request: + branches: + - main + - dev + paths: + - src/** + - scripts/sea-build.ts + - vite.config.ts + - vite.sea.config.ts + - package.json + - pnpm-lock.yaml + - .nvmrc + - .github/workflows/sea.yml + workflow_call: + inputs: + tag-name: + description: Release tag to build from and attach the archives to. + required: true + type: string + +permissions: + contents: read + +jobs: + # One job per platform, and it has to be that way: a single executable is the + # target platform's own node binary with a blob injected into it, so there is + # nothing to cross-compile from. `macos-15-intel` is the Intel label that + # replaced the retired `macos-13`. + build: + name: ${{ matrix.target }} + permissions: + # `gh release upload` writes into the release; a pull request never + # reaches that step, and a caller can only grant what it holds. + contents: write + strategy: + # Every platform reports, rather than the first failure hiding the rest. + fail-fast: false + matrix: + include: + - target: darwin-arm64 + runner: macos-latest + - target: darwin-x64 + runner: macos-15-intel + - target: linux-x64 + runner: ubuntu-latest + - target: linux-arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v7 + with: + # On release, the tag rather than the branch tip: the artifact has to + # carry the version that was released, and `--version` is inlined at + # build time. Empty on a pull request, which checks out its own head. + ref: ${{ inputs.tag-name }} + + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: pnpm + + - run: pnpm install --frozen-lockfile + + # `build` first: it empties `dist` to write the npm artifact, which would + # take the binary with it the other way round. `sea:build` runs the binary + # before packaging it, so a broken one fails here. + - run: pnpm build + - run: pnpm sea:build + + # What the README tells people to run, run as written: the archive name it + # derives from `uname` must be the one this platform produced, the + # checksum must verify, and the extracted binary must work with no node + # on the PATH. + - name: Install it the way the README does + working-directory: dist/release + run: | + NAME="gitignore-sync_$(uname -s | tr A-Z a-z)_$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tar.gz" + shasum -a 256 -c "${NAME}.sha256" + mkdir unpacked + tar -xzf "${NAME}" -C unpacked + env -i PATH=/usr/bin:/bin ./unpacked/gitignore-sync info + env -i PATH=/usr/bin:/bin ./unpacked/gitignore-sync list > /dev/null + + - name: Attach the archive and its checksum + if: inputs.tag-name + env: + GH_TOKEN: ${{ github.token }} + run: >- + gh release upload "${{ inputs.tag-name }}" + dist/release/*.tar.gz dist/release/*.tar.gz.sha256 + --clobber diff --git a/AGENTS.md b/AGENTS.md index 28ce35b..d00a740 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,9 +32,10 @@ Retyping a change is exactly how the two drift; one reflowed line or reworded cl | `pnpm lint` | `oxlint . --deny-warnings` | | `pnpm format` | `oxfmt --check .` (note: `format` is the check, not fix) | | `pnpm typecheck` | `tsc --noEmit` | -| `pnpm check` | `lint` + `format` + `typecheck` + `check:policy` + `test` — the CI gate | +| `pnpm check` | `lint` + `format` + `typecheck` + `check:policy` + `test` + `build` — the CI gate | | `pnpm check:policy` | Proves the two agent policy files ban the same commands | | `pnpm templates:lock`| Re-writes `tests/templates.lock.json` after adding a template version | +| `pnpm sea:build` | After `pnpm build`: a single executable for this platform → `dist/release/` | | `pnpm check:fix` | Auto-fix lint + format | | `pnpm skills:update`| Update project-scoped agent skills via the skills.sh CLI | | `pnpm taze` | Interactive dependency upgrade check | @@ -58,7 +59,6 @@ So a declaration is unavoidable — and once one is needed, the cheapest place i # region core@v1 .DS_Store -.claude/settings.local.json # endregion # region node@v1 @@ -110,7 +110,7 @@ templates → Record // data, versioned with the binar `parse`, `render` and `reconcile` touch **no filesystem**. That puts the whole hard part — marker parsing, free zone, rescuing stray lines, dedup — behind a string-in/string-out seam. **Test that seam with fixture pairs under `tests/fixtures/` (`.in` / `.out`), never with temporary directories.** Adding a pair is the cheapest way to pin a behaviour; `tests/fixtures.test.ts` picks it up automatically and asserts idempotency on top. -`src/io.ts` and `src/detect.ts` are the only modules that read or write files. The commands (`init`, `edit`, `add`, `remove`, `sync`, `check`, `list`, `info`, `audit`) stay thin and compose the pure parts. `info` is the odd one out: it describes the *installation* rather than the file, so a `linked / dev build` is never mistaken for the published release. `check` is the CI gate: dry run, reports drift and duplicates, exits non-zero on deviation. +`src/io.ts` and `src/detect.ts` are the only modules that read or write files. The commands (`init`, `edit`, `add`, `remove`, `sync`, `check`, `list`, `info`, `audit`) stay thin and compose the pure parts. `info` is the odd one out: it describes the *installation* rather than the file, so a `linked / dev build` is never mistaken for the published release, and a downloaded binary reports itself as a `single executable`. `check` is the CI gate: dry run, reports drift and duplicates, exits non-zero on deviation. `edit`, `add` and `remove` are thin verbs over one header edit — they rewrite the `# stacks:` line and re-render, nothing more. They exist because discovering "open the file, change one line, run sync" from an error message is not a workflow. @@ -122,6 +122,8 @@ templates → Record // data, versioned with the binar Every template was derived from the 28 locally cloned `kirchDev` / `TitusKirch` repos, not from a public template site. The count is how many of them the stack applies to. +**That estate is the first source, not the only one** ([ADR-0009](docs/99.adr/0009-accept-stacks-on-the-word-of-the-tool-that-writes-the-files.md)). A stack may also ship on the word of the tool that writes the files — its scaffold's own `.gitignore`, or output it documents — which is the precedent `php@v2` and `laravel@v2` set by following the Laravel skeleton. What never qualifies is a public template site's guess. Either way the provenance comment above the template names the source, and the count below stays honest, `0` included. + | Stack | Fingerprint | Repos | | :--------- | :----------------------------------- | ----: | | `core` | always proposed | 27 | @@ -131,11 +133,14 @@ Every template was derived from the 28 locally cloned `kirchDev` / `TitusKirch` | `dotenv` | `.env.example` / `.env` | 7 | | `php` | `composer.json` | 3 | | `go` | `go.mod` | 3 | +| `python` | `pyproject.toml` / `requirements.txt` / `setup.py`, not under `docs/` | 0 | | `turborepo` | `turbo.json` | 3 | | `tofu` | `*.tf` / `*.tofu` / lock file | 1 | | `laravel` | `artisan` | 1 | | `octane` | `config/octane.php` | 3 | +| `helm` | `Chart.yaml` | 0 | | `nuxt` | `nuxt.config.*` | 1 | +| `vite` | `vite.config.*` beside an `index.html` | 1 | | `tauri` | `src-tauri/` | 1 | | `storybook` | `.storybook/` | 1 | | `gradle` | `build.gradle*` / `settings.gradle*` | 1 | @@ -150,6 +155,10 @@ Every template was derived from the 28 locally cloned `kirchDev` / `TitusKirch` Two stacks show **0**, on purpose. `rust` is fingerprinted on a *root* `Cargo.toml` only — the one Tauri app keeps its crate under `src-tauri/`, and that build output already belongs to the `tauri` stack, so matching it there would render a `/target` line pointing at nothing. `playwright` ships because one repo ignores its three output dirs, but that repo has no root config, so nothing in the estate currently triggers it; it is declared by hand until one does. +`python` shows **0** for `playwright`'s reason: `mc-network` ignores `__pycache__/` but keeps no Python manifest within reach of the fingerprint. It was contributed for three `kirch-ostendorf` Ansible repos outside the 28, which it does trigger, and an `audit` across both estates has it absorbing ten lines. + +Two more ship without estate evidence, on the word of the tool that writes the files. `helm` names the tarballs `helm dependency update` vendors into `charts/` — no repo in the estate holds a chart. `vite` carries the two lines the `create-vite` / `create-vue` scaffold adds on top of `node`, in its spelling, so a fresh scaffold is absorbed; the one Vite app in the estate does not ignore them yet. + ### One detector, and a `source` on every signal There is **one** fingerprint table and **one** `detect(dir)` pass. A `.vscode/` directory sits in the repo directory exactly like `package.json` does, so detecting them two different ways would be an invented distinction. @@ -174,14 +183,16 @@ Deliberate omissions, each for a reason worth keeping: - **`composer.lock` is in no template.** A library ignores it, an application commits it — a project decision, so it belongs in the free zone. - **The JVM stack is `gradle`, not `java`.** What a JVM repo ignores follows the build tool — Gradle writes `build/`, Maven `target/` — so a `java` stack would render lines pointing at nothing in half the repos. `*.jar` is left out because the Gradle wrapper jar is committed. A `maven` stack waits until a repo needs one. - **`dist` belongs to `node` alone**, not to `go`. goreleaser writes there too, but a line may live in only one stack (below), and `node` claims it. +- **`python` carries no `dist/` or `build/`** for the same reason: they are `node`'s and `gradle`'s. A pure Python repository that builds wheels keeps `dist/` in its free zone. `*.py[cod]` is left out too — CPython has written bytecode into `__pycache__/` since 3.2. +- **`helm` never names `charts/` itself**, only `**/charts/*.tgz`: a bare directory would hide the charts a chart repository keeps there, the same defect as a bare `.vscode`. `Chart.lock` is committed, like `.terraform.lock.hcl`, and a packaged chart's `*.tgz` is `node`'s line. - **`.terraform.lock.hcl` is not ignored** — it is meant to be committed. - **No `prisma` stack**, even though one repo ignores `src/generated/prisma/`. The path is configurable, so it is a project rule, and the free zone is exactly where it belongs. -**Coverage is measured, not assumed.** `gitignore-sync audit ` runs the real `reconcile` over every named repo with every stack declared, and reports what is left — per file and aggregated, `--json` included. It exists as a shipped command rather than a scratch script precisely so a later agent or skill measures with the tool's own semantics instead of reimplementing them. The numbers that justified the current set: across the 27 files, **86 patterns** survive outside the five files carrying a generated toptal block, and 33 of those are the bare `.idea` / `.vscode` spellings below. Almost everything else is genuinely project-specific — which is the free zone working as intended. Re-run it before adding a stack; a stack that moves the number by one line is a stack the estate did not ask for. +**Coverage is measured, not assumed.** `gitignore-sync audit ` runs the real `reconcile` over every named repo with every stack declared, and reports what is left — per file and aggregated, `--json` included. It exists as a shipped command rather than a scratch script precisely so a later agent or skill measures with the tool's own semantics instead of reimplementing them. The numbers that justified the current set: across the 27 files, **86 patterns** survive outside the five files carrying a generated toptal block, and 33 of those are the bare `.idea` / `.vscode` spellings below. Almost everything else is genuinely project-specific — which is the free zone working as intended. Re-run it before adding a stack; a stack that moves the number by one line is a stack the estate did not ask for — it ships only if the tool that writes the files vouches for it instead, and otherwise belongs in a free zone. **A bare `.idea` or `.vscode` beside a managed block is a defect, not a duplicate.** git does not descend into an ignored directory, so `.vscode` sitting in the free zone silently disables every `!.vscode/…` exception the block renders. `reconcile` reports it as `smothered` — a distinct, louder finding than the equivalent-spelling note, because the file is now doing something other than what it reads like. -**Pattern fingerprints search one level below the root** (`PATTERN_DEPTH` in `src/detect.ts`), because a Terraform repo keeps its stacks in `tofu/`. `examples/` is never descended into: a `.tf` shown as documentation is not a workspace that produces state, and treating it as one makes every provider repo look like an infrastructure repo. +**Pattern fingerprints search one level below the root** (`PATTERN_DEPTH` in `src/detect.ts`), because a Terraform repo keeps its stacks in `tofu/`. `examples/` is never descended into: a `.tf` shown as documentation is not a workspace that produces state, and treating it as one makes every provider repo look like an infrastructure repo. A fingerprint can narrow itself further: `skip` keeps `python` out of `docs/`, where Read the Docs, MkDocs and Sphinx keep build requirements in repos with no Python of their own, and `beside` counts `vite.config.*` only next to an `index.html`, so a library or CLI built with Vite — this repo included — is not offered lines that point at nothing. ### Sub-`.gitignore`s: three kinds, three verdicts @@ -222,12 +233,14 @@ Four template rules that are not style: - **`agents` carries working files, not configuration.** Across the estate **126 files** under `.claude/`, `.codex/` and `.opencode/` are committed — `settings.json`, skills, `default.rules`, agent definitions. Only `settings.local.json` and `worktrees/` describe one machine, and only those two are in `agents@v1`. `agents@v2` adds `.codex/config.toml`: Laravel Boost writes it with absolute paths of the machine it ran on, and both repos that commit one (`app`, `gildstone`) committed exactly that — the file, not the directory, so `.codex/rules/` stays committable. `.claude/worktrees/` is included because it accumulates for real and the one repo that ignores it today does so in `.git/info/exclude` — private knowledge that belongs where the team can see it. - **`node_modules` belongs to the `node` stack, not to `core`** — a pure Go repo then correctly does not get it. That it lands almost everywhere in practice is a consequence of the estate, not a reason to blur the model. -### Two surfaces, one tool +### Three surfaces, one tool `action.yml` in this repo is a **composite** action wrapping `npx @kirchdev/gitignore-sync check`, because the exit code is the whole result — it adds a job summary and a `status` output, nothing else. `kirchDev/coverage-report` bundles its CLI into `dist/index.js` instead, and should: that action writes sticky comments and check runs of its own. Copying its shape here would ship the same code twice and give two things to keep in step. Most repos need neither. `kirchDev/workflows`' `_ci-check.yml` derives its task list from `package.json`, so adding `gitignore-sync check` to a repo's `check` script is picked up with no workflow change at all. +The third surface is for machines without Node: every stable release carries a **Node single executable** per platform — `darwin` and `linux`, `arm64` and `x64` — built by `sea.yml` — on every pull request that touches the build, for all four platforms, and called by `release-please.yml` to attach them to the release, so the check and the release cannot drift ([ADR-0010](docs/99.adr/0010-ship-standalone-binaries-as-node-single-executables.md)). It is the same bundle with node inside it, not a second runtime: `bun build --compile` would have been one command, but it would ship a binary running on a runtime the test suite never runs on. Three things in it are not cosmetic: the entry is CommonJS (`vite.sea.config.ts`), because the embedder runs the main script as CJS; it must stay one file, because the embedder's `require` loads builtins only; and darwin needs `--macho-segment-name NODE_SEA` plus an ad-hoc re-sign. `scripts/sea-build.ts` checks all of it by running the binary before packaging. Archive names carry no version, so `releases/latest/download/…` always resolves. Windows gets no binary; npm is the path there. + ## Architecture / conventions - **Node 24, pnpm 11.** Pinned via `.nvmrc`, `engines`, and `packageManager`. Not Bun — the sibling `forgemap` declares `engines.node >= 24` and this repo follows it. `pnpm-workspace.yaml` enforces `minimumReleaseAge=4320` (3-day cooldown), isolated node-linker. Package-manager enforcement carries no key on purpose: pnpm 11 replaced `packageManagerStrict`/`packageManagerStrictVersion` with `pmOnFail`, whose default `download` already errors on a foreign package manager and fetches the pinned pnpm version — every other value only weakens it, so leave it unset. @@ -236,11 +249,11 @@ Most repos need neither. `kirchDev/workflows`' `_ci-check.yml` derives its task - **Published as `@kirchdev/gitignore-sync`**, scoped like `@kirchdev/coverage-report`. The `bin` entries stay unscoped (`gitignore-sync`, `gis`) and so does release-please's `package-name` — the scope belongs to the registry, not to what a person types or what a tag is called. - **`pnpm link --global .` edits `pnpm-workspace.yaml`**, adding an `overrides:` block pointing at the local checkout. That file is committed, so the block must never be: it would make every clone try to link a package that exists on one machine. `pnpm unlink` reverts it; `pnpm link` is only for trying the CLI locally. - **The CLI's `--version` is injected at build time** by vite's `define` from `package.json`'s `version`, which release-please owns. Never hand-copy a version literal. -- **Runtime deps stay to the sibling handful**: `citty`, `consola`, `pathe`. `c12` and `defu` are deliberately absent — the config lives in the `.gitignore`, so there is no config file to load or merge. +- **Runtime deps stay to the sibling handful**: `citty`, `consola`, `pathe`. `c12` and `defu` are deliberately absent — the config lives in the `.gitignore`, so there is no config file to load or merge. `postject`, which injects the single executable's blob, is a dev dependency only: the binary carries nothing of it. - **Husky hooks** (`.husky/pre-commit`, `.husky/commit-msg`) run `lint-staged` and `commitlint`. `lint-staged.config.ts` excludes `README.md`, `CLAUDE.md`, and `AGENTS.md` (free-form prose) and `pnpm-lock.yaml`. - **Conventional Commits enforced** via `@commitlint/config-conventional`. Don't `--no-verify` unless explicitly asked. - **release-please** with `release-type: node`, `include-v-in-tag: true`. Files: `release-please-config.json`, `.release-please-manifest.json`, `.github/workflows/release-please.yml`. -- **Workflows** use `actions/checkout@v7`, `actions/setup-node@v7`, `pnpm/action-setup@v6`, `github/codeql-action/{init,analyze}@v4`. Keep these pinned to major versions; Dependabot bumps them monthly. +- **Workflows** use `actions/checkout@v7`, `actions/setup-node@v7`, `github/codeql-action/{init,analyze}@v4` — GitHub's own actions, pinned to major versions. A third-party action is pinned to a commit SHA with its version as a comment (`pnpm/action-setup@ # v6.0.10`), exactly like the `kirchDev/workflows` refs: CodeQL flags a movable tag on one, since its owner can repoint it. Dependabot bumps both forms monthly. - **Bitwarden secret ids in workflows are vault references, not credentials.** The actual secret is `BWS_ACCESS_TOKEN`, a GitHub secret that never appears in a workflow file. Each owner authenticates as a different machine account and reads only its own `-ci` mirror, so this repo — a `kirchDev` repo — must carry the `kirchDev` id (`df8b447a-ffd5-4009-9cc3-b49b014f6978`), not `TitusKirch`'s. A mismatched id resolves for nobody, silently, until someone dispatches the workflow. ## AI & skills diff --git a/CLAUDE.md b/CLAUDE.md index 28ce35b..d00a740 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,9 +32,10 @@ Retyping a change is exactly how the two drift; one reflowed line or reworded cl | `pnpm lint` | `oxlint . --deny-warnings` | | `pnpm format` | `oxfmt --check .` (note: `format` is the check, not fix) | | `pnpm typecheck` | `tsc --noEmit` | -| `pnpm check` | `lint` + `format` + `typecheck` + `check:policy` + `test` — the CI gate | +| `pnpm check` | `lint` + `format` + `typecheck` + `check:policy` + `test` + `build` — the CI gate | | `pnpm check:policy` | Proves the two agent policy files ban the same commands | | `pnpm templates:lock`| Re-writes `tests/templates.lock.json` after adding a template version | +| `pnpm sea:build` | After `pnpm build`: a single executable for this platform → `dist/release/` | | `pnpm check:fix` | Auto-fix lint + format | | `pnpm skills:update`| Update project-scoped agent skills via the skills.sh CLI | | `pnpm taze` | Interactive dependency upgrade check | @@ -58,7 +59,6 @@ So a declaration is unavoidable — and once one is needed, the cheapest place i # region core@v1 .DS_Store -.claude/settings.local.json # endregion # region node@v1 @@ -110,7 +110,7 @@ templates → Record // data, versioned with the binar `parse`, `render` and `reconcile` touch **no filesystem**. That puts the whole hard part — marker parsing, free zone, rescuing stray lines, dedup — behind a string-in/string-out seam. **Test that seam with fixture pairs under `tests/fixtures/` (`.in` / `.out`), never with temporary directories.** Adding a pair is the cheapest way to pin a behaviour; `tests/fixtures.test.ts` picks it up automatically and asserts idempotency on top. -`src/io.ts` and `src/detect.ts` are the only modules that read or write files. The commands (`init`, `edit`, `add`, `remove`, `sync`, `check`, `list`, `info`, `audit`) stay thin and compose the pure parts. `info` is the odd one out: it describes the *installation* rather than the file, so a `linked / dev build` is never mistaken for the published release. `check` is the CI gate: dry run, reports drift and duplicates, exits non-zero on deviation. +`src/io.ts` and `src/detect.ts` are the only modules that read or write files. The commands (`init`, `edit`, `add`, `remove`, `sync`, `check`, `list`, `info`, `audit`) stay thin and compose the pure parts. `info` is the odd one out: it describes the *installation* rather than the file, so a `linked / dev build` is never mistaken for the published release, and a downloaded binary reports itself as a `single executable`. `check` is the CI gate: dry run, reports drift and duplicates, exits non-zero on deviation. `edit`, `add` and `remove` are thin verbs over one header edit — they rewrite the `# stacks:` line and re-render, nothing more. They exist because discovering "open the file, change one line, run sync" from an error message is not a workflow. @@ -122,6 +122,8 @@ templates → Record // data, versioned with the binar Every template was derived from the 28 locally cloned `kirchDev` / `TitusKirch` repos, not from a public template site. The count is how many of them the stack applies to. +**That estate is the first source, not the only one** ([ADR-0009](docs/99.adr/0009-accept-stacks-on-the-word-of-the-tool-that-writes-the-files.md)). A stack may also ship on the word of the tool that writes the files — its scaffold's own `.gitignore`, or output it documents — which is the precedent `php@v2` and `laravel@v2` set by following the Laravel skeleton. What never qualifies is a public template site's guess. Either way the provenance comment above the template names the source, and the count below stays honest, `0` included. + | Stack | Fingerprint | Repos | | :--------- | :----------------------------------- | ----: | | `core` | always proposed | 27 | @@ -131,11 +133,14 @@ Every template was derived from the 28 locally cloned `kirchDev` / `TitusKirch` | `dotenv` | `.env.example` / `.env` | 7 | | `php` | `composer.json` | 3 | | `go` | `go.mod` | 3 | +| `python` | `pyproject.toml` / `requirements.txt` / `setup.py`, not under `docs/` | 0 | | `turborepo` | `turbo.json` | 3 | | `tofu` | `*.tf` / `*.tofu` / lock file | 1 | | `laravel` | `artisan` | 1 | | `octane` | `config/octane.php` | 3 | +| `helm` | `Chart.yaml` | 0 | | `nuxt` | `nuxt.config.*` | 1 | +| `vite` | `vite.config.*` beside an `index.html` | 1 | | `tauri` | `src-tauri/` | 1 | | `storybook` | `.storybook/` | 1 | | `gradle` | `build.gradle*` / `settings.gradle*` | 1 | @@ -150,6 +155,10 @@ Every template was derived from the 28 locally cloned `kirchDev` / `TitusKirch` Two stacks show **0**, on purpose. `rust` is fingerprinted on a *root* `Cargo.toml` only — the one Tauri app keeps its crate under `src-tauri/`, and that build output already belongs to the `tauri` stack, so matching it there would render a `/target` line pointing at nothing. `playwright` ships because one repo ignores its three output dirs, but that repo has no root config, so nothing in the estate currently triggers it; it is declared by hand until one does. +`python` shows **0** for `playwright`'s reason: `mc-network` ignores `__pycache__/` but keeps no Python manifest within reach of the fingerprint. It was contributed for three `kirch-ostendorf` Ansible repos outside the 28, which it does trigger, and an `audit` across both estates has it absorbing ten lines. + +Two more ship without estate evidence, on the word of the tool that writes the files. `helm` names the tarballs `helm dependency update` vendors into `charts/` — no repo in the estate holds a chart. `vite` carries the two lines the `create-vite` / `create-vue` scaffold adds on top of `node`, in its spelling, so a fresh scaffold is absorbed; the one Vite app in the estate does not ignore them yet. + ### One detector, and a `source` on every signal There is **one** fingerprint table and **one** `detect(dir)` pass. A `.vscode/` directory sits in the repo directory exactly like `package.json` does, so detecting them two different ways would be an invented distinction. @@ -174,14 +183,16 @@ Deliberate omissions, each for a reason worth keeping: - **`composer.lock` is in no template.** A library ignores it, an application commits it — a project decision, so it belongs in the free zone. - **The JVM stack is `gradle`, not `java`.** What a JVM repo ignores follows the build tool — Gradle writes `build/`, Maven `target/` — so a `java` stack would render lines pointing at nothing in half the repos. `*.jar` is left out because the Gradle wrapper jar is committed. A `maven` stack waits until a repo needs one. - **`dist` belongs to `node` alone**, not to `go`. goreleaser writes there too, but a line may live in only one stack (below), and `node` claims it. +- **`python` carries no `dist/` or `build/`** for the same reason: they are `node`'s and `gradle`'s. A pure Python repository that builds wheels keeps `dist/` in its free zone. `*.py[cod]` is left out too — CPython has written bytecode into `__pycache__/` since 3.2. +- **`helm` never names `charts/` itself**, only `**/charts/*.tgz`: a bare directory would hide the charts a chart repository keeps there, the same defect as a bare `.vscode`. `Chart.lock` is committed, like `.terraform.lock.hcl`, and a packaged chart's `*.tgz` is `node`'s line. - **`.terraform.lock.hcl` is not ignored** — it is meant to be committed. - **No `prisma` stack**, even though one repo ignores `src/generated/prisma/`. The path is configurable, so it is a project rule, and the free zone is exactly where it belongs. -**Coverage is measured, not assumed.** `gitignore-sync audit ` runs the real `reconcile` over every named repo with every stack declared, and reports what is left — per file and aggregated, `--json` included. It exists as a shipped command rather than a scratch script precisely so a later agent or skill measures with the tool's own semantics instead of reimplementing them. The numbers that justified the current set: across the 27 files, **86 patterns** survive outside the five files carrying a generated toptal block, and 33 of those are the bare `.idea` / `.vscode` spellings below. Almost everything else is genuinely project-specific — which is the free zone working as intended. Re-run it before adding a stack; a stack that moves the number by one line is a stack the estate did not ask for. +**Coverage is measured, not assumed.** `gitignore-sync audit ` runs the real `reconcile` over every named repo with every stack declared, and reports what is left — per file and aggregated, `--json` included. It exists as a shipped command rather than a scratch script precisely so a later agent or skill measures with the tool's own semantics instead of reimplementing them. The numbers that justified the current set: across the 27 files, **86 patterns** survive outside the five files carrying a generated toptal block, and 33 of those are the bare `.idea` / `.vscode` spellings below. Almost everything else is genuinely project-specific — which is the free zone working as intended. Re-run it before adding a stack; a stack that moves the number by one line is a stack the estate did not ask for — it ships only if the tool that writes the files vouches for it instead, and otherwise belongs in a free zone. **A bare `.idea` or `.vscode` beside a managed block is a defect, not a duplicate.** git does not descend into an ignored directory, so `.vscode` sitting in the free zone silently disables every `!.vscode/…` exception the block renders. `reconcile` reports it as `smothered` — a distinct, louder finding than the equivalent-spelling note, because the file is now doing something other than what it reads like. -**Pattern fingerprints search one level below the root** (`PATTERN_DEPTH` in `src/detect.ts`), because a Terraform repo keeps its stacks in `tofu/`. `examples/` is never descended into: a `.tf` shown as documentation is not a workspace that produces state, and treating it as one makes every provider repo look like an infrastructure repo. +**Pattern fingerprints search one level below the root** (`PATTERN_DEPTH` in `src/detect.ts`), because a Terraform repo keeps its stacks in `tofu/`. `examples/` is never descended into: a `.tf` shown as documentation is not a workspace that produces state, and treating it as one makes every provider repo look like an infrastructure repo. A fingerprint can narrow itself further: `skip` keeps `python` out of `docs/`, where Read the Docs, MkDocs and Sphinx keep build requirements in repos with no Python of their own, and `beside` counts `vite.config.*` only next to an `index.html`, so a library or CLI built with Vite — this repo included — is not offered lines that point at nothing. ### Sub-`.gitignore`s: three kinds, three verdicts @@ -222,12 +233,14 @@ Four template rules that are not style: - **`agents` carries working files, not configuration.** Across the estate **126 files** under `.claude/`, `.codex/` and `.opencode/` are committed — `settings.json`, skills, `default.rules`, agent definitions. Only `settings.local.json` and `worktrees/` describe one machine, and only those two are in `agents@v1`. `agents@v2` adds `.codex/config.toml`: Laravel Boost writes it with absolute paths of the machine it ran on, and both repos that commit one (`app`, `gildstone`) committed exactly that — the file, not the directory, so `.codex/rules/` stays committable. `.claude/worktrees/` is included because it accumulates for real and the one repo that ignores it today does so in `.git/info/exclude` — private knowledge that belongs where the team can see it. - **`node_modules` belongs to the `node` stack, not to `core`** — a pure Go repo then correctly does not get it. That it lands almost everywhere in practice is a consequence of the estate, not a reason to blur the model. -### Two surfaces, one tool +### Three surfaces, one tool `action.yml` in this repo is a **composite** action wrapping `npx @kirchdev/gitignore-sync check`, because the exit code is the whole result — it adds a job summary and a `status` output, nothing else. `kirchDev/coverage-report` bundles its CLI into `dist/index.js` instead, and should: that action writes sticky comments and check runs of its own. Copying its shape here would ship the same code twice and give two things to keep in step. Most repos need neither. `kirchDev/workflows`' `_ci-check.yml` derives its task list from `package.json`, so adding `gitignore-sync check` to a repo's `check` script is picked up with no workflow change at all. +The third surface is for machines without Node: every stable release carries a **Node single executable** per platform — `darwin` and `linux`, `arm64` and `x64` — built by `sea.yml` — on every pull request that touches the build, for all four platforms, and called by `release-please.yml` to attach them to the release, so the check and the release cannot drift ([ADR-0010](docs/99.adr/0010-ship-standalone-binaries-as-node-single-executables.md)). It is the same bundle with node inside it, not a second runtime: `bun build --compile` would have been one command, but it would ship a binary running on a runtime the test suite never runs on. Three things in it are not cosmetic: the entry is CommonJS (`vite.sea.config.ts`), because the embedder runs the main script as CJS; it must stay one file, because the embedder's `require` loads builtins only; and darwin needs `--macho-segment-name NODE_SEA` plus an ad-hoc re-sign. `scripts/sea-build.ts` checks all of it by running the binary before packaging. Archive names carry no version, so `releases/latest/download/…` always resolves. Windows gets no binary; npm is the path there. + ## Architecture / conventions - **Node 24, pnpm 11.** Pinned via `.nvmrc`, `engines`, and `packageManager`. Not Bun — the sibling `forgemap` declares `engines.node >= 24` and this repo follows it. `pnpm-workspace.yaml` enforces `minimumReleaseAge=4320` (3-day cooldown), isolated node-linker. Package-manager enforcement carries no key on purpose: pnpm 11 replaced `packageManagerStrict`/`packageManagerStrictVersion` with `pmOnFail`, whose default `download` already errors on a foreign package manager and fetches the pinned pnpm version — every other value only weakens it, so leave it unset. @@ -236,11 +249,11 @@ Most repos need neither. `kirchDev/workflows`' `_ci-check.yml` derives its task - **Published as `@kirchdev/gitignore-sync`**, scoped like `@kirchdev/coverage-report`. The `bin` entries stay unscoped (`gitignore-sync`, `gis`) and so does release-please's `package-name` — the scope belongs to the registry, not to what a person types or what a tag is called. - **`pnpm link --global .` edits `pnpm-workspace.yaml`**, adding an `overrides:` block pointing at the local checkout. That file is committed, so the block must never be: it would make every clone try to link a package that exists on one machine. `pnpm unlink` reverts it; `pnpm link` is only for trying the CLI locally. - **The CLI's `--version` is injected at build time** by vite's `define` from `package.json`'s `version`, which release-please owns. Never hand-copy a version literal. -- **Runtime deps stay to the sibling handful**: `citty`, `consola`, `pathe`. `c12` and `defu` are deliberately absent — the config lives in the `.gitignore`, so there is no config file to load or merge. +- **Runtime deps stay to the sibling handful**: `citty`, `consola`, `pathe`. `c12` and `defu` are deliberately absent — the config lives in the `.gitignore`, so there is no config file to load or merge. `postject`, which injects the single executable's blob, is a dev dependency only: the binary carries nothing of it. - **Husky hooks** (`.husky/pre-commit`, `.husky/commit-msg`) run `lint-staged` and `commitlint`. `lint-staged.config.ts` excludes `README.md`, `CLAUDE.md`, and `AGENTS.md` (free-form prose) and `pnpm-lock.yaml`. - **Conventional Commits enforced** via `@commitlint/config-conventional`. Don't `--no-verify` unless explicitly asked. - **release-please** with `release-type: node`, `include-v-in-tag: true`. Files: `release-please-config.json`, `.release-please-manifest.json`, `.github/workflows/release-please.yml`. -- **Workflows** use `actions/checkout@v7`, `actions/setup-node@v7`, `pnpm/action-setup@v6`, `github/codeql-action/{init,analyze}@v4`. Keep these pinned to major versions; Dependabot bumps them monthly. +- **Workflows** use `actions/checkout@v7`, `actions/setup-node@v7`, `github/codeql-action/{init,analyze}@v4` — GitHub's own actions, pinned to major versions. A third-party action is pinned to a commit SHA with its version as a comment (`pnpm/action-setup@ # v6.0.10`), exactly like the `kirchDev/workflows` refs: CodeQL flags a movable tag on one, since its owner can repoint it. Dependabot bumps both forms monthly. - **Bitwarden secret ids in workflows are vault references, not credentials.** The actual secret is `BWS_ACCESS_TOKEN`, a GitHub secret that never appears in a workflow file. Each owner authenticates as a different machine account and reads only its own `-ci` mirror, so this repo — a `kirchDev` repo — must carry the `kirchDev` id (`df8b447a-ffd5-4009-9cc3-b49b014f6978`), not `TitusKirch`'s. A mismatched id resolves for nobody, silently, until someone dispatches the workflow. ## AI & skills diff --git a/README.md b/README.md index 01fa003..f6fa408 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,24 @@ pnpm add -g @kirchdev/gitignore-sync # npm i -g / yarn global add / bun add -g Or run it without installing: `npx @kirchdev/gitignore-sync `. The binary is also available as `gis`. +For a machine with no Node on it, every release also carries a standalone binary with the runtime inside it, for `darwin_arm64`, `darwin_x64`, `linux_x64` and `linux_arm64`. Download it, verify it, and put it on your `PATH`: + +```bash +NAME="gitignore-sync_$(uname -s | tr A-Z a-z)_$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tar.gz" +curl -fsSLO "https://github.com/kirchDev/gitignore-sync/releases/latest/download/${NAME}" +curl -fsSLO "https://github.com/kirchDev/gitignore-sync/releases/latest/download/${NAME}.sha256" +shasum -a 256 -c "${NAME}.sha256" # or: sha256sum -c +tar -xzf "${NAME}" +mkdir -p ~/.local/bin && mv gitignore-sync ~/.local/bin/ # or any directory on your PATH +gitignore-sync info # reports "single executable" +``` + +> [!NOTE] +> The archive holds one file and installs nothing else, so any directory on your `PATH` works, and `gis` works as a name too (`ln -s ~/.local/bin/gitignore-sync ~/.local/bin/gis`). To update, run the same lines again; to uninstall, delete the file. Swap `latest/download` for `download/vX.Y.Z` to pin a release. Windows has no binary; use the npm package there. + +> [!TIP] +> The macOS binaries are signed ad hoc, not notarised. Downloaded with `curl` as above they run as they are; one downloaded in a browser is quarantined first — clear it with `xattr -d com.apple.quarantine gitignore-sync`. + ## 🚀 Quick start ```bash @@ -82,7 +100,6 @@ Every command takes `--help` and a directory (`--dir` for `add`/`remove`, positi # region core@v1 .DS_Store -.claude/settings.local.json # endregion # region node@v1 @@ -102,7 +119,7 @@ Everything below the closing `# endregion` is the **free zone**: yours, preserve ## 🧩 Stacks -Twenty-one curated blocks, each derived from what the estate actually ignores rather than from a public template site. +Curated blocks, each derived from what real repositories actually ignore — or from what the tool writing the files puts in its own scaffold — rather than from a public template site. | Stack | What it covers | Proposed by `init` when | | :--------- | :----------------------------------- | :---------------------------- | @@ -113,14 +130,19 @@ Twenty-one curated blocks, each derived from what the estate actually ignores ra | `dotenv` | `.env*`, minus the committed example | `.env.example` or `.env` | | `php` | `/vendor`, PHPUnit caches | `composer.json` | | `laravel` | build output, storage keys, SSR | `artisan` | +| `octane` | the server binaries `octane:install` adds | `config/octane.php` | | `go` | test and build droppings | `go.mod` | +| `python` | `__pycache__/`, `.venv/`, tool caches | `pyproject.toml`, `requirements.txt`, `setup.py` | | `tofu` | state, tfvars, local overrides | `*.tf` / `*.tofu` | +| `helm` | vendored chart tarballs | `Chart.yaml` | | `nuxt` | `.nuxt`, `.output`, `.nitro` | `nuxt.config.*` | +| `vite` | `dist-ssr`, `*.local` | `vite.config.*` beside `index.html` | | `tauri` | `src-tauri/target`, generated schemas| `src-tauri/` | | `rust` | `/target` | a root `Cargo.toml` | | `turborepo`| `.turbo` | `turbo.json` | | `playwright` | test-results, reports | `playwright.config.*` | | `storybook`| `storybook-static` | `.storybook/` | +| `gradle` | `.gradle/`, `.kotlin/`, `build/` | `build.gradle*` / `settings.gradle*` | | `vscode` | `.vscode/*` + the shared files | you have a `.vscode/` | | `intellij` | `.idea/*` | you have a `.idea/` | | `vim` | swap and session files | your `$EDITOR` is vim | @@ -149,14 +171,14 @@ There is no config file. The configuration is the `# stacks:` line in the `.giti - run: npx @kirchdev/gitignore-sync check ``` -`check` writes nothing. It exits non-zero when the file has drifted from its header, and prints what it would have changed. +`check` writes nothing. It exits non-zero when the file has drifted from its header, and prints its findings. There is also an action, for a job summary on failure and a `status` output: ```yaml -- uses: kirchDev/gitignore-sync@v0.1.0 +- uses: kirchDev/gitignore-sync@vX.Y.Z with: - version: '0.1.0' # pin it, so a release cannot turn a green pipeline red + version: 'X.Y.Z' # pin it, so a release cannot turn a green pipeline red ``` > [!NOTE] diff --git a/docs/1.concepts/3.detection.md b/docs/1.concepts/3.detection.md index fa8b3fb..dfba5af 100644 --- a/docs/1.concepts/3.detection.md +++ b/docs/1.concepts/3.detection.md @@ -38,6 +38,12 @@ repository keeps its stacks in `tofu/` rather than at the top. `examples/`, `fixtures/`, `testdata/` and dependency directories are never descended into: a `.tf` file shown as documentation is not a workspace. +**A fingerprint can narrow itself further.** `python` does not look inside +`docs/`, where Read the Docs, MkDocs and Sphinx keep their build requirements in +repositories with no Python of their own. `vite` counts a `vite.config.*` only +beside an `index.html` — the entry every Vite app has and a library or CLI built +with Vite does not, so those are not offered lines that point at nothing. + ## Why it's built this way **Adding a file must not rewrite someone's `.gitignore`.** If `sync` detected, diff --git a/docs/1.concepts/4.templates.md b/docs/1.concepts/4.templates.md index 98134b8..e996460 100644 --- a/docs/1.concepts/4.templates.md +++ b/docs/1.concepts/4.templates.md @@ -52,10 +52,15 @@ than deleting something it should not. catch-all. Anything tool-specific belongs in a stack someone can drop, which is why agent working files live in `agents` and `node_modules` in `node`. -**The stacks come from real repositories, not a template site.** Each one was +**The stacks come from real repositories, not a template site.** Most were derived from what a set of locally cloned kirchDev and TitusKirch repositories -actually ignore, and a stack ships only when that estate asks for it. Some -choices that follow from this look like omissions and are not: +actually ignore. A stack may also ship on the word of the tool that writes the +files — its scaffold's own `.gitignore` or the output it documents — which is +how `laravel` follows the Laravel skeleton, `vite` the `create-vite` scaffold and +`helm` what `helm dependency update` vendors +([ADR-0009](/adr/0009-accept-stacks-on-the-word-of-the-tool-that-writes-the-files)). +A public template site's guess never qualifies. Some choices that follow from +this look like omissions and are not: - `composer.lock` is in no template — a library ignores it, an application commits it, so it is a project decision and belongs in the free zone. @@ -64,9 +69,15 @@ choices that follow from this look like omissions and are not: build tool, and `*.jar` is left out because the Gradle wrapper jar is committed. - `octane` uses the spellings `octane:install` appends (`frankenphp`, not `/frankenphp`), so re-running the installer adds nothing. -- `dist` belongs to `node` alone, because a line may live in only one stack. +- `dist` belongs to `node` alone, because a line may live in only one stack. The + same rule keeps `dist/` and `build/` out of `python`: a pure Python repository + that builds wheels keeps `dist/` in its free zone. +- `helm` names only `**/charts/*.tgz`, never `charts/` itself — a bare directory + would hide the charts a chart repository keeps there. `Chart.lock` is + committed. **Coverage is measured with the tool itself.** `gitignore-sync audit ` declares every stack against each named repository and reports what would still -be left over. A stack that would move that number by one line is a stack nobody -asked for — see [Add a stack](/guides/add-a-stack). +be left over. A stack that would move that number by one line needs the writing +tool's own word instead, or it is a project rule — see +[Add a stack](/guides/add-a-stack). diff --git a/docs/2.guides/3.add-a-stack.md b/docs/2.guides/3.add-a-stack.md index 6b7c706..6e3a02c 100644 --- a/docs/2.guides/3.add-a-stack.md +++ b/docs/2.guides/3.add-a-stack.md @@ -4,8 +4,10 @@ description: Add a new curated stack to gitignore-sync — measured against real icon: lucide:circle-plus --- -A new stack is justified by measurement, not by a template site. Do this when an -`audit` across real repositories shows the same leftover lines recurring. +A new stack is justified by evidence, not by a template site: an `audit` across +real repositories showing the same leftover lines recurring, or the tool that +writes the files listing them itself — its scaffold's own `.gitignore`, or output +it documents. ## Steps @@ -16,14 +18,17 @@ A new stack is justified by measurement, not by a template site. Do this when an gitignore-sync audit ../repo-a ../repo-b --min-files=2 ``` - If the stack would remove one line from one file, it is a project rule and - belongs in that repository's free zone. + If the stack would remove one line from one file, it needs the second kind + of evidence — the writing tool's own scaffold or documentation, in its + spelling — or it is a project rule and belongs in that repository's free + zone. 2. **Add the template** — a new key in the registry in `src/templates/index.ts`, starting at `version: 1`. Its position decides where it lands in the `# stacks:` line: repository stacks before the person-level ones. Write a - comment above it saying where the lines came from and what was deliberately - left out. + comment above it saying where the lines came from — the repositories + measured, or the scaffold or documentation followed — and what was + deliberately left out. 3. **Check the template rules** — no line that another stack already carries, no bare `.vscode/` or `.idea/`, `!` exceptions only for files repositories really @@ -31,8 +36,10 @@ A new stack is justified by measurement, not by a template site. Do this when an 4. **Add the fingerprint** — an entry in `src/detect.ts` with its `source`: `repo` for a committed marker, `machine` for something that describes one - person. A stack with no reliable fingerprint can still ship; it is then - declared by hand. + person. A marker that also turns up where the stack does not apply can be + narrowed with `skip` (directories its patterns never enter) or `beside` (a + file that must sit next to the match). A stack with no reliable fingerprint + can still ship; it is then declared by hand. 5. **Pin the behaviour** — add a fixture pair `tests/fixtures/.in` / `.out`. `tests/fixtures.test.ts` picks it up and asserts idempotency on @@ -46,14 +53,16 @@ A new stack is justified by measurement, not by a template site. Do this when an ``` 7. **Update the stack tables** in the README and in `CLAUDE.md` (then copy it over - `AGENTS.md`), and run `pnpm check`. + `AGENTS.md`) — with an honest repository count, `0` included, and the source + named where it is not the estate — and run `pnpm check`. ## Checklist -- [ ] `audit` shows the stack absorbing lines across more than one repository +- [ ] `audit` shows the stack absorbing lines across more than one repository, or the writing tool's own scaffold or documentation lists them - [ ] The template is a new registry key at `version: 1`, with a provenance comment - [ ] `tests/templates.test.ts` passes: no line shared with another stack - [ ] The fingerprint has the right `source`, or the stack is documented as hand-declared - [ ] A fixture pair covers the stack - [ ] `tests/templates.lock.json` contains the new version and is formatted +- [ ] The README and `CLAUDE.md` / `AGENTS.md` stack tables list it - [ ] `pnpm check` is green diff --git a/docs/99.adr/0009-accept-stacks-on-the-word-of-the-tool-that-writes-the-files.md b/docs/99.adr/0009-accept-stacks-on-the-word-of-the-tool-that-writes-the-files.md new file mode 100644 index 0000000..303b4da --- /dev/null +++ b/docs/99.adr/0009-accept-stacks-on-the-word-of-the-tool-that-writes-the-files.md @@ -0,0 +1,66 @@ +--- +title: Accept stacks on the word of the tool that writes the files +description: A stack may ship either because an audit across real repositories shows its lines recurring, or because the tool that writes the files lists them in its own scaffold or documentation; a public template site never qualifies. +status: accepted +date: 2026-10-15 +--- + +# ADR-0009 — Accept stacks on the word of the tool that writes the files + +## Context + +The first stacks were derived from the locally cloned kirchDev and TitusKirch +repositories, and a stack was meant to ship only when an `audit` across that +estate showed it absorbing lines. The rule kept public template sites out: +their blocks are guesses about what some project might produce, and copying +them is what makes a fetched `.gitignore` long. + +The rule had already bent once. `php@v2` and `laravel@v2` followed the Laravel +skeleton's own `.gitignore`, so that a fresh `laravel new` was absorbed rather +than reported as equivalent spellings, before any repository in the estate +carried those lines. + +A contribution then proposed `python`, `helm` and `vite`. Measured across the +estate, `python` absorbed ten lines, while `helm` and `vite` absorbed none: no +repository held a Helm chart, and the one Vite app did not ignore the +scaffold's lines yet. Under the original wording both would have been refused, +although each names exactly what its tool writes — the tarballs +`helm dependency update` vendors into `charts/`, and the two lines the +`create-vite` scaffold adds on top of `node`. + +## Decision + +- A stack ships on one of two kinds of evidence: an `audit` across real + repositories showing its lines recurring, or the tool that writes the files + listing them itself — its scaffold's own `.gitignore`, or output it + documents. +- A public template site's block never counts as evidence of either kind. +- The provenance comment above a template names its source: the repositories + measured, or the scaffold or documentation followed. Lines from a scaffold + keep the scaffold's spelling. +- The stack table in `CLAUDE.md` keeps an honest repository count, `0` + included, and says where a stack's evidence comes from when it is not the + estate. + +## Consequences + +- A repository created from a scaffold is absorbed on its first `sync`, before + the estate has grown a copy of it. +- Stacks can ship with a count of `0`, so the table alone no longer says + whether the estate needs a stack; the provenance note does. +- A stack that follows a scaffold tracks that scaffold. When the scaffold + changes its lines, the stack takes a new version rather than an edit, as + [ADR-0005](/adr/0005-version-templates-append-only-behind-a-lock) requires. +- The template rules are unchanged: one line per stack, no bare editor + directories, measured `!` exceptions. + +## Alternatives considered + +- **Keep the estate as the only source.** Rejected: it refuses stacks whose + lines are known exactly, only because nobody in the estate has used the + tool yet, and it was already not the rule `laravel@v2` had followed. +- **Accept lines from public template sites.** Rejected: that is the + fetch-and-dump approach the tool exists to replace, and the blocks it + produces are what makes a generated `.gitignore` long. +- **Accept a stack on a contributor's say-so alone.** Rejected: the claim is + not checkable by a reviewer, while a scaffold or a measured repository is. diff --git a/docs/99.adr/0010-ship-standalone-binaries-as-node-single-executables.md b/docs/99.adr/0010-ship-standalone-binaries-as-node-single-executables.md new file mode 100644 index 0000000..f7e217f --- /dev/null +++ b/docs/99.adr/0010-ship-standalone-binaries-as-node-single-executables.md @@ -0,0 +1,73 @@ +--- +title: Ship standalone binaries as Node single executables +description: Every stable release carries a Node single executable per platform, built from the npm bundle on a runner of that platform, so the CLI runs on a machine without Node on the same runtime the tests use. +status: accepted +date: 2026-10-15 +--- + +# ADR-0010 — Ship standalone binaries as Node single executables + +## Context + +The CLI shipped as an npm package and as a composite GitHub Action, and both +need Node on the machine. A request came in to run it where neither Node nor a +package manager is installed. The bundle was already one file with three +small runtime dependencies, so nothing in the code stood in the way of +packaging it with a runtime. + +Two ways to do that were tried. `bun build --compile` produced a working +binary in one command and could target every platform from one machine. +Node's own single executable applications (SEA) needed more: a preparation +blob, `postject` to inject it into a copy of the node binary, a CommonJS +entry, and a re-sign on macOS. At the time of this decision, SEA on Node 24 +was still behind `--experimental-sea-config`. + +The project had settled on Node rather than Bun as its runtime: the tests, +the npm package and the action all run on Node 24. + +## Decision + +- Every stable release carries a single executable per platform — `darwin` + and `linux`, `arm64` and `x64` — built with Node SEA, not with Bun. +- Each one is built on a runner of its own platform, after the npm build, and + embeds the node that ran the build, which `.nvmrc` pins. One workflow does + it for two callers: a pull request touching the build runs it for every + platform, and the release calls it with the tag to attach the archives. + After packaging, it installs the archive the way the README tells people to + and runs it with no node on the `PATH`. +- The SEA bundle is a separate vite build: CommonJS, everything but node's + builtins inlined, one file. `scripts/sea-build.ts` refuses a bundle that + split, re-signs ad hoc on macOS, and runs `--version` and `list` out of the + binary before packaging it. +- Archives carry no version in their name, so + `releases/latest/download/` always resolves; a SHA-256 checksum file + sits beside each one. +- `info` reports a single executable as its own build kind, so a downloaded + binary is never mistaken for an installed package or an unknown one. +- Windows gets no binary; the npm package covers it. + +## Consequences + +- The CLI runs on a machine without Node, on the same runtime the test suite + exercises, so a binary cannot behave differently from the npm package + because of the runtime underneath it. +- Each binary is about the size of node itself, and the release takes four + extra runner jobs. +- `postject` joins the dev dependencies; nothing of it ships. +- macOS binaries are signed ad hoc only, not notarised, so a browser download + may still need clearing from quarantine. +- Code that reaches for `node_modules` at runtime, or a dynamic import that + splits the bundle, breaks the binary; the build script catches the split, + and the inlined dependencies keep the rest honest. + +## Alternatives considered + +- **`bun build --compile`.** Rejected: one command and cross-compiled, but + the binary would run on a runtime the test suite never runs on, against a + project that had chosen Node for exactly that reason. +- **`deno compile`.** Rejected for the same reason, with a less complete Node + compatibility layer on top. +- **`pkg` or `nexe`.** Rejected: both had stopped being maintained, and Node's + own mechanism had replaced them. +- **No binaries, npm only.** Rejected: it leaves the request unanswered for + machines where installing Node is the thing someone wants to avoid. diff --git a/docs/99.adr/index.md b/docs/99.adr/index.md index 8371019..c82710a 100644 --- a/docs/99.adr/index.md +++ b/docs/99.adr/index.md @@ -21,3 +21,5 @@ to it. | [ADR-0006](/adr/0006-give-each-line-one-stack-and-keep-core-to-ds-store) | Give each line one stack, and keep core to .DS_Store | accepted | 2026-09-01 | | [ADR-0007](/adr/0007-classify-nested-gitignores-before-acting-on-them) | Classify nested .gitignore files before acting on them | accepted | 2026-09-01 | | [ADR-0008](/adr/0008-ship-the-ci-gate-as-a-composite-action) | Ship the CI gate as a composite action | accepted | 2026-09-01 | +| [ADR-0009](/adr/0009-accept-stacks-on-the-word-of-the-tool-that-writes-the-files) | Accept stacks on the word of the tool that writes the files | accepted | 2026-10-15 | +| [ADR-0010](/adr/0010-ship-standalone-binaries-as-node-single-executables) | Ship standalone binaries as Node single executables | accepted | 2026-10-15 | diff --git a/package.json b/package.json index 6962615..5c86887 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ "scripts": { "build": "vite build", "dev": "vite build --watch", + "sea:bundle": "vite build -c vite.sea.config.ts", + "sea:build": "pnpm sea:bundle && node scripts/sea-build.ts", "link": "pnpm build && pnpm link --global .", "unlink": "pnpm uninstall -g @kirchdev/gitignore-sync && git checkout pnpm-workspace.yaml", "lint": "oxlint . --deny-warnings", @@ -73,6 +75,7 @@ "lint-staged": "^17.6.0", "oxfmt": "0.72.0", "oxlint": "1.87.0", + "postject": "1.0.0-alpha.6", "taze": "^21.3.0", "typescript": "^7.0.2", "vite": "^8.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98ea740..58b8a60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,6 +203,9 @@ importers: oxlint: specifier: 1.87.0 version: 1.87.0 + postject: + specifier: 1.0.0-alpha.6 + version: 1.0.0-alpha.6 taze: specifier: ^21.3.0 version: 21.3.0 @@ -910,6 +913,10 @@ packages: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -1229,6 +1236,11 @@ packages: resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} engines: {node: ^10 || ^12 || >=14} + postject@1.0.0-alpha.6: + resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} + engines: {node: '>=14.0.0'} + hasBin: true + quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} @@ -1921,6 +1933,8 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 + commander@9.5.0: {} + consola@3.4.2: {} conventional-changelog-angular@9.4.0: @@ -2192,6 +2206,10 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.2 + postject@1.0.0-alpha.6: + dependencies: + commander: 9.5.0 + quansync@1.0.0: {} require-from-string@2.0.2: {} diff --git a/scripts/sea-build.ts b/scripts/sea-build.ts new file mode 100644 index 0000000..0a88fdc --- /dev/null +++ b/scripts/sea-build.ts @@ -0,0 +1,138 @@ +#!/usr/bin/env node + +/** + * Packages the CLI as a single executable for the platform this runs on. + * + * The mechanism is node's own: `--experimental-sea-config` writes a + * preparation blob, postject injects it into a copy of the node binary, and + * the result is a file with no runtime to install next to it. + * + * Two details are not cosmetic, and cost a SIGSEGV each when missed: + * + * - **`--macho-segment-name NODE_SEA` on darwin.** Without it the injection + * reports success, the binary runs, and it segfaults before reaching the + * entry point. + * - **A CommonJS entry.** Node's embedder runs the main script as CJS, so an + * ESM bundle dies with "Cannot use import statement outside a module". + * `dist/sea/entry.cjs` exists for that alone — see `vite.sea.config.ts`. + * + * The embedded runtime is whatever node runs this script, so a release carries + * the version CI resolved from `.nvmrc` instead of a second pin that could + * drift from it. + * + * There is no cross-compilation to be had: a single executable *is* the target + * platform's node binary with a blob inside it. The release workflow runs this + * once per platform, on a runner of that platform. + */ + +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + copyFileSync, + mkdirSync, + readFileSync, + readdirSync, + writeFileSync +} from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const seaDir = join(root, 'dist', 'sea'); +const outDir = join(root, 'dist', 'release'); +const binName = 'gitignore-sync'; + +/** postject's default, and the string node looks for inside the binary. */ +const FUSE = 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2'; + +const run = (file: string, args: string[]): void => { + execFileSync(file, args, { stdio: 'inherit' }); +}; + +const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { + version: string; +}; + +const { platform, arch } = process; +if (platform !== 'darwin' && platform !== 'linux') { + console.error(`No single executable is built for ${platform}.`); + process.exit(1); +} +if (arch !== 'arm64' && arch !== 'x64') { + console.error(`No single executable is built for ${arch}.`); + process.exit(1); +} + +// The embedder can only run one script: anything vite split off the entry would +// be missing at runtime, and only on the command that reaches for it. +const emitted = readdirSync(seaDir).filter( + (name) => !['entry.cjs', 'sea-config.json', 'prep.blob'].includes(name) +); +if (emitted.length > 0) { + console.error(`The SEA bundle is not one file: ${emitted.join(', ')}.`); + process.exit(1); +} + +mkdirSync(outDir, { recursive: true }); + +const config = join(seaDir, 'sea-config.json'); +const blob = join(seaDir, 'prep.blob'); +writeFileSync( + config, + `${JSON.stringify( + { + main: join(seaDir, 'entry.cjs'), + output: blob, + disableExperimentalSEAWarning: true + }, + null, + 2 + )}\n`, + 'utf8' +); +run(process.execPath, ['--experimental-sea-config', config]); + +const binary = join(outDir, binName); +copyFileSync(process.execPath, binary); + +const inject = [binary, 'NODE_SEA_BLOB', blob, '--sentinel-fuse', FUSE]; +if (platform === 'darwin') inject.push('--macho-segment-name', 'NODE_SEA'); +run(join(root, 'node_modules', '.bin', 'postject'), inject); + +// Injecting bytes invalidates the signature the node binary shipped with, and +// macOS refuses to run an arm64 binary whose signature no longer matches. +// Ad-hoc is enough for a tarball: nothing here is notarised or distributed +// through the App Store. +if (platform === 'darwin') { + run('codesign', ['--remove-signature', binary]); + run('codesign', ['--sign', '-', binary]); +} + +// The binary has to answer before it is packaged. `--version` is the check that +// would have caught both mistakes in the comment at the top; `list` goes past +// the argument parser into a command and the bundled templates. +const reported = execFileSync(binary, ['--version'], { + encoding: 'utf8' +}).trim(); +if (reported !== pkg.version) { + console.error(`The binary reports ${reported}, expected ${pkg.version}.`); + process.exit(1); +} +execFileSync(binary, ['list'], { stdio: 'ignore' }); + +// No version in the name: the tag already carries it, and a stable name is what +// lets `releases/latest/download/` always point at the newest binary. +const archive = `${binName}_${platform}_${arch}.tar.gz`; +run('tar', ['-czf', join(outDir, archive), '-C', outDir, binName]); + +const sha = createHash('sha256') + .update(readFileSync(join(outDir, archive))) + .digest('hex'); +writeFileSync( + join(outDir, `${archive}.sha256`), + `${sha} ${archive}\n`, + 'utf8' +); + +console.log(`Wrote ${archive}`); +console.log(`sha256 ${sha}`); diff --git a/src/commands/info.ts b/src/commands/info.ts index 603abe1..69deff5 100644 --- a/src/commands/info.ts +++ b/src/commands/info.ts @@ -1,4 +1,5 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs'; +import { isSea } from 'node:sea'; import { defineCommand } from 'citty'; import { colors } from 'consola/utils'; import { dirname, join, resolve } from 'pathe'; @@ -18,8 +19,11 @@ const PACKAGE_NAME = '@kirchdev/gitignore-sync'; /** What a person types, and what the report is headed with. */ const BIN_NAME = 'gitignore-sync'; -/** `linked` = runs from a git work tree; `release` = an installed package. */ -type BuildKind = 'linked' | 'release' | 'unknown'; +/** + * `linked` = runs from a git work tree; `release` = an installed package; + * `sea` = the single executable, which has no package.json to be found. + */ +type BuildKind = 'linked' | 'release' | 'sea' | 'unknown'; type Info = { version: string; @@ -78,6 +82,18 @@ function findPackageRoot( * neither can be established, report `unknown` rather than guessing. */ function detectBuild(resolved: string | null): Info['build'] { + // Asked first, because the question the rest of this function answers has no + // answer here: a single executable is one file holding the runtime, the + // bundle and the templates, so there is no package.json above it to walk up + // to. Without this branch a downloaded release binary reports `unknown`, + // which reads like a defect rather than an ordinary way to install it. + if (isSea()) { + return { + kind: 'sea', + packageRoot: null, + reason: 'single executable, nothing installed beside it' + }; + } if (!resolved) { return { kind: 'unknown', @@ -154,6 +170,7 @@ function inspectRepository(dir: string): Info['repository'] { const BUILD_LABELS: Record = { linked: 'linked / dev build', release: 'installed release', + sea: 'single executable', unknown: 'unknown' }; diff --git a/src/detect.ts b/src/detect.ts index 510e1c3..8108632 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -28,6 +28,10 @@ type Fingerprint = { files?: string[]; /** Or any entry matching one of these, searched to `PATTERN_DEPTH`. */ patterns?: RegExp[]; + /** A pattern match only counts with one of these in the same directory. */ + beside?: string[]; + /** Directories this stack's patterns never descend into, beyond `SKIP`. */ + skip?: string[]; /** Or the platform running the command. */ platform?: string; /** Or `$VISUAL` / `$EDITOR`. */ @@ -83,11 +87,35 @@ const fingerprints: Record = { // Published by `octane:install`, whichever server the repo picks. octane: { source: 'repo', files: ['config/octane.php'] }, go: { source: 'repo', files: ['go.mod'] }, + // A pattern rather than a file list: an Ansible repo keeps its + // `requirements.txt` in `ansible/`, and a monorepo its backend's + // `pyproject.toml` one level down. `docs/` is not: Read the Docs, MkDocs and + // Sphinx keep their build requirements there, in repos with no Python of + // their own. + python: { + source: 'repo', + patterns: [/^(?:pyproject\.toml|requirements\.txt|setup\.py)$/], + skip: ['docs'] + }, tofu: { source: 'repo', patterns: [/\.tf$/, /\.tofu$/, /^\.terraform\.lock\.hcl$/] }, + // Finds a chart at the root or one level down. A chart repository keeping + // each chart in `charts//` sits below `PATTERN_DEPTH` and declares the + // stack by hand — deepening the search for it would start finding the charts + // other people's examples ship. + helm: { source: 'repo', patterns: [/^Chart\.yaml$/] }, nuxt: { source: 'repo', patterns: [/^nuxt\.config\.[cm]?[jt]s$/] }, + // One level down as well: a Go service keeps its SPA in `web/` or + // `frontend/`, which is where the config sits. Only beside an `index.html`, + // the entry every Vite app has and a library or CLI built with Vite does + // not — there `dist-ssr` and `*.local` would point at nothing. + vite: { + source: 'repo', + patterns: [/^vite\.config\.[cm]?[jt]s$/], + beside: ['index.html'] + }, tauri: { source: 'repo', files: ['src-tauri'] }, // A committed `.env.example` is the marker, not `.env` — the latter is the // very file the stack exists to keep out, so it is often absent on a fresh @@ -106,25 +134,27 @@ export const BASE_STACK = 'core'; function matchesPatterns( dir: string, - patterns: RegExp[], + print: Fingerprint, depth: number ): boolean { + const patterns = print.patterns ?? []; let entries: Dirent[]; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return false; } + const beside = + !print.beside || print.beside.some((file) => existsSync(join(dir, file))); for (const entry of entries) { - if (entry.isDirectory()) continue; + if (entry.isDirectory() || !beside) continue; if (patterns.some((pattern) => pattern.test(entry.name))) return true; } if (depth <= 1) return false; for (const entry of entries) { if (!entry.isDirectory() || entry.name.startsWith('.')) continue; - if (SKIP.has(entry.name)) continue; - if (matchesPatterns(join(dir, entry.name), patterns, depth - 1)) - return true; + if (SKIP.has(entry.name) || print.skip?.includes(entry.name)) continue; + if (matchesPatterns(join(dir, entry.name), print, depth - 1)) return true; } return false; } @@ -137,8 +167,7 @@ function matches(dir: string, print: Fingerprint): boolean { const editor = process.env.VISUAL ?? process.env.EDITOR ?? ''; if (print.editor.test(editor)) return true; } - if (print.patterns) - return matchesPatterns(dir, print.patterns, PATTERN_DEPTH); + if (print.patterns) return matchesPatterns(dir, print, PATTERN_DEPTH); return false; } diff --git a/src/templates/index.ts b/src/templates/index.ts index 7d0207a..aef29d4 100644 --- a/src/templates/index.ts +++ b/src/templates/index.ts @@ -112,6 +112,33 @@ const registry: Record = { lines: ['*.exe', '*.test', '*.out', '*.prof'] } ], + // The caches Python tooling drops beside the source it just read, plus the + // virtualenv. Nothing packaging-related: a wheel is built into `dist/` and a + // test run reports into `coverage`, and both of those lines are `node`'s. + // + // `.venv/` is unanchored because an Ansible repo keeps its control-machine + // virtualenv in `ansible/.venv/` rather than at the root. `.coverage` and + // `htmlcov/` are coverage.py's data file and its HTML report — distinct + // spellings from `coverage`, so neither collides with the `node` block. + // + // `*.py[cod]` is deliberately absent: CPython has written bytecode into + // `__pycache__/` since 3.2 (PEP 3147), so the directory already covers where + // it lands and the glob would only reach leftovers from Python 2. + python: [ + { + stack: 'python', + version: 1, + lines: [ + '__pycache__/', + '.venv/', + '.pytest_cache/', + '.mypy_cache/', + '.ruff_cache/', + '.coverage', + 'htmlcov/' + ] + } + ], // State and local overrides. `.terraform.lock.hcl` is deliberately **not** // ignored — it is meant to be committed. `*.tfvars` usually carries // credentials, so it is ignored with the example carved back out. @@ -134,6 +161,28 @@ const registry: Record = { ] } ], + // `helm dependency update` vendors a chart's dependencies as tarballs into + // its `charts/` directory. `Chart.lock` records what it resolved and is + // meant to be committed, like `.terraform.lock.hcl` above. + // + // Only the tarballs are named, never the directory: git does not descend + // into an ignored directory, so a bare `charts/` would hide the charts + // themselves — the same defect a bare `.vscode` causes. + // + // `**/charts/` rather than `charts/` because a chart repository keeps each + // chart in `charts//`, one level deeper than a repo shipping a single + // chart at its root. + // + // A chart built with `helm package` lands as `-.tgz` in the + // working directory, and that spelling is `node@v1`'s (`npm pack` writes one + // too). A line lives in one stack, so it stays there. + helm: [ + { + stack: 'helm', + version: 1, + lines: ['**/charts/*.tgz'] + } + ], // `composer.lock` is not here on purpose: a library ignores it, an // application commits it. That is a project decision, so it belongs in the // free zone rather than in a block the tool re-renders. @@ -297,6 +346,22 @@ const registry: Record = { lines: ['.nuxt', '.output', '.nitro', '.data'] } ], + // The two lines a `create-vue` scaffold carries that `node` does not already + // have, in the scaffold's own spelling, so a fresh one is absorbed rather + // than reported as equivalent spellings. + // + // Named after the build tool and not Vue: what a repo ignores follows + // whatever writes the files, the same reason the JVM stack is `gradle` and + // not `java`. A Vue repo on another bundler has neither line, and a Vite + // library that never builds for SSR carries them inert — which is still + // cheaper than every app repeating them by hand in the free zone. + vite: [ + { + stack: 'vite', + version: 1, + lines: ['dist-ssr', '*.local'] + } + ], tauri: [ { stack: 'tauri', diff --git a/tests/detect.test.ts b/tests/detect.test.ts index b4c0907..aeb0ef7 100644 --- a/tests/detect.test.ts +++ b/tests/detect.test.ts @@ -50,6 +50,41 @@ describe('detect', () => { expect(names(dir)).toContain('gradle'); }); + it('fingerprints python from a requirements.txt one level down', () => { + const dir = fresh(); + mkdirSync(join(dir, 'ansible')); + writeFileSync(join(dir, 'ansible', 'requirements.txt'), ''); + expect(names(dir)).toContain('python'); + }); + + it('does not take a docs build requirements file for python', () => { + const dir = fresh(); + writeFileSync(join(dir, 'package.json'), '{}'); + mkdirSync(join(dir, 'docs')); + writeFileSync(join(dir, 'docs', 'requirements.txt'), ''); + expect(names(dir)).not.toContain('python'); + }); + + it('fingerprints helm from a chart at the root', () => { + const dir = fresh(); + writeFileSync(join(dir, 'Chart.yaml'), ''); + expect(names(dir)).toContain('helm'); + }); + + it('fingerprints vite from a config in a frontend subdirectory', () => { + const dir = fresh(); + mkdirSync(join(dir, 'web')); + writeFileSync(join(dir, 'web', 'vite.config.ts'), ''); + writeFileSync(join(dir, 'web', 'index.html'), ''); + expect(names(dir)).toContain('vite'); + }); + + it('does not take a library or CLI built with vite for a vite app', () => { + const dir = fresh(); + writeFileSync(join(dir, 'vite.config.ts'), ''); + expect(names(dir)).not.toContain('vite'); + }); + it('fingerprints dotenv from a committed .env.example', () => { const dir = fresh(); writeFileSync(join(dir, '.env.example'), ''); diff --git a/tests/fixtures/helm.in b/tests/fixtures/helm.in new file mode 100644 index 0000000..6d1ee54 --- /dev/null +++ b/tests/fixtures/helm.in @@ -0,0 +1,11 @@ +# region gitignore-sync +# stacks: core, helm +# ───────────────────────────────────────── + +# endregion + +# Vendored chart dependencies +**/charts/*.tgz + +# Packaged charts +*.tgz diff --git a/tests/fixtures/helm.out b/tests/fixtures/helm.out new file mode 100644 index 0000000..596390b --- /dev/null +++ b/tests/fixtures/helm.out @@ -0,0 +1,16 @@ +# region gitignore-sync +# stacks: core, helm +# ───────────────────────────────────────── + +# region core@v1 +.DS_Store +# endregion + +# region helm@v1 +**/charts/*.tgz +# endregion + +# endregion + +# Packaged charts +*.tgz diff --git a/tests/fixtures/python.in b/tests/fixtures/python.in new file mode 100644 index 0000000..27216bf --- /dev/null +++ b/tests/fixtures/python.in @@ -0,0 +1,17 @@ +# region gitignore-sync +# stacks: core, python +# ───────────────────────────────────────── + +# endregion + +# Python +__pycache__/ +.venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Ansible installs its collections into the control machine's venv +ansible/collections/ diff --git a/tests/fixtures/python.out b/tests/fixtures/python.out new file mode 100644 index 0000000..635c7cc --- /dev/null +++ b/tests/fixtures/python.out @@ -0,0 +1,22 @@ +# region gitignore-sync +# stacks: core, python +# ───────────────────────────────────────── + +# region core@v1 +.DS_Store +# endregion + +# region python@v1 +__pycache__/ +.venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +# endregion + +# endregion + +# Ansible installs its collections into the control machine's venv +ansible/collections/ diff --git a/tests/fixtures/vite.in b/tests/fixtures/vite.in new file mode 100644 index 0000000..0c0f4ab --- /dev/null +++ b/tests/fixtures/vite.in @@ -0,0 +1,21 @@ +# region gitignore-sync +# stacks: core, node, vite +# ───────────────────────────────────────── + +# endregion + +# Logs +*.log + +node_modules +.DS_Store +dist +dist-ssr +coverage +*.local + +# Cypress artefacts +/cypress/videos/ +/cypress/screenshots/ + +*.tsbuildinfo diff --git a/tests/fixtures/vite.out b/tests/fixtures/vite.out new file mode 100644 index 0000000..3bcb70c --- /dev/null +++ b/tests/fixtures/vite.out @@ -0,0 +1,30 @@ +# region gitignore-sync +# stacks: core, node, vite +# ───────────────────────────────────────── + +# region core@v1 +.DS_Store +# endregion + +# region node@v1 +node_modules +dist +coverage +/logs +*.log +*.tsbuildinfo +.eslintcache +.npm +*.tgz +# endregion + +# region vite@v1 +dist-ssr +*.local +# endregion + +# endregion + +# Cypress artefacts +/cypress/videos/ +/cypress/screenshots/ diff --git a/tests/info.test.ts b/tests/info.test.ts index 0827e26..3fb33c2 100644 --- a/tests/info.test.ts +++ b/tests/info.test.ts @@ -1,7 +1,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { __test } from '../src/commands/info.ts'; const { detectBuild, findPackageRoot, resolveBinary } = __test; @@ -68,4 +68,22 @@ describe('info', () => { it('reports unknown when nothing can be resolved', () => { expect(detectBuild(null).kind).toBe('unknown'); }); + + // `isSea()` is false under the test runner by construction, so the branch is + // reached by swapping the module rather than by building a binary the size of + // node itself in the suite. The real artifact is checked by `scripts/sea-build.ts`, which + // runs `--version` out of the binary before packaging it. + it('calls a single executable its own build, not an unknown one', async () => { + vi.resetModules(); + vi.doMock('node:sea', () => ({ isSea: () => true })); + try { + const { __test: sea } = await import('../src/commands/info.ts'); + const build = sea.detectBuild('/usr/local/bin/gitignore-sync'); + expect(build.kind).toBe('sea'); + expect(build.packageRoot).toBeNull(); + } finally { + vi.doUnmock('node:sea'); + vi.resetModules(); + } + }); }); diff --git a/tests/templates.lock.json b/tests/templates.lock.json index 94f397f..a4366ac 100644 --- a/tests/templates.lock.json +++ b/tests/templates.lock.json @@ -30,6 +30,15 @@ "*.tgz" ], "go@v1": ["*.exe", "*.test", "*.out", "*.prof"], + "python@v1": [ + "__pycache__/", + ".venv/", + ".pytest_cache/", + ".mypy_cache/", + ".ruff_cache/", + ".coverage", + "htmlcov/" + ], "tofu@v1": [ ".terraform/", "*.tfstate", @@ -43,6 +52,7 @@ "crash.log", "crash.*.log" ], + "helm@v1": ["**/charts/*.tgz"], "php@v1": ["/vendor", "/.phpunit.cache", "/.phpunit.result.cache"], "php@v2": [ "/vendor", @@ -94,6 +104,7 @@ ], "storybook@v1": ["storybook-static"], "nuxt@v1": [".nuxt", ".output", ".nitro", ".data"], + "vite@v1": ["dist-ssr", "*.local"], "tauri@v1": ["src-tauri/target", "src-tauri/gen/schemas"], "dotenv@v1": [ ".env", diff --git a/tests/templates.test.ts b/tests/templates.test.ts index 0dafb3b..9b4ba98 100644 --- a/tests/templates.test.ts +++ b/tests/templates.test.ts @@ -52,6 +52,15 @@ describe('templates', () => { } }); + // Same reason as the editor directories above: git does not descend into an + // ignored directory, so a bare `charts/` would hide every chart in a chart + // repository. Only the vendored tarballs may be named. + it('ignores the tarballs inside a chart directory, never the directory', () => { + const helm = currentTemplate('helm')?.lines ?? []; + expect(helm.length).toBeGreaterThan(0); + for (const line of helm) expect(line.endsWith('.tgz')).toBe(true); + }); + it('has no duplicate lines within a block', () => { for (const stack of knownStacks()) { const lines = currentTemplate(stack)?.lines ?? []; diff --git a/vite.config.ts b/vite.config.ts index e9d4879..d752c5d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,11 +1,13 @@ import { readFileSync } from 'node:fs'; import { builtinModules } from 'node:module'; import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; // From vitest/config, not vite: vitest 4 no longer augments vite's UserConfig // with the `test` key, so vite's own defineConfig rejects it. import { defineConfig } from 'vitest/config'; -const root = dirname(new URL(import.meta.url).pathname); +// fileURLToPath, not `.pathname`: the latter keeps `%20` in a path with spaces. +const root = dirname(fileURLToPath(import.meta.url)); const nodeBuiltins = new Set([ ...builtinModules, diff --git a/vite.sea.config.ts b/vite.sea.config.ts new file mode 100644 index 0000000..d8e2de9 --- /dev/null +++ b/vite.sea.config.ts @@ -0,0 +1,64 @@ +import { readFileSync } from 'node:fs'; +import { builtinModules } from 'node:module'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vite'; + +// The entry for the single executable, and nothing else. The npm artifact is +// still `vite.config.ts`'s job; this one exists because a SEA needs two things +// that bundle cannot give it: +// +// - **CommonJS.** Node's embedder runs a single executable's main script as +// CJS. An ESM entry fails at startup with "Cannot use import statement +// outside a module", which is why `dist/bin/gitignore-sync.mjs` cannot be +// injected directly. +// - **No externals but node's builtins.** A single executable carries no +// `node_modules`, so citty, consola and pathe have to be inlined rather +// than resolved at runtime. +// - **One file.** The embedder's `require` loads builtins only, so a chunk +// split off the entry could never be loaded. The bundle has no dynamic +// import to split on, and `scripts/sea-build.ts` refuses to package one +// that does. +// +// The target and the inlined version match the npm build, so `--version` reads +// the same out of either artifact. + +// fileURLToPath, not `.pathname`: the latter keeps `%20` in a path with spaces. +const root = dirname(fileURLToPath(import.meta.url)); + +const nodeBuiltins = new Set([ + ...builtinModules, + ...builtinModules.map((m) => `node:${m}`) +]); + +const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { + version: string; +}; + +export default defineConfig({ + define: { + __APP_VERSION__: JSON.stringify(pkg.version) + }, + build: { + target: 'node24', + outDir: 'dist/sea', + // `vite.config.ts` writes `dist/bin` and empties `dist` to do it. Emptying + // again here would delete the npm artifact, and the release builds both. + emptyOutDir: false, + sourcemap: false, + minify: false, + lib: { + entry: { + entry: resolve(root, 'src/bin/gitignore-sync.ts') + }, + formats: ['cjs'] + }, + rollupOptions: { + external: (id) => nodeBuiltins.has(id), + output: { + entryFileNames: '[name].cjs', + chunkFileNames: 'chunks/[name]-[hash].cjs' + } + } + } +});